sync transactions with pocketbase

This commit is contained in:
davmar
2026-09-09 22:24:16 +02:00
parent c6d1fdb19e
commit 96627a830e
9 changed files with 1104 additions and 324 deletions
+14 -1
View File
@@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
import 'providers/auth_provider.dart'; import 'providers/auth_provider.dart';
import 'providers/bank_provider.dart'; import 'providers/bank_provider.dart';
import 'providers/sync_provider.dart';
import 'providers/theme_provider.dart'; import 'providers/theme_provider.dart';
import 'providers/tx_meta_provider.dart'; import 'providers/tx_meta_provider.dart';
import 'screens/splash_screen.dart'; import 'screens/splash_screen.dart';
@@ -17,6 +18,7 @@ import 'utils/app_config.dart';
import 'utils/app_theme.dart'; import 'utils/app_theme.dart';
import 'utils/constants.dart'; import 'utils/constants.dart';
import 'widgets/skip_build_when_covered.dart'; import 'widgets/skip_build_when_covered.dart';
import 'widgets/sync_feedback_listener.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -45,12 +47,16 @@ class MyApp extends StatefulWidget {
class _MyAppState extends State<MyApp> { class _MyAppState extends State<MyApp> {
late final BankController _bank; late final BankController _bank;
late final TxMetaController _meta;
late final SyncController _sync;
late final AppLinks _appLinks; late final AppLinks _appLinks;
StreamSubscription<Uri>? _linkSub; StreamSubscription<Uri>? _linkSub;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_meta = TxMetaController(widget.storage);
_sync = SyncController(auth: widget.auth, meta: _meta);
_bank = BankController(storage: widget.storage)..refresh(); _bank = BankController(storage: widget.storage)..refresh();
_appLinks = AppLinks(); _appLinks = AppLinks();
_listenForBankRedirects(); _listenForBankRedirects();
@@ -79,6 +85,7 @@ class _MyAppState extends State<MyApp> {
@override @override
void dispose() { void dispose() {
_linkSub?.cancel(); _linkSub?.cancel();
_sync.dispose();
_bank.dispose(); _bank.dispose();
super.dispose(); super.dispose();
} }
@@ -89,7 +96,8 @@ class _MyAppState extends State<MyApp> {
providers: [ providers: [
ChangeNotifierProvider<AuthController>.value(value: widget.auth), ChangeNotifierProvider<AuthController>.value(value: widget.auth),
ChangeNotifierProvider(create: (_) => ThemeController(widget.storage)), ChangeNotifierProvider(create: (_) => ThemeController(widget.storage)),
ChangeNotifierProvider(create: (_) => TxMetaController(widget.storage)), ChangeNotifierProvider<TxMetaController>.value(value: _meta),
ChangeNotifierProvider<SyncController>.value(value: _sync),
ChangeNotifierProvider<BankController>.value(value: _bank), ChangeNotifierProvider<BankController>.value(value: _bank),
], ],
child: const _MaterialRoot(), child: const _MaterialRoot(),
@@ -111,6 +119,11 @@ class _MaterialRoot extends StatelessWidget {
darkTheme: AppTheme.dark(), darkTheme: AppTheme.dark(),
themeMode: themeMode, themeMode: themeMode,
navigatorObservers: [appRouteObserver], navigatorObservers: [appRouteObserver],
builder: (context, child) {
return SyncFeedbackListener(
child: child ?? const SizedBox.shrink(),
);
},
home: const SplashScreen(), home: const SplashScreen(),
); );
} }
+60 -1
View File
@@ -25,6 +25,9 @@ class TxMetaController extends ChangeNotifier {
final StorageService _storage; final StorageService _storage;
/// Invoked after a successful local persist (used by [SyncController]).
VoidCallback? onPersisted;
Map<String, TxMeta> _byId = {}; Map<String, TxMeta> _byId = {};
Map<String, List<String>> _labelsByMerchant = {}; Map<String, List<String>> _labelsByMerchant = {};
Map<String, String> _notesByMerchant = {}; Map<String, String> _notesByMerchant = {};
@@ -32,6 +35,61 @@ class TxMetaController extends ChangeNotifier {
List<TxLabel> _customLabels = []; List<TxLabel> _customLabels = [];
Map<String, String> _labelParents = {}; Map<String, String> _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<String>.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<void> replaceStore(TxMetaStoreData store) async {
_byId = Map.of(store.byId);
_labelsByMerchant = {
for (final e in store.labelsByMerchant.entries)
e.key: List<String>.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. /// Built-in + custom labels available for picking.
List<TxLabel> get allLabels => [...kBuiltinLabels, ..._customLabels]; List<TxLabel> get allLabels => [...kBuiltinLabels, ..._customLabels];
@@ -759,7 +817,7 @@ class TxMetaController extends ChangeNotifier {
unawaited(_writeStore()); unawaited(_writeStore());
} }
Future<void> _writeStore() async { Future<void> _writeStore({bool notifySync = true}) async {
// Let the busy indicator / label checkbox paint before JSON work. // Let the busy indicator / label checkbox paint before JSON work.
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
final snapshot = TxMetaStoreData( final snapshot = TxMetaStoreData(
@@ -778,6 +836,7 @@ class TxMetaController extends ChangeNotifier {
); );
try { try {
await _storage.saveTxMetaStore(snapshot); await _storage.saveTxMetaStore(snapshot);
if (notifySync) onPersisted?.call();
} catch (error, stackTrace) { } catch (error, stackTrace) {
debugPrint('TxMetaController persist failed: $error\n$stackTrace'); debugPrint('TxMetaController persist failed: $error\n$stackTrace');
} }
+72 -5
View File
@@ -1,10 +1,13 @@
/// Signed-in account details and sign-out. /// Signed-in account details, sync status, and sign-out.
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/auth_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 '../utils/constants.dart';
import '../widgets/responsive_body.dart'; import '../widgets/responsive_body.dart';
@@ -14,9 +17,23 @@ class AccountScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final auth = context.watch<AuthController>(); final auth = context.watch<AuthController>();
final sync = context.watch<SyncController>();
final meta = context.watch<TxMetaController>();
final bank = context.watch<BankController>();
final theme = Theme.of(context); final theme = Theme.of(context);
final colors = theme.colorScheme; 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( return Scaffold(
appBar: AppBar(title: const Text('Account')), appBar: AppBar(title: const Text('Account')),
body: ResponsiveBody( body: ResponsiveBody(
@@ -63,12 +80,55 @@ class AccountScreen extends StatelessWidget {
), ),
const Divider(height: 1), const Divider(height: 1),
ListTile( ListTile(
leading: const Icon(Icons.phone_android_outlined), leading: sync.isSyncing
title: const Text('Local data'), ? SizedBox(
subtitle: const Text( width: 24,
'Labels, notes, and bank cache remain on this device.', 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';
}
} }
+374 -161
View File
@@ -45,6 +45,12 @@ class _OverviewScreenState extends State<OverviewScreen> {
/// Labels chart flow filter (defaults to expenses / labeled spend). /// Labels chart flow filter (defaults to expenses / labeled spend).
var _labelFlow = TxFlowFilter.expense; 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)`). /// Selected calendar year for Monthly mode (`DateTime(year)`).
late DateTime _cursor; late DateTime _cursor;
@@ -129,11 +135,18 @@ class _OverviewScreenState extends State<OverviewScreen> {
allowMove: true, allowMove: true,
includeFlow: true, includeFlow: true,
initialFlow: _labelFlow, initialFlow: _labelFlow,
includeBarStyle: true,
initialBarStyle: _labelBarStyle,
includeInvertChart: true,
initialInvertChart: _labelInvertChart,
); );
if (result == null || !mounted) return; if (result == null || !mounted) return;
setState(() { setState(() {
_labelFlow = result.flow; _labelFlow = result.flow;
_labelBarStyle = result.barStyle;
_labelInvertChart = result.flow == TxFlowFilter.all &&
result.invertExpensePerspective;
final allIds = available.map((l) => l.id).toSet(); final allIds = available.map((l) => l.id).toSet();
if (result.labelIds.isEmpty || if (result.labelIds.isEmpty ||
(result.labelIds.length == allIds.length && (result.labelIds.length == allIds.length &&
@@ -221,6 +234,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
_period, _period,
includedLabelIds: _includedLabelIds, includedLabelIds: _includedLabelIds,
flow: _labelFlow, flow: _labelFlow,
invertExpensePerspective: _labelInvertChart,
); );
labelSpend = labels.spend; labelSpend = labels.spend;
labelAmountByPeriod = labels.amountByPeriod; labelAmountByPeriod = labels.amountByPeriod;
@@ -243,21 +257,28 @@ class _OverviewScreenState extends State<OverviewScreen> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
_ChartToolbar(
period: _period,
metric: _metric,
onPeriodChanged: _setPeriod,
onMetricChanged: (value) => setState(() => _metric = value),
),
const SizedBox(height: 10),
_ChartCard( _ChartCard(
period: _period, period: _period,
metric: _metric, metric: _metric,
periodTitle: _periodTitle, periodTitle: _periodTitle,
canGoBack: _canGoBack, canGoBack: _canGoBack,
canGoForward: _canGoForward, canGoForward: _canGoForward,
onPeriodChanged: _setPeriod,
onMetricChanged: (value) => setState(() => _metric = value),
onBack: () => _go(-1), onBack: () => _go(-1),
onForward: () => _go(1), onForward: () => _go(1),
months: periods, months: periods,
monthly: monthly, monthly: monthly,
labelSpend: labelSpend, labelSpend: labelSpend,
labelAmountByMonth: labelAmountByPeriod, labelAmountByMonth: labelAmountByPeriod,
labelBarStyle: _labelBarStyle,
currency: currency, currency: currency,
onPickLabels: () => _pickChartLabels(context),
includedLabelCount: _includedLabelIds.isEmpty && includedLabelCount: _includedLabelIds.isEmpty &&
_labelFlow == TxFlowFilter.expense _labelFlow == TxFlowFilter.expense
? null ? null
@@ -265,7 +286,6 @@ class _OverviewScreenState extends State<OverviewScreen> {
? meta.allLabels.length ? meta.allLabels.length
: _includedLabelIds.length), : _includedLabelIds.length),
availableLabelCount: meta.allLabels.length, availableLabelCount: meta.allLabels.length,
onPickLabels: () => _pickChartLabels(context),
onLabelTap: (label) { onLabelTap: (label) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute<void>( MaterialPageRoute<void>(
@@ -313,7 +333,9 @@ class _OverviewScreenState extends State<OverviewScreen> {
label: switch (_labelFlow) { label: switch (_labelFlow) {
TxFlowFilter.income => 'Labeled income · $_periodTitle', TxFlowFilter.income => 'Labeled income · $_periodTitle',
TxFlowFilter.expense => 'Labeled spend · $_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<OverviewScreen> {
monthly: monthly, monthly: monthly,
labelSpend: labelSpend, labelSpend: labelSpend,
labelAmountByPeriod: labelAmountByPeriod, labelAmountByPeriod: labelAmountByPeriod,
labelFlow: _labelFlow,
currency: currency, currency: currency,
onOpenPeriod: (period) => _openPeriod(context, period), onOpenPeriod: (period) => _openPeriod(context, period),
onOpenLabel: (period, label) => onOpenLabel: (period, label) =>
@@ -367,6 +390,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
required Map<DateTime, _MonthBucket> monthly, required Map<DateTime, _MonthBucket> monthly,
required List<_LabelSpend> labelSpend, required List<_LabelSpend> labelSpend,
required Map<DateTime, Map<String, double>> labelAmountByPeriod, required Map<DateTime, Map<String, double>> labelAmountByPeriod,
required TxFlowFilter labelFlow,
required String currency, required String currency,
required void Function(DateTime period) onOpenPeriod, required void Function(DateTime period) onOpenPeriod,
required void Function(DateTime period, TxLabel label) onOpenLabel, required void Function(DateTime period, TxLabel label) onOpenLabel,
@@ -390,7 +414,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
final bucket = labelAmountByPeriod[key] ?? const {}; final bucket = labelAmountByPeriod[key] ?? const {};
final rows = <_LabelSpend>[ final rows = <_LabelSpend>[
for (final row in labelSpend) for (final row in labelSpend)
if ((bucket[row.label.id] ?? 0) > 0) if ((bucket[row.label.id] ?? 0) != 0)
_LabelSpend( _LabelSpend(
label: row.label, label: row.label,
amount: bucket[row.label.id]!, amount: bucket[row.label.id]!,
@@ -402,6 +426,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
month: period, month: period,
title: title, title: title,
rows: rows, rows: rows,
flow: labelFlow,
currency: currency, currency: currency,
onOpenPeriod: () => onOpenPeriod(period), onOpenPeriod: () => onOpenPeriod(period),
onOpenLabel: (label) => onOpenLabel(period, label), onOpenLabel: (label) => onOpenLabel(period, label),
@@ -528,6 +553,10 @@ class _LabelAggregates {
} }
/// One pass over labeled amounts: totals, per-period series, and history. /// 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( _LabelAggregates _labelAggregates(
List<BankTransaction> txs, List<BankTransaction> txs,
TxMetaController meta, TxMetaController meta,
@@ -535,16 +564,19 @@ _LabelAggregates _labelAggregates(
_PeriodMode mode, { _PeriodMode mode, {
Set<String> includedLabelIds = const {}, Set<String> includedLabelIds = const {},
TxFlowFilter flow = TxFlowFilter.expense, TxFlowFilter flow = TxFlowFilter.expense,
bool invertExpensePerspective = false,
}) { }) {
final keys = {for (final p in periods) _periodKey(p, mode)}; final keys = {for (final p in periods) _periodKey(p, mode)};
final allByLabel = <String, double>{}; final allByLabel = <String, double>{};
final byPeriod = <DateTime, double>{ final byPeriodSigned = <DateTime, double>{
for (final key in keys) key: 0.0, for (final key in keys) key: 0.0,
}; };
final amountByPeriod = <DateTime, Map<String, double>>{ final amountByPeriodSigned = <DateTime, Map<String, double>>{
for (final key in keys) key: {}, for (final key in keys) key: {},
}; };
final filtering = includedLabelIds.isNotEmpty; final filtering = includedLabelIds.isNotEmpty;
final keepSigned = flow == TxFlowFilter.all;
final invert = keepSigned && invertExpensePerspective;
for (final tx in txs) { for (final tx in txs) {
if (meta.metaFor(tx).internalTransfer) continue; if (meta.metaFor(tx).internalTransfer) continue;
@@ -556,7 +588,7 @@ _LabelAggregates _labelAggregates(
final ids = meta.labelIdsFor(tx); final ids = meta.labelIdsFor(tx);
if (ids.isEmpty) continue; if (ids.isEmpty) continue;
final amount = tx.amount.abs(); final amount = tx.amount;
for (final id in ids) { for (final id in ids) {
allByLabel[id] = (allByLabel[id] ?? 0) + amount; allByLabel[id] = (allByLabel[id] ?? 0) + amount;
@@ -566,19 +598,27 @@ _LabelAggregates _labelAggregates(
!filtering || ids.any(includedLabelIds.contains); !filtering || ids.any(includedLabelIds.contains);
if (!matchesFilter) continue; if (!matchesFilter) continue;
byPeriod[key] = byPeriod[key]! + amount; byPeriodSigned[key] = byPeriodSigned[key]! + amount;
final bucket = amountByPeriod[key]!; final bucket = amountByPeriodSigned[key]!;
for (final id in ids) { for (final id in ids) {
if (filtering && !includedLabelIds.contains(id)) continue; if (filtering && !includedLabelIds.contains(id)) continue;
bucket[id] = (bucket[id] ?? 0) + amount; bucket[id] = (bucket[id] ?? 0) + amount;
} }
} }
double displayAmount(double signed) {
if (!keepSigned) return signed.abs();
return invert ? -signed : signed;
}
final allSpend = <_LabelSpend>[ final allSpend = <_LabelSpend>[
for (final entry in allByLabel.entries) for (final entry in allByLabel.entries)
if (meta.labelById(entry.key) != null) if (meta.labelById(entry.key) != null && entry.value != 0)
_LabelSpend(label: meta.labelById(entry.key)!, amount: entry.value), _LabelSpend(
]..sort((a, b) => b.amount.compareTo(a.amount)); label: meta.labelById(entry.key)!,
amount: displayAmount(entry.value),
),
]..sort((a, b) => b.amount.abs().compareTo(a.amount.abs()));
final spend = !filtering final spend = !filtering
? allSpend ? allSpend
@@ -587,6 +627,19 @@ _LabelAggregates _labelAggregates(
if (includedLabelIds.contains(row.label.id)) row, if (includedLabelIds.contains(row.label.id)) row,
]; ];
final byPeriod = <DateTime, double>{
for (final e in byPeriodSigned.entries)
if (e.value != 0) e.key: displayAmount(e.value),
};
final amountByPeriod = <DateTime, Map<String, double>>{
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( return _LabelAggregates(
spend: spend, spend: spend,
allSpend: allSpend, allSpend: allSpend,
@@ -614,86 +667,42 @@ String _shortMonth(DateTime month) {
} }
String _compactMoney(double value) { String _compactMoney(double value) {
if (value >= 1000) { final abs = value.abs();
final k = value / 1000; final sign = value < 0 ? '-' : '';
return k >= 10 ? '${k.round()}k' : '${k.toStringAsFixed(1)}k'; if (abs >= 1000) {
final k = abs / 1000;
return sign + (k >= 10 ? '${k.round()}k' : '${k.toStringAsFixed(1)}k');
} }
return value.round().toString(); return '$sign${abs.round()}';
} }
class _ChartCard extends StatelessWidget { class _ChartToolbar extends StatelessWidget {
const _ChartCard({ const _ChartToolbar({
required this.period, required this.period,
required this.metric, required this.metric,
required this.periodTitle,
required this.canGoBack,
required this.canGoForward,
required this.onPeriodChanged, required this.onPeriodChanged,
required this.onMetricChanged, required this.onMetricChanged,
required this.onBack,
required this.onForward,
required this.months,
required this.monthly,
required this.labelSpend,
required this.labelAmountByMonth,
required this.currency,
required this.onLabelTap,
required this.onMonthTap,
required this.onPickLabels,
required this.includedLabelCount,
required this.availableLabelCount,
}); });
final _PeriodMode period; final _PeriodMode period;
final _MetricMode metric; final _MetricMode metric;
final String periodTitle;
final bool canGoBack;
final bool canGoForward;
final ValueChanged<_PeriodMode> onPeriodChanged; final ValueChanged<_PeriodMode> onPeriodChanged;
final ValueChanged<_MetricMode> onMetricChanged; final ValueChanged<_MetricMode> onMetricChanged;
final VoidCallback onBack;
final VoidCallback onForward; static const _segmentStyle = ButtonStyle(
final List<DateTime> months; visualDensity: VisualDensity(horizontal: -4, vertical: -3),
final Map<DateTime, _MonthBucket> monthly; tapTargetSize: MaterialTapTargetSize.shrinkWrap,
final List<_LabelSpend> labelSpend; padding: WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 10)),
final Map<DateTime, Map<String, double>> labelAmountByMonth; minimumSize: WidgetStatePropertyAll(Size(0, 32)),
final String currency; );
final void Function(TxLabel label) onLabelTap;
final void Function(DateTime month) onMonthTap;
final VoidCallback onPickLabels;
final int? includedLabelCount;
final int availableLabelCount;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); return Row(
final colors = theme.colorScheme;
return Card(
color: colors.surfaceContainer,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 16),
child: Column(
children: [
Row(
children: [ children: [
SegmentedButton<_PeriodMode>( SegmentedButton<_PeriodMode>(
showSelectedIcon: false, showSelectedIcon: false,
style: const ButtonStyle( style: _segmentStyle,
visualDensity: VisualDensity(
horizontal: -4,
vertical: -3,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 10),
),
minimumSize: WidgetStatePropertyAll(Size(0, 32)),
),
segments: const [ segments: const [
ButtonSegment( ButtonSegment(
value: _PeriodMode.monthly, value: _PeriodMode.monthly,
@@ -709,54 +718,10 @@ class _ChartCard extends StatelessWidget {
onPeriodChanged(selection.first); 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(), const Spacer(),
SegmentedButton<_MetricMode>( SegmentedButton<_MetricMode>(
showSelectedIcon: false, showSelectedIcon: false,
style: const ButtonStyle( style: _segmentStyle,
visualDensity: VisualDensity(
horizontal: -4,
vertical: -3,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 10),
),
minimumSize: WidgetStatePropertyAll(Size(0, 32)),
),
segments: const [ segments: const [
ButtonSegment( ButtonSegment(
value: _MetricMode.flow, value: _MetricMode.flow,
@@ -773,8 +738,101 @@ class _ChartCard extends StatelessWidget {
}, },
), ),
], ],
);
}
}
class _ChartCard extends StatelessWidget {
const _ChartCard({
required this.period,
required this.metric,
required this.periodTitle,
required this.canGoBack,
required this.canGoForward,
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.onPickLabels,
required this.includedLabelCount,
required this.availableLabelCount,
required this.onLabelTap,
required this.onMonthTap,
});
final _PeriodMode period;
final _MetricMode metric;
final String periodTitle;
final bool canGoBack;
final bool canGoForward;
final VoidCallback onBack;
final VoidCallback onForward;
final List<DateTime> months;
final Map<DateTime, _MonthBucket> monthly;
final List<_LabelSpend> labelSpend;
final Map<DateTime, Map<String, double>> labelAmountByMonth;
final LabelChartBarStyle labelBarStyle;
final String currency;
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,
), ),
const SizedBox(height: 8), 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,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 16),
child: Column(
children: [
if (period == _PeriodMode.monthly) if (period == _PeriodMode.monthly)
Row( Row(
children: [ children: [
@@ -815,11 +873,18 @@ class _ChartCard extends StatelessWidget {
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
icon: const Icon(Icons.chevron_right, size: 22), icon: const Icon(Icons.chevron_right, size: 22),
), ),
if (showFilter) ...[
const SizedBox(width: 2),
_filterButton(colors),
],
], ],
) )
else else
Padding( Row(
padding: const EdgeInsets.symmetric(vertical: 4), children: [
// Balance the filter so the title stays centered.
SizedBox(width: showFilter ? 36 : 0),
Expanded(
child: Text( child: Text(
periodTitle, periodTitle,
textAlign: TextAlign.center, textAlign: TextAlign.center,
@@ -828,6 +893,12 @@ class _ChartCard extends StatelessWidget {
), ),
), ),
), ),
if (showFilter)
_filterButton(colors)
else
const SizedBox(width: 0),
],
),
if (metric == _MetricMode.flow) ...[ if (metric == _MetricMode.flow) ...[
const Row( const Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
@@ -879,6 +950,7 @@ class _ChartCard extends StatelessWidget {
amountByMonth: labelAmountByMonth, amountByMonth: labelAmountByMonth,
currency: currency, currency: currency,
period: period, period: period,
barStyle: labelBarStyle,
onMonthTap: onMonthTap, onMonthTap: onMonthTap,
), ),
), ),
@@ -1045,6 +1117,7 @@ class _LabelMonthBars extends StatelessWidget {
required this.amountByMonth, required this.amountByMonth,
required this.currency, required this.currency,
required this.period, required this.period,
required this.barStyle,
required this.onMonthTap, required this.onMonthTap,
}); });
@@ -1055,6 +1128,7 @@ class _LabelMonthBars extends StatelessWidget {
final Map<DateTime, Map<String, double>> amountByMonth; final Map<DateTime, Map<String, double>> amountByMonth;
final String currency; final String currency;
final _PeriodMode period; final _PeriodMode period;
final LabelChartBarStyle barStyle;
final void Function(DateTime month) onMonthTap; final void Function(DateTime month) onMonthTap;
@override @override
@@ -1064,7 +1138,7 @@ class _LabelMonthBars extends StatelessWidget {
if (labelSpend.isEmpty) { if (labelSpend.isEmpty) {
return Center( return Center(
child: Text( 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, style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@@ -1074,6 +1148,7 @@ class _LabelMonthBars extends StatelessWidget {
final topLabels = labelSpend.take(_maxSeriesLabels).toList(growable: false); final topLabels = labelSpend.take(_maxSeriesLabels).toList(growable: false);
final topIds = {for (final row in topLabels) row.label.id}; final topIds = {for (final row in topLabels) row.label.id};
final otherColor = colors.outline; final otherColor = colors.outline;
final stacked = barStyle == LabelChartBarStyle.stacked;
double otherFor(DateTime month) { double otherFor(DateTime month) {
final bucket = final bucket =
@@ -1085,32 +1160,59 @@ class _LabelMonthBars extends StatelessWidget {
return other; 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); final seriesCount = topLabels.length + (hasOther ? 1 : 0);
var maxY = 0.0; var maxY = 0.0;
var minY = 0.0;
for (final month in months) { for (final month in months) {
final bucket = final bucket =
amountByMonth[_periodKey(month, period)] ?? const {}; amountByMonth[_periodKey(month, period)] ?? const {};
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) { for (final row in topLabels) {
final value = bucket[row.label.id] ?? 0; final value = bucket[row.label.id] ?? 0;
if (value > maxY) maxY = value; if (value > maxY) maxY = value;
if (value < minY) minY = value;
} }
final other = otherFor(month); final other = otherFor(month);
if (other > maxY) maxY = other; if (other > maxY) maxY = other;
if (other < minY) minY = other;
} }
final safeMax = maxY <= 0 ? 1.0 : maxY * 1.15; }
if (maxY <= 0) { if (maxY == 0 && minY == 0) {
return Center( return Center(
child: Text( 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, style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center, 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 final rodWidth = stacked
? (period == _PeriodMode.yearly ? 18.0 : 14.0)
: period == _PeriodMode.yearly
? (seriesCount <= 3 ? 12.0 : 8.0) ? (seriesCount <= 3 ? 12.0 : 8.0)
: (seriesCount <= 3 : (seriesCount <= 3
? 7.0 ? 7.0
@@ -1118,8 +1220,84 @@ class _LabelMonthBars extends StatelessWidget {
? 5.0 ? 5.0
: 4.0); : 4.0);
List<BarChartRodData> rodsFor(DateTime month) {
final bucket =
amountByMonth[_periodKey(month, period)] ?? const {};
if (stacked) {
final stackItems = <BarChartRodStackItem>[];
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( return BarChart(
BarChartData( BarChartData(
minY: safeMin,
maxY: safeMax, maxY: safeMax,
groupsSpace: period == _PeriodMode.yearly ? 16 : 10, groupsSpace: period == _PeriodMode.yearly ? 16 : 10,
barTouchData: BarTouchData( barTouchData: BarTouchData(
@@ -1132,6 +1310,32 @@ class _LabelMonthBars extends StatelessWidget {
touchTooltipData: BarTouchTooltipData( touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) { getTooltipItem: (group, groupIndex, rod, rodIndex) {
final month = months[group.x.toInt()]; 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 final name = rodIndex < topLabels.length
? topLabels[rodIndex].label.name ? topLabels[rodIndex].label.name
: 'Other'; : 'Other';
@@ -1152,12 +1356,13 @@ class _LabelMonthBars extends StatelessWidget {
sideTitles: SideTitles( sideTitles: SideTitles(
showTitles: true, showTitles: true,
reservedSize: 42, reservedSize: 42,
interval: safeMax / 3, interval: interval,
getTitlesWidget: (value, meta) { getTitlesWidget: (value, meta) {
if (value <= 0) { if (value.abs() < 0.01) {
return Text('0', style: theme.textTheme.labelSmall); 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 const SizedBox.shrink();
} }
return Text( return Text(
@@ -1195,11 +1400,13 @@ class _LabelMonthBars extends StatelessWidget {
gridData: FlGridData( gridData: FlGridData(
show: true, show: true,
drawVerticalLine: false, drawVerticalLine: false,
horizontalInterval: safeMax / 3, horizontalInterval: interval,
getDrawingHorizontalLine: (value) => FlLine( getDrawingHorizontalLine: (value) => FlLine(
color: colors.outlineVariant.withValues(alpha: 0.35), color: value.abs() < 0.01
strokeWidth: 1, ? colors.outline.withValues(alpha: 0.55)
dashArray: const [4, 4], : 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), borderData: FlBorderData(show: false),
@@ -1207,27 +1414,8 @@ class _LabelMonthBars extends StatelessWidget {
for (var i = 0; i < months.length; i++) for (var i = 0; i < months.length; i++)
BarChartGroupData( BarChartGroupData(
x: i, x: i,
barsSpace: 2, barsSpace: stacked ? 0 : 2,
barRods: [ barRods: rodsFor(months[i]),
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,
),
],
), ),
], ],
), ),
@@ -1423,6 +1611,7 @@ class _LabelHistoryCard extends StatelessWidget {
required this.month, required this.month,
required this.title, required this.title,
required this.rows, required this.rows,
required this.flow,
required this.currency, required this.currency,
required this.onOpenPeriod, required this.onOpenPeriod,
required this.onOpenLabel, required this.onOpenLabel,
@@ -1431,10 +1620,28 @@ class _LabelHistoryCard extends StatelessWidget {
final DateTime month; final DateTime month;
final String title; final String title;
final List<_LabelSpend> rows; final List<_LabelSpend> rows;
final TxFlowFilter flow;
final String currency; final String currency;
final VoidCallback onOpenPeriod; final VoidCallback onOpenPeriod;
final void Function(TxLabel label) onOpenLabel; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -1442,6 +1649,7 @@ class _LabelHistoryCard extends StatelessWidget {
final isYear = title.length == 4 && int.tryParse(title) != null; final isYear = title.length == 4 && int.tryParse(title) != null;
final periodLabel = isYear ? title : formatMonthYear(month); final periodLabel = isYear ? title : formatMonthYear(month);
final total = rows.fold<double>(0, (sum, row) => sum + row.amount); final total = rows.fold<double>(0, (sum, row) => sum + row.amount);
final totalStyle = _styleFor(total);
return Card( return Card(
color: colors.surfaceContainer, color: colors.surfaceContainer,
@@ -1483,9 +1691,9 @@ class _LabelHistoryCard extends StatelessWidget {
), ),
), ),
Text( Text(
formatMoney(-total, currency), formatMoney(totalStyle.$1, currency),
style: theme.textTheme.titleSmall?.copyWith( style: theme.textTheme.titleSmall?.copyWith(
color: _kExpenseColor, color: totalStyle.$2,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
@@ -1524,12 +1732,17 @@ class _LabelHistoryCard extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
Text( Builder(
formatMoney(-rows[i].amount, currency), builder: (context) {
final style = _styleFor(rows[i].amount);
return Text(
formatMoney(style.$1, currency),
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: _kExpenseColor, color: style.$2,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
);
},
), ),
], ],
), ),
+98 -4
View File
@@ -5,16 +5,28 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../providers/sync_provider.dart';
import '../screens/account_screen.dart'; import '../screens/account_screen.dart';
import '../screens/login_screen.dart'; import '../screens/login_screen.dart';
import '../screens/settings_screen.dart'; import '../screens/settings_screen.dart';
Widget authAppBarAction(BuildContext context) { Widget authAppBarAction(BuildContext context) {
final auth = context.watch<AuthController>(); final auth = context.watch<AuthController>();
final sync = context.watch<SyncController>();
final showRing = sync.showProgressRing;
final progress = sync.syncProgress;
final success = sync.progressSuccess;
final syncActive = sync.isSyncing || showRing;
if (auth.isLoggedIn) { if (auth.isLoggedIn) {
final ringColor = success
? const Color(0xFF16A34A)
: Theme.of(context).colorScheme.primary;
return PopupMenuButton<_AccountMenuAction>( return PopupMenuButton<_AccountMenuAction>(
tooltip: auth.displayName, tooltip: sync.isSyncing
? 'Syncing… ${(progress * 100).round()}%'
: auth.displayName,
offset: const Offset(0, 48), offset: const Offset(0, 48),
onSelected: (action) async { onSelected: (action) async {
switch (action) { switch (action) {
@@ -28,6 +40,9 @@ Widget authAppBarAction(BuildContext context) {
}, },
itemBuilder: (context) { itemBuilder: (context) {
final email = auth.email; 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 [ return [
if (email != null) if (email != null)
PopupMenuItem<_AccountMenuAction>( PopupMenuItem<_AccountMenuAction>(
@@ -37,6 +52,11 @@ Widget authAppBarAction(BuildContext context) {
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
), ),
if (showSyncRow)
const PopupMenuItem<_AccountMenuAction>(
enabled: false,
child: _SyncProgressMenuTile(),
),
const PopupMenuItem<_AccountMenuAction>( const PopupMenuItem<_AccountMenuAction>(
value: _AccountMenuAction.account, value: _AccountMenuAction.account,
child: ListTile( child: ListTile(
@@ -57,16 +77,40 @@ Widget authAppBarAction(BuildContext context) {
}, },
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: CircleAvatar( child: SizedBox(
width: 32,
height: 32,
child: Stack(
alignment: Alignment.center,
children: [
CircleAvatar(
radius: 16, radius: 16,
backgroundColor: Theme.of(context).colorScheme.primaryContainer, backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Text( child: Text(
auth.initials, auth.initials,
style: Theme.of(context).textTheme.labelLarge?.copyWith( style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer, 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<SyncController>();
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 } enum _AccountMenuAction { account, logout }
List<Widget> bankAppBarActions({ List<Widget> bankAppBarActions({
@@ -96,7 +190,6 @@ List<Widget> bankAppBarActions({
onPressed: refreshing ? null : onRefresh, onPressed: refreshing ? null : onRefresh,
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
), ),
authAppBarAction(context),
IconButton( IconButton(
tooltip: 'Settings', tooltip: 'Settings',
onPressed: () { onPressed: () {
@@ -106,5 +199,6 @@ List<Widget> bankAppBarActions({
}, },
icon: const Icon(Icons.settings_outlined), icon: const Icon(Icons.settings_outlined),
), ),
authAppBarAction(context),
]; ];
} }
+147 -40
View File
@@ -35,15 +35,30 @@ class TransactionFilterSelection {
query.trim().isNotEmpty; 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. /// Result of the simple label multi-select dialog.
class LabelFilterSelection { class LabelFilterSelection {
const LabelFilterSelection({ const LabelFilterSelection({
required this.labelIds, required this.labelIds,
this.flow = TxFlowFilter.all, this.flow = TxFlowFilter.all,
this.barStyle = LabelChartBarStyle.parallel,
this.invertExpensePerspective = false,
}); });
final Set<String> labelIds; final Set<String> labelIds;
final TxFlowFilter flow; 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. /// Returns the selection, or `null` if cancelled.
@@ -58,6 +73,10 @@ Future<LabelFilterSelection?> showLabelFilterDialog(
bool allowCreate = false, bool allowCreate = false,
bool includeFlow = false, bool includeFlow = false,
TxFlowFilter initialFlow = TxFlowFilter.all, TxFlowFilter initialFlow = TxFlowFilter.all,
bool includeBarStyle = false,
LabelChartBarStyle initialBarStyle = LabelChartBarStyle.parallel,
bool includeInvertChart = false,
bool initialInvertChart = false,
}) { }) {
return showDialog<LabelFilterSelection>( return showDialog<LabelFilterSelection>(
context: context, context: context,
@@ -72,6 +91,10 @@ Future<LabelFilterSelection?> showLabelFilterDialog(
allowCreate: allowCreate, allowCreate: allowCreate,
includeFlow: includeFlow, includeFlow: includeFlow,
initialFlow: initialFlow, initialFlow: initialFlow,
includeBarStyle: includeBarStyle,
initialBarStyle: initialBarStyle,
includeInvertChart: includeInvertChart,
initialInvertChart: initialInvertChart,
); );
}, },
); );
@@ -126,6 +149,10 @@ class _SimpleLabelFilterDialog extends StatefulWidget {
required this.allowCreate, required this.allowCreate,
required this.includeFlow, required this.includeFlow,
required this.initialFlow, required this.initialFlow,
required this.includeBarStyle,
required this.initialBarStyle,
required this.includeInvertChart,
required this.initialInvertChart,
}); });
final List<TxLabel> available; final List<TxLabel> available;
@@ -137,6 +164,10 @@ class _SimpleLabelFilterDialog extends StatefulWidget {
final bool allowCreate; final bool allowCreate;
final bool includeFlow; final bool includeFlow;
final TxFlowFilter initialFlow; final TxFlowFilter initialFlow;
final bool includeBarStyle;
final LabelChartBarStyle initialBarStyle;
final bool includeInvertChart;
final bool initialInvertChart;
@override @override
State<_SimpleLabelFilterDialog> createState() => State<_SimpleLabelFilterDialog> createState() =>
@@ -146,6 +177,8 @@ class _SimpleLabelFilterDialog extends StatefulWidget {
class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> { class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
late final Set<String> _selected; late final Set<String> _selected;
late TxFlowFilter _flow; late TxFlowFilter _flow;
late LabelChartBarStyle _barStyle;
late bool _invertChart;
late final TextEditingController _labelSearchController; late final TextEditingController _labelSearchController;
@override @override
@@ -153,6 +186,8 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
super.initState(); super.initState();
_selected = Set<String>.of(widget.initiallySelected); _selected = Set<String>.of(widget.initiallySelected);
_flow = widget.initialFlow; _flow = widget.initialFlow;
_barStyle = widget.initialBarStyle;
_invertChart = widget.initialInvertChart;
_labelSearchController = TextEditingController(); _labelSearchController = TextEditingController();
} }
@@ -208,14 +243,29 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
kUncategorizedFilterLabel.name.toLowerCase().contains(needle)); kUncategorizedFilterLabel.name.toLowerCase().contains(needle));
final media = MediaQuery.of(context); final media = MediaQuery.of(context);
final maxContentHeight = final maxContentHeight =
((media.size.height - media.viewInsets.bottom) * 0.55) ((media.size.height - media.viewInsets.bottom) * 0.72)
.clamp(240.0, 560.0); .clamp(320.0, 720.0);
final visibleIds = <String>[
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( return AlertDialog(
title: Text(widget.title), title: Text(widget.title),
content: SizedBox( content: SizedBox(
width: double.maxFinite, width: double.maxFinite,
height: maxContentHeight, height: maxContentHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxContentHeight * 0.42),
child: SingleChildScrollView(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -254,9 +304,69 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
), ),
], ],
), ),
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<LabelChartBarStyle>(
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), const SizedBox(height: 10),
TextField( 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, controller: _labelSearchController,
decoration: InputDecoration( decoration: InputDecoration(
isDense: true, isDense: true,
@@ -274,17 +384,31 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
), ),
onChanged: (_) => setState(() {}), onChanged: (_) => setState(() {}),
), ),
if (widget.allowCreate) ...[ ),
const SizedBox(height: 8), Tooltip(
Align( message: allVisibleSelected ? 'Deselect all' : 'Select all',
alignment: Alignment.centerLeft, child: Checkbox(
child: TextButton.icon( tristate: true,
onPressed: _createLabel, value: allVisibleSelected
icon: const Icon(Icons.add, size: 18), ? true
label: const Text('New label'), : noneVisibleSelected
? false
: null,
onChanged: visibleIds.isEmpty
? null
: (_) {
setState(() {
if (allVisibleSelected) {
_selected.removeAll(visibleIds);
} else {
_selected.addAll(visibleIds);
}
});
},
), ),
), ),
], ],
),
const SizedBox(height: 8), const SizedBox(height: 8),
Expanded( Expanded(
child: _LabelChecklist( child: _LabelChecklist(
@@ -302,51 +426,34 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
actions: [ actions: [
SizedBox( SizedBox(
width: double.maxFinite, width: double.maxFinite,
child: Column( child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ 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( Expanded(
child: TextButton( child: TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
), ),
], const SizedBox(width: 8),
), Expanded(
const SizedBox(height: 4), flex: 2,
FilledButton( child: FilledButton(
onPressed: () => Navigator.pop( onPressed: () => Navigator.pop(
context, context,
LabelFilterSelection( LabelFilterSelection(
labelIds: Set<String>.of(_selected), labelIds: Set<String>.of(_selected),
flow: widget.includeFlow ? _flow : TxFlowFilter.all, 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'),
), ),
),
], ],
), ),
), ),
@@ -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 (_) {}
}
});
+18 -18
View File
@@ -5,6 +5,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:myapp/models/bank.dart'; import 'package:myapp/models/bank.dart';
import 'package:myapp/providers/auth_provider.dart'; import 'package:myapp/providers/auth_provider.dart';
import 'package:myapp/providers/bank_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/theme_provider.dart';
import 'package:myapp/providers/tx_meta_provider.dart'; import 'package:myapp/providers/tx_meta_provider.dart';
import 'package:myapp/screens/main_shell.dart'; import 'package:myapp/screens/main_shell.dart';
@@ -27,6 +28,7 @@ void main() {
late StorageService storage; late StorageService storage;
late AuthController auth; late AuthController auth;
late SyncController sync;
late BankController bank; late BankController bank;
late TxMetaController meta; late TxMetaController meta;
@@ -39,8 +41,21 @@ void main() {
await auth.init(); await auth.init();
bank = BankController(storage: storage); bank = BankController(storage: storage);
meta = TxMetaController(storage); meta = TxMetaController(storage);
sync = SyncController(auth: auth, meta: meta);
}); });
tearDown(() {
sync.dispose();
});
List<ChangeNotifierProvider> appProviders() => [
ChangeNotifierProvider<AuthController>.value(value: auth),
ChangeNotifierProvider<SyncController>.value(value: sync),
ChangeNotifierProvider(create: (_) => ThemeController(storage)),
ChangeNotifierProvider<TxMetaController>.value(value: meta),
ChangeNotifierProvider<BankController>.value(value: bank),
];
testWidgets('main shell + detail + filter', (tester) async { testWidgets('main shell + detail + filter', (tester) async {
FlutterError.onError = (d) => fail('${d.exceptionAsString()}\n${d.stack}'); FlutterError.onError = (d) => fail('${d.exceptionAsString()}\n${d.stack}');
@@ -63,12 +78,7 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
MultiProvider( MultiProvider(
providers: [ providers: appProviders(),
ChangeNotifierProvider<AuthController>.value(value: auth),
ChangeNotifierProvider(create: (_) => ThemeController(storage)),
ChangeNotifierProvider<TxMetaController>.value(value: meta),
ChangeNotifierProvider<BankController>.value(value: bank),
],
child: MaterialApp( child: MaterialApp(
theme: AppTheme.light(), theme: AppTheme.light(),
darkTheme: AppTheme.dark(), darkTheme: AppTheme.dark(),
@@ -83,12 +93,7 @@ void main() {
// Open filter via transactions - go to a route // Open filter via transactions - go to a route
await tester.pumpWidget( await tester.pumpWidget(
MultiProvider( MultiProvider(
providers: [ providers: appProviders(),
ChangeNotifierProvider<AuthController>.value(value: auth),
ChangeNotifierProvider(create: (_) => ThemeController(storage)),
ChangeNotifierProvider<TxMetaController>.value(value: meta),
ChangeNotifierProvider<BankController>.value(value: bank),
],
child: MaterialApp( child: MaterialApp(
theme: AppTheme.light(), theme: AppTheme.light(),
darkTheme: AppTheme.dark(), darkTheme: AppTheme.dark(),
@@ -126,12 +131,7 @@ void main() {
await meta.setInternalTransfer(sample, true); await meta.setInternalTransfer(sample, true);
await tester.pumpWidget( await tester.pumpWidget(
MultiProvider( MultiProvider(
providers: [ providers: appProviders(),
ChangeNotifierProvider<AuthController>.value(value: auth),
ChangeNotifierProvider(create: (_) => ThemeController(storage)),
ChangeNotifierProvider<TxMetaController>.value(value: meta),
ChangeNotifierProvider<BankController>.value(value: bank),
],
child: MaterialApp( child: MaterialApp(
theme: AppTheme.light(), theme: AppTheme.light(),
darkTheme: AppTheme.dark(), darkTheme: AppTheme.dark(),
+9
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:myapp/providers/auth_provider.dart'; import 'package:myapp/providers/auth_provider.dart';
import 'package:myapp/providers/bank_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/theme_provider.dart';
import 'package:myapp/providers/tx_meta_provider.dart'; import 'package:myapp/providers/tx_meta_provider.dart';
import 'package:myapp/screens/overview_screen.dart'; import 'package:myapp/screens/overview_screen.dart';
@@ -17,6 +18,7 @@ void main() {
late StorageService storage; late StorageService storage;
late AuthController auth; late AuthController auth;
late SyncController sync;
late BankController bank; late BankController bank;
late TxMetaController meta; late TxMetaController meta;
@@ -31,6 +33,11 @@ void main() {
// seed a couple txs // seed a couple txs
// ignore: invalid_use_of_visible_for_testing_member // ignore: invalid_use_of_visible_for_testing_member
meta = TxMetaController(storage); meta = TxMetaController(storage);
sync = SyncController(auth: auth, meta: meta);
});
tearDown(() {
sync.dispose();
}); });
testWidgets('overview builds dark', (tester) async { testWidgets('overview builds dark', (tester) async {
@@ -41,6 +48,7 @@ void main() {
MultiProvider( MultiProvider(
providers: [ providers: [
ChangeNotifierProvider<AuthController>.value(value: auth), ChangeNotifierProvider<AuthController>.value(value: auth),
ChangeNotifierProvider<SyncController>.value(value: sync),
ChangeNotifierProvider(create: (_) => ThemeController(storage)), ChangeNotifierProvider(create: (_) => ThemeController(storage)),
ChangeNotifierProvider<TxMetaController>.value(value: meta), ChangeNotifierProvider<TxMetaController>.value(value: meta),
ChangeNotifierProvider<BankController>.value(value: bank), ChangeNotifierProvider<BankController>.value(value: bank),
@@ -64,6 +72,7 @@ void main() {
MultiProvider( MultiProvider(
providers: [ providers: [
ChangeNotifierProvider<AuthController>.value(value: auth), ChangeNotifierProvider<AuthController>.value(value: auth),
ChangeNotifierProvider<SyncController>.value(value: sync),
ChangeNotifierProvider(create: (_) => ThemeController(storage)), ChangeNotifierProvider(create: (_) => ThemeController(storage)),
ChangeNotifierProvider<TxMetaController>.value(value: meta), ChangeNotifierProvider<TxMetaController>.value(value: meta),
ChangeNotifierProvider<BankController>.value(value: bank), ChangeNotifierProvider<BankController>.value(value: bank),