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/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<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -45,12 +47,16 @@ class MyApp extends StatefulWidget {
class _MyAppState extends State<MyApp> {
late final BankController _bank;
late final TxMetaController _meta;
late final SyncController _sync;
late final AppLinks _appLinks;
StreamSubscription<Uri>? _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<MyApp> {
@override
void dispose() {
_linkSub?.cancel();
_sync.dispose();
_bank.dispose();
super.dispose();
}
@@ -89,7 +96,8 @@ class _MyAppState extends State<MyApp> {
providers: [
ChangeNotifierProvider<AuthController>.value(value: widget.auth),
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),
],
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(),
);
}
+60 -1
View File
@@ -25,6 +25,9 @@ class TxMetaController extends ChangeNotifier {
final StorageService _storage;
/// Invoked after a successful local persist (used by [SyncController]).
VoidCallback? onPersisted;
Map<String, TxMeta> _byId = {};
Map<String, List<String>> _labelsByMerchant = {};
Map<String, String> _notesByMerchant = {};
@@ -32,6 +35,61 @@ class TxMetaController extends ChangeNotifier {
List<TxLabel> _customLabels = [];
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.
List<TxLabel> get allLabels => [...kBuiltinLabels, ..._customLabels];
@@ -759,7 +817,7 @@ class TxMetaController extends ChangeNotifier {
unawaited(_writeStore());
}
Future<void> _writeStore() async {
Future<void> _writeStore({bool notifySync = true}) async {
// Let the busy indicator / label checkbox paint before JSON work.
await Future<void>.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');
}
+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;
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<AuthController>();
final sync = context.watch<SyncController>();
final meta = context.watch<TxMetaController>();
final bank = context.watch<BankController>();
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';
}
}
+374 -161
View File
@@ -45,6 +45,12 @@ class _OverviewScreenState extends State<OverviewScreen> {
/// 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<OverviewScreen> {
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<OverviewScreen> {
_period,
includedLabelIds: _includedLabelIds,
flow: _labelFlow,
invertExpensePerspective: _labelInvertChart,
);
labelSpend = labels.spend;
labelAmountByPeriod = labels.amountByPeriod;
@@ -243,21 +257,28 @@ class _OverviewScreenState extends State<OverviewScreen> {
),
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<OverviewScreen> {
? meta.allLabels.length
: _includedLabelIds.length),
availableLabelCount: meta.allLabels.length,
onPickLabels: () => _pickChartLabels(context),
onLabelTap: (label) {
Navigator.of(context).push(
MaterialPageRoute<void>(
@@ -313,7 +333,9 @@ class _OverviewScreenState extends State<OverviewScreen> {
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<OverviewScreen> {
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<OverviewScreen> {
required Map<DateTime, _MonthBucket> monthly,
required List<_LabelSpend> labelSpend,
required Map<DateTime, Map<String, double>> 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<OverviewScreen> {
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<OverviewScreen> {
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<BankTransaction> txs,
TxMetaController meta,
@@ -535,16 +564,19 @@ _LabelAggregates _labelAggregates(
_PeriodMode mode, {
Set<String> includedLabelIds = const {},
TxFlowFilter flow = TxFlowFilter.expense,
bool invertExpensePerspective = false,
}) {
final keys = {for (final p in periods) _periodKey(p, mode)};
final allByLabel = <String, double>{};
final byPeriod = <DateTime, double>{
final byPeriodSigned = <DateTime, double>{
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: {},
};
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 = <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(
spend: spend,
allSpend: allSpend,
@@ -614,86 +667,42 @@ 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 value.round().toString();
return '$sign${abs.round()}';
}
class _ChartCard extends StatelessWidget {
const _ChartCard({
class _ChartToolbar extends StatelessWidget {
const _ChartToolbar({
required this.period,
required this.metric,
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.currency,
required this.onLabelTap,
required this.onMonthTap,
required this.onPickLabels,
required this.includedLabelCount,
required this.availableLabelCount,
});
final _PeriodMode period;
final _MetricMode metric;
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<DateTime> months;
final Map<DateTime, _MonthBucket> monthly;
final List<_LabelSpend> labelSpend;
final Map<DateTime, Map<String, double>> labelAmountByMonth;
final String currency;
final void Function(TxLabel label) onLabelTap;
final void Function(DateTime month) onMonthTap;
final VoidCallback onPickLabels;
final int? includedLabelCount;
final int availableLabelCount;
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) {
final theme = Theme.of(context);
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(
return 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)),
),
style: _segmentStyle,
segments: const [
ButtonSegment(
value: _PeriodMode.monthly,
@@ -709,54 +718,10 @@ class _ChartCard extends StatelessWidget {
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)),
),
style: _segmentStyle,
segments: const [
ButtonSegment(
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)
Row(
children: [
@@ -815,11 +873,18 @@ 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),
Row(
children: [
// Balance the filter so the title stays centered.
SizedBox(width: showFilter ? 36 : 0),
Expanded(
child: Text(
periodTitle,
textAlign: TextAlign.center,
@@ -828,6 +893,12 @@ class _ChartCard extends StatelessWidget {
),
),
),
if (showFilter)
_filterButton(colors)
else
const SizedBox(width: 0),
],
),
if (metric == _MetricMode.flow) ...[
const Row(
mainAxisAlignment: MainAxisAlignment.end,
@@ -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<DateTime, Map<String, double>> 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,32 +1160,59 @@ 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 {};
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 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
final rodWidth = stacked
? (period == _PeriodMode.yearly ? 18.0 : 14.0)
: period == _PeriodMode.yearly
? (seriesCount <= 3 ? 12.0 : 8.0)
: (seriesCount <= 3
? 7.0
@@ -1118,8 +1220,84 @@ class _LabelMonthBars extends StatelessWidget {
? 5.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(
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<double>(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),
Builder(
builder: (context) {
final style = _styleFor(rows[i].amount);
return Text(
formatMoney(style.$1, currency),
style: theme.textTheme.bodyMedium?.copyWith(
color: _kExpenseColor,
color: style.$2,
fontWeight: FontWeight.w600,
),
);
},
),
],
),
+98 -4
View File
@@ -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<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) {
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,16 +77,40 @@ Widget authAppBarAction(BuildContext context) {
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: CircleAvatar(
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,
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 }
List<Widget> bankAppBarActions({
@@ -96,7 +190,6 @@ List<Widget> bankAppBarActions({
onPressed: refreshing ? null : onRefresh,
icon: const Icon(Icons.refresh),
),
authAppBarAction(context),
IconButton(
tooltip: 'Settings',
onPressed: () {
@@ -106,5 +199,6 @@ List<Widget> bankAppBarActions({
},
icon: const Icon(Icons.settings_outlined),
),
authAppBarAction(context),
];
}
+147 -40
View File
@@ -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<String> 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<LabelFilterSelection?> 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<LabelFilterSelection>(
context: context,
@@ -72,6 +91,10 @@ Future<LabelFilterSelection?> 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<TxLabel> 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<String> _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<String>.of(widget.initiallySelected);
_flow = widget.initialFlow;
_barStyle = widget.initialBarStyle;
_invertChart = widget.initialInvertChart;
_labelSearchController = TextEditingController();
}
@@ -208,14 +243,29 @@ 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 = <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(
title: Text(widget.title),
content: SizedBox(
width: double.maxFinite,
height: maxContentHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxContentHeight * 0.42),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
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),
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,
decoration: InputDecoration(
isDense: true,
@@ -274,17 +384,31 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
),
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'),
),
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,51 +426,34 @@ 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(
const SizedBox(width: 8),
Expanded(
flex: 2,
child: FilledButton(
onPressed: () => Navigator.pop(
context,
LabelFilterSelection(
labelIds: Set<String>.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'),
),
),
],
),
),
@@ -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/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<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 {
FlutterError.onError = (d) => fail('${d.exceptionAsString()}\n${d.stack}');
@@ -63,12 +78,7 @@ void main() {
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<AuthController>.value(value: auth),
ChangeNotifierProvider(create: (_) => ThemeController(storage)),
ChangeNotifierProvider<TxMetaController>.value(value: meta),
ChangeNotifierProvider<BankController>.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<AuthController>.value(value: auth),
ChangeNotifierProvider(create: (_) => ThemeController(storage)),
ChangeNotifierProvider<TxMetaController>.value(value: meta),
ChangeNotifierProvider<BankController>.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<AuthController>.value(value: auth),
ChangeNotifierProvider(create: (_) => ThemeController(storage)),
ChangeNotifierProvider<TxMetaController>.value(value: meta),
ChangeNotifierProvider<BankController>.value(value: bank),
],
providers: appProviders(),
child: MaterialApp(
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
+9
View File
@@ -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<AuthController>.value(value: auth),
ChangeNotifierProvider<SyncController>.value(value: sync),
ChangeNotifierProvider(create: (_) => ThemeController(storage)),
ChangeNotifierProvider<TxMetaController>.value(value: meta),
ChangeNotifierProvider<BankController>.value(value: bank),
@@ -64,6 +72,7 @@ void main() {
MultiProvider(
providers: [
ChangeNotifierProvider<AuthController>.value(value: auth),
ChangeNotifierProvider<SyncController>.value(value: sync),
ChangeNotifierProvider(create: (_) => ThemeController(storage)),
ChangeNotifierProvider<TxMetaController>.value(value: meta),
ChangeNotifierProvider<BankController>.value(value: bank),