diff --git a/lib/data/database/app_database.dart b/lib/data/database/app_database.dart index c40fc9b..848a608 100644 --- a/lib/data/database/app_database.dart +++ b/lib/data/database/app_database.dart @@ -8,12 +8,32 @@ import 'tables.dart'; part 'app_database.g.dart'; -@DriftDatabase(tables: [Transactions, Categories, Budgets, ExchangeRates]) +@DriftDatabase(tables: [Transactions, Categories, Budgets, ExchangeRates, Accounts]) class AppDatabase extends _$AppDatabase { AppDatabase() : super(_openConnection()); @override - int get schemaVersion => 1; + int get schemaVersion => 3; + + @override + MigrationStrategy get migration => MigrationStrategy( + onUpgrade: (migrator, from, to) async { + if (from == 1) { + await migrator.createTable(accounts); + await customStatement( + 'INSERT INTO accounts (name, is_main, currency, sort_order, created_at) ' + 'VALUES (?, ?, ?, ?, ?)', + ['Main', 1, 'USD', 0, DateTime.now().millisecondsSinceEpoch], + ); + } + if (from == 2) { + // Add currency column to existing accounts table + await customStatement( + 'ALTER TABLE accounts ADD COLUMN currency TEXT NOT NULL DEFAULT "USD"', + ); + } + }, + ); // ============================================================================ // TRANSACTIONS diff --git a/lib/data/database/app_database.g.dart b/lib/data/database/app_database.g.dart index a3c824a..6bc3153 100644 --- a/lib/data/database/app_database.g.dart +++ b/lib/data/database/app_database.g.dart @@ -1850,6 +1850,396 @@ class ExchangeRatesCompanion extends UpdateCompanion { } } +class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $AccountsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _isMainMeta = const VerificationMeta('isMain'); + @override + late final GeneratedColumn isMain = GeneratedColumn( + 'is_main', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_main" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _sortOrderMeta = const VerificationMeta( + 'sortOrder', + ); + @override + late final GeneratedColumn sortOrder = GeneratedColumn( + 'sort_order', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _currencyMeta = const VerificationMeta( + 'currency', + ); + @override + late final GeneratedColumn currency = GeneratedColumn( + 'currency', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('USD'), + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + List get $columns => [ + id, + name, + isMain, + sortOrder, + currency, + createdAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'accounts'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('is_main')) { + context.handle( + _isMainMeta, + isMain.isAcceptableOrUnknown(data['is_main']!, _isMainMeta), + ); + } + if (data.containsKey('sort_order')) { + context.handle( + _sortOrderMeta, + sortOrder.isAcceptableOrUnknown(data['sort_order']!, _sortOrderMeta), + ); + } + if (data.containsKey('currency')) { + context.handle( + _currencyMeta, + currency.isAcceptableOrUnknown(data['currency']!, _currencyMeta), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + Account map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Account( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + isMain: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_main'], + )!, + sortOrder: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}sort_order'], + )!, + currency: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}currency'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + $AccountsTable createAlias(String alias) { + return $AccountsTable(attachedDatabase, alias); + } +} + +class Account extends DataClass implements Insertable { + final int id; + final String name; + final bool isMain; + final int sortOrder; + final String currency; + final DateTime createdAt; + const Account({ + required this.id, + required this.name, + required this.isMain, + required this.sortOrder, + required this.currency, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['is_main'] = Variable(isMain); + map['sort_order'] = Variable(sortOrder); + map['currency'] = Variable(currency); + map['created_at'] = Variable(createdAt); + return map; + } + + AccountsCompanion toCompanion(bool nullToAbsent) { + return AccountsCompanion( + id: Value(id), + name: Value(name), + isMain: Value(isMain), + sortOrder: Value(sortOrder), + currency: Value(currency), + createdAt: Value(createdAt), + ); + } + + factory Account.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Account( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + isMain: serializer.fromJson(json['isMain']), + sortOrder: serializer.fromJson(json['sortOrder']), + currency: serializer.fromJson(json['currency']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'isMain': serializer.toJson(isMain), + 'sortOrder': serializer.toJson(sortOrder), + 'currency': serializer.toJson(currency), + 'createdAt': serializer.toJson(createdAt), + }; + } + + Account copyWith({ + int? id, + String? name, + bool? isMain, + int? sortOrder, + String? currency, + DateTime? createdAt, + }) => Account( + id: id ?? this.id, + name: name ?? this.name, + isMain: isMain ?? this.isMain, + sortOrder: sortOrder ?? this.sortOrder, + currency: currency ?? this.currency, + createdAt: createdAt ?? this.createdAt, + ); + Account copyWithCompanion(AccountsCompanion data) { + return Account( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + isMain: data.isMain.present ? data.isMain.value : this.isMain, + sortOrder: data.sortOrder.present ? data.sortOrder.value : this.sortOrder, + currency: data.currency.present ? data.currency.value : this.currency, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('Account(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('isMain: $isMain, ') + ..write('sortOrder: $sortOrder, ') + ..write('currency: $currency, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, name, isMain, sortOrder, currency, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Account && + other.id == this.id && + other.name == this.name && + other.isMain == this.isMain && + other.sortOrder == this.sortOrder && + other.currency == this.currency && + other.createdAt == this.createdAt); +} + +class AccountsCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value isMain; + final Value sortOrder; + final Value currency; + final Value createdAt; + const AccountsCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.isMain = const Value.absent(), + this.sortOrder = const Value.absent(), + this.currency = const Value.absent(), + this.createdAt = const Value.absent(), + }); + AccountsCompanion.insert({ + this.id = const Value.absent(), + required String name, + this.isMain = const Value.absent(), + this.sortOrder = const Value.absent(), + this.currency = const Value.absent(), + this.createdAt = const Value.absent(), + }) : name = Value(name); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? isMain, + Expression? sortOrder, + Expression? currency, + Expression? createdAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (isMain != null) 'is_main': isMain, + if (sortOrder != null) 'sort_order': sortOrder, + if (currency != null) 'currency': currency, + if (createdAt != null) 'created_at': createdAt, + }); + } + + AccountsCompanion copyWith({ + Value? id, + Value? name, + Value? isMain, + Value? sortOrder, + Value? currency, + Value? createdAt, + }) { + return AccountsCompanion( + id: id ?? this.id, + name: name ?? this.name, + isMain: isMain ?? this.isMain, + sortOrder: sortOrder ?? this.sortOrder, + currency: currency ?? this.currency, + createdAt: createdAt ?? this.createdAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (isMain.present) { + map['is_main'] = Variable(isMain.value); + } + if (sortOrder.present) { + map['sort_order'] = Variable(sortOrder.value); + } + if (currency.present) { + map['currency'] = Variable(currency.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AccountsCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('isMain: $isMain, ') + ..write('sortOrder: $sortOrder, ') + ..write('currency: $currency, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); @@ -1857,6 +2247,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $CategoriesTable categories = $CategoriesTable(this); late final $BudgetsTable budgets = $BudgetsTable(this); late final $ExchangeRatesTable exchangeRates = $ExchangeRatesTable(this); + late final $AccountsTable accounts = $AccountsTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -1866,6 +2257,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { categories, budgets, exchangeRates, + accounts, ]; @override DriftDatabaseOptions get options => @@ -2825,6 +3217,213 @@ typedef $$ExchangeRatesTableProcessedTableManager = ExchangeRate, PrefetchHooks Function() >; +typedef $$AccountsTableCreateCompanionBuilder = + AccountsCompanion Function({ + Value id, + required String name, + Value isMain, + Value sortOrder, + Value currency, + Value createdAt, + }); +typedef $$AccountsTableUpdateCompanionBuilder = + AccountsCompanion Function({ + Value id, + Value name, + Value isMain, + Value sortOrder, + Value currency, + Value createdAt, + }); + +class $$AccountsTableFilterComposer + extends Composer<_$AppDatabase, $AccountsTable> { + $$AccountsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get isMain => $composableBuilder( + column: $table.isMain, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get sortOrder => $composableBuilder( + column: $table.sortOrder, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get currency => $composableBuilder( + column: $table.currency, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$AccountsTableOrderingComposer + extends Composer<_$AppDatabase, $AccountsTable> { + $$AccountsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get isMain => $composableBuilder( + column: $table.isMain, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sortOrder => $composableBuilder( + column: $table.sortOrder, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get currency => $composableBuilder( + column: $table.currency, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$AccountsTableAnnotationComposer + extends Composer<_$AppDatabase, $AccountsTable> { + $$AccountsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get isMain => + $composableBuilder(column: $table.isMain, builder: (column) => column); + + GeneratedColumn get sortOrder => + $composableBuilder(column: $table.sortOrder, builder: (column) => column); + + GeneratedColumn get currency => + $composableBuilder(column: $table.currency, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); +} + +class $$AccountsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $AccountsTable, + Account, + $$AccountsTableFilterComposer, + $$AccountsTableOrderingComposer, + $$AccountsTableAnnotationComposer, + $$AccountsTableCreateCompanionBuilder, + $$AccountsTableUpdateCompanionBuilder, + (Account, BaseReferences<_$AppDatabase, $AccountsTable, Account>), + Account, + PrefetchHooks Function() + > { + $$AccountsTableTableManager(_$AppDatabase db, $AccountsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$AccountsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$AccountsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$AccountsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + Value isMain = const Value.absent(), + Value sortOrder = const Value.absent(), + Value currency = const Value.absent(), + Value createdAt = const Value.absent(), + }) => AccountsCompanion( + id: id, + name: name, + isMain: isMain, + sortOrder: sortOrder, + currency: currency, + createdAt: createdAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String name, + Value isMain = const Value.absent(), + Value sortOrder = const Value.absent(), + Value currency = const Value.absent(), + Value createdAt = const Value.absent(), + }) => AccountsCompanion.insert( + id: id, + name: name, + isMain: isMain, + sortOrder: sortOrder, + currency: currency, + createdAt: createdAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$AccountsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $AccountsTable, + Account, + $$AccountsTableFilterComposer, + $$AccountsTableOrderingComposer, + $$AccountsTableAnnotationComposer, + $$AccountsTableCreateCompanionBuilder, + $$AccountsTableUpdateCompanionBuilder, + (Account, BaseReferences<_$AppDatabase, $AccountsTable, Account>), + Account, + PrefetchHooks Function() + >; class $AppDatabaseManager { final _$AppDatabase _db; @@ -2837,4 +3436,6 @@ class $AppDatabaseManager { $$BudgetsTableTableManager(_db, _db.budgets); $$ExchangeRatesTableTableManager get exchangeRates => $$ExchangeRatesTableTableManager(_db, _db.exchangeRates); + $$AccountsTableTableManager get accounts => + $$AccountsTableTableManager(_db, _db.accounts); } diff --git a/lib/data/database/tables.dart b/lib/data/database/tables.dart index 82ebd05..a305251 100644 --- a/lib/data/database/tables.dart +++ b/lib/data/database/tables.dart @@ -47,3 +47,13 @@ class ExchangeRates extends Table { RealColumn get rate => real()(); DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); } + +/// Accounts table for multi-account support +class Accounts extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get name => text()(); + BoolColumn get isMain => boolean().withDefault(const Constant(false))(); + IntColumn get sortOrder => integer().withDefault(const Constant(0))(); + TextColumn get currency => text().withDefault(const Constant('USD'))(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); +} diff --git a/lib/data/repositories/account_repository.dart b/lib/data/repositories/account_repository.dart new file mode 100644 index 0000000..bbe1f42 --- /dev/null +++ b/lib/data/repositories/account_repository.dart @@ -0,0 +1,127 @@ +import 'package:drift/drift.dart'; +import '../database/app_database.dart'; +import '../../shared/models/account.dart' as model; + +class AccountLimitException implements Exception { + final String message; + AccountLimitException(this.message); + + @override + String toString() => 'AccountLimitException: $message'; +} + +class AccountRepository { + final AppDatabase _db; + + AccountRepository(this._db); + + Stream> watchAll() { + return (_db.select(_db.accounts) + ..orderBy([(a) => OrderingTerm.asc(a.sortOrder)])) + .watch() + .asyncMap((rows) async { + if (rows.isEmpty) { + // Fallback: insert default account if none exists + await _db.into(_db.accounts).insert( + AccountsCompanion.insert( + name: 'Main', + isMain: const Value(true), + currency: const Value('USD'), + sortOrder: const Value(0), + ), + ); + // Re-query after insert + final newRows = await (_db.select(_db.accounts) + ..orderBy([(a) => OrderingTerm.asc(a.sortOrder)])) + .get(); + return newRows + .map((row) => model.Account( + id: row.id, + name: row.name, + isMain: row.isMain, + sortOrder: row.sortOrder, + currency: row.currency, + createdAt: row.createdAt, + )) + .toList(); + } + return rows + .map((row) => model.Account( + id: row.id, + name: row.name, + isMain: row.isMain, + sortOrder: row.sortOrder, + currency: row.currency, + createdAt: row.createdAt, + )) + .toList(); + }); + } + + Future> getAll() async { + try { + var rows = await (_db.select(_db.accounts) + ..orderBy([(a) => OrderingTerm.asc(a.sortOrder)])) + .get(); + + print('AccountRepository.getAll(): rows.length = ${rows.length}'); + + // Fallback: insert default account if none exists + if (rows.isEmpty) { + print('AccountRepository.getAll(): inserting default account'); + try { + await _db.into(_db.accounts).insert( + AccountsCompanion.insert( + name: 'Main', + isMain: const Value(true), + currency: const Value('USD'), + sortOrder: const Value(0), + ), + ); + print('AccountRepository.getAll(): default account inserted'); + } catch (e) { + print('AccountRepository.getAll(): insert error: $e'); + } + // Re-query after insert + rows = await (_db.select(_db.accounts) + ..orderBy([(a) => OrderingTerm.asc(a.sortOrder)])) + .get(); + print('AccountRepository.getAll(): after insert, rows.length = ${rows.length}'); + } + + final accounts = rows + .map((row) => model.Account( + id: row.id, + name: row.name, + isMain: row.isMain, + sortOrder: row.sortOrder, + currency: row.currency, + createdAt: row.createdAt, + )) + .toList(); + + print('AccountRepository.getAll(): returning ${accounts.length} accounts'); + return accounts; + } catch (e, stack) { + print('AccountRepository.getAll(): error: $e'); + print('Stack: $stack'); + // Return empty list on error + return []; + } + } + + Future getMain() async { + final row = await (_db.select(_db.accounts) + ..where((a) => a.isMain.equals(true))) + .getSingle(); + + return model.Account( + id: row.id, + name: row.name, + isMain: row.isMain, + sortOrder: row.sortOrder, + currency: row.currency, + createdAt: row.createdAt, + ); + } +} diff --git a/lib/features/add_transaction/screen.dart b/lib/features/add_transaction/screen.dart index 43a053b..0c07013 100644 --- a/lib/features/add_transaction/screen.dart +++ b/lib/features/add_transaction/screen.dart @@ -9,13 +9,19 @@ import '../../core/l10n/app_strings.dart'; import '../../core/l10n/locale_provider.dart'; import '../../core/services/haptic_service.dart'; import '../../shared/models/transaction.dart'; -import '../../shared/widgets/error_snackbar.dart'; import '../dashboard/provider.dart'; import '../settings/provider.dart'; import 'provider.dart'; const _uuid = Uuid(); +// Provider to get main account name +final mainAccountNameProvider = FutureProvider((ref) async { + final repository = ref.watch(accountRepositoryProvider); + final mainAccount = await repository.getMain(); + return mainAccount.name; +}); + class AddTransactionScreen extends ConsumerStatefulWidget { final Transaction? initial; @@ -220,6 +226,7 @@ class _AddTransactionScreenState extends ConsumerState final categories = ref.watch(availableCategoriesProvider(widget.initial)); final overrideCurrency = state.overrideCurrency; final isDark = Theme.of(context).brightness == Brightness.dark; + final accountNameAsync = ref.watch(mainAccountNameProvider); return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, @@ -271,6 +278,19 @@ class _AddTransactionScreenState extends ConsumerState child: ListView( padding: const EdgeInsets.all(20), children: [ + accountNameAsync.when( + data: (accountName) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + 'Account: $accountName', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5), + ), + ), + ), + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + ), _TypeToggle( selected: state.type, strings: s, diff --git a/lib/features/dashboard/provider.dart b/lib/features/dashboard/provider.dart index 041af3b..f415c58 100644 --- a/lib/features/dashboard/provider.dart +++ b/lib/features/dashboard/provider.dart @@ -5,7 +5,9 @@ import '../../core/services/card_color_service.dart'; import '../../core/utils/result.dart'; import '../../data/database/app_database.dart' as db; import '../../data/repositories/transaction_repository.dart'; +import '../../data/repositories/account_repository.dart'; import '../../shared/models/transaction.dart'; +import '../../shared/models/account.dart'; import '../../shared/services/storage_service.dart'; import '../settings/provider.dart'; @@ -22,6 +24,11 @@ final transactionRepositoryProvider = Provider((ref) { return TransactionRepository(db); }); +final accountRepositoryProvider = Provider((ref) { + final db = ref.watch(appDatabaseProvider); + return AccountRepository(db); +}); + final storageServiceProvider = Provider((ref) { return StorageService(ref.watch(sharedPreferencesProvider)); }); @@ -217,6 +224,18 @@ final recentTransactionsProvider = Provider>((ref) { return ref.watch(filteredTransactionsProvider).take(20).toList(); }); +// Watches the list of all accounts +final accountsProvider = StreamProvider>((ref) async* { + final repository = ref.watch(accountRepositoryProvider); + while (true) { + yield await repository.getAll(); + await Future.delayed(const Duration(milliseconds: 100)); + } +}); + +// Ephemeral UI state — active carousel index, starts at 0, not persisted +final activeAccountIndexProvider = StateProvider((ref) => 0); + class CardColors { final Color primary; final Color secondary; diff --git a/lib/features/dashboard/screen.dart b/lib/features/dashboard/screen.dart index f4e0850..58a398c 100644 --- a/lib/features/dashboard/screen.dart +++ b/lib/features/dashboard/screen.dart @@ -7,7 +7,7 @@ import '../../core/services/card_color_service.dart'; import '../../core/services/haptic_service.dart'; import '../settings/provider.dart'; import 'provider.dart'; -import 'widgets/balance_card.dart'; +import 'widgets/balance_card_carousel.dart'; import 'widgets/budget_progress.dart'; import 'widgets/color_editor_overlay.dart'; import 'widgets/filter_chips.dart'; @@ -183,7 +183,7 @@ class _DashboardScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - BalanceCard( + BalanceCardCarousel( balance: balance, currencyInfo: currencyInfo, onLongPress: _onCardLongPress, diff --git a/lib/features/dashboard/widgets/balance_card_carousel.dart b/lib/features/dashboard/widgets/balance_card_carousel.dart new file mode 100644 index 0000000..dddaa6d --- /dev/null +++ b/lib/features/dashboard/widgets/balance_card_carousel.dart @@ -0,0 +1,309 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/services/card_color_service.dart'; +import '../../settings/provider.dart'; +import '../provider.dart'; +import 'balance_card.dart'; + +class BalanceCardCarousel extends ConsumerStatefulWidget { + final double balance; + final CurrencyInfo currencyInfo; + final VoidCallback? onLongPress; + final Color? previewPrimary; + final Color? previewSecondary; + final GradientType? previewGradientType; + + const BalanceCardCarousel({ + super.key, + required this.balance, + required this.currencyInfo, + this.onLongPress, + this.previewPrimary, + this.previewSecondary, + this.previewGradientType, + }); + + @override + ConsumerState createState() => + _BalanceCardCarouselState(); +} + +class _BalanceCardCarouselState extends ConsumerState { + late PageController _pageController; + + @override + void initState() { + super.initState(); + _pageController = PageController(viewportFraction: 1.0); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final accountsAsync = ref.watch(accountsProvider); + final activeIndex = ref.watch(activeAccountIndexProvider); + + return accountsAsync.when( + data: (accounts) { + // Debug logging + debugPrint('BalanceCardCarousel: accounts.length = ${accounts.length}'); + if (accounts.isNotEmpty) { + debugPrint('BalanceCardCarousel: first account = ${accounts[0].name}'); + } + + // Page 0: Total balance + // Pages 1..N: Account cards + // Page N+1: AddAccountCard (if < 5 accounts) + final totalPages = 1 + accounts.length + (accounts.length < 5 ? 1 : 0); + + return Column( + children: [ + SizedBox( + height: 220, + child: PageView.builder( + clipBehavior: Clip.none, + controller: _pageController, + itemCount: totalPages, + onPageChanged: (index) { + ref.read(activeAccountIndexProvider.notifier).state = index; + }, + itemBuilder: (context, index) { + Widget pageContent; + + if (index == 0) { + // Page 0: Total balance card + pageContent = BalanceCard( + balance: widget.balance, + currencyInfo: widget.currencyInfo, + onLongPress: widget.onLongPress, + previewPrimary: widget.previewPrimary, + previewSecondary: widget.previewSecondary, + previewGradientType: widget.previewGradientType, + ); + } else if (index <= accounts.length) { + // Pages 1..N: Account cards + final account = accounts[index - 1]; + debugPrint('BalanceCardCarousel: building account card at index $index for ${account.name}'); + pageContent = _AccountBalanceCard( + account: account, + balance: widget.balance, // TODO: Calculate per-account balance + currencyInfo: widget.currencyInfo, + ); + } else { + // Page N+1: AddAccountCard + pageContent = AddAccountCard( + onTap: () {}, + ); + } + + // Add horizontal padding to create gap between cards + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: pageContent, + ); + }, + ), + ), + const SizedBox(height: 12), + _DotIndicators( + count: totalPages, + activeIndex: activeIndex, + ), + ], + ); + }, + loading: () => const SizedBox( + height: 220, + child: Center(child: CircularProgressIndicator()), + ), + error: (error, stack) { + debugPrint('BalanceCardCarousel error: $error'); + debugPrint('Stack: $stack'); + // Show total balance card on error + return Column( + children: [ + SizedBox( + height: 220, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: BalanceCard( + balance: widget.balance, + currencyInfo: widget.currencyInfo, + onLongPress: widget.onLongPress, + previewPrimary: widget.previewPrimary, + previewSecondary: widget.previewSecondary, + previewGradientType: widget.previewGradientType, + ), + ), + ), + const SizedBox(height: 12), + _DotIndicators( + count: 1, + activeIndex: 0, + ), + ], + ); + }, + ); + } +} + +class _AccountBalanceCard extends ConsumerWidget { + final dynamic account; + final double balance; + final CurrencyInfo currencyInfo; + + const _AccountBalanceCard({ + required this.account, + required this.balance, + required this.currencyInfo, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Stack( + clipBehavior: Clip.none, + children: [ + BalanceCard( + balance: balance, + currencyInfo: currencyInfo, + onLongPress: null, // No long press for account cards + previewPrimary: null, + previewSecondary: null, + previewGradientType: null, + ), + Positioned( + top: 16, + left: 24, + child: Text( + account.name, + style: TextStyle( + fontSize: 11, + letterSpacing: 1.5, + color: Colors.white.withOpacity(0.6), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ); + } +} + +class AddAccountCard extends StatelessWidget { + final VoidCallback? onTap; + + const AddAccountCard({super.key, this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: CustomPaint( + painter: _DashedBorderPainter(), + child: Container( + width: double.infinity, + height: 220, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withOpacity(0.4), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add_rounded, + size: 32, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5), + ), + const SizedBox(height: 8), + Text( + 'Add account', + style: TextStyle( + fontSize: 14, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _DashedBorderPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = const Color(0xFF888888) + ..strokeWidth = 2 + ..style = PaintingStyle.stroke; + + const dashWidth = 8.0; + const dashSpace = 4.0; + const radius = 20.0; + + final path = Path() + ..addRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(0, 0, size.width, size.height), + const Radius.circular(radius), + ), + ); + + final pathMetrics = path.computeMetrics(); + for (final metric in pathMetrics) { + double distance = 0; + while (distance < metric.length) { + final segment = metric.extractPath( + distance, + distance + dashWidth, + ); + canvas.drawPath(segment, paint); + distance += dashWidth + dashSpace; + } + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +class _DotIndicators extends StatelessWidget { + final int count; + final int activeIndex; + + const _DotIndicators({ + required this.count, + required this.activeIndex, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(count, (index) { + final isActive = index == activeIndex; + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.symmetric(horizontal: 4), + width: isActive ? 8 : 6, + height: isActive ? 8 : 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive + ? const Color(0xFF7C6DED) + : Theme.of(context).colorScheme.onSurface.withOpacity(0.2), + ), + ); + }), + ); + } +} diff --git a/lib/shared/models/account.dart b/lib/shared/models/account.dart new file mode 100644 index 0000000..a35323f --- /dev/null +++ b/lib/shared/models/account.dart @@ -0,0 +1,17 @@ +class Account { + final int id; + final String name; + final bool isMain; + final int sortOrder; + final String currency; + final DateTime createdAt; + + const Account({ + required this.id, + required this.name, + required this.isMain, + required this.sortOrder, + required this.currency, + required this.createdAt, + }); +}