From 6cc0de8945e34eec5e001ac7ed9341ed3267a600 Mon Sep 17 00:00:00 2001 From: davmar Date: Thu, 10 Sep 2026 22:45:25 +0200 Subject: [PATCH] fixed some issues and added label and, or capability --- README.md | 1 + lib/models/home_filter_widget.dart | 14 +++++ lib/models/transaction_meta.dart | 9 +++ lib/providers/tx_meta_provider.dart | 41 +++++++++--- lib/screens/look_ahead_screen.dart | 63 ++++++++++++------- lib/screens/overview_screen.dart | 9 +-- lib/screens/transactions_screen.dart | 47 ++++++++++++-- lib/services/pb_sync_service.dart | 6 ++ lib/utils/tx_filter_metrics.dart | 1 + lib/widgets/home_filter_widget_card.dart | 1 + lib/widgets/home_widgets_sheet.dart | 1 + lib/widgets/label_filter_dialog.dart | 34 +++++++++- ...000008_ingtrak_home_widgets_label_match.js | 22 +++++++ test/home_filter_widget_test.dart | 7 +++ 14 files changed, 217 insertions(+), 39 deletions(-) create mode 100644 pocketbase/pb_migrations/1740000008_ingtrak_home_widgets_label_match.js diff --git a/README.md b/README.md index 2bb2c04..16c7eac 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ Migrations live in [`pocketbase/pb_migrations/`](pocketbase/pb_migrations/). The | `1740000003` | `overview_favourites` | | `1740000004`–`0006` | `home_filter_widgets` (+ half width, budget fields) | | `1740000007` | `app_prefs` (Home section toggles + Look ahead years) | +| `1740000008` | `home_filter_widgets.label_match` (AND/OR) | Built-in labels stay in the app; only **custom** labels sync. Rows are scoped per user (`user = @request.auth.id`). diff --git a/lib/models/home_filter_widget.dart b/lib/models/home_filter_widget.dart index d709f94..e3d43eb 100644 --- a/lib/models/home_filter_widget.dart +++ b/lib/models/home_filter_widget.dart @@ -86,6 +86,7 @@ class HomeFilterWidget { this.budgetFixedAmount = 0, this.budgetAverageMonths = 3, this.labelIds = const [], + this.labelMatch = LabelMatchMode.and, this.flow = TxFlowFilter.all, this.amountRange = const TxAmountRange(), this.query = '', @@ -115,6 +116,10 @@ class HomeFilterWidget { final int budgetAverageMonths; final List labelIds; + + /// AND vs OR when multiple [labelIds] are selected. + final LabelMatchMode labelMatch; + final TxFlowFilter flow; final TxAmountRange amountRange; final String query; @@ -128,6 +133,7 @@ class HomeFilterWidget { TransactionFilterSelection get filter => TransactionFilterSelection( labelIds: labelIds.toSet(), + labelMatch: labelMatch, flow: flow, amountRange: amountRange, query: query, @@ -147,6 +153,7 @@ class HomeFilterWidget { double? budgetFixedAmount, int? budgetAverageMonths, List? labelIds, + LabelMatchMode? labelMatch, TxFlowFilter? flow, TxAmountRange? amountRange, String? query, @@ -168,6 +175,7 @@ class HomeFilterWidget { budgetFixedAmount: budgetFixedAmount ?? this.budgetFixedAmount, budgetAverageMonths: budgetAverageMonths ?? this.budgetAverageMonths, labelIds: labelIds ?? this.labelIds, + labelMatch: labelMatch ?? this.labelMatch, flow: flow ?? this.flow, amountRange: amountRange ?? this.amountRange, query: query ?? this.query, @@ -192,6 +200,7 @@ class HomeFilterWidget { display: display, sortOrder: sortOrder, labelIds: filter.labelIds.toList(growable: false), + labelMatch: filter.labelMatch, flow: filter.flow, amountRange: filter.amountRange, query: filter.query, @@ -215,6 +224,7 @@ class HomeFilterWidget { 'budgetFixedAmount': budgetFixedAmount, 'budgetAverageMonths': budgetAverageMonths, 'labelIds': labelIds, + 'labelMatch': labelMatch.name, 'flow': flow.name, 'amountRange': { if (amountRange.exact != null) 'exact': amountRange.exact, @@ -274,6 +284,10 @@ class HomeFilterWidget { for (final id in (json['labelIds'] as List? ?? const [])) if (id is String && id.isNotEmpty) id, ], + labelMatch: LabelMatchMode.values.firstWhere( + (e) => e.name == json['labelMatch'], + orElse: () => LabelMatchMode.and, + ), flow: TxFlowFilter.values.firstWhere( (e) => e.name == json['flow'], orElse: () => TxFlowFilter.all, diff --git a/lib/models/transaction_meta.dart b/lib/models/transaction_meta.dart index c712fc2..b4096eb 100644 --- a/lib/models/transaction_meta.dart +++ b/lib/models/transaction_meta.dart @@ -134,6 +134,15 @@ int labelVisibleDepth( /// Filter-only id for txs with no labels (not an assignable category). const String kUncategorizedFilterId = '__uncategorized__'; +/// How multiple selected labels combine in a transaction filter. +enum LabelMatchMode { + /// Tx must include every selected label. + and, + + /// Tx must include at least one selected label. + or, +} + /// Chip presentation for [kUncategorizedFilterId] refine filters. const TxLabel kUncategorizedFilterLabel = TxLabel( id: kUncategorizedFilterId, diff --git a/lib/providers/tx_meta_provider.dart b/lib/providers/tx_meta_provider.dart index f56ba80..013070b 100644 --- a/lib/providers/tx_meta_provider.dart +++ b/lib/providers/tx_meta_provider.dart @@ -800,6 +800,7 @@ class TxMetaController extends ChangeNotifier { List txs, String query, { Set labelIds = const {}, + LabelMatchMode labelMatch = LabelMatchMode.and, }) { final needle = query.trim().toLowerCase(); final hasText = needle.isNotEmpty; @@ -809,7 +810,15 @@ class TxMetaController extends ChangeNotifier { final labelIndex = _labelById; return [ for (final tx in txs) - if (_matchesSearch(tx, needle, hasText, labelIds, hasLabels, labelIndex)) + if (_matchesSearch( + tx, + needle, + hasText, + labelIds, + hasLabels, + labelMatch, + labelIndex, + )) tx, ]; } @@ -820,10 +829,11 @@ class TxMetaController extends ChangeNotifier { bool hasText, Set labelIds, bool hasLabels, + LabelMatchMode labelMatch, Map labelIndex, ) { final meta = metaFor(tx); - if (hasLabels && !_matchesLabelFilter(meta, labelIds)) { + if (hasLabels && !_matchesLabelFilter(meta, labelIds, labelMatch)) { return false; } if (!hasText) return true; @@ -838,20 +848,33 @@ class TxMetaController extends ChangeNotifier { return false; } - /// AND filter: every selected label must be on the tx. + /// AND: every selected label must be on the tx. + /// OR: at least one selected label (or unlabeled, if that filter is set). /// - /// Uncategorized alone matches unlabeled txs. Uncategorized combined with - /// other labels matches nothing (a tx cannot be both). - bool _matchesLabelFilter(TxMeta meta, Set labelIds) { + /// Uncategorized alone matches unlabeled txs. Uncategorized + others with + /// AND matches nothing; with OR matches unlabeled or any of the labels. + bool _matchesLabelFilter( + TxMeta meta, + Set labelIds, + LabelMatchMode mode, + ) { final wantUncategorized = labelIds.contains(kUncategorizedFilterId); final required = [ for (final id in labelIds) if (id != kUncategorizedFilterId) id, ]; - if (wantUncategorized) { - if (required.isNotEmpty) return false; - return !meta.hasLabel; + if (required.isEmpty) { + if (wantUncategorized) return !meta.hasLabel; + return true; } + if (mode == LabelMatchMode.or) { + if (wantUncategorized && !meta.hasLabel) return true; + for (final id in required) { + if (meta.labelIds.contains(id)) return true; + } + return false; + } + if (wantUncategorized) return false; for (final id in required) { if (!meta.labelIds.contains(id)) return false; } diff --git a/lib/screens/look_ahead_screen.dart b/lib/screens/look_ahead_screen.dart index 60450bb..67e54a7 100644 --- a/lib/screens/look_ahead_screen.dart +++ b/lib/screens/look_ahead_screen.dart @@ -9,7 +9,7 @@ import '../models/transaction_meta.dart'; import '../providers/bank_provider.dart'; import '../providers/preferences_provider.dart'; import '../providers/tx_meta_provider.dart'; -import '../screens/transaction_detail_screen.dart'; +import '../screens/transactions_screen.dart'; import '../utils/app_theme.dart'; import '../utils/helpers.dart'; import '../utils/look_ahead.dart'; @@ -337,17 +337,6 @@ class _LookAheadScreenState extends State { ), ], ), - const SizedBox(height: 4), - Text( - items.isEmpty - ? 'Create a spending from a transaction, or tap New.' - : '${items.length} spending${items.length == 1 ? '' : 's'} · ' - 'projected income ${formatMoney(income, currency)} · ' - 'expenses ${formatMoney(expenses, currency)}.', - style: theme.textTheme.bodySmall?.copyWith( - color: colors.onSurfaceVariant, - ), - ), const SizedBox(height: 10), Row( children: [ @@ -622,6 +611,46 @@ class _TemplateTile extends StatelessWidget { return null; } + void _openTransactions(BuildContext context) { + final labelIds = item.labelIds.toSet(); + SimilarMatchCriteria? similar; + TxFlowFilter? flow; + String? query; + + if (labelIds.isEmpty) { + final tx = _sourceTx; + if (tx != null) { + // Same matching as Look ahead / apply-to-similar for this source tx. + similar = SimilarMatchCriteria( + pattern: defaultSimilarPattern(tx.description), + flow: tx.amount < 0 + ? TxFlowFilter.expense + : tx.amount > 0 + ? TxFlowFilter.income + : TxFlowFilter.all, + ); + flow = similar.flow; + } else if (template != null && template!.merchantKey.isNotEmpty) { + query = template!.merchantKey; + } else { + query = item.description; + } + } + + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => TransactionsScreen( + initialLabelIds: labelIds, + initialLabelMatch: LabelMatchMode.or, + initialQuery: query, + initialFlow: flow, + initialSimilarMatch: similar, + initialSearchInMonthOnly: false, + ), + ), + ); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -658,15 +687,7 @@ class _TemplateTile extends StatelessWidget { color: income ? AppTheme.accent : AppTheme.expense, ), ), - onTap: () { - final tx = _sourceTx; - if (tx == null) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => TransactionDetailScreen(tx: tx), - ), - ); - }, + onTap: () => _openTransactions(context), ); } } diff --git a/lib/screens/overview_screen.dart b/lib/screens/overview_screen.dart index f1e6bd2..79a9f58 100644 --- a/lib/screens/overview_screen.dart +++ b/lib/screens/overview_screen.dart @@ -628,11 +628,11 @@ class _OverviewScreenState extends State { currency, ), label: switch (_labelFlow) { - TxFlowFilter.income => 'Labeled income · $_periodTitle', - TxFlowFilter.expense => 'Labeled spend · $_periodTitle', + TxFlowFilter.income => 'Income · $_periodTitle', + TxFlowFilter.expense => 'Spend · $_periodTitle', TxFlowFilter.all => _labelInvertChart - ? 'Labeled net (expense view) · $_periodTitle' - : 'Labeled net · $_periodTitle', + ? 'Net (expense view) · $_periodTitle' + : 'Net · $_periodTitle', }, ), ], @@ -753,6 +753,7 @@ class _OverviewScreenState extends State { builder: (_) => TransactionsScreen( initialMonth: initial, initialLabelIds: labelIds, + initialLabelMatch: LabelMatchMode.or, initialSearchInMonthOnly: !yearly && labelIds.isNotEmpty, initialYear: yearly ? period.year : null, ), diff --git a/lib/screens/transactions_screen.dart b/lib/screens/transactions_screen.dart index 16bd41d..6eaa39a 100644 --- a/lib/screens/transactions_screen.dart +++ b/lib/screens/transactions_screen.dart @@ -30,11 +30,13 @@ class TransactionsScreen extends StatefulWidget { super.key, this.initialMonth, this.initialLabelIds = const {}, + this.initialLabelMatch = LabelMatchMode.and, this.initialSearchInMonthOnly, this.initialYear, this.initialQuery, this.initialFlow, this.initialAmountRange, + this.initialSimilarMatch, }); /// When set, opens on this calendar month instead of the current month. @@ -43,6 +45,9 @@ class TransactionsScreen extends StatefulWidget { /// Pre-select these label filters (e.g. from overview history). final Set initialLabelIds; + /// AND vs OR for [initialLabelIds] (and later filter edits). + final LabelMatchMode initialLabelMatch; + /// When set, overrides the default "search in month" checkbox. final bool? initialSearchInMonthOnly; @@ -55,6 +60,9 @@ class TransactionsScreen extends StatefulWidget { final TxFlowFilter? initialFlow; final TxAmountRange? initialAmountRange; + /// Match txs the same way as “apply to similar” / Look ahead transaction source. + final SimilarMatchCriteria? initialSimilarMatch; + @override State createState() => _TransactionsScreenState(); } @@ -68,6 +76,8 @@ class _TransactionsScreenState extends State { DateTime? _selectedDay; late bool _searchInMonthOnly; late final Set _labelFilters; + late LabelMatchMode _labelMatch; + SimilarMatchCriteria? _similarMatch; int? _yearFilter; var _showCalendar = false; var _flowFilter = TxFlowFilter.all; @@ -89,6 +99,8 @@ class _TransactionsScreenState extends State { _month = start.isAfter(now) ? now : start; } _labelFilters = Set.of(widget.initialLabelIds); + _labelMatch = widget.initialLabelMatch; + _similarMatch = widget.initialSimilarMatch; // Default to current month so the list, navigator, and filter popup agree. _searchInMonthOnly = widget.initialSearchInMonthOnly ?? true; _yearFilter = widget.initialYear; @@ -98,6 +110,11 @@ class _TransactionsScreenState extends State { if (initialQuery.isNotEmpty) { _searchController.text = initialQuery; _activeQuery = initialQuery; + } else if (_similarMatch != null && + _similarMatch!.pattern.trim().isNotEmpty) { + // Show the similar pattern in the search field for context; filtering + // uses [txMatchesSimilarCriteria], not plain text contains. + _searchController.text = _similarMatch!.pattern; } } @@ -109,16 +126,23 @@ class _TransactionsScreenState extends State { } bool get _isSearching => - _activeQuery.trim().isNotEmpty || _labelFilters.isNotEmpty; + _activeQuery.trim().isNotEmpty || + _labelFilters.isNotEmpty || + _similarMatch != null; bool get _hasListFilters => _labelFilters.isNotEmpty || _flowFilter != TxFlowFilter.all || - _amountRange.isActive; + _amountRange.isActive || + _similarMatch != null; bool _matchesExtraFilters(BankTransaction tx, TxMetaController meta) { if (!txMatchesFlow(tx, _flowFilter)) return false; if (!txMatchesAmountRange(tx, _amountRange)) return false; + final similar = _similarMatch; + if (similar != null && !txMatchesSimilarCriteria(tx, similar)) { + return false; + } return true; } @@ -147,6 +171,11 @@ class _TransactionsScreenState extends State { final trimmed = value.trim(); _searchDebounceTimer?.cancel(); + // Editing the box leaves “similar match” mode and uses plain text search. + if (_similarMatch != null) { + setState(() => _similarMatch = null); + } + if (trimmed.length >= _minLiveSearchChars) { // TextField updates via the controller; only rebuild the list after debounce. _searchDebounceTimer = Timer(_searchDebounce, () { @@ -165,13 +194,19 @@ class _TransactionsScreenState extends State { void _onSearchSubmitted(String value) { _searchDebounceTimer?.cancel(); - setState(() => _activeQuery = value.trim()); + setState(() { + _similarMatch = null; + _activeQuery = value.trim(); + }); } void _clearSearch() { _searchDebounceTimer?.cancel(); _searchController.clear(); - setState(() => _activeQuery = ''); + setState(() { + _activeQuery = ''; + _similarMatch = null; + }); } Future _pickFilters(List available) async { @@ -183,6 +218,7 @@ class _TransactionsScreenState extends State { currentMonth: _month, initial: TransactionFilterSelection( labelIds: Set.of(_labelFilters), + labelMatch: _labelMatch, flow: _flowFilter, amountRange: _amountRange, query: _activeQuery, @@ -195,6 +231,7 @@ class _TransactionsScreenState extends State { _labelFilters ..clear() ..addAll(result.labelIds); + _labelMatch = result.labelMatch; _flowFilter = result.flow; _amountRange = result.amountRange; _searchInMonthOnly = result.monthOnly; @@ -246,6 +283,7 @@ class _TransactionsScreenState extends State { context, initialFilter: TransactionFilterSelection( labelIds: Set.of(_labelFilters), + labelMatch: _labelMatch, flow: _flowFilter, amountRange: _amountRange, query: _activeQuery, @@ -386,6 +424,7 @@ class _TransactionsScreenState extends State { searchPool, _activeQuery, labelIds: _labelFilters, + labelMatch: _labelMatch, )) if (_matchesExtraFilters(tx, metaController)) tx, ]; diff --git a/lib/services/pb_sync_service.dart b/lib/services/pb_sync_service.dart index 48f9bad..e902627 100644 --- a/lib/services/pb_sync_service.dart +++ b/lib/services/pb_sync_service.dart @@ -534,6 +534,7 @@ class PbSyncService { }, 'budgetAverageMonths': r.getIntValue('budget_average_months', 3), 'labelIds': _jsonStringList(r.get('label_ids')), + 'labelMatch': r.getStringValue('label_match'), 'flow': r.getStringValue('flow'), 'amountRange': rangeRaw is Map ? Map.from(rangeRaw) @@ -1259,6 +1260,7 @@ class PbSyncService { 'budget_fixed_amount': w.budgetFixedAmount, 'budget_average_months': w.budgetAverageMonths, 'label_ids': w.labelIds, + 'label_match': w.labelMatch.name, 'flow': w.flow.name, 'amount_range': { if (w.amountRange.exact != null) 'exact': w.amountRange.exact, @@ -1402,6 +1404,10 @@ class PbSyncService { remote.getIntValue('budget_average_months', 3) == w.budgetAverageMonths && _sameIdList(remoteIds, w.labelIds) && + (remote.getStringValue('label_match').isEmpty + ? 'and' + : remote.getStringValue('label_match')) == + w.labelMatch.name && remote.getStringValue('flow') == w.flow.name && asDouble(rangeMap['exact']) == w.amountRange.exact && asDouble(rangeMap['min']) == w.amountRange.min && diff --git a/lib/utils/tx_filter_metrics.dart b/lib/utils/tx_filter_metrics.dart index 674cdf0..6bfbca8 100644 --- a/lib/utils/tx_filter_metrics.dart +++ b/lib/utils/tx_filter_metrics.dart @@ -69,6 +69,7 @@ List applyTransactionFilter({ pool, filter.query, labelIds: filter.labelIds, + labelMatch: filter.labelMatch, )) if (txMatchesFlow(tx, filter.flow) && txMatchesAmountRange(tx, filter.amountRange)) diff --git a/lib/widgets/home_filter_widget_card.dart b/lib/widgets/home_filter_widget_card.dart index 9e1b536..9d6b17a 100644 --- a/lib/widgets/home_filter_widget_card.dart +++ b/lib/widgets/home_filter_widget_card.dart @@ -37,6 +37,7 @@ class HomeFilterWidgetCard extends StatelessWidget { MaterialPageRoute( builder: (_) => TransactionsScreen( initialLabelIds: filter.labelIds, + initialLabelMatch: filter.labelMatch, initialSearchInMonthOnly: widgetConfig.type == HomeWidgetType.budget || filter.monthOnly, initialQuery: filter.query, diff --git a/lib/widgets/home_widgets_sheet.dart b/lib/widgets/home_widgets_sheet.dart index b2dbe1a..f4afae8 100644 --- a/lib/widgets/home_widgets_sheet.dart +++ b/lib/widgets/home_widgets_sheet.dart @@ -55,6 +55,7 @@ Future composeHomeFilterWidget( ? HomeFilterWidget.fromFilter(name: 'Filter', filter: filter) : existing.copyWith( labelIds: filter.labelIds.toList(growable: false), + labelMatch: filter.labelMatch, flow: filter.flow, amountRange: filter.amountRange, query: filter.query, diff --git a/lib/widgets/label_filter_dialog.dart b/lib/widgets/label_filter_dialog.dart index f416c95..7188e3f 100644 --- a/lib/widgets/label_filter_dialog.dart +++ b/lib/widgets/label_filter_dialog.dart @@ -15,6 +15,7 @@ import 'label_parent_picker.dart'; class TransactionFilterSelection { const TransactionFilterSelection({ required this.labelIds, + this.labelMatch = LabelMatchMode.and, this.flow = TxFlowFilter.all, this.amountRange = const TxAmountRange(), this.query = '', @@ -22,6 +23,10 @@ class TransactionFilterSelection { }); final Set labelIds; + + /// AND vs OR when more than one label is selected. + final LabelMatchMode labelMatch; + final TxFlowFilter flow; final TxAmountRange amountRange; final String query; @@ -497,6 +502,7 @@ class _TransactionFilterDialog extends StatefulWidget { class _TransactionFilterDialogState extends State<_TransactionFilterDialog> { late final Set _selected; + late LabelMatchMode _labelMatch; late TxFlowFilter _flow; late bool _monthOnly; late final TextEditingController _queryController; @@ -510,6 +516,7 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> { super.initState(); final initial = widget.initial; _selected = Set.of(initial.labelIds); + _labelMatch = initial.labelMatch; _flow = initial.flow; _monthOnly = initial.monthOnly; _queryController = TextEditingController(text: initial.query); @@ -593,6 +600,7 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> { context, TransactionFilterSelection( labelIds: Set.of(_selected), + labelMatch: _labelMatch, flow: _flow, amountRange: _amountResult, query: _queryController.text.trim(), @@ -840,7 +848,10 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> { ), onChanged: (_) => setState(() {}), ), - Row( + Wrap( + spacing: 0, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, children: [ TextButton( onPressed: () { @@ -859,6 +870,27 @@ class _TransactionFilterDialogState extends State<_TransactionFilterDialog> { onPressed: () => setState(() => _selected.clear()), child: const Text('No labels'), ), + SegmentedButton( + style: const ButtonStyle( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + showSelectedIcon: false, + segments: const [ + ButtonSegment( + value: LabelMatchMode.and, + label: Text('AND'), + ), + ButtonSegment( + value: LabelMatchMode.or, + label: Text('OR'), + ), + ], + selected: {_labelMatch}, + onSelectionChanged: (selection) { + setState(() => _labelMatch = selection.first); + }, + ), ], ), const SizedBox(height: 4), diff --git a/pocketbase/pb_migrations/1740000008_ingtrak_home_widgets_label_match.js b/pocketbase/pb_migrations/1740000008_ingtrak_home_widgets_label_match.js new file mode 100644 index 0000000..df68e49 --- /dev/null +++ b/pocketbase/pb_migrations/1740000008_ingtrak_home_widgets_label_match.js @@ -0,0 +1,22 @@ +/// PocketBase migration: label_match (AND/OR) on home_filter_widgets. +migrate((app) => { + const col = app.findCollectionByNameOrId("home_filter_widgets"); + if (col.fields.getByName("label_match")) return; + + col.fields.add(new Field({ + type: "text", + name: "label_match", + required: false, + max: 16, + })); + app.save(col); +}, (app) => { + try { + const col = app.findCollectionByNameOrId("home_filter_widgets"); + const field = col.fields.getByName("label_match"); + if (field) { + col.fields.removeById(field.id); + app.save(col); + } + } catch (_) {} +}); diff --git a/test/home_filter_widget_test.dart b/test/home_filter_widget_test.dart index eba6d09..564bdc5 100644 --- a/test/home_filter_widget_test.dart +++ b/test/home_filter_widget_test.dart @@ -19,12 +19,19 @@ void main() { expect(restored.flow, TxFlowFilter.expense); expect(restored.monthOnly, isTrue); expect(restored.labelIds, ['groceries']); + expect(restored.labelMatch, LabelMatchMode.and); expect(restored.showAmountOut, isTrue); expect(restored.showAmountIn, isFalse); expect(restored.showCount, isTrue); expect(restored.halfWidth, isFalse); expect(restored.type, HomeWidgetType.stats); + final withOr = widget.copyWith(labelMatch: LabelMatchMode.or); + expect( + HomeFilterWidget.fromJson(withOr.toJson()).labelMatch, + LabelMatchMode.or, + ); + final budget = widget.copyWith( halfWidth: true, type: HomeWidgetType.budget,