mirror of
https://github.com/koloideal/Casha.git
synced 2026-06-10 02:15:29 +03:00
stableee
This commit is contained in:
+1
-1
@@ -135,7 +135,7 @@ class AppTheme {
|
|||||||
final textTheme = GoogleFonts.poppinsTextTheme(base.textTheme).apply(
|
final textTheme = GoogleFonts.poppinsTextTheme(base.textTheme).apply(
|
||||||
bodyColor: const Color(0xFF1A1A2E),
|
bodyColor: const Color(0xFF1A1A2E),
|
||||||
displayColor: const Color(0xFF1A1A2E),
|
displayColor: const Color(0xFF1A1A2E),
|
||||||
fontFamilyFallback: ['Roboto'], // Ensures Cyrillic renders with same visual style
|
fontFamilyFallback: ['Roboto'],
|
||||||
);
|
);
|
||||||
|
|
||||||
return base.copyWith(
|
return base.copyWith(
|
||||||
|
|||||||
@@ -140,7 +140,6 @@ class AppCurrencies {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `(code, textSymbol)` for pickers and conversion rows. BYN uses `''` — use BynSign in UI.
|
|
||||||
const List<(String, String)> kDisplayCurrencies = [
|
const List<(String, String)> kDisplayCurrencies = [
|
||||||
('USD', r'$'),
|
('USD', r'$'),
|
||||||
('EUR', '€'),
|
('EUR', '€'),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
|
|
||||||
class HapticService {
|
class HapticService {
|
||||||
static const _key = 'haptic_enabled';
|
static const _key = 'haptic_enabled';
|
||||||
static bool _enabled = true; // runtime cache
|
static bool _enabled = true;
|
||||||
|
|
||||||
static Future<void> init() async {
|
static Future<void> init() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|||||||
@@ -1,27 +1,20 @@
|
|||||||
/// Result type for handling success and failure cases
|
|
||||||
/// Uses sealed classes for exhaustive pattern matching
|
|
||||||
sealed class Result<T> {
|
sealed class Result<T> {
|
||||||
const Result();
|
const Result();
|
||||||
|
|
||||||
/// Check if result is success
|
|
||||||
bool get isSuccess => this is Success<T>;
|
bool get isSuccess => this is Success<T>;
|
||||||
|
|
||||||
/// Check if result is failure
|
|
||||||
bool get isFailure => this is Failure<T>;
|
bool get isFailure => this is Failure<T>;
|
||||||
|
|
||||||
/// Get data if success, null otherwise
|
|
||||||
T? get dataOrNull => switch (this) {
|
T? get dataOrNull => switch (this) {
|
||||||
Success(data: final data) => data,
|
Success(data: final data) => data,
|
||||||
Failure() => null,
|
Failure() => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Get error if failure, null otherwise
|
|
||||||
String? get errorOrNull => switch (this) {
|
String? get errorOrNull => switch (this) {
|
||||||
Success() => null,
|
Success() => null,
|
||||||
Failure(message: final message) => message,
|
Failure(message: final message) => message,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Transform success value
|
|
||||||
Result<R> map<R>(R Function(T data) transform) {
|
Result<R> map<R>(R Function(T data) transform) {
|
||||||
return switch (this) {
|
return switch (this) {
|
||||||
Success(data: final data) => Success(transform(data)),
|
Success(data: final data) => Success(transform(data)),
|
||||||
@@ -29,7 +22,6 @@ sealed class Result<T> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute callback on success
|
|
||||||
Result<T> onSuccess(void Function(T data) callback) {
|
Result<T> onSuccess(void Function(T data) callback) {
|
||||||
if (this case Success(data: final data)) {
|
if (this case Success(data: final data)) {
|
||||||
callback(data);
|
callback(data);
|
||||||
@@ -37,7 +29,6 @@ sealed class Result<T> {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute callback on failure
|
|
||||||
Result<T> onFailure(void Function(String message) callback) {
|
Result<T> onFailure(void Function(String message) callback) {
|
||||||
if (this case Failure(message: final message)) {
|
if (this case Failure(message: final message)) {
|
||||||
callback(message);
|
callback(message);
|
||||||
@@ -45,7 +36,6 @@ sealed class Result<T> {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get data or throw exception
|
|
||||||
T getOrThrow() {
|
T getOrThrow() {
|
||||||
return switch (this) {
|
return switch (this) {
|
||||||
Success(data: final data) => data,
|
Success(data: final data) => data,
|
||||||
@@ -54,7 +44,6 @@ sealed class Result<T> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get data or return default value
|
|
||||||
T getOrDefault(T defaultValue) {
|
T getOrDefault(T defaultValue) {
|
||||||
return switch (this) {
|
return switch (this) {
|
||||||
Success(data: final data) => data,
|
Success(data: final data) => data,
|
||||||
@@ -62,7 +51,6 @@ sealed class Result<T> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get data or compute from error
|
|
||||||
T getOrElse(T Function(String error) onError) {
|
T getOrElse(T Function(String error) onError) {
|
||||||
return switch (this) {
|
return switch (this) {
|
||||||
Success(data: final data) => data,
|
Success(data: final data) => data,
|
||||||
@@ -71,7 +59,6 @@ sealed class Result<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Success result containing data
|
|
||||||
class Success<T> extends Result<T> {
|
class Success<T> extends Result<T> {
|
||||||
final T data;
|
final T data;
|
||||||
|
|
||||||
@@ -91,7 +78,6 @@ class Success<T> extends Result<T> {
|
|||||||
int get hashCode => data.hashCode;
|
int get hashCode => data.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Failure result containing error message and optional exception
|
|
||||||
class Failure<T> extends Result<T> {
|
class Failure<T> extends Result<T> {
|
||||||
final String message;
|
final String message;
|
||||||
final Exception? exception;
|
final Exception? exception;
|
||||||
@@ -114,15 +100,12 @@ class Failure<T> extends Result<T> {
|
|||||||
int get hashCode => message.hashCode ^ exception.hashCode;
|
int get hashCode => message.hashCode ^ exception.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extension for Future<Result<T>>
|
|
||||||
extension FutureResultExtension<T> on Future<Result<T>> {
|
extension FutureResultExtension<T> on Future<Result<T>> {
|
||||||
/// Map async result
|
|
||||||
Future<Result<R>> mapAsync<R>(R Function(T data) transform) async {
|
Future<Result<R>> mapAsync<R>(R Function(T data) transform) async {
|
||||||
final result = await this;
|
final result = await this;
|
||||||
return result.map(transform);
|
return result.map(transform);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute callback on success
|
|
||||||
Future<Result<T>> onSuccessAsync(
|
Future<Result<T>> onSuccessAsync(
|
||||||
Future<void> Function(T data) callback,
|
Future<void> Function(T data) callback,
|
||||||
) async {
|
) async {
|
||||||
@@ -133,7 +116,6 @@ extension FutureResultExtension<T> on Future<Result<T>> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute callback on failure
|
|
||||||
Future<Result<T>> onFailureAsync(
|
Future<Result<T>> onFailureAsync(
|
||||||
Future<void> Function(String message) callback,
|
Future<void> Function(String message) callback,
|
||||||
) async {
|
) async {
|
||||||
@@ -145,7 +127,6 @@ extension FutureResultExtension<T> on Future<Result<T>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to wrap try-catch blocks
|
|
||||||
Result<T> resultOf<T>(T Function() computation) {
|
Result<T> resultOf<T>(T Function() computation) {
|
||||||
try {
|
try {
|
||||||
return Success(computation());
|
return Success(computation());
|
||||||
@@ -154,7 +135,6 @@ Result<T> resultOf<T>(T Function() computation) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to wrap async try-catch blocks
|
|
||||||
Future<Result<T>> asyncResultOf<T>(Future<T> Function() computation) async {
|
Future<Result<T>> asyncResultOf<T>(Future<T> Function() computation) async {
|
||||||
try {
|
try {
|
||||||
final data = await computation();
|
final data = await computation();
|
||||||
|
|||||||
@@ -18,10 +18,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
onUpgrade: (migrator, from, to) async {
|
onUpgrade: (migrator, from, to) async {
|
||||||
print('--- DATABASE MIGRATION: from=$from to=$to ---');
|
|
||||||
|
|
||||||
if (from == 1) {
|
if (from == 1) {
|
||||||
print('Migration: Creating accounts table');
|
|
||||||
await migrator.createTable(accounts);
|
await migrator.createTable(accounts);
|
||||||
await customStatement(
|
await customStatement(
|
||||||
'INSERT INTO accounts (name, is_main, currency, sort_order, created_at) '
|
'INSERT INTO accounts (name, is_main, currency, sort_order, created_at) '
|
||||||
@@ -31,17 +28,13 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (from == 2) {
|
if (from == 2) {
|
||||||
print('Migration: Adding currency column to accounts');
|
|
||||||
await customStatement(
|
await customStatement(
|
||||||
'ALTER TABLE accounts ADD COLUMN currency TEXT NOT NULL DEFAULT "USD"',
|
'ALTER TABLE accounts ADD COLUMN currency TEXT NOT NULL DEFAULT "USD"',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add account_id column to transactions if upgrading from version < 5
|
|
||||||
if (from < 5) {
|
if (from < 5) {
|
||||||
print('Migration: Adding account_id column to transactions');
|
|
||||||
try {
|
try {
|
||||||
// Check if column exists first
|
|
||||||
final result = await customSelect(
|
final result = await customSelect(
|
||||||
'PRAGMA table_info(transactions)',
|
'PRAGMA table_info(transactions)',
|
||||||
).get();
|
).get();
|
||||||
@@ -49,36 +42,23 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
final hasAccountId = result.any((row) => row.data['name'] == 'account_id');
|
final hasAccountId = result.any((row) => row.data['name'] == 'account_id');
|
||||||
|
|
||||||
if (!hasAccountId) {
|
if (!hasAccountId) {
|
||||||
print('Migration: account_id column does not exist, adding it now');
|
|
||||||
await customStatement(
|
await customStatement(
|
||||||
'ALTER TABLE transactions ADD COLUMN account_id INTEGER NOT NULL DEFAULT 1',
|
'ALTER TABLE transactions ADD COLUMN account_id INTEGER NOT NULL DEFAULT 1',
|
||||||
);
|
);
|
||||||
print('Migration: account_id column added successfully');
|
}
|
||||||
} else {
|
|
||||||
print('Migration: account_id column already exists, skipping');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Migration: Error adding account_id column: $e');
|
print('Migration: Error adding account_id column: $e');
|
||||||
// If the column already exists, this will fail, which is fine
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
print('--- DATABASE MIGRATION: COMPLETE ---');
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TRANSACTIONS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Get all transactions ordered by date descending
|
|
||||||
Future<List<dynamic>> getAllTransactions() {
|
Future<List<dynamic>> getAllTransactions() {
|
||||||
return (select(
|
return (select(
|
||||||
transactions,
|
transactions,
|
||||||
)..orderBy([(t) => OrderingTerm.desc(t.date)])).get();
|
)..orderBy([(t) => OrderingTerm.desc(t.date)])).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get transactions by date range
|
|
||||||
Future<List<dynamic>> getTransactionsByDateRange(
|
Future<List<dynamic>> getTransactionsByDateRange(
|
||||||
DateTime start,
|
DateTime start,
|
||||||
DateTime end,
|
DateTime end,
|
||||||
@@ -90,7 +70,6 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
.get();
|
.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get transactions by type
|
|
||||||
Future<List<dynamic>> getTransactionsByType(String type) {
|
Future<List<dynamic>> getTransactionsByType(String type) {
|
||||||
return (select(transactions)
|
return (select(transactions)
|
||||||
..where((t) => t.type.equals(type))
|
..where((t) => t.type.equals(type))
|
||||||
@@ -98,7 +77,6 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
.get();
|
.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get transactions by category
|
|
||||||
Future<List<dynamic>> getTransactionsByCategory(String category) {
|
Future<List<dynamic>> getTransactionsByCategory(String category) {
|
||||||
return (select(transactions)
|
return (select(transactions)
|
||||||
..where((t) => t.category.equals(category))
|
..where((t) => t.category.equals(category))
|
||||||
@@ -106,7 +84,6 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
.get();
|
.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search transactions by note or category
|
|
||||||
Future<List<dynamic>> searchTransactions(String query) {
|
Future<List<dynamic>> searchTransactions(String query) {
|
||||||
final lowerQuery = query.toLowerCase();
|
final lowerQuery = query.toLowerCase();
|
||||||
return (select(transactions)
|
return (select(transactions)
|
||||||
@@ -119,14 +96,12 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
.get();
|
.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get transaction by ID
|
|
||||||
Future<dynamic> getTransactionById(String id) {
|
Future<dynamic> getTransactionById(String id) {
|
||||||
return (select(
|
return (select(
|
||||||
transactions,
|
transactions,
|
||||||
)..where((t) => t.id.equals(id))).getSingleOrNull();
|
)..where((t) => t.id.equals(id))).getSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert transaction
|
|
||||||
Future<Result<void>> insertTransaction(
|
Future<Result<void>> insertTransaction(
|
||||||
TransactionsCompanion transaction,
|
TransactionsCompanion transaction,
|
||||||
) async {
|
) async {
|
||||||
@@ -135,7 +110,6 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update transaction
|
|
||||||
Future<Result<void>> updateTransaction(dynamic transaction) async {
|
Future<Result<void>> updateTransaction(dynamic transaction) async {
|
||||||
return asyncResultOf(() async {
|
return asyncResultOf(() async {
|
||||||
final companion = transaction as TransactionsCompanion;
|
final companion = transaction as TransactionsCompanion;
|
||||||
@@ -149,7 +123,6 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete transaction
|
|
||||||
Future<Result<void>> deleteTransaction(String id) async {
|
Future<Result<void>> deleteTransaction(String id) async {
|
||||||
return asyncResultOf(() async {
|
return asyncResultOf(() async {
|
||||||
final deleted = await (delete(
|
final deleted = await (delete(
|
||||||
@@ -162,90 +135,64 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete all transactions
|
|
||||||
Future<void> deleteAllTransactions() {
|
Future<void> deleteAllTransactions() {
|
||||||
return delete(transactions).go();
|
return delete(transactions).go();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get recurring transactions that need processing
|
|
||||||
Future<List<dynamic>> getRecurringTransactions() {
|
Future<List<dynamic>> getRecurringTransactions() {
|
||||||
return (select(
|
return (select(
|
||||||
transactions,
|
transactions,
|
||||||
)..where((t) => t.recurrence.equals('none').not())).get();
|
)..where((t) => t.recurrence.equals('none').not())).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CATEGORIES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Get all categories
|
|
||||||
Future<List<Category>> getAllCategories() {
|
Future<List<Category>> getAllCategories() {
|
||||||
return select(categories).get();
|
return select(categories).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get categories by type
|
|
||||||
Future<List<Category>> getCategoriesByType(String type) {
|
Future<List<Category>> getCategoriesByType(String type) {
|
||||||
return (select(categories)..where((c) => c.type.equals(type))).get();
|
return (select(categories)..where((c) => c.type.equals(type))).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert category
|
|
||||||
Future<int> insertCategory(CategoriesCompanion category) {
|
Future<int> insertCategory(CategoriesCompanion category) {
|
||||||
return into(categories).insert(category);
|
return into(categories).insert(category);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update category
|
|
||||||
Future<bool> updateCategory(Category category) {
|
Future<bool> updateCategory(Category category) {
|
||||||
return update(categories).replace(category);
|
return update(categories).replace(category);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete category
|
|
||||||
Future<int> deleteCategory(int id) {
|
Future<int> deleteCategory(int id) {
|
||||||
return (delete(categories)..where((c) => c.id.equals(id))).go();
|
return (delete(categories)..where((c) => c.id.equals(id))).go();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// BUDGETS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Get budget for month/year
|
|
||||||
Future<Budget?> getBudget(int month, int year) {
|
Future<Budget?> getBudget(int month, int year) {
|
||||||
return (select(budgets)
|
return (select(budgets)
|
||||||
..where((b) => b.month.equals(month) & b.year.equals(year)))
|
..where((b) => b.month.equals(month) & b.year.equals(year)))
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert or update budget
|
|
||||||
Future<void> upsertBudget(BudgetsCompanion budget) {
|
Future<void> upsertBudget(BudgetsCompanion budget) {
|
||||||
return into(budgets).insertOnConflictUpdate(budget);
|
return into(budgets).insertOnConflictUpdate(budget);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete budget
|
|
||||||
Future<int> deleteBudget(int id) {
|
Future<int> deleteBudget(int id) {
|
||||||
return (delete(budgets)..where((b) => b.id.equals(id))).go();
|
return (delete(budgets)..where((b) => b.id.equals(id))).go();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// EXCHANGE RATES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Get exchange rate
|
|
||||||
Future<ExchangeRate?> getExchangeRate(String from, String to) {
|
Future<ExchangeRate?> getExchangeRate(String from, String to) {
|
||||||
return (select(exchangeRates)
|
return (select(exchangeRates)
|
||||||
..where((r) => r.fromCurrency.equals(from) & r.toCurrency.equals(to)))
|
..where((r) => r.fromCurrency.equals(from) & r.toCurrency.equals(to)))
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert or update exchange rate
|
|
||||||
Future<void> upsertExchangeRate(ExchangeRatesCompanion rate) {
|
Future<void> upsertExchangeRate(ExchangeRatesCompanion rate) {
|
||||||
return into(exchangeRates).insertOnConflictUpdate(rate);
|
return into(exchangeRates).insertOnConflictUpdate(rate);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all exchange rates
|
|
||||||
Future<List<ExchangeRate>> getAllExchangeRates() {
|
Future<List<ExchangeRate>> getAllExchangeRates() {
|
||||||
return select(exchangeRates).get();
|
return select(exchangeRates).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete old exchange rates (older than 24 hours)
|
|
||||||
Future<int> deleteOldExchangeRates() {
|
Future<int> deleteOldExchangeRates() {
|
||||||
final yesterday = DateTime.now().subtract(const Duration(hours: 24));
|
final yesterday = DateTime.now().subtract(const Duration(hours: 24));
|
||||||
return (delete(
|
return (delete(
|
||||||
@@ -253,11 +200,6 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
)..where((r) => r.updatedAt.isSmallerThanValue(yesterday))).go();
|
)..where((r) => r.updatedAt.isSmallerThanValue(yesterday))).go();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// STATISTICS & AGGREGATIONS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Get total balance
|
|
||||||
Future<double> getTotalBalance() async {
|
Future<double> getTotalBalance() async {
|
||||||
final txs = await getAllTransactions();
|
final txs = await getAllTransactions();
|
||||||
return txs.fold<double>(0.0, (sum, tx) {
|
return txs.fold<double>(0.0, (sum, tx) {
|
||||||
@@ -265,7 +207,6 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get total income for date range
|
|
||||||
Future<double> getTotalIncome(DateTime start, DateTime end) async {
|
Future<double> getTotalIncome(DateTime start, DateTime end) async {
|
||||||
final txs = await getTransactionsByDateRange(start, end);
|
final txs = await getTransactionsByDateRange(start, end);
|
||||||
return txs
|
return txs
|
||||||
@@ -273,7 +214,6 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
.fold<double>(0.0, (sum, tx) => sum + tx.amount);
|
.fold<double>(0.0, (sum, tx) => sum + tx.amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get total expense for date range
|
|
||||||
Future<double> getTotalExpense(DateTime start, DateTime end) async {
|
Future<double> getTotalExpense(DateTime start, DateTime end) async {
|
||||||
final txs = await getTransactionsByDateRange(start, end);
|
final txs = await getTransactionsByDateRange(start, end);
|
||||||
return txs
|
return txs
|
||||||
@@ -281,7 +221,6 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
.fold<double>(0.0, (sum, tx) => sum + tx.amount);
|
.fold<double>(0.0, (sum, tx) => sum + tx.amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get category totals for date range
|
|
||||||
Future<Map<String, double>> getCategoryTotals(
|
Future<Map<String, double>> getCategoryTotals(
|
||||||
DateTime start,
|
DateTime start,
|
||||||
DateTime end,
|
DateTime end,
|
||||||
@@ -298,11 +237,6 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
return totals;
|
return totals;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// ACCOUNTS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Update an account
|
|
||||||
Future<void> updateAccount(AccountsCompanion account) async {
|
Future<void> updateAccount(AccountsCompanion account) async {
|
||||||
await update(accounts).replace(account);
|
await update(accounts).replace(account);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
/// Transactions table
|
|
||||||
class Transactions extends Table {
|
class Transactions extends Table {
|
||||||
TextColumn get id => text()();
|
TextColumn get id => text()();
|
||||||
RealColumn get amount => real()();
|
RealColumn get amount => real()();
|
||||||
TextColumn get category => text()();
|
TextColumn get category => text()();
|
||||||
TextColumn get type => text()(); // 'income' or 'expense'
|
TextColumn get type => text()();
|
||||||
DateTimeColumn get date => dateTime()();
|
DateTimeColumn get date => dateTime()();
|
||||||
TextColumn get note => text().nullable()();
|
TextColumn get note => text().nullable()();
|
||||||
TextColumn get recurrence => text().withDefault(const Constant('none'))();
|
TextColumn get recurrence => text().withDefault(const Constant('none'))();
|
||||||
@@ -19,18 +18,16 @@ class Transactions extends Table {
|
|||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Categories table for custom categories
|
|
||||||
class Categories extends Table {
|
class Categories extends Table {
|
||||||
IntColumn get id => integer().autoIncrement()();
|
IntColumn get id => integer().autoIncrement()();
|
||||||
TextColumn get name => text().withLength(min: 1, max: 50)();
|
TextColumn get name => text().withLength(min: 1, max: 50)();
|
||||||
TextColumn get type => text()(); // 'income' or 'expense'
|
TextColumn get type => text()();
|
||||||
TextColumn get icon => text().nullable()();
|
TextColumn get icon => text().nullable()();
|
||||||
TextColumn get color => text().nullable()();
|
TextColumn get color => text().nullable()();
|
||||||
BoolColumn get isDefault => boolean().withDefault(const Constant(false))();
|
BoolColumn get isDefault => boolean().withDefault(const Constant(false))();
|
||||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Budgets table for monthly budgets
|
|
||||||
class Budgets extends Table {
|
class Budgets extends Table {
|
||||||
IntColumn get id => integer().autoIncrement()();
|
IntColumn get id => integer().autoIncrement()();
|
||||||
RealColumn get amount => real()();
|
RealColumn get amount => real()();
|
||||||
@@ -40,7 +37,6 @@ class Budgets extends Table {
|
|||||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exchange rates cache
|
|
||||||
class ExchangeRates extends Table {
|
class ExchangeRates extends Table {
|
||||||
IntColumn get id => integer().autoIncrement()();
|
IntColumn get id => integer().autoIncrement()();
|
||||||
TextColumn get fromCurrency => text()();
|
TextColumn get fromCurrency => text()();
|
||||||
@@ -49,7 +45,6 @@ class ExchangeRates extends Table {
|
|||||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accounts table for multi-account support
|
|
||||||
class Accounts extends Table {
|
class Accounts extends Table {
|
||||||
IntColumn get id => integer().autoIncrement()();
|
IntColumn get id => integer().autoIncrement()();
|
||||||
TextColumn get name => text()();
|
TextColumn get name => text()();
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ class AccountRepository {
|
|||||||
.watch()
|
.watch()
|
||||||
.asyncMap((rows) async {
|
.asyncMap((rows) async {
|
||||||
if (rows.isEmpty) {
|
if (rows.isEmpty) {
|
||||||
// Fallback: insert default account if none exists
|
|
||||||
await _db.into(_db.accounts).insert(
|
await _db.into(_db.accounts).insert(
|
||||||
AccountsCompanion.insert(
|
AccountsCompanion.insert(
|
||||||
name: 'main',
|
name: 'main',
|
||||||
@@ -30,7 +29,6 @@ class AccountRepository {
|
|||||||
sortOrder: const Value(0),
|
sortOrder: const Value(0),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// Re-query after insert
|
|
||||||
final newRows = await (_db.select(_db.accounts)
|
final newRows = await (_db.select(_db.accounts)
|
||||||
..orderBy([(a) => OrderingTerm.asc(a.sortOrder)]))
|
..orderBy([(a) => OrderingTerm.asc(a.sortOrder)]))
|
||||||
.get();
|
.get();
|
||||||
@@ -63,8 +61,6 @@ class AccountRepository {
|
|||||||
var rows = await (_db.select(_db.accounts)
|
var rows = await (_db.select(_db.accounts)
|
||||||
..orderBy([(a) => OrderingTerm.asc(a.sortOrder)]))
|
..orderBy([(a) => OrderingTerm.asc(a.sortOrder)]))
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
// Fallback: insert default account if none exists
|
|
||||||
if (rows.isEmpty) {
|
if (rows.isEmpty) {
|
||||||
try {
|
try {
|
||||||
await _db.into(_db.accounts).insert(
|
await _db.into(_db.accounts).insert(
|
||||||
@@ -78,7 +74,6 @@ class AccountRepository {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ignore if already exists
|
// Ignore if already exists
|
||||||
}
|
}
|
||||||
// Re-query after insert
|
|
||||||
rows = await (_db.select(_db.accounts)
|
rows = await (_db.select(_db.accounts)
|
||||||
..orderBy([(a) => OrderingTerm.asc(a.sortOrder)]))
|
..orderBy([(a) => OrderingTerm.asc(a.sortOrder)]))
|
||||||
.get();
|
.get();
|
||||||
@@ -95,7 +90,6 @@ class AccountRepository {
|
|||||||
))
|
))
|
||||||
.toList();
|
.toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Return empty list on error
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,11 +110,9 @@ class AccountRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback if no main account is found
|
|
||||||
final all = await getAll();
|
final all = await getAll();
|
||||||
if (all.isNotEmpty) return all.first;
|
if (all.isNotEmpty) return all.first;
|
||||||
|
|
||||||
// Absolute fallback: create and return a default account
|
|
||||||
try {
|
try {
|
||||||
await _db.into(_db.accounts).insert(
|
await _db.into(_db.accounts).insert(
|
||||||
AccountsCompanion.insert(
|
AccountsCompanion.insert(
|
||||||
@@ -131,7 +123,6 @@ class AccountRepository {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Query the newly created account
|
|
||||||
final newRow = await (_db.select(_db.accounts)
|
final newRow = await (_db.select(_db.accounts)
|
||||||
..where((a) => a.isMain.equals(true)))
|
..where((a) => a.isMain.equals(true)))
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
@@ -150,7 +141,6 @@ class AccountRepository {
|
|||||||
// Ignore insert errors
|
// Ignore insert errors
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final fallback to prevent crashes
|
|
||||||
return model.Account(
|
return model.Account(
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'main',
|
name: 'main',
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ class TransactionRepository {
|
|||||||
|
|
||||||
TransactionRepository(this._db);
|
TransactionRepository(this._db);
|
||||||
|
|
||||||
/// Get all transactions
|
|
||||||
Future<Result<List<model.Transaction>>> getAll() async {
|
Future<Result<List<model.Transaction>>> getAll() async {
|
||||||
return asyncResultOf(() async {
|
return asyncResultOf(() async {
|
||||||
final transactions = await _db.getAllTransactions();
|
final transactions = await _db.getAllTransactions();
|
||||||
@@ -16,7 +15,6 @@ class TransactionRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get transactions by date range
|
|
||||||
Future<Result<List<model.Transaction>>> getByDateRange(
|
Future<Result<List<model.Transaction>>> getByDateRange(
|
||||||
DateTime start,
|
DateTime start,
|
||||||
DateTime end,
|
DateTime end,
|
||||||
@@ -27,7 +25,6 @@ class TransactionRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get transactions by type
|
|
||||||
Future<Result<List<model.Transaction>>> getByType(
|
Future<Result<List<model.Transaction>>> getByType(
|
||||||
model.TransactionType type,
|
model.TransactionType type,
|
||||||
) async {
|
) async {
|
||||||
@@ -37,7 +34,6 @@ class TransactionRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search transactions
|
|
||||||
Future<Result<List<model.Transaction>>> search(String query) async {
|
Future<Result<List<model.Transaction>>> search(String query) async {
|
||||||
return asyncResultOf(() async {
|
return asyncResultOf(() async {
|
||||||
final transactions = await _db.searchTransactions(query);
|
final transactions = await _db.searchTransactions(query);
|
||||||
@@ -45,7 +41,6 @@ class TransactionRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get transaction by ID
|
|
||||||
Future<Result<model.Transaction?>> getById(String id) async {
|
Future<Result<model.Transaction?>> getById(String id) async {
|
||||||
return asyncResultOf(() async {
|
return asyncResultOf(() async {
|
||||||
final transaction = await _db.getTransactionById(id);
|
final transaction = await _db.getTransactionById(id);
|
||||||
@@ -53,31 +48,17 @@ class TransactionRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add transaction
|
|
||||||
Future<Result<void>> add(model.Transaction transaction) async {
|
Future<Result<void>> add(model.Transaction transaction) async {
|
||||||
return asyncResultOf(() async {
|
return asyncResultOf(() async {
|
||||||
print('--- SAVING TRANSACTION: START ---');
|
|
||||||
print('Transaction data: ID=${transaction.id}, Amount=${transaction.amount}, AccId=${transaction.accountId}');
|
|
||||||
print('Category=${transaction.category}, Type=${transaction.type.name}');
|
|
||||||
print('Date=${transaction.date}, Currency=${transaction.currencyCode}');
|
|
||||||
|
|
||||||
final companion = _toCompanion(transaction);
|
final companion = _toCompanion(transaction);
|
||||||
print('Companion created successfully');
|
|
||||||
print('Companion: $companion');
|
|
||||||
|
|
||||||
final result = await _db.insertTransaction(companion);
|
final result = await _db.insertTransaction(companion);
|
||||||
print('DB Insert finished. Result Success: ${result.isSuccess}');
|
|
||||||
|
|
||||||
if (result.isFailure) {
|
if (result.isFailure) {
|
||||||
print('!!! DB INSERT FAILED: ${result.errorOrNull}');
|
|
||||||
throw Exception(result.errorOrNull);
|
throw Exception(result.errorOrNull);
|
||||||
}
|
}
|
||||||
|
|
||||||
print('--- SAVING TRANSACTION: END (SUCCESS) ---');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update transaction
|
|
||||||
Future<Result<void>> update(model.Transaction transaction) async {
|
Future<Result<void>> update(model.Transaction transaction) async {
|
||||||
return asyncResultOf(() async {
|
return asyncResultOf(() async {
|
||||||
final dbTransaction = _toDbModel(transaction);
|
final dbTransaction = _toDbModel(transaction);
|
||||||
@@ -89,19 +70,16 @@ class TransactionRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete transaction
|
|
||||||
Future<Result<void>> delete(String id) async {
|
Future<Result<void>> delete(String id) async {
|
||||||
return _db.deleteTransaction(id);
|
return _db.deleteTransaction(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete all transactions
|
|
||||||
Future<Result<void>> deleteAll() async {
|
Future<Result<void>> deleteAll() async {
|
||||||
return asyncResultOf(() async {
|
return asyncResultOf(() async {
|
||||||
await _db.deleteAllTransactions();
|
await _db.deleteAllTransactions();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get recurring transactions
|
|
||||||
Future<Result<List<model.Transaction>>> getRecurring() async {
|
Future<Result<List<model.Transaction>>> getRecurring() async {
|
||||||
return asyncResultOf(() async {
|
return asyncResultOf(() async {
|
||||||
final transactions = await _db.getRecurringTransactions();
|
final transactions = await _db.getRecurringTransactions();
|
||||||
@@ -109,28 +87,24 @@ class TransactionRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get total balance
|
|
||||||
Future<Result<double>> getTotalBalance() async {
|
Future<Result<double>> getTotalBalance() async {
|
||||||
return asyncResultOf(() async {
|
return asyncResultOf(() async {
|
||||||
return await _db.getTotalBalance();
|
return await _db.getTotalBalance();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get total income for date range
|
|
||||||
Future<Result<double>> getTotalIncome(DateTime start, DateTime end) async {
|
Future<Result<double>> getTotalIncome(DateTime start, DateTime end) async {
|
||||||
return asyncResultOf(() async {
|
return asyncResultOf(() async {
|
||||||
return await _db.getTotalIncome(start, end);
|
return await _db.getTotalIncome(start, end);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get total expense for date range
|
|
||||||
Future<Result<double>> getTotalExpense(DateTime start, DateTime end) async {
|
Future<Result<double>> getTotalExpense(DateTime start, DateTime end) async {
|
||||||
return asyncResultOf(() async {
|
return asyncResultOf(() async {
|
||||||
return await _db.getTotalExpense(start, end);
|
return await _db.getTotalExpense(start, end);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get category totals
|
|
||||||
Future<Result<Map<String, double>>> getCategoryTotals(
|
Future<Result<Map<String, double>>> getCategoryTotals(
|
||||||
DateTime start,
|
DateTime start,
|
||||||
DateTime end,
|
DateTime end,
|
||||||
@@ -141,11 +115,6 @@ class TransactionRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CONVERTERS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Convert database model to app model
|
|
||||||
model.Transaction _toModel(dynamic dbTransaction) {
|
model.Transaction _toModel(dynamic dbTransaction) {
|
||||||
return model.Transaction(
|
return model.Transaction(
|
||||||
id: dbTransaction.id as String,
|
id: dbTransaction.id as String,
|
||||||
@@ -164,9 +133,7 @@ class TransactionRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert app model to database model
|
|
||||||
dynamic _toDbModel(model.Transaction transaction) {
|
dynamic _toDbModel(model.Transaction transaction) {
|
||||||
// This will be replaced with proper TransactionData after code generation
|
|
||||||
return TransactionsCompanion(
|
return TransactionsCompanion(
|
||||||
id: Value(transaction.id),
|
id: Value(transaction.id),
|
||||||
amount: Value(transaction.amount),
|
amount: Value(transaction.amount),
|
||||||
@@ -183,10 +150,8 @@ class TransactionRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert app model to companion for insert
|
|
||||||
TransactionsCompanion _toCompanion(model.Transaction transaction) {
|
TransactionsCompanion _toCompanion(model.Transaction transaction) {
|
||||||
try {
|
try {
|
||||||
print('_toCompanion: Creating companion for transaction ${transaction.id}');
|
|
||||||
final companion = TransactionsCompanion(
|
final companion = TransactionsCompanion(
|
||||||
id: Value(transaction.id),
|
id: Value(transaction.id),
|
||||||
amount: Value(transaction.amount),
|
amount: Value(transaction.amount),
|
||||||
@@ -200,17 +165,12 @@ class TransactionRepository {
|
|||||||
currencyCode: Value(transaction.currencyCode),
|
currencyCode: Value(transaction.currencyCode),
|
||||||
accountId: Value(transaction.accountId),
|
accountId: Value(transaction.accountId),
|
||||||
);
|
);
|
||||||
print('_toCompanion: Companion created successfully');
|
|
||||||
return companion;
|
return companion;
|
||||||
} catch (e, stack) {
|
} catch (e, _) {
|
||||||
print('!!! _toCompanion FAILED !!!');
|
|
||||||
print('Error: $e');
|
|
||||||
print('Stack: $stack');
|
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse recurrence type
|
|
||||||
model.RecurrenceType _parseRecurrence(String recurrence) {
|
model.RecurrenceType _parseRecurrence(String recurrence) {
|
||||||
return model.RecurrenceType.values.firstWhere(
|
return model.RecurrenceType.values.firstWhere(
|
||||||
(e) => e.name == recurrence,
|
(e) => e.name == recurrence,
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ class AddTransactionState {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory AddTransactionState.fromTransaction(Transaction tx) {
|
factory AddTransactionState.fromTransaction(Transaction tx) {
|
||||||
// Override type to transfer when category is 'Transfer'
|
|
||||||
final resolvedType = (tx.category == 'Transfer')
|
final resolvedType = (tx.category == 'Transfer')
|
||||||
? TransactionType.transfer
|
? TransactionType.transfer
|
||||||
: tx.type;
|
: tx.type;
|
||||||
|
|||||||
@@ -206,7 +206,6 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
|
|||||||
final amount = double.parse(parsed);
|
final amount = double.parse(parsed);
|
||||||
final state = ref.read(addTransactionProvider(widget.initial));
|
final state = ref.read(addTransactionProvider(widget.initial));
|
||||||
|
|
||||||
// Validate transfer
|
|
||||||
if (state.type == TransactionType.transfer) {
|
if (state.type == TransactionType.transfer) {
|
||||||
bool hasError = false;
|
bool hasError = false;
|
||||||
|
|
||||||
@@ -471,14 +470,11 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
|
|||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
|
|
||||||
// Always delete the record we were given
|
|
||||||
await ref
|
await ref
|
||||||
.read(transactionsProvider.notifier)
|
.read(transactionsProvider.notifier)
|
||||||
.delete(widget.initial!.id);
|
.delete(widget.initial!.id);
|
||||||
|
|
||||||
// If this is a Transfer, also delete the counterpart
|
|
||||||
if (widget.initial!.category == 'Transfer') {
|
if (widget.initial!.category == 'Transfer') {
|
||||||
// Use the pre-populated IDs from initState if available
|
|
||||||
final counterpartId =
|
final counterpartId =
|
||||||
widget.initial!.type == TransactionType.expense
|
widget.initial!.type == TransactionType.expense
|
||||||
? _transferIncomeRecordId
|
? _transferIncomeRecordId
|
||||||
@@ -489,7 +485,6 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
|
|||||||
.read(transactionsProvider.notifier)
|
.read(transactionsProvider.notifier)
|
||||||
.delete(counterpartId);
|
.delete(counterpartId);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: search manually
|
|
||||||
final allTxs =
|
final allTxs =
|
||||||
ref.read(transactionsProvider).valueOrNull ??
|
ref.read(transactionsProvider).valueOrNull ??
|
||||||
[];
|
[];
|
||||||
@@ -893,10 +888,9 @@ class _ToAccountDropdownOverlay extends ConsumerWidget {
|
|||||||
.selectedAccountId;
|
.selectedAccountId;
|
||||||
final toAccountId = ref.read(addTransactionProvider(initial)).toAccountId;
|
final toAccountId = ref.read(addTransactionProvider(initial)).toAccountId;
|
||||||
|
|
||||||
// Calculate position from trigger key
|
|
||||||
double top = 340;
|
double top = 340;
|
||||||
double left = 20;
|
double left = 20;
|
||||||
double triggerWidth = 200; // fallback width
|
double triggerWidth = 200;
|
||||||
|
|
||||||
if (triggerKey?.currentContext != null) {
|
if (triggerKey?.currentContext != null) {
|
||||||
final triggerBox =
|
final triggerBox =
|
||||||
|
|||||||
@@ -134,10 +134,9 @@ class AccountDropdownOverlay extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final accountsAsync = ref.watch(accountsProvider);
|
final accountsAsync = ref.watch(accountsProvider);
|
||||||
|
|
||||||
// Calculate position from trigger key
|
|
||||||
double top = 76;
|
double top = 76;
|
||||||
double left = 20;
|
double left = 20;
|
||||||
double triggerWidth = 200; // fallback width
|
double triggerWidth = 200;
|
||||||
|
|
||||||
if (triggerKey?.currentContext != null) {
|
if (triggerKey?.currentContext != null) {
|
||||||
final triggerBox =
|
final triggerBox =
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../core/constants.dart';
|
|
||||||
import '../../../shared/models/transaction.dart';
|
import '../../../shared/models/transaction.dart';
|
||||||
|
|
||||||
class SubmitButton extends StatelessWidget {
|
class SubmitButton extends StatelessWidget {
|
||||||
|
|||||||
@@ -12,15 +12,6 @@ import '../../shared/models/account.dart';
|
|||||||
import '../../shared/services/storage_service.dart';
|
import '../../shared/services/storage_service.dart';
|
||||||
import '../settings/provider.dart';
|
import '../settings/provider.dart';
|
||||||
|
|
||||||
// BUG FOUND: lib/features/dashboard/provider.dart
|
|
||||||
// Description: CardColorsNotifier calls an async `_load()` in the constructor without awaiting it.
|
|
||||||
// If the user triggers `save()` before `_load()` completes, the late `_load()` can
|
|
||||||
// overwrite the newly saved colors/gradient types.
|
|
||||||
// Reproduction: Open the app (cold start), open the card color editor immediately, press Apply
|
|
||||||
// before the initial load finishes.
|
|
||||||
// Suggested fix: Track a generation/token for in-flight loads and ignore stale load results
|
|
||||||
// after any state mutation (save/reset/theme-change).
|
|
||||||
|
|
||||||
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
|
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
|
||||||
throw UnimplementedError('Override in main');
|
throw UnimplementedError('Override in main');
|
||||||
});
|
});
|
||||||
@@ -113,15 +104,13 @@ class TransactionsNotifier
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns a map: transactionId -> paired Transaction (its counterpart)
|
|
||||||
final transferPairsProvider = Provider<Map<String, Transaction>>((ref) {
|
final transferPairsProvider = Provider<Map<String, Transaction>>((ref) {
|
||||||
final txs = ref.watch(transactionsProvider).valueOrNull ?? [];
|
final txs = ref.watch(transactionsProvider).valueOrNull ?? [];
|
||||||
final transfers = txs.where((t) => t.category == 'Transfer').toList();
|
final transfers = txs.where((t) => t.category == 'Transfer').toList();
|
||||||
final Map<String, Transaction> pairs = {};
|
final Map<String, Transaction> pairs = {};
|
||||||
|
|
||||||
for (final tx in transfers) {
|
for (final tx in transfers) {
|
||||||
if (pairs.containsKey(tx.id)) continue; // already paired
|
if (pairs.containsKey(tx.id)) continue;
|
||||||
// Find counterpart: opposite type, same amount, same date (minute precision), same note
|
|
||||||
final counterpart = transfers.firstWhereOrNull(
|
final counterpart = transfers.firstWhereOrNull(
|
||||||
(other) =>
|
(other) =>
|
||||||
other.id != tx.id &&
|
other.id != tx.id &&
|
||||||
@@ -156,18 +145,15 @@ final timeFilterProvider = StateProvider<TimeFilter>(
|
|||||||
(ref) => TimeFilter.lastMonth,
|
(ref) => TimeFilter.lastMonth,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Base filtered transactions by active account
|
|
||||||
final accountFilteredTransactionsProvider = Provider<List<Transaction>>((ref) {
|
final accountFilteredTransactionsProvider = Provider<List<Transaction>>((ref) {
|
||||||
final txsAsync = ref.watch(transactionsProvider);
|
final txsAsync = ref.watch(transactionsProvider);
|
||||||
final txs = txsAsync.valueOrNull ?? [];
|
final txs = txsAsync.valueOrNull ?? [];
|
||||||
final activeAccount = ref.watch(activeAccountProvider);
|
final activeAccount = ref.watch(activeAccountProvider);
|
||||||
|
|
||||||
// If activeAccount is null (Total Balance page), return all transactions
|
|
||||||
if (activeAccount == null) {
|
if (activeAccount == null) {
|
||||||
return txs;
|
return txs;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by account ID
|
|
||||||
return txs.where((t) => t.accountId == activeAccount.id).toList();
|
return txs.where((t) => t.accountId == activeAccount.id).toList();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,18 +200,15 @@ final totalBalanceProvider = Provider<double>((ref) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final totalIncomeProvider = Provider<double>((ref) {
|
final totalIncomeProvider = Provider<double>((ref) {
|
||||||
// Watch the filtered transactions directly
|
|
||||||
final txs = ref.watch(accountFilteredTransactionsProvider);
|
final txs = ref.watch(accountFilteredTransactionsProvider);
|
||||||
final filtered = txs.where(
|
final filtered = txs.where(
|
||||||
(t) => t.type == TransactionType.income && t.category != 'Transfer',
|
(t) => t.type == TransactionType.income && t.category != 'Transfer',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Watch the dependencies that change on swipe!
|
|
||||||
final index = ref.watch(activeAccountIndexProvider);
|
final index = ref.watch(activeAccountIndexProvider);
|
||||||
final accountsAsync = ref.watch(accountsProvider);
|
final accountsAsync = ref.watch(accountsProvider);
|
||||||
final globalCurrency = ref.watch(currencyProvider).code;
|
final globalCurrency = ref.watch(currencyProvider).code;
|
||||||
|
|
||||||
// Resolve target currency synchronously based on the current swipe index
|
|
||||||
String targetCurrency = globalCurrency;
|
String targetCurrency = globalCurrency;
|
||||||
if (index > 0) {
|
if (index > 0) {
|
||||||
final accounts = accountsAsync.valueOrNull ?? [];
|
final accounts = accountsAsync.valueOrNull ?? [];
|
||||||
@@ -341,12 +324,9 @@ final filteredTransactionsProvider = Provider<List<Transaction>>((ref) {
|
|||||||
|
|
||||||
filtered.sort((a, b) => b.date.compareTo(a.date));
|
filtered.sort((a, b) => b.date.compareTo(a.date));
|
||||||
|
|
||||||
// Deduplicate transfers for Total Balance view
|
|
||||||
if (activeAccount == null) {
|
if (activeAccount == null) {
|
||||||
filtered = filtered.where((t) {
|
filtered = filtered.where((t) {
|
||||||
if (t.category != 'Transfer') return true;
|
if (t.category != 'Transfer') return true;
|
||||||
// On Total Balance: show only expense side of complete pairs
|
|
||||||
// If income side has a known pair, hide it
|
|
||||||
if (t.type == TransactionType.income && transferPairs.containsKey(t.id)) {
|
if (t.type == TransactionType.income && transferPairs.containsKey(t.id)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -361,7 +341,6 @@ final recentTransactionsProvider = Provider<List<Transaction>>((ref) {
|
|||||||
return ref.watch(filteredTransactionsProvider).take(20).toList();
|
return ref.watch(filteredTransactionsProvider).take(20).toList();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Watches the list of all accounts
|
|
||||||
final accountsProvider = StreamProvider<List<Account>>((ref) async* {
|
final accountsProvider = StreamProvider<List<Account>>((ref) async* {
|
||||||
final repository = ref.watch(accountRepositoryProvider);
|
final repository = ref.watch(accountRepositoryProvider);
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -370,15 +349,13 @@ final accountsProvider = StreamProvider<List<Account>>((ref) async* {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ephemeral UI state — active carousel index, starts at 0, not persisted
|
|
||||||
final activeAccountIndexProvider = StateProvider<int>((ref) => 0);
|
final activeAccountIndexProvider = StateProvider<int>((ref) => 0);
|
||||||
|
|
||||||
// Returns the currently active Account based on carousel index
|
|
||||||
final activeAccountProvider = Provider<Account?>((ref) {
|
final activeAccountProvider = Provider<Account?>((ref) {
|
||||||
final index = ref.watch(activeAccountIndexProvider);
|
final index = ref.watch(activeAccountIndexProvider);
|
||||||
final accountsAsync = ref.watch(accountsProvider);
|
final accountsAsync = ref.watch(accountsProvider);
|
||||||
|
|
||||||
if (index == 0) return null; // 0 means "Total Balance"
|
if (index == 0) return null;
|
||||||
|
|
||||||
return accountsAsync.when(
|
return accountsAsync.when(
|
||||||
data: (accounts) {
|
data: (accounts) {
|
||||||
@@ -416,7 +393,6 @@ final cardColorsProvider =
|
|||||||
return notifier;
|
return notifier;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Account-specific color provider
|
|
||||||
final accountCardColorsProvider =
|
final accountCardColorsProvider =
|
||||||
StateNotifierProvider.family<CardColorsNotifier, CardColors, int>((
|
StateNotifierProvider.family<CardColorsNotifier, CardColors, int>((
|
||||||
ref,
|
ref,
|
||||||
@@ -457,7 +433,7 @@ class CardColorsNotifier extends StateNotifier<CardColors> {
|
|||||||
final (c1, c2, lightG, darkG) = await CardColorService.load(
|
final (c1, c2, lightG, darkG) = await CardColorService.load(
|
||||||
accountId: accountId,
|
accountId: accountId,
|
||||||
);
|
);
|
||||||
if (currentGeneration != _loadGeneration) return; // stale
|
if (currentGeneration != _loadGeneration) return;
|
||||||
state = CardColors(c1, c2, lightG, darkG);
|
state = CardColors(c1, c2, lightG, darkG);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,7 +443,6 @@ class CardColorsNotifier extends StateNotifier<CardColors> {
|
|||||||
GradientType lightGradient,
|
GradientType lightGradient,
|
||||||
GradientType darkGradient,
|
GradientType darkGradient,
|
||||||
) async {
|
) async {
|
||||||
// Invalidate any in-flight load so it can't overwrite this save.
|
|
||||||
_loadGeneration++;
|
_loadGeneration++;
|
||||||
state = CardColors(primary, secondary, lightGradient, darkGradient);
|
state = CardColors(primary, secondary, lightGradient, darkGradient);
|
||||||
await CardColorService.save(
|
await CardColorService.save(
|
||||||
@@ -506,20 +481,17 @@ class CardColorsNotifier extends StateNotifier<CardColors> {
|
|||||||
final previousBrightness = _resolve(previous);
|
final previousBrightness = _resolve(previous);
|
||||||
final nextBrightness = _resolve(next);
|
final nextBrightness = _resolve(next);
|
||||||
|
|
||||||
// No change in actual brightness
|
|
||||||
if (previousBrightness == nextBrightness) return;
|
if (previousBrightness == nextBrightness) return;
|
||||||
|
|
||||||
final oldDefaults = _defaultsFor(previousBrightness);
|
final oldDefaults = _defaultsFor(previousBrightness);
|
||||||
final newDefaults = _defaultsFor(nextBrightness);
|
final newDefaults = _defaultsFor(nextBrightness);
|
||||||
|
|
||||||
// Check if current colors match old theme defaults
|
|
||||||
final isUsingOldDefaults =
|
final isUsingOldDefaults =
|
||||||
state.primary == oldDefaults.primary &&
|
state.primary == oldDefaults.primary &&
|
||||||
state.secondary == oldDefaults.secondary &&
|
state.secondary == oldDefaults.secondary &&
|
||||||
state.gradientTypeForBrightness(previousBrightness) ==
|
state.gradientTypeForBrightness(previousBrightness) ==
|
||||||
oldDefaults.gradient;
|
oldDefaults.gradient;
|
||||||
|
|
||||||
// Only auto-switch if using default colors
|
|
||||||
if (isUsingOldDefaults) {
|
if (isUsingOldDefaults) {
|
||||||
_loadGeneration++;
|
_loadGeneration++;
|
||||||
state = CardColors(
|
state = CardColors(
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
HSVColor savedSecondaryHSV = HSVColor.fromColor(
|
HSVColor savedSecondaryHSV = HSVColor.fromColor(
|
||||||
CardColorService.defaultSecondary,
|
CardColorService.defaultSecondary,
|
||||||
);
|
);
|
||||||
// Per-theme gradient types (light/dark), persisted separately.
|
|
||||||
GradientType tempLightGradientType = CardColorService.defaultGradientLight;
|
GradientType tempLightGradientType = CardColorService.defaultGradientLight;
|
||||||
GradientType tempDarkGradientType = CardColorService.defaultGradientDark;
|
GradientType tempDarkGradientType = CardColorService.defaultGradientDark;
|
||||||
GradientType savedLightGradientType = CardColorService.defaultGradientLight;
|
GradientType savedLightGradientType = CardColorService.defaultGradientLight;
|
||||||
@@ -52,7 +51,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
|
|
||||||
OverlayEntry? overlayEntry;
|
OverlayEntry? overlayEntry;
|
||||||
|
|
||||||
// Account editing state
|
|
||||||
Account? editingAccount;
|
Account? editingAccount;
|
||||||
String tempAccountName = '';
|
String tempAccountName = '';
|
||||||
String tempAccountCurrency = 'USD';
|
String tempAccountCurrency = 'USD';
|
||||||
@@ -192,8 +190,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
accountId: newId,
|
accountId: newId,
|
||||||
);
|
);
|
||||||
} else if (editingAccount != null) {
|
} else if (editingAccount != null) {
|
||||||
// Existing edit logic
|
|
||||||
// Save colors
|
|
||||||
await ref
|
await ref
|
||||||
.read(accountCardColorsProvider(editingAccount!.id).notifier)
|
.read(accountCardColorsProvider(editingAccount!.id).notifier)
|
||||||
.save(
|
.save(
|
||||||
@@ -203,7 +199,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
tempDarkGradientType,
|
tempDarkGradientType,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update account name and currency
|
|
||||||
final updatedAccount = Account(
|
final updatedAccount = Account(
|
||||||
id: editingAccount!.id,
|
id: editingAccount!.id,
|
||||||
name: tempAccountName.trim(),
|
name: tempAccountName.trim(),
|
||||||
@@ -216,7 +211,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
await ref.read(accountRepositoryProvider).update(updatedAccount);
|
await ref.read(accountRepositoryProvider).update(updatedAccount);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Restore original values on cancel
|
|
||||||
setState(() {
|
setState(() {
|
||||||
tempPrimary = savedPrimary;
|
tempPrimary = savedPrimary;
|
||||||
tempSecondary = savedSecondary;
|
tempSecondary = savedSecondary;
|
||||||
@@ -370,8 +364,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
previewSecondary: editingCard ? tempSecondary : null,
|
previewSecondary: editingCard ? tempSecondary : null,
|
||||||
previewGradientType: editingCard
|
previewGradientType: editingCard
|
||||||
? (Theme.of(context).brightness == Brightness.dark
|
? (Theme.of(context).brightness == Brightness.dark
|
||||||
? tempDarkGradientType
|
? tempDarkGradientType
|
||||||
: tempLightGradientType)
|
: tempLightGradientType)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -457,9 +451,9 @@ class _AccountsInfoBlock extends ConsumerWidget {
|
|||||||
final s = ref.watch(stringsProvider);
|
final s = ref.watch(stringsProvider);
|
||||||
final onSurface = Theme.of(context).colorScheme.onSurface;
|
final onSurface = Theme.of(context).colorScheme.onSurface;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 60),
|
padding: const EdgeInsets.only(bottom: 60),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 260),
|
constraints: const BoxConstraints(maxWidth: 260),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -484,17 +478,26 @@ class _AccountsInfoBlock extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_InfoRow(icon: Icons.swap_horiz_rounded, text: s.accountsInfoBalance),
|
_InfoRow(
|
||||||
|
icon: Icons.swap_horiz_rounded,
|
||||||
|
text: s.accountsInfoBalance,
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_InfoRow(icon: Icons.touch_app_rounded, text: s.accountsInfoCustomize),
|
_InfoRow(
|
||||||
|
icon: Icons.touch_app_rounded,
|
||||||
|
text: s.accountsInfoCustomize,
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_InfoRow(icon: Icons.lock_outline_rounded, text: s.accountsInfoLimit),
|
_InfoRow(
|
||||||
|
icon: Icons.lock_outline_rounded,
|
||||||
|
text: s.accountsInfoLimit,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _InfoRow extends StatelessWidget {
|
class _InfoRow extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ class _AccountEditorOverlayState extends State<AccountEditorOverlay> {
|
|||||||
final editorPanelTop = cardTop + cardHeight + 20;
|
final editorPanelTop = cardTop + cardHeight + 20;
|
||||||
final colorPanelTop = editorPanelTop + editorPanelHeight + 12;
|
final colorPanelTop = editorPanelTop + editorPanelHeight + 12;
|
||||||
const colorPanelHeight = 410.0;
|
const colorPanelHeight = 410.0;
|
||||||
// Preview card in overlay should match BalanceCardCarousel sizing.
|
|
||||||
|
|
||||||
return Consumer(
|
return Consumer(
|
||||||
builder: (context, ref, _) {
|
builder: (context, ref, _) {
|
||||||
@@ -203,25 +202,24 @@ class _AccountEditorOverlayState extends State<AccountEditorOverlay> {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: cardHeight,
|
height: cardHeight,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: BalanceCard(
|
child: BalanceCard(
|
||||||
balance: previewBalance,
|
balance: previewBalance,
|
||||||
currencyInfo: CurrencyInfo(
|
currencyInfo: CurrencyInfo(
|
||||||
currencyMap[dash.tempAccountCurrency]?.symbol ??
|
currencyMap[dash.tempAccountCurrency]?.symbol ?? '\$',
|
||||||
'\$',
|
dash.tempAccountCurrency,
|
||||||
dash.tempAccountCurrency,
|
|
||||||
),
|
|
||||||
onLongPress: null,
|
|
||||||
accountName: dash.tempAccountName,
|
|
||||||
previewPrimary: dash.tempPrimary,
|
|
||||||
previewSecondary: dash.tempSecondary,
|
|
||||||
previewGradientType:
|
|
||||||
Theme.of(widget.context).brightness ==
|
|
||||||
Brightness.dark
|
|
||||||
? dash.tempDarkGradientType
|
|
||||||
: dash.tempLightGradientType,
|
|
||||||
),
|
),
|
||||||
|
onLongPress: null,
|
||||||
|
accountName: dash.tempAccountName,
|
||||||
|
previewPrimary: dash.tempPrimary,
|
||||||
|
previewSecondary: dash.tempSecondary,
|
||||||
|
previewGradientType:
|
||||||
|
Theme.of(widget.context).brightness ==
|
||||||
|
Brightness.dark
|
||||||
|
? dash.tempDarkGradientType
|
||||||
|
: dash.tempLightGradientType,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -299,75 +297,75 @@ class _AccountEditorOverlayState extends State<AccountEditorOverlay> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: kDisplayCurrencies.map((entry) {
|
children: kDisplayCurrencies.map((entry) {
|
||||||
final isSelected = entry.$1 == _selectedCurrency;
|
final isSelected = entry.$1 == _selectedCurrency;
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedCurrency = entry.$1;
|
_selectedCurrency = entry.$1;
|
||||||
dash.setState(() {
|
dash.setState(() {
|
||||||
dash.tempAccountCurrency = entry.$1;
|
dash.tempAccountCurrency = entry.$1;
|
||||||
});
|
});
|
||||||
dash.overlayEntry?.markNeedsBuild();
|
dash.overlayEntry?.markNeedsBuild();
|
||||||
_showCurrencyDropdown = false;
|
_showCurrencyDropdown = false;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 8,
|
horizontal: 8,
|
||||||
vertical: 8,
|
vertical: 8,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
entry.$1 == 'BYN'
|
entry.$1 == 'BYN'
|
||||||
? BynSign(
|
? BynSign(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? const Color(0xFF7C6DED)
|
? const Color(0xFF7C6DED)
|
||||||
: Theme.of(
|
: Theme.of(
|
||||||
widget.context,
|
widget.context,
|
||||||
).colorScheme.onSurface,
|
).colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
: Text(
|
: Text(
|
||||||
entry.$2,
|
entry.$2,
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: isSelected
|
|
||||||
? const Color(0xFF7C6DED)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
entry.$1,
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? const Color(0xFF7C6DED)
|
? const Color(0xFF7C6DED)
|
||||||
: Theme.of(widget.context)
|
: null,
|
||||||
.colorScheme
|
|
||||||
.onSurface
|
|
||||||
.withOpacity(0.6),
|
|
||||||
),
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
entry.$1,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: isSelected
|
||||||
|
? const Color(0xFF7C6DED)
|
||||||
|
: Theme.of(widget.context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface
|
||||||
|
.withOpacity(0.6),
|
||||||
),
|
),
|
||||||
if (isSelected) ...[
|
overflow: TextOverflow.ellipsis,
|
||||||
const SizedBox(width: 4),
|
),
|
||||||
const Icon(
|
|
||||||
Icons.check_rounded,
|
|
||||||
size: 14,
|
|
||||||
color: Color(0xFF7C6DED),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
if (isSelected) ...[
|
||||||
);
|
const SizedBox(width: 4),
|
||||||
}).toList(),
|
const Icon(
|
||||||
|
Icons.check_rounded,
|
||||||
|
size: 14,
|
||||||
|
color: Color(0xFF7C6DED),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ class _BalanceCardCarouselState extends ConsumerState<BalanceCardCarousel> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// 0.92 позволяет видеть края предыдущей/следующей карточки
|
|
||||||
final savedIndex = ref.read(activeAccountIndexProvider);
|
final savedIndex = ref.read(activeAccountIndexProvider);
|
||||||
_pageController = PageController(
|
_pageController = PageController(
|
||||||
viewportFraction: 0.92,
|
viewportFraction: 0.92,
|
||||||
@@ -68,14 +67,12 @@ class _BalanceCardCarouselState extends ConsumerState<BalanceCardCarousel> {
|
|||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 230,
|
height: 230,
|
||||||
// OverflowBox позволяет PageView игнорировать паддинги родителя (DashboardScreen)
|
|
||||||
// и растянуться на всю ширину экрана
|
|
||||||
child: OverflowBox(
|
child: OverflowBox(
|
||||||
maxWidth: MediaQuery.of(context).size.width,
|
maxWidth: MediaQuery.of(context).size.width,
|
||||||
child: PageView.builder(
|
child: PageView.builder(
|
||||||
controller: _pageController,
|
controller: _pageController,
|
||||||
clipBehavior:
|
clipBehavior:
|
||||||
Clip.none, // Не обрезает карточку при 3D-наклоне
|
Clip.none,
|
||||||
itemCount: totalPages,
|
itemCount: totalPages,
|
||||||
onPageChanged: (index) {
|
onPageChanged: (index) {
|
||||||
ref.read(activeAccountIndexProvider.notifier).state = index;
|
ref.read(activeAccountIndexProvider.notifier).state = index;
|
||||||
@@ -105,7 +102,6 @@ class _BalanceCardCarouselState extends ConsumerState<BalanceCardCarousel> {
|
|||||||
accountCardColorsProvider(account.id),
|
accountCardColorsProvider(account.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate this specific account's balance
|
|
||||||
final txs =
|
final txs =
|
||||||
ref.watch(transactionsProvider).valueOrNull ?? [];
|
ref.watch(transactionsProvider).valueOrNull ?? [];
|
||||||
final accountTxs = txs
|
final accountTxs = txs
|
||||||
@@ -119,7 +115,7 @@ class _BalanceCardCarouselState extends ConsumerState<BalanceCardCarousel> {
|
|||||||
t.amount,
|
t.amount,
|
||||||
t.currencyCode,
|
t.currencyCode,
|
||||||
account
|
account
|
||||||
.currency, // target is the account's own currency
|
.currency,
|
||||||
);
|
);
|
||||||
return t.type == TransactionType.income
|
return t.type == TransactionType.income
|
||||||
? sum + converted
|
? sum + converted
|
||||||
@@ -128,7 +124,7 @@ class _BalanceCardCarouselState extends ConsumerState<BalanceCardCarousel> {
|
|||||||
|
|
||||||
cardWidget = BalanceCard(
|
cardWidget = BalanceCard(
|
||||||
balance:
|
balance:
|
||||||
accountBalance, // Use the dynamically calculated balance!
|
accountBalance,
|
||||||
currencyInfo: CurrencyInfo(
|
currencyInfo: CurrencyInfo(
|
||||||
currencyMap[account.currency]?.symbol ?? '\$',
|
currencyMap[account.currency]?.symbol ?? '\$',
|
||||||
account.currency,
|
account.currency,
|
||||||
@@ -144,7 +140,6 @@ class _BalanceCardCarouselState extends ConsumerState<BalanceCardCarousel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отступ между карточками во время свайпа
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: cardWidget,
|
child: cardWidget,
|
||||||
@@ -198,12 +193,12 @@ class AddAccountCard extends StatelessWidget {
|
|||||||
margin: const EdgeInsets.symmetric(
|
margin: const EdgeInsets.symmetric(
|
||||||
horizontal: 10,
|
horizontal: 10,
|
||||||
vertical: 10,
|
vertical: 10,
|
||||||
), // Reduced margins for larger size
|
),
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
painter: _DashedBorderPainter(),
|
painter: _DashedBorderPainter(),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 205, // Increased height
|
height: 205,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface.withOpacity(0.4),
|
color: Theme.of(context).colorScheme.surface.withOpacity(0.4),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
@@ -213,7 +208,7 @@ class AddAccountCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.add_rounded,
|
Icons.add_rounded,
|
||||||
size: 36, // Slightly bigger icon
|
size: 36,
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.onSurface.withOpacity(0.5),
|
).colorScheme.onSurface.withOpacity(0.5),
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ class _FullScreenBlurOverlayState extends State<FullScreenBlurOverlay> {
|
|||||||
child: _buildPanel(panelHeight),
|
child: _buildPanel(panelHeight),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Close Button - Top Right
|
|
||||||
Positioned(
|
Positioned(
|
||||||
top: cardTop - 20,
|
top: cardTop - 20,
|
||||||
right: 20,
|
right: 20,
|
||||||
@@ -367,7 +366,7 @@ class _FullScreenBlurOverlayState extends State<FullScreenBlurOverlay> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 36, //
|
height: 36,
|
||||||
child: ColorPickerSlider(
|
child: ColorPickerSlider(
|
||||||
TrackType.hue,
|
TrackType.hue,
|
||||||
currentHSV,
|
currentHSV,
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ class TransactionTile extends ConsumerWidget {
|
|||||||
? Icons.swap_horiz_rounded
|
? Icons.swap_horiz_rounded
|
||||||
: (AppCategories.icons[transaction.category] ?? Icons.category_rounded);
|
: (AppCategories.icons[transaction.category] ?? Icons.category_rounded);
|
||||||
|
|
||||||
// Check if we're on Total Balance page
|
|
||||||
final activeAccount = ref.watch(activeAccountProvider);
|
final activeAccount = ref.watch(activeAccountProvider);
|
||||||
final displayCurrency =
|
final displayCurrency =
|
||||||
activeAccount?.currency ?? ref.watch(currencyProvider).code;
|
activeAccount?.currency ?? ref.watch(currencyProvider).code;
|
||||||
@@ -57,19 +56,16 @@ class TransactionTile extends ConsumerWidget {
|
|||||||
: 0.0;
|
: 0.0;
|
||||||
final displaySymbol = currencyMap[displayCurrency]?.symbol ?? '';
|
final displaySymbol = currencyMap[displayCurrency]?.symbol ?? '';
|
||||||
|
|
||||||
// Look up the account name by matching transaction.accountId
|
|
||||||
final accounts = ref.watch(accountsProvider).valueOrNull ?? [];
|
final accounts = ref.watch(accountsProvider).valueOrNull ?? [];
|
||||||
final txAccount = accounts.firstWhereOrNull(
|
final txAccount = accounts.firstWhereOrNull(
|
||||||
(a) => a.id == transaction.accountId,
|
(a) => a.id == transaction.accountId,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build account label with 10-character limit
|
|
||||||
String accountLabel = txAccount?.name ?? '';
|
String accountLabel = txAccount?.name ?? '';
|
||||||
if (accountLabel.length > 10) {
|
if (accountLabel.length > 10) {
|
||||||
accountLabel = '${accountLabel.substring(0, 10)}...';
|
accountLabel = '${accountLabel.substring(0, 10)}...';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transfer pairing logic
|
|
||||||
final pairs = ref.watch(transferPairsProvider);
|
final pairs = ref.watch(transferPairsProvider);
|
||||||
final counterpart = pairs[transaction.id];
|
final counterpart = pairs[transaction.id];
|
||||||
|
|
||||||
@@ -381,11 +377,9 @@ class TransactionTile extends ConsumerWidget {
|
|||||||
bool isIncome,
|
bool isIncome,
|
||||||
Account? activeAccount,
|
Account? activeAccount,
|
||||||
) {
|
) {
|
||||||
// Total Balance view with Transfer expense: no prefix
|
|
||||||
if (isTransfer && activeAccount == null && !isIncome) {
|
if (isTransfer && activeAccount == null && !isIncome) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
// All other cases: show + or −
|
|
||||||
return isIncome ? '+ ' : '\u2212 ';
|
return isIncome ? '+ ' : '\u2212 ';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,15 +390,12 @@ class TransactionTile extends ConsumerWidget {
|
|||||||
Account? activeAccount,
|
Account? activeAccount,
|
||||||
Color defaultColor,
|
Color defaultColor,
|
||||||
) {
|
) {
|
||||||
// Total Balance view with Transfer expense: neutral color
|
|
||||||
if (isTransfer && activeAccount == null && !isIncome) {
|
if (isTransfer && activeAccount == null && !isIncome) {
|
||||||
return Theme.of(context).colorScheme.onSurface.withOpacity(0.8);
|
return Theme.of(context).colorScheme.onSurface.withOpacity(0.8);
|
||||||
}
|
}
|
||||||
// Transfer in account view or Total Balance income: use income/expense colors
|
|
||||||
if (isTransfer) {
|
if (isTransfer) {
|
||||||
return isIncome ? AppColors.income : AppColors.expense;
|
return isIncome ? AppColors.income : AppColors.expense;
|
||||||
}
|
}
|
||||||
// Non-transfer: use default color
|
|
||||||
return defaultColor;
|
return defaultColor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
|
|
||||||
class ExchangeRateService {
|
class ExchangeRateService {
|
||||||
static const String _primaryUrl = 'https://open.er-api.com/v6/latest/USD';
|
static const String _primaryUrl = 'https://open.er-api.com/v6/latest/USD';
|
||||||
static const String _fallbackUrl =
|
static const String _fallbackUrl = 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.json';
|
||||||
'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.json';
|
|
||||||
static const String _cacheKey = 'exchange_rates';
|
static const String _cacheKey = 'exchange_rates';
|
||||||
|
|
||||||
static const Map<String, double> _fallbackRates = {
|
static const Map<String, double> _fallbackRates = {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ class StorageService {
|
|||||||
|
|
||||||
StorageService(this._prefs);
|
StorageService(this._prefs);
|
||||||
|
|
||||||
/// Load all transactions with error handling
|
|
||||||
Result<List<Transaction>> loadTransactions() {
|
Result<List<Transaction>> loadTransactions() {
|
||||||
return resultOf(() {
|
return resultOf(() {
|
||||||
final raw = _prefs.getString(_transactionsKey);
|
final raw = _prefs.getString(_transactionsKey);
|
||||||
@@ -29,13 +28,11 @@ class StorageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load transactions (legacy - throws on error)
|
|
||||||
List<Transaction> loadTransactionsUnsafe() {
|
List<Transaction> loadTransactionsUnsafe() {
|
||||||
final result = loadTransactions();
|
final result = loadTransactions();
|
||||||
return result.getOrDefault([]);
|
return result.getOrDefault([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save transactions with error handling
|
|
||||||
Future<Result<void>> saveTransactions(List<Transaction> transactions) async {
|
Future<Result<void>> saveTransactions(List<Transaction> transactions) async {
|
||||||
return asyncResultOf(() async {
|
return asyncResultOf(() async {
|
||||||
final encoded = jsonEncode(transactions.map((t) => t.toJson()).toList());
|
final encoded = jsonEncode(transactions.map((t) => t.toJson()).toList());
|
||||||
@@ -43,7 +40,6 @@ class StorageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add transaction with error handling
|
|
||||||
Future<Result<void>> addTransaction(Transaction transaction) async {
|
Future<Result<void>> addTransaction(Transaction transaction) async {
|
||||||
return asyncResultOf(() async {
|
return asyncResultOf(() async {
|
||||||
final listResult = loadTransactions();
|
final listResult = loadTransactions();
|
||||||
@@ -57,7 +53,6 @@ class StorageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update transaction with error handling
|
|
||||||
Future<Result<void>> updateTransaction(Transaction transaction) async {
|
Future<Result<void>> updateTransaction(Transaction transaction) async {
|
||||||
return asyncResultOf(() async {
|
return asyncResultOf(() async {
|
||||||
final listResult = loadTransactions();
|
final listResult = loadTransactions();
|
||||||
@@ -76,7 +71,6 @@ class StorageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete transaction with error handling
|
|
||||||
Future<Result<void>> deleteTransaction(String id) async {
|
Future<Result<void>> deleteTransaction(String id) async {
|
||||||
return asyncResultOf(() async {
|
return asyncResultOf(() async {
|
||||||
final listResult = loadTransactions();
|
final listResult = loadTransactions();
|
||||||
@@ -100,7 +94,6 @@ class StorageService {
|
|||||||
return _prefs.getDouble(_budgetKey);
|
return _prefs.getDouble(_budgetKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save budget with error handling
|
|
||||||
Future<Result<void>> saveBudget(double? budget) async {
|
Future<Result<void>> saveBudget(double? budget) async {
|
||||||
return asyncResultOf(() async {
|
return asyncResultOf(() async {
|
||||||
if (budget == null) {
|
if (budget == null) {
|
||||||
@@ -196,7 +189,6 @@ class StorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process recurring transactions with error handling (returns count)
|
|
||||||
Future<Result<int>> processRecurringTransactionsWithResult() async {
|
Future<Result<int>> processRecurringTransactionsWithResult() async {
|
||||||
return asyncResultOf(() async {
|
return asyncResultOf(() async {
|
||||||
final transactionsResult = loadTransactions();
|
final transactionsResult = loadTransactions();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../core/utils/result.dart';
|
import '../../core/utils/result.dart';
|
||||||
|
|
||||||
/// Show error snackbar with custom styling
|
|
||||||
void showErrorSnackbar(BuildContext context, String message) {
|
void showErrorSnackbar(BuildContext context, String message) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
@@ -28,7 +27,6 @@ void showErrorSnackbar(BuildContext context, String message) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show success snackbar with custom styling
|
|
||||||
void showSuccessSnackbar(BuildContext context, String message) {
|
void showSuccessSnackbar(BuildContext context, String message) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
@@ -48,7 +46,6 @@ void showSuccessSnackbar(BuildContext context, String message) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show warning snackbar with custom styling
|
|
||||||
void showWarningSnackbar(BuildContext context, String message) {
|
void showWarningSnackbar(BuildContext context, String message) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
@@ -68,31 +65,25 @@ void showWarningSnackbar(BuildContext context, String message) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extension to handle Result with UI feedback
|
|
||||||
extension ResultUIExtension<T> on Result<T> {
|
extension ResultUIExtension<T> on Result<T> {
|
||||||
/// Show snackbar on failure
|
|
||||||
Result<T> showErrorOnFailure(BuildContext context) {
|
Result<T> showErrorOnFailure(BuildContext context) {
|
||||||
onFailure((message) => showErrorSnackbar(context, message));
|
onFailure((message) => showErrorSnackbar(context, message));
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show snackbar on success with custom message
|
|
||||||
Result<T> showSuccessMessage(BuildContext context, String message) {
|
Result<T> showSuccessMessage(BuildContext context, String message) {
|
||||||
onSuccess((_) => showSuccessSnackbar(context, message));
|
onSuccess((_) => showSuccessSnackbar(context, message));
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extension for Future<Result<T>>
|
|
||||||
extension FutureResultUIExtension<T> on Future<Result<T>> {
|
extension FutureResultUIExtension<T> on Future<Result<T>> {
|
||||||
/// Show snackbar on failure
|
|
||||||
Future<Result<T>> showErrorOnFailure(BuildContext context) async {
|
Future<Result<T>> showErrorOnFailure(BuildContext context) async {
|
||||||
final result = await this;
|
final result = await this;
|
||||||
result.onFailure((message) => showErrorSnackbar(context, message));
|
result.onFailure((message) => showErrorSnackbar(context, message));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show snackbar on success with custom message
|
|
||||||
Future<Result<T>> showSuccessMessage(
|
Future<Result<T>> showSuccessMessage(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
String message,
|
String message,
|
||||||
@@ -102,7 +93,6 @@ extension FutureResultUIExtension<T> on Future<Result<T>> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show both success and error messages
|
|
||||||
Future<Result<T>> showFeedback(
|
Future<Result<T>> showFeedback(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required String successMessage,
|
required String successMessage,
|
||||||
@@ -115,7 +105,6 @@ extension FutureResultUIExtension<T> on Future<Result<T>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Error dialog widget
|
|
||||||
class ErrorDialog extends StatelessWidget {
|
class ErrorDialog extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final String message;
|
final String message;
|
||||||
|
|||||||
Reference in New Issue
Block a user