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,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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
});
|
||||
Reference in New Issue
Block a user