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 createState() => _TransactionsPageState(); } class _TransactionsPageState extends ConsumerState { 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 _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?.data ?? const []; final categories = ref.watch(categoriesListProvider).valueOrNull ?? const []; 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( 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( 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 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 accountNames; final Map 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)), ], ), ), ], ), ), ); } }