sync transactions with pocketbase
This commit is contained in:
+14
-1
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
+396
-183
@@ -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,11 +667,79 @@ String _shortMonth(DateTime month) {
|
||||
}
|
||||
|
||||
String _compactMoney(double value) {
|
||||
if (value >= 1000) {
|
||||
final k = value / 1000;
|
||||
return k >= 10 ? '${k.round()}k' : '${k.toStringAsFixed(1)}k';
|
||||
final abs = value.abs();
|
||||
final sign = value < 0 ? '-' : '';
|
||||
if (abs >= 1000) {
|
||||
final k = abs / 1000;
|
||||
return sign + (k >= 10 ? '${k.round()}k' : '${k.toStringAsFixed(1)}k');
|
||||
}
|
||||
return '$sign${abs.round()}';
|
||||
}
|
||||
|
||||
class _ChartToolbar extends StatelessWidget {
|
||||
const _ChartToolbar({
|
||||
required this.period,
|
||||
required this.metric,
|
||||
required this.onPeriodChanged,
|
||||
required this.onMetricChanged,
|
||||
});
|
||||
|
||||
final _PeriodMode period;
|
||||
final _MetricMode metric;
|
||||
final ValueChanged<_PeriodMode> onPeriodChanged;
|
||||
final ValueChanged<_MetricMode> onMetricChanged;
|
||||
|
||||
static const _segmentStyle = ButtonStyle(
|
||||
visualDensity: VisualDensity(horizontal: -4, vertical: -3),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
padding: WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 10)),
|
||||
minimumSize: WidgetStatePropertyAll(Size(0, 32)),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
SegmentedButton<_PeriodMode>(
|
||||
showSelectedIcon: false,
|
||||
style: _segmentStyle,
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: _PeriodMode.monthly,
|
||||
label: Text('Month'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _PeriodMode.yearly,
|
||||
label: Text('Year'),
|
||||
),
|
||||
],
|
||||
selected: {period},
|
||||
onSelectionChanged: (selection) {
|
||||
onPeriodChanged(selection.first);
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
SegmentedButton<_MetricMode>(
|
||||
showSelectedIcon: false,
|
||||
style: _segmentStyle,
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: _MetricMode.flow,
|
||||
label: Text('Flow'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _MetricMode.labels,
|
||||
label: Text('Labels'),
|
||||
),
|
||||
],
|
||||
selected: {metric},
|
||||
onSelectionChanged: (selection) {
|
||||
onMetricChanged(selection.first);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return value.round().toString();
|
||||
}
|
||||
|
||||
class _ChartCard extends StatelessWidget {
|
||||
@@ -628,20 +749,19 @@ class _ChartCard extends StatelessWidget {
|
||||
required this.periodTitle,
|
||||
required this.canGoBack,
|
||||
required this.canGoForward,
|
||||
required this.onPeriodChanged,
|
||||
required this.onMetricChanged,
|
||||
required this.onBack,
|
||||
required this.onForward,
|
||||
required this.months,
|
||||
required this.monthly,
|
||||
required this.labelSpend,
|
||||
required this.labelAmountByMonth,
|
||||
required this.labelBarStyle,
|
||||
required this.currency,
|
||||
required this.onLabelTap,
|
||||
required this.onMonthTap,
|
||||
required this.onPickLabels,
|
||||
required this.includedLabelCount,
|
||||
required this.availableLabelCount,
|
||||
required this.onLabelTap,
|
||||
required this.onMonthTap,
|
||||
});
|
||||
|
||||
final _PeriodMode period;
|
||||
@@ -649,25 +769,59 @@ class _ChartCard extends StatelessWidget {
|
||||
final String periodTitle;
|
||||
final bool canGoBack;
|
||||
final bool canGoForward;
|
||||
final ValueChanged<_PeriodMode> onPeriodChanged;
|
||||
final ValueChanged<_MetricMode> onMetricChanged;
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback onForward;
|
||||
final List<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 void Function(TxLabel label) onLabelTap;
|
||||
final void Function(DateTime month) onMonthTap;
|
||||
final VoidCallback onPickLabels;
|
||||
final int? includedLabelCount;
|
||||
final int availableLabelCount;
|
||||
final void Function(TxLabel label) onLabelTap;
|
||||
final void Function(DateTime month) onMonthTap;
|
||||
|
||||
Widget _filterButton(ColorScheme colors) {
|
||||
return IconButton(
|
||||
tooltip: includedLabelCount == null
|
||||
? 'Filter labels ($availableLabelCount)'
|
||||
: 'Filter labels ($includedLabelCount/$availableLabelCount)',
|
||||
onPressed: onPickLabels,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -4,
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: includedLabelCount == null
|
||||
? colors.surfaceContainerHigh
|
||||
: colors.primary.withValues(alpha: 0.22),
|
||||
foregroundColor: includedLabelCount == null
|
||||
? colors.onSurfaceVariant
|
||||
: colors.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
icon: Badge(
|
||||
isLabelVisible: includedLabelCount != null,
|
||||
smallSize: 6,
|
||||
child: const Icon(Icons.filter_list, size: 20),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final showFilter = metric == _MetricMode.labels;
|
||||
|
||||
return Card(
|
||||
color: colors.surfaceContainer,
|
||||
@@ -676,105 +830,9 @@ class _ChartCard extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 14, 14, 16),
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
SegmentedButton<_PeriodMode>(
|
||||
showSelectedIcon: false,
|
||||
style: const ButtonStyle(
|
||||
visualDensity: VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -3,
|
||||
),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
padding: WidgetStatePropertyAll(
|
||||
EdgeInsets.symmetric(horizontal: 10),
|
||||
),
|
||||
minimumSize: WidgetStatePropertyAll(Size(0, 32)),
|
||||
),
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: _PeriodMode.monthly,
|
||||
label: Text('Month'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _PeriodMode.yearly,
|
||||
label: Text('Year'),
|
||||
),
|
||||
],
|
||||
selected: {period},
|
||||
onSelectionChanged: (selection) {
|
||||
onPeriodChanged(selection.first);
|
||||
},
|
||||
),
|
||||
if (metric == _MetricMode.labels) ...[
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
tooltip: includedLabelCount == null
|
||||
? 'Filter labels ($availableLabelCount)'
|
||||
: 'Filter labels ($includedLabelCount/$availableLabelCount)',
|
||||
onPressed: onPickLabels,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -4,
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: includedLabelCount == null
|
||||
? colors.surfaceContainerHigh
|
||||
: colors.primary.withValues(alpha: 0.22),
|
||||
foregroundColor: includedLabelCount == null
|
||||
? colors.onSurfaceVariant
|
||||
: colors.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
icon: Badge(
|
||||
isLabelVisible: includedLabelCount != null,
|
||||
smallSize: 6,
|
||||
child: const Icon(Icons.filter_list, size: 18),
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
SegmentedButton<_MetricMode>(
|
||||
showSelectedIcon: false,
|
||||
style: const ButtonStyle(
|
||||
visualDensity: VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -3,
|
||||
),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
padding: WidgetStatePropertyAll(
|
||||
EdgeInsets.symmetric(horizontal: 10),
|
||||
),
|
||||
minimumSize: WidgetStatePropertyAll(Size(0, 32)),
|
||||
),
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: _MetricMode.flow,
|
||||
label: Text('Flow'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _MetricMode.labels,
|
||||
label: Text('Labels'),
|
||||
),
|
||||
],
|
||||
selected: {metric},
|
||||
onSelectionChanged: (selection) {
|
||||
onMetricChanged(selection.first);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (period == _PeriodMode.monthly)
|
||||
Row(
|
||||
children: [
|
||||
@@ -815,18 +873,31 @@ class _ChartCard extends StatelessWidget {
|
||||
padding: EdgeInsets.zero,
|
||||
icon: const Icon(Icons.chevron_right, size: 22),
|
||||
),
|
||||
if (showFilter) ...[
|
||||
const SizedBox(width: 2),
|
||||
_filterButton(colors),
|
||||
],
|
||||
],
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Text(
|
||||
periodTitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
Row(
|
||||
children: [
|
||||
// Balance the filter so the title stays centered.
|
||||
SizedBox(width: showFilter ? 36 : 0),
|
||||
Expanded(
|
||||
child: Text(
|
||||
periodTitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showFilter)
|
||||
_filterButton(colors)
|
||||
else
|
||||
const SizedBox(width: 0),
|
||||
],
|
||||
),
|
||||
if (metric == _MetricMode.flow) ...[
|
||||
const Row(
|
||||
@@ -879,6 +950,7 @@ class _ChartCard extends StatelessWidget {
|
||||
amountByMonth: labelAmountByMonth,
|
||||
currency: currency,
|
||||
period: period,
|
||||
barStyle: labelBarStyle,
|
||||
onMonthTap: onMonthTap,
|
||||
),
|
||||
),
|
||||
@@ -1045,6 +1117,7 @@ class _LabelMonthBars extends StatelessWidget {
|
||||
required this.amountByMonth,
|
||||
required this.currency,
|
||||
required this.period,
|
||||
required this.barStyle,
|
||||
required this.onMonthTap,
|
||||
});
|
||||
|
||||
@@ -1055,6 +1128,7 @@ class _LabelMonthBars extends StatelessWidget {
|
||||
final Map<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,41 +1160,144 @@ class _LabelMonthBars extends StatelessWidget {
|
||||
return other;
|
||||
}
|
||||
|
||||
final hasOther = months.any((m) => otherFor(m) > 0);
|
||||
final hasOther = months.any((m) => otherFor(m) != 0);
|
||||
final seriesCount = topLabels.length + (hasOther ? 1 : 0);
|
||||
|
||||
var maxY = 0.0;
|
||||
var minY = 0.0;
|
||||
for (final month in months) {
|
||||
final bucket =
|
||||
amountByMonth[_periodKey(month, period)] ?? const {};
|
||||
for (final row in topLabels) {
|
||||
final value = bucket[row.label.id] ?? 0;
|
||||
if (value > maxY) maxY = value;
|
||||
if (stacked) {
|
||||
var pos = 0.0;
|
||||
var neg = 0.0;
|
||||
void accumulate(double value) {
|
||||
if (value > 0) {
|
||||
pos += value;
|
||||
} else if (value < 0) {
|
||||
neg += value;
|
||||
}
|
||||
}
|
||||
|
||||
for (final row in topLabels) {
|
||||
accumulate(bucket[row.label.id] ?? 0);
|
||||
}
|
||||
accumulate(otherFor(month));
|
||||
if (pos > maxY) maxY = pos;
|
||||
if (neg < minY) minY = neg;
|
||||
} else {
|
||||
for (final row in topLabels) {
|
||||
final value = bucket[row.label.id] ?? 0;
|
||||
if (value > maxY) maxY = value;
|
||||
if (value < minY) minY = value;
|
||||
}
|
||||
final other = otherFor(month);
|
||||
if (other > maxY) maxY = other;
|
||||
if (other < minY) minY = other;
|
||||
}
|
||||
final other = otherFor(month);
|
||||
if (other > maxY) maxY = other;
|
||||
}
|
||||
final safeMax = maxY <= 0 ? 1.0 : maxY * 1.15;
|
||||
if (maxY <= 0) {
|
||||
if (maxY == 0 && minY == 0) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No labeled expenses in this period.\nTag transactions to see them here.',
|
||||
'No labeled activity in this period.\nTag transactions to see them here.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
final span = (maxY - minY).abs();
|
||||
final pad = span <= 0 ? 1.0 : span * 0.15;
|
||||
final safeMax = maxY + (maxY == 0 && minY < 0 ? 0 : pad);
|
||||
final safeMin = minY - (minY == 0 && maxY > 0 ? 0 : pad);
|
||||
|
||||
final rodWidth = period == _PeriodMode.yearly
|
||||
? (seriesCount <= 3 ? 12.0 : 8.0)
|
||||
: (seriesCount <= 3
|
||||
? 7.0
|
||||
: seriesCount <= 5
|
||||
? 5.0
|
||||
: 4.0);
|
||||
final rodWidth = stacked
|
||||
? (period == _PeriodMode.yearly ? 18.0 : 14.0)
|
||||
: period == _PeriodMode.yearly
|
||||
? (seriesCount <= 3 ? 12.0 : 8.0)
|
||||
: (seriesCount <= 3
|
||||
? 7.0
|
||||
: seriesCount <= 5
|
||||
? 5.0
|
||||
: 4.0);
|
||||
|
||||
List<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),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: _kExpenseColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final style = _styleFor(rows[i].amount);
|
||||
return Text(
|
||||
formatMoney(style.$1, currency),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: style.$2,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+103
-9
@@ -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,14 +77,38 @@ Widget authAppBarAction(BuildContext context) {
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Text(
|
||||
auth.initials,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
child: SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Text(
|
||||
auth.initials,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color:
|
||||
Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showRing)
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator(
|
||||
value: progress <= 0 ? null : progress.clamp(0.02, 1.0),
|
||||
strokeWidth: 2.5,
|
||||
color: ringColor,
|
||||
backgroundColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withValues(alpha: 0.45),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -82,6 +126,56 @@ Widget authAppBarAction(BuildContext context) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Live sync status row inside the account popup (watches [SyncController]).
|
||||
class _SyncProgressMenuTile extends StatelessWidget {
|
||||
const _SyncProgressMenuTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sync = context.watch<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),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,8 +243,17 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
|
||||
kUncategorizedFilterLabel.name.toLowerCase().contains(needle));
|
||||
final media = MediaQuery.of(context);
|
||||
final maxContentHeight =
|
||||
((media.size.height - media.viewInsets.bottom) * 0.55)
|
||||
.clamp(240.0, 560.0);
|
||||
((media.size.height - media.viewInsets.bottom) * 0.72)
|
||||
.clamp(320.0, 720.0);
|
||||
|
||||
final visibleIds = <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),
|
||||
@@ -219,72 +263,152 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
widget.subtitle,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (widget.includeFlow) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Income / expense',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_RectToggleBar<TxFlowFilter>(
|
||||
value: _flow,
|
||||
onChanged: (value) => setState(() => _flow = value),
|
||||
options: const [
|
||||
_RectToggleOption(
|
||||
value: TxFlowFilter.all,
|
||||
label: 'All',
|
||||
),
|
||||
_RectToggleOption(
|
||||
value: TxFlowFilter.income,
|
||||
label: 'In',
|
||||
icon: Icons.south_west,
|
||||
),
|
||||
_RectToggleOption(
|
||||
value: TxFlowFilter.expense,
|
||||
label: 'Out',
|
||||
icon: Icons.north_east,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _labelSearchController,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
hintText: 'Filter labels',
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
suffixIcon: needle.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
onPressed: () {
|
||||
_labelSearchController.clear();
|
||||
setState(() {});
|
||||
},
|
||||
icon: const Icon(Icons.clear, size: 18),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxContentHeight * 0.42),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
widget.subtitle,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
if (widget.allowCreate) ...[
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: _createLabel,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('New label'),
|
||||
),
|
||||
if (widget.includeFlow) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Income / expense',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_RectToggleBar<TxFlowFilter>(
|
||||
value: _flow,
|
||||
onChanged: (value) => setState(() => _flow = value),
|
||||
options: const [
|
||||
_RectToggleOption(
|
||||
value: TxFlowFilter.all,
|
||||
label: 'All',
|
||||
),
|
||||
_RectToggleOption(
|
||||
value: TxFlowFilter.income,
|
||||
label: 'In',
|
||||
icon: Icons.south_west,
|
||||
),
|
||||
_RectToggleOption(
|
||||
value: TxFlowFilter.expense,
|
||||
label: 'Out',
|
||||
icon: Icons.north_east,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (widget.includeInvertChart &&
|
||||
_flow == TxFlowFilter.all) ...[
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
title: const Text('Invert chart'),
|
||||
subtitle: const Text(
|
||||
'Expense view: spending up, income/refunds down',
|
||||
),
|
||||
value: _invertChart,
|
||||
onChanged: (value) =>
|
||||
setState(() => _invertChart = value),
|
||||
),
|
||||
],
|
||||
],
|
||||
if (widget.includeBarStyle) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Bar layout',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_RectToggleBar<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),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: _createLabel,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('New label'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _labelSearchController,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
hintText: 'Filter labels',
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
suffixIcon: needle.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
onPressed: () {
|
||||
_labelSearchController.clear();
|
||||
setState(() {});
|
||||
},
|
||||
icon: const Icon(Icons.clear, size: 18),
|
||||
),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: allVisibleSelected ? 'Deselect all' : 'Select all',
|
||||
child: Checkbox(
|
||||
tristate: true,
|
||||
value: allVisibleSelected
|
||||
? true
|
||||
: noneVisibleSelected
|
||||
? false
|
||||
: null,
|
||||
onChanged: visibleIds.isEmpty
|
||||
? null
|
||||
: (_) {
|
||||
setState(() {
|
||||
if (allVisibleSelected) {
|
||||
_selected.removeAll(visibleIds);
|
||||
} else {
|
||||
_selected.addAll(visibleIds);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: _LabelChecklist(
|
||||
@@ -302,50 +426,33 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> {
|
||||
actions: [
|
||||
SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
child: Row(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_selected
|
||||
..clear()
|
||||
..addAll(entries.map((e) => e.label.id));
|
||||
if (showUncategorized) {
|
||||
_selected.add(kUncategorizedFilterId);
|
||||
}
|
||||
});
|
||||
},
|
||||
child: const Text('All'),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => setState(() => _selected.clear()),
|
||||
child: const Text('None'),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(
|
||||
context,
|
||||
LabelFilterSelection(
|
||||
labelIds: Set<String>.of(_selected),
|
||||
flow: widget.includeFlow ? _flow : TxFlowFilter.all,
|
||||
),
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: FilledButton(
|
||||
onPressed: () => Navigator.pop(
|
||||
context,
|
||||
LabelFilterSelection(
|
||||
labelIds: Set<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'),
|
||||
),
|
||||
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 (_) {}
|
||||
}
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user