Files
ingtrakapp/lib/screens/look_ahead_screen.dart
T

673 lines
21 KiB
Dart

/// Look ahead: projected balance from recurring templates through a date.
library;
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/transaction_detail_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/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 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;
List<RecurringItem>? _cachedItems;
int? _itemsCacheKey;
@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 {
if (_period == _PeriodMode.yearly) {
return DateTime(_cursor.year, 12, 31);
}
return DateTime(_cursor.year, _cursor.month + 1, 0);
}
String get _periodTitle {
if (_period == _PeriodMode.yearly) return '${_cursor.year}';
return formatMonthYear(_cursor);
}
DateTime _maxMonthFor(int maxYears) {
final t = _today;
return DateTime(t.year + maxYears, t.month);
}
bool _canGoBack() {
if (_period == _PeriodMode.yearly) {
return _cursor.year > _today.year;
}
return monthStart(_cursor).isAfter(_minMonth);
}
bool _canGoForward(int maxYears) {
if (_period == _PeriodMode.yearly) {
return _cursor.year < _today.year + maxYears;
}
return monthStart(_cursor).isBefore(_maxMonthFor(maxYears));
}
void _clampCursor(int maxYears) {
if (_period == _PeriodMode.yearly) {
if (_cursor.year > _today.year + maxYears) {
_cursor = DateTime(_today.year + maxYears);
}
} else {
final maxMonth = _maxMonthFor(maxYears);
if (_cursor.isAfter(maxMonth)) _cursor = maxMonth;
}
}
void _setPeriod(_PeriodMode mode, int maxYears) {
if (mode == _period) return;
setState(() {
_period = mode;
if (mode == _PeriodMode.yearly) {
_cursor = DateTime(_cursor.year);
if (_cursor.year < _today.year) _cursor = DateTime(_today.year);
if (_cursor.year > _today.year + maxYears) {
_cursor = DateTime(_today.year + maxYears);
}
} else {
final month = DateTime(_cursor.year, _today.month);
_cursor = month.isBefore(_minMonth) ? _minMonth : monthStart(month);
final maxMonth = _maxMonthFor(maxYears);
if (_cursor.isAfter(maxMonth)) _cursor = maxMonth;
}
});
}
void _go(int delta, int maxYears) {
setState(() {
if (_period == _PeriodMode.yearly) {
final next = DateTime(_cursor.year + delta);
if (next.year < _today.year || next.year > _today.year + 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;
}
});
}
@override
Widget build(BuildContext context) {
if (ShellTabScope.of(context) != LookAheadScreen.tabIndex) {
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 = Object.hash(
meta.storeRevision,
identityHashCode(bank.transactions),
);
if (_cachedItems == null || _itemsCacheKey != cacheKey) {
_itemsCacheKey = cacheKey;
_cachedItems = recurringItemsFromTemplates(
templates: meta.recurringTemplates,
transactions: bank.transactions,
isInternalTransfer: (tx) => meta.metaFor(tx).internalTransfer,
labelIdsFor: meta.labelIdsFor,
labelName: (id) => meta.labelById(id)?.name ?? 'label',
);
}
final items = _cachedItems!;
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: 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:
_period == _PeriodMode.yearly ? 'Previous year' : 'Previous month',
forwardTooltip:
_period == _PeriodMode.yearly ? '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',
),
),
],
),
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: 4),
Text(
items.isEmpty
? 'Create a spending from a transaction, or tap New.'
: '${items.length} spending${items.length == 1 ? '' : 's'} · '
'projected income ${formatMoney(income, currency)} · '
'expenses ${formatMoney(expenses, currency)}.',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
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),
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 _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;
}
@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: () {
final tx = _sourceTx;
if (tx == null) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => TransactionDetailScreen(tx: tx),
),
);
},
);
}
}