diff --git a/lib/providers/sync_provider.dart b/lib/providers/sync_provider.dart index 1934149..dbcb51c 100644 --- a/lib/providers/sync_provider.dart +++ b/lib/providers/sync_provider.dart @@ -206,10 +206,20 @@ class SyncController extends ChangeNotifier { /// Bank pull-to-refresh / reconnect finished — reconcile with PocketBase. /// - /// Always runs a full sync when logged in so (1) new API txs are pushed and + /// Runs a full sync when idle so (1) new API txs are pushed and /// (2) any cloud-only history the bank window lacks is pulled back. + /// + /// If a sync is already in flight (common at cold start: constructor sync + /// overlaps bank refresh), do **not** queue another full sync — that was + /// causing a back-to-back double pull of 4k+ txs. void _onBankDataLoaded() { if (!_auth.isLoggedIn) return; + if (_syncing) { + debugPrint( + 'SyncController: bank load finished during sync — skip queued full sync', + ); + return; + } unawaited(syncNow()); } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 9b20408..f9bf033 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -566,13 +566,19 @@ List _packedHomeWidgetRows(List widgets) { rows.add( Padding( padding: const EdgeInsets.only(bottom: 10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: HomeFilterWidgetCard(widgetConfig: a)), - const SizedBox(width: 10), - Expanded(child: HomeFilterWidgetCard(widgetConfig: b)), - ], + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: HomeFilterWidgetCard(widgetConfig: a), + ), + const SizedBox(width: 10), + Expanded( + child: HomeFilterWidgetCard(widgetConfig: b), + ), + ], + ), ), ), ); diff --git a/lib/widgets/home_filter_widget_card.dart b/lib/widgets/home_filter_widget_card.dart index 47d8a8d..9e1b536 100644 --- a/lib/widgets/home_filter_widget_card.dart +++ b/lib/widgets/home_filter_widget_card.dart @@ -65,28 +65,28 @@ class HomeFilterWidgetCard extends StatelessWidget { child: Card( color: colors.surfaceContainer, elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), child: InkWell( - borderRadius: BorderRadius.circular(16), onTap: editing ? onEdit : () => _openTransactions(context), child: Padding( padding: EdgeInsets.fromLTRB( 14, - 12, + 14, editing ? 4 : 14, - 12, + 14, ), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ if (dragHandle != null) ...[ - dragHandle!, + Padding( + padding: const EdgeInsets.only(top: 8), + child: dragHandle!, + ), const SizedBox(width: 4), ], - Icon( - widgetConfig.icon, - color: colors.primary, - ), - const SizedBox(width: 10), Expanded( child: widgetConfig.type == HomeWidgetType.budget ? _BudgetBody( @@ -127,6 +127,139 @@ class HomeFilterWidgetCard extends StatelessWidget { } } +String _monthName(DateTime date) { + const months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + return months[date.month - 1]; +} + +String? _subtitleFor(HomeFilterWidget widget) { + if (widget.type == HomeWidgetType.budget) return 'MONTHLY LIMIT'; + if (widget.monthOnly) return _monthName(DateTime.now()); + return null; +} + +class _IconBadge extends StatelessWidget { + const _IconBadge({required this.icon}); + + final IconData icon; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Container( + width: 40, + height: 40, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colors.outlineVariant.withValues(alpha: 0.55), + ), + color: colors.surfaceContainerHigh.withValues(alpha: 0.35), + ), + child: Icon(icon, color: colors.primary, size: 20), + ); + } +} + +class _CardHeader extends StatelessWidget { + const _CardHeader({ + required this.icon, + required this.title, + this.subtitle, + this.trailing, + }); + + final IconData icon; + final String title; + final String? subtitle; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _IconBadge(icon: icon), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + letterSpacing: + subtitle == 'MONTHLY LIMIT' ? 0.6 : 0, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + if (trailing != null) ...[ + const SizedBox(width: 8), + trailing!, + ], + ], + ); + } +} + +class _UsedPill extends StatelessWidget { + const _UsedPill({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + class _StatsBody extends StatelessWidget { const _StatsBody({ required this.widgetConfig, @@ -150,42 +283,65 @@ class _StatsBody extends StatelessWidget { filter: widgetConfig.filter, ); + // Prefer a single hero "Spent" figure when that's the main metric. + final spentOnly = widgetConfig.showAmountOut && + !widgetConfig.showAmountIn && + !widgetConfig.showCount; + + // Avoid LayoutBuilder/Spacer/Expanded here: half-width rows use + // IntrinsicHeight, which cannot measure those widgets. return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - widgetConfig.name, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, + _CardHeader( + icon: widgetConfig.icon, + title: widgetConfig.name, + subtitle: _subtitleFor(widgetConfig), + ), + if (spentOnly) ...[ + const SizedBox(height: 32), + Text( + 'Spent', + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onSurfaceVariant, + ), ), - 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, - ), - ], - ), + 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, + ), + ], + ), + ], ], ); } @@ -216,68 +372,147 @@ class _BudgetBody extends StatelessWidget { final progress = budget.progress.clamp(0.0, 1.0); final over = budget.overBudget; final fillColor = over ? colors.error : colors.primary; - final trackColor = colors.surfaceContainerHighest; + // Grayish unfilled track (not green-tinted). + final trackColor = Color.alphaBlend( + colors.onSurface.withValues(alpha: 0.14), + colors.surfaceContainerHigh, + ); + final trackLabelColor = colors.onSurfaceVariant; + final spentLabelColor = colors.onSurface; + final pct = budget.target <= 0 ? 0 : (progress * 100).round(); + final half = widgetConfig.halfWidth; + final isBar = widgetConfig.chartStyle == HomeWidgetChartStyle.bar; + final subtitle = widgetConfig.monthOnly + ? _monthName(DateTime.now()) + : 'MONTHLY LIMIT'; + + final capLine = _BudgetLegendLine( + bulletColor: trackLabelColor.withValues(alpha: 0.75), + textColor: trackLabelColor, + fontWeight: FontWeight.w500, + text: budget.target <= 0 + ? '— cap' + : formatMoney(budget.target, currency), + ); + final spentLine = _BudgetLegendLine( + bulletColor: fillColor, + textColor: spentLabelColor, + fontWeight: FontWeight.w700, + text: formatMoney(budget.used, currency), + ); + + final isRingOrPie = widgetConfig.chartStyle == HomeWidgetChartStyle.pie || + widgetConfig.chartStyle == HomeWidgetChartStyle.ring; + + Widget ringChart({required double size}) => SizedBox( + width: size, + height: size, + child: CustomPaint( + painter: _BudgetChartPainter( + progress: budget.target <= 0 ? 0 : progress, + style: widgetConfig.chartStyle, + fillColor: fillColor, + trackColor: trackColor, + percentLabel: budget.target > 0 ? '$pct%' : null, + labelColor: colors.onSurface, + labelScale: 0.32, + ), + ), + ); + + // Full-width ring/pie: ring beside header+amounts so the card stays short. + if (isRingOrPie && !half) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _CardHeader( + icon: widgetConfig.icon, + title: widgetConfig.name, + subtitle: subtitle, + ), + const SizedBox(height: 10), + capLine, + const SizedBox(height: 6), + spentLine, + ], + ), + ), + const SizedBox(width: 8), + ringChart(size: 68), + ], + ); + } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - widgetConfig.name, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + _CardHeader( + icon: widgetConfig.icon, + title: widgetConfig.name, + subtitle: subtitle, + // Percent lives in the ring/pie center; pill only for full-width bars. + trailing: budget.target > 0 && !half && isBar + ? _UsedPill(label: '$pct% used') + : null, ), - 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), + const SizedBox(height: 14), 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.bar => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _BudgetBar( + progress: budget.target <= 0 ? 0 : progress, + fillColor: fillColor, + trackColor: trackColor, + // Solid rounded bar (not a slider thumb). + height: 14, + percentLabel: half && budget.target > 0 ? '$pct%' : null, + ), + const SizedBox(height: 10), + if (half) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + capLine, + const SizedBox(height: 4), + spentLine, + ], + ) + else + Row( + children: [ + Expanded(child: capLine), + const SizedBox(width: 8), + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: spentLine, + ), + ), + ], + ), + ], ), HomeWidgetChartStyle.pie || HomeWidgetChartStyle.ring => Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - SizedBox( - width: 52, - height: 52, - child: CustomPaint( - painter: _BudgetChartPainter( - progress: budget.target <= 0 ? 0 : progress, - style: widgetConfig.chartStyle, - fillColor: fillColor, - trackColor: trackColor, - ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + capLine, + const SizedBox(height: 8), + spentLine, + ], ), ), 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, - ), - ), - ), + ringChart(size: 68), ], ), }, @@ -286,58 +521,194 @@ class _BudgetBody extends StatelessWidget { } } +/// Rounded progress bar (no slider thumb). +class _BudgetBar extends StatelessWidget { + const _BudgetBar({ + required this.progress, + required this.fillColor, + required this.trackColor, + required this.height, + this.percentLabel, + }); + + final double progress; + final Color fillColor; + final Color trackColor; + final double height; + final String? percentLabel; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return SizedBox( + height: height, + child: ClipRRect( + borderRadius: BorderRadius.circular(999), + child: Stack( + fit: StackFit.expand, + children: [ + ColoredBox(color: trackColor), + FractionallySizedBox( + widthFactor: progress.clamp(0.0, 1.0), + alignment: Alignment.centerLeft, + child: ColoredBox(color: fillColor), + ), + if (percentLabel != null) + Center( + child: Text( + percentLabel!, + style: theme.textTheme.labelSmall?.copyWith( + color: progress >= 0.45 + ? colors.onPrimary + : colors.onSurface, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _BudgetLegendLine extends StatelessWidget { + const _BudgetLegendLine({ + required this.bulletColor, + required this.textColor, + required this.text, + this.fontWeight = FontWeight.w600, + }); + + final Color bulletColor; + final Color textColor; + final String text; + final FontWeight fontWeight; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: bulletColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelMedium?.copyWith( + color: textColor, + fontWeight: fontWeight, + ), + ), + ), + ], + ); + } +} + class _BudgetChartPainter extends CustomPainter { _BudgetChartPainter({ required this.progress, required this.style, required this.fillColor, required this.trackColor, + this.percentLabel, + this.labelColor, + this.labelScale = 0.42, }); final double progress; final HomeWidgetChartStyle style; final Color fillColor; final Color trackColor; + final String? percentLabel; + final Color? labelColor; + final double labelScale; @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; + final sweep = 2 * math.pi * progress.clamp(0.0, 1.0); if (style == HomeWidgetChartStyle.pie) { - final rect = Rect.fromCircle(center: center, radius: radius); - final track = Paint()..color = trackColor; - canvas.drawCircle(center, radius, track); + // Donut-style pizza: thick ring cutout so % stays readable. + final stroke = radius * 0.42; + final r = radius - stroke / 2; + final trackPaint = Paint() + ..color = trackColor + ..style = PaintingStyle.stroke + ..strokeWidth = stroke; + canvas.drawCircle(center, r, trackPaint); 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); + canvas.drawArc( + Rect.fromCircle(center: center, radius: r), + -math.pi / 2, + sweep, + false, + Paint() + ..color = fillColor + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..strokeCap = StrokeCap.butt, + ); + } + } else { + // Thick ring with rounded caps (reference mock). + final stroke = radius * 0.22; + final r = radius - stroke / 2; + canvas.drawCircle( + center, + r, + Paint() + ..color = trackColor + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..strokeCap = StrokeCap.round, + ); + if (progress > 0) { + canvas.drawArc( + Rect.fromCircle(center: center, radius: r), + -math.pi / 2, + sweep, + false, + Paint() + ..color = fillColor + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..strokeCap = StrokeCap.round, + ); } - 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, + final label = percentLabel; + if (label != null && labelColor != null) { + final tp = TextPainter( + text: TextSpan( + text: label, + style: TextStyle( + color: labelColor, + fontSize: radius * labelScale, + fontWeight: FontWeight.w800, + height: 1, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint( + canvas, + center - Offset(tp.width / 2, tp.height / 2), ); } } @@ -347,7 +718,10 @@ class _BudgetChartPainter extends CustomPainter { return oldDelegate.progress != progress || oldDelegate.style != style || oldDelegate.fillColor != fillColor || - oldDelegate.trackColor != trackColor; + oldDelegate.trackColor != trackColor || + oldDelegate.percentLabel != percentLabel || + oldDelegate.labelColor != labelColor || + oldDelegate.labelScale != labelScale; } } diff --git a/lib/widgets/home_widgets_sheet.dart b/lib/widgets/home_widgets_sheet.dart index df6b99c..b2dbe1a 100644 --- a/lib/widgets/home_widgets_sheet.dart +++ b/lib/widgets/home_widgets_sheet.dart @@ -10,7 +10,6 @@ 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( @@ -50,22 +49,11 @@ Future composeHomeFilterWidget( 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) + ? HomeFilterWidget.fromFilter(name: 'Filter', filter: filter) : existing.copyWith( - name: name, labelIds: filter.labelIds.toList(growable: false), flow: filter.flow, amountRange: filter.amountRange, @@ -73,6 +61,9 @@ Future composeHomeFilterWidget( monthOnly: filter.monthOnly, ); + // Name is edited inside Widget options (no separate rename dialog). + await WidgetsBinding.instance.endOfFrame; + if (!navigator.mounted) return null; return _promptWidgetMetrics(navigator.context, draft); } @@ -80,265 +71,296 @@ 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, - ), - ), - ], - ], + builder: (context) => _WidgetOptionsDialog(draft: draft), + ); +} + +class _WidgetOptionsDialog extends StatefulWidget { + const _WidgetOptionsDialog({required this.draft}); + + final HomeFilterWidget draft; + + @override + State<_WidgetOptionsDialog> createState() => _WidgetOptionsDialogState(); +} + +class _WidgetOptionsDialogState extends State<_WidgetOptionsDialog> { + late HomeWidgetType _type; + late String _iconId; + late HomeWidgetChartStyle _chartStyle; + late HomeBudgetCapMode _budgetCapMode; + late int _budgetAverageMonths; + late bool _showIn; + late bool _showOut; + late bool _showCount; + late bool _halfWidth; + late final TextEditingController _nameController; + late final TextEditingController _amountController; + + @override + void initState() { + super.initState(); + final draft = widget.draft; + _type = draft.type; + _iconId = draft.iconId; + _chartStyle = draft.chartStyle; + _budgetCapMode = draft.budgetCapMode; + _budgetAverageMonths = draft.budgetAverageMonths; + _showIn = draft.showAmountIn; + _showOut = draft.showAmountOut; + _showCount = draft.showCount; + _halfWidth = draft.halfWidth; + _nameController = TextEditingController(text: draft.name); + final fixed = draft.budgetFixedAmount; + _amountController = TextEditingController( + text: fixed > 0 + ? (fixed == fixed.roundToDouble() + ? fixed.toStringAsFixed(0) + : fixed.toStringAsFixed(2)) + : '', + ); + } + + @override + void dispose() { + _nameController.dispose(); + _amountController.dispose(); + super.dispose(); + } + + void _save() { + final name = _nameController.text.trim(); + if (name.isEmpty) return; + if (_type == HomeWidgetType.stats && !_showIn && !_showOut && !_showCount) { + return; + } + var fixed = widget.draft.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( + widget.draft.copyWith( + name: name, + type: _type, + iconId: _iconId, + chartStyle: _chartStyle, + budgetCapMode: _budgetCapMode, + budgetFixedAmount: fixed, + budgetAverageMonths: _budgetAverageMonths, + showAmountIn: _showIn, + showAmountOut: _showOut, + showCount: _showCount, + halfWidth: _halfWidth, + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AlertDialog( + title: const Text('Widget options'), + content: SizedBox( + width: double.maxFinite, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _nameController, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'Name', + border: OutlineInputBorder(), + isDense: true, ), ), - ), - 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, - ), - ); + const SizedBox(height: 16), + Text('Type', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + SegmentedButton( + showSelectedIcon: false, + 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); }, - child: const Text('Save'), ), + 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('Spent'), + 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( + showSelectedIcon: false, + 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 + DropdownButtonFormField( + key: ValueKey(_budgetAverageMonths), + initialValue: _budgetAverageMonths, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Average of last N months', + border: OutlineInputBorder(), + isDense: true, + ), + items: [ + for (final n in const [1, 2, 3, 6, 12, 24]) + DropdownMenuItem( + value: n, + child: Text('$n months'), + ), + ], + onChanged: (value) { + if (value == null) return; + setState(() => _budgetAverageMonths = value); + }, + ), + const SizedBox(height: 16), + Text('Chart', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + for (final entry in const [ + (HomeWidgetChartStyle.bar, Icons.linear_scale, 'Bar'), + ( + HomeWidgetChartStyle.pie, + Icons.pie_chart_outline, + 'Pie' + ), + (HomeWidgetChartStyle.ring, Icons.donut_large, 'Ring'), + ]) + ChoiceChip( + avatar: Icon(entry.$2, size: 18), + label: Text(entry.$3), + selected: _chartStyle == entry.$1, + onSelected: (_) => + setState(() => _chartStyle = entry.$1), + ), + ], + ), + 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, + ), + ), + ], ], - ); - }, - ); - }, - ).whenComplete(amountController.dispose); + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _save, + child: const Text('Save'), + ), + ], + ); + } } class _HomeWidgetsSheet extends StatelessWidget { @@ -517,7 +539,7 @@ class _HomeWidgetsSheet extends StatelessWidget { 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.showAmountOut) 'Spent', if (w.type == HomeWidgetType.stats && w.showCount) 'Count', if (w.halfWidth) 'Half', ];