accounts, cashflow, categories, goals, income, portfolio (+instrument), rebalance, tax, rules переведены на Cached<T> по контракту docs/ai/offline-cache.md. Общие/второстепенные селекторы (scopesProvider, categoriesListProvider и т.п.) оставлены как есть — не основной контент экрана. sync_page.dart и settings_page.dart не тронуты (первый — заменён health-page отдельно, второй — чистые действия без списка для баннера).
214 lines
7.3 KiB
Dart
214 lines
7.3 KiB
Dart
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/cache/cached.dart';
|
||
import '../../core/widgets/async_value_view.dart';
|
||
import '../../core/widgets/empty_state.dart';
|
||
import '../../core/widgets/money_text.dart';
|
||
import '../../core/widgets/stale_banner.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);
|
||
final stale = oldestFetch([accounts.valueOrNull?.fetchedAt]);
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(title: const Text('Счета')),
|
||
body: RefreshIndicator(
|
||
onRefresh: () async => ref.invalidate(accountsProvider),
|
||
child: AsyncValueView(
|
||
value: accounts,
|
||
onRetry: () => ref.invalidate(accountsProvider),
|
||
data: (cached) {
|
||
final rows = cached.data;
|
||
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: [
|
||
if (stale != null) StaleBanner(fetchedAt: stale),
|
||
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)),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|