feat(app): Flutter-клиент — логин, дашборд, потоки, категории, транзакции, правила
Riverpod + go_router с auth-guard, NavigationRail на широком экране и bottom bar на узком. Токены в flutter_secure_storage, на web access живёт в памяти. Интерцептор подставляет токен и делает ровно один refresh на 401. Деньги приходят строками и форматируются через Decimal: парсить их в double значило бы терять копейки ровно там, где бэкенд их бережёт.
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user