look ahead
This commit is contained in:
@@ -17,23 +17,11 @@ class AccountScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = context.watch<AuthController>();
|
||||
final sync = context.watch<SyncController>();
|
||||
final meta = context.watch<TxMetaController>();
|
||||
final bank = context.watch<BankController>();
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
|
||||
final lastSync = sync.lastSyncedAt;
|
||||
final syncSubtitle = sync.isSyncing
|
||||
? (sync.statusDetail.isEmpty
|
||||
? 'Syncing… ${(sync.syncProgress * 100).round()}%'
|
||||
: '${sync.statusDetail} · ${(sync.syncProgress * 100).round()}%')
|
||||
: sync.lastError != null
|
||||
? 'Last error: ${sync.lastError}'
|
||||
: lastSync == null
|
||||
? 'Not synced yet'
|
||||
: 'Last sync ${_formatTime(lastSync)}';
|
||||
|
||||
final categorized = meta.labeledTransactionCount(bank.transactions);
|
||||
final perTxAnnotations = meta.syncableAnnotationCount;
|
||||
final viaMerchantOnly = meta.merchantOnlyLabeledCount(bank.transactions);
|
||||
@@ -83,41 +71,7 @@ class AccountScreen extends StatelessWidget {
|
||||
subtitle: Text(kPocketBaseUrl),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: sync.isSyncing
|
||||
? SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
value: sync.syncProgress <= 0
|
||||
? null
|
||||
: sync.syncProgress.clamp(0.02, 1.0),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
sync.lastError == null
|
||||
? Icons.sync
|
||||
: Icons.sync_problem_outlined,
|
||||
),
|
||||
title: const Text('Cloud sync'),
|
||||
subtitle: Text(syncSubtitle),
|
||||
trailing: TextButton(
|
||||
onPressed: sync.isSyncing ? null : () => sync.syncNow(),
|
||||
child: const Text('Sync now'),
|
||||
),
|
||||
),
|
||||
if (sync.lastSyncSummary.isNotEmpty) ...[
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.fact_check_outlined),
|
||||
title: const Text('Last sync detail'),
|
||||
subtitle: Text(sync.lastSyncSummary),
|
||||
),
|
||||
],
|
||||
const _CloudSyncTile(),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.phone_android_outlined),
|
||||
@@ -150,6 +104,72 @@ class AccountScreen extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Isolated so sync progress ticks do not rebuild account stats.
|
||||
class _CloudSyncTile extends StatelessWidget {
|
||||
const _CloudSyncTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: context.read<SyncController>(),
|
||||
builder: (context, _) {
|
||||
final sync = context.read<SyncController>();
|
||||
final lastSync = sync.lastSyncedAt;
|
||||
final syncSubtitle = sync.isSyncing
|
||||
? (sync.statusDetail.isEmpty
|
||||
? 'Syncing… ${(sync.syncProgress * 100).round()}%'
|
||||
: '${sync.statusDetail} · ${(sync.syncProgress * 100).round()}%')
|
||||
: sync.lastError != null
|
||||
? 'Last error: ${sync.lastError}'
|
||||
: lastSync == null
|
||||
? 'Not synced yet'
|
||||
: 'Last sync ${_formatTime(lastSync)}';
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: sync.isSyncing
|
||||
? SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
value: sync.syncProgress <= 0
|
||||
? null
|
||||
: sync.syncProgress.clamp(0.02, 1.0),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
sync.lastError == null
|
||||
? Icons.sync
|
||||
: Icons.sync_problem_outlined,
|
||||
),
|
||||
title: const Text('Cloud sync'),
|
||||
subtitle: Text(syncSubtitle),
|
||||
trailing: TextButton(
|
||||
onPressed: sync.isSyncing ? null : () => sync.syncNow(),
|
||||
child: const Text('Sync now'),
|
||||
),
|
||||
),
|
||||
if (sync.lastSyncSummary.isNotEmpty) ...[
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.fact_check_outlined),
|
||||
title: const Text('Last sync detail'),
|
||||
subtitle: Text(sync.lastSyncSummary),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatTime(DateTime time) {
|
||||
final local = time.toLocal();
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../utils/navigation.dart';
|
||||
import '../widgets/empty_state.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';
|
||||
@@ -20,8 +21,16 @@ import 'transactions_screen.dart';
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
static const tabIndex = 0;
|
||||
|
||||
@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) != tabIndex) {
|
||||
return const SizedBox.expand();
|
||||
}
|
||||
|
||||
final bank = context.watch<BankController>();
|
||||
|
||||
return Scaffold(
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
/// 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/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;
|
||||
var _didBackfill = false;
|
||||
List<RecurringItem>? _cachedItems;
|
||||
int? _itemsCacheKey;
|
||||
|
||||
static const _maxYearsAhead = 5;
|
||||
|
||||
@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);
|
||||
|
||||
DateTime get _maxMonth {
|
||||
final t = _today;
|
||||
return DateTime(t.year + _maxYearsAhead, t.month);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
bool get _canGoBack {
|
||||
if (_period == _PeriodMode.yearly) {
|
||||
return _cursor.year > _today.year;
|
||||
}
|
||||
return monthStart(_cursor).isAfter(_minMonth);
|
||||
}
|
||||
|
||||
bool get _canGoForward {
|
||||
if (_period == _PeriodMode.yearly) {
|
||||
return _cursor.year < _today.year + _maxYearsAhead;
|
||||
}
|
||||
return monthStart(_cursor).isBefore(_maxMonth);
|
||||
}
|
||||
|
||||
void _setPeriod(_PeriodMode mode) {
|
||||
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 + _maxYearsAhead) {
|
||||
_cursor = DateTime(_today.year + _maxYearsAhead);
|
||||
}
|
||||
} else {
|
||||
final month = DateTime(_cursor.year, _today.month);
|
||||
_cursor = month.isBefore(_minMonth) ? _minMonth : monthStart(month);
|
||||
if (_cursor.isAfter(_maxMonth)) _cursor = _maxMonth;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _go(int delta) {
|
||||
setState(() {
|
||||
if (_period == _PeriodMode.yearly) {
|
||||
final next = DateTime(_cursor.year + delta);
|
||||
if (next.year < _today.year ||
|
||||
next.year > _today.year + _maxYearsAhead) {
|
||||
return;
|
||||
}
|
||||
_cursor = next;
|
||||
} else {
|
||||
final next = addMonths(monthStart(_cursor), delta);
|
||||
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 theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
|
||||
if (!_didBackfill && bank.transactions.isNotEmpty) {
|
||||
_didBackfill = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
context
|
||||
.read<TxMetaController>()
|
||||
.ensureAutoRecurringTemplates(bank.transactions);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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: _setPeriod,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_PeriodNavigator(
|
||||
title: _periodTitle,
|
||||
canGoBack: _canGoBack,
|
||||
canGoForward: _canGoForward,
|
||||
onBack: () => _go(-1),
|
||||
onForward: () => _go(1),
|
||||
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 templates',
|
||||
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
|
||||
? 'Mark transactions as recurring, or create a template.'
|
||||
: '${items.length} template${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),
|
||||
if (items.isEmpty)
|
||||
const EmptyState(
|
||||
icon: Icons.event_repeat_outlined,
|
||||
title: 'No recurring templates',
|
||||
message:
|
||||
'Mark a transaction as recurring, or tap New to base a '
|
||||
'template on 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 template',
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+33
-22
@@ -1,9 +1,11 @@
|
||||
/// Host scaffold: Home + Overview. Settings lives in the app-bar gear.
|
||||
/// Host scaffold: Home, Overview, Look ahead. Settings lives in the app-bar gear.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../widgets/shell_tab_scope.dart';
|
||||
import 'home_screen.dart';
|
||||
import 'look_ahead_screen.dart';
|
||||
import 'overview_screen.dart';
|
||||
|
||||
class MainShell extends StatefulWidget {
|
||||
@@ -19,31 +21,40 @@ class _MainShellState extends State<MainShell> {
|
||||
static const _pages = [
|
||||
HomeScreen(),
|
||||
OverviewScreen(),
|
||||
LookAheadScreen(),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: IndexedStack(
|
||||
index: _index,
|
||||
children: _pages,
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _index,
|
||||
onDestinationSelected: (index) => setState(() => _index = index),
|
||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: 'Home',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.insights_outlined),
|
||||
selectedIcon: Icon(Icons.insights),
|
||||
label: 'Overview',
|
||||
),
|
||||
],
|
||||
return ShellTabScope(
|
||||
index: _index,
|
||||
child: Scaffold(
|
||||
body: IndexedStack(
|
||||
index: _index,
|
||||
children: _pages,
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _index,
|
||||
onDestinationSelected: (index) => setState(() => _index = index),
|
||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: 'Home',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.insights_outlined),
|
||||
selectedIcon: Icon(Icons.insights),
|
||||
label: 'Overview',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.event_repeat_outlined),
|
||||
selectedIcon: Icon(Icons.event_repeat),
|
||||
label: 'Look ahead',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import '../widgets/empty_state.dart';
|
||||
import '../widgets/label_filter_dialog.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 'label_transactions_screen.dart';
|
||||
import 'transactions_screen.dart';
|
||||
@@ -31,6 +32,8 @@ const _kExpenseColor = AppTheme.expense;
|
||||
class OverviewScreen extends StatefulWidget {
|
||||
const OverviewScreen({super.key});
|
||||
|
||||
static const tabIndex = 1;
|
||||
|
||||
@override
|
||||
State<OverviewScreen> createState() => _OverviewScreenState();
|
||||
}
|
||||
@@ -163,6 +166,10 @@ class _OverviewScreenState extends State<OverviewScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (ShellTabScope.of(context) != OverviewScreen.tabIndex) {
|
||||
return const SizedBox.expand();
|
||||
}
|
||||
|
||||
final bank = context.watch<BankController>();
|
||||
final meta = context.watch<TxMetaController>();
|
||||
|
||||
|
||||
@@ -26,11 +26,14 @@ class TransactionDetailScreen extends StatefulWidget {
|
||||
|
||||
class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
late final TextEditingController _notesController;
|
||||
late final TextEditingController _labelSearchController;
|
||||
late SimilarMatchCriteria _matchCriteria;
|
||||
late Set<String> _draftLabelIds;
|
||||
var _similarCount = 0;
|
||||
var _similarReady = false;
|
||||
var _dirty = false;
|
||||
var _labelsDirty = false;
|
||||
late bool _applyToSimilar;
|
||||
var _applyToSimilar = false;
|
||||
var _busy = false;
|
||||
String? _busyMessage;
|
||||
|
||||
@@ -40,6 +43,7 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
final meta = context.read<TxMetaController>();
|
||||
final notes = meta.metaFor(widget.tx).notes;
|
||||
_notesController = TextEditingController(text: notes);
|
||||
_labelSearchController = TextEditingController();
|
||||
final tx = widget.tx;
|
||||
_matchCriteria = SimilarMatchCriteria(
|
||||
pattern: defaultSimilarPattern(tx.description),
|
||||
@@ -50,19 +54,31 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
: TxFlowFilter.all,
|
||||
);
|
||||
_draftLabelIds = meta.metaFor(tx).labelIds.toSet();
|
||||
// Defer full-list similar scan so the detail route paints immediately.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshSimilarCount());
|
||||
}
|
||||
|
||||
void _refreshSimilarCount() {
|
||||
if (!mounted) return;
|
||||
final all = context.read<BankController>().transactions;
|
||||
final similarCount = meta.countSimilar(
|
||||
tx,
|
||||
all,
|
||||
criteria: _matchCriteria,
|
||||
);
|
||||
// Default on when there are peers — otherwise users never notice the toggle.
|
||||
_applyToSimilar = similarCount > 1;
|
||||
final count = context.read<TxMetaController>().countSimilar(
|
||||
widget.tx,
|
||||
all,
|
||||
criteria: _matchCriteria,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_similarCount = count;
|
||||
_similarReady = true;
|
||||
// Default on when there are peers — otherwise users never notice the toggle.
|
||||
if (count > 1) _applyToSimilar = true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_notesController.dispose();
|
||||
_labelSearchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -85,13 +101,10 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
if (result == null || !mounted) return;
|
||||
setState(() {
|
||||
_matchCriteria = result;
|
||||
final count = context.read<TxMetaController>().countSimilar(
|
||||
widget.tx,
|
||||
all,
|
||||
criteria: result,
|
||||
);
|
||||
if (count > 1) _applyToSimilar = true;
|
||||
_similarReady = false;
|
||||
_similarCount = 0;
|
||||
});
|
||||
_refreshSimilarCount();
|
||||
}
|
||||
|
||||
Future<void> _runBusy(
|
||||
@@ -282,17 +295,27 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
final tx = widget.tx;
|
||||
final metaController = context.watch<TxMetaController>();
|
||||
final meta = metaController.metaFor(tx);
|
||||
// Prefer read: bank data rarely changes while editing labels.
|
||||
final all = context.read<BankController>().transactions;
|
||||
final similarCount = metaController.countSimilar(
|
||||
tx,
|
||||
all,
|
||||
criteria: _matchCriteria,
|
||||
);
|
||||
final similarCount = _similarCount;
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final amountColor = tx.amount >= 0 ? AppTheme.accent : AppTheme.expense;
|
||||
final labelEntries = metaController.labelsInTreeOrder();
|
||||
final needle = _labelSearchController.text.trim().toLowerCase();
|
||||
final allLabelEntries = metaController.labelsInTreeOrder();
|
||||
final labelEntries = needle.isEmpty
|
||||
? allLabelEntries
|
||||
: () {
|
||||
final keep = <String>{};
|
||||
for (final entry in allLabelEntries) {
|
||||
if (!entry.label.name.toLowerCase().contains(needle)) continue;
|
||||
keep.add(entry.label.id);
|
||||
keep.addAll(metaController.ancestorIdsOf(entry.label.id));
|
||||
keep.addAll(metaController.descendantIdsOf(entry.label.id));
|
||||
}
|
||||
return [
|
||||
for (final entry in allLabelEntries)
|
||||
if (keep.contains(entry.label.id)) entry,
|
||||
];
|
||||
}();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -305,275 +328,325 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ResponsiveBody(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 32),
|
||||
children: [
|
||||
if (_busy) ...[
|
||||
const LinearProgressIndicator(minHeight: 2),
|
||||
if (_busyMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
|
||||
child: Text(
|
||||
_busyMessage!,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
AbsorbPointer(
|
||||
absorbing: _busy,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
formatMoney(tx.amount, tx.currency),
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
color: amountColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(tx.description, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
[
|
||||
formatIsoDate(tx.date),
|
||||
if (tx.pending) 'Pending',
|
||||
].join(' · '),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Text('Match similar', style: theme.textTheme.titleSmall),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: _busy ? null : _editMatchCriteria,
|
||||
icon: const Icon(Icons.tune, size: 18),
|
||||
label: const Text('Edit'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'“${_matchCriteria.pattern}”',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.copy_all_outlined),
|
||||
title: const Text('Include matched transactions'),
|
||||
subtitle: Text(
|
||||
similarCount <= 1
|
||||
? 'No other matches for this pattern'
|
||||
: '$similarCount matching transactions',
|
||||
),
|
||||
value: _applyToSimilar && similarCount > 1,
|
||||
onChanged: _busy || similarCount <= 1
|
||||
? null
|
||||
: (value) => setState(() => _applyToSimilar = value),
|
||||
),
|
||||
if (_applyToSimilar && similarCount > 1)
|
||||
Align(
|
||||
body: Column(
|
||||
children: [
|
||||
if (_busy) const LinearProgressIndicator(minHeight: 2),
|
||||
if (_busy && _busyMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: _busy ? null : _editMatchCriteria,
|
||||
child: Text('Review $similarCount matches'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.autorenew),
|
||||
title: const Text('Recurring'),
|
||||
subtitle: const Text('Salary, mortgage, subscriptions…'),
|
||||
value: meta.recurring,
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (value) {
|
||||
context.read<TxMetaController>().setRecurring(tx, value);
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.swap_horiz),
|
||||
title: const Text('Internal transfer'),
|
||||
subtitle: const Text(
|
||||
'Between your own accounts — excluded from income/expense',
|
||||
),
|
||||
value: meta.internalTransfer,
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (value) {
|
||||
context
|
||||
.read<TxMetaController>()
|
||||
.setInternalTransfer(tx, value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Text('Labels', style: theme.textTheme.titleSmall),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: _busy ? null : _createLabel,
|
||||
child: const Text('New'),
|
||||
),
|
||||
if (_draftLabelIds.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: _busy ? null : _clearDraftLabels,
|
||||
child: const Text('Clear'),
|
||||
child: Text(
|
||||
_busyMessage!,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colors.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colors.outlineVariant.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
),
|
||||
Expanded(
|
||||
child: ResponsiveBody(
|
||||
child: AbsorbPointer(
|
||||
absorbing: _busy,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 16),
|
||||
children: [
|
||||
for (final entry in labelEntries) ...[
|
||||
if (entry != labelEntries.first)
|
||||
Divider(
|
||||
height: 1,
|
||||
indent: 40,
|
||||
color: colors.outlineVariant.withValues(alpha: 0.45),
|
||||
Text(
|
||||
formatMoney(tx.amount, tx.currency),
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
color: amountColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(tx.description, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
[
|
||||
formatIsoDate(tx.date),
|
||||
if (tx.pending) 'Pending',
|
||||
].join(' · '),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Text('Match similar', style: theme.textTheme.titleSmall),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: _busy ? null : _editMatchCriteria,
|
||||
icon: const Icon(Icons.tune, size: 18),
|
||||
label: const Text('Edit'),
|
||||
),
|
||||
InkWell(
|
||||
onTap:
|
||||
_busy ? null : () => _toggleDraftLabel(entry.label.id),
|
||||
onLongPress:
|
||||
_busy ? null : () => _labelActions(entry.label),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
8 + entry.depth * 16.0,
|
||||
2,
|
||||
4,
|
||||
2,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (entry.depth > 0) ...[
|
||||
Icon(
|
||||
Icons.subdirectory_arrow_right,
|
||||
size: 14,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Icon(
|
||||
entry.label.icon,
|
||||
size: 18,
|
||||
color: entry.label.color,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.label.name,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: Checkbox(
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
value: _draftLabelIds.contains(entry.label.id),
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (_) =>
|
||||
_toggleDraftLabel(entry.label.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'“${_matchCriteria.pattern}”',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.copy_all_outlined),
|
||||
title: const Text('Include matched transactions'),
|
||||
subtitle: Text(
|
||||
similarCount <= 1
|
||||
? 'No other matches for this pattern'
|
||||
: '$similarCount matching transactions',
|
||||
),
|
||||
value: _applyToSimilar && similarCount > 1,
|
||||
onChanged: _busy || !_similarReady || similarCount <= 1
|
||||
? null
|
||||
: (value) => setState(() => _applyToSimilar = value),
|
||||
),
|
||||
if (_applyToSimilar && similarCount > 1)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: _busy ? null : _editMatchCriteria,
|
||||
child: Text('Review $similarCount matches'),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.autorenew),
|
||||
title: const Text('Recurring'),
|
||||
subtitle: const Text('Salary, mortgage, subscriptions…'),
|
||||
value: meta.recurring,
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (value) {
|
||||
context.read<TxMetaController>().setRecurring(tx, value);
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.swap_horiz),
|
||||
title: const Text('Internal transfer'),
|
||||
subtitle: const Text(
|
||||
'Between your own accounts — excluded from income/expense',
|
||||
),
|
||||
value: meta.internalTransfer,
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (value) {
|
||||
context
|
||||
.read<TxMetaController>()
|
||||
.setInternalTransfer(tx, value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Text('Labels', style: theme.textTheme.titleSmall),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: _busy ? null : _createLabel,
|
||||
child: const Text('New'),
|
||||
),
|
||||
if (_draftLabelIds.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: _busy ? null : _clearDraftLabels,
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _labelSearchController,
|
||||
enabled: !_busy,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
hintText: 'Filter labels',
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
suffixIcon: needle.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
onPressed: () {
|
||||
_labelSearchController.clear();
|
||||
setState(() {});
|
||||
},
|
||||
icon: const Icon(Icons.clear, size: 18),
|
||||
),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colors.outlineVariant.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
height: 280,
|
||||
child: labelEntries.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
needle.isEmpty
|
||||
? 'No labels yet'
|
||||
: 'No labels match',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: labelEntries.length,
|
||||
separatorBuilder: (_, _) => Divider(
|
||||
height: 1,
|
||||
indent: 40,
|
||||
color: colors.outlineVariant
|
||||
.withValues(alpha: 0.45),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final entry = labelEntries[index];
|
||||
return InkWell(
|
||||
onTap: _busy
|
||||
? null
|
||||
: () => _toggleDraftLabel(entry.label.id),
|
||||
onLongPress: _busy
|
||||
? null
|
||||
: () => _labelActions(entry.label),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
8 + entry.depth * 16.0,
|
||||
2,
|
||||
4,
|
||||
2,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (entry.depth > 0) ...[
|
||||
Icon(
|
||||
Icons.subdirectory_arrow_right,
|
||||
size: 14,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Icon(
|
||||
entry.label.icon,
|
||||
size: 18,
|
||||
color: entry.label.color,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.label.name,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: Checkbox(
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
value: _draftLabelIds
|
||||
.contains(entry.label.id),
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (_) => _toggleDraftLabel(
|
||||
entry.label.id,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('Notes', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _notesController,
|
||||
minLines: 3,
|
||||
maxLines: 6,
|
||||
enabled: !_busy,
|
||||
textInputAction: TextInputAction.newline,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Add a private note…',
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (!_dirty) setState(() => _dirty = true);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _dirty && !_busy ? _saveNotes : null,
|
||||
child: Text(
|
||||
_applyToSimilar && similarCount > 1
|
||||
? 'Save notes for $similarCount'
|
||||
: 'Save notes',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Sub-labels also select their parents. Unchecking a parent '
|
||||
'clears its sub-labels. Long-press a row to move or delete. '
|
||||
'Use Apply to save label changes.',
|
||||
style: theme.textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _busy || !_labelsDirty
|
||||
? null
|
||||
: () => _applyLabels(toMatched: false),
|
||||
child: const Text('Apply'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _busy ||
|
||||
!_labelsDirty ||
|
||||
!_applyToSimilar ||
|
||||
similarCount <= 1
|
||||
? null
|
||||
: () => _applyLabels(toMatched: true),
|
||||
child: Text(
|
||||
similarCount > 1
|
||||
? 'Apply to matched ($similarCount)'
|
||||
: 'Apply to matched',
|
||||
),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Material(
|
||||
elevation: 3,
|
||||
color: colors.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _busy || !_labelsDirty
|
||||
? null
|
||||
: () => _applyLabels(toMatched: false),
|
||||
child: const Text('Apply'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _busy ||
|
||||
!_labelsDirty ||
|
||||
!_applyToSimilar ||
|
||||
similarCount <= 1
|
||||
? null
|
||||
: () => _applyLabels(toMatched: true),
|
||||
child: Text(
|
||||
similarCount > 1
|
||||
? 'Apply to matched ($similarCount)'
|
||||
: 'Apply to matched',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('Notes', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _notesController,
|
||||
minLines: 3,
|
||||
maxLines: 6,
|
||||
enabled: !_busy,
|
||||
textInputAction: TextInputAction.newline,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Add a private note…',
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (!_dirty) setState(() => _dirty = true);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _dirty && !_busy ? _saveNotes : null,
|
||||
child: Text(
|
||||
_applyToSimilar && similarCount > 1
|
||||
? 'Save notes for $similarCount'
|
||||
: 'Save notes',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Sub-labels also select their parents. Unchecking a parent '
|
||||
'clears its sub-labels. Long-press a row to move or delete. '
|
||||
'Use Apply to save label changes.',
|
||||
style: theme.textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
var _showCalendar = false;
|
||||
var _flowFilter = TxFlowFilter.all;
|
||||
var _amountRange = const TxAmountRange();
|
||||
var _recurringOnly = false;
|
||||
|
||||
/// Query actually used for filtering (Enter, or live once ≥3 chars).
|
||||
String _activeQuery = '';
|
||||
@@ -96,11 +97,15 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
bool get _hasListFilters =>
|
||||
_labelFilters.isNotEmpty ||
|
||||
_flowFilter != TxFlowFilter.all ||
|
||||
_amountRange.isActive;
|
||||
_amountRange.isActive ||
|
||||
_recurringOnly;
|
||||
|
||||
bool _matchesExtraFilters(BankTransaction tx) =>
|
||||
txMatchesFlow(tx, _flowFilter) &&
|
||||
txMatchesAmountRange(tx, _amountRange);
|
||||
bool _matchesExtraFilters(BankTransaction tx, TxMetaController meta) {
|
||||
if (!txMatchesFlow(tx, _flowFilter)) return false;
|
||||
if (!txMatchesAmountRange(tx, _amountRange)) return false;
|
||||
if (_recurringOnly && !meta.metaFor(tx).recurring) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void _goMonth(int delta) {
|
||||
final next = addMonths(_month, delta);
|
||||
@@ -167,6 +172,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
amountRange: _amountRange,
|
||||
query: _activeQuery,
|
||||
monthOnly: _searchInMonthOnly,
|
||||
recurringOnly: _recurringOnly,
|
||||
),
|
||||
);
|
||||
if (result == null || !mounted) return;
|
||||
@@ -178,6 +184,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
_flowFilter = result.flow;
|
||||
_amountRange = result.amountRange;
|
||||
_searchInMonthOnly = result.monthOnly;
|
||||
_recurringOnly = result.recurringOnly;
|
||||
if (!_searchInMonthOnly) {
|
||||
_showCalendar = false;
|
||||
_selectedDay = null;
|
||||
@@ -339,7 +346,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
_activeQuery,
|
||||
labelIds: _labelFilters,
|
||||
))
|
||||
if (_matchesExtraFilters(tx)) tx,
|
||||
if (_matchesExtraFilters(tx, metaController)) tx,
|
||||
];
|
||||
|
||||
// Labels on the current result that aren't part of the active label filter.
|
||||
@@ -675,6 +682,15 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_recurringOnly)
|
||||
InputChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
avatar: const Icon(Icons.event_repeat, size: 14),
|
||||
label: const Text('Recurring'),
|
||||
onDeleted: () {
|
||||
setState(() => _recurringOnly = false);
|
||||
},
|
||||
),
|
||||
for (final id in _labelFilters)
|
||||
if (id == kUncategorizedFilterId)
|
||||
InputChip(
|
||||
|
||||
Reference in New Issue
Block a user