Files
fin-tracker/app/lib/features/transactions/transactions_page.dart
T
Dmitry ce0966fca2 feat(app): Flutter-клиент — логин, дашборд, потоки, категории, транзакции, правила
Riverpod + go_router с auth-guard, NavigationRail на широком экране и bottom bar
на узком. Токены в flutter_secure_storage, на web access живёт в памяти.
Интерцептор подставляет токен и делает ровно один refresh на 401.

Деньги приходят строками и форматируются через Decimal: парсить их в double
значило бы терять копейки ровно там, где бэкенд их бережёт.
2026-09-18 13:44:09 +03:00

348 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/money_text.dart';
import '../accounts/providers.dart';
import '../categories/providers.dart';
import 'providers.dart';
import 'transaction_row.dart';
const _flowTypes = [
FlowType.income,
FlowType.expense,
FlowType.internalTransfer,
FlowType.savingsTransfer,
];
String _flowTypeLabel(FlowType t) => switch (t) {
FlowType.income => 'Доход',
FlowType.expense => 'Расход',
FlowType.internalTransfer => 'Перевод',
FlowType.savingsTransfer => 'В сбережения',
FlowType.brokerExternalFlow => 'Брокер',
FlowType.other => 'Прочее',
FlowType.deleted => 'Удалено',
FlowType.unknownDefaultOpenApi => 'Неизвестно',
};
/// Операции: filtered, paginated transaction list with infinite scroll and a
/// detail bottom sheet per row.
class TransactionsPage extends ConsumerStatefulWidget {
const TransactionsPage({super.key});
@override
ConsumerState<TransactionsPage> createState() => _TransactionsPageState();
}
class _TransactionsPageState extends ConsumerState<TransactionsPage> {
final _searchController = TextEditingController();
final _scrollController = ScrollController();
Timer? _debounce;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_debounce?.cancel();
_searchController.dispose();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels > _scrollController.position.maxScrollExtent - 200) {
ref.read(transactionsControllerProvider.notifier).loadMore();
}
}
void _onSearchChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 400), () {
ref.read(transactionsControllerProvider.notifier).setFilter(
(f) => f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()),
);
});
}
Future<void> _pickDateRange() async {
final filter = ref.read(transactionsControllerProvider.notifier).filter;
final now = DateTime.now();
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime.utc(2015),
lastDate: now,
initialDateRange: filter.from != null && filter.to != null
? DateTimeRange(start: filter.from!, end: filter.to!)
: null,
helpText: 'Период',
);
if (picked != null) {
ref.read(transactionsControllerProvider.notifier).setFilter(
(f) => f.copyWith(from: () => picked.start, to: () => picked.end),
);
}
}
void _clearDateRange() {
ref.read(transactionsControllerProvider.notifier).setFilter(
(f) => f.copyWith(from: () => null, to: () => null),
);
}
void _showDetail(TransactionOut t) {
final accountNames = ref.read(accountNamesProvider);
final categoryNames = ref.read(categoryNamesProvider);
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => _TransactionDetailSheet(
transaction: t,
accountNames: accountNames,
categoryNames: categoryNames,
),
);
}
@override
Widget build(BuildContext context) {
final state = ref.watch(transactionsControllerProvider);
final controllerFilter = ref.watch(transactionsControllerProvider.notifier).filter;
final categoryNames = ref.watch(categoryNamesProvider);
final accounts = ref.watch(accountsProvider).valueOrNull ?? const <AccountOut>[];
final categories = ref.watch(categoriesListProvider).valueOrNull ?? const <CategoryOut>[];
return Scaffold(
appBar: AppBar(title: const Text('Операции')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _searchController,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.search),
hintText: 'Поиск по плательщику или комментарию',
isDense: true,
border: OutlineInputBorder(),
),
onChanged: _onSearchChanged,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
InputChip(
avatar: const Icon(Icons.date_range, size: 18),
label: Text(
controllerFilter.from != null && controllerFilter.to != null
? '${ruDate(controllerFilter.from!)} ${ruDate(controllerFilter.to!)}'
: 'Период',
),
onPressed: _pickDateRange,
onDeleted: controllerFilter.from != null ? _clearDateRange : null,
),
SizedBox(
width: 180,
child: DropdownButtonFormField<int?>(
initialValue: controllerFilter.accountId,
isDense: true,
decoration: const InputDecoration(labelText: 'Счёт', isDense: true),
items: [
const DropdownMenuItem(value: null, child: Text('Все счета')),
for (final a in accounts) DropdownMenuItem(value: a.id, child: Text(a.name)),
],
onChanged: (v) => ref.read(transactionsControllerProvider.notifier).setFilter(
(f) => f.copyWith(accountId: () => v),
),
),
),
SizedBox(
width: 180,
child: DropdownButtonFormField<int?>(
initialValue: controllerFilter.categoryId,
isDense: true,
decoration: const InputDecoration(labelText: 'Категория', isDense: true),
items: [
const DropdownMenuItem(value: null, child: Text('Все категории')),
for (final c in categories) DropdownMenuItem(value: c.id, child: Text(c.name)),
],
onChanged: (v) => ref.read(transactionsControllerProvider.notifier).setFilter(
(f) => f.copyWith(categoryId: () => v),
),
),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
ChoiceChip(
label: const Text('Все типы'),
selected: controllerFilter.flowType == null,
onSelected: (_) => ref.read(transactionsControllerProvider.notifier).setFilter(
(f) => f.copyWith(flowType: () => null),
),
),
for (final ft in _flowTypes)
ChoiceChip(
label: Text(_flowTypeLabel(ft)),
selected: controllerFilter.flowType == ft,
onSelected: (_) => ref.read(transactionsControllerProvider.notifier).setFilter(
(f) => f.copyWith(flowType: () => ft),
),
),
],
),
],
),
),
const Divider(height: 1),
Expanded(child: _buildList(state, categoryNames)),
],
),
);
}
Widget _buildList(TransactionsState state, Map<int, String> categoryNames) {
if (state.loading && state.items.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.error != null && state.items.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error, size: 32),
const SizedBox(height: 8),
Text('${state.error}', textAlign: TextAlign.center),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: () => ref.read(transactionsControllerProvider.notifier).refresh(),
child: const Text('Повторить'),
),
],
),
),
);
}
if (state.items.isEmpty) {
return const EmptyState(
icon: Icons.receipt_long_outlined,
message: 'Операций не найдено — попробуйте изменить фильтры или выполните синхронизацию.',
);
}
return RefreshIndicator(
onRefresh: () => ref.read(transactionsControllerProvider.notifier).refresh(),
child: ListView.builder(
controller: _scrollController,
itemCount: state.items.length + 1,
itemBuilder: (context, index) {
if (index == state.items.length) {
if (!state.hasMore) return const SizedBox(height: 24);
return Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: state.loadingMore
? const CircularProgressIndicator()
: TextButton(
onPressed: () => ref.read(transactionsControllerProvider.notifier).loadMore(),
child: const Text('Ещё'),
),
),
);
}
final t = state.items[index];
return TransactionRow(
transaction: t,
categoryName: t.categoryId != null ? categoryNames[t.categoryId] : null,
onTap: () => _showDetail(t),
);
},
),
);
}
}
class _TransactionDetailSheet extends StatelessWidget {
const _TransactionDetailSheet({
required this.transaction,
required this.accountNames,
required this.categoryNames,
});
final TransactionOut transaction;
final Map<int, String> accountNames;
final Map<int, String> categoryNames;
@override
Widget build(BuildContext context) {
final t = transaction;
final rows = <(String, String)>[
('Дата', ruDate(t.date)),
('Плательщик', t.payee ?? '—'),
if (t.payeeCanonical != null && t.payeeCanonical != t.payee) ('Канонический', t.payeeCanonical!),
('Комментарий', t.comment ?? '—'),
('Категория', t.categoryId != null ? (categoryNames[t.categoryId] ?? '#${t.categoryId}') : 'Без категории'),
('Тип', _flowTypeLabel(t.flowType)),
if (t.outcome != '0')
('Списание', '${MoneyText.format(t.outcome, t.outcomeCurrency ?? 'RUB')}'
'${t.outcomeRub != null ? ' (${MoneyText.format(t.outcomeRub!, 'RUB')})' : ''}'),
if (t.income != '0')
('Зачисление', '${MoneyText.format(t.income, t.incomeCurrency ?? 'RUB')}'
'${t.incomeRub != null ? ' (${MoneyText.format(t.incomeRub!, 'RUB')})' : ''}'),
if (t.outcomeAccountId != null)
('Счёт списания', accountNames[t.outcomeAccountId] ?? '#${t.outcomeAccountId}'),
if (t.incomeAccountId != null)
('Счёт зачисления', accountNames[t.incomeAccountId] ?? '#${t.incomeAccountId}'),
if (t.mcc != null) ('MCC', '${t.mcc}'),
('Удержание (hold)', t.hold ? 'да' : 'нет'),
('Разовая трата', t.isOneOff ? 'да' : 'нет'),
if (t.tags.isNotEmpty) ('Теги', t.tags.map((id) => categoryNames[id] ?? '#$id').join(', ')),
if (t.tripId != null) ('Поездка', '#${t.tripId}'),
('Источник (id)', t.sourceId),
];
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Операция', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
for (final (label, value) in rows)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 140, child: Text(label, style: Theme.of(context).textTheme.bodySmall)),
Expanded(child: Text(value)),
],
),
),
],
),
),
);
}
}