new favourites feature
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user