This commit is contained in:
2026-03-23 01:18:08 +03:00
parent 2fe390b068
commit 4f87aa7598
9 changed files with 1128 additions and 5 deletions
+22 -2
View File
@@ -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
+601
View File
@@ -1850,6 +1850,396 @@ class ExchangeRatesCompanion extends UpdateCompanion<ExchangeRate> {
}
}
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<int> id = GeneratedColumn<int>(
'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<String> name = GeneratedColumn<String>(
'name',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _isMainMeta = const VerificationMeta('isMain');
@override
late final GeneratedColumn<bool> isMain = GeneratedColumn<bool>(
'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<int> sortOrder = GeneratedColumn<int>(
'sort_order',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultValue: const Constant(0),
);
static const VerificationMeta _currencyMeta = const VerificationMeta(
'currency',
);
@override
late final GeneratedColumn<String> currency = GeneratedColumn<String>(
'currency',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: false,
defaultValue: const Constant('USD'),
);
static const VerificationMeta _createdAtMeta = const VerificationMeta(
'createdAt',
);
@override
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at',
aliasedName,
false,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: currentDateAndTime,
);
@override
List<GeneratedColumn> 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<Account> 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<GeneratedColumn> get $primaryKey => {id};
@override
Account map(Map<String, dynamic> 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<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,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<int>(id);
map['name'] = Variable<String>(name);
map['is_main'] = Variable<bool>(isMain);
map['sort_order'] = Variable<int>(sortOrder);
map['currency'] = Variable<String>(currency);
map['created_at'] = Variable<DateTime>(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<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return Account(
id: serializer.fromJson<int>(json['id']),
name: serializer.fromJson<String>(json['name']),
isMain: serializer.fromJson<bool>(json['isMain']),
sortOrder: serializer.fromJson<int>(json['sortOrder']),
currency: serializer.fromJson<String>(json['currency']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'name': serializer.toJson<String>(name),
'isMain': serializer.toJson<bool>(isMain),
'sortOrder': serializer.toJson<int>(sortOrder),
'currency': serializer.toJson<String>(currency),
'createdAt': serializer.toJson<DateTime>(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<Account> {
final Value<int> id;
final Value<String> name;
final Value<bool> isMain;
final Value<int> sortOrder;
final Value<String> currency;
final Value<DateTime> 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<Account> custom({
Expression<int>? id,
Expression<String>? name,
Expression<bool>? isMain,
Expression<int>? sortOrder,
Expression<String>? currency,
Expression<DateTime>? 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<int>? id,
Value<String>? name,
Value<bool>? isMain,
Value<int>? sortOrder,
Value<String>? currency,
Value<DateTime>? 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<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<int>(id.value);
}
if (name.present) {
map['name'] = Variable<String>(name.value);
}
if (isMain.present) {
map['is_main'] = Variable<bool>(isMain.value);
}
if (sortOrder.present) {
map['sort_order'] = Variable<int>(sortOrder.value);
}
if (currency.present) {
map['currency'] = Variable<String>(currency.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(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<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@@ -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<int> id,
required String name,
Value<bool> isMain,
Value<int> sortOrder,
Value<String> currency,
Value<DateTime> createdAt,
});
typedef $$AccountsTableUpdateCompanionBuilder =
AccountsCompanion Function({
Value<int> id,
Value<String> name,
Value<bool> isMain,
Value<int> sortOrder,
Value<String> currency,
Value<DateTime> createdAt,
});
class $$AccountsTableFilterComposer
extends Composer<_$AppDatabase, $AccountsTable> {
$$AccountsTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<int> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get name => $composableBuilder(
column: $table.name,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<bool> get isMain => $composableBuilder(
column: $table.isMain,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get sortOrder => $composableBuilder(
column: $table.sortOrder,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get currency => $composableBuilder(
column: $table.currency,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> 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<int> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get name => $composableBuilder(
column: $table.name,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<bool> get isMain => $composableBuilder(
column: $table.isMain,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get sortOrder => $composableBuilder(
column: $table.sortOrder,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get currency => $composableBuilder(
column: $table.currency,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> 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<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get name =>
$composableBuilder(column: $table.name, builder: (column) => column);
GeneratedColumn<bool> get isMain =>
$composableBuilder(column: $table.isMain, builder: (column) => column);
GeneratedColumn<int> get sortOrder =>
$composableBuilder(column: $table.sortOrder, builder: (column) => column);
GeneratedColumn<String> get currency =>
$composableBuilder(column: $table.currency, builder: (column) => column);
GeneratedColumn<DateTime> 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<int> id = const Value.absent(),
Value<String> name = const Value.absent(),
Value<bool> isMain = const Value.absent(),
Value<int> sortOrder = const Value.absent(),
Value<String> currency = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
}) => AccountsCompanion(
id: id,
name: name,
isMain: isMain,
sortOrder: sortOrder,
currency: currency,
createdAt: createdAt,
),
createCompanionCallback:
({
Value<int> id = const Value.absent(),
required String name,
Value<bool> isMain = const Value.absent(),
Value<int> sortOrder = const Value.absent(),
Value<String> currency = const Value.absent(),
Value<DateTime> 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);
}
+10
View File
@@ -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)();
}
@@ -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<List<model.Account>> 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<List<model.Account>> 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<model.Account> 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,
);
}
}
+21 -1
View File
@@ -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<String>((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<AddTransactionScreen>
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<AddTransactionScreen>
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,
+19
View File
@@ -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<TransactionRepository>((ref) {
return TransactionRepository(db);
});
final accountRepositoryProvider = Provider<AccountRepository>((ref) {
final db = ref.watch(appDatabaseProvider);
return AccountRepository(db);
});
final storageServiceProvider = Provider<StorageService>((ref) {
return StorageService(ref.watch(sharedPreferencesProvider));
});
@@ -217,6 +224,18 @@ final recentTransactionsProvider = Provider<List<Transaction>>((ref) {
return ref.watch(filteredTransactionsProvider).take(20).toList();
});
// Watches the list of all accounts
final accountsProvider = StreamProvider<List<Account>>((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<int>((ref) => 0);
class CardColors {
final Color primary;
final Color secondary;
+2 -2
View File
@@ -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<DashboardScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
BalanceCard(
BalanceCardCarousel(
balance: balance,
currencyInfo: currencyInfo,
onLongPress: _onCardLongPress,
@@ -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<BalanceCardCarousel> createState() =>
_BalanceCardCarouselState();
}
class _BalanceCardCarouselState extends ConsumerState<BalanceCardCarousel> {
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),
),
);
}),
);
}
}
+17
View File
@@ -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,
});
}