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