feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
This commit is contained in:
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user