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.
|
/// 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.
|
/// (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() {
|
void _onBankDataLoaded() {
|
||||||
if (!_auth.isLoggedIn) return;
|
if (!_auth.isLoggedIn) return;
|
||||||
|
if (_syncing) {
|
||||||
|
debugPrint(
|
||||||
|
'SyncController: bank load finished during sync — skip queued full sync',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
unawaited(syncNow());
|
unawaited(syncNow());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -566,13 +566,19 @@ List<Widget> _packedHomeWidgetRows(List<HomeFilterWidget> widgets) {
|
|||||||
rows.add(
|
rows.add(
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 10),
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
child: Row(
|
child: IntrinsicHeight(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Row(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
Expanded(child: HomeFilterWidgetCard(widgetConfig: a)),
|
children: [
|
||||||
const SizedBox(width: 10),
|
Expanded(
|
||||||
Expanded(child: HomeFilterWidgetCard(widgetConfig: b)),
|
child: HomeFilterWidgetCard(widgetConfig: a),
|
||||||
],
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: HomeFilterWidgetCard(widgetConfig: b),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -65,28 +65,28 @@ class HomeFilterWidgetCard extends StatelessWidget {
|
|||||||
child: Card(
|
child: Card(
|
||||||
color: colors.surfaceContainer,
|
color: colors.surfaceContainer,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
margin: EdgeInsets.zero,
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
onTap: editing ? onEdit : () => _openTransactions(context),
|
onTap: editing ? onEdit : () => _openTransactions(context),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
14,
|
14,
|
||||||
12,
|
14,
|
||||||
editing ? 4 : 14,
|
editing ? 4 : 14,
|
||||||
12,
|
14,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (dragHandle != null) ...[
|
if (dragHandle != null) ...[
|
||||||
dragHandle!,
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: dragHandle!,
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
],
|
],
|
||||||
Icon(
|
|
||||||
widgetConfig.icon,
|
|
||||||
color: colors.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: widgetConfig.type == HomeWidgetType.budget
|
child: widgetConfig.type == HomeWidgetType.budget
|
||||||
? _BudgetBody(
|
? _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 {
|
class _StatsBody extends StatelessWidget {
|
||||||
const _StatsBody({
|
const _StatsBody({
|
||||||
required this.widgetConfig,
|
required this.widgetConfig,
|
||||||
@@ -150,42 +283,65 @@ class _StatsBody extends StatelessWidget {
|
|||||||
filter: widgetConfig.filter,
|
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(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
_CardHeader(
|
||||||
widgetConfig.name,
|
icon: widgetConfig.icon,
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
title: widgetConfig.name,
|
||||||
fontWeight: FontWeight.w700,
|
subtitle: _subtitleFor(widgetConfig),
|
||||||
|
),
|
||||||
|
if (spentOnly) ...[
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
Text(
|
||||||
|
'Spent',
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
const SizedBox(height: 2),
|
||||||
overflow: TextOverflow.ellipsis,
|
Text(
|
||||||
),
|
formatMoney(metrics.amountOut, currency),
|
||||||
const SizedBox(height: 8),
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
Wrap(
|
color: AppTheme.expense,
|
||||||
spacing: 12,
|
fontWeight: FontWeight.w700,
|
||||||
runSpacing: 6,
|
height: 1.15,
|
||||||
children: [
|
),
|
||||||
if (widgetConfig.showAmountIn)
|
),
|
||||||
_Metric(
|
] else ...[
|
||||||
label: 'In',
|
const SizedBox(height: 14),
|
||||||
value: formatMoney(metrics.amountIn, currency),
|
Wrap(
|
||||||
color: AppTheme.accent,
|
spacing: 16,
|
||||||
),
|
runSpacing: 8,
|
||||||
if (widgetConfig.showAmountOut)
|
children: [
|
||||||
_Metric(
|
if (widgetConfig.showAmountIn)
|
||||||
label: 'Out',
|
_Metric(
|
||||||
value: formatMoney(metrics.amountOut, currency),
|
label: 'In',
|
||||||
color: AppTheme.expense,
|
value: formatMoney(metrics.amountIn, currency),
|
||||||
),
|
color: AppTheme.accent,
|
||||||
if (widgetConfig.showCount)
|
),
|
||||||
_Metric(
|
if (widgetConfig.showAmountOut)
|
||||||
label: 'Txs',
|
_Metric(
|
||||||
value: '${metrics.count}',
|
label: 'Spent',
|
||||||
color: colors.primary,
|
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 progress = budget.progress.clamp(0.0, 1.0);
|
||||||
final over = budget.overBudget;
|
final over = budget.overBudget;
|
||||||
final fillColor = over ? colors.error : colors.primary;
|
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(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
_CardHeader(
|
||||||
widgetConfig.name,
|
icon: widgetConfig.icon,
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
title: widgetConfig.name,
|
||||||
fontWeight: FontWeight.w700,
|
subtitle: subtitle,
|
||||||
),
|
// Percent lives in the ring/pie center; pill only for full-width bars.
|
||||||
maxLines: 1,
|
trailing: budget.target > 0 && !half && isBar
|
||||||
overflow: TextOverflow.ellipsis,
|
? _UsedPill(label: '$pct% used')
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 14),
|
||||||
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) {
|
switch (widgetConfig.chartStyle) {
|
||||||
HomeWidgetChartStyle.bar => ClipRRect(
|
HomeWidgetChartStyle.bar => Column(
|
||||||
borderRadius: BorderRadius.circular(6),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
child: LinearProgressIndicator(
|
children: [
|
||||||
value: budget.target <= 0 ? 0 : progress,
|
_BudgetBar(
|
||||||
minHeight: 10,
|
progress: budget.target <= 0 ? 0 : progress,
|
||||||
backgroundColor: trackColor,
|
fillColor: fillColor,
|
||||||
color: 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(
|
HomeWidgetChartStyle.pie || HomeWidgetChartStyle.ring => Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
Expanded(
|
||||||
width: 52,
|
child: Column(
|
||||||
height: 52,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: CustomPaint(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
painter: _BudgetChartPainter(
|
children: [
|
||||||
progress: budget.target <= 0 ? 0 : progress,
|
capLine,
|
||||||
style: widgetConfig.chartStyle,
|
const SizedBox(height: 8),
|
||||||
fillColor: fillColor,
|
spentLine,
|
||||||
trackColor: trackColor,
|
],
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
ringChart(size: 68),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -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 {
|
class _BudgetChartPainter extends CustomPainter {
|
||||||
_BudgetChartPainter({
|
_BudgetChartPainter({
|
||||||
required this.progress,
|
required this.progress,
|
||||||
required this.style,
|
required this.style,
|
||||||
required this.fillColor,
|
required this.fillColor,
|
||||||
required this.trackColor,
|
required this.trackColor,
|
||||||
|
this.percentLabel,
|
||||||
|
this.labelColor,
|
||||||
|
this.labelScale = 0.42,
|
||||||
});
|
});
|
||||||
|
|
||||||
final double progress;
|
final double progress;
|
||||||
final HomeWidgetChartStyle style;
|
final HomeWidgetChartStyle style;
|
||||||
final Color fillColor;
|
final Color fillColor;
|
||||||
final Color trackColor;
|
final Color trackColor;
|
||||||
|
final String? percentLabel;
|
||||||
|
final Color? labelColor;
|
||||||
|
final double labelScale;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
final center = Offset(size.width / 2, size.height / 2);
|
final center = Offset(size.width / 2, size.height / 2);
|
||||||
final radius = math.min(size.width, 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) {
|
if (style == HomeWidgetChartStyle.pie) {
|
||||||
final rect = Rect.fromCircle(center: center, radius: radius);
|
// Donut-style pizza: thick ring cutout so % stays readable.
|
||||||
final track = Paint()..color = trackColor;
|
final stroke = radius * 0.42;
|
||||||
canvas.drawCircle(center, radius, track);
|
final r = radius - stroke / 2;
|
||||||
|
final trackPaint = Paint()
|
||||||
|
..color = trackColor
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = stroke;
|
||||||
|
canvas.drawCircle(center, r, trackPaint);
|
||||||
if (progress > 0) {
|
if (progress > 0) {
|
||||||
final sweep = 2 * math.pi * progress.clamp(0.0, 1.0);
|
canvas.drawArc(
|
||||||
final fill = Paint()..color = fillColor;
|
Rect.fromCircle(center: center, radius: r),
|
||||||
canvas.drawArc(rect, -math.pi / 2, sweep, true, fill);
|
-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).
|
final label = percentLabel;
|
||||||
const stroke = 7.0;
|
if (label != null && labelColor != null) {
|
||||||
final trackPaint = Paint()
|
final tp = TextPainter(
|
||||||
..color = trackColor
|
text: TextSpan(
|
||||||
..style = PaintingStyle.stroke
|
text: label,
|
||||||
..strokeWidth = stroke
|
style: TextStyle(
|
||||||
..strokeCap = StrokeCap.round;
|
color: labelColor,
|
||||||
final fillPaint = Paint()
|
fontSize: radius * labelScale,
|
||||||
..color = fillColor
|
fontWeight: FontWeight.w800,
|
||||||
..style = PaintingStyle.stroke
|
height: 1,
|
||||||
..strokeWidth = stroke
|
),
|
||||||
..strokeCap = StrokeCap.round;
|
),
|
||||||
final r = radius - stroke / 2;
|
textDirection: TextDirection.ltr,
|
||||||
canvas.drawCircle(center, r, trackPaint);
|
)..layout();
|
||||||
if (progress > 0) {
|
tp.paint(
|
||||||
final sweep = 2 * math.pi * progress.clamp(0.0, 1.0);
|
canvas,
|
||||||
canvas.drawArc(
|
center - Offset(tp.width / 2, tp.height / 2),
|
||||||
Rect.fromCircle(center: center, radius: r),
|
|
||||||
-math.pi / 2,
|
|
||||||
sweep,
|
|
||||||
false,
|
|
||||||
fillPaint,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,7 +718,10 @@ class _BudgetChartPainter extends CustomPainter {
|
|||||||
return oldDelegate.progress != progress ||
|
return oldDelegate.progress != progress ||
|
||||||
oldDelegate.style != style ||
|
oldDelegate.style != style ||
|
||||||
oldDelegate.fillColor != fillColor ||
|
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/preferences_provider.dart';
|
||||||
import '../providers/tx_meta_provider.dart';
|
import '../providers/tx_meta_provider.dart';
|
||||||
import '../widgets/label_filter_dialog.dart';
|
import '../widgets/label_filter_dialog.dart';
|
||||||
import '../widgets/overview_favourites_sheet.dart' show promptFavouriteName;
|
|
||||||
|
|
||||||
Future<void> showHomeWidgetsSheet(BuildContext context) {
|
Future<void> showHomeWidgetsSheet(BuildContext context) {
|
||||||
return showModalBottomSheet<void>(
|
return showModalBottomSheet<void>(
|
||||||
@@ -50,22 +49,11 @@ Future<HomeFilterWidget?> composeHomeFilterWidget(
|
|||||||
if (filter == null || !navigator.mounted) return null;
|
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;
|
if (!navigator.mounted) return null;
|
||||||
|
|
||||||
final draft = existing == null
|
final draft = existing == null
|
||||||
? HomeFilterWidget.fromFilter(name: name, filter: filter)
|
? HomeFilterWidget.fromFilter(name: 'Filter', filter: filter)
|
||||||
: existing.copyWith(
|
: existing.copyWith(
|
||||||
name: name,
|
|
||||||
labelIds: filter.labelIds.toList(growable: false),
|
labelIds: filter.labelIds.toList(growable: false),
|
||||||
flow: filter.flow,
|
flow: filter.flow,
|
||||||
amountRange: filter.amountRange,
|
amountRange: filter.amountRange,
|
||||||
@@ -73,6 +61,9 @@ Future<HomeFilterWidget?> composeHomeFilterWidget(
|
|||||||
monthOnly: filter.monthOnly,
|
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);
|
return _promptWidgetMetrics(navigator.context, draft);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,265 +71,296 @@ Future<HomeFilterWidget?> _promptWidgetMetrics(
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
HomeFilterWidget draft,
|
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>(
|
return showDialog<HomeFilterWidget>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
builder: (context) {
|
builder: (context) => _WidgetOptionsDialog(draft: draft),
|
||||||
return StatefulBuilder(
|
);
|
||||||
builder: (context, setState) {
|
}
|
||||||
final theme = Theme.of(context);
|
|
||||||
return AlertDialog(
|
class _WidgetOptionsDialog extends StatefulWidget {
|
||||||
title: const Text('Widget options'),
|
const _WidgetOptionsDialog({required this.draft});
|
||||||
content: SizedBox(
|
|
||||||
width: 360,
|
final HomeFilterWidget draft;
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
@override
|
||||||
mainAxisSize: MainAxisSize.min,
|
State<_WidgetOptionsDialog> createState() => _WidgetOptionsDialogState();
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
}
|
||||||
children: [
|
|
||||||
Text('Type', style: theme.textTheme.titleSmall),
|
class _WidgetOptionsDialogState extends State<_WidgetOptionsDialog> {
|
||||||
const SizedBox(height: 8),
|
late HomeWidgetType _type;
|
||||||
SegmentedButton<HomeWidgetType>(
|
late String _iconId;
|
||||||
segments: const [
|
late HomeWidgetChartStyle _chartStyle;
|
||||||
ButtonSegment(
|
late HomeBudgetCapMode _budgetCapMode;
|
||||||
value: HomeWidgetType.stats,
|
late int _budgetAverageMonths;
|
||||||
label: Text('Stats'),
|
late bool _showIn;
|
||||||
icon: Icon(Icons.analytics_outlined),
|
late bool _showOut;
|
||||||
),
|
late bool _showCount;
|
||||||
ButtonSegment(
|
late bool _halfWidth;
|
||||||
value: HomeWidgetType.budget,
|
late final TextEditingController _nameController;
|
||||||
label: Text('Budget'),
|
late final TextEditingController _amountController;
|
||||||
icon: Icon(Icons.savings_outlined),
|
|
||||||
),
|
@override
|
||||||
],
|
void initState() {
|
||||||
selected: {type},
|
super.initState();
|
||||||
onSelectionChanged: (value) {
|
final draft = widget.draft;
|
||||||
setState(() => type = value.first);
|
_type = draft.type;
|
||||||
},
|
_iconId = draft.iconId;
|
||||||
),
|
_chartStyle = draft.chartStyle;
|
||||||
const SizedBox(height: 16),
|
_budgetCapMode = draft.budgetCapMode;
|
||||||
Text('Icon', style: theme.textTheme.titleSmall),
|
_budgetAverageMonths = draft.budgetAverageMonths;
|
||||||
const SizedBox(height: 8),
|
_showIn = draft.showAmountIn;
|
||||||
Wrap(
|
_showOut = draft.showAmountOut;
|
||||||
spacing: 6,
|
_showCount = draft.showCount;
|
||||||
runSpacing: 6,
|
_halfWidth = draft.halfWidth;
|
||||||
children: [
|
_nameController = TextEditingController(text: draft.name);
|
||||||
for (final entry in HomeWidgetIcons.all)
|
final fixed = draft.budgetFixedAmount;
|
||||||
ChoiceChip(
|
_amountController = TextEditingController(
|
||||||
label: Icon(entry.$2, size: 18),
|
text: fixed > 0
|
||||||
selected: iconId == entry.$1,
|
? (fixed == fixed.roundToDouble()
|
||||||
onSelected: (_) =>
|
? fixed.toStringAsFixed(0)
|
||||||
setState(() => iconId = entry.$1),
|
: fixed.toStringAsFixed(2))
|
||||||
tooltip: entry.$3,
|
: '',
|
||||||
visualDensity: VisualDensity.compact,
|
);
|
||||||
padding: const EdgeInsets.all(4),
|
}
|
||||||
),
|
|
||||||
],
|
@override
|
||||||
),
|
void dispose() {
|
||||||
const SizedBox(height: 12),
|
_nameController.dispose();
|
||||||
SwitchListTile(
|
_amountController.dispose();
|
||||||
contentPadding: EdgeInsets.zero,
|
super.dispose();
|
||||||
title: const Text('Half width'),
|
}
|
||||||
subtitle:
|
|
||||||
const Text('Share the row with another widget'),
|
void _save() {
|
||||||
value: halfWidth,
|
final name = _nameController.text.trim();
|
||||||
onChanged: (value) =>
|
if (name.isEmpty) return;
|
||||||
setState(() => halfWidth = value),
|
if (_type == HomeWidgetType.stats && !_showIn && !_showOut && !_showCount) {
|
||||||
),
|
return;
|
||||||
if (type == HomeWidgetType.stats) ...[
|
}
|
||||||
const Divider(),
|
var fixed = widget.draft.budgetFixedAmount;
|
||||||
CheckboxListTile(
|
if (_type == HomeWidgetType.budget &&
|
||||||
contentPadding: EdgeInsets.zero,
|
_budgetCapMode == HomeBudgetCapMode.fixed) {
|
||||||
title: const Text('Amount in'),
|
final parsed = double.tryParse(
|
||||||
value: showIn,
|
_amountController.text.trim().replaceAll(',', '.'),
|
||||||
onChanged: (value) =>
|
);
|
||||||
setState(() => showIn = value ?? false),
|
if (parsed == null || parsed <= 0) return;
|
||||||
),
|
fixed = parsed;
|
||||||
CheckboxListTile(
|
}
|
||||||
contentPadding: EdgeInsets.zero,
|
Navigator.of(context).pop(
|
||||||
title: const Text('Amount out'),
|
widget.draft.copyWith(
|
||||||
value: showOut,
|
name: name,
|
||||||
onChanged: (value) =>
|
type: _type,
|
||||||
setState(() => showOut = value ?? false),
|
iconId: _iconId,
|
||||||
),
|
chartStyle: _chartStyle,
|
||||||
CheckboxListTile(
|
budgetCapMode: _budgetCapMode,
|
||||||
contentPadding: EdgeInsets.zero,
|
budgetFixedAmount: fixed,
|
||||||
title: const Text('Number of transactions'),
|
budgetAverageMonths: _budgetAverageMonths,
|
||||||
value: showCount,
|
showAmountIn: _showIn,
|
||||||
onChanged: (value) =>
|
showAmountOut: _showOut,
|
||||||
setState(() => showCount = value ?? false),
|
showCount: _showCount,
|
||||||
),
|
halfWidth: _halfWidth,
|
||||||
] else ...[
|
),
|
||||||
const Divider(),
|
);
|
||||||
Text('Budget cap', style: theme.textTheme.titleSmall),
|
}
|
||||||
const SizedBox(height: 8),
|
|
||||||
SegmentedButton<HomeBudgetCapMode>(
|
@override
|
||||||
segments: const [
|
Widget build(BuildContext context) {
|
||||||
ButtonSegment(
|
final theme = Theme.of(context);
|
||||||
value: HomeBudgetCapMode.fixed,
|
return AlertDialog(
|
||||||
label: Text('Fixed'),
|
title: const Text('Widget options'),
|
||||||
),
|
content: SizedBox(
|
||||||
ButtonSegment(
|
width: double.maxFinite,
|
||||||
value: HomeBudgetCapMode.averageMonths,
|
child: SingleChildScrollView(
|
||||||
label: Text('Average'),
|
child: Column(
|
||||||
),
|
mainAxisSize: MainAxisSize.min,
|
||||||
],
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
selected: {budgetCapMode},
|
children: [
|
||||||
onSelectionChanged: (value) {
|
TextField(
|
||||||
setState(() => budgetCapMode = value.first);
|
controller: _nameController,
|
||||||
},
|
textCapitalization: TextCapitalization.sentences,
|
||||||
),
|
decoration: const InputDecoration(
|
||||||
const SizedBox(height: 12),
|
labelText: 'Name',
|
||||||
if (budgetCapMode == HomeBudgetCapMode.fixed)
|
border: OutlineInputBorder(),
|
||||||
TextField(
|
isDense: true,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
actions: [
|
Text('Type', style: theme.textTheme.titleSmall),
|
||||||
TextButton(
|
const SizedBox(height: 8),
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
SegmentedButton<HomeWidgetType>(
|
||||||
child: const Text('Cancel'),
|
showSelectedIcon: false,
|
||||||
),
|
segments: const [
|
||||||
FilledButton(
|
ButtonSegment(
|
||||||
onPressed: () {
|
value: HomeWidgetType.stats,
|
||||||
if (type == HomeWidgetType.stats &&
|
label: Text('Stats'),
|
||||||
!showIn &&
|
icon: Icon(Icons.analytics_outlined),
|
||||||
!showOut &&
|
),
|
||||||
!showCount) {
|
ButtonSegment(
|
||||||
return;
|
value: HomeWidgetType.budget,
|
||||||
}
|
label: Text('Budget'),
|
||||||
var fixed = budgetFixedAmount;
|
icon: Icon(Icons.savings_outlined),
|
||||||
if (type == HomeWidgetType.budget &&
|
),
|
||||||
budgetCapMode == HomeBudgetCapMode.fixed) {
|
],
|
||||||
final parsed = double.tryParse(
|
selected: {_type},
|
||||||
amountController.text.trim().replaceAll(',', '.'),
|
onSelectionChanged: (value) {
|
||||||
);
|
setState(() => _type = value.first);
|
||||||
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'),
|
|
||||||
),
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
);
|
),
|
||||||
},
|
actions: [
|
||||||
).whenComplete(amountController.dispose);
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _save,
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomeWidgetsSheet extends StatelessWidget {
|
class _HomeWidgetsSheet extends StatelessWidget {
|
||||||
@@ -517,7 +539,7 @@ class _HomeWidgetsSheet extends StatelessWidget {
|
|||||||
if (w.labelIds.isNotEmpty) '${w.labelIds.length} labels',
|
if (w.labelIds.isNotEmpty) '${w.labelIds.length} labels',
|
||||||
if (w.query.trim().isNotEmpty) '“${w.query.trim()}”',
|
if (w.query.trim().isNotEmpty) '“${w.query.trim()}”',
|
||||||
if (w.type == HomeWidgetType.stats && w.showAmountIn) 'In',
|
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.type == HomeWidgetType.stats && w.showCount) 'Count',
|
||||||
if (w.halfWidth) 'Half',
|
if (w.halfWidth) 'Half',
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user