Files
fin-tracker/app/lib/features/accounts/accounts_page.dart
T
Dmitry 62d36aa3e8 feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
2026-09-19 22:14:21 +03:00

767 lines
25 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:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/cache/cached.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/help_tip.dart';
import '../../core/widgets/money_text.dart';
import '../../core/widgets/stale_banner.dart';
import 'account_create_dialog.dart';
import 'actions.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 accountKindLabel(AccountKind kind) => switch (kind) {
AccountKind.zmCash => 'Наличные',
AccountKind.zmCard => 'Карта',
AccountKind.zmChecking => 'Расчётный счёт',
AccountKind.zmDeposit => 'Вклад',
AccountKind.zmLoan => 'Кредит',
AccountKind.zmEmoney => 'Электронные деньги',
AccountKind.zmDebt => 'Долг',
AccountKind.broker => 'Брокерский счёт',
AccountKind.manualAsset => 'Актив вручную',
AccountKind.unknownDefaultOpenApi => 'Неизвестно',
};
/// Where an account comes from — the thing that tells a ZenMoney mirror from the broker
/// account it mirrors when both are called «ИИС».
String _sourceLabel(String source) => switch (source) {
'zenmoney' => 'ZenMoney',
'tinvest' => 'T-Invest',
'report_sber' => 'Сбер, отчёты',
'report_vtb' => 'ВТБ, отчёты',
'csv' => 'CSV',
_ => source,
};
enum _Status {
all('Все'),
active('Активные'),
disabled('Отключённые'),
archived('Архивные');
const _Status(this.label);
final String label;
bool matches(AccountOut a) => switch (this) {
_Status.all => true,
_Status.active => !a.archived && !a.disabled,
_Status.disabled => a.disabled,
_Status.archived => a.archived && !a.disabled,
};
}
/// Below this width a row is two lines instead of one.
const _wideRow = 880.0;
/// Счета: every account in one compact list. The two switches that matter — «Активен» and
/// «В капитал» — and the account type sit on the row itself, and a selection turns them into
/// bulk actions: switching off a dozen old cards is one selection, not a dozen taps.
class AccountsPage extends ConsumerStatefulWidget {
const AccountsPage({super.key});
@override
ConsumerState<AccountsPage> createState() => _AccountsPageState();
}
class _AccountsPageState extends ConsumerState<AccountsPage> {
_Status _status = _Status.all;
AccountRole? _role;
String _query = '';
final Set<int> _selected = {};
final Set<int> _busy = {};
void _snack(String message) =>
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
Future<void> _apply(
Iterable<int> ids,
AccountPatch patch, {
bool clearSelection = false,
}) async {
final list = ids.toList();
setState(() => _busy.addAll(list));
final error = await ref.read(accountActionsProvider).patch(list, patch);
if (!mounted) return;
setState(() {
_busy.removeAll(list);
if (clearSelection) _selected.clear();
});
if (error != null) _snack(error);
}
List<AccountOut> _visible(List<AccountOut> rows) {
final q = _query.trim().toLowerCase();
int rank(AccountOut a) => a.archived ? 2 : (a.disabled ? 1 : 0);
final out =
[
for (final a in rows)
if (_status.matches(a) &&
(_role == null || a.role == _role) &&
(q.isEmpty ||
a.name.toLowerCase().contains(q) ||
accountKindLabel(a.kind).toLowerCase().contains(q) ||
_sourceLabel(a.source_).toLowerCase().contains(q)))
a,
]..sort((a, b) {
final byRank = rank(a).compareTo(rank(b));
if (byRank != 0) return byRank;
final byRole = _roleOrder
.indexOf(a.role)
.compareTo(_roleOrder.indexOf(b.role));
return byRole != 0 ? byRole : a.name.compareTo(b.name);
});
return out;
}
@override
Widget build(BuildContext context) {
final accounts = ref.watch(accountsProvider);
final stale = oldestFetch([accounts.valueOrNull?.fetchedAt]);
return Scaffold(
appBar: AppBar(
title: const Text('Счета'),
actions: [
IconButton(
tooltip: 'Новый брокерский счёт',
icon: const Icon(Icons.add),
onPressed: () => showAccountCreateDialog(context),
),
IconButton(
tooltip: 'Портфели',
icon: const Icon(Icons.pie_chart_outline),
onPressed: () => context.go('/portfolios'),
),
],
),
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: 'Счетов ещё нет — нужна синхронизация.',
),
],
);
}
// a selection can outlive its account (a sync archived it, a filter hid it)
_selected.retainAll({for (final a in rows) a.id});
final visible = _visible(rows);
return LayoutBuilder(
builder: (context, constraints) {
final wide = constraints.maxWidth >= _wideRow;
return ListView(
padding: const EdgeInsets.all(16),
children: [
if (stale != null) StaleBanner(fetchedAt: stale),
_Filters(
rows: rows,
status: _status,
role: _role,
onStatus: (s) => setState(() => _status = s),
onRole: (r) => setState(() => _role = r),
onQuery: (q) => setState(() => _query = q),
),
const SizedBox(height: 12),
if (_selected.isNotEmpty) ...[
_BulkBar(
count: _selected.length,
busy: _busy.isNotEmpty,
onEnable: () => _apply(
_selected,
AccountPatch(disabled: false),
clearSelection: true,
),
onDisable: () => _apply(
_selected,
AccountPatch(disabled: true),
clearSelection: true,
),
onRole: (r) => _apply(
_selected,
AccountPatch(role: r),
clearSelection: true,
),
onNetWorth: (v) => _apply(
_selected,
AccountPatch(includeInNetWorth: v),
clearSelection: true,
),
onClear: () => setState(_selected.clear),
),
const SizedBox(height: 12),
],
Card(
child: visible.isEmpty
? const Padding(
padding: EdgeInsets.all(32),
child: Center(child: Text('Ничего не найдено')),
)
: Column(
children: [
if (wide)
_HeaderRow(
allSelected: visible.every(
(a) => _selected.contains(a.id),
),
onToggleAll: (on) => setState(() {
if (on) {
_selected.addAll(
visible.map((a) => a.id),
);
} else {
_selected.removeAll(
visible.map((a) => a.id),
);
}
}),
),
for (final a in visible)
_AccountRow(
account: a,
wide: wide,
selected: _selected.contains(a.id),
busy: _busy.contains(a.id),
onSelect: (on) => setState(() {
if (on) {
_selected.add(a.id);
} else {
_selected.remove(a.id);
}
}),
onRole: (r) =>
_apply([a.id], AccountPatch(role: r)),
onNetWorth: (v) => _apply([
a.id,
], AccountPatch(includeInNetWorth: v)),
onActive: (v) => _apply([
a.id,
], AccountPatch(disabled: !v)),
),
],
),
),
],
);
},
);
},
),
),
);
}
}
class _Filters extends StatelessWidget {
const _Filters({
required this.rows,
required this.status,
required this.role,
required this.onStatus,
required this.onRole,
required this.onQuery,
});
final List<AccountOut> rows;
final _Status status;
final AccountRole? role;
final ValueChanged<_Status> onStatus;
final ValueChanged<AccountRole?> onRole;
final ValueChanged<String> onQuery;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SizedBox(
width: 260,
child: TextField(
onChanged: onQuery,
decoration: const InputDecoration(
hintText: 'Найти счёт…',
isDense: true,
prefixIcon: Icon(Icons.search, size: 20),
),
),
),
for (final s in _Status.values)
ChoiceChip(
label: Text('${s.label} ${rows.where(s.matches).length}'),
selected: s == status,
onSelected: (_) => onStatus(s),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final r in _roleOrder)
FilterChip(
label: Text(_roleLabel(r)),
selected: role == r,
onSelected: (on) => onRole(on ? r : null),
),
],
),
],
);
}
}
/// «Выбрано N» with what can be done to all of them at once.
class _BulkBar extends StatelessWidget {
const _BulkBar({
required this.count,
required this.busy,
required this.onEnable,
required this.onDisable,
required this.onRole,
required this.onNetWorth,
required this.onClear,
});
final int count;
final bool busy;
final VoidCallback onEnable;
final VoidCallback onDisable;
final ValueChanged<AccountRole> onRole;
final ValueChanged<bool> onNetWorth;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Card(
color: scheme.primary.withValues(alpha: 0.14),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
'Выбрано: $count',
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(width: 8),
TextButton.icon(
onPressed: busy ? null : onDisable,
icon: const Icon(Icons.toggle_off_outlined),
label: const Text('Отключить'),
),
TextButton.icon(
onPressed: busy ? null : onEnable,
icon: const Icon(Icons.toggle_on_outlined),
label: const Text('Включить'),
),
PopupMenuButton<AccountRole>(
enabled: !busy,
tooltip: 'Сменить тип',
onSelected: onRole,
itemBuilder: (_) => [
for (final r in _roleOrder)
PopupMenuItem(value: r, child: Text(_roleLabel(r))),
],
child: const _MenuLabel(icon: Icons.label_outline, text: 'Тип'),
),
PopupMenuButton<bool>(
enabled: !busy,
tooltip: 'Учёт в капитале',
onSelected: onNetWorth,
itemBuilder: (_) => const [
PopupMenuItem(value: true, child: Text('Учитывать в капитале')),
PopupMenuItem(
value: false,
child: Text('Не учитывать в капитале'),
),
],
child: const _MenuLabel(
icon: Icons.account_balance_wallet_outlined,
text: 'В капитал',
),
),
TextButton(onPressed: onClear, child: const Text('Снять выбор')),
],
),
),
);
}
}
class _MenuLabel extends StatelessWidget {
const _MenuLabel({required this.icon, required this.text});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.primary;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 8),
Text(
text,
style: TextStyle(color: color, fontWeight: FontWeight.w600),
),
Icon(Icons.arrow_drop_down, size: 18, color: color),
],
),
);
}
}
const _typeWidth = 156.0;
const _amountWidth = 150.0;
const _switchWidth = 96.0;
class _HeaderRow extends StatelessWidget {
const _HeaderRow({required this.allSelected, required this.onToggleAll});
final bool allSelected;
final ValueChanged<bool> onToggleAll;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final style = Theme.of(context).textTheme.labelMedium
?.copyWith(color: scheme.onSurfaceVariant);
return Container(
padding: const EdgeInsets.fromLTRB(8, 4, 16, 4),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: scheme.outlineVariant)),
),
child: Row(
children: [
Checkbox(
value: allSelected,
onChanged: (v) => onToggleAll(v ?? false),
),
Expanded(child: Text('Счёт', style: style)),
SizedBox(
width: _typeWidth,
child: TermLabel('Тип', style: style),
),
SizedBox(
width: _amountWidth,
child: Text(
'Баланс / стоимость',
textAlign: TextAlign.end,
style: style,
),
),
SizedBox(
width: _switchWidth,
child: TermLabel(
'В капитал',
style: style,
alignment: MainAxisAlignment.center,
textAlign: TextAlign.center,
),
),
SizedBox(
width: _switchWidth,
child: TermLabel(
'Активен',
style: style,
alignment: MainAxisAlignment.center,
textAlign: TextAlign.center,
),
),
],
),
);
}
}
class _AccountRow extends StatelessWidget {
const _AccountRow({
required this.account,
required this.wide,
required this.selected,
required this.busy,
required this.onSelect,
required this.onRole,
required this.onNetWorth,
required this.onActive,
});
final AccountOut account;
final bool wide;
final bool selected;
final bool busy;
final ValueChanged<bool> onSelect;
final ValueChanged<AccountRole> onRole;
final ValueChanged<bool> onNetWorth;
final ValueChanged<bool> onActive;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final a = account;
final isBroker = a.kind == AccountKind.broker;
// a broker account has no balance: what it is worth comes from the ledger valuation
final amount = isBroker ? a.valueRub : a.balance;
final dim = a.disabled || a.archived;
final title = Opacity(
opacity: dim ? 0.55 : 1,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
a.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w500),
),
Text(
[
accountKindLabel(a.kind),
a.currency,
_sourceLabel(a.source_),
if (a.archived) 'в архиве',
].join(' · '),
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
);
final money = Opacity(
opacity: dim ? 0.55 : 1,
child: amount == null
? Text('—', style: theme.textTheme.titleSmall)
: MoneyText(
amount,
currency: isBroker ? 'RUB' : a.currency,
style: theme.textTheme.titleSmall,
),
);
final role = _RoleChip(role: a.role, enabled: !busy, onChanged: onRole);
final netWorth = _SwitchCell(
label: 'В капитал',
value: a.includeInNetWorth,
enabled: !busy,
onChanged: onNetWorth,
showLabel: !wide,
);
final active = _SwitchCell(
label: 'Активен',
value: !a.disabled,
enabled: !busy,
onChanged: onActive,
showLabel: !wide,
);
return Container(
decoration: BoxDecoration(
color: selected ? scheme.primary.withValues(alpha: 0.08) : null,
border: Border(bottom: BorderSide(color: scheme.outlineVariant)),
),
child: InkWell(
onTap: () => onSelect(!selected),
child: Padding(
padding: EdgeInsets.fromLTRB(8, wide ? 8 : 6, 16, wide ? 8 : 6),
child: wide
? Row(
children: [
Checkbox(
value: selected,
onChanged: (v) => onSelect(v ?? false),
),
Expanded(child: title),
SizedBox(
width: _typeWidth,
child: Align(
alignment: Alignment.centerLeft,
child: role,
),
),
SizedBox(
width: _amountWidth,
child: Align(
alignment: Alignment.centerRight,
child: money,
),
),
SizedBox(
width: _switchWidth,
child: Center(child: netWorth),
),
SizedBox(
width: _switchWidth,
child: Center(child: active),
),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Checkbox(
value: selected,
visualDensity: VisualDensity.compact,
onChanged: (v) => onSelect(v ?? false),
),
Expanded(child: title),
money,
],
),
Padding(
padding: const EdgeInsets.only(left: 40),
child: Wrap(
spacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [role, netWorth, active],
),
),
],
),
),
),
);
}
}
/// The account type as a small pill that opens the four choices — one tap and one pick
/// instead of a form-field dropdown per row.
class _RoleChip extends StatelessWidget {
const _RoleChip({
required this.role,
required this.enabled,
required this.onChanged,
});
final AccountRole role;
final bool enabled;
final ValueChanged<AccountRole> onChanged;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return PopupMenuButton<AccountRole>(
enabled: enabled,
tooltip: 'Тип счёта',
onSelected: (r) {
if (r != role) onChanged(r);
},
itemBuilder: (_) => [
for (final r in _roleOrder)
CheckedPopupMenuItem(
value: r,
checked: r == role,
child: Text(_roleLabel(r)),
),
],
child: Container(
padding: const EdgeInsets.fromLTRB(12, 6, 6, 6),
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
_roleLabel(role),
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13),
),
),
Icon(
Icons.arrow_drop_down,
size: 18,
color: scheme.onSurfaceVariant,
),
],
),
),
);
}
}
class _SwitchCell extends StatelessWidget {
const _SwitchCell({
required this.label,
required this.value,
required this.enabled,
required this.onChanged,
required this.showLabel,
});
final String label;
final bool value;
final bool enabled;
final ValueChanged<bool> onChanged;
final bool showLabel;
@override
Widget build(BuildContext context) {
final sw = Semantics(
label: label,
child: Switch(value: value, onChanged: enabled ? onChanged : null),
);
if (!showLabel) return sw;
// on a phone both switches and the type pill have to share one line, so the switch is
// drawn at 80 % and keeps a tap area of its own
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: Theme.of(context).textTheme.bodySmall),
SizedBox(
width: 40,
height: 32,
child: Transform.scale(scale: 0.75, child: sw),
),
],
);
}
}