Files
2026-09-17 00:50:07 +02:00

728 lines
25 KiB
Dart

/// Home: balance overview; tap balance to open transactions.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/home_filter_widget.dart';
import '../providers/bank_provider.dart';
import '../providers/preferences_provider.dart';
import '../providers/tx_meta_provider.dart';
import '../utils/app_theme.dart';
import '../utils/connect_bank.dart';
import '../utils/deferred_tab_work.dart';
import '../utils/helpers.dart';
import '../utils/navigation.dart';
import '../utils/tx_filter_metrics.dart';
import '../widgets/empty_state.dart';
import '../widgets/home_filter_widget_card.dart';
import '../widgets/home_widgets_sheet.dart';
import '../widgets/loading_view.dart';
import '../widgets/responsive_body.dart';
import '../widgets/shell_tab_scope.dart';
import '../widgets/skip_build_when_covered.dart';
import 'transaction_detail_screen.dart';
import 'transactions_screen.dart';
class _HomeSnapshot {
const _HomeSnapshot({
required this.categorized,
required this.statsById,
required this.budgetById,
});
final int categorized;
final Map<String, TxFilterMetrics> statsById;
final Map<String, HomeBudgetMetrics> budgetById;
}
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
static const tabIndex = 0;
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
var _editingWidgets = false;
final _work = DeferredTabWork<_HomeSnapshot>();
int _homeCacheKey({
required TxMetaController meta,
required BankController bank,
required List<HomeFilterWidget> widgets,
}) {
return Object.hash(
meta.storeRevision,
identityHashCode(bank.transactions),
Object.hashAll(widgets.map((w) => w.id)),
Object.hashAll(widgets.map((w) => w.updatedAt)),
_editingWidgets,
);
}
@override
Widget build(BuildContext context) {
// Inactive IndexedStack tabs must not watch providers — otherwise every
// annotation save rebuilds Home while the user is on another tab.
final active = ShellTabScope.of(context) == HomeScreen.tabIndex;
if (!active) {
if (_work.isLoading) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (ShellTabScope.of(context) == HomeScreen.tabIndex) return;
_work.cancel();
});
}
return const SizedBox.expand();
}
final bank = context.watch<BankController>();
final meta = context.watch<TxMetaController>();
final prefs = context.watch<PreferencesController>();
final widgets = _editingWidgets
? prefs.homeFilterWidgets
: prefs.displayedHomeFilterWidgets;
final needsData = bank.isLinked || bank.hasData;
final emptyLoading = bank.isLoading && bank.balance == null && !bank.hasData;
final cacheKey = _homeCacheKey(meta: meta, bank: bank, widgets: widgets);
if (needsData && !emptyLoading && !_work.readyFor(cacheKey)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (ShellTabScope.of(context) != HomeScreen.tabIndex) return;
_work.ensure(
key: cacheKey,
mounted: () => mounted,
onUpdate: () {
if (mounted) setState(() {});
},
compute: (yieldUi) async {
final categorized =
meta.labeledTransactionCount(bank.transactions);
await yieldUi();
final stats = <String, TxFilterMetrics>{};
final budgets = <String, HomeBudgetMetrics>{};
for (var i = 0; i < widgets.length; i++) {
final w = widgets[i];
if (w.type == HomeWidgetType.budget) {
budgets[w.id] = computeHomeBudgetMetrics(
all: bank.transactions,
meta: meta,
widget: w,
);
} else {
stats[w.id] = computeTxFilterMetrics(
all: bank.transactions,
meta: meta,
filter: w.filter,
);
}
if (i % 2 == 1) await yieldUi();
}
return _HomeSnapshot(
categorized: categorized,
statsById: stats,
budgetById: budgets,
);
},
);
});
}
final showSpinner =
needsData && !emptyLoading && !_work.readyFor(cacheKey);
return Scaffold(
appBar: AppBar(
title: Text(_editingWidgets ? 'Edit Home' : 'Home'),
actions: [
if (bank.isLinked || bank.hasData)
IconButton(
tooltip: _editingWidgets ? 'Done' : 'Edit Home layout',
onPressed: () =>
setState(() => _editingWidgets = !_editingWidgets),
icon: Icon(
_editingWidgets
? Icons.check
: Icons.dashboard_customize_outlined,
),
),
...bankAppBarActions(
context: context,
onRefresh: bank.refresh,
refreshing: bank.isLoading,
),
],
),
body: RefreshIndicator(
onRefresh: bank.refresh,
child: ResponsiveBody(
child: showSpinner
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 120),
LoadingView(message: 'Loading home…'),
],
)
: _HomeBody(
bank: bank,
editingWidgets: _editingWidgets,
snapshot: _work.value,
),
),
),
);
}
}
class _HomeBody extends StatelessWidget {
const _HomeBody({
required this.bank,
required this.editingWidgets,
required this.snapshot,
});
final BankController bank;
final bool editingWidgets;
final _HomeSnapshot? snapshot;
void _openTransactions(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const TransactionsScreen()),
);
}
@override
Widget build(BuildContext context) {
// Stay subscribed, but skip heavy scans while covered by another route
// (e.g. transaction detail applying labels to many similar txs).
context.watch<TxMetaController>();
final prefs = context.watch<PreferencesController>();
if (bank.isLoading && bank.balance == null && !bank.hasData) {
return const LoadingView(message: 'Checking bank link…');
}
if (!bank.isLinked && !bank.hasData) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
EmptyState(
icon: Icons.account_balance_outlined,
title: 'Bank not linked',
message: bank.error ??
'Connect your bank to see balance and categorize spending.',
actionLabel: bank.isConnecting ? 'Opening…' : 'Connect bank',
onAction: () => runConnectBank(context, bank),
),
],
);
}
if (bank.error != null && bank.balance == null && !bank.hasData) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
EmptyState(
icon: Icons.error_outline,
title: 'Could not load balance',
message: bank.error!,
actionLabel: 'Retry',
onAction: bank.refresh,
),
],
);
}
return SkipBuildWhenCovered(
builder: (context) {
final balance = bank.balance;
final theme = Theme.of(context);
final colors = theme.colorScheme;
final meta = context.watch<TxMetaController>();
final recent = bank.transactions.take(5).toList(growable: false);
final categorized = snapshot?.categorized ??
meta.labeledTransactionCount(bank.transactions);
final widgets = editingWidgets
? prefs.homeFilterWidgets
: prefs.displayedHomeFilterWidgets;
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(top: 8, bottom: 32),
children: [
Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => _openTransactions(context),
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(
'Current balance',
style: theme.textTheme.labelMedium?.copyWith(
color: colors.onPrimary.withValues(alpha: 0.8),
),
),
const SizedBox(height: 8),
Text(
balance == null
? '—'
: formatMoney(balance.amount, balance.currency),
style: theme.textTheme.headlineMedium?.copyWith(
color: colors.onPrimary,
),
),
const SizedBox(height: 12),
Row(
children: [
Icon(
Icons.receipt_long,
size: 16,
color: colors.onPrimary.withValues(alpha: 0.9),
),
const SizedBox(width: 6),
Expanded(
child: Text(
'Tap to view transactions',
style: theme.textTheme.labelMedium?.copyWith(
color:
colors.onPrimary.withValues(alpha: 0.9),
),
),
),
Icon(
Icons.chevron_right,
color: colors.onPrimary.withValues(alpha: 0.9),
),
],
),
],
),
),
),
),
if (_showBuiltIn(
editing: editingWidgets,
enabled: prefs.homeBuiltInSections.showTransactions,
) ||
_showBuiltIn(
editing: editingWidgets,
enabled: prefs.homeBuiltInSections.showCategorized,
)) ...[
const SizedBox(height: 20),
Row(
children: [
if (_showBuiltIn(
editing: editingWidgets,
enabled: prefs.homeBuiltInSections.showTransactions,
))
Expanded(
child: _EditableHomeBlock(
editing: editingWidgets,
visible: prefs.homeBuiltInSections.showTransactions,
onToggleVisible: (value) =>
prefs.setHomeShowTransactions(value),
child: _StatChip(
label: 'Transactions',
value: '${bank.transactions.length}',
icon: Icons.list_alt,
),
),
),
if (_showBuiltIn(
editing: editingWidgets,
enabled: prefs.homeBuiltInSections.showTransactions,
) &&
_showBuiltIn(
editing: editingWidgets,
enabled: prefs.homeBuiltInSections.showCategorized,
))
const SizedBox(width: 10),
if (_showBuiltIn(
editing: editingWidgets,
enabled: prefs.homeBuiltInSections.showCategorized,
))
Expanded(
child: _EditableHomeBlock(
editing: editingWidgets,
visible: prefs.homeBuiltInSections.showCategorized,
onToggleVisible: (value) =>
prefs.setHomeShowCategorized(value),
child: _StatChip(
label: 'Categorized',
value: '$categorized',
icon: Icons.label_outline,
),
),
),
],
),
],
if (editingWidgets || widgets.isNotEmpty) ...[
const SizedBox(height: 24),
if (editingWidgets)
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: () => showHomeWidgetsSheet(context),
icon: const Icon(Icons.add, size: 18),
label: const Text('Add'),
),
),
if (widgets.isEmpty)
Text(
'No widgets yet. Tap Add to pin a transaction filter.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
)
else if (editingWidgets)
ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: widgets.length,
onReorderItem: prefs.reorderHomeFilterWidgets,
proxyDecorator: (child, index, animation) {
return Material(
elevation: 2,
color: Colors.transparent,
borderRadius: BorderRadius.circular(16),
child: child,
);
},
itemBuilder: (context, index) {
final widgetConfig = widgets[index];
return Padding(
key: ValueKey(widgetConfig.id),
padding: const EdgeInsets.only(bottom: 10),
child: _HomeWidgetSlot(
halfWidth: widgetConfig.halfWidth,
child: HomeFilterWidgetCard(
widgetConfig: widgetConfig,
editing: true,
precomputedStats: snapshot?.statsById[widgetConfig.id],
precomputedBudget:
snapshot?.budgetById[widgetConfig.id],
onToggleDisplay: (display) =>
prefs.setHomeFilterWidgetDisplay(
widgetConfig.id,
display,
),
onEdit: () async {
final preferences =
context.read<PreferencesController>();
final updated = await composeHomeFilterWidget(
context,
existing: widgetConfig,
);
if (updated == null) return;
await preferences.updateHomeFilterWidget(updated);
},
dragHandle: ReorderableDragStartListener(
index: index,
child: Icon(
Icons.drag_handle,
color: colors.onSurfaceVariant,
),
),
),
),
);
},
)
else
..._packedHomeWidgetRows(
widgets,
statsById: snapshot?.statsById,
budgetById: snapshot?.budgetById,
),
],
if (_showBuiltIn(
editing: editingWidgets,
enabled: prefs.homeBuiltInSections.showRecent,
) &&
(recent.isNotEmpty || editingWidgets)) ...[
const SizedBox(height: 24),
_EditableHomeBlock(
editing: editingWidgets,
visible: prefs.homeBuiltInSections.showRecent,
onToggleVisible: (value) => prefs.setHomeShowRecent(value),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Text('Recent', style: theme.textTheme.titleMedium),
const Spacer(),
TextButton(
onPressed: () => _openTransactions(context),
child: const Text('See all'),
),
],
),
const SizedBox(height: 4),
if (recent.isEmpty)
Text(
'No recent transactions yet.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
)
else
for (final tx in recent)
ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: Builder(
builder: (context) {
final labels =
meta.leafLabelsFor(meta.metaFor(tx));
final label =
labels.isEmpty ? null : labels.first;
return Icon(
label?.icon ?? Icons.receipt_long_outlined,
color: label?.color,
);
},
),
title: Text(
tx.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(formatIsoDate(tx.date)),
trailing: Text(
formatMoney(tx.amount, tx.currency),
style: theme.textTheme.titleSmall?.copyWith(
color: tx.amount >= 0
? AppTheme.accent
: AppTheme.expense,
),
),
onTap: editingWidgets
? null
: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
TransactionDetailScreen(tx: tx),
),
);
},
),
],
),
),
],
if (bank.error != null) ...[
const SizedBox(height: 12),
Text(
bank.error!,
style: theme.textTheme.bodyMedium
?.copyWith(color: colors.error),
),
],
],
);
},
);
}
}
class _StatChip extends StatelessWidget {
const _StatChip({
required this.label,
required this.value,
required this.icon,
});
final String label;
final String value;
final IconData icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: colors.surfaceContainerLow,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Icon(icon, size: 18, color: colors.primary),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.labelMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
Text(value, style: theme.textTheme.titleMedium),
],
),
),
],
),
);
}
}
bool _showBuiltIn({required bool editing, required bool enabled}) =>
editing || enabled;
/// Wraps a built-in Home block with show/hide controls while editing.
class _EditableHomeBlock extends StatelessWidget {
const _EditableHomeBlock({
required this.editing,
required this.visible,
required this.onToggleVisible,
required this.child,
});
final bool editing;
final bool visible;
final ValueChanged<bool> onToggleVisible;
final Widget child;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final content = Opacity(
opacity: editing && !visible ? 0.45 : 1,
child: child,
);
if (!editing) return content;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: content),
IconButton(
tooltip: visible ? 'Hide from Home' : 'Show on Home',
onPressed: () => onToggleVisible(!visible),
icon: Icon(
visible ? Icons.visibility_outlined : Icons.visibility_off_outlined,
color: visible ? colors.onSurfaceVariant : colors.primary,
),
),
],
);
}
}
/// Full- or half-width slot for a Home filter widget card.
class _HomeWidgetSlot extends StatelessWidget {
const _HomeWidgetSlot({
required this.halfWidth,
required this.child,
});
final bool halfWidth;
final Widget child;
@override
Widget build(BuildContext context) {
if (!halfWidth) return child;
return LayoutBuilder(
builder: (context, constraints) {
final width = (constraints.maxWidth - 10) / 2;
return Align(
alignment: Alignment.centerLeft,
child: SizedBox(width: width, child: child),
);
},
);
}
}
List<Widget> _packedHomeWidgetRows(
List<HomeFilterWidget> widgets, {
Map<String, TxFilterMetrics>? statsById,
Map<String, HomeBudgetMetrics>? budgetById,
}) {
final rows = <Widget>[];
var i = 0;
while (i < widgets.length) {
final a = widgets[i];
final canPair = a.halfWidth &&
i + 1 < widgets.length &&
widgets[i + 1].halfWidth;
if (canPair) {
final b = widgets[i + 1];
rows.add(
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: HomeFilterWidgetCard(
widgetConfig: a,
precomputedStats: statsById?[a.id],
precomputedBudget: budgetById?[a.id],
),
),
const SizedBox(width: 10),
Expanded(
child: HomeFilterWidgetCard(
widgetConfig: b,
precomputedStats: statsById?[b.id],
precomputedBudget: budgetById?[b.id],
),
),
],
),
),
),
);
i += 2;
continue;
}
rows.add(
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _HomeWidgetSlot(
halfWidth: a.halfWidth,
child: HomeFilterWidgetCard(
widgetConfig: a,
precomputedStats: statsById?[a.id],
precomputedBudget: budgetById?[a.id],
),
),
),
);
i += 1;
}
return rows;
}