From 40366f070898c5ba0974d05d5117aae2524e8174 Mon Sep 17 00:00:00 2001 From: davmar Date: Fri, 11 Sep 2026 12:31:07 +0200 Subject: [PATCH] some issues fixed --- lib/models/home_filter_widget.dart | 38 +++++- lib/models/overview_favourite.dart | 48 +++++++ lib/providers/preferences_provider.dart | 96 +++++++++----- lib/providers/sync_provider.dart | 10 +- lib/screens/overview_screen.dart | 111 +++++++++++++---- lib/services/pb_sync_service.dart | 44 ++++--- lib/widgets/home_filter_widget_card.dart | 117 +++++++++++------- ...12_ingtrak_favourites_widgets_sync_meta.js | 48 +++++++ test/overview_favourite_test.dart | 31 ++++- 9 files changed, 416 insertions(+), 127 deletions(-) create mode 100644 pocketbase/pb_migrations/1740000012_ingtrak_favourites_widgets_sync_meta.js diff --git a/lib/models/home_filter_widget.dart b/lib/models/home_filter_widget.dart index e3d43eb..ed00ceb 100644 --- a/lib/models/home_filter_widget.dart +++ b/lib/models/home_filter_widget.dart @@ -94,6 +94,8 @@ class HomeFilterWidget { this.showAmountIn = true, this.showAmountOut = true, this.showCount = true, + this.updatedAt = 0, + this.deleted = false, }); final String id; @@ -129,6 +131,12 @@ class HomeFilterWidget { final bool showAmountOut; final bool showCount; + /// Epoch ms; used for last-write-wins sync across devices. + final int updatedAt; + + /// Soft-delete so removals propagate via sync instead of being recreated. + final bool deleted; + IconData get icon => HomeWidgetIcons.iconFor(iconId); TransactionFilterSelection get filter => TransactionFilterSelection( @@ -161,6 +169,9 @@ class HomeFilterWidget { bool? showAmountIn, bool? showAmountOut, bool? showCount, + int? updatedAt, + bool? deleted, + bool touchUpdatedAt = false, }) { return HomeFilterWidget( id: id ?? this.id, @@ -183,6 +194,10 @@ class HomeFilterWidget { showAmountIn: showAmountIn ?? this.showAmountIn, showAmountOut: showAmountOut ?? this.showAmountOut, showCount: showCount ?? this.showCount, + updatedAt: touchUpdatedAt + ? DateTime.now().millisecondsSinceEpoch + : (updatedAt ?? this.updatedAt), + deleted: deleted ?? this.deleted, ); } @@ -208,6 +223,7 @@ class HomeFilterWidget { showAmountIn: showIn, showAmountOut: showOut, showCount: true, + updatedAt: DateTime.now().millisecondsSinceEpoch, ); } @@ -236,6 +252,8 @@ class HomeFilterWidget { 'showAmountIn': showAmountIn, 'showAmountOut': showAmountOut, 'showCount': showCount, + 'updatedAt': updatedAt, + 'deleted': deleted, }; factory HomeFilterWidget.fromJson(Map json) { @@ -302,20 +320,30 @@ class HomeFilterWidget { showAmountIn: json['showAmountIn'] as bool? ?? true, showAmountOut: json['showAmountOut'] as bool? ?? true, showCount: json['showCount'] as bool? ?? true, + updatedAt: asInt(json['updatedAt'] ?? json['updated_at'], 0), + deleted: json['deleted'] as bool? ?? false, ); } } -/// Union home widgets by id (local wins on conflict). +/// Union home widgets by id with last-write-wins on [HomeFilterWidget.updatedAt]. List mergeHomeFilterWidgets( List local, List remote, ) { - final byId = { - for (final w in remote) w.id: w, - }; + final byId = {}; + void put(HomeFilterWidget w) { + final existing = byId[w.id]; + if (existing == null || w.updatedAt >= existing.updatedAt) { + byId[w.id] = w; + } + } + + for (final w in remote) { + put(w); + } for (final w in local) { - byId[w.id] = w; + put(w); } final merged = byId.values.toList() ..sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); diff --git a/lib/models/overview_favourite.dart b/lib/models/overview_favourite.dart index 743927d..a5d4558 100644 --- a/lib/models/overview_favourite.dart +++ b/lib/models/overview_favourite.dart @@ -16,6 +16,8 @@ class OverviewFavourite { this.flow = TxFlowFilter.expense, this.barStyle = LabelChartBarStyle.parallel, this.invertExpensePerspective = false, + this.updatedAt = 0, + this.deleted = false, }); final String id; @@ -34,6 +36,12 @@ class OverviewFavourite { final LabelChartBarStyle barStyle; final bool invertExpensePerspective; + /// Epoch ms; used for last-write-wins sync across devices. + final int updatedAt; + + /// Soft-delete so removals propagate via sync instead of being recreated. + final bool deleted; + OverviewFavourite copyWith({ String? id, String? name, @@ -44,6 +52,9 @@ class OverviewFavourite { TxFlowFilter? flow, LabelChartBarStyle? barStyle, bool? invertExpensePerspective, + int? updatedAt, + bool? deleted, + bool touchUpdatedAt = false, }) { return OverviewFavourite( id: id ?? this.id, @@ -56,6 +67,10 @@ class OverviewFavourite { barStyle: barStyle ?? this.barStyle, invertExpensePerspective: invertExpensePerspective ?? this.invertExpensePerspective, + updatedAt: touchUpdatedAt + ? DateTime.now().millisecondsSinceEpoch + : (updatedAt ?? this.updatedAt), + deleted: deleted ?? this.deleted, ); } @@ -69,9 +84,18 @@ class OverviewFavourite { 'flow': flow.name, 'barStyle': barStyle.name, 'invertExpensePerspective': invertExpensePerspective, + 'updatedAt': updatedAt, + 'deleted': deleted, }; factory OverviewFavourite.fromJson(Map json) { + final rawUpdated = json['updatedAt'] ?? json['updated_at']; + final updatedAt = switch (rawUpdated) { + int n => n, + num n => n.round(), + String s => int.tryParse(s) ?? 0, + _ => 0, + }; return OverviewFavourite( id: json['id'] as String? ?? 'fav_${DateTime.now().millisecondsSinceEpoch}', name: (json['name'] as String?)?.trim().isNotEmpty == true @@ -100,6 +124,8 @@ class OverviewFavourite { ), invertExpensePerspective: json['invertExpensePerspective'] as bool? ?? false, + updatedAt: updatedAt, + deleted: json['deleted'] as bool? ?? false, ); } } @@ -107,3 +133,25 @@ class OverviewFavourite { enum OverviewMetricMode { flow, labels } enum OverviewPeriodMode { monthly, yearly } + +/// Union favourites by id with last-write-wins on [OverviewFavourite.updatedAt]. +List mergeOverviewFavourites( + List local, + List remote, +) { + final byId = {}; + void put(OverviewFavourite fav) { + final existing = byId[fav.id]; + if (existing == null || fav.updatedAt >= existing.updatedAt) { + byId[fav.id] = fav; + } + } + + for (final fav in remote) { + put(fav); + } + for (final fav in local) { + put(fav); + } + return byId.values.toList(growable: false); +} diff --git a/lib/providers/preferences_provider.dart b/lib/providers/preferences_provider.dart index 2974c76..a55180a 100644 --- a/lib/providers/preferences_provider.dart +++ b/lib/providers/preferences_provider.dart @@ -42,26 +42,40 @@ class PreferencesController extends ChangeNotifier { ); List get overviewFavourites => + List.unmodifiable([ + for (final fav in _overviewFavourites) + if (!fav.deleted) fav, + ]); + + /// Full list including soft-deleted rows (for PocketBase push). + List get overviewFavouritesForSync => List.unmodifiable(_overviewFavourites); List get displayedOverviewFavourites => [ for (final fav in _overviewFavourites) - if (fav.display) fav, + if (!fav.deleted && fav.display) fav, ]; bool get hasDisplayedOverviewFavourites => - _overviewFavourites.any((f) => f.display); + _overviewFavourites.any((f) => !f.deleted && f.display); List get homeFilterWidgets => + List.unmodifiable([ + for (final w in _homeFilterWidgets) + if (!w.deleted) w, + ]); + + /// Full list including soft-deleted rows (for PocketBase push). + List get homeFilterWidgetsForSync => List.unmodifiable(_homeFilterWidgets); List get displayedHomeFilterWidgets => [ for (final w in _homeFilterWidgets) - if (w.display) w, + if (!w.deleted && w.display) w, ]; bool get hasDisplayedHomeFilterWidgets => - _homeFilterWidgets.any((w) => w.display); + _homeFilterWidgets.any((w) => !w.deleted && w.display); HomeBuiltInSections get homeBuiltInSections => _homeBuiltInSections; @@ -118,7 +132,10 @@ class PreferencesController extends ChangeNotifier { } Future addOverviewFavourite(OverviewFavourite favourite) async { - _overviewFavourites = [..._overviewFavourites, favourite]; + final stamped = favourite.updatedAt == 0 + ? favourite.copyWith(touchUpdatedAt: true) + : favourite; + _overviewFavourites = [..._overviewFavourites, stamped]; notifyListeners(); await _persistFavourites(); } @@ -127,18 +144,19 @@ class PreferencesController extends ChangeNotifier { final index = _overviewFavourites.indexWhere((f) => f.id == favourite.id); if (index < 0) return; final next = [..._overviewFavourites]; - next[index] = favourite; + next[index] = favourite.copyWith(touchUpdatedAt: true); _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; + final index = _overviewFavourites.indexWhere((f) => f.id == id); + if (index < 0) return; + final current = _overviewFavourites[index]; + if (current.deleted) return; + final next = [..._overviewFavourites]; + next[index] = current.copyWith(deleted: true, touchUpdatedAt: true); _overviewFavourites = next; notifyListeners(); await _persistFavourites(); @@ -148,7 +166,7 @@ class PreferencesController extends ChangeNotifier { final index = _overviewFavourites.indexWhere((f) => f.id == id); if (index < 0) return; final current = _overviewFavourites[index]; - if (current.display == display) return; + if (current.deleted || current.display == display) return; await updateOverviewFavourite(current.copyWith(display: display)); } @@ -164,16 +182,20 @@ class PreferencesController extends ChangeNotifier { } Future addHomeFilterWidget(HomeFilterWidget widget) async { - final order = _homeFilterWidgets.isEmpty + final order = _homeFilterWidgets.where((w) => !w.deleted).isEmpty ? 0 : _homeFilterWidgets + .where((w) => !w.deleted) .map((w) => w.sortOrder) .reduce((a, b) => a > b ? a : b) + 1; - _homeFilterWidgets = [ - ..._homeFilterWidgets, - widget.copyWith(sortOrder: widget.sortOrder == 0 ? order : widget.sortOrder), - ]; + final stamped = (widget.updatedAt == 0 + ? widget.copyWith(touchUpdatedAt: true) + : widget) + .copyWith( + sortOrder: widget.sortOrder == 0 ? order : widget.sortOrder, + ); + _homeFilterWidgets = [..._homeFilterWidgets, stamped]; notifyListeners(); await _persistHomeWidgets(); } @@ -182,7 +204,7 @@ class PreferencesController extends ChangeNotifier { final index = _homeFilterWidgets.indexWhere((w) => w.id == widget.id); if (index < 0) return; final next = [..._homeFilterWidgets]; - next[index] = widget; + next[index] = widget.copyWith(touchUpdatedAt: true); next.sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); _homeFilterWidgets = next; notifyListeners(); @@ -190,11 +212,12 @@ class PreferencesController extends ChangeNotifier { } Future removeHomeFilterWidget(String id) async { - final next = [ - for (final w in _homeFilterWidgets) - if (w.id != id) w, - ]; - if (next.length == _homeFilterWidgets.length) return; + final index = _homeFilterWidgets.indexWhere((w) => w.id == id); + if (index < 0) return; + final current = _homeFilterWidgets[index]; + if (current.deleted) return; + final next = [..._homeFilterWidgets]; + next[index] = current.copyWith(deleted: true, touchUpdatedAt: true); _homeFilterWidgets = next; notifyListeners(); await _persistHomeWidgets(); @@ -204,19 +227,32 @@ class PreferencesController extends ChangeNotifier { final index = _homeFilterWidgets.indexWhere((w) => w.id == id); if (index < 0) return; final current = _homeFilterWidgets[index]; - if (current.display == display) return; + if (current.deleted || current.display == display) return; await updateHomeFilterWidget(current.copyWith(display: display)); } Future reorderHomeFilterWidgets(int oldIndex, int newIndex) async { if (oldIndex == newIndex) return; - final next = [..._homeFilterWidgets]; - final item = next.removeAt(oldIndex); - final target = newIndex.clamp(0, next.length); - next.insert(target, item); - _homeFilterWidgets = [ - for (var i = 0; i < next.length; i++) next[i].copyWith(sortOrder: i), + final visible = [ + for (final w in _homeFilterWidgets) + if (!w.deleted) w, ]; + if (oldIndex < 0 || oldIndex >= visible.length) return; + final nextVisible = [...visible]; + final item = nextVisible.removeAt(oldIndex); + final target = newIndex.clamp(0, nextVisible.length); + nextVisible.insert(target, item); + final orderById = { + for (var i = 0; i < nextVisible.length; i++) nextVisible[i].id: i, + }; + final now = DateTime.now().millisecondsSinceEpoch; + _homeFilterWidgets = [ + for (final w in _homeFilterWidgets) + if (w.deleted) + w + else + w.copyWith(sortOrder: orderById[w.id] ?? w.sortOrder, updatedAt: now), + ]..sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); notifyListeners(); await _persistHomeWidgets(); } diff --git a/lib/providers/sync_provider.dart b/lib/providers/sync_provider.dart index 667362b..c85c165 100644 --- a/lib/providers/sync_provider.dart +++ b/lib/providers/sync_provider.dart @@ -372,7 +372,7 @@ class SyncController extends ChangeNotifier { ); } final mergedFavourites = mergeOverviewFavourites( - _prefs.overviewFavourites, + _prefs.overviewFavouritesForSync, remoteFavourites, ); List remoteHomeWidgets = const []; @@ -386,7 +386,7 @@ class SyncController extends ChangeNotifier { ); } final mergedHomeWidgets = mergeHomeFilterWidgets( - _prefs.homeFilterWidgets, + _prefs.homeFilterWidgetsForSync, remoteHomeWidgets, ); AppCloudPrefs? remoteAppPrefs; @@ -576,7 +576,7 @@ class SyncController extends ChangeNotifier { _setStatus('Pushing favourites…'); try { await service.pushOverviewFavourites( - _prefs.overviewFavourites, + _prefs.overviewFavouritesForSync, onStatus: _setStatus, ); parts.add('Favourites updated'); @@ -584,6 +584,7 @@ class SyncController extends ChangeNotifier { debugPrint( 'SyncController: overview_favourites push skipped ($error)', ); + _pushFavouritesPending = true; } bump(); } @@ -591,7 +592,7 @@ class SyncController extends ChangeNotifier { _setStatus('Pushing home widgets…'); try { await service.pushHomeFilterWidgets( - _prefs.homeFilterWidgets, + _prefs.homeFilterWidgetsForSync, onStatus: _setStatus, ); parts.add('Home widgets updated'); @@ -599,6 +600,7 @@ class SyncController extends ChangeNotifier { debugPrint( 'SyncController: home_filter_widgets push skipped ($error)', ); + _pushHomeWidgetsPending = true; } bump(); } diff --git a/lib/screens/overview_screen.dart b/lib/screens/overview_screen.dart index 79a9f58..c4da88f 100644 --- a/lib/screens/overview_screen.dart +++ b/lib/screens/overview_screen.dart @@ -474,7 +474,19 @@ class _OverviewScreenState extends State { ), ); }, - onMonthTap: (month) => _openPeriod(context, month), + onMonthTap: (month, {labelId, flow}) => _openPeriod( + context, + month, + labelId: labelId, + labelIds: { + for (final id in favourite.labelIds) id, + }, + flow: flow ?? + (favourite.metric == OverviewMetricMode.labels + ? favourite.flow + : null), + periodMode: favourite.period, + ), ); } @@ -590,7 +602,13 @@ class _OverviewScreenState extends State { ), ); }, - onMonthTap: (month) => _openPeriod(context, month), + onMonthTap: (month, {labelId, flow}) => _openPeriod( + context, + month, + labelId: labelId, + flow: flow ?? + (_metric == OverviewMetricMode.labels ? _labelFlow : null), + ), ), const SizedBox(height: 14), if (_metric == OverviewMetricMode.flow) ...[ @@ -739,22 +757,28 @@ class _OverviewScreenState extends State { BuildContext context, DateTime period, { String? labelId, + Set? labelIds, + TxFlowFilter? flow, + OverviewPeriodMode? periodMode, }) { - final yearly = _period == OverviewPeriodMode.yearly; + final mode = periodMode ?? _period; + final yearly = mode == OverviewPeriodMode.yearly; final initial = yearly ? DateTime(period.year, 12) : period; - final labelIds = labelId != null + final resolvedLabelIds = labelId != null ? {labelId} - : { - // Open with the same labels currently shown on the chart. - for (final id in _includedLabelIds) id, - }; + : (labelIds ?? + { + // Open with the same labels currently shown on the chart. + for (final id in _includedLabelIds) id, + }); Navigator.of(context).push( MaterialPageRoute( builder: (_) => TransactionsScreen( initialMonth: initial, - initialLabelIds: labelIds, + initialLabelIds: resolvedLabelIds, initialLabelMatch: LabelMatchMode.or, - initialSearchInMonthOnly: !yearly && labelIds.isNotEmpty, + initialFlow: flow, + initialSearchInMonthOnly: !yearly && resolvedLabelIds.isNotEmpty, initialYear: yearly ? period.year : null, ), ), @@ -1182,7 +1206,11 @@ class _ChartCard extends StatelessWidget { final int? includedLabelCount; final int availableLabelCount; final void Function(TxLabel label) onLabelTap; - final void Function(DateTime month) onMonthTap; + final void Function( + DateTime month, { + String? labelId, + TxFlowFilter? flow, + }) onMonthTap; final ValueChanged? onPeriodModeChanged; final VoidCallback? onEdit; @@ -1277,7 +1305,7 @@ class _ChartCard extends StatelessWidget { runSpacing: 2, alignment: WrapAlignment.end, children: [ - for (final row in labelSpend.take(compact ? 3 : 5)) + for (final row in labelSpend.take(6)) GestureDetector( onTap: () => onLabelTap(row.label), child: _LegendDot( @@ -1286,7 +1314,7 @@ class _ChartCard extends StatelessWidget { compact: compact, ), ), - if (labelSpend.length > (compact ? 3 : 5)) + if (labelSpend.length > 6) _LegendDot( color: colors.outline, label: 'Other', @@ -1529,6 +1557,7 @@ class _ChartCard extends StatelessWidget { monthly: monthly, currency: currency, period: period, + onMonthTap: onMonthTap, ) : _LabelMonthBars( months: months, @@ -1554,12 +1583,18 @@ class _FlowBars extends StatelessWidget { required this.monthly, required this.currency, required this.period, + required this.onMonthTap, }); final List months; final Map monthly; final String currency; final OverviewPeriodMode period; + final void Function( + DateTime month, { + String? labelId, + TxFlowFilter? flow, + }) onMonthTap; @override Widget build(BuildContext context) { @@ -1594,6 +1629,19 @@ class _FlowBars extends StatelessWidget { maxY: safeMax, groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10, barTouchData: BarTouchData( + touchCallback: (event, response) { + if (event is! FlTapUpEvent) return; + final spot = response?.spot; + if (spot == null) return; + final index = spot.touchedBarGroupIndex; + if (index < 0 || index >= months.length) return; + final rodIndex = spot.touchedRodDataIndex; + // Rods: 0 = expense, 1 = income. + final flow = rodIndex == 1 + ? TxFlowFilter.income + : TxFlowFilter.expense; + onMonthTap(months[index], flow: flow); + }, touchTooltipData: BarTouchTooltipData( getTooltipItem: (group, groupIndex, rod, rodIndex) { final month = months[group.x.toInt()]; @@ -1709,7 +1757,7 @@ class _LabelMonthBars extends StatelessWidget { required this.onMonthTap, }); - static const _maxSeriesLabels = 5; + static const _maxSeriesLabels = 6; final List months; final List<_LabelSpend> labelSpend; @@ -1718,7 +1766,11 @@ class _LabelMonthBars extends StatelessWidget { final OverviewPeriodMode period; final LabelChartBarStyle barStyle; final bool invertChart; - final void Function(DateTime month) onMonthTap; + final void Function( + DateTime month, { + String? labelId, + TxFlowFilter? flow, + }) onMonthTap; double _plot(double value) => invertChart ? -value : value; @@ -1805,12 +1857,12 @@ class _LabelMonthBars extends StatelessWidget { final rodWidth = stacked ? (period == OverviewPeriodMode.yearly ? 18.0 : 14.0) : period == OverviewPeriodMode.yearly - ? (seriesCount <= 3 ? 12.0 : 8.0) + ? (seriesCount <= 3 ? 12.0 : seriesCount <= 6 ? 8.0 : 6.0) : (seriesCount <= 3 ? 7.0 - : seriesCount <= 5 - ? 5.0 - : 4.0); + : seriesCount <= 6 + ? 4.5 + : 3.5); List rodsFor(DateTime month) { final bucket = @@ -1896,9 +1948,24 @@ class _LabelMonthBars extends StatelessWidget { barTouchData: BarTouchData( touchCallback: (event, response) { if (event is! FlTapUpEvent) return; - final index = response?.spot?.touchedBarGroupIndex; - if (index == null || index < 0 || index >= months.length) return; - onMonthTap(months[index]); + final spot = response?.spot; + if (spot == null) return; + final index = spot.touchedBarGroupIndex; + if (index < 0 || index >= months.length) return; + String? labelId; + if (stacked) { + final stackIdx = spot.touchedStackItemIndex; + if (stackIdx >= 0 && stackIdx < topLabels.length) { + labelId = topLabels[stackIdx].label.id; + } + } else { + final rodIdx = spot.touchedRodDataIndex; + if (rodIdx >= 0 && rodIdx < topLabels.length) { + labelId = topLabels[rodIdx].label.id; + } + // Last rod may be "Other" — open month with chart labels only. + } + onMonthTap(months[index], labelId: labelId); }, touchTooltipData: BarTouchTooltipData( getTooltipItem: (group, groupIndex, rod, rodIndex) { diff --git a/lib/services/pb_sync_service.dart b/lib/services/pb_sync_service.dart index e9d7156..83feda0 100644 --- a/lib/services/pb_sync_service.dart +++ b/lib/services/pb_sync_service.dart @@ -25,20 +25,6 @@ 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}; @@ -485,6 +471,8 @@ class PbSyncService { ), invertExpensePerspective: r.getBoolValue('invert_expense_perspective'), + updatedAt: _recordUpdatedAtMs(r), + deleted: r.getBoolValue('deleted'), ), ); } @@ -544,6 +532,8 @@ class PbSyncService { 'showAmountIn': r.getBoolValue('show_amount_in', true), 'showAmountOut': r.getBoolValue('show_amount_out', true), 'showCount': r.getBoolValue('show_count', true), + 'updatedAt': _recordUpdatedAtMs(r), + 'deleted': r.getBoolValue('deleted'), }), ); } @@ -1364,6 +1354,8 @@ class PbSyncService { 'flow': fav.flow.name, 'bar_style': fav.barStyle.name, 'invert_expense_perspective': fav.invertExpensePerspective, + 'updated_at': fav.updatedAt, + 'deleted': fav.deleted, }; final remote = existing[clientId]; if (remote == null) { @@ -1428,6 +1420,8 @@ class PbSyncService { 'show_amount_in': w.showAmountIn, 'show_amount_out': w.showAmountOut, 'show_count': w.showCount, + 'updated_at': w.updatedAt, + 'deleted': w.deleted, }; final remote = existing[clientId]; if (remote == null) { @@ -1533,7 +1527,9 @@ class PbSyncService { remote.getStringValue('flow') == fav.flow.name && remote.getStringValue('bar_style') == fav.barStyle.name && remote.getBoolValue('invert_expense_perspective') == - fav.invertExpensePerspective; + fav.invertExpensePerspective && + remote.getBoolValue('deleted') == fav.deleted && + _recordUpdatedAtMs(remote) == fav.updatedAt; } bool _homeFilterWidgetUnchanged(RecordModel remote, HomeFilterWidget w) { @@ -1572,7 +1568,9 @@ class PbSyncService { 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; + remote.getBoolValue('show_count', true) == w.showCount && + remote.getBoolValue('deleted') == w.deleted && + _recordUpdatedAtMs(remote) == w.updatedAt; } bool _appPrefsUnchanged(RecordModel remote, AppCloudPrefs prefs) { @@ -1585,6 +1583,20 @@ class PbSyncService { remote.getIntValue('look_ahead_max_years', 5) == prefs.lookAheadMaxYears.clamp(1, 30); } + + /// Prefer custom `updated_at`, else PocketBase system `updated`. + int _recordUpdatedAtMs(RecordModel r) { + final raw = r.get('updated_at'); + final custom = switch (raw) { + int n => n, + num n => n.round(), + String s => int.tryParse(s) ?? 0, + _ => 0, + }; + if (custom > 0) return custom; + final system = DateTime.tryParse(r.getStringValue('updated')); + return system?.millisecondsSinceEpoch ?? 0; + } } RecurringTemplateSource _parseTemplateSource(String raw) { diff --git a/lib/widgets/home_filter_widget_card.dart b/lib/widgets/home_filter_widget_card.dart index 148a15a..92fe688 100644 --- a/lib/widgets/home_filter_widget_card.dart +++ b/lib/widgets/home_filter_widget_card.dart @@ -288,9 +288,80 @@ class _StatsBody extends StatelessWidget { final spentOnly = widgetConfig.showAmountOut && !widgetConfig.showAmountIn && !widgetConfig.showCount; + final half = widgetConfig.halfWidth; + + Widget metricsBlock({required bool alignEnd}) { + if (spentOnly) { + return Column( + crossAxisAlignment: + alignEnd ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + Text( + 'Spent', + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + Text( + formatMoney(metrics.amountOut, currency), + style: theme.textTheme.titleMedium?.copyWith( + color: AppTheme.expense, + fontWeight: FontWeight.w700, + height: 1.15, + ), + ), + ], + ); + } + return Wrap( + spacing: 16, + runSpacing: 8, + alignment: alignEnd ? WrapAlignment.end : WrapAlignment.start, + children: [ + if (widgetConfig.showAmountIn) + _Metric( + label: 'In', + value: formatMoney(metrics.amountIn, currency), + color: AppTheme.accent, + ), + if (widgetConfig.showAmountOut) + _Metric( + label: 'Spent', + value: formatMoney(metrics.amountOut, currency), + color: AppTheme.expense, + ), + if (widgetConfig.showCount) + _Metric( + label: 'Transactions', + value: '${metrics.count}', + color: colors.primary, + ), + ], + ); + } // Avoid LayoutBuilder/Spacer/Expanded here: half-width rows use // IntrinsicHeight, which cannot measure those widgets. + if (!half) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _CardHeader( + icon: widgetConfig.icon, + title: widgetConfig.name, + subtitle: _subtitleFor(widgetConfig), + ), + ), + const SizedBox(width: 12), + Flexible( + child: metricsBlock(alignEnd: true), + ), + ], + ); + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -299,50 +370,8 @@ class _StatsBody extends StatelessWidget { title: widgetConfig.name, subtitle: _subtitleFor(widgetConfig), ), - if (spentOnly) ...[ - const SizedBox(height: 32), - Text( - 'Spent', - style: theme.textTheme.labelMedium?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - const SizedBox(height: 2), - Text( - formatMoney(metrics.amountOut, currency), - style: theme.textTheme.titleMedium?.copyWith( - color: AppTheme.expense, - fontWeight: FontWeight.w700, - height: 1.15, - ), - ), - ] else ...[ - const SizedBox(height: 14), - Wrap( - spacing: 16, - runSpacing: 8, - children: [ - if (widgetConfig.showAmountIn) - _Metric( - label: 'In', - value: formatMoney(metrics.amountIn, currency), - color: AppTheme.accent, - ), - if (widgetConfig.showAmountOut) - _Metric( - label: 'Spent', - value: formatMoney(metrics.amountOut, currency), - color: AppTheme.expense, - ), - if (widgetConfig.showCount) - _Metric( - label: 'Txs', - value: '${metrics.count}', - color: colors.primary, - ), - ], - ), - ], + SizedBox(height: spentOnly ? 32 : 14), + metricsBlock(alignEnd: false), ], ); } diff --git a/pocketbase/pb_migrations/1740000012_ingtrak_favourites_widgets_sync_meta.js b/pocketbase/pb_migrations/1740000012_ingtrak_favourites_widgets_sync_meta.js new file mode 100644 index 0000000..73c251d --- /dev/null +++ b/pocketbase/pb_migrations/1740000012_ingtrak_favourites_widgets_sync_meta.js @@ -0,0 +1,48 @@ +/// PocketBase migration: `updated_at` + `deleted` on overview favourites and +/// home filter widgets for cross-device last-write-wins sync. +/// +/// 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 +migrate((app) => { + function addSyncFields(collectionName) { + const col = app.findCollectionByNameOrId(collectionName); + let changed = false; + if (!col.fields.getByName("updated_at")) { + col.fields.add(new Field({ + type: "number", + name: "updated_at", + required: false, + })); + changed = true; + } + if (!col.fields.getByName("deleted")) { + col.fields.add(new Field({ + type: "bool", + name: "deleted", + required: false, + })); + changed = true; + } + if (changed) app.save(col); + } + + addSyncFields("overview_favourites"); + addSyncFields("home_filter_widgets"); +}, (app) => { + function removeSyncFields(collectionName) { + try { + const col = app.findCollectionByNameOrId(collectionName); + const updated = col.fields.getByName("updated_at"); + if (updated) col.fields.removeById(updated.id); + const deleted = col.fields.getByName("deleted"); + if (deleted) col.fields.removeById(deleted.id); + app.save(col); + } catch (_) {} + } + removeSyncFields("overview_favourites"); + removeSyncFields("home_filter_widgets"); +}); diff --git a/test/overview_favourite_test.dart b/test/overview_favourite_test.dart index 83b8552..f5dbb02 100644 --- a/test/overview_favourite_test.dart +++ b/test/overview_favourite_test.dart @@ -1,7 +1,6 @@ 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() { @@ -16,6 +15,8 @@ void main() { flow: TxFlowFilter.expense, barStyle: LabelChartBarStyle.stacked, invertExpensePerspective: false, + updatedAt: 100, + deleted: false, ); final restored = OverviewFavourite.fromJson(fav.toJson()); @@ -27,35 +28,53 @@ void main() { expect(restored.labelIds, ['groceries', 'custom_ah']); expect(restored.flow, TxFlowFilter.expense); expect(restored.barStyle, LabelChartBarStyle.stacked); + expect(restored.updatedAt, 100); + expect(restored.deleted, isFalse); }); - test('mergeOverviewFavourites unions by id with local wins', () { + test('mergeOverviewFavourites unions by id with last-write-wins', () { const local = OverviewFavourite( id: 'fav_1', name: 'Local name', display: true, + updatedAt: 200, ); const remoteOnly = OverviewFavourite( id: 'fav_2', name: 'Remote only', display: false, metric: OverviewMetricMode.labels, + updatedAt: 50, ); - const remoteConflict = OverviewFavourite( + const remoteConflictOlder = OverviewFavourite( id: 'fav_1', name: 'Remote name', display: false, + updatedAt: 100, + ); + const remoteConflictNewer = OverviewFavourite( + id: 'fav_3', + name: 'Remote newer', + display: true, + updatedAt: 300, + ); + const localOlder = OverviewFavourite( + id: 'fav_3', + name: 'Local older', + display: false, + updatedAt: 10, ); final merged = mergeOverviewFavourites( - [local], - [remoteConflict, remoteOnly], + [local, localOlder], + [remoteConflictOlder, remoteOnly, remoteConflictNewer], ); - expect(merged.length, 2); + expect(merged.length, 3); 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'); + expect(byId['fav_3']!.name, 'Remote newer'); }); }