/// Manage Home filter widgets: display, metrics, add/remove. library; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/home_filter_widget.dart'; import '../models/transaction_meta.dart'; import '../providers/bank_provider.dart'; import '../providers/preferences_provider.dart'; import '../providers/tx_meta_provider.dart'; import '../widgets/label_filter_dialog.dart'; import '../widgets/overview_favourites_sheet.dart' show promptFavouriteName; Future showHomeWidgetsSheet(BuildContext context) { return showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, builder: (context) { return Padding( padding: EdgeInsets.only( bottom: MediaQuery.viewInsetsOf(context).bottom, ), child: const _HomeWidgetsSheet(), ); }, ); } Future composeHomeFilterWidget( BuildContext context, { TransactionFilterSelection? initialFilter, HomeFilterWidget? existing, bool skipFilterDialog = false, }) async { // Capture before any modal opens — the caller's element may be rebuilt. final bank = context.read(); final meta = context.read(); final navigator = Navigator.of(context); TransactionFilterSelection? filter = initialFilter ?? existing?.filter; if (!skipFilterDialog || filter == null) { filter = await showTransactionFilterDialog( navigator.context, available: meta.allLabels, transactions: bank.transactions, initial: filter ?? const TransactionFilterSelection(labelIds: {}), ); if (filter == null || !navigator.mounted) return null; } if (!navigator.mounted) return null; final name = await promptFavouriteName( navigator.context, initial: existing?.name ?? '', title: existing == null ? 'Name this widget' : 'Rename widget', ); if (name == null || !navigator.mounted) return null; // Let the name dialog finish popping before presenting options. await WidgetsBinding.instance.endOfFrame; if (!navigator.mounted) return null; final draft = existing == null ? HomeFilterWidget.fromFilter(name: name, filter: filter) : existing.copyWith( name: name, labelIds: filter.labelIds.toList(growable: false), flow: filter.flow, amountRange: filter.amountRange, query: filter.query, monthOnly: filter.monthOnly, ); return _promptWidgetMetrics(navigator.context, draft); } 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, useRootNavigator: true, builder: (context) { return StatefulBuilder( builder: (context, setState) { final theme = Theme.of(context); return AlertDialog( title: const Text('Widget options'), 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, ), ), ], ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: () { 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, halfWidth: halfWidth, ), ); }, child: const Text('Save'), ), ], ); }, ); }, ).whenComplete(amountController.dispose); } class _HomeWidgetsSheet extends StatelessWidget { const _HomeWidgetsSheet(); @override Widget build(BuildContext context) { final prefs = context.watch(); final theme = Theme.of(context); final widgets = prefs.homeFilterWidgets; return SafeArea( child: ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.sizeOf(context).height * 0.72, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 4, 20, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Home layout', style: theme.textTheme.titleLarge), const SizedBox(height: 4), Text( 'Choose which built-in sections and filter widgets appear ' 'on Home. Check Display to show an item.', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(8, 0, 8, 0), child: Column( children: [ CheckboxListTile( value: prefs.homeBuiltInSections.showTransactions, onChanged: (value) { if (value == null) return; prefs.setHomeShowTransactions(value); }, title: const Text('Transactions'), subtitle: const Text('Total transaction count'), controlAffinity: ListTileControlAffinity.leading, ), CheckboxListTile( value: prefs.homeBuiltInSections.showCategorized, onChanged: (value) { if (value == null) return; prefs.setHomeShowCategorized(value); }, title: const Text('Categorized'), subtitle: const Text('Labeled transaction count'), controlAffinity: ListTileControlAffinity.leading, ), CheckboxListTile( value: prefs.homeBuiltInSections.showRecent, onChanged: (value) { if (value == null) return; prefs.setHomeShowRecent(value); }, title: const Text('Recent transactions'), subtitle: const Text('Latest activity list'), controlAffinity: ListTileControlAffinity.leading, ), ], ), ), const Divider(height: 1), Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: OutlinedButton.icon( onPressed: () async { final preferences = context.read(); final created = await composeHomeFilterWidget(context); if (created == null) return; await preferences.addHomeFilterWidget(created); }, icon: const Icon(Icons.add), label: const Text('Add filter widget'), ), ), const Divider(height: 1), Expanded( child: widgets.isEmpty ? Center( child: Padding( padding: const EdgeInsets.all(24), child: Text( 'No filter widgets yet. Add one here, or save a ' 'filter from Transactions.', textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ) : ListView.separated( padding: const EdgeInsets.fromLTRB(8, 8, 8, 24), itemCount: widgets.length, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { final widgetItem = widgets[index]; return CheckboxListTile( value: widgetItem.display, onChanged: (value) { if (value == null) return; prefs.setHomeFilterWidgetDisplay( widgetItem.id, value, ); }, secondary: PopupMenuButton( tooltip: 'Widget actions', onSelected: (value) async { if (value == 'edit') { final preferences = context.read(); final updated = await composeHomeFilterWidget( context, existing: widgetItem, ); if (updated == null) return; await preferences .updateHomeFilterWidget(updated); } else if (value == 'delete') { await prefs .removeHomeFilterWidget(widgetItem.id); } }, itemBuilder: (context) => const [ PopupMenuItem( value: 'edit', child: Text('Edit'), ), PopupMenuItem( value: 'delete', child: Text('Delete'), ), ], ), title: Text(widgetItem.name), subtitle: Text( '${_subtitle(widgetItem)}\n' 'Display on Home', ), isThreeLine: true, controlAffinity: ListTileControlAffinity.leading, ); }, ), ), ], ), ), ); } 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.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.join(' · '); } }