new favourites feature

This commit is contained in:
davmar
2026-09-10 16:25:24 +02:00
parent aec89ac41d
commit 83295439d5
22 changed files with 3168 additions and 282 deletions
+273
View File
@@ -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<BankTransaction> mergeBankTransactionLists(
return mergeTransactions(local, remote);
}
/// Union Overview favourites by client id (local wins on conflict).
List<OverviewFavourite> mergeOverviewFavourites(
List<OverviewFavourite> local,
List<OverviewFavourite> remote,
) {
final byId = <String, OverviewFavourite>{
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 = <String, TxMeta>{...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<List<OverviewFavourite>> 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 = <OverviewFavourite>[];
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<dynamic>('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<void> pushOverviewFavourites(
List<OverviewFavourite> 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<List<HomeFilterWidget>> 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 = <HomeFilterWidget>[];
for (final r in records) {
final clientId = r.getStringValue('client_id');
if (clientId.isEmpty) continue;
final rangeRaw = r.get<dynamic>('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<dynamic>('label_ids')),
'flow': r.getStringValue('flow'),
'amountRange': rangeRaw is Map
? Map<String, dynamic>.from(rangeRaw)
: const <String, dynamic>{},
'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<void> pushHomeFilterWidgets(
List<HomeFilterWidget> 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<String> 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<void> _syncOverviewFavourites(
String userId,
String filter,
List<OverviewFavourite> favourites,
) async {
final existing = {
for (final r in await _fullList('overview_favourites', filter))
r.getStringValue('client_id'): r,
};
final keep = <String>{};
final ops = <_BatchOp>[];
for (final fav in favourites) {
final clientId = _clip(fav.id, 120);
if (clientId.isEmpty) continue;
keep.add(clientId);
final body = <String, dynamic>{
'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<void> _syncHomeFilterWidgets(
String userId,
String filter,
List<HomeFilterWidget> widgets,
) async {
final existing = {
for (final r in await _fullList('home_filter_widgets', filter))
r.getStringValue('client_id'): r,
};
final keep = <String>{};
final ops = <_BatchOp>[];
for (final w in widgets) {
final clientId = _clip(w.id, 120);
if (clientId.isEmpty) continue;
keep.add(clientId);
final body = <String, dynamic>{
'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<String, dynamic> 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<dynamic>('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<dynamic>('label_ids'));
final rangeRaw = remote.get<dynamic>('amount_range');
final rangeMap = rangeRaw is Map
? Map<String, dynamic>.from(rangeRaw)
: const <String, dynamic>{};
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) {