Files
fin-tracker/app/lib/features/accounts/accounts_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

209 lines
7.1 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 '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)),
),
],
),
],
),
],
),
),
);
}
}