/// 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/helpers.dart'; import '../utils/navigation.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 HomeScreen extends StatefulWidget { const HomeScreen({super.key}); static const tabIndex = 0; @override State createState() => _HomeScreenState(); } class _HomeScreenState extends State { var _editingWidgets = false; @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. if (ShellTabScope.of(context) != HomeScreen.tabIndex) { return const SizedBox.expand(); } final bank = context.watch(); 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: _HomeBody( bank: bank, editingWidgets: _editingWidgets, ), ), ), ); } } class _HomeBody extends StatelessWidget { const _HomeBody({ required this.bank, required this.editingWidgets, }); final BankController bank; final bool editingWidgets; void _openTransactions(BuildContext context) { Navigator.of(context).push( MaterialPageRoute(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(); final prefs = context.watch(); 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(); final recent = bank.transactions.take(5).toList(growable: false); final 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, onToggleDisplay: (display) => prefs.setHomeFilterWidgetDisplay( widgetConfig.id, display, ), onEdit: () async { final preferences = context.read(); 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), ], 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( 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 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 _packedHomeWidgetRows(List widgets) { final rows = []; 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), ), const SizedBox(width: 10), Expanded( child: HomeFilterWidgetCard(widgetConfig: b), ), ], ), ), ), ); i += 2; continue; } rows.add( Padding( padding: const EdgeInsets.only(bottom: 10), child: _HomeWidgetSlot( halfWidth: a.halfWidth, child: HomeFilterWidgetCard(widgetConfig: a), ), ), ); i += 1; } return rows; }