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,208 @@
import 'package:dio/dio.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
import '../../core/auth/auth_controller.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/money_text.dart';
import 'providers.dart';
const _roleOrder = [AccountRole.liquid, AccountRole.savings, AccountRole.investment, AccountRole.debt];
String _roleLabel(AccountRole role) => switch (role) {
AccountRole.liquid => 'Ликвидные',
AccountRole.savings => 'Сбережения',
AccountRole.investment => 'Инвестиции',
AccountRole.debt => 'Долги',
AccountRole.unknownDefaultOpenApi => 'Неизвестно',
};
String _kindLabel(AccountKind kind) => switch (kind) {
AccountKind.zmCash => 'Наличные',
AccountKind.zmCard => 'Карта',
AccountKind.zmChecking => 'Расчётный счёт',
AccountKind.zmDeposit => 'Вклад',
AccountKind.zmLoan => 'Кредит',
AccountKind.zmEmoney => 'Электронные деньги',
AccountKind.zmDebt => 'Долг',
AccountKind.broker => 'Брокерский счёт',
AccountKind.manualAsset => 'Актив вручную',
AccountKind.unknownDefaultOpenApi => 'Неизвестно',
};
/// Счета: every account grouped by role, with in-place role and
/// include-in-net-worth edits (`PATCH /accounts/{id}`). Archived accounts
/// collapse into their own section at the bottom regardless of role.
class AccountsPage extends ConsumerWidget {
const AccountsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final accounts = ref.watch(accountsProvider);
return Scaffold(
appBar: AppBar(title: const Text('Счета')),
body: RefreshIndicator(
onRefresh: () async => ref.invalidate(accountsProvider),
child: AsyncValueView(
value: accounts,
onRetry: () => ref.invalidate(accountsProvider),
data: (rows) {
if (rows.isEmpty) {
return ListView(
children: const [
EmptyState(
icon: Icons.account_balance_outlined,
message: 'Счетов ещё нет — нужна синхронизация.',
),
],
);
}
final active = rows.where((a) => !a.archived).toList();
final archived = rows.where((a) => a.archived).toList();
return ListView(
padding: const EdgeInsets.all(16),
children: [
for (final role in _roleOrder)
if (active.any((a) => a.role == role))
_RoleSection(
title: _roleLabel(role),
accounts: active.where((a) => a.role == role).toList(),
),
if (archived.isNotEmpty)
ExpansionTile(
title: Text('Архивные (${archived.length})'),
initiallyExpanded: false,
children: [for (final a in archived) _AccountTile(account: a)],
),
],
);
},
),
),
);
}
}
class _RoleSection extends StatelessWidget {
const _RoleSection({required this.title, required this.accounts});
final String title;
final List<AccountOut> accounts;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
),
for (final a in accounts) _AccountTile(account: a),
],
),
);
}
}
class _AccountTile extends ConsumerStatefulWidget {
const _AccountTile({required this.account});
final AccountOut account;
@override
ConsumerState<_AccountTile> createState() => _AccountTileState();
}
class _AccountTileState extends ConsumerState<_AccountTile> {
bool _saving = false;
Future<void> _patch(AccountPatch patch) async {
setState(() => _saving = true);
try {
await ref.read(apiProvider).getAccountsApi().accountsPatch(
accountId: widget.account.id,
accountPatch: patch,
);
ref.invalidate(accountsProvider);
} on DioException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final a = widget.account;
return Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(a.name, style: Theme.of(context).textTheme.titleSmall),
Text(
'${_kindLabel(a.kind)} · ${a.currency}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
MoneyText(
a.balance ?? '0',
currency: a.currency,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: DropdownButtonFormField<AccountRole>(
initialValue: a.role,
isDense: true,
decoration: const InputDecoration(labelText: 'Роль', isDense: true),
items: [
for (final r in _roleOrder)
DropdownMenuItem(value: r, child: Text(_roleLabel(r))),
],
onChanged: _saving ? null : (role) {
if (role != null) _patch(AccountPatch(role: role));
},
),
),
const SizedBox(width: 12),
Column(
children: [
const Text('В капитал', style: TextStyle(fontSize: 11)),
Switch(
value: a.includeInNetWorth,
onChanged: _saving
? null
: (v) => _patch(AccountPatch(includeInNetWorth: v)),
),
],
),
],
),
],
),
),
);
}
}
+16
View File
@@ -0,0 +1,16 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
/// Every account, archived included — screens decide what to show.
final accountsProvider = FutureProvider.autoDispose<List<AccountOut>>((ref) async {
final r = await ref.watch(apiProvider).getAccountsApi().accountsList();
return r.data ?? const [];
});
/// `account_id -> name`, for screens that only carry the id (transactions, rules).
final accountNamesProvider = Provider.autoDispose<Map<int, String>>((ref) {
final accounts = ref.watch(accountsProvider).valueOrNull ?? const [];
return {for (final a in accounts) a.id: a.name};
});