2 Commits
Author SHA1 Message Date
davmar cfb551328b fix tap on chart 2026-09-11 12:45:50 +02:00
davmar 40366f0708 some issues fixed 2026-09-11 12:31:07 +02:00
9 changed files with 600 additions and 161 deletions
+33 -5
View File
@@ -94,6 +94,8 @@ class HomeFilterWidget {
this.showAmountIn = true, this.showAmountIn = true,
this.showAmountOut = true, this.showAmountOut = true,
this.showCount = true, this.showCount = true,
this.updatedAt = 0,
this.deleted = false,
}); });
final String id; final String id;
@@ -129,6 +131,12 @@ class HomeFilterWidget {
final bool showAmountOut; final bool showAmountOut;
final bool showCount; 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); IconData get icon => HomeWidgetIcons.iconFor(iconId);
TransactionFilterSelection get filter => TransactionFilterSelection( TransactionFilterSelection get filter => TransactionFilterSelection(
@@ -161,6 +169,9 @@ class HomeFilterWidget {
bool? showAmountIn, bool? showAmountIn,
bool? showAmountOut, bool? showAmountOut,
bool? showCount, bool? showCount,
int? updatedAt,
bool? deleted,
bool touchUpdatedAt = false,
}) { }) {
return HomeFilterWidget( return HomeFilterWidget(
id: id ?? this.id, id: id ?? this.id,
@@ -183,6 +194,10 @@ class HomeFilterWidget {
showAmountIn: showAmountIn ?? this.showAmountIn, showAmountIn: showAmountIn ?? this.showAmountIn,
showAmountOut: showAmountOut ?? this.showAmountOut, showAmountOut: showAmountOut ?? this.showAmountOut,
showCount: showCount ?? this.showCount, showCount: showCount ?? this.showCount,
updatedAt: touchUpdatedAt
? DateTime.now().millisecondsSinceEpoch
: (updatedAt ?? this.updatedAt),
deleted: deleted ?? this.deleted,
); );
} }
@@ -208,6 +223,7 @@ class HomeFilterWidget {
showAmountIn: showIn, showAmountIn: showIn,
showAmountOut: showOut, showAmountOut: showOut,
showCount: true, showCount: true,
updatedAt: DateTime.now().millisecondsSinceEpoch,
); );
} }
@@ -236,6 +252,8 @@ class HomeFilterWidget {
'showAmountIn': showAmountIn, 'showAmountIn': showAmountIn,
'showAmountOut': showAmountOut, 'showAmountOut': showAmountOut,
'showCount': showCount, 'showCount': showCount,
'updatedAt': updatedAt,
'deleted': deleted,
}; };
factory HomeFilterWidget.fromJson(Map<String, dynamic> json) { factory HomeFilterWidget.fromJson(Map<String, dynamic> json) {
@@ -302,20 +320,30 @@ class HomeFilterWidget {
showAmountIn: json['showAmountIn'] as bool? ?? true, showAmountIn: json['showAmountIn'] as bool? ?? true,
showAmountOut: json['showAmountOut'] as bool? ?? true, showAmountOut: json['showAmountOut'] as bool? ?? true,
showCount: json['showCount'] 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> mergeHomeFilterWidgets(
List<HomeFilterWidget> local, List<HomeFilterWidget> local,
List<HomeFilterWidget> remote, List<HomeFilterWidget> remote,
) { ) {
final byId = <String, HomeFilterWidget>{ final byId = <String, HomeFilterWidget>{};
for (final w in remote) w.id: w, 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) { for (final w in local) {
byId[w.id] = w; put(w);
} }
final merged = byId.values.toList() final merged = byId.values.toList()
..sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); ..sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
+48
View File
@@ -16,6 +16,8 @@ class OverviewFavourite {
this.flow = TxFlowFilter.expense, this.flow = TxFlowFilter.expense,
this.barStyle = LabelChartBarStyle.parallel, this.barStyle = LabelChartBarStyle.parallel,
this.invertExpensePerspective = false, this.invertExpensePerspective = false,
this.updatedAt = 0,
this.deleted = false,
}); });
final String id; final String id;
@@ -34,6 +36,12 @@ class OverviewFavourite {
final LabelChartBarStyle barStyle; final LabelChartBarStyle barStyle;
final bool invertExpensePerspective; 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({ OverviewFavourite copyWith({
String? id, String? id,
String? name, String? name,
@@ -44,6 +52,9 @@ class OverviewFavourite {
TxFlowFilter? flow, TxFlowFilter? flow,
LabelChartBarStyle? barStyle, LabelChartBarStyle? barStyle,
bool? invertExpensePerspective, bool? invertExpensePerspective,
int? updatedAt,
bool? deleted,
bool touchUpdatedAt = false,
}) { }) {
return OverviewFavourite( return OverviewFavourite(
id: id ?? this.id, id: id ?? this.id,
@@ -56,6 +67,10 @@ class OverviewFavourite {
barStyle: barStyle ?? this.barStyle, barStyle: barStyle ?? this.barStyle,
invertExpensePerspective: invertExpensePerspective:
invertExpensePerspective ?? this.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, 'flow': flow.name,
'barStyle': barStyle.name, 'barStyle': barStyle.name,
'invertExpensePerspective': invertExpensePerspective, 'invertExpensePerspective': invertExpensePerspective,
'updatedAt': updatedAt,
'deleted': deleted,
}; };
factory OverviewFavourite.fromJson(Map<String, dynamic> json) { 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( return OverviewFavourite(
id: json['id'] as String? ?? 'fav_${DateTime.now().millisecondsSinceEpoch}', id: json['id'] as String? ?? 'fav_${DateTime.now().millisecondsSinceEpoch}',
name: (json['name'] as String?)?.trim().isNotEmpty == true name: (json['name'] as String?)?.trim().isNotEmpty == true
@@ -100,6 +124,8 @@ class OverviewFavourite {
), ),
invertExpensePerspective: invertExpensePerspective:
json['invertExpensePerspective'] as bool? ?? false, json['invertExpensePerspective'] as bool? ?? false,
updatedAt: updatedAt,
deleted: json['deleted'] as bool? ?? false,
); );
} }
} }
@@ -107,3 +133,25 @@ class OverviewFavourite {
enum OverviewMetricMode { flow, labels } enum OverviewMetricMode { flow, labels }
enum OverviewPeriodMode { monthly, yearly } 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<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.unmodifiable(_overviewFavourites);
List<OverviewFavourite> get displayedOverviewFavourites => [ List<OverviewFavourite> get displayedOverviewFavourites => [
for (final fav in _overviewFavourites) for (final fav in _overviewFavourites)
if (fav.display) fav, if (!fav.deleted && fav.display) fav,
]; ];
bool get hasDisplayedOverviewFavourites => bool get hasDisplayedOverviewFavourites =>
_overviewFavourites.any((f) => f.display); _overviewFavourites.any((f) => !f.deleted && f.display);
List<HomeFilterWidget> get homeFilterWidgets => 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.unmodifiable(_homeFilterWidgets);
List<HomeFilterWidget> get displayedHomeFilterWidgets => [ List<HomeFilterWidget> get displayedHomeFilterWidgets => [
for (final w in _homeFilterWidgets) for (final w in _homeFilterWidgets)
if (w.display) w, if (!w.deleted && w.display) w,
]; ];
bool get hasDisplayedHomeFilterWidgets => bool get hasDisplayedHomeFilterWidgets =>
_homeFilterWidgets.any((w) => w.display); _homeFilterWidgets.any((w) => !w.deleted && w.display);
HomeBuiltInSections get homeBuiltInSections => _homeBuiltInSections; HomeBuiltInSections get homeBuiltInSections => _homeBuiltInSections;
@@ -118,7 +132,10 @@ class PreferencesController extends ChangeNotifier {
} }
Future<void> addOverviewFavourite(OverviewFavourite favourite) async { Future<void> addOverviewFavourite(OverviewFavourite favourite) async {
_overviewFavourites = [..._overviewFavourites, favourite]; final stamped = favourite.updatedAt == 0
? favourite.copyWith(touchUpdatedAt: true)
: favourite;
_overviewFavourites = [..._overviewFavourites, stamped];
notifyListeners(); notifyListeners();
await _persistFavourites(); await _persistFavourites();
} }
@@ -127,18 +144,19 @@ class PreferencesController extends ChangeNotifier {
final index = _overviewFavourites.indexWhere((f) => f.id == favourite.id); final index = _overviewFavourites.indexWhere((f) => f.id == favourite.id);
if (index < 0) return; if (index < 0) return;
final next = [..._overviewFavourites]; final next = [..._overviewFavourites];
next[index] = favourite; next[index] = favourite.copyWith(touchUpdatedAt: true);
_overviewFavourites = next; _overviewFavourites = next;
notifyListeners(); notifyListeners();
await _persistFavourites(); await _persistFavourites();
} }
Future<void> removeOverviewFavourite(String id) async { Future<void> removeOverviewFavourite(String id) async {
final next = [ final index = _overviewFavourites.indexWhere((f) => f.id == id);
for (final fav in _overviewFavourites) if (index < 0) return;
if (fav.id != id) fav, final current = _overviewFavourites[index];
]; if (current.deleted) return;
if (next.length == _overviewFavourites.length) return; final next = [..._overviewFavourites];
next[index] = current.copyWith(deleted: true, touchUpdatedAt: true);
_overviewFavourites = next; _overviewFavourites = next;
notifyListeners(); notifyListeners();
await _persistFavourites(); await _persistFavourites();
@@ -148,7 +166,7 @@ class PreferencesController extends ChangeNotifier {
final index = _overviewFavourites.indexWhere((f) => f.id == id); final index = _overviewFavourites.indexWhere((f) => f.id == id);
if (index < 0) return; if (index < 0) return;
final current = _overviewFavourites[index]; final current = _overviewFavourites[index];
if (current.display == display) return; if (current.deleted || current.display == display) return;
await updateOverviewFavourite(current.copyWith(display: display)); await updateOverviewFavourite(current.copyWith(display: display));
} }
@@ -164,16 +182,20 @@ class PreferencesController extends ChangeNotifier {
} }
Future<void> addHomeFilterWidget(HomeFilterWidget widget) async { Future<void> addHomeFilterWidget(HomeFilterWidget widget) async {
final order = _homeFilterWidgets.isEmpty final order = _homeFilterWidgets.where((w) => !w.deleted).isEmpty
? 0 ? 0
: _homeFilterWidgets : _homeFilterWidgets
.where((w) => !w.deleted)
.map((w) => w.sortOrder) .map((w) => w.sortOrder)
.reduce((a, b) => a > b ? a : b) + .reduce((a, b) => a > b ? a : b) +
1; 1;
_homeFilterWidgets = [ final stamped = (widget.updatedAt == 0
..._homeFilterWidgets, ? widget.copyWith(touchUpdatedAt: true)
widget.copyWith(sortOrder: widget.sortOrder == 0 ? order : widget.sortOrder), : widget)
]; .copyWith(
sortOrder: widget.sortOrder == 0 ? order : widget.sortOrder,
);
_homeFilterWidgets = [..._homeFilterWidgets, stamped];
notifyListeners(); notifyListeners();
await _persistHomeWidgets(); await _persistHomeWidgets();
} }
@@ -182,7 +204,7 @@ class PreferencesController extends ChangeNotifier {
final index = _homeFilterWidgets.indexWhere((w) => w.id == widget.id); final index = _homeFilterWidgets.indexWhere((w) => w.id == widget.id);
if (index < 0) return; if (index < 0) return;
final next = [..._homeFilterWidgets]; final next = [..._homeFilterWidgets];
next[index] = widget; next[index] = widget.copyWith(touchUpdatedAt: true);
next.sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); next.sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
_homeFilterWidgets = next; _homeFilterWidgets = next;
notifyListeners(); notifyListeners();
@@ -190,11 +212,12 @@ class PreferencesController extends ChangeNotifier {
} }
Future<void> removeHomeFilterWidget(String id) async { Future<void> removeHomeFilterWidget(String id) async {
final next = [ final index = _homeFilterWidgets.indexWhere((w) => w.id == id);
for (final w in _homeFilterWidgets) if (index < 0) return;
if (w.id != id) w, final current = _homeFilterWidgets[index];
]; if (current.deleted) return;
if (next.length == _homeFilterWidgets.length) return; final next = [..._homeFilterWidgets];
next[index] = current.copyWith(deleted: true, touchUpdatedAt: true);
_homeFilterWidgets = next; _homeFilterWidgets = next;
notifyListeners(); notifyListeners();
await _persistHomeWidgets(); await _persistHomeWidgets();
@@ -204,19 +227,32 @@ class PreferencesController extends ChangeNotifier {
final index = _homeFilterWidgets.indexWhere((w) => w.id == id); final index = _homeFilterWidgets.indexWhere((w) => w.id == id);
if (index < 0) return; if (index < 0) return;
final current = _homeFilterWidgets[index]; final current = _homeFilterWidgets[index];
if (current.display == display) return; if (current.deleted || current.display == display) return;
await updateHomeFilterWidget(current.copyWith(display: display)); await updateHomeFilterWidget(current.copyWith(display: display));
} }
Future<void> reorderHomeFilterWidgets(int oldIndex, int newIndex) async { Future<void> reorderHomeFilterWidgets(int oldIndex, int newIndex) async {
if (oldIndex == newIndex) return; if (oldIndex == newIndex) return;
final next = [..._homeFilterWidgets]; final visible = [
final item = next.removeAt(oldIndex); for (final w in _homeFilterWidgets)
final target = newIndex.clamp(0, next.length); if (!w.deleted) w,
next.insert(target, item);
_homeFilterWidgets = [
for (var i = 0; i < next.length; i++) next[i].copyWith(sortOrder: i),
]; ];
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(); notifyListeners();
await _persistHomeWidgets(); await _persistHomeWidgets();
} }
+6 -4
View File
@@ -372,7 +372,7 @@ class SyncController extends ChangeNotifier {
); );
} }
final mergedFavourites = mergeOverviewFavourites( final mergedFavourites = mergeOverviewFavourites(
_prefs.overviewFavourites, _prefs.overviewFavouritesForSync,
remoteFavourites, remoteFavourites,
); );
List<HomeFilterWidget> remoteHomeWidgets = const []; List<HomeFilterWidget> remoteHomeWidgets = const [];
@@ -386,7 +386,7 @@ class SyncController extends ChangeNotifier {
); );
} }
final mergedHomeWidgets = mergeHomeFilterWidgets( final mergedHomeWidgets = mergeHomeFilterWidgets(
_prefs.homeFilterWidgets, _prefs.homeFilterWidgetsForSync,
remoteHomeWidgets, remoteHomeWidgets,
); );
AppCloudPrefs? remoteAppPrefs; AppCloudPrefs? remoteAppPrefs;
@@ -576,7 +576,7 @@ class SyncController extends ChangeNotifier {
_setStatus('Pushing favourites…'); _setStatus('Pushing favourites…');
try { try {
await service.pushOverviewFavourites( await service.pushOverviewFavourites(
_prefs.overviewFavourites, _prefs.overviewFavouritesForSync,
onStatus: _setStatus, onStatus: _setStatus,
); );
parts.add('Favourites updated'); parts.add('Favourites updated');
@@ -584,6 +584,7 @@ class SyncController extends ChangeNotifier {
debugPrint( debugPrint(
'SyncController: overview_favourites push skipped ($error)', 'SyncController: overview_favourites push skipped ($error)',
); );
_pushFavouritesPending = true;
} }
bump(); bump();
} }
@@ -591,7 +592,7 @@ class SyncController extends ChangeNotifier {
_setStatus('Pushing home widgets…'); _setStatus('Pushing home widgets…');
try { try {
await service.pushHomeFilterWidgets( await service.pushHomeFilterWidgets(
_prefs.homeFilterWidgets, _prefs.homeFilterWidgetsForSync,
onStatus: _setStatus, onStatus: _setStatus,
); );
parts.add('Home widgets updated'); parts.add('Home widgets updated');
@@ -599,6 +600,7 @@ class SyncController extends ChangeNotifier {
debugPrint( debugPrint(
'SyncController: home_filter_widgets push skipped ($error)', 'SyncController: home_filter_widgets push skipped ($error)',
); );
_pushHomeWidgetsPending = true;
} }
bump(); bump();
} }
+273 -56
View File
@@ -474,7 +474,29 @@ 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,
),
onChartTap: () => _openChartFilter(
context,
labelIds: {
for (final id in favourite.labelIds) id,
},
flow: favourite.metric == OverviewMetricMode.labels
? favourite.flow
: null,
periodMode: favourite.period,
),
); );
} }
@@ -590,7 +612,17 @@ 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),
),
onChartTap: () => _openChartFilter(
context,
flow: _metric == OverviewMetricMode.labels ? _labelFlow : null,
),
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
if (_metric == OverviewMetricMode.flow) ...[ if (_metric == OverviewMetricMode.flow) ...[
@@ -739,27 +771,61 @@ class _OverviewScreenState extends State<OverviewScreen> {
BuildContext context, BuildContext context,
DateTime period, { DateTime period, {
String? labelId, 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 initial = yearly ? DateTime(period.year, 12) : period;
final labelIds = labelId != null final resolvedLabelIds = labelId != null
? {labelId} ? {labelId}
: { : (labelIds ??
// Open with the same labels currently shown on the chart. {
for (final id in _includedLabelIds) id, // Open with the same labels currently shown on the chart.
}; for (final id in _includedLabelIds) id,
});
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute<void>( MaterialPageRoute<void>(
builder: (_) => TransactionsScreen( builder: (_) => TransactionsScreen(
initialMonth: initial, initialMonth: initial,
initialLabelIds: labelIds, initialLabelIds: resolvedLabelIds,
initialLabelMatch: LabelMatchMode.or, initialLabelMatch: LabelMatchMode.or,
initialSearchInMonthOnly: !yearly && labelIds.isNotEmpty, initialFlow: flow,
initialSearchInMonthOnly: !yearly && resolvedLabelIds.isNotEmpty,
initialYear: yearly ? period.year : null, initialYear: yearly ? period.year : null,
), ),
), ),
); );
} }
/// Open Transactions with this chart's filters (no specific bar/column).
void _openChartFilter(
BuildContext context, {
Set<String>? labelIds,
TxFlowFilter? flow,
OverviewPeriodMode? periodMode,
}) {
final mode = periodMode ?? _period;
final yearly = mode == OverviewPeriodMode.yearly;
final resolvedLabelIds = labelIds ??
{
for (final id in _includedLabelIds) id,
};
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => TransactionsScreen(
// Monthly charts show a year — open that whole year with the chart filter.
initialMonth: yearly ? null : DateTime(_cursor.year, 12),
initialYear: yearly ? null : _cursor.year,
initialLabelIds: resolvedLabelIds,
initialLabelMatch: LabelMatchMode.or,
initialFlow: flow,
initialSearchInMonthOnly: false,
),
),
);
}
} }
class _MonthBucket { class _MonthBucket {
@@ -1159,6 +1225,7 @@ class _ChartCard extends StatelessWidget {
required this.availableLabelCount, required this.availableLabelCount,
required this.onLabelTap, required this.onLabelTap,
required this.onMonthTap, required this.onMonthTap,
required this.onChartTap,
this.onPeriodModeChanged, this.onPeriodModeChanged,
this.onEdit, this.onEdit,
}); });
@@ -1182,7 +1249,12 @@ class _ChartCard extends StatelessWidget {
final int? includedLabelCount; final int? includedLabelCount;
final int availableLabelCount; final int availableLabelCount;
final void Function(TxLabel label) onLabelTap; final void Function(TxLabel label) onLabelTap;
final void Function(DateTime month) onMonthTap; final void Function(
DateTime month, {
String? labelId,
TxFlowFilter? flow,
}) onMonthTap;
final VoidCallback onChartTap;
final ValueChanged<OverviewPeriodMode>? onPeriodModeChanged; final ValueChanged<OverviewPeriodMode>? onPeriodModeChanged;
final VoidCallback? onEdit; final VoidCallback? onEdit;
@@ -1277,7 +1349,7 @@ class _ChartCard extends StatelessWidget {
runSpacing: 2, runSpacing: 2,
alignment: WrapAlignment.end, alignment: WrapAlignment.end,
children: [ children: [
for (final row in labelSpend.take(compact ? 3 : 5)) for (final row in labelSpend.take(6))
GestureDetector( GestureDetector(
onTap: () => onLabelTap(row.label), onTap: () => onLabelTap(row.label),
child: _LegendDot( child: _LegendDot(
@@ -1286,7 +1358,7 @@ class _ChartCard extends StatelessWidget {
compact: compact, compact: compact,
), ),
), ),
if (labelSpend.length > (compact ? 3 : 5)) if (labelSpend.length > 6)
_LegendDot( _LegendDot(
color: colors.outline, color: colors.outline,
label: 'Other', label: 'Other',
@@ -1523,23 +1595,29 @@ class _ChartCard extends StatelessWidget {
], ],
SizedBox( SizedBox(
height: chartHeight, height: chartHeight,
child: metric == OverviewMetricMode.flow child: _TappableChartArea(
? _FlowBars( onChartTap: onChartTap,
months: months, child: metric == OverviewMetricMode.flow
monthly: monthly, ? _FlowBars(
currency: currency, months: months,
period: period, monthly: monthly,
) currency: currency,
: _LabelMonthBars( period: period,
months: months, onMonthTap: onMonthTap,
labelSpend: labelSpend, onChartTap: onChartTap,
amountByMonth: labelAmountByMonth, )
currency: currency, : _LabelMonthBars(
period: period, months: months,
barStyle: labelBarStyle, labelSpend: labelSpend,
invertChart: invertChart, amountByMonth: labelAmountByMonth,
onMonthTap: onMonthTap, currency: currency,
), period: period,
barStyle: labelBarStyle,
invertChart: invertChart,
onMonthTap: onMonthTap,
onChartTap: onChartTap,
),
),
), ),
], ],
), ),
@@ -1548,18 +1626,89 @@ class _ChartCard extends StatelessWidget {
} }
} }
/// Lets empty-chart taps open the chart filter without double-firing when a bar
/// was just selected (fl_chart + parent [GestureDetector] both see the tap).
class _TappableChartArea extends StatefulWidget {
const _TappableChartArea({
required this.onChartTap,
required this.child,
});
final VoidCallback onChartTap;
final Widget child;
@override
State<_TappableChartArea> createState() => _TappableChartAreaState();
}
class _TappableChartAreaState extends State<_TappableChartArea> {
var _handling = false;
void _runExclusive(VoidCallback action) {
if (_handling) return;
_handling = true;
action();
Future<void>.delayed(const Duration(milliseconds: 400), () {
if (mounted) _handling = false;
});
}
void openChartFilter() => _runExclusive(widget.onChartTap);
void openBar(VoidCallback action) => _runExclusive(action);
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: openChartFilter,
child: _ChartTapScope(
openChartFilter: openChartFilter,
openBar: openBar,
child: widget.child,
),
);
}
}
class _ChartTapScope extends InheritedWidget {
const _ChartTapScope({
required this.openChartFilter,
required this.openBar,
required super.child,
});
final VoidCallback openChartFilter;
final void Function(VoidCallback action) openBar;
static _ChartTapScope? maybeOf(BuildContext context) {
return context.getInheritedWidgetOfExactType<_ChartTapScope>();
}
@override
bool updateShouldNotify(_ChartTapScope oldWidget) => false;
}
class _FlowBars extends StatelessWidget { class _FlowBars extends StatelessWidget {
const _FlowBars({ const _FlowBars({
required this.months, required this.months,
required this.monthly, required this.monthly,
required this.currency, required this.currency,
required this.period, required this.period,
required this.onMonthTap,
required this.onChartTap,
}); });
final List<DateTime> months; final List<DateTime> months;
final Map<DateTime, _MonthBucket> monthly; final Map<DateTime, _MonthBucket> monthly;
final String currency; final String currency;
final OverviewPeriodMode period; final OverviewPeriodMode period;
final void Function(
DateTime month, {
String? labelId,
TxFlowFilter? flow,
}) onMonthTap;
final VoidCallback onChartTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -1578,13 +1727,17 @@ class _FlowBars extends StatelessWidget {
}); });
if (empty) { if (empty) {
return Center( return GestureDetector(
child: Text( behavior: HitTestBehavior.opaque,
period == OverviewPeriodMode.yearly onTap: onChartTap,
? 'No income or expenses in these years' child: Center(
: 'No income or expenses in this year', child: Text(
style: theme.textTheme.bodyMedium, period == OverviewPeriodMode.yearly
textAlign: TextAlign.center, ? 'No income or expenses in these years'
: 'No income or expenses in this year',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
), ),
); );
} }
@@ -1594,6 +1747,29 @@ class _FlowBars extends StatelessWidget {
maxY: safeMax, maxY: safeMax,
groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10, groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10,
barTouchData: BarTouchData( barTouchData: BarTouchData(
touchCallback: (event, response) {
if (event is! FlTapUpEvent) return;
final spot = response?.spot;
final index = spot?.touchedBarGroupIndex;
if (spot == null ||
index == null ||
index < 0 ||
index >= months.length) {
// Parent GestureDetector also handles empty-area taps.
return;
}
final rodIndex = spot.touchedRodDataIndex;
// Rods: 0 = expense, 1 = income.
final flow = rodIndex == 1
? TxFlowFilter.income
: TxFlowFilter.expense;
final scope = _ChartTapScope.maybeOf(context);
if (scope != null) {
scope.openBar(() => onMonthTap(months[index], flow: flow));
} else {
onMonthTap(months[index], flow: flow);
}
},
touchTooltipData: BarTouchTooltipData( touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) { getTooltipItem: (group, groupIndex, rod, rodIndex) {
final month = months[group.x.toInt()]; final month = months[group.x.toInt()];
@@ -1707,9 +1883,10 @@ class _LabelMonthBars extends StatelessWidget {
required this.barStyle, required this.barStyle,
required this.invertChart, required this.invertChart,
required this.onMonthTap, required this.onMonthTap,
required this.onChartTap,
}); });
static const _maxSeriesLabels = 5; static const _maxSeriesLabels = 6;
final List<DateTime> months; final List<DateTime> months;
final List<_LabelSpend> labelSpend; final List<_LabelSpend> labelSpend;
@@ -1718,7 +1895,12 @@ class _LabelMonthBars extends StatelessWidget {
final OverviewPeriodMode period; final OverviewPeriodMode period;
final LabelChartBarStyle barStyle; final LabelChartBarStyle barStyle;
final bool invertChart; final bool invertChart;
final void Function(DateTime month) onMonthTap; final void Function(
DateTime month, {
String? labelId,
TxFlowFilter? flow,
}) onMonthTap;
final VoidCallback onChartTap;
double _plot(double value) => invertChart ? -value : value; double _plot(double value) => invertChart ? -value : value;
@@ -1727,11 +1909,15 @@ class _LabelMonthBars extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final colors = theme.colorScheme; final colors = theme.colorScheme;
if (labelSpend.isEmpty) { if (labelSpend.isEmpty) {
return Center( return GestureDetector(
child: Text( behavior: HitTestBehavior.opaque,
'No labeled activity in this period.\nTag transactions to see them here.', onTap: onChartTap,
style: theme.textTheme.bodyMedium, child: Center(
textAlign: TextAlign.center, child: Text(
'No labeled activity in this period.\nTag transactions to see them here.',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
), ),
); );
} }
@@ -1789,11 +1975,15 @@ class _LabelMonthBars extends StatelessWidget {
} }
} }
if (maxY == 0 && minY == 0) { if (maxY == 0 && minY == 0) {
return Center( return GestureDetector(
child: Text( behavior: HitTestBehavior.opaque,
'No labeled activity in this period.\nTag transactions to see them here.', onTap: onChartTap,
style: theme.textTheme.bodyMedium, child: Center(
textAlign: TextAlign.center, child: Text(
'No labeled activity in this period.\nTag transactions to see them here.',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
), ),
); );
} }
@@ -1805,12 +1995,12 @@ class _LabelMonthBars extends StatelessWidget {
final rodWidth = stacked final rodWidth = stacked
? (period == OverviewPeriodMode.yearly ? 18.0 : 14.0) ? (period == OverviewPeriodMode.yearly ? 18.0 : 14.0)
: period == OverviewPeriodMode.yearly : period == OverviewPeriodMode.yearly
? (seriesCount <= 3 ? 12.0 : 8.0) ? (seriesCount <= 3 ? 12.0 : seriesCount <= 6 ? 8.0 : 6.0)
: (seriesCount <= 3 : (seriesCount <= 3
? 7.0 ? 7.0
: seriesCount <= 5 : seriesCount <= 6
? 5.0 ? 4.5
: 4.0); : 3.5);
List<BarChartRodData> rodsFor(DateTime month) { List<BarChartRodData> rodsFor(DateTime month) {
final bucket = final bucket =
@@ -1896,9 +2086,36 @@ class _LabelMonthBars extends StatelessWidget {
barTouchData: BarTouchData( barTouchData: BarTouchData(
touchCallback: (event, response) { touchCallback: (event, response) {
if (event is! FlTapUpEvent) return; if (event is! FlTapUpEvent) return;
final index = response?.spot?.touchedBarGroupIndex; final spot = response?.spot;
if (index == null || index < 0 || index >= months.length) return; final index = spot?.touchedBarGroupIndex;
onMonthTap(months[index]); if (spot == null ||
index == null ||
index < 0 ||
index >= months.length) {
// Parent GestureDetector also handles empty-area taps.
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.
}
final scope = _ChartTapScope.maybeOf(context);
if (scope != null) {
scope.openBar(
() => onMonthTap(months[index], labelId: labelId),
);
} else {
onMonthTap(months[index], labelId: labelId);
}
}, },
touchTooltipData: BarTouchTooltipData( touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) { getTooltipItem: (group, groupIndex, rod, rodIndex) {
+28 -16
View File
@@ -25,20 +25,6 @@ List<BankTransaction> mergeBankTransactionLists(
return mergeTransactions(local, remote); 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. /// Union-merge so neither device loses annotations on first sync.
TxMetaStoreData mergeTxMetaStores(TxMetaStoreData a, TxMetaStoreData b) { TxMetaStoreData mergeTxMetaStores(TxMetaStoreData a, TxMetaStoreData b) {
final byId = <String, TxMeta>{...a.byId}; final byId = <String, TxMeta>{...a.byId};
@@ -485,6 +471,8 @@ class PbSyncService {
), ),
invertExpensePerspective: invertExpensePerspective:
r.getBoolValue('invert_expense_perspective'), 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), 'showAmountIn': r.getBoolValue('show_amount_in', true),
'showAmountOut': r.getBoolValue('show_amount_out', true), 'showAmountOut': r.getBoolValue('show_amount_out', true),
'showCount': r.getBoolValue('show_count', true), 'showCount': r.getBoolValue('show_count', true),
'updatedAt': _recordUpdatedAtMs(r),
'deleted': r.getBoolValue('deleted'),
}), }),
); );
} }
@@ -1364,6 +1354,8 @@ class PbSyncService {
'flow': fav.flow.name, 'flow': fav.flow.name,
'bar_style': fav.barStyle.name, 'bar_style': fav.barStyle.name,
'invert_expense_perspective': fav.invertExpensePerspective, 'invert_expense_perspective': fav.invertExpensePerspective,
'updated_at': fav.updatedAt,
'deleted': fav.deleted,
}; };
final remote = existing[clientId]; final remote = existing[clientId];
if (remote == null) { if (remote == null) {
@@ -1428,6 +1420,8 @@ class PbSyncService {
'show_amount_in': w.showAmountIn, 'show_amount_in': w.showAmountIn,
'show_amount_out': w.showAmountOut, 'show_amount_out': w.showAmountOut,
'show_count': w.showCount, 'show_count': w.showCount,
'updated_at': w.updatedAt,
'deleted': w.deleted,
}; };
final remote = existing[clientId]; final remote = existing[clientId];
if (remote == null) { if (remote == null) {
@@ -1533,7 +1527,9 @@ class PbSyncService {
remote.getStringValue('flow') == fav.flow.name && remote.getStringValue('flow') == fav.flow.name &&
remote.getStringValue('bar_style') == fav.barStyle.name && remote.getStringValue('bar_style') == fav.barStyle.name &&
remote.getBoolValue('invert_expense_perspective') == remote.getBoolValue('invert_expense_perspective') ==
fav.invertExpensePerspective; fav.invertExpensePerspective &&
remote.getBoolValue('deleted') == fav.deleted &&
_recordUpdatedAtMs(remote) == fav.updatedAt;
} }
bool _homeFilterWidgetUnchanged(RecordModel remote, HomeFilterWidget w) { bool _homeFilterWidgetUnchanged(RecordModel remote, HomeFilterWidget w) {
@@ -1572,7 +1568,9 @@ class PbSyncService {
remote.getBoolValue('month_only') == w.monthOnly && remote.getBoolValue('month_only') == w.monthOnly &&
remote.getBoolValue('show_amount_in', true) == w.showAmountIn && remote.getBoolValue('show_amount_in', true) == w.showAmountIn &&
remote.getBoolValue('show_amount_out', true) == w.showAmountOut && 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) { bool _appPrefsUnchanged(RecordModel remote, AppCloudPrefs prefs) {
@@ -1585,6 +1583,20 @@ class PbSyncService {
remote.getIntValue('look_ahead_max_years', 5) == remote.getIntValue('look_ahead_max_years', 5) ==
prefs.lookAheadMaxYears.clamp(1, 30); 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) { RecurringTemplateSource _parseTemplateSource(String raw) {
+73 -44
View File
@@ -288,9 +288,80 @@ class _StatsBody extends StatelessWidget {
final spentOnly = widgetConfig.showAmountOut && final spentOnly = widgetConfig.showAmountOut &&
!widgetConfig.showAmountIn && !widgetConfig.showAmountIn &&
!widgetConfig.showCount; !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 // Avoid LayoutBuilder/Spacer/Expanded here: half-width rows use
// IntrinsicHeight, which cannot measure those widgets. // 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( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -299,50 +370,8 @@ class _StatsBody extends StatelessWidget {
title: widgetConfig.name, title: widgetConfig.name,
subtitle: _subtitleFor(widgetConfig), subtitle: _subtitleFor(widgetConfig),
), ),
if (spentOnly) ...[ SizedBox(height: spentOnly ? 32 : 14),
const SizedBox(height: 32), metricsBlock(alignEnd: false),
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,
),
],
),
],
], ],
); );
} }
@@ -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:flutter_test/flutter_test.dart';
import 'package:myapp/models/overview_favourite.dart'; import 'package:myapp/models/overview_favourite.dart';
import 'package:myapp/models/transaction_meta.dart'; import 'package:myapp/models/transaction_meta.dart';
import 'package:myapp/services/pb_sync_service.dart';
import 'package:myapp/widgets/label_filter_dialog.dart'; import 'package:myapp/widgets/label_filter_dialog.dart';
void main() { void main() {
@@ -16,6 +15,8 @@ void main() {
flow: TxFlowFilter.expense, flow: TxFlowFilter.expense,
barStyle: LabelChartBarStyle.stacked, barStyle: LabelChartBarStyle.stacked,
invertExpensePerspective: false, invertExpensePerspective: false,
updatedAt: 100,
deleted: false,
); );
final restored = OverviewFavourite.fromJson(fav.toJson()); final restored = OverviewFavourite.fromJson(fav.toJson());
@@ -27,35 +28,53 @@ void main() {
expect(restored.labelIds, ['groceries', 'custom_ah']); expect(restored.labelIds, ['groceries', 'custom_ah']);
expect(restored.flow, TxFlowFilter.expense); expect(restored.flow, TxFlowFilter.expense);
expect(restored.barStyle, LabelChartBarStyle.stacked); 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( const local = OverviewFavourite(
id: 'fav_1', id: 'fav_1',
name: 'Local name', name: 'Local name',
display: true, display: true,
updatedAt: 200,
); );
const remoteOnly = OverviewFavourite( const remoteOnly = OverviewFavourite(
id: 'fav_2', id: 'fav_2',
name: 'Remote only', name: 'Remote only',
display: false, display: false,
metric: OverviewMetricMode.labels, metric: OverviewMetricMode.labels,
updatedAt: 50,
); );
const remoteConflict = OverviewFavourite( const remoteConflictOlder = OverviewFavourite(
id: 'fav_1', id: 'fav_1',
name: 'Remote name', name: 'Remote name',
display: false, 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( final merged = mergeOverviewFavourites(
[local], [local, localOlder],
[remoteConflict, remoteOnly], [remoteConflictOlder, remoteOnly, remoteConflictNewer],
); );
expect(merged.length, 2); expect(merged.length, 3);
final byId = {for (final f in merged) f.id: f}; final byId = {for (final f in merged) f.id: f};
expect(byId['fav_1']!.name, 'Local name'); expect(byId['fav_1']!.name, 'Local name');
expect(byId['fav_1']!.display, isTrue); expect(byId['fav_1']!.display, isTrue);
expect(byId['fav_2']!.name, 'Remote only'); expect(byId['fav_2']!.name, 'Remote only');
expect(byId['fav_3']!.name, 'Remote newer');
}); });
} }