diff --git a/lib/main.dart b/lib/main.dart index 818c8a3..6a73d7d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -49,6 +49,7 @@ class MyApp extends StatefulWidget { class _MyAppState extends State { late final BankController _bank; late final TxMetaController _meta; + late final PreferencesController _prefs; late final SyncController _sync; late final AppLinks _appLinks; StreamSubscription? _linkSub; @@ -57,8 +58,14 @@ class _MyAppState extends State { void initState() { super.initState(); _meta = TxMetaController(widget.storage); + _prefs = PreferencesController(widget.storage); _bank = BankController(storage: widget.storage)..refresh(); - _sync = SyncController(auth: widget.auth, meta: _meta, bank: _bank); + _sync = SyncController( + auth: widget.auth, + meta: _meta, + bank: _bank, + prefs: _prefs, + ); _appLinks = AppLinks(); _listenForBankRedirects(); } @@ -97,9 +104,7 @@ class _MyAppState extends State { providers: [ ChangeNotifierProvider.value(value: widget.auth), ChangeNotifierProvider(create: (_) => ThemeController(widget.storage)), - ChangeNotifierProvider( - create: (_) => PreferencesController(widget.storage), - ), + ChangeNotifierProvider.value(value: _prefs), ChangeNotifierProvider.value(value: _meta), ChangeNotifierProvider.value(value: _sync), ChangeNotifierProvider.value(value: _bank), diff --git a/lib/models/home_filter_widget.dart b/lib/models/home_filter_widget.dart new file mode 100644 index 0000000..86bb9f9 --- /dev/null +++ b/lib/models/home_filter_widget.dart @@ -0,0 +1,174 @@ +/// Named transaction-filter widgets pinned on the Home screen. +library; + +import '../widgets/label_filter_dialog.dart'; +import 'transaction_meta.dart'; + +/// A saved [TransactionFilterSelection] plus which metrics to show on Home. +class HomeFilterWidget { + const HomeFilterWidget({ + required this.id, + required this.name, + this.display = true, + this.sortOrder = 0, + this.labelIds = const [], + this.flow = TxFlowFilter.all, + this.amountRange = const TxAmountRange(), + this.query = '', + this.monthOnly = false, + this.showAmountIn = true, + this.showAmountOut = true, + this.showCount = true, + }); + + final String id; + final String name; + final bool display; + final int sortOrder; + + final List labelIds; + final TxFlowFilter flow; + final TxAmountRange amountRange; + final String query; + final bool monthOnly; + + final bool showAmountIn; + final bool showAmountOut; + final bool showCount; + + TransactionFilterSelection get filter => TransactionFilterSelection( + labelIds: labelIds.toSet(), + flow: flow, + amountRange: amountRange, + query: query, + monthOnly: monthOnly, + ); + + HomeFilterWidget copyWith({ + String? id, + String? name, + bool? display, + int? sortOrder, + List? labelIds, + TxFlowFilter? flow, + TxAmountRange? amountRange, + String? query, + bool? monthOnly, + bool? showAmountIn, + bool? showAmountOut, + bool? showCount, + }) { + return HomeFilterWidget( + id: id ?? this.id, + name: name ?? this.name, + display: display ?? this.display, + sortOrder: sortOrder ?? this.sortOrder, + labelIds: labelIds ?? this.labelIds, + flow: flow ?? this.flow, + amountRange: amountRange ?? this.amountRange, + query: query ?? this.query, + monthOnly: monthOnly ?? this.monthOnly, + showAmountIn: showAmountIn ?? this.showAmountIn, + showAmountOut: showAmountOut ?? this.showAmountOut, + showCount: showCount ?? this.showCount, + ); + } + + static HomeFilterWidget fromFilter({ + required String name, + required TransactionFilterSelection filter, + bool display = true, + int sortOrder = 0, + }) { + final showIn = filter.flow != TxFlowFilter.expense; + final showOut = filter.flow != TxFlowFilter.income; + return HomeFilterWidget( + id: 'hw_${DateTime.now().millisecondsSinceEpoch}', + name: name, + display: display, + sortOrder: sortOrder, + labelIds: filter.labelIds.toList(growable: false), + flow: filter.flow, + amountRange: filter.amountRange, + query: filter.query, + monthOnly: filter.monthOnly, + showAmountIn: showIn, + showAmountOut: showOut, + showCount: true, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'display': display, + 'sortOrder': sortOrder, + 'labelIds': labelIds, + 'flow': flow.name, + 'amountRange': { + if (amountRange.exact != null) 'exact': amountRange.exact, + if (amountRange.min != null) 'min': amountRange.min, + if (amountRange.max != null) 'max': amountRange.max, + }, + 'query': query, + 'monthOnly': monthOnly, + 'showAmountIn': showAmountIn, + 'showAmountOut': showAmountOut, + 'showCount': showCount, + }; + + factory HomeFilterWidget.fromJson(Map json) { + final rangeRaw = json['amountRange']; + final rangeMap = rangeRaw is Map + ? Map.from(rangeRaw) + : const {}; + double? asDouble(dynamic value) { + if (value is num) return value.toDouble(); + return null; + } + + return HomeFilterWidget( + id: json['id'] as String? ?? + 'hw_${DateTime.now().millisecondsSinceEpoch}', + name: (json['name'] as String?)?.trim().isNotEmpty == true + ? (json['name'] as String).trim() + : 'Filter', + display: json['display'] as bool? ?? true, + sortOrder: json['sortOrder'] as int? ?? 0, + labelIds: [ + for (final id in (json['labelIds'] as List? ?? const [])) + if (id is String && id.isNotEmpty) id, + ], + flow: TxFlowFilter.values.firstWhere( + (e) => e.name == json['flow'], + orElse: () => TxFlowFilter.all, + ), + amountRange: TxAmountRange( + exact: asDouble(rangeMap['exact']), + min: asDouble(rangeMap['min']), + max: asDouble(rangeMap['max']), + ), + query: json['query'] as String? ?? '', + monthOnly: json['monthOnly'] as bool? ?? false, + showAmountIn: json['showAmountIn'] as bool? ?? true, + showAmountOut: json['showAmountOut'] as bool? ?? true, + showCount: json['showCount'] as bool? ?? true, + ); + } +} + +/// Union home widgets by id (local wins on conflict). +List mergeHomeFilterWidgets( + List local, + List remote, +) { + final byId = { + for (final w in remote) w.id: w, + }; + for (final w in local) { + byId[w.id] = w; + } + final merged = byId.values.toList() + ..sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); + return merged; +} diff --git a/lib/models/overview_favourite.dart b/lib/models/overview_favourite.dart new file mode 100644 index 0000000..743927d --- /dev/null +++ b/lib/models/overview_favourite.dart @@ -0,0 +1,109 @@ +/// Named Overview chart filter snapshots (local favourites). +library; + +import '../models/transaction_meta.dart'; +import '../widgets/label_filter_dialog.dart'; + +/// Persisted Overview lens: period, metric, and Labels-chart options. +class OverviewFavourite { + const OverviewFavourite({ + required this.id, + required this.name, + this.display = true, + this.metric = OverviewMetricMode.flow, + this.period = OverviewPeriodMode.monthly, + this.labelIds = const [], + this.flow = TxFlowFilter.expense, + this.barStyle = LabelChartBarStyle.parallel, + this.invertExpensePerspective = false, + }); + + final String id; + final String name; + + /// When true, this favourite appears on the Favourites overview view. + final bool display; + + final OverviewMetricMode metric; + final OverviewPeriodMode period; + + /// Empty = all labels (Labels metric only). + final List labelIds; + + final TxFlowFilter flow; + final LabelChartBarStyle barStyle; + final bool invertExpensePerspective; + + OverviewFavourite copyWith({ + String? id, + String? name, + bool? display, + OverviewMetricMode? metric, + OverviewPeriodMode? period, + List? labelIds, + TxFlowFilter? flow, + LabelChartBarStyle? barStyle, + bool? invertExpensePerspective, + }) { + return OverviewFavourite( + id: id ?? this.id, + name: name ?? this.name, + display: display ?? this.display, + metric: metric ?? this.metric, + period: period ?? this.period, + labelIds: labelIds ?? this.labelIds, + flow: flow ?? this.flow, + barStyle: barStyle ?? this.barStyle, + invertExpensePerspective: + invertExpensePerspective ?? this.invertExpensePerspective, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'display': display, + 'metric': metric.name, + 'period': period.name, + 'labelIds': labelIds, + 'flow': flow.name, + 'barStyle': barStyle.name, + 'invertExpensePerspective': invertExpensePerspective, + }; + + factory OverviewFavourite.fromJson(Map json) { + return OverviewFavourite( + id: json['id'] as String? ?? 'fav_${DateTime.now().millisecondsSinceEpoch}', + name: (json['name'] as String?)?.trim().isNotEmpty == true + ? (json['name'] as String).trim() + : 'Favourite', + display: json['display'] as bool? ?? true, + metric: OverviewMetricMode.values.firstWhere( + (e) => e.name == json['metric'], + orElse: () => OverviewMetricMode.flow, + ), + period: OverviewPeriodMode.values.firstWhere( + (e) => e.name == json['period'], + orElse: () => OverviewPeriodMode.monthly, + ), + labelIds: [ + for (final id in (json['labelIds'] as List? ?? const [])) + if (id is String && id.isNotEmpty) id, + ], + flow: TxFlowFilter.values.firstWhere( + (e) => e.name == json['flow'], + orElse: () => TxFlowFilter.expense, + ), + barStyle: LabelChartBarStyle.values.firstWhere( + (e) => e.name == json['barStyle'], + orElse: () => LabelChartBarStyle.parallel, + ), + invertExpensePerspective: + json['invertExpensePerspective'] as bool? ?? false, + ); + } +} + +enum OverviewMetricMode { flow, labels } + +enum OverviewPeriodMode { monthly, yearly } diff --git a/lib/providers/preferences_provider.dart b/lib/providers/preferences_provider.dart index 9723c3c..4ddd268 100644 --- a/lib/providers/preferences_provider.dart +++ b/lib/providers/preferences_provider.dart @@ -1,21 +1,55 @@ -/// App preferences beyond theme (Look ahead horizon, etc.). +/// App preferences beyond theme (Look ahead, Overview favourites, Home widgets). library; import 'package:flutter/foundation.dart'; +import '../models/home_filter_widget.dart'; +import '../models/overview_favourite.dart'; import '../services/storage_service.dart'; class PreferencesController extends ChangeNotifier { PreferencesController(this._storage) { _lookAheadMaxYears = _storage.lookAheadMaxYears; + _overviewFavourites = _storage.loadOverviewFavourites(); + _homeFilterWidgets = _storage.loadHomeFilterWidgets(); } final StorageService _storage; late int _lookAheadMaxYears; + late List _overviewFavourites; + late List _homeFilterWidgets; + + /// Fired after favourites are persisted (for cloud push debounce). + VoidCallback? onFavouritesPersisted; + + /// Fired after Home widgets are persisted (for cloud push debounce). + VoidCallback? onHomeWidgetsPersisted; /// Years ahead Look ahead can navigate (1–30, default 5). int get lookAheadMaxYears => _lookAheadMaxYears; + List get overviewFavourites => + List.unmodifiable(_overviewFavourites); + + List get displayedOverviewFavourites => [ + for (final fav in _overviewFavourites) + if (fav.display) fav, + ]; + + bool get hasDisplayedOverviewFavourites => + _overviewFavourites.any((f) => f.display); + + List get homeFilterWidgets => + List.unmodifiable(_homeFilterWidgets); + + List get displayedHomeFilterWidgets => [ + for (final w in _homeFilterWidgets) + if (w.display) w, + ]; + + bool get hasDisplayedHomeFilterWidgets => + _homeFilterWidgets.any((w) => w.display); + Future setLookAheadMaxYears(int years) async { final next = years.clamp(1, 30); if (next == _lookAheadMaxYears) return; @@ -23,4 +57,118 @@ class PreferencesController extends ChangeNotifier { notifyListeners(); await _storage.setLookAheadMaxYears(next); } + + Future addOverviewFavourite(OverviewFavourite favourite) async { + _overviewFavourites = [..._overviewFavourites, favourite]; + notifyListeners(); + await _persistFavourites(); + } + + Future updateOverviewFavourite(OverviewFavourite favourite) async { + final index = _overviewFavourites.indexWhere((f) => f.id == favourite.id); + if (index < 0) return; + final next = [..._overviewFavourites]; + next[index] = favourite; + _overviewFavourites = next; + notifyListeners(); + await _persistFavourites(); + } + + Future removeOverviewFavourite(String id) async { + final next = [ + for (final fav in _overviewFavourites) + if (fav.id != id) fav, + ]; + if (next.length == _overviewFavourites.length) return; + _overviewFavourites = next; + notifyListeners(); + await _persistFavourites(); + } + + Future setOverviewFavouriteDisplay(String id, bool display) async { + final index = _overviewFavourites.indexWhere((f) => f.id == id); + if (index < 0) return; + final current = _overviewFavourites[index]; + if (current.display == display) return; + await updateOverviewFavourite(current.copyWith(display: display)); + } + + Future replaceOverviewFavourites( + List favourites, { + bool notify = true, + bool schedulePush = false, + }) async { + _overviewFavourites = List.of(favourites); + if (notify) notifyListeners(); + await _storage.saveOverviewFavourites(_overviewFavourites); + if (schedulePush) onFavouritesPersisted?.call(); + } + + Future addHomeFilterWidget(HomeFilterWidget widget) async { + final order = _homeFilterWidgets.isEmpty + ? 0 + : _homeFilterWidgets + .map((w) => w.sortOrder) + .reduce((a, b) => a > b ? a : b) + + 1; + _homeFilterWidgets = [ + ..._homeFilterWidgets, + widget.copyWith(sortOrder: widget.sortOrder == 0 ? order : widget.sortOrder), + ]; + notifyListeners(); + await _persistHomeWidgets(); + } + + Future updateHomeFilterWidget(HomeFilterWidget widget) async { + final index = _homeFilterWidgets.indexWhere((w) => w.id == widget.id); + if (index < 0) return; + final next = [..._homeFilterWidgets]; + next[index] = widget; + next.sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); + _homeFilterWidgets = next; + notifyListeners(); + await _persistHomeWidgets(); + } + + Future removeHomeFilterWidget(String id) async { + final next = [ + for (final w in _homeFilterWidgets) + if (w.id != id) w, + ]; + if (next.length == _homeFilterWidgets.length) return; + _homeFilterWidgets = next; + notifyListeners(); + await _persistHomeWidgets(); + } + + Future setHomeFilterWidgetDisplay(String id, bool display) async { + final index = _homeFilterWidgets.indexWhere((w) => w.id == id); + if (index < 0) return; + final current = _homeFilterWidgets[index]; + if (current.display == display) return; + await updateHomeFilterWidget(current.copyWith(display: display)); + } + + Future replaceHomeFilterWidgets( + List widgets, { + bool notify = true, + bool schedulePush = false, + }) async { + final next = List.of(widgets) + ..sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); + _homeFilterWidgets = next; + if (notify) notifyListeners(); + await _storage.saveHomeFilterWidgets(_homeFilterWidgets); + if (schedulePush) onHomeWidgetsPersisted?.call(); + } + + Future _persistFavourites() async { + await _storage.saveOverviewFavourites(_overviewFavourites); + onFavouritesPersisted?.call(); + } + + Future _persistHomeWidgets() async { + await _storage.saveHomeFilterWidgets(_homeFilterWidgets); + onHomeWidgetsPersisted?.call(); + } } diff --git a/lib/providers/sync_provider.dart b/lib/providers/sync_provider.dart index 72e1b65..04fbfad 100644 --- a/lib/providers/sync_provider.dart +++ b/lib/providers/sync_provider.dart @@ -5,9 +5,12 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import '../models/home_filter_widget.dart'; +import '../models/overview_favourite.dart'; import '../services/pb_sync_service.dart'; import 'auth_provider.dart'; import 'bank_provider.dart'; +import 'preferences_provider.dart'; import 'tx_meta_provider.dart'; /// User-visible sync toast state (watched by [SyncFeedbackListener]). @@ -30,11 +33,15 @@ class SyncController extends ChangeNotifier { required AuthController auth, required TxMetaController meta, required BankController bank, + required PreferencesController prefs, }) : _auth = auth, _meta = meta, - _bank = bank { + _bank = bank, + _prefs = prefs { _wasLoggedIn = auth.isLoggedIn; _meta.onPersisted = _schedulePush; + _prefs.onFavouritesPersisted = _schedulePush; + _prefs.onHomeWidgetsPersisted = _schedulePush; _auth.addListener(_onAuthChanged); _bank.onAfterSuccessfulLoad = _onBankDataLoaded; if (_auth.isLoggedIn) { @@ -45,6 +52,7 @@ class SyncController extends ChangeNotifier { final AuthController _auth; final TxMetaController _meta; final BankController _bank; + final PreferencesController _prefs; bool _wasLoggedIn = false; bool _syncing = false; @@ -260,20 +268,77 @@ class SyncController extends ChangeNotifier { _setProgress(0.5); _setStatus('Merging…'); final mergedMeta = mergeTxMetaStores(_meta.exportStore(), remoteMeta); + List remoteFavourites = const []; + try { + remoteFavourites = await service.pullOverviewFavourites( + onStatus: _setStatus, + ); + } catch (error) { + debugPrint( + 'SyncController: overview_favourites pull skipped ($error)', + ); + } + final mergedFavourites = mergeOverviewFavourites( + _prefs.overviewFavourites, + remoteFavourites, + ); + List remoteHomeWidgets = const []; + try { + remoteHomeWidgets = await service.pullHomeFilterWidgets( + onStatus: _setStatus, + ); + } catch (error) { + debugPrint( + 'SyncController: home_filter_widgets pull skipped ($error)', + ); + } + final mergedHomeWidgets = mergeHomeFilterWidgets( + _prefs.homeFilterWidgets, + remoteHomeWidgets, + ); // Apply without notifying — one rebuild after both stores are ready. await _meta.replaceStore(mergedMeta, notify: false); await _bank.applySyncedTransactions(remoteTxs, notify: false); + await _prefs.replaceOverviewFavourites( + mergedFavourites, + notify: false, + ); + await _prefs.replaceHomeFilterWidgets( + mergedHomeWidgets, + notify: false, + ); _meta.notifyListeners(); _bank.notifyListeners(); + _prefs.notifyListeners(); _setProgress(0.55); _setStatus('Pushing…'); final metaSummary = await service.push( mergedMeta, progressStart: 0.55, - progressEnd: 0.82, + progressEnd: 0.75, onProgress: _setProgress, onStatus: _setStatus, ); + try { + await service.pushOverviewFavourites( + mergedFavourites, + onStatus: _setStatus, + ); + } catch (error) { + debugPrint( + 'SyncController: overview_favourites push skipped ($error)', + ); + } + try { + await service.pushHomeFilterWidgets( + mergedHomeWidgets, + onStatus: _setStatus, + ); + } catch (error) { + debugPrint( + 'SyncController: home_filter_widgets push skipped ($error)', + ); + } final bankSummary = await service.pushTransactions( _bank.transactions, progressStart: 0.82, @@ -323,10 +388,31 @@ class SyncController extends ChangeNotifier { final summary = await service.push( _meta.exportStore(), progressStart: 0.05, - progressEnd: 1.0, + progressEnd: 0.9, onProgress: _setProgress, onStatus: _setStatus, ); + try { + await service.pushOverviewFavourites( + _prefs.overviewFavourites, + onStatus: _setStatus, + ); + } catch (error) { + debugPrint( + 'SyncController: overview_favourites push skipped ($error)', + ); + } + try { + await service.pushHomeFilterWidgets( + _prefs.homeFilterWidgets, + onStatus: _setStatus, + ); + } catch (error) { + debugPrint( + 'SyncController: home_filter_widgets push skipped ($error)', + ); + } + _setProgress(1.0); _lastSyncedAt = DateTime.now(); _lastError = null; _lastSyncSummary = summary; @@ -359,6 +445,8 @@ class SyncController extends ChangeNotifier { _auth.removeListener(_onAuthChanged); _bank.onAfterSuccessfulLoad = null; _meta.onPersisted = null; + _prefs.onFavouritesPersisted = null; + _prefs.onHomeWidgetsPersisted = null; super.dispose(); } } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index deaaf51..dcc23fa 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -5,12 +5,15 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/bank_provider.dart'; +import '../providers/preferences_provider.dart'; import '../providers/tx_meta_provider.dart'; import '../utils/app_theme.dart'; import '../utils/connect_bank.dart'; import '../utils/helpers.dart'; import '../utils/navigation.dart'; import '../widgets/empty_state.dart'; +import '../widgets/home_filter_widget_card.dart'; +import '../widgets/home_widgets_sheet.dart'; import '../widgets/loading_view.dart'; import '../widgets/responsive_body.dart'; import '../widgets/shell_tab_scope.dart'; @@ -18,16 +21,23 @@ import '../widgets/skip_build_when_covered.dart'; import 'transaction_detail_screen.dart'; import 'transactions_screen.dart'; -class HomeScreen extends StatelessWidget { +class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); static const tabIndex = 0; + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + var _editingWidgets = false; + @override Widget build(BuildContext context) { // Inactive IndexedStack tabs must not watch providers — otherwise every // annotation save rebuilds Home while the user is on another tab. - if (ShellTabScope.of(context) != tabIndex) { + if (ShellTabScope.of(context) != HomeScreen.tabIndex) { return const SizedBox.expand(); } @@ -35,17 +45,31 @@ class HomeScreen extends StatelessWidget { return Scaffold( appBar: AppBar( - title: const Text('Home'), - actions: bankAppBarActions( - context: context, - onRefresh: bank.refresh, - refreshing: bank.isLoading, - ), + title: Text(_editingWidgets ? 'Edit widgets' : 'Home'), + actions: [ + if (bank.isLinked || bank.hasData) + IconButton( + tooltip: _editingWidgets ? 'Done' : 'Edit Home widgets', + onPressed: () => + setState(() => _editingWidgets = !_editingWidgets), + icon: Icon( + _editingWidgets ? Icons.check : Icons.dashboard_customize_outlined, + ), + ), + ...bankAppBarActions( + context: context, + onRefresh: bank.refresh, + refreshing: bank.isLoading, + ), + ], ), body: RefreshIndicator( onRefresh: bank.refresh, child: ResponsiveBody( - child: _HomeBody(bank: bank), + child: _HomeBody( + bank: bank, + editingWidgets: _editingWidgets, + ), ), ), ); @@ -53,9 +77,13 @@ class HomeScreen extends StatelessWidget { } class _HomeBody extends StatelessWidget { - const _HomeBody({required this.bank}); + const _HomeBody({ + required this.bank, + required this.editingWidgets, + }); final BankController bank; + final bool editingWidgets; void _openTransactions(BuildContext context) { Navigator.of(context).push( @@ -68,6 +96,7 @@ class _HomeBody extends StatelessWidget { // Stay subscribed, but skip heavy scans while covered by another route // (e.g. transaction detail applying labels to many similar txs). context.watch(); + final prefs = context.watch(); if (bank.isLoading && bank.balance == null && !bank.hasData) { return const LoadingView(message: 'Checking bank link…'); @@ -106,163 +135,204 @@ class _HomeBody extends StatelessWidget { return SkipBuildWhenCovered( builder: (context) { - final balance = bank.balance; - final theme = Theme.of(context); - final colors = theme.colorScheme; - final meta = context.watch(); - final recent = bank.transactions.take(5).toList(growable: false); - final categorized = - meta.labeledTransactionCount(bank.transactions); + final balance = bank.balance; + final theme = Theme.of(context); + final colors = theme.colorScheme; + final meta = context.watch(); + final recent = bank.transactions.take(5).toList(growable: false); + final categorized = + meta.labeledTransactionCount(bank.transactions); + final widgets = editingWidgets + ? prefs.homeFilterWidgets + : prefs.displayedHomeFilterWidgets; - return ListView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.only(top: 8, bottom: 32), - children: [ - Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(20), - onTap: () => _openTransactions(context), - child: Ink( - width: double.infinity, - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(top: 8, bottom: 32), + children: [ + Material( + color: Colors.transparent, + child: InkWell( borderRadius: BorderRadius.circular(20), - gradient: LinearGradient( - colors: [colors.primary, colors.tertiary], - ), - boxShadow: [ - BoxShadow( - color: colors.primary.withValues(alpha: 0.28), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Current balance', - style: theme.textTheme.labelMedium?.copyWith( - color: colors.onPrimary.withValues(alpha: 0.8), + onTap: () => _openTransactions(context), + child: Ink( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + colors: [colors.primary, colors.tertiary], ), - ), - const SizedBox(height: 8), - Text( - balance == null - ? '—' - : formatMoney(balance.amount, balance.currency), - style: theme.textTheme.headlineMedium?.copyWith( - color: colors.onPrimary, - ), - ), - const SizedBox(height: 12), - Row( - children: [ - Icon( - Icons.receipt_long, - size: 16, - color: colors.onPrimary.withValues(alpha: 0.9), - ), - const SizedBox(width: 6), - Expanded( - child: Text( - 'Tap to view transactions', - style: theme.textTheme.labelMedium?.copyWith( - color: colors.onPrimary.withValues(alpha: 0.9), - ), - ), - ), - Icon( - Icons.chevron_right, - color: colors.onPrimary.withValues(alpha: 0.9), + boxShadow: [ + BoxShadow( + color: colors.primary.withValues(alpha: 0.28), + blurRadius: 20, + offset: const Offset(0, 10), ), ], ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Current balance', + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onPrimary.withValues(alpha: 0.8), + ), + ), + const SizedBox(height: 8), + Text( + balance == null + ? '—' + : formatMoney(balance.amount, balance.currency), + style: theme.textTheme.headlineMedium?.copyWith( + color: colors.onPrimary, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Icon( + Icons.receipt_long, + size: 16, + color: colors.onPrimary.withValues(alpha: 0.9), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Tap to view transactions', + style: theme.textTheme.labelMedium?.copyWith( + color: + colors.onPrimary.withValues(alpha: 0.9), + ), + ), + ), + Icon( + Icons.chevron_right, + color: colors.onPrimary.withValues(alpha: 0.9), + ), + ], + ), + ], + ), + ), + ), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: _StatChip( + label: 'Transactions', + value: '${bank.transactions.length}', + icon: Icons.list_alt, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _StatChip( + label: 'Categorized', + value: '$categorized', + icon: Icons.label_outline, + ), + ), + ], + ), + if (editingWidgets || widgets.isNotEmpty) ...[ + const SizedBox(height: 24), + Row( + children: [ + Text('Widgets', style: theme.textTheme.titleMedium), + const Spacer(), + if (editingWidgets) + TextButton.icon( + onPressed: () => showHomeWidgetsSheet(context), + icon: const Icon(Icons.add, size: 18), + label: const Text('Add'), + ), ], ), - ), - ), - ), - const SizedBox(height: 20), - Row( - children: [ - Expanded( - child: _StatChip( - label: 'Transactions', - value: '${bank.transactions.length}', - icon: Icons.list_alt, - ), - ), - const SizedBox(width: 10), - Expanded( - child: _StatChip( - label: 'Categorized', - value: '$categorized', - icon: Icons.label_outline, - ), - ), - ], - ), - if (recent.isNotEmpty) ...[ - const SizedBox(height: 24), - Row( - children: [ - Text('Recent', style: theme.textTheme.titleMedium), - const Spacer(), - TextButton( - onPressed: () => _openTransactions(context), - child: const Text('See all'), - ), + const SizedBox(height: 8), + if (widgets.isEmpty) + Text( + 'No widgets yet. Tap Add to pin a transaction filter.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ) + else + for (final widgetConfig in widgets) ...[ + HomeFilterWidgetCard( + widgetConfig: widgetConfig, + editing: editingWidgets, + onRemove: editingWidgets + ? () => prefs.removeHomeFilterWidget(widgetConfig.id) + : null, + ), + const SizedBox(height: 10), + ], ], - ), - const SizedBox(height: 4), - for (final tx in recent) - ListTile( - contentPadding: EdgeInsets.zero, - dense: true, - leading: Builder( - builder: (context) { - final labels = - meta.leafLabelsFor(meta.metaFor(tx)); - final label = labels.isEmpty ? null : labels.first; - return Icon( - label?.icon ?? Icons.receipt_long_outlined, - color: label?.color, + if (recent.isNotEmpty) ...[ + const SizedBox(height: 24), + Row( + children: [ + Text('Recent', style: theme.textTheme.titleMedium), + const Spacer(), + TextButton( + onPressed: () => _openTransactions(context), + child: const Text('See all'), + ), + ], + ), + const SizedBox(height: 4), + for (final tx in recent) + ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + leading: Builder( + builder: (context) { + final labels = + meta.leafLabelsFor(meta.metaFor(tx)); + final label = labels.isEmpty ? null : labels.first; + return Icon( + label?.icon ?? Icons.receipt_long_outlined, + color: label?.color, + ); + }, + ), + title: Text( + tx.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text(formatIsoDate(tx.date)), + trailing: Text( + formatMoney(tx.amount, tx.currency), + style: theme.textTheme.titleSmall?.copyWith( + color: tx.amount >= 0 + ? AppTheme.accent + : AppTheme.expense, + ), + ), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => TransactionDetailScreen(tx: tx), + ), ); }, ), - title: Text( - tx.description, - maxLines: 1, - overflow: TextOverflow.ellipsis, + ], + if (bank.error != null) ...[ + const SizedBox(height: 12), + Text( + bank.error!, + style: theme.textTheme.bodyMedium + ?.copyWith(color: colors.error), ), - subtitle: Text(formatIsoDate(tx.date)), - trailing: Text( - formatMoney(tx.amount, tx.currency), - style: theme.textTheme.titleSmall?.copyWith( - color: tx.amount >= 0 ? AppTheme.accent : AppTheme.expense, - ), - ), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => TransactionDetailScreen(tx: tx), - ), - ); - }, - ), - ], - if (bank.error != null) ...[ - const SizedBox(height: 12), - Text( - bank.error!, - style: theme.textTheme.bodyMedium?.copyWith(color: colors.error), - ), - ], - ], - ); + ], + ], + ); }, ); } @@ -297,8 +367,13 @@ class _StatChip extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + label, + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), Text(value, style: theme.textTheme.titleMedium), - Text(label, style: theme.textTheme.labelMedium), ], ), ), diff --git a/lib/screens/label_transactions_screen.dart b/lib/screens/label_transactions_screen.dart index 262d86f..984f00c 100644 --- a/lib/screens/label_transactions_screen.dart +++ b/lib/screens/label_transactions_screen.dart @@ -31,7 +31,10 @@ class LabelTransactionsScreen extends StatelessWidget { builder: (context) { final txs = [ for (final tx in bank.transactions) - if (meta.labelIdsFor(tx).contains(label.id)) tx, + if (label.id == kUncategorizedFilterId + ? meta.labelIdsFor(tx).isEmpty + : meta.labelIdsFor(tx).contains(label.id)) + tx, ]; final theme = Theme.of(context); final currency = bank.balance?.currency ?? diff --git a/lib/screens/overview_screen.dart b/lib/screens/overview_screen.dart index 8b6f580..f6f4d35 100644 --- a/lib/screens/overview_screen.dart +++ b/lib/screens/overview_screen.dart @@ -6,8 +6,10 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/bank.dart'; +import '../models/overview_favourite.dart'; import '../models/transaction_meta.dart'; import '../providers/bank_provider.dart'; +import '../providers/preferences_provider.dart'; import '../providers/tx_meta_provider.dart'; import '../utils/app_theme.dart'; import '../utils/helpers.dart'; @@ -16,16 +18,13 @@ import '../utils/tx_calendar.dart'; import '../widgets/empty_state.dart'; import '../widgets/label_filter_dialog.dart'; import '../widgets/loading_view.dart'; +import '../widgets/overview_favourites_sheet.dart'; import '../widgets/responsive_body.dart'; import '../widgets/shell_tab_scope.dart'; import '../widgets/skip_build_when_covered.dart'; import 'label_transactions_screen.dart'; import 'transactions_screen.dart'; -enum _PeriodMode { monthly, yearly } - -enum _MetricMode { flow, labels } - const _kIncomeColor = AppTheme.accent; const _kExpenseColor = AppTheme.expense; @@ -39,8 +38,8 @@ class OverviewScreen extends StatefulWidget { } class _OverviewScreenState extends State { - _PeriodMode _period = _PeriodMode.monthly; - _MetricMode _metric = _MetricMode.flow; + OverviewPeriodMode _period = OverviewPeriodMode.monthly; + OverviewMetricMode _metric = OverviewMetricMode.flow; /// Empty = include every label. Non-empty = only these ids on the Labels chart. final Set _includedLabelIds = {}; @@ -57,17 +56,32 @@ class _OverviewScreenState extends State { /// Selected calendar year for Monthly mode (`DateTime(year)`). late DateTime _cursor; + /// Favourites charts view (vs full overview with history). + bool _showFavouritesView = false; + var _favouritesDefaultApplied = false; + @override void initState() { super.initState(); _cursor = DateTime(DateTime.now().year); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_favouritesDefaultApplied) return; + _favouritesDefaultApplied = true; + final prefs = context.read(); + if (prefs.hasDisplayedOverviewFavourites) { + _showFavouritesView = true; + } + } + DateTime get _nowMonth => monthStart(DateTime.now()); /// Chart X buckets: 12 months of [_cursor] year, or one Jan-1 per data year. List _chartPeriods(List txs) { - if (_period == _PeriodMode.monthly) { + if (_period == OverviewPeriodMode.monthly) { final year = _cursor.year; return [for (var m = 1; m <= 12; m++) DateTime(year, m)]; } @@ -81,18 +95,18 @@ class _OverviewScreenState extends State { } String get _periodTitle { - if (_period == _PeriodMode.monthly) return '${_cursor.year}'; + if (_period == OverviewPeriodMode.monthly) return '${_cursor.year}'; return 'All years'; } bool get _canGoForward => - _period == _PeriodMode.monthly && _cursor.year < _nowMonth.year; + _period == OverviewPeriodMode.monthly && _cursor.year < _nowMonth.year; bool get _canGoBack => - _period == _PeriodMode.monthly && _cursor.year > _nowMonth.year - 20; + _period == OverviewPeriodMode.monthly && _cursor.year > _nowMonth.year - 20; void _go(int delta) { - if (_period != _PeriodMode.monthly) return; + if (_period != OverviewPeriodMode.monthly) return; setState(() { final next = DateTime(_cursor.year + delta); if (next.year > _nowMonth.year) { @@ -105,7 +119,7 @@ class _OverviewScreenState extends State { }); } - void _setPeriod(_PeriodMode mode) { + void _setPeriod(OverviewPeriodMode mode) { setState(() { _period = mode; _cursor = DateTime(_nowMonth.year); @@ -115,18 +129,17 @@ class _OverviewScreenState extends State { Future _pickChartLabels(BuildContext context) async { final meta = context.read(); final available = meta.allLabels; - if (available.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No labels yet — create some first')), - ); - return; - } - final initial = Set.of( - _includedLabelIds.isEmpty - ? available.map((l) => l.id) - : _includedLabelIds.where((id) => available.any((l) => l.id == id)), - ); + final initial = { + if (_includedLabelIds.isEmpty) + ...available.map((l) => l.id) + else ...[ + for (final id in _includedLabelIds) + if (id == kUncategorizedFilterId || + available.any((l) => l.id == id)) + id, + ], + }; final result = await showLabelFilterDialog( context, @@ -136,6 +149,7 @@ class _OverviewScreenState extends State { subtitle: 'Choose which labels appear. Long-press to nest under another.', allowMove: true, + includeUncategorized: true, includeFlow: true, initialFlow: _labelFlow, includeBarStyle: true, @@ -151,19 +165,86 @@ class _OverviewScreenState extends State { _labelInvertChart = result.flow == TxFlowFilter.all && result.invertExpensePerspective; final allIds = available.map((l) => l.id).toSet(); - if (result.labelIds.isEmpty || - (result.labelIds.length == allIds.length && - result.labelIds.containsAll(allIds))) { + final hasUncategorized = + result.labelIds.contains(kUncategorizedFilterId); + final realIds = result.labelIds + .where((id) => id != kUncategorizedFilterId) + .toSet(); + final allRealSelected = realIds.length == allIds.length && + realIds.containsAll(allIds); + if (!hasUncategorized && + (realIds.isEmpty || allRealSelected)) { _includedLabelIds.clear(); } else { _includedLabelIds ..clear() ..addAll(result.labelIds); } - _metric = _MetricMode.labels; + _metric = OverviewMetricMode.labels; }); } + OverviewFavourite _currentFilterSnapshot({String name = ''}) { + return OverviewFavourite( + id: 'fav_${DateTime.now().millisecondsSinceEpoch}', + name: name, + display: true, + metric: _metric, + period: _period, + labelIds: _includedLabelIds.toList(growable: false), + flow: _labelFlow, + barStyle: _labelBarStyle, + invertExpensePerspective: _labelInvertChart, + ); + } + + Future _openFavouritesManager() { + return showOverviewFavouritesSheet( + context, + currentFilterSnapshot: () => _currentFilterSnapshot(), + ); + } + + Future _saveCurrentAsFavourite() async { + final name = await promptFavouriteName(context); + if (name == null || !mounted) return; + await Future.delayed(Duration.zero); + if (!mounted) return; + await context.read().addOverviewFavourite( + _currentFilterSnapshot(name: name), + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Saved “$name”')), + ); + } + + void _openFavouritesView() { + final prefs = context.read(); + if (!prefs.hasDisplayedOverviewFavourites) { + _openFavouritesManager(); + return; + } + setState(() => _showFavouritesView = true); + } + + List _chartPeriodsFor( + OverviewPeriodMode period, + List txs, + ) { + if (period == OverviewPeriodMode.monthly) { + final year = _cursor.year; + return [for (var m = 1; m <= 12; m++) DateTime(year, m)]; + } + final years = {_nowMonth.year}; + for (final tx in txs) { + final date = _txDate(tx); + if (date != null) years.add(date.year); + } + final sorted = years.toList()..sort(); + return [for (final year in sorted) DateTime(year)]; + } + @override Widget build(BuildContext context) { if (ShellTabScope.of(context) != OverviewScreen.tabIndex) { @@ -172,31 +253,226 @@ class _OverviewScreenState extends State { final bank = context.watch(); final meta = context.watch(); + final prefs = context.watch(); + + // Drop out of favourites view if nothing is displayed anymore. + final shouldLeaveFavourites = + _showFavouritesView && !prefs.hasDisplayedOverviewFavourites; + if (shouldLeaveFavourites) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (_showFavouritesView && + !context.read().hasDisplayedOverviewFavourites) { + setState(() => _showFavouritesView = false); + } + }); + } return Scaffold( appBar: AppBar( - title: const Text('Overview'), - actions: bankAppBarActions( - context: context, - onRefresh: bank.refresh, - refreshing: bank.isLoading, - ), + title: Text(_showFavouritesView ? 'Favourites' : 'Overview'), + actions: [ + if (_showFavouritesView) + IconButton( + tooltip: 'Manage favourites', + onPressed: _openFavouritesManager, + icon: const Icon(Icons.tune_outlined), + ), + ...bankAppBarActions( + context: context, + onRefresh: bank.refresh, + refreshing: bank.isLoading, + ), + ], ), body: RefreshIndicator( onRefresh: bank.refresh, child: ResponsiveBody( child: SkipBuildWhenCovered( - builder: (context) => _buildBody(context, bank, meta), + builder: (context) => _showFavouritesView + ? _buildFavouritesBody(context, bank, meta, prefs) + : _buildBody(context, bank, meta, prefs), ), ), ), ); } + Widget _buildFavouritesBody( + BuildContext context, + BankController bank, + TxMetaController meta, + PreferencesController prefs, + ) { + final theme = Theme.of(context); + + if (bank.isLoading && bank.transactions.isEmpty && bank.balance == null) { + return const LoadingView(message: 'Loading overview…'); + } + + if (!bank.isLinked && !bank.hasData) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + EmptyState( + icon: Icons.star_outline, + title: 'No favourites charts yet', + message: 'Connect your bank on Home to see charts.', + ), + ], + ); + } + + final currency = bank.balance?.currency ?? + (bank.transactions.isNotEmpty + ? bank.transactions.first.currency + : 'EUR'); + final txs = bank.transactions; + final displayed = prefs.displayedOverviewFavourites; + + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(0, 8, 0, 32), + children: [ + if (bank.error != null) ...[ + Text( + bank.error!, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + ), + ), + const SizedBox(height: 12), + ], + Row( + children: [ + Expanded( + child: Text( + 'Favourite charts', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + TextButton( + onPressed: _openFavouritesManager, + child: const Text('Manage'), + ), + TextButton.icon( + onPressed: () => setState(() => _showFavouritesView = false), + icon: const Icon(Icons.insights_outlined, size: 18), + label: const Text('Full overview'), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'All favourites with Display checked appear here. ' + 'Use Full overview to set a filter, then ★ → Save current filter.', + style: theme.textTheme.labelMedium, + ), + if (displayed.isEmpty) ...[ + const SizedBox(height: 24), + Text( + 'No displayed favourites yet. Tap Manage to enable Display, ' + 'or save a new filter from Full overview.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ] else ...[ + const SizedBox(height: 12), + for (var i = 0; i < displayed.length; i++) ...[ + if (i > 0) const SizedBox(height: 14), + _favouriteChartCard( + context: context, + favourite: displayed[i], + txs: txs, + meta: meta, + currency: currency, + ), + ], + ], + ], + ); + } + + Widget _favouriteChartCard({ + required BuildContext context, + required OverviewFavourite favourite, + required List txs, + required TxMetaController meta, + required String currency, + }) { + final periods = _chartPeriodsFor(favourite.period, txs); + Map monthly = const {}; + var labelSpend = const <_LabelSpend>[]; + Map> labelAmountByPeriod = const {}; + + if (favourite.metric == OverviewMetricMode.flow) { + monthly = _flowTotals(txs, meta, periods, favourite.period); + } else { + final labels = _labelAggregates( + txs, + meta, + periods, + favourite.period, + includedLabelIds: favourite.labelIds.toSet(), + flow: favourite.flow, + ); + labelSpend = labels.spend; + labelAmountByPeriod = labels.amountByPeriod; + } + + final periodTitle = favourite.period == OverviewPeriodMode.monthly + ? '${_cursor.year}' + : 'All years'; + + return _ChartCard( + period: favourite.period, + metric: favourite.metric, + heading: favourite.name, + periodTitle: periodTitle, + canGoBack: favourite.period == OverviewPeriodMode.monthly && + _cursor.year > _nowMonth.year - 20, + canGoForward: favourite.period == OverviewPeriodMode.monthly && + _cursor.year < _nowMonth.year, + onBack: () => _go(-1), + onForward: () => _go(1), + months: periods, + monthly: monthly, + labelSpend: labelSpend, + labelAmountByMonth: labelAmountByPeriod, + labelBarStyle: favourite.barStyle, + invertChart: favourite.flow == TxFlowFilter.all && + favourite.invertExpensePerspective, + currency: currency, + onPickLabels: null, + includedLabelCount: null, + availableLabelCount: meta.allLabels.length, + onPeriodModeChanged: (mode) { + context.read().updateOverviewFavourite( + favourite.copyWith(period: mode), + ); + }, + onEdit: () { + showEditOverviewFavouriteSheet(context, favourite: favourite); + }, + onLabelTap: (label) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => LabelTransactionsScreen(label: label), + ), + ); + }, + onMonthTap: (month) => _openPeriod(context, month), + ); + } + Widget _buildBody( BuildContext context, BankController bank, TxMetaController meta, + PreferencesController prefs, ) { final theme = Theme.of(context); @@ -231,7 +507,7 @@ class _OverviewScreenState extends State { var labelSpend = const <_LabelSpend>[]; Map> labelAmountByPeriod = const {}; - if (_metric == _MetricMode.flow) { + if (_metric == OverviewMetricMode.flow) { monthly = _flowTotals(txs, meta, periods, _period); } else { final labels = _labelAggregates( @@ -246,7 +522,7 @@ class _OverviewScreenState extends State { labelAmountByPeriod = labels.amountByPeriod; } - final summaryFlow = _metric == _MetricMode.flow + final summaryFlow = _metric == OverviewMetricMode.flow ? _sumBuckets(monthly.values) : const _MonthBucket(income: 0, expense: 0); @@ -266,8 +542,12 @@ class _OverviewScreenState extends State { _ChartToolbar( period: _period, metric: _metric, + hasDisplayedFavourites: prefs.hasDisplayedOverviewFavourites, onPeriodChanged: _setPeriod, onMetricChanged: (value) => setState(() => _metric = value), + onOpenFavourites: _openFavouritesView, + onSaveFavourite: _saveCurrentAsFavourite, + onManageFavourites: _openFavouritesManager, ), const SizedBox(height: 10), _ChartCard( @@ -303,7 +583,7 @@ class _OverviewScreenState extends State { onMonthTap: (month) => _openPeriod(context, month), ), const SizedBox(height: 14), - if (_metric == _MetricMode.flow) ...[ + if (_metric == OverviewMetricMode.flow) ...[ Row( children: [ Expanded( @@ -348,7 +628,7 @@ class _OverviewScreenState extends State { ], const SizedBox(height: 22), Text( - _metric == _MetricMode.flow + _metric == OverviewMetricMode.flow ? 'History · Income & expenses' : 'History · Labels', style: theme.textTheme.titleLarge?.copyWith( @@ -357,11 +637,11 @@ class _OverviewScreenState extends State { ), const SizedBox(height: 4), Text( - _period == _PeriodMode.monthly - ? (_metric == _MetricMode.flow + _period == OverviewPeriodMode.monthly + ? (_metric == OverviewMetricMode.flow ? 'Months in ${_cursor.year}' : 'Per-label totals by month in ${_cursor.year}') - : (_metric == _MetricMode.flow + : (_metric == OverviewMetricMode.flow ? 'One row per year' : 'Per-label totals by year'), style: theme.textTheme.labelMedium, @@ -392,8 +672,8 @@ class _OverviewScreenState extends State { List _historyTiles({ required List historyPeriods, - required _MetricMode metric, - required _PeriodMode periodMode, + required OverviewMetricMode metric, + required OverviewPeriodMode periodMode, required Map monthly, required List<_LabelSpend> labelSpend, required Map> labelAmountByPeriod, @@ -406,7 +686,7 @@ class _OverviewScreenState extends State { for (final period in historyPeriods) { final key = _periodKey(period, periodMode); final title = _periodAxisLabel(period, periodMode); - if (metric == _MetricMode.flow) { + if (metric == OverviewMetricMode.flow) { out.add( _HistoryCard( month: period, @@ -450,7 +730,7 @@ class _OverviewScreenState extends State { DateTime period, { String? labelId, }) { - final yearly = _period == _PeriodMode.yearly; + final yearly = _period == OverviewPeriodMode.yearly; final initial = yearly ? DateTime(period.year, 12) : period; final labelIds = labelId != null ? {labelId} @@ -498,13 +778,13 @@ DateTime? _txDate(BankTransaction tx) { return parsed == null ? null : dateOnly(parsed); } -DateTime _periodKey(DateTime date, _PeriodMode mode) { - if (mode == _PeriodMode.yearly) return DateTime(date.year); +DateTime _periodKey(DateTime date, OverviewPeriodMode mode) { + if (mode == OverviewPeriodMode.yearly) return DateTime(date.year); return monthStart(date); } -String _periodAxisLabel(DateTime period, _PeriodMode mode) { - if (mode == _PeriodMode.yearly) return '${period.year}'; +String _periodAxisLabel(DateTime period, OverviewPeriodMode mode) { + if (mode == OverviewPeriodMode.yearly) return '${period.year}'; return _shortMonth(period); } @@ -512,7 +792,7 @@ Map _flowTotals( List txs, TxMetaController meta, List periods, - _PeriodMode mode, + OverviewPeriodMode mode, ) { final keys = {for (final p in periods) _periodKey(p, mode)}; final totals = { @@ -565,11 +845,14 @@ class _LabelAggregates { /// the signed net is kept (can be negative). For In/Out, bars use absolute nets. /// Chart-only invert is applied later in [_LabelMonthBars], not here — history /// always follows bank transaction signs. +/// +/// Empty [includedLabelIds] = every real label (not uncategorized). Include +/// [kUncategorizedFilterId] explicitly for unlabeled txs. _LabelAggregates _labelAggregates( List txs, TxMetaController meta, List periods, - _PeriodMode mode, { + OverviewPeriodMode mode, { Set includedLabelIds = const {}, TxFlowFilter flow = TxFlowFilter.expense, }) { @@ -582,6 +865,12 @@ _LabelAggregates _labelAggregates( for (final key in keys) key: {}, }; final filtering = includedLabelIds.isNotEmpty; + final wantUncategorized = + includedLabelIds.contains(kUncategorizedFilterId); + final realFilterIds = { + for (final id in includedLabelIds) + if (id != kUncategorizedFilterId) id, + }; final keepSigned = flow == TxFlowFilter.all; for (final tx in txs) { @@ -593,21 +882,32 @@ _LabelAggregates _labelAggregates( if (!keys.contains(key)) continue; final ids = meta.labelIdsFor(tx); - if (ids.isEmpty) continue; final amount = tx.amount; + if (ids.isEmpty) { + if (filtering && !wantUncategorized) continue; + if (!filtering) continue; // default "all labels" skips unlabeled + allByLabel[kUncategorizedFilterId] = + (allByLabel[kUncategorizedFilterId] ?? 0) + amount; + byPeriodSigned[key] = byPeriodSigned[key]! + amount; + final bucket = amountByPeriodSigned[key]!; + bucket[kUncategorizedFilterId] = + (bucket[kUncategorizedFilterId] ?? 0) + amount; + continue; + } + for (final id in ids) { allByLabel[id] = (allByLabel[id] ?? 0) + amount; } final matchesFilter = - !filtering || ids.any(includedLabelIds.contains); + !filtering || ids.any(realFilterIds.contains); if (!matchesFilter) continue; byPeriodSigned[key] = byPeriodSigned[key]! + amount; final bucket = amountByPeriodSigned[key]!; for (final id in ids) { - if (filtering && !includedLabelIds.contains(id)) continue; + if (filtering && !realFilterIds.contains(id)) continue; bucket[id] = (bucket[id] ?? 0) + amount; } } @@ -617,11 +917,16 @@ _LabelAggregates _labelAggregates( return signed; } + TxLabel? resolveLabel(String id) { + if (id == kUncategorizedFilterId) return kUncategorizedFilterLabel; + return meta.labelById(id); + } + final allSpend = <_LabelSpend>[ for (final entry in allByLabel.entries) - if (meta.labelById(entry.key) != null && entry.value != 0) + if (resolveLabel(entry.key) != null && entry.value != 0) _LabelSpend( - label: meta.labelById(entry.key)!, + label: resolveLabel(entry.key)!, amount: displayAmount(entry.value), ), ]..sort((a, b) => b.amount.abs().compareTo(a.amount.abs())); @@ -686,14 +991,22 @@ class _ChartToolbar extends StatelessWidget { const _ChartToolbar({ required this.period, required this.metric, + required this.hasDisplayedFavourites, required this.onPeriodChanged, required this.onMetricChanged, + required this.onOpenFavourites, + required this.onSaveFavourite, + required this.onManageFavourites, }); - final _PeriodMode period; - final _MetricMode metric; - final ValueChanged<_PeriodMode> onPeriodChanged; - final ValueChanged<_MetricMode> onMetricChanged; + final OverviewPeriodMode period; + final OverviewMetricMode metric; + final bool hasDisplayedFavourites; + final ValueChanged onPeriodChanged; + final ValueChanged onMetricChanged; + final VoidCallback onOpenFavourites; + final VoidCallback onSaveFavourite; + final VoidCallback onManageFavourites; static const _segmentStyle = ButtonStyle( visualDensity: VisualDensity(horizontal: -4, vertical: -3), @@ -704,18 +1017,20 @@ class _ChartToolbar extends StatelessWidget { @override Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Row( children: [ - SegmentedButton<_PeriodMode>( + SegmentedButton( showSelectedIcon: false, style: _segmentStyle, segments: const [ ButtonSegment( - value: _PeriodMode.monthly, + value: OverviewPeriodMode.monthly, label: Text('Month'), ), ButtonSegment( - value: _PeriodMode.yearly, + value: OverviewPeriodMode.yearly, label: Text('Year'), ), ], @@ -725,16 +1040,16 @@ class _ChartToolbar extends StatelessWidget { }, ), const Spacer(), - SegmentedButton<_MetricMode>( + SegmentedButton( showSelectedIcon: false, style: _segmentStyle, segments: const [ ButtonSegment( - value: _MetricMode.flow, + value: OverviewMetricMode.flow, label: Text('Flow'), ), ButtonSegment( - value: _MetricMode.labels, + value: OverviewMetricMode.labels, label: Text('Labels'), ), ], @@ -743,6 +1058,69 @@ class _ChartToolbar extends StatelessWidget { onMetricChanged(selection.first); }, ), + const SizedBox(width: 6), + MenuAnchor( + alignmentOffset: const Offset(0, 4), + style: MenuStyle( + alignment: AlignmentDirectional.bottomEnd, + backgroundColor: + WidgetStatePropertyAll(colors.surfaceContainerHigh), + surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent), + elevation: const WidgetStatePropertyAll(6), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + builder: (context, controller, child) { + return IconButton( + tooltip: 'Favourites', + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + visualDensity: + const VisualDensity(horizontal: -4, vertical: -4), + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: hasDisplayedFavourites + ? colors.primary.withValues(alpha: 0.22) + : colors.surfaceContainerHigh, + foregroundColor: hasDisplayedFavourites + ? colors.primary + : colors.onSurfaceVariant, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + icon: Icon( + hasDisplayedFavourites ? Icons.star : Icons.star_outline, + size: 20, + ), + ); + }, + menuChildren: [ + MenuItemButton( + leadingIcon: const Icon(Icons.star_outline), + onPressed: onSaveFavourite, + child: const Text('Save current filter'), + ), + MenuItemButton( + leadingIcon: const Icon(Icons.tune_outlined), + onPressed: onManageFavourites, + child: const Text('Manage favourites'), + ), + if (hasDisplayedFavourites) + MenuItemButton( + leadingIcon: const Icon(Icons.grid_view_outlined), + onPressed: onOpenFavourites, + child: const Text('View favourites'), + ), + ], + ), ], ); } @@ -752,6 +1130,7 @@ class _ChartCard extends StatelessWidget { const _ChartCard({ required this.period, required this.metric, + this.heading, required this.periodTitle, required this.canGoBack, required this.canGoForward, @@ -769,10 +1148,13 @@ class _ChartCard extends StatelessWidget { required this.availableLabelCount, required this.onLabelTap, required this.onMonthTap, + this.onPeriodModeChanged, + this.onEdit, }); - final _PeriodMode period; - final _MetricMode metric; + final OverviewPeriodMode period; + final OverviewMetricMode metric; + final String? heading; final String periodTitle; final bool canGoBack; final bool canGoForward; @@ -785,11 +1167,20 @@ class _ChartCard extends StatelessWidget { final LabelChartBarStyle labelBarStyle; final bool invertChart; final String currency; - final VoidCallback onPickLabels; + final VoidCallback? onPickLabels; final int? includedLabelCount; final int availableLabelCount; final void Function(TxLabel label) onLabelTap; final void Function(DateTime month) onMonthTap; + final ValueChanged? onPeriodModeChanged; + final VoidCallback? onEdit; + + static const _compactSegmentStyle = ButtonStyle( + visualDensity: VisualDensity(horizontal: -4, vertical: -3), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 8)), + minimumSize: WidgetStatePropertyAll(Size(0, 32)), + ); Widget _filterButton(ColorScheme colors) { return IconButton( @@ -825,11 +1216,32 @@ class _ChartCard extends StatelessWidget { ); } + Widget _editButton(ColorScheme colors) { + return IconButton( + tooltip: 'Edit favourite', + onPressed: onEdit, + visualDensity: const VisualDensity(horizontal: -4, vertical: -4), + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: colors.surfaceContainerHigh, + foregroundColor: colors.onSurfaceVariant, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + icon: const Icon(Icons.edit_outlined, size: 18), + ); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; - final showFilter = metric == _MetricMode.labels; + final showFilter = + metric == OverviewMetricMode.labels && onPickLabels != null; + final showFavouriteChrome = + onPeriodModeChanged != null || onEdit != null; return Card( color: colors.surfaceContainer, @@ -841,7 +1253,64 @@ class _ChartCard extends StatelessWidget { padding: const EdgeInsets.fromLTRB(14, 12, 14, 16), child: Column( children: [ - if (period == _PeriodMode.monthly) + if (showFavouriteChrome) ...[ + Row( + children: [ + if (onPeriodModeChanged != null) + SegmentedButton( + showSelectedIcon: false, + style: _compactSegmentStyle, + segments: const [ + ButtonSegment( + value: OverviewPeriodMode.monthly, + label: Text('Month'), + ), + ButtonSegment( + value: OverviewPeriodMode.yearly, + label: Text('Year'), + ), + ], + selected: {period}, + onSelectionChanged: (selection) { + onPeriodModeChanged!(selection.first); + }, + ) + else + const SizedBox.shrink(), + if (heading != null) ...[ + const SizedBox(width: 10), + Expanded( + child: Text( + heading!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ] else + const Spacer(), + if (onEdit != null) ...[ + const SizedBox(width: 6), + _editButton(colors), + ], + ], + ), + const SizedBox(height: 8), + ] else if (heading != null) ...[ + Align( + alignment: Alignment.centerLeft, + child: Text( + heading!, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 8), + ], + if (period == OverviewPeriodMode.monthly) Row( children: [ IconButton( @@ -890,7 +1359,6 @@ class _ChartCard extends StatelessWidget { else Row( children: [ - // Balance the filter so the title stays centered. SizedBox(width: showFilter ? 36 : 0), Expanded( child: Text( @@ -907,7 +1375,7 @@ class _ChartCard extends StatelessWidget { const SizedBox(width: 0), ], ), - if (metric == _MetricMode.flow) ...[ + if (metric == OverviewMetricMode.flow) ...[ const Row( mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -945,7 +1413,7 @@ class _ChartCard extends StatelessWidget { ], SizedBox( height: 220, - child: metric == _MetricMode.flow + child: metric == OverviewMetricMode.flow ? _FlowBars( months: months, monthly: monthly, @@ -981,7 +1449,7 @@ class _FlowBars extends StatelessWidget { final List months; final Map monthly; final String currency; - final _PeriodMode period; + final OverviewPeriodMode period; @override Widget build(BuildContext context) { @@ -1002,7 +1470,7 @@ class _FlowBars extends StatelessWidget { if (empty) { return Center( child: Text( - period == _PeriodMode.yearly + period == OverviewPeriodMode.yearly ? 'No income or expenses in these years' : 'No income or expenses in this year', style: theme.textTheme.bodyMedium, @@ -1014,7 +1482,7 @@ class _FlowBars extends StatelessWidget { return BarChart( BarChartData( maxY: safeMax, - groupsSpace: period == _PeriodMode.yearly ? 16 : 10, + groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10, barTouchData: BarTouchData( touchTooltipData: BarTouchTooltipData( getTooltipItem: (group, groupIndex, rod, rodIndex) { @@ -1063,7 +1531,7 @@ class _FlowBars extends StatelessWidget { if (index < 0 || index >= months.length) { return const SizedBox.shrink(); } - if (period == _PeriodMode.monthly && + if (period == OverviewPeriodMode.monthly && index % 2 == 1 && months.length > 8) { return const SizedBox.shrink(); @@ -1098,14 +1566,14 @@ class _FlowBars extends StatelessWidget { barRods: [ BarChartRodData( toY: monthly[_periodKey(months[i], period)]!.expense, - width: period == _PeriodMode.yearly ? 14 : 9, + width: period == OverviewPeriodMode.yearly ? 14 : 9, borderRadius: const BorderRadius.vertical(top: Radius.circular(6)), color: _kExpenseColor, ), BarChartRodData( toY: monthly[_periodKey(months[i], period)]!.income, - width: period == _PeriodMode.yearly ? 14 : 9, + width: period == OverviewPeriodMode.yearly ? 14 : 9, borderRadius: const BorderRadius.vertical(top: Radius.circular(6)), color: _kIncomeColor, @@ -1137,7 +1605,7 @@ class _LabelMonthBars extends StatelessWidget { final List<_LabelSpend> labelSpend; final Map> amountByMonth; final String currency; - final _PeriodMode period; + final OverviewPeriodMode period; final LabelChartBarStyle barStyle; final bool invertChart; final void Function(DateTime month) onMonthTap; @@ -1225,8 +1693,8 @@ class _LabelMonthBars extends StatelessWidget { final safeMin = minY - (minY == 0 && maxY > 0 ? 0 : pad); final rodWidth = stacked - ? (period == _PeriodMode.yearly ? 18.0 : 14.0) - : period == _PeriodMode.yearly + ? (period == OverviewPeriodMode.yearly ? 18.0 : 14.0) + : period == OverviewPeriodMode.yearly ? (seriesCount <= 3 ? 12.0 : 8.0) : (seriesCount <= 3 ? 7.0 @@ -1314,7 +1782,7 @@ class _LabelMonthBars extends StatelessWidget { BarChartData( minY: safeMin, maxY: safeMax, - groupsSpace: period == _PeriodMode.yearly ? 16 : 10, + groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10, barTouchData: BarTouchData( touchCallback: (event, response) { if (event is! FlTapUpEvent) return; @@ -1396,7 +1864,7 @@ class _LabelMonthBars extends StatelessWidget { if (index < 0 || index >= months.length) { return const SizedBox.shrink(); } - if (period == _PeriodMode.monthly && + if (period == OverviewPeriodMode.monthly && index % 2 == 1 && months.length > 8) { return const SizedBox.shrink(); diff --git a/lib/screens/transactions_screen.dart b/lib/screens/transactions_screen.dart index ef533fc..4fe6b67 100644 --- a/lib/screens/transactions_screen.dart +++ b/lib/screens/transactions_screen.dart @@ -9,6 +9,7 @@ import 'package:provider/provider.dart'; import '../models/bank.dart'; import '../models/transaction_meta.dart'; import '../providers/bank_provider.dart'; +import '../providers/preferences_provider.dart'; import '../providers/tx_meta_provider.dart'; import '../utils/app_theme.dart'; import '../utils/connect_bank.dart'; @@ -16,6 +17,7 @@ import '../utils/helpers.dart'; import '../utils/navigation.dart'; import '../utils/tx_calendar.dart'; import '../widgets/empty_state.dart'; +import '../widgets/home_widgets_sheet.dart'; import '../widgets/label_filter_dialog.dart'; import '../widgets/loading_view.dart'; import '../widgets/responsive_body.dart'; @@ -30,6 +32,9 @@ class TransactionsScreen extends StatefulWidget { this.initialLabelIds = const {}, this.initialSearchInMonthOnly, this.initialYear, + this.initialQuery, + this.initialFlow, + this.initialAmountRange, }); /// When set, opens on this calendar month instead of the current month. @@ -44,6 +49,12 @@ class TransactionsScreen extends StatefulWidget { /// When set, label/search results are limited to this calendar year. final int? initialYear; + /// Pre-fill search query (e.g. from a Home filter widget). + final String? initialQuery; + + final TxFlowFilter? initialFlow; + final TxAmountRange? initialAmountRange; + @override State createState() => _TransactionsScreenState(); } @@ -81,6 +92,13 @@ class _TransactionsScreenState extends State { // Default to current month so the list, navigator, and filter popup agree. _searchInMonthOnly = widget.initialSearchInMonthOnly ?? true; _yearFilter = widget.initialYear; + _flowFilter = widget.initialFlow ?? TxFlowFilter.all; + _amountRange = widget.initialAmountRange ?? const TxAmountRange(); + final initialQuery = widget.initialQuery?.trim() ?? ''; + if (initialQuery.isNotEmpty) { + _searchController.text = initialQuery; + _activeQuery = initialQuery; + } } @override @@ -222,6 +240,26 @@ class _TransactionsScreenState extends State { ); } + Future _saveAsHomeWidget() async { + final created = await composeHomeFilterWidget( + context, + initialFilter: TransactionFilterSelection( + labelIds: Set.of(_labelFilters), + flow: _flowFilter, + amountRange: _amountRange, + query: _activeQuery, + monthOnly: _searchInMonthOnly, + ), + skipFilterDialog: true, + ); + if (created == null || !mounted) return; + await context.read().addHomeFilterWidget(created); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Added “${created.name}” to Home')), + ); + } + @override Widget build(BuildContext context) { final bank = context.watch(); @@ -229,11 +267,18 @@ class _TransactionsScreenState extends State { return Scaffold( appBar: AppBar( title: const Text('Transactions'), - actions: bankAppBarActions( - context: context, - onRefresh: bank.refresh, - refreshing: bank.isLoading, - ), + actions: [ + IconButton( + tooltip: 'Save as Home widget', + onPressed: _saveAsHomeWidget, + icon: const Icon(Icons.dashboard_customize_outlined), + ), + ...bankAppBarActions( + context: context, + onRefresh: bank.refresh, + refreshing: bank.isLoading, + ), + ], ), body: Column( children: [ diff --git a/lib/services/pb_sync_service.dart b/lib/services/pb_sync_service.dart index ebd92f9..576caab 100644 --- a/lib/services/pb_sync_service.dart +++ b/lib/services/pb_sync_service.dart @@ -8,7 +8,10 @@ import 'package:flutter/material.dart'; import 'package:pocketbase/pocketbase.dart'; import '../models/bank.dart'; +import '../models/home_filter_widget.dart'; +import '../models/overview_favourite.dart'; import '../models/transaction_meta.dart'; +import '../widgets/label_filter_dialog.dart'; import 'ing_csv_import.dart'; import 'storage_service.dart'; @@ -20,6 +23,20 @@ List mergeBankTransactionLists( return mergeTransactions(local, remote); } +/// Union Overview favourites by client id (local wins on conflict). +List mergeOverviewFavourites( + List local, + List remote, +) { + final byId = { + for (final fav in remote) fav.id: fav, + }; + for (final fav in local) { + byId[fav.id] = fav; + } + return byId.values.toList(growable: false); +} + /// Union-merge so neither device loses annotations on first sync. TxMetaStoreData mergeTxMetaStores(TxMetaStoreData a, TxMetaStoreData b) { final byId = {...a.byId}; @@ -426,6 +443,110 @@ class PbSyncService { return txSummary; } + /// Pull Overview favourites for the signed-in user. + /// + /// Soft-fails at the call site if the collection is missing. + Future> pullOverviewFavourites({ + void Function(String message)? onStatus, + }) async { + final userId = _userId; + final filter = 'user="$userId"'; + onStatus?.call('Pulling overview favourites…'); + final records = await _fullList('overview_favourites', filter); + final out = []; + for (final r in records) { + final clientId = r.getStringValue('client_id'); + if (clientId.isEmpty) continue; + out.add( + OverviewFavourite( + id: clientId, + name: r.getStringValue('name').isEmpty + ? 'Favourite' + : r.getStringValue('name'), + display: r.getBoolValue('display', true), + metric: OverviewMetricMode.values.firstWhere( + (e) => e.name == r.getStringValue('metric'), + orElse: () => OverviewMetricMode.flow, + ), + period: OverviewPeriodMode.values.firstWhere( + (e) => e.name == r.getStringValue('period'), + orElse: () => OverviewPeriodMode.monthly, + ), + labelIds: _jsonStringList(r.get('label_ids')), + flow: TxFlowFilter.values.firstWhere( + (e) => e.name == r.getStringValue('flow'), + orElse: () => TxFlowFilter.expense, + ), + barStyle: LabelChartBarStyle.values.firstWhere( + (e) => e.name == r.getStringValue('bar_style'), + orElse: () => LabelChartBarStyle.parallel, + ), + invertExpensePerspective: + r.getBoolValue('invert_expense_perspective'), + ), + ); + } + debugPrint('PbSyncService pull: overview_favourites=${out.length}'); + return out; + } + + /// Upsert Overview favourites; deletes remote rows no longer local. + Future pushOverviewFavourites( + List favourites, { + void Function(String message)? onStatus, + }) async { + final userId = _userId; + final filter = 'user="$userId"'; + onStatus?.call('Syncing overview favourites…'); + await _syncOverviewFavourites(userId, filter, favourites); + } + + /// Pull Home filter widgets for the signed-in user. + Future> pullHomeFilterWidgets({ + void Function(String message)? onStatus, + }) async { + final userId = _userId; + final filter = 'user="$userId"'; + onStatus?.call('Pulling home widgets…'); + final records = await _fullList('home_filter_widgets', filter); + final out = []; + for (final r in records) { + final clientId = r.getStringValue('client_id'); + if (clientId.isEmpty) continue; + final rangeRaw = r.get('amount_range'); + out.add( + HomeFilterWidget.fromJson({ + 'id': clientId, + 'name': r.getStringValue('name'), + 'display': r.getBoolValue('display', true), + 'sortOrder': r.getIntValue('sort_order', 0), + 'labelIds': _jsonStringList(r.get('label_ids')), + 'flow': r.getStringValue('flow'), + 'amountRange': rangeRaw is Map + ? Map.from(rangeRaw) + : const {}, + 'query': r.getStringValue('query'), + 'monthOnly': r.getBoolValue('month_only'), + 'showAmountIn': r.getBoolValue('show_amount_in', true), + 'showAmountOut': r.getBoolValue('show_amount_out', true), + 'showCount': r.getBoolValue('show_count', true), + }), + ); + } + debugPrint('PbSyncService pull: home_filter_widgets=${out.length}'); + return out; + } + + Future pushHomeFilterWidgets( + List widgets, { + void Function(String message)? onStatus, + }) async { + final userId = _userId; + final filter = 'user="$userId"'; + onStatus?.call('Syncing home widgets…'); + await _syncHomeFilterWidgets(userId, filter, widgets); + } + /// Upserts local bank txs to PocketBase. Does **not** delete remote rows /// (partial local caches must not wipe another device). Future pushTransactions( @@ -688,6 +809,18 @@ class PbSyncService { filter = 'user="$userId" && client_id="${key.replaceAll('"', '\\"')}"'; } + case 'overview_favourites': + final key = body['client_id']; + if (userId != null && key is String) { + filter = + 'user="$userId" && client_id="${key.replaceAll('"', '\\"')}"'; + } + case 'home_filter_widgets': + final key = body['client_id']; + if (userId != null && key is String) { + filter = + 'user="$userId" && client_id="${key.replaceAll('"', '\\"')}"'; + } case 'transactions': final key = body['tx_id']; if (userId != null && key is String) { @@ -993,6 +1126,111 @@ class PbSyncService { await _runWriteOps('recurring_templates', ops); } + Future _syncOverviewFavourites( + String userId, + String filter, + List favourites, + ) async { + final existing = { + for (final r in await _fullList('overview_favourites', filter)) + r.getStringValue('client_id'): r, + }; + final keep = {}; + final ops = <_BatchOp>[]; + + for (final fav in favourites) { + final clientId = _clip(fav.id, 120); + if (clientId.isEmpty) continue; + keep.add(clientId); + final body = { + 'user': userId, + 'client_id': clientId, + 'name': _clip(fav.name.isEmpty ? 'Favourite' : fav.name, 200), + 'display': fav.display, + 'metric': fav.metric.name, + 'period': fav.period.name, + 'label_ids': fav.labelIds, + 'flow': fav.flow.name, + 'bar_style': fav.barStyle.name, + 'invert_expense_perspective': fav.invertExpensePerspective, + }; + final remote = existing[clientId]; + if (remote == null) { + ops.add(_BatchOp.create(body)); + } else if (!_overviewFavouriteUnchanged(remote, fav)) { + ops.add(_BatchOp.update(remote.id, body)); + } + } + + for (final e in existing.entries) { + if (!keep.contains(e.key)) { + ops.add(_BatchOp.delete(e.value.id)); + } + } + + debugPrint( + 'PbSyncService overview_favourites: remote=${existing.length} ' + 'ops=${ops.length}', + ); + await _runWriteOps('overview_favourites', ops); + } + + Future _syncHomeFilterWidgets( + String userId, + String filter, + List widgets, + ) async { + final existing = { + for (final r in await _fullList('home_filter_widgets', filter)) + r.getStringValue('client_id'): r, + }; + final keep = {}; + final ops = <_BatchOp>[]; + + for (final w in widgets) { + final clientId = _clip(w.id, 120); + if (clientId.isEmpty) continue; + keep.add(clientId); + final body = { + 'user': userId, + 'client_id': clientId, + 'name': _clip(w.name.isEmpty ? 'Filter' : w.name, 200), + 'display': w.display, + 'sort_order': w.sortOrder, + 'label_ids': w.labelIds, + 'flow': w.flow.name, + 'amount_range': { + if (w.amountRange.exact != null) 'exact': w.amountRange.exact, + if (w.amountRange.min != null) 'min': w.amountRange.min, + if (w.amountRange.max != null) 'max': w.amountRange.max, + }, + 'query': _clip(w.query, 400), + 'month_only': w.monthOnly, + 'show_amount_in': w.showAmountIn, + 'show_amount_out': w.showAmountOut, + 'show_count': w.showCount, + }; + final remote = existing[clientId]; + if (remote == null) { + ops.add(_BatchOp.create(body)); + } else if (!_homeFilterWidgetUnchanged(remote, w)) { + ops.add(_BatchOp.update(remote.id, body)); + } + } + + for (final e in existing.entries) { + if (!keep.contains(e.key)) { + ops.add(_BatchOp.delete(e.value.id)); + } + } + + debugPrint( + 'PbSyncService home_filter_widgets: remote=${existing.length} ' + 'ops=${ops.length}', + ); + await _runWriteOps('home_filter_widgets', ops); + } + bool _labelUnchanged(RecordModel remote, Map body) { return remote.getStringValue('name') == body['name'] && remote.getIntValue('color') == body['color'] && @@ -1035,6 +1273,41 @@ class PbSyncService { remote.getIntValue('avg_count', 5) == t.avgCount && remote.getBoolValue('auto_from_recurring') == t.autoFromRecurring; } + + bool _overviewFavouriteUnchanged(RecordModel remote, OverviewFavourite fav) { + final remoteIds = _jsonStringList(remote.get('label_ids')); + return remote.getStringValue('name') == fav.name && + remote.getBoolValue('display', true) == fav.display && + remote.getStringValue('metric') == fav.metric.name && + remote.getStringValue('period') == fav.period.name && + _sameIdList(remoteIds, fav.labelIds) && + remote.getStringValue('flow') == fav.flow.name && + remote.getStringValue('bar_style') == fav.barStyle.name && + remote.getBoolValue('invert_expense_perspective') == + fav.invertExpensePerspective; + } + + bool _homeFilterWidgetUnchanged(RecordModel remote, HomeFilterWidget w) { + final remoteIds = _jsonStringList(remote.get('label_ids')); + final rangeRaw = remote.get('amount_range'); + final rangeMap = rangeRaw is Map + ? Map.from(rangeRaw) + : const {}; + double? asDouble(dynamic value) => value is num ? value.toDouble() : null; + return remote.getStringValue('name') == w.name && + remote.getBoolValue('display', true) == w.display && + remote.getIntValue('sort_order', 0) == w.sortOrder && + _sameIdList(remoteIds, w.labelIds) && + remote.getStringValue('flow') == w.flow.name && + asDouble(rangeMap['exact']) == w.amountRange.exact && + asDouble(rangeMap['min']) == w.amountRange.min && + asDouble(rangeMap['max']) == w.amountRange.max && + remote.getStringValue('query') == w.query && + remote.getBoolValue('month_only') == w.monthOnly && + remote.getBoolValue('show_amount_in', true) == w.showAmountIn && + remote.getBoolValue('show_amount_out', true) == w.showAmountOut && + remote.getBoolValue('show_count', true) == w.showCount; + } } RecurringTemplateSource _parseTemplateSource(String raw) { diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index efc8e30..0d55505 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -7,6 +7,8 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/bank.dart'; +import '../models/home_filter_widget.dart'; +import '../models/overview_favourite.dart'; import '../models/transaction_meta.dart'; import '../utils/constants.dart'; @@ -133,6 +135,54 @@ class StorageService { return _prefs.setInt(StorageKeys.lookAheadMaxYears, years.clamp(1, 30)); } + List loadOverviewFavourites() { + final raw = _prefs.getString(StorageKeys.overviewFavourites); + if (raw == null || raw.isEmpty) return const []; + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return const []; + return [ + for (final item in decoded) + if (item is Map) + OverviewFavourite.fromJson(Map.from(item)), + ]; + } catch (_) { + return const []; + } + } + + Future saveOverviewFavourites(List favourites) { + final encoded = jsonEncode([ + for (final fav in favourites) fav.toJson(), + ]); + return _prefs.setString(StorageKeys.overviewFavourites, encoded); + } + + List loadHomeFilterWidgets() { + final raw = _prefs.getString(StorageKeys.homeFilterWidgets); + if (raw == null || raw.isEmpty) return const []; + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return const []; + final list = [ + for (final item in decoded) + if (item is Map) + HomeFilterWidget.fromJson(Map.from(item)), + ]; + list.sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); + return list; + } catch (_) { + return const []; + } + } + + Future saveHomeFilterWidgets(List widgets) { + final encoded = jsonEncode([ + for (final w in widgets) w.toJson(), + ]); + return _prefs.setString(StorageKeys.homeFilterWidgets, encoded); + } + bool get useRemoteData => _prefs.getBool(StorageKeys.useRemoteData) ?? false; Future setUseRemoteData(bool value) { diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 7cb2a55..7e13ea7 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -51,6 +51,12 @@ abstract final class StorageKeys { /// How many years ahead Look ahead can navigate (default 5). static const lookAheadMaxYears = 'look_ahead_max_years_v1'; + + /// Named Overview chart favourites (JSON list). + static const overviewFavourites = 'overview_favourites_v1'; + + /// Home screen filter widgets (JSON list). + static const homeFilterWidgets = 'home_filter_widgets_v1'; } /// Named routes registered on [MaterialApp]. diff --git a/lib/utils/tx_filter_metrics.dart b/lib/utils/tx_filter_metrics.dart new file mode 100644 index 0000000..86f2f75 --- /dev/null +++ b/lib/utils/tx_filter_metrics.dart @@ -0,0 +1,87 @@ +/// Apply a [TransactionFilterSelection] and compute Home widget metrics. +library; + +import '../models/bank.dart'; +import '../models/transaction_meta.dart'; +import '../providers/tx_meta_provider.dart'; +import '../utils/tx_calendar.dart'; +import '../widgets/label_filter_dialog.dart'; + +class TxFilterMetrics { + const TxFilterMetrics({ + required this.count, + required this.amountIn, + required this.amountOut, + required this.transactions, + }); + + final int count; + final double amountIn; + final double amountOut; + final List transactions; + + double get net => amountIn - amountOut; +} + +/// Filter [all] the same way Transactions does (pool → search → flow/amount). +List applyTransactionFilter({ + required List all, + required TxMetaController meta, + required TransactionFilterSelection filter, + DateTime? month, +}) { + final nowMonth = monthStart(month ?? DateTime.now()); + final pool = filter.monthOnly + ? [ + for (final tx in all) + if (_txInMonth(tx, nowMonth)) tx, + ] + : all; + + return [ + for (final tx in meta.search( + pool, + filter.query, + labelIds: filter.labelIds, + )) + if (txMatchesFlow(tx, filter.flow) && + txMatchesAmountRange(tx, filter.amountRange)) + tx, + ]; +} + +TxFilterMetrics computeTxFilterMetrics({ + required List all, + required TxMetaController meta, + required TransactionFilterSelection filter, + DateTime? month, +}) { + final filtered = applyTransactionFilter( + all: all, + meta: meta, + filter: filter, + month: month, + ); + var amountIn = 0.0; + var amountOut = 0.0; + for (final tx in filtered) { + if (meta.metaFor(tx).internalTransfer) continue; + if (tx.amount < 0) { + amountOut += tx.amount.abs(); + } else if (tx.amount > 0) { + amountIn += tx.amount; + } + } + return TxFilterMetrics( + count: filtered.length, + amountIn: amountIn, + amountOut: amountOut, + transactions: filtered, + ); +} + +bool _txInMonth(BankTransaction tx, DateTime month) { + final date = DateTime.tryParse(tx.date); + if (date == null) return false; + return isSameMonth(date, month); +} diff --git a/lib/widgets/home_filter_widget_card.dart b/lib/widgets/home_filter_widget_card.dart new file mode 100644 index 0000000..fc1e894 --- /dev/null +++ b/lib/widgets/home_filter_widget_card.dart @@ -0,0 +1,165 @@ +/// Home card for a saved transaction-filter widget. +library; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/home_filter_widget.dart'; +import '../providers/bank_provider.dart'; +import '../providers/tx_meta_provider.dart'; +import '../screens/transactions_screen.dart'; +import '../utils/app_theme.dart'; +import '../utils/helpers.dart'; +import '../utils/tx_filter_metrics.dart'; + +class HomeFilterWidgetCard extends StatelessWidget { + const HomeFilterWidgetCard({ + super.key, + required this.widgetConfig, + this.editing = false, + this.onRemove, + }); + + final HomeFilterWidget widgetConfig; + final bool editing; + final VoidCallback? onRemove; + + void _open(BuildContext context) { + final filter = widgetConfig.filter; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => TransactionsScreen( + initialLabelIds: filter.labelIds, + initialSearchInMonthOnly: filter.monthOnly, + initialQuery: filter.query, + initialFlow: filter.flow, + initialAmountRange: filter.amountRange, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final bank = context.watch(); + final meta = context.watch(); + final theme = Theme.of(context); + final colors = theme.colorScheme; + final currency = bank.balance?.currency ?? + (bank.transactions.isNotEmpty + ? bank.transactions.first.currency + : 'EUR'); + + final metrics = computeTxFilterMetrics( + all: bank.transactions, + meta: meta, + filter: widgetConfig.filter, + ); + + return Card( + color: colors.surfaceContainer, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: editing ? null : () => _open(context), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 8, 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widgetConfig.name, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 6, + children: [ + if (widgetConfig.showAmountIn) + _Metric( + label: 'In', + value: formatMoney(metrics.amountIn, currency), + color: AppTheme.accent, + ), + if (widgetConfig.showAmountOut) + _Metric( + label: 'Out', + value: formatMoney(metrics.amountOut, currency), + color: AppTheme.expense, + ), + if (widgetConfig.showCount) + _Metric( + label: 'Txs', + value: '${metrics.count}', + color: colors.primary, + ), + ], + ), + ], + ), + ), + if (editing) + IconButton( + tooltip: 'Remove from Home', + onPressed: onRemove, + icon: Icon( + Icons.remove_circle_outline, + color: colors.error, + ), + ) + else + Icon( + Icons.chevron_right, + color: colors.onSurfaceVariant, + ), + ], + ), + ), + ), + ); + } +} + +class _Metric extends StatelessWidget { + const _Metric({ + required this.label, + required this.value, + required this.color, + }); + + final String label; + final String value; + final Color color; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Text( + value, + style: theme.textTheme.titleSmall?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/home_widgets_sheet.dart b/lib/widgets/home_widgets_sheet.dart new file mode 100644 index 0000000..6e783eb --- /dev/null +++ b/lib/widgets/home_widgets_sheet.dart @@ -0,0 +1,260 @@ +/// Manage Home filter widgets: display, metrics, add/remove. +library; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/home_filter_widget.dart'; +import '../models/transaction_meta.dart'; +import '../providers/bank_provider.dart'; +import '../providers/preferences_provider.dart'; +import '../providers/tx_meta_provider.dart'; +import '../widgets/label_filter_dialog.dart'; +import '../widgets/overview_favourites_sheet.dart' show promptFavouriteName; + +Future showHomeWidgetsSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), + child: const _HomeWidgetsSheet(), + ); + }, + ); +} + +Future composeHomeFilterWidget( + BuildContext context, { + TransactionFilterSelection? initialFilter, + HomeFilterWidget? existing, + bool skipFilterDialog = false, +}) async { + TransactionFilterSelection? filter = initialFilter ?? existing?.filter; + if (!skipFilterDialog || filter == null) { + final bank = context.read(); + final meta = context.read(); + filter = await showTransactionFilterDialog( + context, + available: meta.allLabels, + transactions: bank.transactions, + initial: filter ?? const TransactionFilterSelection(labelIds: {}), + ); + if (filter == null || !context.mounted) return null; + } + + final name = await promptFavouriteName( + context, + initial: existing?.name ?? '', + title: existing == null ? 'Name this widget' : 'Rename widget', + ); + if (name == null || !context.mounted) return null; + + await Future.delayed(Duration.zero); + if (!context.mounted) return null; + + final draft = existing == null + ? HomeFilterWidget.fromFilter(name: name, filter: filter) + : existing.copyWith( + name: name, + labelIds: filter.labelIds.toList(growable: false), + flow: filter.flow, + amountRange: filter.amountRange, + query: filter.query, + monthOnly: filter.monthOnly, + ); + + return _promptWidgetMetrics(context, draft); +} + +Future _promptWidgetMetrics( + BuildContext context, + HomeFilterWidget draft, +) { + var showIn = draft.showAmountIn; + var showOut = draft.showAmountOut; + var showCount = draft.showCount; + + return showDialog( + context: context, + useRootNavigator: true, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + title: const Text('Widget metrics'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Amount in'), + value: showIn, + onChanged: (value) => + setState(() => showIn = value ?? false), + ), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Amount out'), + value: showOut, + onChanged: (value) => + setState(() => showOut = value ?? false), + ), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Number of transactions'), + value: showCount, + onChanged: (value) => + setState(() => showCount = value ?? false), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + if (!showIn && !showOut && !showCount) return; + Navigator.of(context).pop( + draft.copyWith( + showAmountIn: showIn, + showAmountOut: showOut, + showCount: showCount, + ), + ); + }, + child: const Text('Save'), + ), + ], + ); + }, + ); + }, + ); +} + +class _HomeWidgetsSheet extends StatelessWidget { + const _HomeWidgetsSheet(); + + @override + Widget build(BuildContext context) { + final prefs = context.watch(); + final theme = Theme.of(context); + final widgets = prefs.homeFilterWidgets; + + return SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.72, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Home widgets', style: theme.textTheme.titleLarge), + const SizedBox(height: 4), + Text( + 'Pin saved transaction filters on Home. Check Display to ' + 'show a widget; edit metrics or remove anytime.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: OutlinedButton.icon( + onPressed: () async { + final created = await composeHomeFilterWidget(context); + if (created == null || !context.mounted) return; + await prefs.addHomeFilterWidget(created); + }, + icon: const Icon(Icons.add), + label: const Text('Add filter widget'), + ), + ), + const Divider(height: 1), + Expanded( + child: widgets.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + 'No widgets yet. Add a filter, or save one from ' + 'Transactions.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 24), + itemCount: widgets.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final widgetItem = widgets[index]; + return CheckboxListTile( + value: widgetItem.display, + onChanged: (value) { + if (value == null) return; + prefs.setHomeFilterWidgetDisplay( + widgetItem.id, + value, + ); + }, + secondary: IconButton( + tooltip: 'Edit', + onPressed: () async { + final updated = await composeHomeFilterWidget( + context, + existing: widgetItem, + ); + if (updated == null || !context.mounted) return; + await prefs.updateHomeFilterWidget(updated); + }, + icon: const Icon(Icons.edit_outlined), + ), + title: Text(widgetItem.name), + subtitle: Text( + '${_subtitle(widgetItem)}\n' + 'Display on Home', + ), + isThreeLine: true, + controlAffinity: ListTileControlAffinity.leading, + ); + }, + ), + ), + ], + ), + ), + ); + } + + static String _subtitle(HomeFilterWidget w) { + final parts = [ + if (w.monthOnly) 'This month', + if (w.flow != TxFlowFilter.all) w.flow.name, + if (w.labelIds.isNotEmpty) '${w.labelIds.length} labels', + if (w.query.trim().isNotEmpty) '“${w.query.trim()}”', + if (w.showAmountIn) 'In', + if (w.showAmountOut) 'Out', + if (w.showCount) 'Count', + ]; + return parts.isEmpty ? 'All transactions' : parts.join(' · '); + } +} diff --git a/lib/widgets/label_filter_dialog.dart b/lib/widgets/label_filter_dialog.dart index 461975d..f416c95 100644 --- a/lib/widgets/label_filter_dialog.dart +++ b/lib/widgets/label_filter_dialog.dart @@ -446,6 +446,8 @@ class _SimpleLabelFilterDialogState extends State<_SimpleLabelFilterDialog> { onToggle: _toggle, allowMove: widget.allowMove, shrinkWrap: true, + parentOf: meta.parentIdOf, + expandAll: needle.isNotEmpty, ), ], ), @@ -868,6 +870,8 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> { onToggle: _toggle, allowMove: false, shrinkWrap: true, + parentOf: meta.parentIdOf, + expandAll: needle.isNotEmpty, ), const SizedBox(height: 12), ], @@ -914,14 +918,16 @@ List _filterLabelEntries({ ]; } -class _LabelChecklist extends StatelessWidget { +class _LabelChecklist extends StatefulWidget { const _LabelChecklist({ required this.entries, required this.showUncategorized, required this.selected, required this.onToggle, required this.allowMove, + required this.parentOf, this.shrinkWrap = false, + this.expandAll = false, }); final List entries; @@ -929,13 +935,62 @@ class _LabelChecklist extends StatelessWidget { final Set selected; final void Function(String id, bool selected) onToggle; final bool allowMove; + final String? Function(String id) parentOf; final bool shrinkWrap; + final bool expandAll; + + @override + State<_LabelChecklist> createState() => _LabelChecklistState(); +} + +class _LabelChecklistState extends State<_LabelChecklist> { + final Set _expanded = {}; + + @override + void didUpdateWidget(covariant _LabelChecklist oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.expandAll && !oldWidget.expandAll) { + _expanded + ..clear() + ..addAll({ + for (final entry in widget.entries) + if (_hasChildren(entry.label.id)) entry.label.id, + }); + } + } + + bool _hasChildren(String id) { + for (final entry in widget.entries) { + if (widget.parentOf(entry.label.id) == id) return true; + } + return false; + } + + bool _isVisible(String id) { + if (widget.expandAll) return true; + var parent = widget.parentOf(id); + while (parent != null) { + if (!_expanded.contains(parent)) return false; + parent = widget.parentOf(parent); + } + return true; + } + + void _toggleExpanded(String id) { + setState(() { + if (_expanded.contains(id)) { + _expanded.remove(id); + } else { + _expanded.add(id); + } + }); + } @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; - if (entries.isEmpty && !showUncategorized) { + if (widget.entries.isEmpty && !widget.showUncategorized) { return DecoratedBox( decoration: BoxDecoration( color: colors.surfaceContainerHighest, @@ -958,6 +1013,11 @@ class _LabelChecklist extends StatelessWidget { ); } + final visibleEntries = [ + for (final entry in widget.entries) + if (_isVisible(entry.label.id)) entry, + ]; + return DecoratedBox( decoration: BoxDecoration( color: colors.surfaceContainerHighest, @@ -969,12 +1029,13 @@ class _LabelChecklist extends StatelessWidget { child: ClipRRect( borderRadius: BorderRadius.circular(10), child: ListView.separated( - shrinkWrap: shrinkWrap, - physics: shrinkWrap + shrinkWrap: widget.shrinkWrap, + physics: widget.shrinkWrap ? const NeverScrollableScrollPhysics() : null, padding: const EdgeInsets.symmetric(vertical: 4), - itemCount: entries.length + (showUncategorized ? 1 : 0), + itemCount: + visibleEntries.length + (widget.showUncategorized ? 1 : 0), separatorBuilder: (_, _) => Divider( height: 1, thickness: 1, @@ -982,28 +1043,35 @@ class _LabelChecklist extends StatelessWidget { color: colors.outlineVariant.withValues(alpha: 0.45), ), itemBuilder: (context, index) { - if (showUncategorized && index == 0) { + if (widget.showUncategorized && index == 0) { return _FilterRow( depth: 0, icon: kUncategorizedFilterLabel.icon, color: kUncategorizedFilterLabel.color, name: kUncategorizedFilterLabel.name, - checked: selected.contains(kUncategorizedFilterId), + checked: widget.selected.contains(kUncategorizedFilterId), onToggle: (value) => - onToggle(kUncategorizedFilterId, value), + widget.onToggle(kUncategorizedFilterId, value), ); } - final entry = - entries[showUncategorized ? index - 1 : index]; + final entry = visibleEntries[ + widget.showUncategorized ? index - 1 : index]; final label = entry.label; + final hasChildren = _hasChildren(label.id); + final expanded = + widget.expandAll || _expanded.contains(label.id); return _FilterRow( depth: entry.depth, icon: label.icon, color: label.color, name: label.name, - checked: selected.contains(label.id), - onToggle: (value) => onToggle(label.id, value), - onLongPress: allowMove + checked: widget.selected.contains(label.id), + onToggle: (value) => widget.onToggle(label.id, value), + hasChildren: hasChildren, + expanded: expanded, + onExpandToggle: + hasChildren ? () => _toggleExpanded(label.id) : null, + onLongPress: widget.allowMove ? () async { await showMoveLabelDialog(context, label: label); } @@ -1134,6 +1202,9 @@ class _FilterRow extends StatelessWidget { required this.checked, required this.onToggle, this.onLongPress, + this.hasChildren = false, + this.expanded = false, + this.onExpandToggle, }); final int depth; @@ -1143,26 +1214,52 @@ class _FilterRow extends StatelessWidget { final bool checked; final ValueChanged onToggle; final VoidCallback? onLongPress; + final bool hasChildren; + final bool expanded; + final VoidCallback? onExpandToggle; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; return InkWell( - onTap: () => onToggle(!checked), + onTap: () { + if (hasChildren && onExpandToggle != null) { + onExpandToggle!(); + } else { + onToggle(!checked); + } + }, onLongPress: onLongPress, child: Padding( padding: EdgeInsets.fromLTRB(8 + depth * 16.0, 2, 4, 2), child: Row( children: [ - if (depth > 0) ...[ - Icon( - Icons.subdirectory_arrow_right, - size: 14, - color: colors.onSurfaceVariant, - ), - const SizedBox(width: 4), - ], + SizedBox( + width: 28, + height: 32, + child: hasChildren + ? IconButton( + tooltip: expanded ? 'Collapse' : 'Expand', + onPressed: onExpandToggle, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + icon: Icon( + expanded + ? Icons.expand_more + : Icons.chevron_right, + size: 20, + color: colors.onSurfaceVariant, + ), + ) + : depth > 0 + ? Icon( + Icons.subdirectory_arrow_right, + size: 14, + color: colors.onSurfaceVariant, + ) + : null, + ), Icon(icon, size: 18, color: color), const SizedBox(width: 10), Expanded( diff --git a/lib/widgets/overview_favourites_sheet.dart b/lib/widgets/overview_favourites_sheet.dart new file mode 100644 index 0000000..f49cc11 --- /dev/null +++ b/lib/widgets/overview_favourites_sheet.dart @@ -0,0 +1,491 @@ +/// Manage Overview favourites: display toggles, rename, delete, save current. +library; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/overview_favourite.dart'; +import '../models/transaction_meta.dart'; +import '../providers/preferences_provider.dart'; +import '../providers/tx_meta_provider.dart'; +import 'label_filter_dialog.dart'; + +Future showOverviewFavouritesSheet( + BuildContext context, { + required OverviewFavourite? Function() currentFilterSnapshot, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), + child: _OverviewFavouritesSheet( + currentFilterSnapshot: currentFilterSnapshot, + ), + ); + }, + ); +} + +Future promptFavouriteName( + BuildContext context, { + String initial = '', + String title = 'Name this favourite', +}) { + return showDialog( + context: context, + useRootNavigator: true, + builder: (dialogContext) { + return _FavouriteNameDialog( + initial: initial, + title: title, + ); + }, + ); +} + +/// Edit an existing favourite (name, metric, period, labels, display). +Future showEditOverviewFavouriteSheet( + BuildContext context, { + required OverviewFavourite favourite, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), + child: _EditOverviewFavouriteSheet(favourite: favourite), + ); + }, + ); +} + +class _FavouriteNameDialog extends StatefulWidget { + const _FavouriteNameDialog({ + required this.initial, + required this.title, + }); + + final String initial; + final String title; + + @override + State<_FavouriteNameDialog> createState() => _FavouriteNameDialogState(); +} + +class _FavouriteNameDialogState extends State<_FavouriteNameDialog> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initial); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + final name = _controller.text.trim(); + if (name.isEmpty) return; + Navigator.of(context).pop(name); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: TextField( + controller: _controller, + autofocus: true, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'Name', + hintText: 'e.g. Groceries this year', + ), + onSubmitted: (_) => _submit(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _submit, + child: const Text('Save'), + ), + ], + ); + } +} + +class _OverviewFavouritesSheet extends StatelessWidget { + const _OverviewFavouritesSheet({required this.currentFilterSnapshot}); + + final OverviewFavourite? Function() currentFilterSnapshot; + + Future _saveCurrent(BuildContext context) async { + final prefs = context.read(); + final snap = currentFilterSnapshot(); + if (snap == null) return; + + final name = await promptFavouriteName(context); + if (name == null || !context.mounted) return; + + // Let the dialog route + IME finish tearing down before notifying + // dependents (avoids wrong-build-scope / _dependents asserts). + await Future.delayed(Duration.zero); + if (!context.mounted) return; + + await prefs.addOverviewFavourite(snap.copyWith(name: name)); + if (!context.mounted) return; + + final messenger = ScaffoldMessenger.maybeOf(context); + messenger?.showSnackBar(SnackBar(content: Text('Saved “$name”'))); + } + + @override + Widget build(BuildContext context) { + final prefs = context.watch(); + final theme = Theme.of(context); + final favourites = prefs.overviewFavourites; + + return SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.72, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Favourites', style: theme.textTheme.titleLarge), + const SizedBox(height: 4), + Text( + 'Check Display for each favourite you want on the Favourites ' + 'view — all checked ones show as charts. Save as many filters ' + 'as you like.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: OutlinedButton.icon( + onPressed: () => _saveCurrent(context), + icon: const Icon(Icons.star_outline), + label: const Text('Save current filter'), + ), + ), + const Divider(height: 1), + Expanded( + child: favourites.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + 'No favourites yet. Set up Flow or Labels on Overview, ' + 'then save the current filter.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 24), + itemCount: favourites.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final fav = favourites[index]; + return CheckboxListTile( + value: fav.display, + onChanged: (value) { + if (value == null) return; + prefs.setOverviewFavouriteDisplay(fav.id, value); + }, + secondary: IconButton( + tooltip: 'Delete', + onPressed: () => + prefs.removeOverviewFavourite(fav.id), + icon: const Icon(Icons.delete_outline), + ), + title: Text(fav.name), + subtitle: Text( + '${_subtitle(fav)}\n' + 'Display on Favourites view', + ), + isThreeLine: true, + controlAffinity: ListTileControlAffinity.leading, + ); + }, + ), + ), + ], + ), + ), + ); + } + + static String _subtitle(OverviewFavourite fav) { + final metric = + fav.metric == OverviewMetricMode.flow ? 'Flow' : 'Labels'; + final period = + fav.period == OverviewPeriodMode.monthly ? 'Month' : 'Year'; + if (fav.metric == OverviewMetricMode.flow) { + return '$metric · $period'; + } + final flow = switch (fav.flow) { + TxFlowFilter.all => 'All', + TxFlowFilter.income => 'Income', + TxFlowFilter.expense => 'Expenses', + }; + final labels = fav.labelIds.isEmpty + ? 'All labels' + : '${fav.labelIds.length} labels'; + return '$metric · $period · $flow · $labels'; + } +} + +class _EditOverviewFavouriteSheet extends StatefulWidget { + const _EditOverviewFavouriteSheet({required this.favourite}); + + final OverviewFavourite favourite; + + @override + State<_EditOverviewFavouriteSheet> createState() => + _EditOverviewFavouriteSheetState(); +} + +class _EditOverviewFavouriteSheetState + extends State<_EditOverviewFavouriteSheet> { + late OverviewFavourite _draft; + late final TextEditingController _nameController; + + static const _segmentStyle = ButtonStyle( + visualDensity: VisualDensity(horizontal: -2, vertical: -2), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 12)), + minimumSize: WidgetStatePropertyAll(Size(0, 36)), + ); + + @override + void initState() { + super.initState(); + _draft = widget.favourite; + _nameController = TextEditingController(text: _draft.name); + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + Future _persist(OverviewFavourite next) async { + setState(() => _draft = next); + await context.read().updateOverviewFavourite(next); + } + + Future _editLabels() async { + final meta = context.read(); + final available = meta.allLabels; + + final initial = { + if (_draft.labelIds.isEmpty) + ...available.map((l) => l.id) + else ...[ + for (final id in _draft.labelIds) + if (id == kUncategorizedFilterId || + available.any((l) => l.id == id)) + id, + ], + }; + + final result = await showLabelFilterDialog( + context, + available: available, + initiallySelected: initial, + title: 'Labels on chart', + subtitle: 'Choose which labels appear on this favourite.', + allowMove: true, + includeUncategorized: true, + includeFlow: true, + initialFlow: _draft.flow, + includeBarStyle: true, + initialBarStyle: _draft.barStyle, + includeInvertChart: true, + initialInvertChart: _draft.invertExpensePerspective, + ); + if (result == null || !mounted) return; + + final allIds = available.map((l) => l.id).toSet(); + final hasUncategorized = + result.labelIds.contains(kUncategorizedFilterId); + final realIds = result.labelIds + .where((id) => id != kUncategorizedFilterId) + .toSet(); + final allRealSelected = + realIds.length == allIds.length && realIds.containsAll(allIds); + final labelIds = !hasUncategorized && + (realIds.isEmpty || allRealSelected) + ? const [] + : result.labelIds.toList(growable: false); + + await _persist( + _draft.copyWith( + metric: OverviewMetricMode.labels, + labelIds: labelIds, + flow: result.flow, + barStyle: result.barStyle, + invertExpensePerspective: + result.flow == TxFlowFilter.all && result.invertExpensePerspective, + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final prefs = context.read(); + + return SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.85, + ), + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), + children: [ + Text('Edit favourite', style: theme.textTheme.titleLarge), + const SizedBox(height: 16), + TextField( + controller: _nameController, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'Name', + ), + textInputAction: TextInputAction.done, + onEditingComplete: () { + FocusScope.of(context).unfocus(); + }, + ), + const SizedBox(height: 20), + Text('Chart type', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + SegmentedButton( + showSelectedIcon: false, + style: _segmentStyle, + segments: const [ + ButtonSegment( + value: OverviewMetricMode.flow, + label: Text('Flow'), + ), + ButtonSegment( + value: OverviewMetricMode.labels, + label: Text('Labels'), + ), + ], + selected: {_draft.metric}, + onSelectionChanged: (selection) { + _persist(_draft.copyWith(metric: selection.first)); + }, + ), + const SizedBox(height: 16), + Text('Period', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + SegmentedButton( + showSelectedIcon: false, + style: _segmentStyle, + segments: const [ + ButtonSegment( + value: OverviewPeriodMode.monthly, + label: Text('Month'), + ), + ButtonSegment( + value: OverviewPeriodMode.yearly, + label: Text('Year'), + ), + ], + selected: {_draft.period}, + onSelectionChanged: (selection) { + _persist(_draft.copyWith(period: selection.first)); + }, + ), + const SizedBox(height: 8), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Display on Favourites view'), + value: _draft.display, + onChanged: (value) { + _persist(_draft.copyWith(display: value)); + }, + ), + if (_draft.metric == OverviewMetricMode.labels) ...[ + const SizedBox(height: 8), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.filter_list), + title: const Text('Label filter'), + subtitle: Text( + _draft.labelIds.isEmpty + ? 'All labels · ${_draft.flow.name}' + : '${_draft.labelIds.length} labels · ${_draft.flow.name}', + ), + trailing: const Icon(Icons.chevron_right), + onTap: _editLabels, + ), + ], + const SizedBox(height: 16), + FilledButton( + onPressed: () async { + final name = _nameController.text.trim(); + final next = + name.isEmpty ? _draft : _draft.copyWith(name: name); + await prefs.updateOverviewFavourite(next); + if (!context.mounted) return; + Navigator.of(context).pop(); + }, + child: const Text('Done'), + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: () async { + await prefs.removeOverviewFavourite(_draft.id); + if (!context.mounted) return; + Navigator.of(context).pop(); + }, + icon: Icon( + Icons.delete_outline, + color: theme.colorScheme.error, + ), + label: Text( + 'Delete favourite', + style: TextStyle(color: theme.colorScheme.error), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/skip_build_when_covered.dart b/lib/widgets/skip_build_when_covered.dart index a130c3a..9273d6a 100644 --- a/lib/widgets/skip_build_when_covered.dart +++ b/lib/widgets/skip_build_when_covered.dart @@ -8,6 +8,9 @@ final RouteObserver> appRouteObserver = RouteObserver>(); /// Builds via [builder] only when this route is not covered by a pushed page. +/// +/// Uses [ModalRoute.isCurrent] so nested dialogs above a bottom sheet do not +/// falsely "uncover" the page when the dialog pops (sheet still open). class SkipBuildWhenCovered extends StatefulWidget { const SkipBuildWhenCovered({ super.key, @@ -26,6 +29,13 @@ class _SkipBuildWhenCoveredState extends State with RouteAware { var _covered = false; + void _syncCovered() { + final route = ModalRoute.of(context); + final covered = route != null && !route.isCurrent; + if (covered == _covered) return; + setState(() => _covered = covered); + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -33,6 +43,7 @@ class _SkipBuildWhenCoveredState extends State if (route is PageRoute) { appRouteObserver.subscribe(this, route); } + _syncCovered(); } @override @@ -42,14 +53,10 @@ class _SkipBuildWhenCoveredState extends State } @override - void didPushNext() { - if (!_covered) setState(() => _covered = true); - } + void didPushNext() => _syncCovered(); @override - void didPopNext() { - if (_covered) setState(() => _covered = false); - } + void didPopNext() => _syncCovered(); @override Widget build(BuildContext context) { diff --git a/pocketbase/pb_migrations/1740000003_ingtrak_overview_favourites.js b/pocketbase/pb_migrations/1740000003_ingtrak_overview_favourites.js new file mode 100644 index 0000000..7a6f805 --- /dev/null +++ b/pocketbase/pb_migrations/1740000003_ingtrak_overview_favourites.js @@ -0,0 +1,110 @@ +/// PocketBase migration: Overview chart favourites. +/// +/// 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 +/// +/// Depends on usersapp (same owner rules as labels / recurring_templates). +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", + }; + + const overviewFavourites = new Collection({ + type: "base", + name: "overview_favourites", + ...ownerRules, + fields: [ + { + type: "relation", + name: "user", + required: true, + collectionId: users.id, + cascadeDelete: true, + maxSelect: 1, + }, + { + // App-side OverviewFavourite.id + type: "text", + name: "client_id", + required: true, + min: 1, + max: 120, + }, + { + type: "text", + name: "name", + required: true, + min: 1, + max: 200, + }, + { + // Show on Favourites overview view + type: "bool", + name: "display", + required: false, + }, + { + // "flow" | "labels" + type: "text", + name: "metric", + required: true, + min: 1, + max: 32, + }, + { + // "monthly" | "yearly" + type: "text", + name: "period", + required: true, + min: 1, + max: 32, + }, + { + // Label client_ids included on Labels chart ([] = all) + type: "json", + name: "label_ids", + required: false, + }, + { + // "all" | "income" | "expense" + type: "text", + name: "flow", + required: true, + min: 1, + max: 32, + }, + { + // "parallel" | "stacked" + type: "text", + name: "bar_style", + required: true, + min: 1, + max: 32, + }, + { + type: "bool", + name: "invert_expense_perspective", + required: false, + }, + ], + indexes: [ + "CREATE UNIQUE INDEX idx_overview_favourites_user_client ON overview_favourites (user, client_id)", + ], + }); + app.save(overviewFavourites); +}, (app) => { + try { + const col = app.findCollectionByNameOrId("overview_favourites"); + app.delete(col); + } catch (_) {} +}); diff --git a/pocketbase/pb_migrations/1740000004_ingtrak_home_filter_widgets.js b/pocketbase/pb_migrations/1740000004_ingtrak_home_filter_widgets.js new file mode 100644 index 0000000..e891800 --- /dev/null +++ b/pocketbase/pb_migrations/1740000004_ingtrak_home_filter_widgets.js @@ -0,0 +1,114 @@ +/// PocketBase migration: Home filter widgets. +/// +/// Apply on the server: +/// +/// 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 +/// +/// Depends on usersapp (same owner rules as overview_favourites). +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", + }; + + const homeFilterWidgets = new Collection({ + type: "base", + name: "home_filter_widgets", + ...ownerRules, + fields: [ + { + type: "relation", + name: "user", + required: true, + collectionId: users.id, + cascadeDelete: true, + maxSelect: 1, + }, + { + type: "text", + name: "client_id", + required: true, + min: 1, + max: 120, + }, + { + type: "text", + name: "name", + required: true, + min: 1, + max: 200, + }, + { + type: "bool", + name: "display", + required: false, + }, + { + type: "number", + name: "sort_order", + required: false, + onlyInt: true, + }, + { + type: "json", + name: "label_ids", + required: false, + }, + { + type: "text", + name: "flow", + required: true, + min: 1, + max: 32, + }, + { + type: "json", + name: "amount_range", + required: false, + }, + { + type: "text", + name: "query", + required: false, + max: 400, + }, + { + type: "bool", + name: "month_only", + required: false, + }, + { + type: "bool", + name: "show_amount_in", + required: false, + }, + { + type: "bool", + name: "show_amount_out", + required: false, + }, + { + type: "bool", + name: "show_count", + required: false, + }, + ], + indexes: [ + "CREATE UNIQUE INDEX idx_home_filter_widgets_user_client ON home_filter_widgets (user, client_id)", + ], + }); + app.save(homeFilterWidgets); +}, (app) => { + try { + const col = app.findCollectionByNameOrId("home_filter_widgets"); + app.delete(col); + } catch (_) {} +}); diff --git a/test/home_filter_widget_test.dart b/test/home_filter_widget_test.dart new file mode 100644 index 0000000..dc1cd41 --- /dev/null +++ b/test/home_filter_widget_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:myapp/models/home_filter_widget.dart'; +import 'package:myapp/models/transaction_meta.dart'; +import 'package:myapp/widgets/label_filter_dialog.dart'; + +void main() { + test('HomeFilterWidget JSON round-trips', () { + final widget = HomeFilterWidget.fromFilter( + name: 'Groceries out', + filter: const TransactionFilterSelection( + labelIds: {'groceries'}, + flow: TxFlowFilter.expense, + monthOnly: true, + ), + ); + + final restored = HomeFilterWidget.fromJson(widget.toJson()); + expect(restored.name, 'Groceries out'); + expect(restored.flow, TxFlowFilter.expense); + expect(restored.monthOnly, isTrue); + expect(restored.labelIds, ['groceries']); + expect(restored.showAmountOut, isTrue); + expect(restored.showAmountIn, isFalse); + expect(restored.showCount, isTrue); + }); + + test('mergeHomeFilterWidgets unions by id with local wins', () { + const local = HomeFilterWidget( + id: 'hw_1', + name: 'Local', + display: true, + showAmountIn: false, + ); + const remote = HomeFilterWidget( + id: 'hw_1', + name: 'Remote', + display: false, + ); + const remoteOnly = HomeFilterWidget( + id: 'hw_2', + name: 'Other', + sortOrder: 1, + ); + + final merged = mergeHomeFilterWidgets([local], [remote, remoteOnly]); + expect(merged.length, 2); + expect(merged.firstWhere((w) => w.id == 'hw_1').name, 'Local'); + expect(merged.firstWhere((w) => w.id == 'hw_1').display, isTrue); + }); +} diff --git a/test/overview_favourite_test.dart b/test/overview_favourite_test.dart new file mode 100644 index 0000000..83b8552 --- /dev/null +++ b/test/overview_favourite_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:myapp/models/overview_favourite.dart'; +import 'package:myapp/models/transaction_meta.dart'; +import 'package:myapp/services/pb_sync_service.dart'; +import 'package:myapp/widgets/label_filter_dialog.dart'; + +void main() { + test('OverviewFavourite JSON round-trips', () { + const fav = OverviewFavourite( + id: 'fav_1', + name: 'Groceries', + display: true, + metric: OverviewMetricMode.labels, + period: OverviewPeriodMode.monthly, + labelIds: ['groceries', 'custom_ah'], + flow: TxFlowFilter.expense, + barStyle: LabelChartBarStyle.stacked, + invertExpensePerspective: false, + ); + + final restored = OverviewFavourite.fromJson(fav.toJson()); + expect(restored.id, fav.id); + expect(restored.name, fav.name); + expect(restored.display, isTrue); + expect(restored.metric, OverviewMetricMode.labels); + expect(restored.period, OverviewPeriodMode.monthly); + expect(restored.labelIds, ['groceries', 'custom_ah']); + expect(restored.flow, TxFlowFilter.expense); + expect(restored.barStyle, LabelChartBarStyle.stacked); + }); + + test('mergeOverviewFavourites unions by id with local wins', () { + const local = OverviewFavourite( + id: 'fav_1', + name: 'Local name', + display: true, + ); + const remoteOnly = OverviewFavourite( + id: 'fav_2', + name: 'Remote only', + display: false, + metric: OverviewMetricMode.labels, + ); + const remoteConflict = OverviewFavourite( + id: 'fav_1', + name: 'Remote name', + display: false, + ); + + final merged = mergeOverviewFavourites( + [local], + [remoteConflict, remoteOnly], + ); + + expect(merged.length, 2); + final byId = {for (final f in merged) f.id: f}; + expect(byId['fav_1']!.name, 'Local name'); + expect(byId['fav_1']!.display, isTrue); + expect(byId['fav_2']!.name, 'Remote only'); + }); +}