Files
ingtrakapp/lib/screens/look_ahead_screen.dart
2026-09-17 01:23:07 +02:00

1220 lines
38 KiB
Dart

/// Look ahead: projected balance from recurring templates through a date.
library;
import 'dart:async';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/bank.dart';
import '../models/transaction_meta.dart';
import '../providers/bank_provider.dart';
import '../providers/preferences_provider.dart';
import '../providers/tx_meta_provider.dart';
import '../screens/transactions_screen.dart';
import '../utils/app_theme.dart';
import '../utils/helpers.dart';
import '../utils/look_ahead.dart';
import '../utils/navigation.dart';
import '../utils/tx_calendar.dart';
import '../widgets/empty_state.dart';
import '../widgets/loading_view.dart';
import '../widgets/recurring_template_sheet.dart';
import '../widgets/responsive_body.dart';
import '../widgets/shell_tab_scope.dart';
import '../widgets/skip_build_when_covered.dart';
enum _PeriodMode { monthly, yearly }
class _ItemsSnapshot {
const _ItemsSnapshot({required this.key, required this.items});
final int key;
final List<RecurringItem> items;
}
class _BalancePoint {
const _BalancePoint({
required this.key,
required this.label,
required this.balance,
});
final DateTime key;
final String label;
final double balance;
}
class LookAheadScreen extends StatefulWidget {
const LookAheadScreen({super.key});
static const tabIndex = 2;
@override
State<LookAheadScreen> createState() => _LookAheadScreenState();
}
class _LookAheadScreenState extends State<LookAheadScreen> {
var _period = _PeriodMode.monthly;
late DateTime _cursor;
_ItemsSnapshot? _snapshot;
int? _loadingKey;
int _computeGen = 0;
var _showChart = false;
@override
void initState() {
super.initState();
final today = dateOnly(DateTime.now());
// Default: end of next calendar month.
_cursor = monthStart(DateTime(today.year, today.month + 1));
}
DateTime get _today => dateOnly(DateTime.now());
DateTime get _minMonth => monthStart(_today);
/// Inclusive end date for the selected period.
DateTime get _through {
// Chart view (and yearly list) always project through year-end.
if (_showChart || _period == _PeriodMode.yearly) {
return DateTime(_cursor.year, 12, 31);
}
return DateTime(_cursor.year, _cursor.month + 1, 0);
}
String get _periodTitle {
if (_showChart || _period == _PeriodMode.yearly) return '${_cursor.year}';
return formatMonthYear(_cursor);
}
DateTime _maxMonthFor(int maxYears) {
final t = _today;
return DateTime(t.year + maxYears, t.month);
}
int _maxYear(int maxYears) => _today.year + maxYears;
bool get _navByYear => _showChart || _period == _PeriodMode.yearly;
bool _canGoBack() {
if (_navByYear) return _cursor.year > _today.year;
return monthStart(_cursor).isAfter(_minMonth);
}
bool _canGoForward(int maxYears) {
if (_navByYear) return _cursor.year < _maxYear(maxYears);
return monthStart(_cursor).isBefore(_maxMonthFor(maxYears));
}
void _clampCursor(int maxYears) {
if (_navByYear) {
if (_cursor.year > _maxYear(maxYears)) {
_cursor = DateTime(_maxYear(maxYears));
}
if (_cursor.year < _today.year) {
_cursor = DateTime(_today.year);
}
} else {
final maxMonth = _maxMonthFor(maxYears);
if (_cursor.isAfter(maxMonth)) _cursor = maxMonth;
if (_cursor.isBefore(_minMonth)) _cursor = _minMonth;
}
}
void _setPeriod(_PeriodMode mode, int maxYears) {
if (mode == _period) return;
setState(() {
_period = mode;
if (mode == _PeriodMode.yearly || _showChart) {
_cursor = DateTime(_cursor.year);
_clampCursor(maxYears);
} else {
final month = DateTime(_cursor.year, _today.month);
_cursor = month.isBefore(_minMonth) ? _minMonth : monthStart(month);
_clampCursor(maxYears);
}
});
}
void _go(int delta, int maxYears) {
setState(() {
if (_navByYear) {
final next = DateTime(_cursor.year + delta);
if (next.year < _today.year || next.year > _maxYear(maxYears)) {
return;
}
_cursor = next;
} else {
final next = addMonths(monthStart(_cursor), delta);
final maxMonth = _maxMonthFor(maxYears);
if (next.isBefore(_minMonth) || next.isAfter(maxMonth)) return;
_cursor = next;
}
});
}
void _toggleChart(int maxYears) {
setState(() {
_showChart = !_showChart;
if (_showChart) {
_cursor = DateTime(_cursor.year);
} else if (_period == _PeriodMode.monthly) {
final month = DateTime(_cursor.year, _today.month);
_cursor = month.isBefore(_minMonth) ? _minMonth : monthStart(month);
}
_clampCursor(maxYears);
});
}
int _itemsCacheKey(TxMetaController meta, BankController bank) =>
Object.hash(meta.storeRevision, identityHashCode(bank.transactions));
/// Projected account balance at each month-end / year-end.
List<_BalancePoint> _balanceSeries({
required List<RecurringItem> items,
required double currentBalance,
required int maxYears,
}) {
final today = _today;
if (_period == _PeriodMode.monthly) {
final year = _cursor.year;
const months = [
'J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D',
];
final out = <_BalancePoint>[];
for (var m = 1; m <= 12; m++) {
final end = DateTime(year, m + 1, 0);
final balance = !end.isAfter(today)
? currentBalance
: predictBalance(
currentBalance: currentBalance,
from: today,
throughDate: end,
items: items,
);
out.add(
_BalancePoint(
key: DateTime(year, m),
label: months[m - 1],
balance: balance,
),
);
}
return out;
}
final startYear = _today.year;
final endYear = _maxYear(maxYears);
final out = <_BalancePoint>[];
for (var y = startYear; y <= endYear; y++) {
final end = DateTime(y, 12, 31);
final balance = !end.isAfter(today)
? currentBalance
: predictBalance(
currentBalance: currentBalance,
from: today,
throughDate: end,
items: items,
);
out.add(
_BalancePoint(
key: DateTime(y),
label: '$y',
balance: balance,
),
);
}
return out;
}
/// Resolve recurring amounts off the build path, yielding so nav stays live.
void _ensureItemsLoaded({
required int cacheKey,
required TxMetaController meta,
required BankController bank,
}) {
if (_snapshot?.key == cacheKey || _loadingKey == cacheKey) return;
_loadingKey = cacheKey;
final gen = ++_computeGen;
final templates = List<RecurringTemplate>.of(meta.recurringTemplates);
final transactions = bank.transactions;
unawaited(() async {
// Let the spinner / tab switch paint before heavy work.
await Future<void>.delayed(Duration.zero);
if (!mounted || gen != _computeGen) return;
final items = <RecurringItem>[];
for (var i = 0; i < templates.length; i++) {
final tpl = templates[i];
items.add(
RecurringItem(
templateId: tpl.id,
description: tpl.name,
amount: resolveRecurringAmount(
template: tpl,
transactions: transactions,
isInternalTransfer: (tx) => meta.metaFor(tx).internalTransfer,
labelIdsFor: meta.labelIdsFor,
),
currency: tpl.currency,
dayOfMonth: tpl.dayOfMonth.clamp(1, 28),
ruleSummary: recurringRuleSummary(
template: tpl,
labelName: (id) => meta.labelById(id)?.name ?? 'label',
),
sourceTxId: tpl.sourceTxId,
labelIds: [
if (tpl.labelId != null && tpl.labelId!.isNotEmpty) tpl.labelId!,
],
),
);
// Yield often so bottom-nav taps are not starved.
if (i % 2 == 1) {
await Future<void>.delayed(Duration.zero);
if (!mounted || gen != _computeGen) return;
}
}
if (!mounted || gen != _computeGen) return;
setState(() {
_snapshot = _ItemsSnapshot(key: cacheKey, items: items);
if (_loadingKey == cacheKey) _loadingKey = null;
});
}());
}
@override
Widget build(BuildContext context) {
final active = ShellTabScope.of(context) == LookAheadScreen.tabIndex;
if (!active) {
if (_loadingKey != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (ShellTabScope.of(context) == LookAheadScreen.tabIndex) return;
_computeGen++;
_loadingKey = null;
});
}
return const SizedBox.expand();
}
return SkipBuildWhenCovered(
builder: (context) => _buildContent(context),
);
}
Widget _buildContent(BuildContext context) {
final bank = context.watch<BankController>();
final meta = context.watch<TxMetaController>();
final maxYears =
context.watch<PreferencesController>().lookAheadMaxYears;
final maxMonth = _maxMonthFor(maxYears);
final needsClamp = _period == _PeriodMode.yearly
? _cursor.year > _today.year + maxYears
: _cursor.isAfter(maxMonth);
if (needsClamp) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() => _clampCursor(maxYears));
});
}
final theme = Theme.of(context);
final colors = theme.colorScheme;
final balance = bank.balance;
final currency = balance?.currency ??
(bank.transactions.isNotEmpty
? bank.transactions.first.currency
: 'EUR');
final currentAmount = balance?.amount ?? 0;
final today = _today;
final through = _through;
final cacheKey = _itemsCacheKey(meta, bank);
if (_snapshot?.key != cacheKey && _loadingKey != cacheKey) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (ShellTabScope.of(context) != LookAheadScreen.tabIndex) return;
_ensureItemsLoaded(cacheKey: cacheKey, meta: meta, bank: bank);
});
}
final snapshot = _snapshot;
final ready = snapshot != null && snapshot.key == cacheKey;
if (!ready) {
return Scaffold(
appBar: AppBar(
title: const Text('Look ahead'),
actions: [
IconButton(
tooltip: _showChart ? 'Show list' : 'Show chart',
onPressed: () => _toggleChart(maxYears),
icon: Icon(
_showChart ? Icons.view_list_outlined : Icons.calendar_month,
),
),
...bankAppBarActions(context: context),
],
),
body: const LoadingView(message: 'Calculating look ahead…'),
);
}
final items = snapshot.items;
final projected = projectedOccurrences(
items: items,
from: today,
to: through,
);
final predicted = projected.fold<double>(
currentAmount,
(sum, row) => sum + row.item.amount,
);
final delta = predicted - currentAmount;
var income = 0.0;
var expenses = 0.0;
for (final row in projected) {
if (row.item.amount >= 0) {
income += row.item.amount;
} else {
expenses += -row.item.amount;
}
}
// One occurrence of each template per month (template run-rate).
var monthlyIncome = 0.0;
var monthlyExpense = 0.0;
for (final item in items) {
if (item.amount >= 0) {
monthlyIncome += item.amount;
} else {
monthlyExpense += -item.amount;
}
}
return Scaffold(
appBar: AppBar(
title: const Text('Look ahead'),
actions: [
IconButton(
tooltip: _showChart ? 'Show list' : 'Show chart',
onPressed: () => _toggleChart(maxYears),
icon: Icon(
_showChart ? Icons.view_list_outlined : Icons.calendar_month,
),
),
...bankAppBarActions(context: context),
],
),
body: ResponsiveBody(
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(0, 8, 0, 32),
children: [
_PeriodToolbar(
period: _period,
onPeriodChanged: (mode) => _setPeriod(mode, maxYears),
),
const SizedBox(height: 10),
_PeriodNavigator(
title: _periodTitle,
canGoBack: _canGoBack(),
canGoForward: _canGoForward(maxYears),
onBack: () => _go(-1, maxYears),
onForward: () => _go(1, maxYears),
backTooltip: _navByYear ? 'Previous year' : 'Previous month',
forwardTooltip: _navByYear ? 'Next year' : 'Next month',
),
const SizedBox(height: 12),
Material(
color: Colors.transparent,
child: Ink(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: [colors.primary, colors.tertiary],
),
boxShadow: [
BoxShadow(
color: colors.primary.withValues(alpha: 0.28),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Projected balance',
style: theme.textTheme.labelMedium?.copyWith(
color: colors.onPrimary.withValues(alpha: 0.8),
),
),
const SizedBox(height: 4),
Text(
'As of ${_formatLongDate(through)}',
style: theme.textTheme.titleMedium?.copyWith(
color: colors.onPrimary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
Text(
formatMoney(predicted, currency),
style: theme.textTheme.headlineMedium?.copyWith(
color: colors.onPrimary,
),
),
const SizedBox(height: 8),
Text(
balance == null
? 'No current balance loaded — projection starts at 0'
: 'Now ${formatMoney(currentAmount, currency)}'
' · ${delta >= 0 ? '+' : ''}'
'${formatMoney(delta, currency)} by then',
style: theme.textTheme.labelMedium?.copyWith(
color: colors.onPrimary.withValues(alpha: 0.9),
),
),
],
),
),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: _SummaryCard(
icon: Icons.south_west,
iconBackground: AppTheme.accent.withValues(alpha: 0.18),
iconColor: AppTheme.accent,
amount: formatMoney(income, currency),
label: 'Income',
),
),
const SizedBox(width: 12),
Expanded(
child: _SummaryCard(
icon: Icons.north_east,
iconBackground: AppTheme.expense.withValues(alpha: 0.18),
iconColor: AppTheme.expense,
amount: formatMoney(expenses, currency),
label: 'Expenses',
),
),
],
),
if (_showChart) ...[
const SizedBox(height: 16),
_LookAheadBalanceChart(
points: _balanceSeries(
items: items,
currentBalance: currentAmount,
maxYears: maxYears,
),
period: _period,
currency: currency,
),
const SizedBox(height: 8),
Text(
_period == _PeriodMode.yearly
? 'Projected account balance by year'
: 'Projected account balance by month',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
] else ...[
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: Text(
'Recurring spendings',
style: theme.textTheme.titleMedium,
),
),
TextButton.icon(
onPressed: () => showCreateRecurringTemplateSheet(context),
icon: const Icon(Icons.add, size: 18),
label: const Text('New'),
),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _SummaryCard(
icon: Icons.south_west,
iconBackground: AppTheme.accent.withValues(alpha: 0.18),
iconColor: AppTheme.accent,
amount: formatMoney(monthlyIncome, currency),
label: 'Monthly income',
),
),
const SizedBox(width: 12),
Expanded(
child: _SummaryCard(
icon: Icons.north_east,
iconBackground: AppTheme.expense.withValues(alpha: 0.18),
iconColor: AppTheme.expense,
amount: formatMoney(monthlyExpense, currency),
label: 'Monthly expense',
),
),
],
),
const SizedBox(height: 10),
_SavingsCard(
monthlySavings: monthlyIncome - monthlyExpense,
currency: currency,
),
const SizedBox(height: 10),
if (items.isEmpty)
const EmptyState(
icon: Icons.event_repeat_outlined,
title: 'No recurring spendings',
message:
'Open a transaction and tap Create recurring spending, '
'or use New to pick a transaction or label average.',
)
else
Card(
color: colors.surfaceContainer,
elevation: 0,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
for (var i = 0; i < items.length; i++) ...[
if (i > 0)
Divider(
height: 1,
indent: 16,
color: colors.outlineVariant.withValues(alpha: 0.4),
),
_TemplateTile(
item: items[i],
template:
meta.recurringTemplateById(items[i].templateId),
transactions: bank.transactions,
),
],
],
),
),
],
],
),
),
);
}
}
class _LookAheadBalanceChart extends StatelessWidget {
const _LookAheadBalanceChart({
required this.points,
required this.period,
required this.currency,
});
final List<_BalancePoint> points;
final _PeriodMode period;
final String currency;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
if (points.isEmpty) {
return Card(
color: colors.surfaceContainer,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'No projected balance for this period',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
),
);
}
var minY = points.first.balance;
var maxY = points.first.balance;
for (final p in points) {
if (p.balance < minY) minY = p.balance;
if (p.balance > maxY) maxY = p.balance;
}
if (minY == maxY) {
minY -= 1;
maxY += 1;
}
final pad = (maxY - minY).abs() * 0.12;
final safeMin = minY - pad;
final safeMax = maxY + pad;
final interval = ((safeMax - safeMin) / 3).abs();
final safeInterval = interval <= 0 ? 1.0 : interval;
final spots = [
for (var i = 0; i < points.length; i++)
FlSpot(i.toDouble(), points[i].balance),
];
return Card(
color: colors.surfaceContainer,
elevation: 0,
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Projected balance',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
SizedBox(
height: 220,
child: LineChart(
LineChartData(
minX: 0,
maxX: (points.length - 1).toDouble(),
minY: safeMin,
maxY: safeMax,
lineTouchData: LineTouchData(
handleBuiltInTouches: true,
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (touched) {
return [
for (final spot in touched)
if (spot.x.round() >= 0 &&
spot.x.round() < points.length)
LineTooltipItem(
'${points[spot.x.round()].label}\n'
'${formatMoney(points[spot.x.round()].balance, currency)}',
theme.textTheme.labelMedium!.copyWith(
color: colors.onInverseSurface,
fontWeight: FontWeight.w600,
),
),
];
},
),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 44,
interval: safeInterval,
getTitlesWidget: (value, meta) {
if ((value - safeMax).abs() < 0.01 ||
(value - safeMin).abs() < 0.01) {
return const SizedBox.shrink();
}
return Text(
_compactMoney(value),
style: theme.textTheme.labelSmall,
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.round();
if (index < 0 || index >= points.length) {
return const SizedBox.shrink();
}
if (period == _PeriodMode.yearly &&
points.length > 8 &&
index % 2 == 1) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
points[index].label,
style: theme.textTheme.labelSmall,
),
);
},
),
),
),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: safeInterval,
getDrawingHorizontalLine: (value) => FlLine(
color: colors.outlineVariant.withValues(alpha: 0.35),
strokeWidth: 1,
dashArray: const [4, 4],
),
),
borderData: FlBorderData(show: false),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
curveSmoothness: 0.18,
color: colors.primary,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, bar, index) {
return FlDotCirclePainter(
radius: 3.5,
color: colors.primary,
strokeWidth: 0,
);
},
),
belowBarData: BarAreaData(
show: true,
color: colors.primary.withValues(alpha: 0.14),
),
),
],
),
duration: Duration.zero,
),
),
],
),
),
);
}
}
class _SavingsCard extends StatelessWidget {
const _SavingsCard({
required this.monthlySavings,
required this.currency,
});
final double monthlySavings;
final String currency;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final yearly = monthlySavings * 12;
final positive = monthlySavings >= 0;
final tone = positive ? AppTheme.accent : AppTheme.expense;
return Card(
color: colors.surfaceContainer,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: tone.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.savings_outlined, color: tone, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: _SavingsMetric(
label: 'Monthly savings',
amount: formatMoney(monthlySavings, currency),
color: tone,
),
),
const SizedBox(width: 12),
Expanded(
child: _SavingsMetric(
label: 'Yearly savings',
amount: formatMoney(yearly, currency),
color: tone,
alignEnd: true,
),
),
],
),
),
);
}
}
class _SavingsMetric extends StatelessWidget {
const _SavingsMetric({
required this.label,
required this.amount,
required this.color,
this.alignEnd = false,
});
final String label;
final String amount;
final Color color;
final bool alignEnd;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return Column(
crossAxisAlignment:
alignEnd ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
Text(
label.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
letterSpacing: 0.6,
fontWeight: FontWeight.w600,
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
amount,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: color,
),
),
],
);
}
}
String _compactMoney(double value) {
final abs = value.abs();
if (abs >= 1000000) return '${(value / 1000000).toStringAsFixed(1)}M';
if (abs >= 1000) return '${(value / 1000).toStringAsFixed(1)}k';
if (abs >= 100) return value.toStringAsFixed(0);
return value.toStringAsFixed(abs < 10 && abs > 0 ? 1 : 0);
}
class _PeriodToolbar extends StatelessWidget {
const _PeriodToolbar({
required this.period,
required this.onPeriodChanged,
});
final _PeriodMode period;
final ValueChanged<_PeriodMode> onPeriodChanged;
static const _segmentStyle = ButtonStyle(
visualDensity: VisualDensity(horizontal: -4, vertical: -3),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 10)),
minimumSize: WidgetStatePropertyAll(Size(0, 32)),
);
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.centerLeft,
child: SegmentedButton<_PeriodMode>(
showSelectedIcon: false,
style: _segmentStyle,
segments: const [
ButtonSegment(
value: _PeriodMode.monthly,
label: Text('Month'),
),
ButtonSegment(
value: _PeriodMode.yearly,
label: Text('Year'),
),
],
selected: {period},
onSelectionChanged: (selection) {
onPeriodChanged(selection.first);
},
),
);
}
}
class _PeriodNavigator extends StatelessWidget {
const _PeriodNavigator({
required this.title,
required this.canGoBack,
required this.canGoForward,
required this.onBack,
required this.onForward,
required this.backTooltip,
required this.forwardTooltip,
});
final String title;
final bool canGoBack;
final bool canGoForward;
final VoidCallback onBack;
final VoidCallback onForward;
final String backTooltip;
final String forwardTooltip;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
IconButton(
tooltip: backTooltip,
onPressed: canGoBack ? onBack : null,
visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
padding: EdgeInsets.zero,
icon: const Icon(Icons.chevron_left, size: 22),
),
Expanded(
child: Text(
title,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
IconButton(
tooltip: forwardTooltip,
onPressed: canGoForward ? onForward : null,
visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
padding: EdgeInsets.zero,
icon: const Icon(Icons.chevron_right, size: 22),
),
],
);
}
}
class _SummaryCard extends StatelessWidget {
const _SummaryCard({
required this.icon,
required this.iconBackground,
required this.iconColor,
required this.amount,
required this.label,
});
final IconData icon;
final Color iconBackground;
final Color iconColor;
final String amount;
final String label;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return Card(
color: colors.surfaceContainer,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: iconBackground,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: iconColor, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
amount,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: colors.onSurface,
),
),
const SizedBox(height: 2),
Text(
label.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
letterSpacing: 0.6,
fontWeight: FontWeight.w600,
color: colors.onSurfaceVariant,
),
),
],
),
),
],
),
),
);
}
}
String _formatLongDate(DateTime date) {
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
return '${date.day} ${months[date.month - 1]} ${date.year}';
}
class _TemplateTile extends StatelessWidget {
const _TemplateTile({
required this.item,
required this.template,
required this.transactions,
});
final RecurringItem item;
final RecurringTemplate? template;
final List<BankTransaction> transactions;
BankTransaction? get _sourceTx {
final id = item.sourceTxId;
if (id == null) return null;
for (final tx in transactions) {
if (tx.id == id) return tx;
}
return null;
}
void _openTransactions(BuildContext context) {
final labelIds = item.labelIds.toSet();
SimilarMatchCriteria? similar;
TxFlowFilter? flow;
String? query;
if (labelIds.isEmpty) {
final tx = _sourceTx;
if (tx != null) {
// Same matching as Look ahead / apply-to-similar for this source tx.
similar = SimilarMatchCriteria(
pattern: defaultSimilarPattern(tx.description),
flow: tx.amount < 0
? TxFlowFilter.expense
: tx.amount > 0
? TxFlowFilter.income
: TxFlowFilter.all,
);
flow = similar.flow;
} else if (template != null && template!.merchantKey.isNotEmpty) {
query = template!.merchantKey;
} else {
query = item.description;
}
}
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => TransactionsScreen(
initialLabelIds: labelIds,
initialLabelMatch: LabelMatchMode.or,
initialQuery: query,
initialFlow: flow,
initialSimilarMatch: similar,
initialSearchInMonthOnly: false,
),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final income = item.amount >= 0;
final tpl = template;
return ListTile(
leading: IconButton(
tooltip: 'Edit spending',
icon: Icon(Icons.tune, color: colors.primary),
onPressed: tpl == null
? null
: () => showEditRecurringTemplateSheet(context, template: tpl),
),
title: Text(
item.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
[
'Day ${item.dayOfMonth}',
if (item.ruleSummary.isNotEmpty) item.ruleSummary,
].join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
trailing: Text(
formatMoney(item.amount, item.currency),
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: income ? AppTheme.accent : AppTheme.expense,
),
),
onTap: () => _openTransactions(context),
);
}
}