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 createState() => _AccountsPageState(); } class _AccountsPageState extends ConsumerState { _Status _status = _Status.all; AccountRole? _role; String _query = ''; final Set _selected = {}; final Set _busy = {}; void _snack(String message) => ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(message))); Future _apply( Iterable 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 _visible(List 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 rows; final _Status status; final AccountRole? role; final ValueChanged<_Status> onStatus; final ValueChanged onRole; final ValueChanged 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 onRole; final ValueChanged 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( 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( 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 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 onSelect; final ValueChanged onRole; final ValueChanged onNetWorth; final ValueChanged 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 onChanged; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return PopupMenuButton( 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 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), ), ], ); } }