widgets looking ok
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -566,13 +566,19 @@ List<Widget> _packedHomeWidgetRows(List<HomeFilterWidget> 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+287
-265
@@ -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<void> showHomeWidgetsSheet(BuildContext context) {
|
||||
return showModalBottomSheet<void>(
|
||||
@@ -50,22 +49,11 @@ Future<HomeFilterWidget?> 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<HomeFilterWidget?> 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<HomeFilterWidget?> _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<HomeFilterWidget>(
|
||||
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<HomeWidgetType>(
|
||||
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<HomeBudgetCapMode>(
|
||||
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<int>(
|
||||
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<HomeWidgetChartStyle>(
|
||||
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<HomeWidgetType>(
|
||||
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<HomeBudgetCapMode>(
|
||||
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<int>(
|
||||
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',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user