feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события

Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
This commit is contained in:
Dmitry
2026-09-19 22:14:21 +03:00
parent a559d6de3e
commit 62d36aa3e8
73 changed files with 6406 additions and 1192 deletions
+688 -135
View File
@@ -1,53 +1,159 @@
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 'package:go_router/go_router.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/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];
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 => 'Неизвестно',
};
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 => 'Неизвестно',
};
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 => 'Неизвестно',
};
/// Счета: 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 {
/// 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
Widget build(BuildContext context, WidgetRef ref) {
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('Счета')),
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(
@@ -65,25 +171,107 @@ class AccountsPage extends ConsumerWidget {
],
);
}
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(),
// 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),
),
if (archived.isNotEmpty)
ExpansionTile(
title: Text('Архивные (${archived.length})'),
initiallyExpanded: false,
children: [for (final a in archived) _AccountTile(account: a)],
),
],
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)),
),
],
),
),
],
);
},
);
},
),
@@ -92,118 +280,445 @@ class AccountsPage extends ConsumerWidget {
}
}
class _RoleSection extends StatelessWidget {
const _RoleSection({required this.title, required this.accounts});
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 String title;
final List<AccountOut> accounts;
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.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
Icon(icon, size: 18, color: color),
const SizedBox(width: 8),
Text(
text,
style: TextStyle(color: color, fontWeight: FontWeight.w600),
),
for (final a in accounts) _AccountTile(account: a),
Icon(Icons.arrow_drop_down, size: 18, color: color),
],
),
);
}
}
class _AccountTile extends ConsumerStatefulWidget {
const _AccountTile({required this.account});
const _typeWidth = 156.0;
const _amountWidth = 150.0;
const _switchWidth = 96.0;
final AccountOut account;
class _HeaderRow extends StatelessWidget {
const _HeaderRow({required this.allSelected, required this.onToggleAll});
@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);
}
}
final bool allSelected;
final ValueChanged<bool> onToggleAll;
@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,
),
],
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,
),
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(
),
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: [
const Text('В капитал', style: TextStyle(fontSize: 11)),
Switch(
value: a.includeInNetWorth,
onChanged: _saving
? null
: (v) => _patch(AccountPatch(includeInNetWorth: v)),
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,
),
],
),
@@ -211,3 +726,41 @@ class _AccountTileState extends ConsumerState<_AccountTile> {
);
}
}
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),
),
],
);
}
}