diff --git a/lib/main.dart b/lib/main.dart index 8a2f7dd..a24f2f5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:provider/provider.dart'; import 'providers/auth_provider.dart'; import 'providers/bank_provider.dart'; +import 'providers/sync_provider.dart'; import 'providers/theme_provider.dart'; import 'providers/tx_meta_provider.dart'; import 'screens/splash_screen.dart'; @@ -17,6 +18,7 @@ import 'utils/app_config.dart'; import 'utils/app_theme.dart'; import 'utils/constants.dart'; import 'widgets/skip_build_when_covered.dart'; +import 'widgets/sync_feedback_listener.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -45,12 +47,16 @@ class MyApp extends StatefulWidget { class _MyAppState extends State { late final BankController _bank; + late final TxMetaController _meta; + late final SyncController _sync; late final AppLinks _appLinks; StreamSubscription? _linkSub; @override void initState() { super.initState(); + _meta = TxMetaController(widget.storage); + _sync = SyncController(auth: widget.auth, meta: _meta); _bank = BankController(storage: widget.storage)..refresh(); _appLinks = AppLinks(); _listenForBankRedirects(); @@ -79,6 +85,7 @@ class _MyAppState extends State { @override void dispose() { _linkSub?.cancel(); + _sync.dispose(); _bank.dispose(); super.dispose(); } @@ -89,7 +96,8 @@ class _MyAppState extends State { providers: [ ChangeNotifierProvider.value(value: widget.auth), ChangeNotifierProvider(create: (_) => ThemeController(widget.storage)), - ChangeNotifierProvider(create: (_) => TxMetaController(widget.storage)), + ChangeNotifierProvider.value(value: _meta), + ChangeNotifierProvider.value(value: _sync), ChangeNotifierProvider.value(value: _bank), ], child: const _MaterialRoot(), @@ -111,6 +119,11 @@ class _MaterialRoot extends StatelessWidget { darkTheme: AppTheme.dark(), themeMode: themeMode, navigatorObservers: [appRouteObserver], + builder: (context, child) { + return SyncFeedbackListener( + child: child ?? const SizedBox.shrink(), + ); + }, home: const SplashScreen(), ); } diff --git a/lib/providers/tx_meta_provider.dart b/lib/providers/tx_meta_provider.dart index ee0a438..84f8df9 100644 --- a/lib/providers/tx_meta_provider.dart +++ b/lib/providers/tx_meta_provider.dart @@ -25,6 +25,9 @@ class TxMetaController extends ChangeNotifier { final StorageService _storage; + /// Invoked after a successful local persist (used by [SyncController]). + VoidCallback? onPersisted; + Map _byId = {}; Map> _labelsByMerchant = {}; Map _notesByMerchant = {}; @@ -32,6 +35,61 @@ class TxMetaController extends ChangeNotifier { List _customLabels = []; Map _labelParents = {}; + /// Snapshot of the in-memory annotation store. + TxMetaStoreData exportStore() { + return TxMetaStoreData( + byId: Map.of(_byId), + labelsByMerchant: { + for (final e in _labelsByMerchant.entries) + e.key: List.of(e.value), + }, + notesByMerchant: Map.of(_notesByMerchant), + exactTemplates: Map.of(_exactTemplates), + customLabels: List.of(_customLabels), + labelParents: Map.of(_labelParents), + ); + } + + /// Rows in the local annotation map (may include empty stubs). + int get storedAnnotationCount => _byId.length; + + /// Annotations that are pushed to PocketBase (label/notes/flags). + int get syncableAnnotationCount { + var n = 0; + for (final meta in _byId.values) { + if (meta.hasLabel || + meta.hasNotes || + meta.recurring || + meta.internalTransfer) { + n++; + } + } + return n; + } + + int get merchantRuleCount => { + ..._labelsByMerchant.keys, + ..._notesByMerchant.keys, + }.length; + + int get customLabelCount => _customLabels.length; + + /// Replace local store (e.g. after merge) and persist without re-triggering push. + Future replaceStore(TxMetaStoreData store) async { + _byId = Map.of(store.byId); + _labelsByMerchant = { + for (final e in store.labelsByMerchant.entries) + e.key: List.of(e.value), + }; + _notesByMerchant = Map.of(store.notesByMerchant); + _exactTemplates = Map.of(store.exactTemplates); + _customLabels = List.of(store.customLabels); + _labelParents = Map.of(store.labelParents); + _invalidateLabelCache(); + notifyListeners(); + await _writeStore(notifySync: false); + } + /// Built-in + custom labels available for picking. List get allLabels => [...kBuiltinLabels, ..._customLabels]; @@ -759,7 +817,7 @@ class TxMetaController extends ChangeNotifier { unawaited(_writeStore()); } - Future _writeStore() async { + Future _writeStore({bool notifySync = true}) async { // Let the busy indicator / label checkbox paint before JSON work. await Future.delayed(Duration.zero); final snapshot = TxMetaStoreData( @@ -778,6 +836,7 @@ class TxMetaController extends ChangeNotifier { ); try { await _storage.saveTxMetaStore(snapshot); + if (notifySync) onPersisted?.call(); } catch (error, stackTrace) { debugPrint('TxMetaController persist failed: $error\n$stackTrace'); } diff --git a/lib/screens/account_screen.dart b/lib/screens/account_screen.dart index 8b7d2ce..27d7013 100644 --- a/lib/screens/account_screen.dart +++ b/lib/screens/account_screen.dart @@ -1,10 +1,13 @@ -/// Signed-in account details and sign-out. +/// Signed-in account details, sync status, and sign-out. library; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/auth_provider.dart'; +import '../providers/bank_provider.dart'; +import '../providers/sync_provider.dart'; +import '../providers/tx_meta_provider.dart'; import '../utils/constants.dart'; import '../widgets/responsive_body.dart'; @@ -14,9 +17,23 @@ class AccountScreen extends StatelessWidget { @override Widget build(BuildContext context) { final auth = context.watch(); + final sync = context.watch(); + final meta = context.watch(); + final bank = context.watch(); final theme = Theme.of(context); final colors = theme.colorScheme; + final lastSync = sync.lastSyncedAt; + final syncSubtitle = sync.isSyncing + ? (sync.statusDetail.isEmpty + ? 'Syncing… ${(sync.syncProgress * 100).round()}%' + : '${sync.statusDetail} · ${(sync.syncProgress * 100).round()}%') + : sync.lastError != null + ? 'Last error: ${sync.lastError}' + : lastSync == null + ? 'Not synced yet' + : 'Last sync ${_formatTime(lastSync)}'; + return Scaffold( appBar: AppBar(title: const Text('Account')), body: ResponsiveBody( @@ -63,12 +80,55 @@ class AccountScreen extends StatelessWidget { ), const Divider(height: 1), ListTile( - leading: const Icon(Icons.phone_android_outlined), - title: const Text('Local data'), - subtitle: const Text( - 'Labels, notes, and bank cache remain on this device.', + leading: sync.isSyncing + ? SizedBox( + width: 24, + height: 24, + child: Padding( + padding: const EdgeInsets.all(2), + child: CircularProgressIndicator( + strokeWidth: 2, + value: sync.syncProgress <= 0 + ? null + : sync.syncProgress.clamp(0.02, 1.0), + ), + ), + ) + : Icon( + sync.lastError == null + ? Icons.sync + : Icons.sync_problem_outlined, + ), + title: const Text('Cloud sync'), + subtitle: Text(syncSubtitle), + trailing: TextButton( + onPressed: sync.isSyncing ? null : () => sync.syncNow(), + child: const Text('Sync now'), ), ), + if (sync.lastSyncSummary.isNotEmpty) ...[ + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.fact_check_outlined), + title: const Text('Last sync detail'), + subtitle: Text(sync.lastSyncSummary), + ), + ], + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.phone_android_outlined), + title: const Text('What syncs'), + subtitle: Text( + 'Bank cache stays on this device ' + '(${bank.transactions.length} transactions).\n' + 'Cloud gets labels/notes only: ' + '${meta.syncableAnnotationCount} annotated txs, ' + '${meta.merchantRuleCount} merchant rules, ' + '${meta.customLabelCount} custom labels.\n' + 'PocketBase tx_meta ≈ annotated count, not bank total.', + ), + isThreeLine: true, + ), ], ), ), @@ -87,4 +147,11 @@ class AccountScreen extends StatelessWidget { ), ); } + + static String _formatTime(DateTime time) { + final local = time.toLocal(); + final h = local.hour.toString().padLeft(2, '0'); + final m = local.minute.toString().padLeft(2, '0'); + return '$h:$m'; + } } diff --git a/lib/screens/overview_screen.dart b/lib/screens/overview_screen.dart index f2c0732..fc956d9 100644 --- a/lib/screens/overview_screen.dart +++ b/lib/screens/overview_screen.dart @@ -45,6 +45,12 @@ class _OverviewScreenState extends State { /// Labels chart flow filter (defaults to expenses / labeled spend). var _labelFlow = TxFlowFilter.expense; + /// Parallel (default) vs stacked bars on the Labels chart. + var _labelBarStyle = LabelChartBarStyle.parallel; + + /// When flow is All: flip signs so expenses plot upward. + var _labelInvertChart = false; + /// Selected calendar year for Monthly mode (`DateTime(year)`). late DateTime _cursor; @@ -129,11 +135,18 @@ class _OverviewScreenState extends State { allowMove: true, includeFlow: true, initialFlow: _labelFlow, + includeBarStyle: true, + initialBarStyle: _labelBarStyle, + includeInvertChart: true, + initialInvertChart: _labelInvertChart, ); if (result == null || !mounted) return; setState(() { _labelFlow = result.flow; + _labelBarStyle = result.barStyle; + _labelInvertChart = result.flow == TxFlowFilter.all && + result.invertExpensePerspective; final allIds = available.map((l) => l.id).toSet(); if (result.labelIds.isEmpty || (result.labelIds.length == allIds.length && @@ -221,6 +234,7 @@ class _OverviewScreenState extends State { _period, includedLabelIds: _includedLabelIds, flow: _labelFlow, + invertExpensePerspective: _labelInvertChart, ); labelSpend = labels.spend; labelAmountByPeriod = labels.amountByPeriod; @@ -243,21 +257,28 @@ class _OverviewScreenState extends State { ), const SizedBox(height: 12), ], + _ChartToolbar( + period: _period, + metric: _metric, + onPeriodChanged: _setPeriod, + onMetricChanged: (value) => setState(() => _metric = value), + ), + const SizedBox(height: 10), _ChartCard( period: _period, metric: _metric, periodTitle: _periodTitle, canGoBack: _canGoBack, canGoForward: _canGoForward, - onPeriodChanged: _setPeriod, - onMetricChanged: (value) => setState(() => _metric = value), onBack: () => _go(-1), onForward: () => _go(1), months: periods, monthly: monthly, labelSpend: labelSpend, labelAmountByMonth: labelAmountByPeriod, + labelBarStyle: _labelBarStyle, currency: currency, + onPickLabels: () => _pickChartLabels(context), includedLabelCount: _includedLabelIds.isEmpty && _labelFlow == TxFlowFilter.expense ? null @@ -265,7 +286,6 @@ class _OverviewScreenState extends State { ? meta.allLabels.length : _includedLabelIds.length), availableLabelCount: meta.allLabels.length, - onPickLabels: () => _pickChartLabels(context), onLabelTap: (label) { Navigator.of(context).push( MaterialPageRoute( @@ -313,7 +333,9 @@ class _OverviewScreenState extends State { label: switch (_labelFlow) { TxFlowFilter.income => 'Labeled income · $_periodTitle', TxFlowFilter.expense => 'Labeled spend · $_periodTitle', - TxFlowFilter.all => 'Labeled activity · $_periodTitle', + TxFlowFilter.all => _labelInvertChart + ? 'Labeled net (expense view) · $_periodTitle' + : 'Labeled net · $_periodTitle', }, ), ], @@ -351,6 +373,7 @@ class _OverviewScreenState extends State { monthly: monthly, labelSpend: labelSpend, labelAmountByPeriod: labelAmountByPeriod, + labelFlow: _labelFlow, currency: currency, onOpenPeriod: (period) => _openPeriod(context, period), onOpenLabel: (period, label) => @@ -367,6 +390,7 @@ class _OverviewScreenState extends State { required Map monthly, required List<_LabelSpend> labelSpend, required Map> labelAmountByPeriod, + required TxFlowFilter labelFlow, required String currency, required void Function(DateTime period) onOpenPeriod, required void Function(DateTime period, TxLabel label) onOpenLabel, @@ -390,7 +414,7 @@ class _OverviewScreenState extends State { final bucket = labelAmountByPeriod[key] ?? const {}; final rows = <_LabelSpend>[ for (final row in labelSpend) - if ((bucket[row.label.id] ?? 0) > 0) + if ((bucket[row.label.id] ?? 0) != 0) _LabelSpend( label: row.label, amount: bucket[row.label.id]!, @@ -402,6 +426,7 @@ class _OverviewScreenState extends State { month: period, title: title, rows: rows, + flow: labelFlow, currency: currency, onOpenPeriod: () => onOpenPeriod(period), onOpenLabel: (label) => onOpenLabel(period, label), @@ -528,6 +553,10 @@ class _LabelAggregates { } /// One pass over labeled amounts: totals, per-period series, and history. +/// +/// Amounts are summed with sign (refunds offset spend). For [TxFlowFilter.all] +/// the signed net is kept (can be negative). With [invertExpensePerspective], +/// signs flip so spending plots upward. For In/Out, bars use absolute nets. _LabelAggregates _labelAggregates( List txs, TxMetaController meta, @@ -535,16 +564,19 @@ _LabelAggregates _labelAggregates( _PeriodMode mode, { Set includedLabelIds = const {}, TxFlowFilter flow = TxFlowFilter.expense, + bool invertExpensePerspective = false, }) { final keys = {for (final p in periods) _periodKey(p, mode)}; final allByLabel = {}; - final byPeriod = { + final byPeriodSigned = { for (final key in keys) key: 0.0, }; - final amountByPeriod = >{ + final amountByPeriodSigned = >{ for (final key in keys) key: {}, }; final filtering = includedLabelIds.isNotEmpty; + final keepSigned = flow == TxFlowFilter.all; + final invert = keepSigned && invertExpensePerspective; for (final tx in txs) { if (meta.metaFor(tx).internalTransfer) continue; @@ -556,7 +588,7 @@ _LabelAggregates _labelAggregates( final ids = meta.labelIdsFor(tx); if (ids.isEmpty) continue; - final amount = tx.amount.abs(); + final amount = tx.amount; for (final id in ids) { allByLabel[id] = (allByLabel[id] ?? 0) + amount; @@ -566,19 +598,27 @@ _LabelAggregates _labelAggregates( !filtering || ids.any(includedLabelIds.contains); if (!matchesFilter) continue; - byPeriod[key] = byPeriod[key]! + amount; - final bucket = amountByPeriod[key]!; + byPeriodSigned[key] = byPeriodSigned[key]! + amount; + final bucket = amountByPeriodSigned[key]!; for (final id in ids) { if (filtering && !includedLabelIds.contains(id)) continue; bucket[id] = (bucket[id] ?? 0) + amount; } } + double displayAmount(double signed) { + if (!keepSigned) return signed.abs(); + return invert ? -signed : signed; + } + final allSpend = <_LabelSpend>[ for (final entry in allByLabel.entries) - if (meta.labelById(entry.key) != null) - _LabelSpend(label: meta.labelById(entry.key)!, amount: entry.value), - ]..sort((a, b) => b.amount.compareTo(a.amount)); + if (meta.labelById(entry.key) != null && entry.value != 0) + _LabelSpend( + label: meta.labelById(entry.key)!, + amount: displayAmount(entry.value), + ), + ]..sort((a, b) => b.amount.abs().compareTo(a.amount.abs())); final spend = !filtering ? allSpend @@ -587,6 +627,19 @@ _LabelAggregates _labelAggregates( if (includedLabelIds.contains(row.label.id)) row, ]; + final byPeriod = { + for (final e in byPeriodSigned.entries) + if (e.value != 0) e.key: displayAmount(e.value), + }; + final amountByPeriod = >{ + for (final e in amountByPeriodSigned.entries) + e.key: { + for (final labelEntry in e.value.entries) + if (labelEntry.value != 0) + labelEntry.key: displayAmount(labelEntry.value), + }, + }; + return _LabelAggregates( spend: spend, allSpend: allSpend, @@ -614,11 +667,79 @@ String _shortMonth(DateTime month) { } String _compactMoney(double value) { - if (value >= 1000) { - final k = value / 1000; - return k >= 10 ? '${k.round()}k' : '${k.toStringAsFixed(1)}k'; + final abs = value.abs(); + final sign = value < 0 ? '-' : ''; + if (abs >= 1000) { + final k = abs / 1000; + return sign + (k >= 10 ? '${k.round()}k' : '${k.toStringAsFixed(1)}k'); + } + return '$sign${abs.round()}'; +} + +class _ChartToolbar extends StatelessWidget { + const _ChartToolbar({ + required this.period, + required this.metric, + required this.onPeriodChanged, + required this.onMetricChanged, + }); + + final _PeriodMode period; + final _MetricMode metric; + final ValueChanged<_PeriodMode> onPeriodChanged; + final ValueChanged<_MetricMode> onMetricChanged; + + static const _segmentStyle = ButtonStyle( + visualDensity: VisualDensity(horizontal: -4, vertical: -3), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 10)), + minimumSize: WidgetStatePropertyAll(Size(0, 32)), + ); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + SegmentedButton<_PeriodMode>( + showSelectedIcon: false, + style: _segmentStyle, + segments: const [ + ButtonSegment( + value: _PeriodMode.monthly, + label: Text('Month'), + ), + ButtonSegment( + value: _PeriodMode.yearly, + label: Text('Year'), + ), + ], + selected: {period}, + onSelectionChanged: (selection) { + onPeriodChanged(selection.first); + }, + ), + const Spacer(), + SegmentedButton<_MetricMode>( + showSelectedIcon: false, + style: _segmentStyle, + segments: const [ + ButtonSegment( + value: _MetricMode.flow, + label: Text('Flow'), + ), + ButtonSegment( + value: _MetricMode.labels, + label: Text('Labels'), + ), + ], + selected: {metric}, + onSelectionChanged: (selection) { + onMetricChanged(selection.first); + }, + ), + ], + ); } - return value.round().toString(); } class _ChartCard extends StatelessWidget { @@ -628,20 +749,19 @@ class _ChartCard extends StatelessWidget { required this.periodTitle, required this.canGoBack, required this.canGoForward, - required this.onPeriodChanged, - required this.onMetricChanged, required this.onBack, required this.onForward, required this.months, required this.monthly, required this.labelSpend, required this.labelAmountByMonth, + required this.labelBarStyle, required this.currency, - required this.onLabelTap, - required this.onMonthTap, required this.onPickLabels, required this.includedLabelCount, required this.availableLabelCount, + required this.onLabelTap, + required this.onMonthTap, }); final _PeriodMode period; @@ -649,25 +769,59 @@ class _ChartCard extends StatelessWidget { final String periodTitle; final bool canGoBack; final bool canGoForward; - final ValueChanged<_PeriodMode> onPeriodChanged; - final ValueChanged<_MetricMode> onMetricChanged; final VoidCallback onBack; final VoidCallback onForward; final List months; final Map monthly; final List<_LabelSpend> labelSpend; final Map> labelAmountByMonth; + final LabelChartBarStyle labelBarStyle; final String currency; - final void Function(TxLabel label) onLabelTap; - final void Function(DateTime month) onMonthTap; final VoidCallback onPickLabels; final int? includedLabelCount; final int availableLabelCount; + final void Function(TxLabel label) onLabelTap; + final void Function(DateTime month) onMonthTap; + + Widget _filterButton(ColorScheme colors) { + return IconButton( + tooltip: includedLabelCount == null + ? 'Filter labels ($availableLabelCount)' + : 'Filter labels ($includedLabelCount/$availableLabelCount)', + onPressed: onPickLabels, + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + constraints: const BoxConstraints( + minWidth: 36, + minHeight: 36, + ), + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: includedLabelCount == null + ? colors.surfaceContainerHigh + : colors.primary.withValues(alpha: 0.22), + foregroundColor: includedLabelCount == null + ? colors.onSurfaceVariant + : colors.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + icon: Badge( + isLabelVisible: includedLabelCount != null, + smallSize: 6, + child: const Icon(Icons.filter_list, size: 20), + ), + ); + } @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; + final showFilter = metric == _MetricMode.labels; return Card( color: colors.surfaceContainer, @@ -676,105 +830,9 @@ class _ChartCard extends StatelessWidget { borderRadius: BorderRadius.circular(20), ), child: Padding( - padding: const EdgeInsets.fromLTRB(14, 14, 14, 16), + padding: const EdgeInsets.fromLTRB(14, 12, 14, 16), child: Column( children: [ - Row( - children: [ - SegmentedButton<_PeriodMode>( - showSelectedIcon: false, - style: const ButtonStyle( - visualDensity: VisualDensity( - horizontal: -4, - vertical: -3, - ), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - padding: WidgetStatePropertyAll( - EdgeInsets.symmetric(horizontal: 10), - ), - minimumSize: WidgetStatePropertyAll(Size(0, 32)), - ), - segments: const [ - ButtonSegment( - value: _PeriodMode.monthly, - label: Text('Month'), - ), - ButtonSegment( - value: _PeriodMode.yearly, - label: Text('Year'), - ), - ], - selected: {period}, - onSelectionChanged: (selection) { - onPeriodChanged(selection.first); - }, - ), - if (metric == _MetricMode.labels) ...[ - const SizedBox(width: 4), - IconButton( - tooltip: includedLabelCount == null - ? 'Filter labels ($availableLabelCount)' - : 'Filter labels ($includedLabelCount/$availableLabelCount)', - onPressed: onPickLabels, - visualDensity: const VisualDensity( - horizontal: -4, - vertical: -4, - ), - constraints: const BoxConstraints( - minWidth: 32, - minHeight: 32, - ), - padding: EdgeInsets.zero, - style: IconButton.styleFrom( - backgroundColor: includedLabelCount == null - ? colors.surfaceContainerHigh - : colors.primary.withValues(alpha: 0.22), - foregroundColor: includedLabelCount == null - ? colors.onSurfaceVariant - : colors.primary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - icon: Badge( - isLabelVisible: includedLabelCount != null, - smallSize: 6, - child: const Icon(Icons.filter_list, size: 18), - ), - ), - ], - const Spacer(), - SegmentedButton<_MetricMode>( - showSelectedIcon: false, - style: const ButtonStyle( - visualDensity: VisualDensity( - horizontal: -4, - vertical: -3, - ), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - padding: WidgetStatePropertyAll( - EdgeInsets.symmetric(horizontal: 10), - ), - minimumSize: WidgetStatePropertyAll(Size(0, 32)), - ), - segments: const [ - ButtonSegment( - value: _MetricMode.flow, - label: Text('Flow'), - ), - ButtonSegment( - value: _MetricMode.labels, - label: Text('Labels'), - ), - ], - selected: {metric}, - onSelectionChanged: (selection) { - onMetricChanged(selection.first); - }, - ), - ], - ), - const SizedBox(height: 8), if (period == _PeriodMode.monthly) Row( children: [ @@ -815,18 +873,31 @@ class _ChartCard extends StatelessWidget { padding: EdgeInsets.zero, icon: const Icon(Icons.chevron_right, size: 22), ), + if (showFilter) ...[ + const SizedBox(width: 2), + _filterButton(colors), + ], ], ) else - Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( - periodTitle, - textAlign: TextAlign.center, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, + Row( + children: [ + // Balance the filter so the title stays centered. + SizedBox(width: showFilter ? 36 : 0), + Expanded( + child: Text( + periodTitle, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), ), - ), + if (showFilter) + _filterButton(colors) + else + const SizedBox(width: 0), + ], ), if (metric == _MetricMode.flow) ...[ const Row( @@ -879,6 +950,7 @@ class _ChartCard extends StatelessWidget { amountByMonth: labelAmountByMonth, currency: currency, period: period, + barStyle: labelBarStyle, onMonthTap: onMonthTap, ), ), @@ -1045,6 +1117,7 @@ class _LabelMonthBars extends StatelessWidget { required this.amountByMonth, required this.currency, required this.period, + required this.barStyle, required this.onMonthTap, }); @@ -1055,6 +1128,7 @@ class _LabelMonthBars extends StatelessWidget { final Map> amountByMonth; final String currency; final _PeriodMode period; + final LabelChartBarStyle barStyle; final void Function(DateTime month) onMonthTap; @override @@ -1064,7 +1138,7 @@ class _LabelMonthBars extends StatelessWidget { if (labelSpend.isEmpty) { return Center( child: Text( - 'No labeled expenses in this period.\nTag transactions to see them here.', + 'No labeled activity in this period.\nTag transactions to see them here.', style: theme.textTheme.bodyMedium, textAlign: TextAlign.center, ), @@ -1074,6 +1148,7 @@ class _LabelMonthBars extends StatelessWidget { final topLabels = labelSpend.take(_maxSeriesLabels).toList(growable: false); final topIds = {for (final row in topLabels) row.label.id}; final otherColor = colors.outline; + final stacked = barStyle == LabelChartBarStyle.stacked; double otherFor(DateTime month) { final bucket = @@ -1085,41 +1160,144 @@ class _LabelMonthBars extends StatelessWidget { return other; } - final hasOther = months.any((m) => otherFor(m) > 0); + final hasOther = months.any((m) => otherFor(m) != 0); final seriesCount = topLabels.length + (hasOther ? 1 : 0); var maxY = 0.0; + var minY = 0.0; for (final month in months) { final bucket = amountByMonth[_periodKey(month, period)] ?? const {}; - for (final row in topLabels) { - final value = bucket[row.label.id] ?? 0; - if (value > maxY) maxY = value; + if (stacked) { + var pos = 0.0; + var neg = 0.0; + void accumulate(double value) { + if (value > 0) { + pos += value; + } else if (value < 0) { + neg += value; + } + } + + for (final row in topLabels) { + accumulate(bucket[row.label.id] ?? 0); + } + accumulate(otherFor(month)); + if (pos > maxY) maxY = pos; + if (neg < minY) minY = neg; + } else { + for (final row in topLabels) { + final value = bucket[row.label.id] ?? 0; + if (value > maxY) maxY = value; + if (value < minY) minY = value; + } + final other = otherFor(month); + if (other > maxY) maxY = other; + if (other < minY) minY = other; } - final other = otherFor(month); - if (other > maxY) maxY = other; } - final safeMax = maxY <= 0 ? 1.0 : maxY * 1.15; - if (maxY <= 0) { + if (maxY == 0 && minY == 0) { return Center( child: Text( - 'No labeled expenses in this period.\nTag transactions to see them here.', + 'No labeled activity in this period.\nTag transactions to see them here.', style: theme.textTheme.bodyMedium, textAlign: TextAlign.center, ), ); } + final span = (maxY - minY).abs(); + final pad = span <= 0 ? 1.0 : span * 0.15; + final safeMax = maxY + (maxY == 0 && minY < 0 ? 0 : pad); + final safeMin = minY - (minY == 0 && maxY > 0 ? 0 : pad); - final rodWidth = period == _PeriodMode.yearly - ? (seriesCount <= 3 ? 12.0 : 8.0) - : (seriesCount <= 3 - ? 7.0 - : seriesCount <= 5 - ? 5.0 - : 4.0); + final rodWidth = stacked + ? (period == _PeriodMode.yearly ? 18.0 : 14.0) + : period == _PeriodMode.yearly + ? (seriesCount <= 3 ? 12.0 : 8.0) + : (seriesCount <= 3 + ? 7.0 + : seriesCount <= 5 + ? 5.0 + : 4.0); + + List rodsFor(DateTime month) { + final bucket = + amountByMonth[_periodKey(month, period)] ?? const {}; + if (stacked) { + final stackItems = []; + var pos = 0.0; + var neg = 0.0; + + void addSegment(double value, Color color) { + if (value > 0) { + stackItems.add(BarChartRodStackItem(pos, pos + value, color)); + pos += value; + } else if (value < 0) { + stackItems.add(BarChartRodStackItem(neg + value, neg, color)); + neg += value; + } + } + + for (final row in topLabels) { + addSegment(bucket[row.label.id] ?? 0, row.label.color); + } + addSegment(otherFor(month), otherColor); + + if (stackItems.isEmpty) { + return [ + BarChartRodData( + toY: 0, + width: rodWidth, + color: colors.surfaceContainerHighest, + ), + ]; + } + return [ + BarChartRodData( + fromY: neg, + toY: pos, + width: rodWidth, + borderRadius: BorderRadius.vertical( + top: pos > 0 ? const Radius.circular(4) : Radius.zero, + bottom: neg < 0 ? const Radius.circular(4) : Radius.zero, + ), + color: colors.surfaceContainerHighest, + rodStackItems: stackItems, + ), + ]; + } + + BorderRadius rodRadius(double value) { + if (value >= 0) { + return const BorderRadius.vertical(top: Radius.circular(4)); + } + return const BorderRadius.vertical(bottom: Radius.circular(4)); + } + + return [ + for (final row in topLabels) + BarChartRodData( + toY: bucket[row.label.id] ?? 0, + width: rodWidth, + borderRadius: rodRadius(bucket[row.label.id] ?? 0), + color: row.label.color, + ), + if (hasOther) + BarChartRodData( + toY: otherFor(month), + width: rodWidth, + borderRadius: rodRadius(otherFor(month)), + color: otherColor, + ), + ]; + } + + final axisInterval = ((safeMax - safeMin) / 3).abs(); + final interval = axisInterval <= 0 ? 1.0 : axisInterval; return BarChart( BarChartData( + minY: safeMin, maxY: safeMax, groupsSpace: period == _PeriodMode.yearly ? 16 : 10, barTouchData: BarTouchData( @@ -1132,6 +1310,32 @@ class _LabelMonthBars extends StatelessWidget { touchTooltipData: BarTouchTooltipData( getTooltipItem: (group, groupIndex, rod, rodIndex) { final month = months[group.x.toInt()]; + if (stacked && rod.rodStackItems.isNotEmpty) { + final bucket = + amountByMonth[_periodKey(month, period)] ?? const {}; + final buffer = StringBuffer( + '${_periodAxisLabel(month, period)}\n', + ); + var total = 0.0; + for (final row in topLabels) { + final amount = bucket[row.label.id] ?? 0; + if (amount == 0) continue; + total += amount; + buffer.writeln( + '${row.label.name}: ${formatMoney(amount, currency)}', + ); + } + final other = otherFor(month); + if (other != 0) { + total += other; + buffer.writeln('Other: ${formatMoney(other, currency)}'); + } + buffer.write('Total: ${formatMoney(total, currency)}'); + return BarTooltipItem( + buffer.toString(), + TextStyle(color: colors.onInverseSurface), + ); + } final name = rodIndex < topLabels.length ? topLabels[rodIndex].label.name : 'Other'; @@ -1152,12 +1356,13 @@ class _LabelMonthBars extends StatelessWidget { sideTitles: SideTitles( showTitles: true, reservedSize: 42, - interval: safeMax / 3, + interval: interval, getTitlesWidget: (value, meta) { - if (value <= 0) { + if (value.abs() < 0.01) { return Text('0', style: theme.textTheme.labelSmall); } - if ((value - safeMax).abs() < 0.01) { + if ((value - safeMax).abs() < 0.01 || + (value - safeMin).abs() < 0.01) { return const SizedBox.shrink(); } return Text( @@ -1195,11 +1400,13 @@ class _LabelMonthBars extends StatelessWidget { gridData: FlGridData( show: true, drawVerticalLine: false, - horizontalInterval: safeMax / 3, + horizontalInterval: interval, getDrawingHorizontalLine: (value) => FlLine( - color: colors.outlineVariant.withValues(alpha: 0.35), - strokeWidth: 1, - dashArray: const [4, 4], + color: value.abs() < 0.01 + ? colors.outline.withValues(alpha: 0.55) + : colors.outlineVariant.withValues(alpha: 0.35), + strokeWidth: value.abs() < 0.01 ? 1.2 : 1, + dashArray: value.abs() < 0.01 ? null : const [4, 4], ), ), borderData: FlBorderData(show: false), @@ -1207,27 +1414,8 @@ class _LabelMonthBars extends StatelessWidget { for (var i = 0; i < months.length; i++) BarChartGroupData( x: i, - barsSpace: 2, - barRods: [ - for (final row in topLabels) - BarChartRodData( - toY: amountByMonth[_periodKey(months[i], period)] - ?[row.label.id] ?? - 0, - width: rodWidth, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(4)), - color: row.label.color, - ), - if (hasOther) - BarChartRodData( - toY: otherFor(months[i]), - width: rodWidth, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(4)), - color: otherColor, - ), - ], + barsSpace: stacked ? 0 : 2, + barRods: rodsFor(months[i]), ), ], ), @@ -1423,6 +1611,7 @@ class _LabelHistoryCard extends StatelessWidget { required this.month, required this.title, required this.rows, + required this.flow, required this.currency, required this.onOpenPeriod, required this.onOpenLabel, @@ -1431,10 +1620,28 @@ class _LabelHistoryCard extends StatelessWidget { final DateTime month; final String title; final List<_LabelSpend> rows; + final TxFlowFilter flow; final String currency; final VoidCallback onOpenPeriod; final void Function(TxLabel label) onOpenLabel; + /// Display amount + color for history rows. + /// + /// Keeps the original `-amount` sign convention; for All, color follows the + /// displayed value (positive → green, negative → red). + (double display, Color color) _styleFor(double amount) { + switch (flow) { + case TxFlowFilter.expense: + case TxFlowFilter.income: + return (-amount.abs(), _kExpenseColor); + case TxFlowFilter.all: + final display = -amount; + if (display > 0) return (display, _kIncomeColor); + if (display < 0) return (display, _kExpenseColor); + return (0, _kExpenseColor); + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -1442,6 +1649,7 @@ class _LabelHistoryCard extends StatelessWidget { final isYear = title.length == 4 && int.tryParse(title) != null; final periodLabel = isYear ? title : formatMonthYear(month); final total = rows.fold(0, (sum, row) => sum + row.amount); + final totalStyle = _styleFor(total); return Card( color: colors.surfaceContainer, @@ -1483,9 +1691,9 @@ class _LabelHistoryCard extends StatelessWidget { ), ), Text( - formatMoney(-total, currency), + formatMoney(totalStyle.$1, currency), style: theme.textTheme.titleSmall?.copyWith( - color: _kExpenseColor, + color: totalStyle.$2, fontWeight: FontWeight.w700, ), ), @@ -1524,12 +1732,17 @@ class _LabelHistoryCard extends StatelessWidget { overflow: TextOverflow.ellipsis, ), ), - Text( - formatMoney(-rows[i].amount, currency), - style: theme.textTheme.bodyMedium?.copyWith( - color: _kExpenseColor, - fontWeight: FontWeight.w600, - ), + Builder( + builder: (context) { + final style = _styleFor(rows[i].amount); + return Text( + formatMoney(style.$1, currency), + style: theme.textTheme.bodyMedium?.copyWith( + color: style.$2, + fontWeight: FontWeight.w600, + ), + ); + }, ), ], ), diff --git a/lib/utils/navigation.dart b/lib/utils/navigation.dart index ec2142b..37766a1 100644 --- a/lib/utils/navigation.dart +++ b/lib/utils/navigation.dart @@ -5,16 +5,28 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/auth_provider.dart'; +import '../providers/sync_provider.dart'; import '../screens/account_screen.dart'; import '../screens/login_screen.dart'; import '../screens/settings_screen.dart'; Widget authAppBarAction(BuildContext context) { final auth = context.watch(); + final sync = context.watch(); + final showRing = sync.showProgressRing; + final progress = sync.syncProgress; + final success = sync.progressSuccess; + final syncActive = sync.isSyncing || showRing; if (auth.isLoggedIn) { + final ringColor = success + ? const Color(0xFF16A34A) + : Theme.of(context).colorScheme.primary; + return PopupMenuButton<_AccountMenuAction>( - tooltip: auth.displayName, + tooltip: sync.isSyncing + ? 'Syncing… ${(progress * 100).round()}%' + : auth.displayName, offset: const Offset(0, 48), onSelected: (action) async { switch (action) { @@ -28,6 +40,9 @@ Widget authAppBarAction(BuildContext context) { }, itemBuilder: (context) { final email = auth.email; + // Capture whether sync was active when the menu opened; the tile + // itself keeps listening so the % can update while the menu is open. + final showSyncRow = syncActive; return [ if (email != null) PopupMenuItem<_AccountMenuAction>( @@ -37,6 +52,11 @@ Widget authAppBarAction(BuildContext context) { style: Theme.of(context).textTheme.bodySmall, ), ), + if (showSyncRow) + const PopupMenuItem<_AccountMenuAction>( + enabled: false, + child: _SyncProgressMenuTile(), + ), const PopupMenuItem<_AccountMenuAction>( value: _AccountMenuAction.account, child: ListTile( @@ -57,14 +77,38 @@ Widget authAppBarAction(BuildContext context) { }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), - child: CircleAvatar( - radius: 16, - backgroundColor: Theme.of(context).colorScheme.primaryContainer, - child: Text( - auth.initials, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: Theme.of(context).colorScheme.onPrimaryContainer, + child: SizedBox( + width: 32, + height: 32, + child: Stack( + alignment: Alignment.center, + children: [ + CircleAvatar( + radius: 16, + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + child: Text( + auth.initials, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: + Theme.of(context).colorScheme.onPrimaryContainer, + ), ), + ), + if (showRing) + SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator( + value: progress <= 0 ? null : progress.clamp(0.02, 1.0), + strokeWidth: 2.5, + color: ringColor, + backgroundColor: Theme.of(context) + .colorScheme + .outlineVariant + .withValues(alpha: 0.45), + ), + ), + ], ), ), ), @@ -82,6 +126,56 @@ Widget authAppBarAction(BuildContext context) { ); } +/// Live sync status row inside the account popup (watches [SyncController]). +class _SyncProgressMenuTile extends StatelessWidget { + const _SyncProgressMenuTile(); + + @override + Widget build(BuildContext context) { + final sync = context.watch(); + final theme = Theme.of(context); + final colors = theme.colorScheme; + final pct = (sync.syncProgress * 100).round().clamp(0, 100); + final detail = sync.statusDetail.trim(); + + if (sync.progressSuccess) { + return ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.check_circle_outline, color: colors.primary), + title: Text( + 'Sync complete', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurface, + ), + ), + ); + } + + return ListTile( + contentPadding: EdgeInsets.zero, + leading: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + value: sync.syncProgress <= 0 + ? null + : sync.syncProgress.clamp(0.02, 1.0), + strokeWidth: 2.5, + ), + ), + title: Text( + detail.isEmpty ? 'Sync in progress ($pct%)' : detail, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurface, + ), + ), + subtitle: detail.isEmpty + ? null + : Text('Sync in progress ($pct%)'), + ); + } +} + enum _AccountMenuAction { account, logout } List bankAppBarActions({ @@ -96,7 +190,6 @@ List bankAppBarActions({ onPressed: refreshing ? null : onRefresh, icon: const Icon(Icons.refresh), ), - authAppBarAction(context), IconButton( tooltip: 'Settings', onPressed: () { @@ -106,5 +199,6 @@ List bankAppBarActions({ }, icon: const Icon(Icons.settings_outlined), ), + authAppBarAction(context), ]; } diff --git a/lib/widgets/label_filter_dialog.dart b/lib/widgets/label_filter_dialog.dart index 206cac5..301f363 100644 --- a/lib/widgets/label_filter_dialog.dart +++ b/lib/widgets/label_filter_dialog.dart @@ -35,15 +35,30 @@ class TransactionFilterSelection { query.trim().isNotEmpty; } +/// How label series are drawn on the overview chart. +enum LabelChartBarStyle { + /// Side-by-side bars per label (default). + parallel, + + /// Single column with stacked label segments. + stacked, +} + /// Result of the simple label multi-select dialog. class LabelFilterSelection { const LabelFilterSelection({ required this.labelIds, this.flow = TxFlowFilter.all, + this.barStyle = LabelChartBarStyle.parallel, + this.invertExpensePerspective = false, }); final Set labelIds; final TxFlowFilter flow; + final LabelChartBarStyle barStyle; + + /// When [flow] is All: flip signs so expenses plot upward (spend-positive). + final bool invertExpensePerspective; } /// Returns the selection, or `null` if cancelled. @@ -58,6 +73,10 @@ Future showLabelFilterDialog( bool allowCreate = false, bool includeFlow = false, TxFlowFilter initialFlow = TxFlowFilter.all, + bool includeBarStyle = false, + LabelChartBarStyle initialBarStyle = LabelChartBarStyle.parallel, + bool includeInvertChart = false, + bool initialInvertChart = false, }) { return showDialog( context: context, @@ -72,6 +91,10 @@ Future showLabelFilterDialog( allowCreate: allowCreate, includeFlow: includeFlow, initialFlow: initialFlow, + includeBarStyle: includeBarStyle, + initialBarStyle: initialBarStyle, + includeInvertChart: includeInvertChart, + initialInvertChart: initialInvertChart, ); }, ); @@ -126,6 +149,10 @@ class _SimpleLabelFilterDialog extends StatefulWidget { required this.allowCreate, required this.includeFlow, required this.initialFlow, + required this.includeBarStyle, + required this.initialBarStyle, + required this.includeInvertChart, + required this.initialInvertChart, }); final List available; @@ -137,6 +164,10 @@ class _SimpleLabelFilterDialog extends StatefulWidget { final bool allowCreate; final bool includeFlow; final TxFlowFilter initialFlow; + final bool includeBarStyle; + final LabelChartBarStyle initialBarStyle; + final bool includeInvertChart; + final bool initialInvertChart; @override State<_SimpleLabelFilterDialog> createState() => @@ -146,6 +177,8 @@ class _SimpleLabelFilterDialog extends StatefulWidget { class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> { late final Set _selected; late TxFlowFilter _flow; + late LabelChartBarStyle _barStyle; + late bool _invertChart; late final TextEditingController _labelSearchController; @override @@ -153,6 +186,8 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> { super.initState(); _selected = Set.of(widget.initiallySelected); _flow = widget.initialFlow; + _barStyle = widget.initialBarStyle; + _invertChart = widget.initialInvertChart; _labelSearchController = TextEditingController(); } @@ -208,8 +243,17 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> { kUncategorizedFilterLabel.name.toLowerCase().contains(needle)); final media = MediaQuery.of(context); final maxContentHeight = - ((media.size.height - media.viewInsets.bottom) * 0.55) - .clamp(240.0, 560.0); + ((media.size.height - media.viewInsets.bottom) * 0.72) + .clamp(320.0, 720.0); + + final visibleIds = [ + for (final e in entries) e.label.id, + if (showUncategorized) kUncategorizedFilterId, + ]; + final selectedVisible = visibleIds.where(_selected.contains).length; + final allVisibleSelected = + visibleIds.isNotEmpty && selectedVisible == visibleIds.length; + final noneVisibleSelected = selectedVisible == 0; return AlertDialog( title: Text(widget.title), @@ -219,72 +263,152 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - widget.subtitle, - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - if (widget.includeFlow) ...[ - const SizedBox(height: 12), - Text( - 'Income / expense', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - _RectToggleBar( - value: _flow, - onChanged: (value) => setState(() => _flow = value), - options: const [ - _RectToggleOption( - value: TxFlowFilter.all, - label: 'All', - ), - _RectToggleOption( - value: TxFlowFilter.income, - label: 'In', - icon: Icons.south_west, - ), - _RectToggleOption( - value: TxFlowFilter.expense, - label: 'Out', - icon: Icons.north_east, - ), - ], - ), - ], - const SizedBox(height: 10), - TextField( - controller: _labelSearchController, - decoration: InputDecoration( - isDense: true, - hintText: 'Filter labels', - prefixIcon: const Icon(Icons.search, size: 20), - suffixIcon: needle.isEmpty - ? null - : IconButton( - onPressed: () { - _labelSearchController.clear(); - setState(() {}); - }, - icon: const Icon(Icons.clear, size: 18), + ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxContentHeight * 0.42), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, ), - ), - onChanged: (_) => setState(() {}), - ), - if (widget.allowCreate) ...[ - const SizedBox(height: 8), - Align( - alignment: Alignment.centerLeft, - child: TextButton.icon( - onPressed: _createLabel, - icon: const Icon(Icons.add, size: 18), - label: const Text('New label'), + ), + if (widget.includeFlow) ...[ + const SizedBox(height: 12), + Text( + 'Income / expense', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _RectToggleBar( + value: _flow, + onChanged: (value) => setState(() => _flow = value), + options: const [ + _RectToggleOption( + value: TxFlowFilter.all, + label: 'All', + ), + _RectToggleOption( + value: TxFlowFilter.income, + label: 'In', + icon: Icons.south_west, + ), + _RectToggleOption( + value: TxFlowFilter.expense, + label: 'Out', + icon: Icons.north_east, + ), + ], + ), + if (widget.includeInvertChart && + _flow == TxFlowFilter.all) ...[ + const SizedBox(height: 8), + SwitchListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: const Text('Invert chart'), + subtitle: const Text( + 'Expense view: spending up, income/refunds down', + ), + value: _invertChart, + onChanged: (value) => + setState(() => _invertChart = value), + ), + ], + ], + if (widget.includeBarStyle) ...[ + const SizedBox(height: 12), + Text( + 'Bar layout', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _RectToggleBar( + value: _barStyle, + onChanged: (value) => + setState(() => _barStyle = value), + options: const [ + _RectToggleOption( + value: LabelChartBarStyle.parallel, + label: 'Parallel', + icon: Icons.view_week_outlined, + ), + _RectToggleOption( + value: LabelChartBarStyle.stacked, + label: 'Stacked', + icon: Icons.layers_outlined, + ), + ], + ), + ], + if (widget.allowCreate) ...[ + const SizedBox(height: 10), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: _createLabel, + icon: const Icon(Icons.add, size: 18), + label: const Text('New label'), + ), + ), + ], + ], ), ), - ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: TextField( + controller: _labelSearchController, + decoration: InputDecoration( + isDense: true, + hintText: 'Filter labels', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: needle.isEmpty + ? null + : IconButton( + onPressed: () { + _labelSearchController.clear(); + setState(() {}); + }, + icon: const Icon(Icons.clear, size: 18), + ), + ), + onChanged: (_) => setState(() {}), + ), + ), + Tooltip( + message: allVisibleSelected ? 'Deselect all' : 'Select all', + child: Checkbox( + tristate: true, + value: allVisibleSelected + ? true + : noneVisibleSelected + ? false + : null, + onChanged: visibleIds.isEmpty + ? null + : (_) { + setState(() { + if (allVisibleSelected) { + _selected.removeAll(visibleIds); + } else { + _selected.addAll(visibleIds); + } + }); + }, + ), + ), + ], + ), const SizedBox(height: 8), Expanded( child: _LabelChecklist( @@ -302,50 +426,33 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> { actions: [ SizedBox( width: double.maxFinite, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + child: Row( children: [ - Row( - children: [ - Expanded( - child: TextButton( - onPressed: () { - setState(() { - _selected - ..clear() - ..addAll(entries.map((e) => e.label.id)); - if (showUncategorized) { - _selected.add(kUncategorizedFilterId); - } - }); - }, - child: const Text('All'), - ), - ), - Expanded( - child: TextButton( - onPressed: () => setState(() => _selected.clear()), - child: const Text('None'), - ), - ), - Expanded( - child: TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - ), - ], - ), - const SizedBox(height: 4), - FilledButton( - onPressed: () => Navigator.pop( - context, - LabelFilterSelection( - labelIds: Set.of(_selected), - flow: widget.includeFlow ? _flow : TxFlowFilter.all, - ), + Expanded( + child: TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: FilledButton( + onPressed: () => Navigator.pop( + context, + LabelFilterSelection( + labelIds: Set.of(_selected), + flow: widget.includeFlow ? _flow : TxFlowFilter.all, + barStyle: widget.includeBarStyle + ? _barStyle + : LabelChartBarStyle.parallel, + invertExpensePerspective: widget.includeInvertChart && + _flow == TxFlowFilter.all && + _invertChart, + ), + ), + child: const Text('Apply'), ), - child: const Text('Apply'), ), ], ), diff --git a/pocketbase/pb_migrations/1740000000_ingtrak_sync_collections.js b/pocketbase/pb_migrations/1740000000_ingtrak_sync_collections.js new file mode 100644 index 0000000..e418938 --- /dev/null +++ b/pocketbase/pb_migrations/1740000000_ingtrak_sync_collections.js @@ -0,0 +1,218 @@ +/// PocketBase migration: IngTrak sync collections (custom labels + annotations). +/// +/// Apply on the server (from /opt/pocketbase or wherever the binary lives): +/// +/// sudo cp this_file /opt/pocketbase/pb_migrations/ +/// sudo chown pocketbase:pocketbase /opt/pocketbase/pb_migrations/*.js +/// sudo -u pocketbase /opt/pocketbase/pocketbase migrate up +/// sudo systemctl restart pocketbase +/// +/// Auth collection must already exist as `usersapp`. +/// Built-in labels stay in the Flutter app — only custom labels sync. +migrate((app) => { + const users = app.findCollectionByNameOrId("usersapp"); + + const ownerRules = { + listRule: "user = @request.auth.id", + viewRule: "user = @request.auth.id", + createRule: "@request.auth.id != '' && user = @request.auth.id", + updateRule: "user = @request.auth.id", + deleteRule: "user = @request.auth.id", + }; + + // --- custom labels (TxLabel where custom == true + labelParents) --- + const labels = new Collection({ + type: "base", + name: "labels", + ...ownerRules, + fields: [ + { + type: "relation", + name: "user", + required: true, + collectionId: users.id, + cascadeDelete: true, + maxSelect: 1, + }, + { + // App-side id, e.g. "custom_ah" (not the PocketBase record id) + type: "text", + name: "client_id", + required: true, + min: 1, + max: 80, + }, + { + type: "text", + name: "name", + required: true, + min: 1, + max: 80, + }, + { + // Flutter Color.value (ARGB int) + type: "number", + name: "color", + required: true, + onlyInt: true, + }, + { + // Parent label client_id (builtin or custom); empty = root + type: "text", + name: "parent_client_id", + required: false, + max: 80, + }, + ], + indexes: [ + "CREATE UNIQUE INDEX idx_labels_user_client ON labels (user, client_id)", + ], + }); + app.save(labels); + + // --- per-transaction overlays (TxMetaStoreData.byId) --- + const txMeta = new Collection({ + type: "base", + name: "tx_meta", + ...ownerRules, + fields: [ + { + type: "relation", + name: "user", + required: true, + collectionId: users.id, + cascadeDelete: true, + maxSelect: 1, + }, + { + // BankTransaction.id → "date|currency|amount|description" + type: "text", + name: "tx_id", + required: true, + min: 1, + max: 2000, + }, + { + type: "json", + name: "label_ids", + required: false, + }, + { + type: "bool", + name: "recurring", + required: false, + }, + { + type: "bool", + name: "internal_transfer", + required: false, + }, + { + type: "text", + name: "notes", + required: false, + max: 4000, + }, + ], + indexes: [ + "CREATE UNIQUE INDEX idx_tx_meta_user_tx ON tx_meta (user, tx_id)", + ], + }); + app.save(txMeta); + + // --- merchant auto-label / notes rules (labelsByMerchant + notesByMerchant) --- + const merchantRules = new Collection({ + type: "base", + name: "merchant_rules", + ...ownerRules, + fields: [ + { + type: "relation", + name: "user", + required: true, + collectionId: users.id, + cascadeDelete: true, + maxSelect: 1, + }, + { + // merchantKeyFromDescription(...) e.g. "albert heijn" + type: "text", + name: "merchant_key", + required: true, + min: 1, + max: 200, + }, + { + type: "json", + name: "label_ids", + required: false, + }, + { + type: "text", + name: "notes", + required: false, + max: 4000, + }, + ], + indexes: [ + "CREATE UNIQUE INDEX idx_merchant_rules_user_key ON merchant_rules (user, merchant_key)", + ], + }); + app.save(merchantRules); + + // --- exact amount+description templates (exactTemplates / TxExactTemplate) --- + const exactTemplates = new Collection({ + type: "base", + name: "exact_templates", + ...ownerRules, + fields: [ + { + type: "relation", + name: "user", + required: true, + collectionId: users.id, + cascadeDelete: true, + maxSelect: 1, + }, + { + // BankTransaction.exactKey → "descriptionKey|amount|currency" + type: "text", + name: "exact_key", + required: true, + min: 1, + max: 2000, + }, + { + type: "text", + name: "notes", + required: false, + max: 4000, + }, + { + type: "bool", + name: "recurring", + required: false, + }, + { + type: "bool", + name: "internal_transfer", + required: false, + }, + ], + indexes: [ + "CREATE UNIQUE INDEX idx_exact_templates_user_key ON exact_templates (user, exact_key)", + ], + }); + app.save(exactTemplates); +}, (app) => { + for (const name of [ + "exact_templates", + "merchant_rules", + "tx_meta", + "labels", + ]) { + try { + app.delete(app.findCollectionByNameOrId(name)); + } catch (_) {} + } +}); diff --git a/test/filter_detail_smoke_test.dart b/test/filter_detail_smoke_test.dart index 4fd236f..03cde54 100644 --- a/test/filter_detail_smoke_test.dart +++ b/test/filter_detail_smoke_test.dart @@ -5,6 +5,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:myapp/models/bank.dart'; import 'package:myapp/providers/auth_provider.dart'; import 'package:myapp/providers/bank_provider.dart'; +import 'package:myapp/providers/sync_provider.dart'; import 'package:myapp/providers/theme_provider.dart'; import 'package:myapp/providers/tx_meta_provider.dart'; import 'package:myapp/screens/main_shell.dart'; @@ -27,6 +28,7 @@ void main() { late StorageService storage; late AuthController auth; + late SyncController sync; late BankController bank; late TxMetaController meta; @@ -39,8 +41,21 @@ void main() { await auth.init(); bank = BankController(storage: storage); meta = TxMetaController(storage); + sync = SyncController(auth: auth, meta: meta); }); + tearDown(() { + sync.dispose(); + }); + + List appProviders() => [ + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.value(value: sync), + ChangeNotifierProvider(create: (_) => ThemeController(storage)), + ChangeNotifierProvider.value(value: meta), + ChangeNotifierProvider.value(value: bank), + ]; + testWidgets('main shell + detail + filter', (tester) async { FlutterError.onError = (d) => fail('${d.exceptionAsString()}\n${d.stack}'); @@ -63,12 +78,7 @@ void main() { await tester.pumpWidget( MultiProvider( - providers: [ - ChangeNotifierProvider.value(value: auth), - ChangeNotifierProvider(create: (_) => ThemeController(storage)), - ChangeNotifierProvider.value(value: meta), - ChangeNotifierProvider.value(value: bank), - ], + providers: appProviders(), child: MaterialApp( theme: AppTheme.light(), darkTheme: AppTheme.dark(), @@ -83,12 +93,7 @@ void main() { // Open filter via transactions - go to a route await tester.pumpWidget( MultiProvider( - providers: [ - ChangeNotifierProvider.value(value: auth), - ChangeNotifierProvider(create: (_) => ThemeController(storage)), - ChangeNotifierProvider.value(value: meta), - ChangeNotifierProvider.value(value: bank), - ], + providers: appProviders(), child: MaterialApp( theme: AppTheme.light(), darkTheme: AppTheme.dark(), @@ -126,12 +131,7 @@ void main() { await meta.setInternalTransfer(sample, true); await tester.pumpWidget( MultiProvider( - providers: [ - ChangeNotifierProvider.value(value: auth), - ChangeNotifierProvider(create: (_) => ThemeController(storage)), - ChangeNotifierProvider.value(value: meta), - ChangeNotifierProvider.value(value: bank), - ], + providers: appProviders(), child: MaterialApp( theme: AppTheme.light(), darkTheme: AppTheme.dark(), diff --git a/test/overview_smoke_test.dart b/test/overview_smoke_test.dart index a37a7e6..eaa6953 100644 --- a/test/overview_smoke_test.dart +++ b/test/overview_smoke_test.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:myapp/providers/auth_provider.dart'; import 'package:myapp/providers/bank_provider.dart'; +import 'package:myapp/providers/sync_provider.dart'; import 'package:myapp/providers/theme_provider.dart'; import 'package:myapp/providers/tx_meta_provider.dart'; import 'package:myapp/screens/overview_screen.dart'; @@ -17,6 +18,7 @@ void main() { late StorageService storage; late AuthController auth; + late SyncController sync; late BankController bank; late TxMetaController meta; @@ -31,6 +33,11 @@ void main() { // seed a couple txs // ignore: invalid_use_of_visible_for_testing_member meta = TxMetaController(storage); + sync = SyncController(auth: auth, meta: meta); + }); + + tearDown(() { + sync.dispose(); }); testWidgets('overview builds dark', (tester) async { @@ -41,6 +48,7 @@ void main() { MultiProvider( providers: [ ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.value(value: sync), ChangeNotifierProvider(create: (_) => ThemeController(storage)), ChangeNotifierProvider.value(value: meta), ChangeNotifierProvider.value(value: bank), @@ -64,6 +72,7 @@ void main() { MultiProvider( providers: [ ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.value(value: sync), ChangeNotifierProvider(create: (_) => ThemeController(storage)), ChangeNotifierProvider.value(value: meta), ChangeNotifierProvider.value(value: bank),