some issues fixed

This commit is contained in:
davmar
2026-09-11 12:31:07 +02:00
parent 2e40786bcd
commit 40366f0708
9 changed files with 416 additions and 127 deletions
+33 -5
View File
@@ -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<String, dynamic> json) {
@@ -302,21 +320,31 @@ 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<HomeFilterWidget> mergeHomeFilterWidgets(
List<HomeFilterWidget> local,
List<HomeFilterWidget> remote,
) {
final byId = <String, HomeFilterWidget>{
for (final w in remote) w.id: w,
};
for (final w in local) {
final byId = <String, HomeFilterWidget>{};
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) {
put(w);
}
final merged = byId.values.toList()
..sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
return merged;
+48
View File
@@ -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<String, dynamic> 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<OverviewFavourite> mergeOverviewFavourites(
List<OverviewFavourite> local,
List<OverviewFavourite> remote,
) {
final byId = <String, OverviewFavourite>{};
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);
}
+66 -30
View File
@@ -42,26 +42,40 @@ class PreferencesController extends ChangeNotifier {
);
List<OverviewFavourite> get overviewFavourites =>
List.unmodifiable([
for (final fav in _overviewFavourites)
if (!fav.deleted) fav,
]);
/// Full list including soft-deleted rows (for PocketBase push).
List<OverviewFavourite> get overviewFavouritesForSync =>
List.unmodifiable(_overviewFavourites);
List<OverviewFavourite> 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<HomeFilterWidget> get homeFilterWidgets =>
List.unmodifiable([
for (final w in _homeFilterWidgets)
if (!w.deleted) w,
]);
/// Full list including soft-deleted rows (for PocketBase push).
List<HomeFilterWidget> get homeFilterWidgetsForSync =>
List.unmodifiable(_homeFilterWidgets);
List<HomeFilterWidget> 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<void> 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<void> 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<void> 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<void> 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<void> 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 = <String, int>{
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();
}
+6 -4
View File
@@ -372,7 +372,7 @@ class SyncController extends ChangeNotifier {
);
}
final mergedFavourites = mergeOverviewFavourites(
_prefs.overviewFavourites,
_prefs.overviewFavouritesForSync,
remoteFavourites,
);
List<HomeFilterWidget> 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();
}
+87 -20
View File
@@ -474,7 +474,19 @@ class _OverviewScreenState extends State<OverviewScreen> {
),
);
},
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<OverviewScreen> {
),
);
},
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<OverviewScreen> {
BuildContext context,
DateTime period, {
String? labelId,
Set<String>? 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}
: {
: (labelIds ??
{
// Open with the same labels currently shown on the chart.
for (final id in _includedLabelIds) id,
};
});
Navigator.of(context).push(
MaterialPageRoute<void>(
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<OverviewPeriodMode>? 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<DateTime> months;
final Map<DateTime, _MonthBucket> 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<DateTime> 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<BarChartRodData> 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) {
+28 -16
View File
@@ -25,20 +25,6 @@ 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};
@@ -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<dynamic>('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) {
+43 -14
View File
@@ -288,19 +288,14 @@ class _StatsBody extends StatelessWidget {
final spentOnly = widgetConfig.showAmountOut &&
!widgetConfig.showAmountIn &&
!widgetConfig.showCount;
final half = widgetConfig.halfWidth;
// Avoid LayoutBuilder/Spacer/Expanded here: half-width rows use
// IntrinsicHeight, which cannot measure those widgets.
Widget metricsBlock({required bool alignEnd}) {
if (spentOnly) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment:
alignEnd ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
_CardHeader(
icon: widgetConfig.icon,
title: widgetConfig.name,
subtitle: _subtitleFor(widgetConfig),
),
if (spentOnly) ...[
const SizedBox(height: 32),
Text(
'Spent',
style: theme.textTheme.labelMedium?.copyWith(
@@ -316,11 +311,13 @@ class _StatsBody extends StatelessWidget {
height: 1.15,
),
),
] else ...[
const SizedBox(height: 14),
Wrap(
],
);
}
return Wrap(
spacing: 16,
runSpacing: 8,
alignment: alignEnd ? WrapAlignment.end : WrapAlignment.start,
children: [
if (widgetConfig.showAmountIn)
_Metric(
@@ -336,13 +333,45 @@ class _StatsBody extends StatelessWidget {
),
if (widgetConfig.showCount)
_Metric(
label: 'Txs',
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: [
_CardHeader(
icon: widgetConfig.icon,
title: widgetConfig.name,
subtitle: _subtitleFor(widgetConfig),
),
SizedBox(height: spentOnly ? 32 : 14),
metricsBlock(alignEnd: false),
],
);
}
@@ -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");
});
+25 -6
View File
@@ -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');
});
}