From 86b5f1e09b0ef69884a9592fe85d94ace70d88f3 Mon Sep 17 00:00:00 2001 From: davmar Date: Thu, 10 Sep 2026 17:31:18 +0200 Subject: [PATCH] new widget types --- lib/models/home_filter_widget.dart | 131 +++++++- lib/services/pb_sync_service.dart | 28 ++ lib/utils/tx_filter_metrics.dart | 74 ++++- lib/widgets/home_filter_widget_card.dart | 294 +++++++++++++++--- lib/widgets/home_widgets_sheet.dart | 268 +++++++++++++--- .../1740000004_ingtrak_home_filter_widgets.js | 35 +++ .../1740000006_ingtrak_home_widgets_budget.js | 56 ++++ test/home_filter_widget_test.dart | 20 +- 8 files changed, 817 insertions(+), 89 deletions(-) create mode 100644 pocketbase/pb_migrations/1740000006_ingtrak_home_widgets_budget.js diff --git a/lib/models/home_filter_widget.dart b/lib/models/home_filter_widget.dart index a357ceb..d709f94 100644 --- a/lib/models/home_filter_widget.dart +++ b/lib/models/home_filter_widget.dart @@ -1,10 +1,77 @@ /// Named transaction-filter widgets pinned on the Home screen. library; +import 'package:flutter/material.dart'; + import '../widgets/label_filter_dialog.dart'; import 'transaction_meta.dart'; -/// A saved [TransactionFilterSelection] plus which metrics to show on Home. +/// What a Home filter widget shows. +enum HomeWidgetType { + /// In / Out / count metrics. + stats, + + /// Progress of this month’s net toward a budget cap. + budget, +} + +/// How the budget ceiling is chosen. +enum HomeBudgetCapMode { + /// User-entered amount (positive). + fixed, + + /// Absolute average of monthly nets over the last N months. + averageMonths, +} + +/// Budget progress visualization. +enum HomeWidgetChartStyle { + bar, + pie, + ring, +} + +/// Preset icons users can assign to a Home widget. +abstract final class HomeWidgetIcons { + static const List<(String id, IconData icon, String label)> all = [ + ('dashboard', Icons.dashboard_outlined, 'Dashboard'), + ('savings', Icons.savings_outlined, 'Savings'), + ('wallet', Icons.account_balance_wallet_outlined, 'Wallet'), + ('cart', Icons.shopping_cart_outlined, 'Shopping'), + ('bag', Icons.shopping_bag_outlined, 'Bag'), + ('food', Icons.restaurant_outlined, 'Food'), + ('bolt', Icons.bolt_outlined, 'Utilities'), + ('home', Icons.home_work_outlined, 'Home'), + ('car', Icons.directions_car_outlined, 'Transport'), + ('bus', Icons.directions_bus_outlined, 'Transit'), + ('health', Icons.local_hospital_outlined, 'Health'), + ('sport', Icons.fitness_center_outlined, 'Sport'), + ('travel', Icons.flight_outlined, 'Travel'), + ('gift', Icons.card_giftcard_outlined, 'Gifts'), + ('pets', Icons.pets_outlined, 'Pets'), + ('work', Icons.work_outline, 'Work'), + ('pay', Icons.payments_outlined, 'Pay'), + ('swap', Icons.swap_horiz, 'Transfers'), + ('chart', Icons.pie_chart_outline, 'Chart'), + ('flag', Icons.flag_outlined, 'Goal'), + ]; + + static IconData iconFor(String? id) { + for (final entry in all) { + if (entry.$1 == id) return entry.$2; + } + return Icons.dashboard_outlined; + } + + static String labelFor(String? id) { + for (final entry in all) { + if (entry.$1 == id) return entry.$3; + } + return 'Dashboard'; + } +} + +/// A saved [TransactionFilterSelection] plus Home display options. class HomeFilterWidget { const HomeFilterWidget({ required this.id, @@ -12,6 +79,12 @@ class HomeFilterWidget { this.display = true, this.sortOrder = 0, this.halfWidth = false, + this.type = HomeWidgetType.stats, + this.iconId = 'dashboard', + this.chartStyle = HomeWidgetChartStyle.bar, + this.budgetCapMode = HomeBudgetCapMode.fixed, + this.budgetFixedAmount = 0, + this.budgetAverageMonths = 3, this.labelIds = const [], this.flow = TxFlowFilter.all, this.amountRange = const TxAmountRange(), @@ -30,6 +103,17 @@ class HomeFilterWidget { /// When true, the Home card uses half the row width. final bool halfWidth; + final HomeWidgetType type; + final String iconId; + final HomeWidgetChartStyle chartStyle; + final HomeBudgetCapMode budgetCapMode; + + /// Positive ceiling when [budgetCapMode] is [HomeBudgetCapMode.fixed]. + final double budgetFixedAmount; + + /// Months of history when [budgetCapMode] is [HomeBudgetCapMode.averageMonths]. + final int budgetAverageMonths; + final List labelIds; final TxFlowFilter flow; final TxAmountRange amountRange; @@ -40,6 +124,8 @@ class HomeFilterWidget { final bool showAmountOut; final bool showCount; + IconData get icon => HomeWidgetIcons.iconFor(iconId); + TransactionFilterSelection get filter => TransactionFilterSelection( labelIds: labelIds.toSet(), flow: flow, @@ -54,6 +140,12 @@ class HomeFilterWidget { bool? display, int? sortOrder, bool? halfWidth, + HomeWidgetType? type, + String? iconId, + HomeWidgetChartStyle? chartStyle, + HomeBudgetCapMode? budgetCapMode, + double? budgetFixedAmount, + int? budgetAverageMonths, List? labelIds, TxFlowFilter? flow, TxAmountRange? amountRange, @@ -69,6 +161,12 @@ class HomeFilterWidget { display: display ?? this.display, sortOrder: sortOrder ?? this.sortOrder, halfWidth: halfWidth ?? this.halfWidth, + type: type ?? this.type, + iconId: iconId ?? this.iconId, + chartStyle: chartStyle ?? this.chartStyle, + budgetCapMode: budgetCapMode ?? this.budgetCapMode, + budgetFixedAmount: budgetFixedAmount ?? this.budgetFixedAmount, + budgetAverageMonths: budgetAverageMonths ?? this.budgetAverageMonths, labelIds: labelIds ?? this.labelIds, flow: flow ?? this.flow, amountRange: amountRange ?? this.amountRange, @@ -110,6 +208,12 @@ class HomeFilterWidget { 'display': display, 'sortOrder': sortOrder, 'halfWidth': halfWidth, + 'type': type.name, + 'iconId': iconId, + 'chartStyle': chartStyle.name, + 'budgetCapMode': budgetCapMode.name, + 'budgetFixedAmount': budgetFixedAmount, + 'budgetAverageMonths': budgetAverageMonths, 'labelIds': labelIds, 'flow': flow.name, 'amountRange': { @@ -134,6 +238,12 @@ class HomeFilterWidget { return null; } + int asInt(dynamic value, int fallback) { + if (value is int) return value; + if (value is num) return value.round(); + return fallback; + } + return HomeFilterWidget( id: json['id'] as String? ?? 'hw_${DateTime.now().millisecondsSinceEpoch}', @@ -141,8 +251,25 @@ class HomeFilterWidget { ? (json['name'] as String).trim() : 'Filter', display: json['display'] as bool? ?? true, - sortOrder: json['sortOrder'] as int? ?? 0, + sortOrder: asInt(json['sortOrder'], 0), halfWidth: json['halfWidth'] as bool? ?? false, + type: HomeWidgetType.values.firstWhere( + (e) => e.name == json['type'], + orElse: () => HomeWidgetType.stats, + ), + iconId: (json['iconId'] as String?)?.trim().isNotEmpty == true + ? (json['iconId'] as String).trim() + : 'dashboard', + chartStyle: HomeWidgetChartStyle.values.firstWhere( + (e) => e.name == json['chartStyle'], + orElse: () => HomeWidgetChartStyle.bar, + ), + budgetCapMode: HomeBudgetCapMode.values.firstWhere( + (e) => e.name == json['budgetCapMode'], + orElse: () => HomeBudgetCapMode.fixed, + ), + budgetFixedAmount: asDouble(json['budgetFixedAmount']) ?? 0, + budgetAverageMonths: asInt(json['budgetAverageMonths'], 3).clamp(1, 36), labelIds: [ for (final id in (json['labelIds'] as List? ?? const [])) if (id is String && id.isNotEmpty) id, diff --git a/lib/services/pb_sync_service.dart b/lib/services/pb_sync_service.dart index a148e1f..5c84edc 100644 --- a/lib/services/pb_sync_service.dart +++ b/lib/services/pb_sync_service.dart @@ -521,6 +521,16 @@ class PbSyncService { 'display': r.getBoolValue('display', true), 'sortOrder': r.getIntValue('sort_order', 0), 'halfWidth': r.getBoolValue('half_width'), + 'type': r.getStringValue('widget_type'), + 'iconId': r.getStringValue('icon_id'), + 'chartStyle': r.getStringValue('chart_style'), + 'budgetCapMode': r.getStringValue('budget_cap_mode'), + 'budgetFixedAmount': switch (r.get('budget_fixed_amount')) { + num n => n.toDouble(), + String s => double.tryParse(s) ?? 0, + _ => 0.0, + }, + 'budgetAverageMonths': r.getIntValue('budget_average_months', 3), 'labelIds': _jsonStringList(r.get('label_ids')), 'flow': r.getStringValue('flow'), 'amountRange': rangeRaw is Map @@ -1199,6 +1209,12 @@ class PbSyncService { 'display': w.display, 'sort_order': w.sortOrder, 'half_width': w.halfWidth, + 'widget_type': w.type.name, + 'icon_id': w.iconId, + 'chart_style': w.chartStyle.name, + 'budget_cap_mode': w.budgetCapMode.name, + 'budget_fixed_amount': w.budgetFixedAmount, + 'budget_average_months': w.budgetAverageMonths, 'label_ids': w.labelIds, 'flow': w.flow.name, 'amount_range': { @@ -1296,10 +1312,22 @@ class PbSyncService { ? Map.from(rangeRaw) : const {}; double? asDouble(dynamic value) => value is num ? value.toDouble() : null; + final remoteFixed = switch (remote.get('budget_fixed_amount')) { + num n => n.toDouble(), + String s => double.tryParse(s) ?? 0, + _ => 0.0, + }; return remote.getStringValue('name') == w.name && remote.getBoolValue('display', true) == w.display && remote.getIntValue('sort_order', 0) == w.sortOrder && remote.getBoolValue('half_width') == w.halfWidth && + remote.getStringValue('widget_type') == w.type.name && + remote.getStringValue('icon_id') == w.iconId && + remote.getStringValue('chart_style') == w.chartStyle.name && + remote.getStringValue('budget_cap_mode') == w.budgetCapMode.name && + (remoteFixed * 100).round() == (w.budgetFixedAmount * 100).round() && + remote.getIntValue('budget_average_months', 3) == + w.budgetAverageMonths && _sameIdList(remoteIds, w.labelIds) && remote.getStringValue('flow') == w.flow.name && asDouble(rangeMap['exact']) == w.amountRange.exact && diff --git a/lib/utils/tx_filter_metrics.dart b/lib/utils/tx_filter_metrics.dart index 86f2f75..674cdf0 100644 --- a/lib/utils/tx_filter_metrics.dart +++ b/lib/utils/tx_filter_metrics.dart @@ -2,6 +2,7 @@ library; import '../models/bank.dart'; +import '../models/home_filter_widget.dart'; import '../models/transaction_meta.dart'; import '../providers/tx_meta_provider.dart'; import '../utils/tx_calendar.dart'; @@ -23,15 +24,40 @@ class TxFilterMetrics { double get net => amountIn - amountOut; } +class HomeBudgetMetrics { + const HomeBudgetMetrics({ + required this.monthNet, + required this.target, + required this.averageMonths, + }); + + /// Net (in − out) for the current calendar month. + final double monthNet; + + /// Positive budget ceiling (fixed amount or avg |net| of last N months). + final double target; + + final int averageMonths; + + /// How much of the ceiling is used, based on |monthNet|. + double get used => monthNet.abs(); + + double get progress => target <= 0 ? 0 : used / target; + + bool get overBudget => target > 0 && used > target; +} + /// Filter [all] the same way Transactions does (pool → search → flow/amount). List applyTransactionFilter({ required List all, required TxMetaController meta, required TransactionFilterSelection filter, DateTime? month, + bool forceMonthOnly = false, }) { final nowMonth = monthStart(month ?? DateTime.now()); - final pool = filter.monthOnly + final limitToMonth = forceMonthOnly || filter.monthOnly; + final pool = limitToMonth ? [ for (final tx in all) if (_txInMonth(tx, nowMonth)) tx, @@ -55,12 +81,14 @@ TxFilterMetrics computeTxFilterMetrics({ required TxMetaController meta, required TransactionFilterSelection filter, DateTime? month, + bool forceMonthOnly = false, }) { final filtered = applyTransactionFilter( all: all, meta: meta, filter: filter, month: month, + forceMonthOnly: forceMonthOnly, ); var amountIn = 0.0; var amountOut = 0.0; @@ -80,6 +108,50 @@ TxFilterMetrics computeTxFilterMetrics({ ); } +/// Budget progress: this month’s |net| vs fixed or average-of-last-N-months cap. +HomeBudgetMetrics computeHomeBudgetMetrics({ + required List all, + required TxMetaController meta, + required HomeFilterWidget widget, + DateTime? now, +}) { + final today = now ?? DateTime.now(); + final currentMonth = monthStart(today); + final monthFilter = widget.filter; + final monthNet = computeTxFilterMetrics( + all: all, + meta: meta, + filter: monthFilter, + month: currentMonth, + forceMonthOnly: true, + ).net; + + final months = widget.budgetAverageMonths.clamp(1, 36); + final double target; + if (widget.budgetCapMode == HomeBudgetCapMode.fixed) { + target = widget.budgetFixedAmount.abs(); + } else { + var sum = 0.0; + for (var i = 1; i <= months; i++) { + final month = addMonths(currentMonth, -i); + sum += computeTxFilterMetrics( + all: all, + meta: meta, + filter: monthFilter, + month: month, + forceMonthOnly: true, + ).net.abs(); + } + target = sum / months; + } + + return HomeBudgetMetrics( + monthNet: monthNet, + target: target, + averageMonths: months, + ); +} + bool _txInMonth(BankTransaction tx, DateTime month) { final date = DateTime.tryParse(tx.date); if (date == null) return false; diff --git a/lib/widgets/home_filter_widget_card.dart b/lib/widgets/home_filter_widget_card.dart index 4fe6df2..47d8a8d 100644 --- a/lib/widgets/home_filter_widget_card.dart +++ b/lib/widgets/home_filter_widget_card.dart @@ -1,9 +1,12 @@ /// Home card for a saved transaction-filter widget. library; +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../models/bank.dart'; import '../models/home_filter_widget.dart'; import '../providers/bank_provider.dart'; import '../providers/tx_meta_provider.dart'; @@ -34,7 +37,8 @@ class HomeFilterWidgetCard extends StatelessWidget { MaterialPageRoute( builder: (_) => TransactionsScreen( initialLabelIds: filter.labelIds, - initialSearchInMonthOnly: filter.monthOnly, + initialSearchInMonthOnly: + widgetConfig.type == HomeWidgetType.budget || filter.monthOnly, initialQuery: filter.query, initialFlow: filter.flow, initialAmountRange: filter.amountRange, @@ -54,12 +58,6 @@ class HomeFilterWidgetCard extends StatelessWidget { ? bank.transactions.first.currency : 'EUR'); - final metrics = computeTxFilterMetrics( - all: bank.transactions, - meta: meta, - filter: widgetConfig.filter, - ); - final hidden = editing && !widgetConfig.display; return Opacity( @@ -70,9 +68,7 @@ class HomeFilterWidgetCard extends StatelessWidget { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: InkWell( borderRadius: BorderRadius.circular(16), - onTap: editing - ? onEdit - : () => _openTransactions(context), + onTap: editing ? onEdit : () => _openTransactions(context), child: Padding( padding: EdgeInsets.fromLTRB( 14, @@ -86,45 +82,25 @@ class HomeFilterWidgetCard extends StatelessWidget { dragHandle!, const SizedBox(width: 4), ], + Icon( + widgetConfig.icon, + color: colors.primary, + ), + const SizedBox(width: 10), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widgetConfig.name, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, + child: widgetConfig.type == HomeWidgetType.budget + ? _BudgetBody( + widgetConfig: widgetConfig, + currency: currency, + transactions: bank.transactions, + meta: meta, + ) + : _StatsBody( + widgetConfig: widgetConfig, + currency: currency, + transactions: bank.transactions, + meta: meta, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 8), - Wrap( - spacing: 12, - runSpacing: 6, - children: [ - if (widgetConfig.showAmountIn) - _Metric( - label: 'In', - value: formatMoney(metrics.amountIn, currency), - color: AppTheme.accent, - ), - if (widgetConfig.showAmountOut) - _Metric( - label: 'Out', - value: formatMoney(metrics.amountOut, currency), - color: AppTheme.expense, - ), - if (widgetConfig.showCount) - _Metric( - label: 'Txs', - value: '${metrics.count}', - color: colors.primary, - ), - ], - ), - ], - ), ), if (editing && onToggleDisplay != null) IconButton( @@ -151,6 +127,230 @@ class HomeFilterWidgetCard extends StatelessWidget { } } +class _StatsBody extends StatelessWidget { + const _StatsBody({ + required this.widgetConfig, + required this.currency, + required this.transactions, + required this.meta, + }); + + final HomeFilterWidget widgetConfig; + final String currency; + final List transactions; + final TxMetaController meta; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final metrics = computeTxFilterMetrics( + all: transactions, + meta: meta, + filter: widgetConfig.filter, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widgetConfig.name, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 6, + children: [ + if (widgetConfig.showAmountIn) + _Metric( + label: 'In', + value: formatMoney(metrics.amountIn, currency), + color: AppTheme.accent, + ), + if (widgetConfig.showAmountOut) + _Metric( + label: 'Out', + value: formatMoney(metrics.amountOut, currency), + color: AppTheme.expense, + ), + if (widgetConfig.showCount) + _Metric( + label: 'Txs', + value: '${metrics.count}', + color: colors.primary, + ), + ], + ), + ], + ); + } +} + +class _BudgetBody extends StatelessWidget { + const _BudgetBody({ + required this.widgetConfig, + required this.currency, + required this.transactions, + required this.meta, + }); + + final HomeFilterWidget widgetConfig; + final String currency; + final List transactions; + final TxMetaController meta; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final budget = computeHomeBudgetMetrics( + all: transactions, + meta: meta, + widget: widgetConfig, + ); + final progress = budget.progress.clamp(0.0, 1.0); + final over = budget.overBudget; + final fillColor = over ? colors.error : colors.primary; + final trackColor = colors.surfaceContainerHighest; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widgetConfig.name, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + '${formatMoney(budget.monthNet, currency)} · ' + '${formatMoney(budget.used, currency)} / ' + '${budget.target <= 0 ? '—' : formatMoney(budget.target, currency)}', + style: theme.textTheme.labelMedium?.copyWith( + color: over ? colors.error : colors.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + switch (widgetConfig.chartStyle) { + HomeWidgetChartStyle.bar => ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: budget.target <= 0 ? 0 : progress, + minHeight: 10, + backgroundColor: trackColor, + color: fillColor, + ), + ), + HomeWidgetChartStyle.pie || HomeWidgetChartStyle.ring => Row( + children: [ + SizedBox( + width: 52, + height: 52, + child: CustomPaint( + painter: _BudgetChartPainter( + progress: budget.target <= 0 ? 0 : progress, + style: widgetConfig.chartStyle, + fillColor: fillColor, + trackColor: trackColor, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + over + ? 'Over budget' + : budget.target <= 0 + ? 'Set a budget cap' + : '${(progress * 100).round()}% of cap', + style: theme.textTheme.labelMedium?.copyWith( + color: over ? colors.error : colors.onSurfaceVariant, + ), + ), + ), + ], + ), + }, + ], + ); + } +} + +class _BudgetChartPainter extends CustomPainter { + _BudgetChartPainter({ + required this.progress, + required this.style, + required this.fillColor, + required this.trackColor, + }); + + final double progress; + final HomeWidgetChartStyle style; + final Color fillColor; + final Color trackColor; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = math.min(size.width, size.height) / 2; + + if (style == HomeWidgetChartStyle.pie) { + final rect = Rect.fromCircle(center: center, radius: radius); + final track = Paint()..color = trackColor; + canvas.drawCircle(center, radius, track); + if (progress > 0) { + final sweep = 2 * math.pi * progress.clamp(0.0, 1.0); + final fill = Paint()..color = fillColor; + canvas.drawArc(rect, -math.pi / 2, sweep, true, fill); + } + return; + } + + // Ring (activity-style). + const stroke = 7.0; + final trackPaint = Paint() + ..color = trackColor + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..strokeCap = StrokeCap.round; + final fillPaint = Paint() + ..color = fillColor + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..strokeCap = StrokeCap.round; + final r = radius - stroke / 2; + canvas.drawCircle(center, r, trackPaint); + if (progress > 0) { + final sweep = 2 * math.pi * progress.clamp(0.0, 1.0); + canvas.drawArc( + Rect.fromCircle(center: center, radius: r), + -math.pi / 2, + sweep, + false, + fillPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _BudgetChartPainter oldDelegate) { + return oldDelegate.progress != progress || + oldDelegate.style != style || + oldDelegate.fillColor != fillColor || + oldDelegate.trackColor != trackColor; + } +} + class _Metric extends StatelessWidget { const _Metric({ required this.label, diff --git a/lib/widgets/home_widgets_sheet.dart b/lib/widgets/home_widgets_sheet.dart index 1b6e892..df6b99c 100644 --- a/lib/widgets/home_widgets_sheet.dart +++ b/lib/widgets/home_widgets_sheet.dart @@ -80,10 +80,23 @@ Future _promptWidgetMetrics( BuildContext context, HomeFilterWidget draft, ) { + var type = draft.type; + var iconId = draft.iconId; + var chartStyle = draft.chartStyle; + var budgetCapMode = draft.budgetCapMode; + var budgetFixedAmount = draft.budgetFixedAmount; + var budgetAverageMonths = draft.budgetAverageMonths; var showIn = draft.showAmountIn; var showOut = draft.showAmountOut; var showCount = draft.showCount; var halfWidth = draft.halfWidth; + final amountController = TextEditingController( + text: budgetFixedAmount > 0 + ? (budgetFixedAmount == budgetFixedAmount.roundToDouble() + ? budgetFixedAmount.toStringAsFixed(0) + : budgetFixedAmount.toStringAsFixed(2)) + : '', + ); return showDialog( context: context, @@ -91,40 +104,195 @@ Future _promptWidgetMetrics( builder: (context) { return StatefulBuilder( builder: (context, setState) { + final theme = Theme.of(context); return AlertDialog( title: const Text('Widget options'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CheckboxListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Amount in'), - value: showIn, - onChanged: (value) => - setState(() => showIn = value ?? false), + content: SizedBox( + width: 360, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Type', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment( + value: HomeWidgetType.stats, + label: Text('Stats'), + icon: Icon(Icons.analytics_outlined), + ), + ButtonSegment( + value: HomeWidgetType.budget, + label: Text('Budget'), + icon: Icon(Icons.savings_outlined), + ), + ], + selected: {type}, + onSelectionChanged: (value) { + setState(() => type = value.first); + }, + ), + const SizedBox(height: 16), + Text('Icon', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final entry in HomeWidgetIcons.all) + ChoiceChip( + label: Icon(entry.$2, size: 18), + selected: iconId == entry.$1, + onSelected: (_) => + setState(() => iconId = entry.$1), + tooltip: entry.$3, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(4), + ), + ], + ), + const SizedBox(height: 12), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Half width'), + subtitle: + const Text('Share the row with another widget'), + value: halfWidth, + onChanged: (value) => + setState(() => halfWidth = value), + ), + if (type == HomeWidgetType.stats) ...[ + const Divider(), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Amount in'), + value: showIn, + onChanged: (value) => + setState(() => showIn = value ?? false), + ), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Amount out'), + value: showOut, + onChanged: (value) => + setState(() => showOut = value ?? false), + ), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Number of transactions'), + value: showCount, + onChanged: (value) => + setState(() => showCount = value ?? false), + ), + ] else ...[ + const Divider(), + Text('Budget cap', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment( + value: HomeBudgetCapMode.fixed, + label: Text('Fixed'), + ), + ButtonSegment( + value: HomeBudgetCapMode.averageMonths, + label: Text('Average'), + ), + ], + selected: {budgetCapMode}, + onSelectionChanged: (value) { + setState(() => budgetCapMode = value.first); + }, + ), + const SizedBox(height: 12), + if (budgetCapMode == HomeBudgetCapMode.fixed) + TextField( + controller: amountController, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration( + labelText: 'Budget amount', + hintText: 'e.g. 500', + border: OutlineInputBorder(), + isDense: true, + ), + ) + else + Row( + children: [ + Expanded( + child: Text( + 'Average |net| of last', + style: theme.textTheme.bodyMedium, + ), + ), + SizedBox( + width: 88, + child: DropdownButtonFormField( + initialValue: budgetAverageMonths, + decoration: const InputDecoration( + border: OutlineInputBorder(), + isDense: true, + suffixText: 'mo', + ), + items: [ + for (final n in const [1, 2, 3, 6, 12, 24]) + DropdownMenuItem( + value: n, + child: Text('$n'), + ), + ], + onChanged: (value) { + if (value == null) return; + setState( + () => budgetAverageMonths = value, + ); + }, + ), + ), + ], + ), + const SizedBox(height: 16), + Text('Chart', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment( + value: HomeWidgetChartStyle.bar, + label: Text('Bar'), + icon: Icon(Icons.linear_scale), + ), + ButtonSegment( + value: HomeWidgetChartStyle.pie, + label: Text('Pie'), + icon: Icon(Icons.pie_chart_outline), + ), + ButtonSegment( + value: HomeWidgetChartStyle.ring, + label: Text('Ring'), + icon: Icon(Icons.donut_large), + ), + ], + selected: {chartStyle}, + onSelectionChanged: (value) { + setState(() => chartStyle = value.first); + }, + ), + const SizedBox(height: 8), + Text( + 'Progress uses this month’s net (|in − out|) for the ' + 'filter toward the cap.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], ), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Amount out'), - value: showOut, - onChanged: (value) => - setState(() => showOut = value ?? false), - ), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Number of transactions'), - value: showCount, - onChanged: (value) => - setState(() => showCount = value ?? false), - ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Half width'), - subtitle: const Text('Share the row with another widget'), - value: halfWidth, - onChanged: (value) => setState(() => halfWidth = value), - ), - ], + ), ), actions: [ TextButton( @@ -133,9 +301,29 @@ Future _promptWidgetMetrics( ), FilledButton( onPressed: () { - if (!showIn && !showOut && !showCount) return; + if (type == HomeWidgetType.stats && + !showIn && + !showOut && + !showCount) { + return; + } + var fixed = budgetFixedAmount; + if (type == HomeWidgetType.budget && + budgetCapMode == HomeBudgetCapMode.fixed) { + final parsed = double.tryParse( + amountController.text.trim().replaceAll(',', '.'), + ); + if (parsed == null || parsed <= 0) return; + fixed = parsed; + } Navigator.of(context).pop( draft.copyWith( + type: type, + iconId: iconId, + chartStyle: chartStyle, + budgetCapMode: budgetCapMode, + budgetFixedAmount: fixed, + budgetAverageMonths: budgetAverageMonths, showAmountIn: showIn, showAmountOut: showOut, showCount: showCount, @@ -150,7 +338,7 @@ Future _promptWidgetMetrics( }, ); }, - ); + ).whenComplete(amountController.dispose); } class _HomeWidgetsSheet extends StatelessWidget { @@ -318,15 +506,21 @@ class _HomeWidgetsSheet extends StatelessWidget { static String _subtitle(HomeFilterWidget w) { final parts = [ + w.type == HomeWidgetType.budget ? 'Budget' : 'Stats', + if (w.type == HomeWidgetType.budget) + w.budgetCapMode == HomeBudgetCapMode.fixed + ? 'Fixed' + : 'Avg ${w.budgetAverageMonths}mo', + if (w.type == HomeWidgetType.budget) w.chartStyle.name, if (w.monthOnly) 'This month', if (w.flow != TxFlowFilter.all) w.flow.name, if (w.labelIds.isNotEmpty) '${w.labelIds.length} labels', if (w.query.trim().isNotEmpty) '“${w.query.trim()}”', - if (w.showAmountIn) 'In', - if (w.showAmountOut) 'Out', - if (w.showCount) 'Count', + if (w.type == HomeWidgetType.stats && w.showAmountIn) 'In', + if (w.type == HomeWidgetType.stats && w.showAmountOut) 'Out', + if (w.type == HomeWidgetType.stats && w.showCount) 'Count', if (w.halfWidth) 'Half', ]; - return parts.isEmpty ? 'All transactions' : parts.join(' · '); + return parts.join(' · '); } } diff --git a/pocketbase/pb_migrations/1740000004_ingtrak_home_filter_widgets.js b/pocketbase/pb_migrations/1740000004_ingtrak_home_filter_widgets.js index 74ec792..6d1e50c 100644 --- a/pocketbase/pb_migrations/1740000004_ingtrak_home_filter_widgets.js +++ b/pocketbase/pb_migrations/1740000004_ingtrak_home_filter_widgets.js @@ -62,6 +62,41 @@ migrate((app) => { name: "half_width", required: false, }, + { + type: "text", + name: "widget_type", + required: false, + max: 40, + }, + { + type: "text", + name: "icon_id", + required: false, + max: 40, + }, + { + type: "text", + name: "chart_style", + required: false, + max: 40, + }, + { + type: "text", + name: "budget_cap_mode", + required: false, + max: 40, + }, + { + type: "number", + name: "budget_fixed_amount", + required: false, + }, + { + type: "number", + name: "budget_average_months", + required: false, + onlyInt: true, + }, { type: "json", name: "label_ids", diff --git a/pocketbase/pb_migrations/1740000006_ingtrak_home_widgets_budget.js b/pocketbase/pb_migrations/1740000006_ingtrak_home_widgets_budget.js new file mode 100644 index 0000000..d596a43 --- /dev/null +++ b/pocketbase/pb_migrations/1740000006_ingtrak_home_widgets_budget.js @@ -0,0 +1,56 @@ +/// PocketBase migration: budget / chart / icon fields on home_filter_widgets. +migrate((app) => { + const col = app.findCollectionByNameOrId("home_filter_widgets"); + col.fields.add({ + type: "text", + name: "widget_type", + required: false, + max: 40, + }); + col.fields.add({ + type: "text", + name: "icon_id", + required: false, + max: 40, + }); + col.fields.add({ + type: "text", + name: "chart_style", + required: false, + max: 40, + }); + col.fields.add({ + type: "text", + name: "budget_cap_mode", + required: false, + max: 40, + }); + col.fields.add({ + type: "number", + name: "budget_fixed_amount", + required: false, + }); + col.fields.add({ + type: "number", + name: "budget_average_months", + required: false, + onlyInt: true, + }); + app.save(col); +}, (app) => { + try { + const col = app.findCollectionByNameOrId("home_filter_widgets"); + for (const name of [ + "widget_type", + "icon_id", + "chart_style", + "budget_cap_mode", + "budget_fixed_amount", + "budget_average_months", + ]) { + const field = col.fields.getByName(name); + if (field) col.fields.remove(field); + } + app.save(col); + } catch (_) {} +}); diff --git a/test/home_filter_widget_test.dart b/test/home_filter_widget_test.dart index 5927a99..eba6d09 100644 --- a/test/home_filter_widget_test.dart +++ b/test/home_filter_widget_test.dart @@ -23,9 +23,25 @@ void main() { expect(restored.showAmountIn, isFalse); expect(restored.showCount, isTrue); expect(restored.halfWidth, isFalse); + expect(restored.type, HomeWidgetType.stats); - final half = widget.copyWith(halfWidth: true); - expect(HomeFilterWidget.fromJson(half.toJson()).halfWidth, isTrue); + final budget = widget.copyWith( + halfWidth: true, + type: HomeWidgetType.budget, + iconId: 'cart', + chartStyle: HomeWidgetChartStyle.ring, + budgetCapMode: HomeBudgetCapMode.averageMonths, + budgetAverageMonths: 6, + budgetFixedAmount: 250, + ); + final budgetRestored = HomeFilterWidget.fromJson(budget.toJson()); + expect(budgetRestored.halfWidth, isTrue); + expect(budgetRestored.type, HomeWidgetType.budget); + expect(budgetRestored.iconId, 'cart'); + expect(budgetRestored.chartStyle, HomeWidgetChartStyle.ring); + expect(budgetRestored.budgetCapMode, HomeBudgetCapMode.averageMonths); + expect(budgetRestored.budgetAverageMonths, 6); + expect(budgetRestored.budgetFixedAmount, 250); }); test('mergeHomeFilterWidgets unions by id with local wins', () {