ok for now
This commit is contained in:
@@ -1,20 +1,146 @@
|
||||
/// Transactions tab: recent bank movements from the ingtrak API.
|
||||
/// Searchable transaction list with month heatmap and money in/out totals.
|
||||
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 '../utils/connect_bank.dart';
|
||||
import '../utils/helpers.dart';
|
||||
import '../utils/navigation.dart';
|
||||
import '../utils/tx_calendar.dart';
|
||||
import '../widgets/empty_state.dart';
|
||||
import '../widgets/loading_view.dart';
|
||||
import '../widgets/responsive_body.dart';
|
||||
import '../widgets/tx_heatmap.dart';
|
||||
import 'transaction_detail_screen.dart';
|
||||
|
||||
class TransactionsScreen extends StatelessWidget {
|
||||
class TransactionsScreen extends StatefulWidget {
|
||||
const TransactionsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<TransactionsScreen> createState() => _TransactionsScreenState();
|
||||
}
|
||||
|
||||
class _TransactionsScreenState extends State<TransactionsScreen> {
|
||||
final _searchController = TextEditingController();
|
||||
String _query = '';
|
||||
DateTime _month = monthStart(DateTime.now());
|
||||
DateTime? _selectedDay;
|
||||
var _searchInMonthOnly = false;
|
||||
final Set<TxCategory> _categoryFilters = {};
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _isSearching =>
|
||||
_query.trim().isNotEmpty || _categoryFilters.isNotEmpty;
|
||||
|
||||
void _goMonth(int delta) {
|
||||
final next = addMonths(_month, delta);
|
||||
final current = monthStart(DateTime.now());
|
||||
if (delta > 0 && next.isAfter(current)) return;
|
||||
setState(() {
|
||||
_month = next.isAfter(current) ? current : next;
|
||||
_selectedDay = null;
|
||||
});
|
||||
}
|
||||
|
||||
DateTime? _txDate(BankTransaction tx) => DateTime.tryParse(tx.date);
|
||||
|
||||
bool _inCalendarScope(BankTransaction tx) {
|
||||
final date = _txDate(tx);
|
||||
if (date == null) return false;
|
||||
if (_selectedDay != null) return isSameDay(date, _selectedDay!);
|
||||
return isSameMonth(date, _month);
|
||||
}
|
||||
|
||||
bool _inMonth(BankTransaction tx) {
|
||||
final date = _txDate(tx);
|
||||
if (date == null) return false;
|
||||
return isSameMonth(date, _month);
|
||||
}
|
||||
|
||||
Future<void> _pickCategories(List<TxCategory> available) async {
|
||||
if (available.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No categories in use yet')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final selected = Set<TxCategory>.of(_categoryFilters);
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setSheetState) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Filter by category',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final category in available)
|
||||
FilterChip(
|
||||
avatar: Icon(
|
||||
category.icon,
|
||||
size: 16,
|
||||
color: category.color,
|
||||
),
|
||||
label: Text(category.label),
|
||||
selected: selected.contains(category),
|
||||
onSelected: (value) {
|
||||
setSheetState(() {
|
||||
if (value) {
|
||||
selected.add(category);
|
||||
} else {
|
||||
selected.remove(category);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_categoryFilters
|
||||
..clear()
|
||||
..addAll(selected);
|
||||
});
|
||||
Navigator.pop(sheetContext);
|
||||
},
|
||||
child: const Text('Apply'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bank = context.watch<BankController>();
|
||||
@@ -22,31 +148,26 @@ class TransactionsScreen extends StatelessWidget {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Transactions'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
onPressed: bank.isLoading ? null : bank.refresh,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
actions: bankAppBarActions(
|
||||
context: context,
|
||||
onRefresh: bank.refresh,
|
||||
refreshing: bank.isLoading,
|
||||
),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: bank.refresh,
|
||||
child: ResponsiveBody(
|
||||
child: _TransactionsBody(bank: bank),
|
||||
child: _buildBody(context, bank),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TransactionsBody extends StatelessWidget {
|
||||
const _TransactionsBody({required this.bank});
|
||||
Widget _buildBody(BuildContext context, BankController bank) {
|
||||
final metaController = context.watch<TxMetaController>();
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
|
||||
final BankController bank;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (bank.isLoading && bank.transactions.isEmpty) {
|
||||
return const LoadingView(message: 'Loading transactions…');
|
||||
}
|
||||
@@ -59,7 +180,7 @@ class _TransactionsBody extends StatelessWidget {
|
||||
icon: Icons.receipt_long_outlined,
|
||||
title: 'No transactions yet',
|
||||
message: bank.error ??
|
||||
'Link your bank on the Balance tab to load recent activity.',
|
||||
'Link your bank from Home to load recent activity.',
|
||||
actionLabel: bank.isConnecting ? 'Opening…' : 'Connect bank',
|
||||
onAction: () => runConnectBank(context, bank),
|
||||
),
|
||||
@@ -95,46 +216,332 @@ class _TransactionsBody extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
final nowMonth = monthStart(DateTime.now());
|
||||
final monthDates = <DateTime>[
|
||||
for (final tx in bank.transactions)
|
||||
if (_txDate(tx) != null && isSameMonth(_txDate(tx)!, _month))
|
||||
dateOnly(_txDate(tx)!),
|
||||
];
|
||||
final counts = txCountsByDay(monthDates);
|
||||
|
||||
final calendarScoped = [
|
||||
for (final tx in bank.transactions)
|
||||
if (_inCalendarScope(tx)) tx,
|
||||
];
|
||||
|
||||
final searchPool = !_isSearching
|
||||
? calendarScoped
|
||||
: _searchInMonthOnly
|
||||
? [
|
||||
for (final tx in bank.transactions)
|
||||
if (_inMonth(tx)) tx,
|
||||
]
|
||||
: bank.transactions;
|
||||
|
||||
final filtered = metaController.search(
|
||||
searchPool,
|
||||
_query,
|
||||
categories: _categoryFilters,
|
||||
);
|
||||
|
||||
var moneyOut = 0.0;
|
||||
var moneyIn = 0.0;
|
||||
for (final tx in calendarScoped) {
|
||||
if (tx.amount < 0) {
|
||||
moneyOut += tx.amount.abs();
|
||||
} else if (tx.amount > 0) {
|
||||
moneyIn += tx.amount;
|
||||
}
|
||||
}
|
||||
final currency = bank.balance?.currency ??
|
||||
(bank.transactions.isNotEmpty
|
||||
? bank.transactions.first.currency
|
||||
: 'EUR');
|
||||
final usedCategories = metaController.usedCategories(bank.transactions);
|
||||
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 32),
|
||||
itemCount: bank.transactions.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
return _TransactionTile(tx: bank.transactions[index]);
|
||||
},
|
||||
padding: const EdgeInsets.only(bottom: 32),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search description, notes…',
|
||||
prefixIcon: IconButton(
|
||||
tooltip: 'Categories',
|
||||
onPressed: () => _pickCategories(usedCategories),
|
||||
icon: Icon(
|
||||
Icons.search,
|
||||
color: _categoryFilters.isEmpty
|
||||
? null
|
||||
: colors.primary,
|
||||
),
|
||||
),
|
||||
suffixIcon: _query.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
tooltip: 'Clear',
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() => _query = '');
|
||||
},
|
||||
icon: const Icon(Icons.clear),
|
||||
),
|
||||
),
|
||||
onChanged: (value) => setState(() => _query = value),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Tooltip(
|
||||
message: 'Search only in calendar month',
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _searchInMonthOnly,
|
||||
onChanged: (value) {
|
||||
setState(() => _searchInMonthOnly = value ?? false);
|
||||
},
|
||||
),
|
||||
Text('Month', style: theme.textTheme.labelMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_categoryFilters.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final category in _categoryFilters)
|
||||
InputChip(
|
||||
avatar: Icon(category.icon, size: 16, color: category.color),
|
||||
label: Text(category.label),
|
||||
onDeleted: () {
|
||||
setState(() => _categoryFilters.remove(category));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TxHeatmap(
|
||||
month: _month,
|
||||
counts: counts,
|
||||
canGoForward: _month.isBefore(nowMonth),
|
||||
selectedDay: _selectedDay,
|
||||
onPreviousMonth: () => _goMonth(-1),
|
||||
onNextMonth: () => _goMonth(1),
|
||||
onSelectDay: (day) => setState(() => _selectedDay = day),
|
||||
),
|
||||
if (_selectedDay != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: InputChip(
|
||||
label: Text(formatDate(_selectedDay!)),
|
||||
onDeleted: () => setState(() => _selectedDay = null),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 4, 0, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TotalsCard(
|
||||
label: 'Money out',
|
||||
value: formatMoney(moneyOut, currency),
|
||||
color: colors.error,
|
||||
icon: Icons.arrow_upward,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _TotalsCard(
|
||||
label: 'Money in',
|
||||
value: formatMoney(moneyIn, currency),
|
||||
color: colors.primary,
|
||||
icon: Icons.arrow_downward,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_isSearching)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
_searchInMonthOnly
|
||||
? 'Searching in ${formatMonthYear(_month)}'
|
||||
: 'Searching all transactions',
|
||||
style: theme.textTheme.labelMedium,
|
||||
),
|
||||
),
|
||||
if (filtered.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text(
|
||||
_isSearching
|
||||
? 'No transactions match this search.'
|
||||
: _selectedDay != null
|
||||
? 'No transactions on this day.'
|
||||
: 'No transactions this month.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
)
|
||||
else
|
||||
for (var i = 0; i < filtered.length; i++) ...[
|
||||
if (i > 0)
|
||||
Divider(
|
||||
height: 1,
|
||||
color: colors.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
_CompactTransactionTile(
|
||||
tx: filtered[i],
|
||||
meta: metaController.metaFor(filtered[i]),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TransactionTile extends StatelessWidget {
|
||||
const _TransactionTile({required this.tx});
|
||||
class _TotalsCard extends StatelessWidget {
|
||||
const _TotalsCard({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.color,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
final BankTransaction tx;
|
||||
final String label;
|
||||
final String value;
|
||||
final Color color;
|
||||
final IconData icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final isCredit = tx.amount >= 0;
|
||||
final amountColor = isCredit ? colors.primary : colors.error;
|
||||
|
||||
return Card(
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
tx.description,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
[
|
||||
formatIsoDate(tx.date),
|
||||
if (tx.pending) 'Pending',
|
||||
].where((part) => part.isNotEmpty).join(' · '),
|
||||
),
|
||||
trailing: Text(
|
||||
formatMoney(tx.amount, tx.currency),
|
||||
style: theme.textTheme.titleMedium?.copyWith(color: amountColor),
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: theme.textTheme.labelMedium),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
value,
|
||||
style: theme.textTheme.titleMedium?.copyWith(color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CompactTransactionTile extends StatelessWidget {
|
||||
const _CompactTransactionTile({
|
||||
required this.tx,
|
||||
required this.meta,
|
||||
});
|
||||
|
||||
final BankTransaction tx;
|
||||
final TxMeta meta;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final amountColor = tx.amount >= 0 ? colors.primary : colors.error;
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => TransactionDetailScreen(tx: tx),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: meta.category.color.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
meta.category.icon,
|
||||
size: 18,
|
||||
color: meta.category.color,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
tx.description,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontSize: 15,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
[
|
||||
formatIsoDate(tx.date),
|
||||
if (meta.hasCategory) meta.category.label,
|
||||
if (meta.recurring) 'Recurring',
|
||||
if (meta.hasNotes) 'Note',
|
||||
if (tx.pending) 'Pending',
|
||||
].join(' · '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
formatMoney(tx.amount, tx.currency),
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: amountColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user