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:
Dmitry
2026-09-18 13:44:09 +03:00
parent b9c12fa1a1
commit ce0966fca2
99 changed files with 6284 additions and 0 deletions
@@ -0,0 +1,160 @@
import 'package:dio/dio.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
class TransactionsFilter {
const TransactionsFilter({
this.q,
this.from,
this.to,
this.accountId,
this.categoryId,
this.flowType,
});
final String? q;
final DateTime? from;
final DateTime? to;
final int? accountId;
final int? categoryId;
final FlowType? flowType;
bool get isEmpty =>
q == null && from == null && to == null && accountId == null && categoryId == null && flowType == null;
TransactionsFilter copyWith({
String? Function()? q,
DateTime? Function()? from,
DateTime? Function()? to,
int? Function()? accountId,
int? Function()? categoryId,
FlowType? Function()? flowType,
}) {
return TransactionsFilter(
q: q != null ? q() : this.q,
from: from != null ? from() : this.from,
to: to != null ? to() : this.to,
accountId: accountId != null ? accountId() : this.accountId,
categoryId: categoryId != null ? categoryId() : this.categoryId,
flowType: flowType != null ? flowType() : this.flowType,
);
}
}
class TransactionsState {
const TransactionsState({
this.items = const [],
this.page = 1,
this.pageSize = 50,
this.total = 0,
this.loading = false,
this.loadingMore = false,
this.error,
});
final List<TransactionOut> items;
final int page;
final int pageSize;
final int total;
final bool loading;
final bool loadingMore;
final Object? error;
bool get hasMore => items.length < total;
TransactionsState copyWith({
List<TransactionOut>? items,
int? page,
int? pageSize,
int? total,
bool? loading,
bool? loadingMore,
Object? error,
bool clearError = false,
}) {
return TransactionsState(
items: items ?? this.items,
page: page ?? this.page,
pageSize: pageSize ?? this.pageSize,
total: total ?? this.total,
loading: loading ?? this.loading,
loadingMore: loadingMore ?? this.loadingMore,
error: clearError ? null : (error ?? this.error),
);
}
}
/// Filters + paginated results for Операции. `refresh()` restarts from page 1
/// (called on build and on every filter change); `loadMore()` appends the
/// next page for infinite scroll / the "ещё" button.
class TransactionsController extends Notifier<TransactionsState> {
TransactionsFilter filter = const TransactionsFilter();
@override
TransactionsState build() {
Future.microtask(refresh);
return const TransactionsState(loading: true);
}
Future<void> setFilter(TransactionsFilter Function(TransactionsFilter) update) {
filter = update(filter);
return refresh();
}
Future<void> refresh() async {
state = state.copyWith(loading: true, clearError: true);
try {
final r = await ref.read(apiProvider).getTransactionsApi().transactionsList(
from: filter.from,
to: filter.to,
accountId: filter.accountId,
categoryId: filter.categoryId,
flowType: filter.flowType,
q: (filter.q == null || filter.q!.isEmpty) ? null : filter.q,
page: 1,
pageSize: state.pageSize,
);
final page = r.data!;
state = TransactionsState(
items: page.items,
page: page.page,
pageSize: page.pageSize,
total: page.total,
);
} on DioException catch (e) {
state = state.copyWith(loading: false, error: e);
}
}
Future<void> loadMore() async {
if (state.loading || state.loadingMore || !state.hasMore) return;
state = state.copyWith(loadingMore: true, clearError: true);
try {
final next = state.page + 1;
final r = await ref.read(apiProvider).getTransactionsApi().transactionsList(
from: filter.from,
to: filter.to,
accountId: filter.accountId,
categoryId: filter.categoryId,
flowType: filter.flowType,
q: (filter.q == null || filter.q!.isEmpty) ? null : filter.q,
page: next,
pageSize: state.pageSize,
);
final page = r.data!;
state = state.copyWith(
items: [...state.items, ...page.items],
page: page.page,
total: page.total,
loadingMore: false,
);
} on DioException catch (e) {
state = state.copyWith(loadingMore: false, error: e);
}
}
}
final transactionsControllerProvider =
NotifierProvider<TransactionsController, TransactionsState>(TransactionsController.new);
@@ -0,0 +1,83 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/money_text.dart';
/// The amount/currency/RUB-equivalent a transaction is shown with, chosen by
/// its [FlowType]: the outgoing leg for expenses and transfers out, the
/// incoming leg for income.
({String amount, String currency, String? rub}) primaryAmount(TransactionOut t) {
switch (t.flowType) {
case FlowType.income:
return (amount: t.income, currency: t.incomeCurrency ?? 'RUB', rub: t.incomeRub);
case FlowType.expense:
return (amount: t.outcome, currency: t.outcomeCurrency ?? 'RUB', rub: t.outcomeRub);
case FlowType.internalTransfer:
case FlowType.savingsTransfer:
case FlowType.brokerExternalFlow:
case FlowType.other:
case FlowType.deleted:
case FlowType.unknownDefaultOpenApi:
if (t.outcome != '0' && t.outcomeCurrency != null) {
return (amount: t.outcome, currency: t.outcomeCurrency!, rub: t.outcomeRub);
}
return (amount: t.income, currency: t.incomeCurrency ?? 'RUB', rub: t.incomeRub);
}
}
(IconData, Color) flowTypeIcon(FlowType type, ColorScheme scheme) => switch (type) {
FlowType.income => (Icons.arrow_circle_down_outlined, Colors.green),
FlowType.expense => (Icons.arrow_circle_up_outlined, scheme.error),
FlowType.internalTransfer => (Icons.swap_horiz, scheme.primary),
FlowType.savingsTransfer => (Icons.savings_outlined, Colors.amber.shade800),
FlowType.brokerExternalFlow => (Icons.trending_up, Colors.deepPurple),
FlowType.other || FlowType.deleted || FlowType.unknownDefaultOpenApi => (
Icons.help_outline,
scheme.outline,
),
};
/// One row of the Операции list: date, payee, category, native amount with
/// its RUB equivalent when the currency differs, and a flow-type icon.
class TransactionRow extends StatelessWidget {
const TransactionRow({
required this.transaction,
required this.categoryName,
required this.onTap,
super.key,
});
final TransactionOut transaction;
final String? categoryName;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final t = transaction;
final scheme = Theme.of(context).colorScheme;
final (icon, color) = flowTypeIcon(t.flowType, scheme);
final amount = primaryAmount(t);
final showRub = amount.currency != 'RUB' && amount.rub != null;
final payee = t.payeeCanonical?.isNotEmpty == true ? t.payeeCanonical! : (t.payee ?? '');
return ListTile(
onTap: onTap,
leading: Icon(icon, color: color),
title: Text(payee),
subtitle: Text([ruDate(t.date), ?categoryName].join(' · ')),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
MoneyText(amount.amount, currency: amount.currency),
if (showRub)
Text(
MoneyText.format(amount.rub!, 'RUB'),
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: scheme.outline),
),
],
),
);
}
}
@@ -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)),
],
),
),
],
),
),
);
}
}