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
+174
View File
@@ -0,0 +1,174 @@
/// Named transaction-filter widgets pinned on the Home screen.
library;
import '../widgets/label_filter_dialog.dart';
import 'transaction_meta.dart';
/// A saved [TransactionFilterSelection] plus which metrics to show on Home.
class HomeFilterWidget {
const HomeFilterWidget({
required this.id,
required this.name,
this.display = true,
this.sortOrder = 0,
this.labelIds = const [],
this.flow = TxFlowFilter.all,
this.amountRange = const TxAmountRange(),
this.query = '',
this.monthOnly = false,
this.showAmountIn = true,
this.showAmountOut = true,
this.showCount = true,
});
final String id;
final String name;
final bool display;
final int sortOrder;
final List<String> labelIds;
final TxFlowFilter flow;
final TxAmountRange amountRange;
final String query;
final bool monthOnly;
final bool showAmountIn;
final bool showAmountOut;
final bool showCount;
TransactionFilterSelection get filter => TransactionFilterSelection(
labelIds: labelIds.toSet(),
flow: flow,
amountRange: amountRange,
query: query,
monthOnly: monthOnly,
);
HomeFilterWidget copyWith({
String? id,
String? name,
bool? display,
int? sortOrder,
List<String>? labelIds,
TxFlowFilter? flow,
TxAmountRange? amountRange,
String? query,
bool? monthOnly,
bool? showAmountIn,
bool? showAmountOut,
bool? showCount,
}) {
return HomeFilterWidget(
id: id ?? this.id,
name: name ?? this.name,
display: display ?? this.display,
sortOrder: sortOrder ?? this.sortOrder,
labelIds: labelIds ?? this.labelIds,
flow: flow ?? this.flow,
amountRange: amountRange ?? this.amountRange,
query: query ?? this.query,
monthOnly: monthOnly ?? this.monthOnly,
showAmountIn: showAmountIn ?? this.showAmountIn,
showAmountOut: showAmountOut ?? this.showAmountOut,
showCount: showCount ?? this.showCount,
);
}
static HomeFilterWidget fromFilter({
required String name,
required TransactionFilterSelection filter,
bool display = true,
int sortOrder = 0,
}) {
final showIn = filter.flow != TxFlowFilter.expense;
final showOut = filter.flow != TxFlowFilter.income;
return HomeFilterWidget(
id: 'hw_${DateTime.now().millisecondsSinceEpoch}',
name: name,
display: display,
sortOrder: sortOrder,
labelIds: filter.labelIds.toList(growable: false),
flow: filter.flow,
amountRange: filter.amountRange,
query: filter.query,
monthOnly: filter.monthOnly,
showAmountIn: showIn,
showAmountOut: showOut,
showCount: true,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'display': display,
'sortOrder': sortOrder,
'labelIds': labelIds,
'flow': flow.name,
'amountRange': {
if (amountRange.exact != null) 'exact': amountRange.exact,
if (amountRange.min != null) 'min': amountRange.min,
if (amountRange.max != null) 'max': amountRange.max,
},
'query': query,
'monthOnly': monthOnly,
'showAmountIn': showAmountIn,
'showAmountOut': showAmountOut,
'showCount': showCount,
};
factory HomeFilterWidget.fromJson(Map<String, dynamic> json) {
final rangeRaw = json['amountRange'];
final rangeMap = rangeRaw is Map
? Map<String, dynamic>.from(rangeRaw)
: const <String, dynamic>{};
double? asDouble(dynamic value) {
if (value is num) return value.toDouble();
return null;
}
return HomeFilterWidget(
id: json['id'] as String? ??
'hw_${DateTime.now().millisecondsSinceEpoch}',
name: (json['name'] as String?)?.trim().isNotEmpty == true
? (json['name'] as String).trim()
: 'Filter',
display: json['display'] as bool? ?? true,
sortOrder: json['sortOrder'] as int? ?? 0,
labelIds: [
for (final id in (json['labelIds'] as List? ?? const []))
if (id is String && id.isNotEmpty) id,
],
flow: TxFlowFilter.values.firstWhere(
(e) => e.name == json['flow'],
orElse: () => TxFlowFilter.all,
),
amountRange: TxAmountRange(
exact: asDouble(rangeMap['exact']),
min: asDouble(rangeMap['min']),
max: asDouble(rangeMap['max']),
),
query: json['query'] as String? ?? '',
monthOnly: json['monthOnly'] as bool? ?? false,
showAmountIn: json['showAmountIn'] as bool? ?? true,
showAmountOut: json['showAmountOut'] as bool? ?? true,
showCount: json['showCount'] as bool? ?? true,
);
}
}
/// Union home widgets by id (local wins on conflict).
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) {
byId[w.id] = w;
}
final merged = byId.values.toList()
..sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
return merged;
}
+109
View File
@@ -0,0 +1,109 @@
/// Named Overview chart filter snapshots (local favourites).
library;
import '../models/transaction_meta.dart';
import '../widgets/label_filter_dialog.dart';
/// Persisted Overview lens: period, metric, and Labels-chart options.
class OverviewFavourite {
const OverviewFavourite({
required this.id,
required this.name,
this.display = true,
this.metric = OverviewMetricMode.flow,
this.period = OverviewPeriodMode.monthly,
this.labelIds = const [],
this.flow = TxFlowFilter.expense,
this.barStyle = LabelChartBarStyle.parallel,
this.invertExpensePerspective = false,
});
final String id;
final String name;
/// When true, this favourite appears on the Favourites overview view.
final bool display;
final OverviewMetricMode metric;
final OverviewPeriodMode period;
/// Empty = all labels (Labels metric only).
final List<String> labelIds;
final TxFlowFilter flow;
final LabelChartBarStyle barStyle;
final bool invertExpensePerspective;
OverviewFavourite copyWith({
String? id,
String? name,
bool? display,
OverviewMetricMode? metric,
OverviewPeriodMode? period,
List<String>? labelIds,
TxFlowFilter? flow,
LabelChartBarStyle? barStyle,
bool? invertExpensePerspective,
}) {
return OverviewFavourite(
id: id ?? this.id,
name: name ?? this.name,
display: display ?? this.display,
metric: metric ?? this.metric,
period: period ?? this.period,
labelIds: labelIds ?? this.labelIds,
flow: flow ?? this.flow,
barStyle: barStyle ?? this.barStyle,
invertExpensePerspective:
invertExpensePerspective ?? this.invertExpensePerspective,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'display': display,
'metric': metric.name,
'period': period.name,
'labelIds': labelIds,
'flow': flow.name,
'barStyle': barStyle.name,
'invertExpensePerspective': invertExpensePerspective,
};
factory OverviewFavourite.fromJson(Map<String, dynamic> json) {
return OverviewFavourite(
id: json['id'] as String? ?? 'fav_${DateTime.now().millisecondsSinceEpoch}',
name: (json['name'] as String?)?.trim().isNotEmpty == true
? (json['name'] as String).trim()
: 'Favourite',
display: json['display'] as bool? ?? true,
metric: OverviewMetricMode.values.firstWhere(
(e) => e.name == json['metric'],
orElse: () => OverviewMetricMode.flow,
),
period: OverviewPeriodMode.values.firstWhere(
(e) => e.name == json['period'],
orElse: () => OverviewPeriodMode.monthly,
),
labelIds: [
for (final id in (json['labelIds'] as List? ?? const []))
if (id is String && id.isNotEmpty) id,
],
flow: TxFlowFilter.values.firstWhere(
(e) => e.name == json['flow'],
orElse: () => TxFlowFilter.expense,
),
barStyle: LabelChartBarStyle.values.firstWhere(
(e) => e.name == json['barStyle'],
orElse: () => LabelChartBarStyle.parallel,
),
invertExpensePerspective:
json['invertExpensePerspective'] as bool? ?? false,
);
}
}
enum OverviewMetricMode { flow, labels }
enum OverviewPeriodMode { monthly, yearly }