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
@@ -0,0 +1,171 @@
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 'providers.dart';
/// The brokers whose accounts are created by hand: T-Invest accounts come from the sync.
const _brokerChoices = [
(Broker.sber, 'Сбер'),
(Broker.vtb, 'ВТБ'),
(Broker.other, 'Другой (CSV)'),
];
/// Maps the import contract's broker key (`sber | vtb | csv`) onto a creatable broker.
Broker? brokerFromImportKey(String? key) => switch (key) {
'sber' => Broker.sber,
'vtb' => Broker.vtb,
'csv' => Broker.other,
_ => null,
};
/// Asks for the details of a broker account and creates it. Returns the new account, or null
/// when cancelled or when the server refused (the reason is shown in the dialog).
Future<AccountOut?> showAccountCreateDialog(
BuildContext context, {
Broker? broker,
String? sourceId,
String? name,
}) => showDialog<AccountOut>(
context: context,
builder: (_) =>
_AccountCreateDialog(broker: broker, sourceId: sourceId, name: name),
);
class _AccountCreateDialog extends ConsumerStatefulWidget {
const _AccountCreateDialog({this.broker, this.sourceId, this.name});
final Broker? broker;
final String? sourceId;
final String? name;
@override
ConsumerState<_AccountCreateDialog> createState() =>
_AccountCreateDialogState();
}
class _AccountCreateDialogState extends ConsumerState<_AccountCreateDialog> {
final _formKey = GlobalKey<FormState>();
late final _name = TextEditingController(text: widget.name ?? '');
late final _sourceId = TextEditingController(text: widget.sourceId ?? '');
final _currency = TextEditingController(text: 'RUB');
late Broker _broker = widget.broker ?? Broker.sber;
bool _saving = false;
String? _error;
@override
void dispose() {
_name.dispose();
_sourceId.dispose();
_currency.dispose();
super.dispose();
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() {
_saving = true;
_error = null;
});
try {
final r = await ref
.read(apiProvider)
.getAccountsApi()
.accountsCreate(
accountCreate: AccountCreate(
name: _name.text.trim(),
broker: _broker,
sourceId: _sourceId.text.trim(),
currency: _currency.text.trim().toUpperCase(),
),
);
ref.invalidate(accountsProvider);
if (mounted) Navigator.of(context).pop(r.data);
} on DioException catch (e) {
if (mounted) {
setState(() {
_saving = false;
_error = problemMessage(e);
});
}
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Новый брокерский счёт'),
content: SizedBox(
width: 420,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _name,
decoration: const InputDecoration(labelText: 'Название'),
validator: (v) =>
(v ?? '').trim().isEmpty ? 'Обязательное поле' : null,
),
const SizedBox(height: 12),
DropdownButtonFormField<Broker>(
initialValue: _broker,
decoration: const InputDecoration(labelText: 'Брокер'),
items: [
for (final (b, label) in _brokerChoices)
DropdownMenuItem(value: b, child: Text(label)),
],
onChanged: (b) => setState(() => _broker = b ?? _broker),
),
const SizedBox(height: 12),
TextFormField(
controller: _sourceId,
decoration: const InputDecoration(
labelText: 'Номер договора',
helperText: 'Как напечатан в отчёте брокера — по нему импорт находит счёт',
helperMaxLines: 2,
),
validator: (v) =>
(v ?? '').trim().isEmpty ? 'Обязательное поле' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _currency,
textCapitalization: TextCapitalization.characters,
decoration: const InputDecoration(labelText: 'Валюта'),
validator: (v) => (v ?? '').trim().length == 3
? null
: 'Три буквы, например RUB',
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(
_error!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
],
],
),
),
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
FilledButton(
onPressed: _saving ? null : _save,
child: const Text('Создать'),
),
],
);
}
}
+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),
),
],
);
}
}
+63
View File
@@ -0,0 +1,63 @@
import 'package:dio/dio.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
import '../../core/auth/auth_controller.dart';
import '../portfolio/providers.dart' show scopesProvider;
import '../portfolios/providers.dart' show portfolioListProvider;
import '../rebalance/providers.dart' show portfoliosProvider;
import 'providers.dart';
/// Changes to accounts: one place for a single switch and for a bulk edit.
abstract class AccountActions {
/// Applies [patch] to every account in [ids]. Returns null on success, or a message naming
/// how many failed and why. One failure does not stop the rest: a bulk «отключить» that
/// stalls on the 20th of 30 accounts would leave the list half changed and silent.
Future<String?> patch(Iterable<int> ids, AccountPatch patch);
}
final accountActionsProvider = Provider<AccountActions>(
(ref) => _ApiAccountActions(ref),
);
class _ApiAccountActions implements AccountActions {
_ApiAccountActions(this._ref);
final Ref _ref;
@override
Future<String?> patch(Iterable<int> ids, AccountPatch patch) async {
final list = ids.toList();
final api = _ref.read(apiProvider).getAccountsApi();
var failed = 0;
String? reason;
for (final id in list) {
try {
await api.accountsPatch(accountId: id, accountPatch: patch);
} on DioException catch (e) {
failed++;
reason ??= problemMessage(e);
}
}
_ref.invalidate(accountsProvider);
if (patch.disabled != null ||
patch.includeInNetWorth != null ||
patch.role != null) {
// net worth, scopes and portfolios all follow these switches: refetch what is derived
// from them and rebuild the metrics once, however many accounts changed
_ref
..invalidate(scopesProvider)
..invalidate(portfolioListProvider)
..invalidate(portfoliosProvider);
try {
await _ref.read(apiProvider).getMetricsApi().metricsRefresh();
} on DioException {
// the next scheduled or manual refresh picks the change up
}
}
return failed == 0
? null
: 'Не удалось изменить $failed из ${list.length}: $reason';
}
}
+7 -2
View File
@@ -7,9 +7,14 @@ import '../../core/cache/cached.dart';
/// Every account, archived included — screens decide what to show. See
/// `docs/ai/offline-cache.md`: Счета reads `.data` and shows the offline banner;
/// `accountNamesProvider` and the other consumers (Транзакции, События) just unwrap `.data`.
final accountsProvider = FutureProvider.autoDispose<Cached<List<AccountOut>>>((ref) async {
final accountsProvider = FutureProvider.autoDispose<Cached<List<AccountOut>>>((
ref,
) async {
final r = await ref.watch(apiProvider).getAccountsApi().accountsList();
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?);
return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
});
/// `account_id -> name`, for screens that only carry the id (transactions, rules).
+6 -1
View File
@@ -42,7 +42,12 @@ class EventRow extends StatelessWidget {
leading: Icon(icon, color: color),
title: Row(
children: [
Flexible(child: Text(eventTitle(e.kind, e.ticker), overflow: TextOverflow.ellipsis)),
Flexible(
child: Text(
eventTitle(e.kind, e.ticker),
overflow: TextOverflow.ellipsis,
),
),
if (e.externalFlow) ...[
const SizedBox(width: 6),
Tooltip(
+180 -39
View File
@@ -1,17 +1,22 @@
import 'dart:async';
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/utils/ru_date.dart';
import '../../core/widgets/help_tip.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/money_text.dart';
import '../accounts/providers.dart';
import '../portfolio/labels.dart' show formatQty, signColor;
import 'event_row.dart';
import 'labels.dart';
import 'manual_event_dialog.dart';
import 'providers.dart';
/// События: the broker ledger with filters and infinite scroll — the screen that answers
@@ -44,7 +49,8 @@ class _EventsPageState extends ConsumerState<EventsPage> {
}
void _onScroll() {
if (_scrollController.position.pixels > _scrollController.position.maxScrollExtent - 200) {
if (_scrollController.position.pixels >
_scrollController.position.maxScrollExtent - 200) {
ref.read(eventsControllerProvider.notifier).loadMore();
}
}
@@ -52,8 +58,11 @@ class _EventsPageState extends ConsumerState<EventsPage> {
void _onSearchChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 400), () {
ref.read(eventsControllerProvider.notifier).setFilter(
(f) => f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()),
ref
.read(eventsControllerProvider.notifier)
.setFilter(
(f) =>
f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()),
);
});
}
@@ -71,16 +80,79 @@ class _EventsPageState extends ConsumerState<EventsPage> {
helpText: 'Период',
);
if (picked != null) {
ref.read(eventsControllerProvider.notifier).setFilter(
ref
.read(eventsControllerProvider.notifier)
.setFilter(
(f) => f.copyWith(from: () => picked.start, to: () => picked.end),
);
}
}
void _clearDateRange() {
ref.read(eventsControllerProvider.notifier).setFilter(
(f) => f.copyWith(from: () => null, to: () => null),
);
ref
.read(eventsControllerProvider.notifier)
.setFilter((f) => f.copyWith(from: () => null, to: () => null));
}
void _snack(String message) =>
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
/// Lots and every valuation are rebuilt from the ledger by the metrics refresh, so a change
/// to the ledger asks for one. Best effort: the event itself is already saved.
Future<void> _queueMetricsRefresh() async {
try {
await ref.read(apiProvider).getMetricsApi().metricsRefresh();
} on DioException {
// the next scheduled or manual refresh picks the change up
}
}
Future<void> _addManual() async {
final body = await showManualEventDialog(context);
if (body == null || !mounted) return;
try {
await ref
.read(apiProvider)
.getEventsApi()
.eventsCreate(manualEventCreate: body);
await ref.read(eventsControllerProvider.notifier).refresh();
await _queueMetricsRefresh();
if (mounted) _snack('Событие добавлено, метрики пересчитываются');
} on DioException catch (e) {
if (mounted) _snack(problemMessage(e));
}
}
Future<void> _deleteManual(EventOut e) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Удалить событие?'),
content: Text(
'«${eventTitle(e.kind, e.ticker)}» от ${ruDate(e.tradeDate)} будет удалено.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Удалить'),
),
],
),
);
if (confirmed != true || !mounted) return;
try {
await ref.read(apiProvider).getEventsApi().eventsDelete(eventId: e.id);
await ref.read(eventsControllerProvider.notifier).refresh();
await _queueMetricsRefresh();
if (mounted) _snack('Событие удалено, метрики пересчитываются');
} on DioException catch (err) {
if (mounted) _snack(problemMessage(err));
}
}
void _showDetail(EventOut e) {
@@ -88,7 +160,16 @@ class _EventsPageState extends ConsumerState<EventsPage> {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => _EventDetailSheet(event: e, accountNames: accountNames),
builder: (sheetContext) => _EventDetailSheet(
event: e,
accountNames: accountNames,
onDelete: e.source_ == 'manual'
? () {
Navigator.of(sheetContext).pop();
_deleteManual(e);
}
: null,
),
);
}
@@ -97,16 +178,24 @@ class _EventsPageState extends ConsumerState<EventsPage> {
final state = ref.watch(eventsControllerProvider);
final filter = ref.watch(eventsControllerProvider.notifier).filter;
final accountNames = ref.watch(accountNamesProvider);
final accounts = ref.watch(accountsProvider).valueOrNull?.data ?? const <AccountOut>[];
final accounts =
ref.watch(accountsProvider).valueOrNull?.data ?? const <AccountOut>[];
return Scaffold(
floatingActionButton: FloatingActionButton.extended(
onPressed: _addManual,
icon: const Icon(Icons.add),
label: const Text('Событие'),
),
appBar: AppBar(
title: const Text('События'),
actions: [
if (state.total > 0)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Center(child: Text('${state.items.length} из ${state.total}')),
child: Center(
child: Text('${state.items.length} из ${state.total}'),
),
),
],
),
@@ -138,12 +227,17 @@ class _EventsPageState extends ConsumerState<EventsPage> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error, size: 32),
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
size: 32,
),
const SizedBox(height: 8),
Text('${state.error}', textAlign: TextAlign.center),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: () => ref.read(eventsControllerProvider.notifier).refresh(),
onPressed: () =>
ref.read(eventsControllerProvider.notifier).refresh(),
child: const Text('Повторить'),
),
],
@@ -171,7 +265,9 @@ class _EventsPageState extends ConsumerState<EventsPage> {
child: state.loadingMore
? const CircularProgressIndicator()
: TextButton(
onPressed: () => ref.read(eventsControllerProvider.notifier).loadMore(),
onPressed: () => ref
.read(eventsControllerProvider.notifier)
.loadMore(),
child: const Text('Ещё'),
),
),
@@ -246,51 +342,67 @@ class _Filters extends ConsumerWidget {
width: 180,
child: DropdownButtonFormField<int?>(
initialValue: filter.accountId,
isDense: true,
decoration: const InputDecoration(labelText: 'Счёт', isDense: true),
decoration: const InputDecoration(labelText: 'Счёт'),
items: [
const DropdownMenuItem(value: null, child: Text('Все счета')),
const DropdownMenuItem(
value: null,
child: Text('Все счета'),
),
for (final a in accounts)
DropdownMenuItem(value: a.id, child: Text(a.name)),
],
onChanged: (v) => notifier.setFilter((f) => f.copyWith(accountId: () => v)),
onChanged: (v) =>
notifier.setFilter((f) => f.copyWith(accountId: () => v)),
),
),
SizedBox(
width: 180,
child: DropdownButtonFormField<EventKind?>(
initialValue: filter.kind,
isDense: true,
decoration: const InputDecoration(labelText: 'Тип', isDense: true),
decoration: const InputDecoration(labelText: 'Тип'),
items: [
const DropdownMenuItem(value: null, child: Text('Все типы')),
const DropdownMenuItem(
value: null,
child: Text('Все типы'),
),
for (final k in filterableEventKinds)
DropdownMenuItem(value: k, child: Text(eventKindLabel(k))),
DropdownMenuItem(
value: k,
child: Text(eventKindLabel(k)),
),
],
onChanged: (v) => notifier.setFilter((f) => f.copyWith(kind: () => v)),
onChanged: (v) =>
notifier.setFilter((f) => f.copyWith(kind: () => v)),
),
),
SizedBox(
width: 180,
child: DropdownButtonFormField<EventStatus?>(
initialValue: filter.status,
isDense: true,
decoration: const InputDecoration(labelText: 'Статус', isDense: true),
decoration: const InputDecoration(labelText: 'Статус'),
items: [
const DropdownMenuItem(value: null, child: Text('Любой статус')),
const DropdownMenuItem(
value: null,
child: Text('Любой статус'),
),
for (final s in EventStatus.values)
if (s != EventStatus.unknownDefaultOpenApi)
DropdownMenuItem(value: s, child: Text(eventStatusLabel(s))),
DropdownMenuItem(
value: s,
child: Text(eventStatusLabel(s)),
),
],
onChanged: (v) => notifier.setFilter((f) => f.copyWith(status: () => v)),
onChanged: (v) =>
notifier.setFilter((f) => f.copyWith(status: () => v)),
),
),
FilterChip(
label: const Text('Внешние потоки'),
tooltip: 'Только пополнения, выводы и переводы бумаг — то, что читает XIRR',
selected: filter.externalFlow == true,
onSelected: (on) =>
notifier.setFilter((f) => f.copyWith(externalFlow: () => on ? true : null)),
onSelected: (on) => notifier.setFilter(
(f) => f.copyWith(externalFlow: () => on ? true : null),
),
),
],
),
@@ -301,31 +413,45 @@ class _Filters extends ConsumerWidget {
}
class _EventDetailSheet extends StatelessWidget {
const _EventDetailSheet({required this.event, required this.accountNames});
const _EventDetailSheet({
required this.event,
required this.accountNames,
this.onDelete,
});
final EventOut event;
final Map<int, String> accountNames;
/// Set only for events entered by hand: broker events come back with the next sync.
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
final e = event;
final theme = Theme.of(context);
String money(String? v, String currency) => v == null ? '' : MoneyText.format(v, currency);
String money(String? v, String currency) =>
v == null ? '' : MoneyText.format(v, currency);
final rows = <(String, String)>[
('Дата сделки', ruDate(e.tradeDate)),
('Время', '${ruDate(e.ts)} ${e.ts.hour.toString().padLeft(2, '0')}:'
'${e.ts.minute.toString().padLeft(2, '0')}'),
(
'Время',
'${ruDate(e.ts)} ${e.ts.hour.toString().padLeft(2, '0')}:'
'${e.ts.minute.toString().padLeft(2, '0')}',
),
('Тип', eventKindLabel(e.kind)),
('Статус', eventStatusLabel(e.status)),
('Источник', eventSourceLabel(e.source_)),
('Счёт', accountNames[e.accountId] ?? '#${e.accountId}'),
if (e.ticker != null) ('Инструмент', e.ticker!),
if (e.quantity != null) ('Количество', formatQty(e.quantity!)),
if (e.price != null) ('Цена', money(e.price, e.priceCurrency ?? e.currency)),
if (e.price != null)
('Цена', money(e.price, e.priceCurrency ?? e.currency)),
('Сумма', money(e.amount, e.currency)),
('Сумма, ₽', money(e.amountRub, 'RUB')),
if (e.fee != null) ('Комиссия', money(e.fee, e.currency)),
if (e.tax != null) ('Налог', money(e.tax, e.currency)),
if (e.accruedInterest != null) ('НКД', money(e.accruedInterest, e.currency)),
if (e.accruedInterest != null)
('НКД', money(e.accruedInterest, e.currency)),
('Внешний поток', e.externalFlow ? 'да' : 'нет'),
if (e.description != null) ('Описание', e.description!),
];
@@ -339,12 +465,16 @@ class _EventDetailSheet extends StatelessWidget {
Row(
children: [
Expanded(
child: Text(eventTitle(e.kind, e.ticker), style: theme.textTheme.titleLarge),
child: Text(
eventTitle(e.kind, e.ticker),
style: theme.textTheme.titleLarge,
),
),
Text(
MoneyText.format(e.amount, e.currency),
style: theme.textTheme.titleMedium
?.copyWith(color: signColor(context, e.amount)),
style: theme.textTheme.titleMedium?.copyWith(
color: signColor(context, e.amount),
),
),
],
),
@@ -357,12 +487,23 @@ class _EventDetailSheet extends StatelessWidget {
children: [
SizedBox(
width: 140,
child: Text(label, style: theme.textTheme.bodySmall),
child: TermLabel(label, style: theme.textTheme.bodySmall),
),
Expanded(child: Text(value)),
],
),
),
if (onDelete != null) ...[
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: onDelete,
icon: const Icon(Icons.delete_outline, size: 18),
label: const Text('Удалить'),
),
),
],
if (e.instrumentId != null) ...[
const SizedBox(height: 12),
Align(
+30 -14
View File
@@ -35,31 +35,47 @@ const eventStatusLabels = {
'ignored': 'Игнорируется',
};
String eventStatusLabel(EventStatus status) => eventStatusLabels[status.value] ?? status.value;
String eventStatusLabel(EventStatus status) =>
eventStatusLabels[status.value] ?? status.value;
/// Icon and colour per kind, on the same principle as the transaction list: money coming
/// in is green, money leaving is the error colour, everything structural is neutral.
(IconData, Color) eventKindIcon(EventKind kind, ColorScheme scheme) => switch (kind) {
(IconData, Color) eventKindIcon(EventKind kind, ColorScheme scheme) =>
switch (kind) {
EventKind.buy => (Icons.add_shopping_cart, scheme.primary),
EventKind.sell => (Icons.sell_outlined, Colors.deepPurple),
EventKind.dividend || EventKind.coupon || EventKind.interest => (
Icons.payments_outlined,
Colors.green,
),
EventKind.dividend ||
EventKind.coupon ||
EventKind.interest => (Icons.payments_outlined, Colors.green),
EventKind.taxRefund => (Icons.assignment_return_outlined, Colors.green),
EventKind.tax || EventKind.commission => (Icons.receipt_outlined, scheme.error),
EventKind.tax ||
EventKind.commission => (Icons.receipt_outlined, scheme.error),
EventKind.deposit => (Icons.arrow_circle_down_outlined, Colors.green),
EventKind.withdrawal => (Icons.arrow_circle_up_outlined, scheme.error),
EventKind.transferIn || EventKind.transferOut => (Icons.swap_horiz, scheme.primary),
EventKind.transferIn ||
EventKind.transferOut => (Icons.swap_horiz, scheme.primary),
EventKind.fxExchange => (Icons.currency_exchange, Colors.amber),
EventKind.split || EventKind.amortization || EventKind.repayment => (
Icons.call_split,
scheme.outline,
),
EventKind.other || EventKind.unknownDefaultOpenApi => (Icons.help_outline, scheme.outline),
EventKind.split ||
EventKind.amortization ||
EventKind.repayment => (Icons.call_split, scheme.outline),
EventKind.other ||
EventKind.unknownDefaultOpenApi => (Icons.help_outline, scheme.outline),
};
/// `'Покупка · SBER'`, the one-line identity of a row. Events with no instrument behind
/// them (deposits, fees) keep just the kind.
String eventTitle(EventKind kind, String? ticker) =>
ticker == null || ticker.isEmpty ? eventKindLabel(kind) : '${eventKindLabel(kind)} · $ticker';
ticker == null || ticker.isEmpty
? eventKindLabel(kind)
: '${eventKindLabel(kind)} · $ticker';
/// Where a ledger event came from, as a person reads it.
const eventSourceLabels = {
'tinvest': 'T-Invest',
'report_sber': 'Отчёт Сбера',
'report_vtb': 'Отчёт ВТБ',
'csv': 'CSV',
'manual': 'Введено вручную',
};
String eventSourceLabel(String source) => eventSourceLabels[source] ?? source;
@@ -0,0 +1,317 @@
import 'package:decimal/decimal.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/utils/ru_date.dart';
import '../accounts/providers.dart';
import '../pending/providers.dart' show instrumentSearchProvider;
import 'labels.dart';
/// The kinds `POST /events` accepts, in the order a person looks for them.
const manualEventKinds = [
EventKind.buy,
EventKind.sell,
EventKind.transferIn,
EventKind.transferOut,
EventKind.dividend,
EventKind.coupon,
EventKind.interest,
EventKind.deposit,
EventKind.withdrawal,
EventKind.commission,
EventKind.tax,
EventKind.taxRefund,
];
bool _isTrade(EventKind k) => k == EventKind.buy || k == EventKind.sell;
bool _isTransfer(EventKind k) =>
k == EventKind.transferIn || k == EventKind.transferOut;
bool _isPayout(EventKind k) => k == EventKind.dividend || k == EventKind.coupon;
/// A ledger event the broker's feeds do not carry. Amounts are typed as a person reads them
/// off a statement — positive; the server derives the signs from the kind.
Future<ManualEventCreate?> showManualEventDialog(BuildContext context) =>
showDialog<ManualEventCreate>(
context: context,
builder: (_) => const _ManualEventDialog(),
);
class _ManualEventDialog extends ConsumerStatefulWidget {
const _ManualEventDialog();
@override
ConsumerState<_ManualEventDialog> createState() => _ManualEventDialogState();
}
class _ManualEventDialogState extends ConsumerState<_ManualEventDialog> {
final _formKey = GlobalKey<FormState>();
final _quantity = TextEditingController();
final _price = TextEditingController();
final _amount = TextEditingController();
final _fee = TextEditingController();
final _accrued = TextEditingController();
final _description = TextEditingController();
AccountOut? _account;
EventKind _kind = EventKind.buy;
DateTime _date = DateTime.now();
InstrumentOut? _instrument;
@override
void dispose() {
for (final c in [
_quantity,
_price,
_amount,
_fee,
_accrued,
_description,
]) {
c.dispose();
}
super.dispose();
}
static String? _positive(String? v, {required bool required}) {
final text = (v ?? '').trim().replaceAll(',', '.');
if (text.isEmpty) return required ? 'Обязательное поле' : null;
final d = Decimal.tryParse(text);
return d == null || d <= Decimal.zero ? 'Число больше нуля' : null;
}
static String? _text(TextEditingController c) {
final t = c.text.trim().replaceAll(',', '.').replaceAll(' ', '');
return t.isEmpty ? null : t;
}
bool get _needsInstrument =>
_isTrade(_kind) || _isTransfer(_kind) || _isPayout(_kind);
ManualEventCreate _build() {
final trade = _isTrade(_kind);
return ManualEventCreate(
accountId: _account!.id,
kind: _kind,
tradeDate: DateTime.utc(_date.year, _date.month, _date.day),
instrumentId: _needsInstrument ? _instrument!.id : null,
quantity: trade || _isTransfer(_kind) ? _text(_quantity) : null,
price: trade ? _text(_price) : null,
amount: _isTransfer(_kind) ? null : _text(_amount),
fee: trade ? _text(_fee) : null,
accruedInterest: trade ? _text(_accrued) : null,
description: _description.text.trim().isEmpty
? null
: _description.text.trim(),
);
}
@override
Widget build(BuildContext context) {
final accounts = [
for (final a
in ref.watch(accountsProvider).valueOrNull?.data ??
const <AccountOut>[])
if (a.kind == AccountKind.broker && !a.archived && !a.disabled) a,
];
final trade = _isTrade(_kind);
return AlertDialog(
title: const Text('Новое событие'),
content: SizedBox(
width: 460,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<AccountOut>(
initialValue: _account,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Брокерский счёт',
),
items: [
for (final a in accounts)
DropdownMenuItem(value: a, child: Text(a.name)),
],
validator: (v) => v == null ? 'Выберите счёт' : null,
onChanged: (v) => setState(() => _account = v),
),
const SizedBox(height: 12),
DropdownButtonFormField<EventKind>(
initialValue: _kind,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Событие'),
items: [
for (final k in manualEventKinds)
DropdownMenuItem(
value: k,
child: Text(eventKindLabel(k)),
),
],
onChanged: (v) => setState(() {
_kind = v ?? _kind;
// the field is gone for kinds without an instrument: its pick must go too
if (!_needsInstrument) _instrument = null;
}),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: Text('Дата: ${ruDate(_date)}')),
TextButton(
onPressed: () async {
final picked = await showDatePicker(
context: context,
initialDate: _date,
firstDate: DateTime(2015),
lastDate: DateTime.now(),
);
if (picked != null) setState(() => _date = picked);
},
child: const Text('Выбрать'),
),
],
),
if (_needsInstrument) ...[
const SizedBox(height: 8),
_InstrumentField(
onChanged: (i) => _instrument = i,
validator: () => _instrument == null
? 'Выберите инструмент из списка'
: null,
),
],
if (trade || _isTransfer(_kind)) ...[
const SizedBox(height: 12),
TextFormField(
controller: _quantity,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(
labelText: 'Количество, шт.',
),
validator: (v) => _positive(v, required: true),
),
],
if (trade) ...[
const SizedBox(height: 12),
TextFormField(
controller: _price,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(
labelText: 'Цена за штуку',
),
validator: (v) =>
_positive(v, required: _text(_amount) == null),
),
const SizedBox(height: 12),
TextFormField(
controller: _fee,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(
labelText: 'Комиссия',
helperText: 'Входит в итоговую сумму',
),
validator: (v) => _positive(v, required: false),
),
const SizedBox(height: 12),
TextFormField(
controller: _accrued,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(
labelText: 'НКД (для облигаций)',
),
validator: (v) => _positive(v, required: false),
),
],
if (!_isTransfer(_kind)) ...[
const SizedBox(height: 12),
TextFormField(
controller: _amount,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: InputDecoration(
labelText: trade ? 'Итого по выписке' : 'Сумма',
helperText: trade
? 'Необязательно: иначе количество × цена ± НКД ± комиссия'
: 'Положительная; знак зададут по виду события',
helperMaxLines: 2,
),
validator: (v) => _positive(v, required: !trade),
),
],
const SizedBox(height: 12),
TextFormField(
controller: _description,
decoration: const InputDecoration(
labelText: 'Описание (необязательно)',
),
),
],
),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () {
if (!(_formKey.currentState?.validate() ?? false)) return;
Navigator.of(context).pop(_build());
},
child: const Text('Добавить'),
),
],
);
}
}
/// Type-ahead over `GET /instruments?q=`. The chosen instrument is reported through
/// [onChanged]; editing the text afterwards drops it, so a stale pick can never be submitted.
class _InstrumentField extends ConsumerWidget {
const _InstrumentField({required this.onChanged, required this.validator});
final ValueChanged<InstrumentOut?> onChanged;
final String? Function() validator;
static String _label(InstrumentOut i) => '${i.ticker ?? ''} · ${i.name}';
@override
Widget build(BuildContext context, WidgetRef ref) {
return Autocomplete<InstrumentOut>(
displayStringForOption: _label,
optionsBuilder: (value) async {
final q = value.text.trim();
if (q.length < 2) return const <InstrumentOut>[];
return ref.read(instrumentSearchProvider(q).future);
},
onSelected: onChanged,
fieldViewBuilder: (context, controller, focusNode, onSubmit) =>
TextFormField(
controller: controller,
focusNode: focusNode,
decoration: const InputDecoration(
labelText: 'Инструмент',
helperText: 'Тикер, ISIN или название',
prefixIcon: Icon(Icons.search),
),
onChanged: (_) => onChanged(null),
validator: (_) => validator(),
),
);
}
}
+4 -1
View File
@@ -112,7 +112,10 @@ class EventsController extends Notifier<EventsState> {
}
Future<EventPage> _fetch(int page) async {
final r = await ref.read(apiProvider).getEventsApi().eventsList(
final r = await ref
.read(apiProvider)
.getEventsApi()
.eventsList(
from: filter.from,
to: filter.to,
accountId: filter.accountId,
+182 -60
View File
@@ -20,9 +20,13 @@ import '../../core/widgets/tile_carousel.dart';
import '../../core/api/api_client.dart';
import '../health/data_quality_list.dart' show severityColor;
import 'providers.dart';
import 'scope_cards.dart';
double _d(String s) => Decimal.parse(s).toDouble();
/// The refresh log row can be a run still in progress, which has no failed step yet.
String _failedStepNote(String? step) => step == null ? '' : ' (шаг «$step»)';
/// Обзор: the dashboard landing page — net worth, this month's cashflow,
/// runway, a net worth line chart, a 12-month income/expense bar chart, and
/// a data-quality summary linking to the findings.
@@ -39,10 +43,28 @@ class _HomePageState extends ConsumerState<HomePage> {
Future<void> _refresh() async {
setState(() => _refreshing = true);
try {
await ref.read(apiProvider).getMetricsApi().metricsRefresh();
final metrics = ref.read(apiProvider).getMetricsApi();
await metrics
.metricsRefresh(); // 202: only queued, the worker does the rebuild
final done = await waitForMetricsRefresh(
() async => (await metrics.metricsStatus()).data!,
);
if (mounted) {
final message = done == null
? 'Пересчёт идёт дольше обычного — данные обновятся, когда он закончится'
: done.consistent
? null
: 'Пересчёт не завершён${_failedStepNote(done.lastRefresh?.failedStep)}'
'часть метрик может не сходиться';
if (message != null) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
}
} on DioException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(problemMessage(e))));
} finally {
if (mounted) setState(() => _refreshing = false);
invalidateHomeProviders(ref);
@@ -59,6 +81,7 @@ class _HomePageState extends ConsumerState<HomePage> {
final status = ref.watch(metricsStatusProvider);
final dataQuality = ref.watch(dataQualityProvider);
final portfolio = ref.watch(portfolioSummaryHomeProvider);
final cards = ref.watch(scopeCardsProvider);
final stale = oldestFetch([
breakdown.valueOrNull?.fetchedAt,
@@ -69,6 +92,7 @@ class _HomePageState extends ConsumerState<HomePage> {
status.valueOrNull?.fetchedAt,
dataQuality.valueOrNull?.fetchedAt,
portfolio.valueOrNull?.fetchedAt,
cards.valueOrNull?.fetchedAt,
]);
return Scaffold(
@@ -79,7 +103,10 @@ class _HomePageState extends ConsumerState<HomePage> {
tooltip: 'Пересчитать метрики',
icon: _refreshing
? const SizedBox(
width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
onPressed: _refreshing ? null : _refresh,
),
@@ -98,12 +125,21 @@ class _HomePageState extends ConsumerState<HomePage> {
value: status,
onRetry: () => ref.invalidate(metricsStatusProvider),
data: (cached) {
final log = cached.data;
final status = cached.data;
final log = status.lastRefresh;
final at = log?.finishedAt ?? log?.startedAt;
final text = at == null
? 'Данные ещё не пересчитывались'
: 'Данные на ${ruDate(at.toLocal())} ${at.toLocal().hour.toString().padLeft(2, '0')}:${at.toLocal().minute.toString().padLeft(2, '0')}';
return Text(text, style: Theme.of(context).textTheme.bodySmall);
final style = Theme.of(context).textTheme.bodySmall;
if (status.consistent) return Text(text, style: style);
return Text(
'$text · пересчёт не завершён${_failedStepNote(log?.failedStep)}, '
'часть метрик может не сходиться',
style: style?.copyWith(
color: Theme.of(context).colorScheme.error,
),
);
},
),
),
@@ -113,20 +149,43 @@ class _HomePageState extends ConsumerState<HomePage> {
final rows = cached.data;
return ActionChip(
avatar: Icon(
rows.isEmpty ? Icons.check_circle_outline : Icons.warning_amber_outlined,
rows.isEmpty
? Icons.check_circle_outline
: Icons.warning_amber_outlined,
size: 18,
color: rows.isEmpty ? ChartColors.slot3Aqua : severityColor(rows.first.severity),
color: rows.isEmpty
? ChartColors.slot3Aqua
: severityColor(rows.first.severity),
),
label: Text(
rows.isEmpty ? 'ок' : '${rows.length} замечаний',
),
label: Text(rows.isEmpty ? 'ок' : '${rows.length} замечаний'),
// Health page renders the same findings via DataQualityList — no
// second implementation of this list here.
onPressed: rows.isEmpty ? null : () => context.go('/health?tab=quality'),
onPressed: rows.isEmpty
? null
: () => context.go('/health?tab=quality'),
);
},
),
],
),
const SizedBox(height: 20),
AsyncValueView(
value: cards,
onRetry: () => ref.invalidate(scopeCardsProvider),
data: (cached) => cached.data.isEmpty
? const SizedBox.shrink()
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 20),
const SectionHeader(title: 'Портфели'),
const SizedBox(height: 12),
ScopeCards(cards: cached.data),
],
),
),
const SizedBox(height: 24),
const SectionHeader(title: 'Капитал'),
const SizedBox(height: 12),
AsyncValueView(
@@ -146,7 +205,8 @@ class _HomePageState extends ConsumerState<HomePage> {
AsyncValueView(
value: runway,
onRetry: () => ref.invalidate(runwayProvider),
data: (cached) => TileCarousel(children: [_RunwayTile(runway: cached.data)]),
data: (cached) =>
TileCarousel(children: [_RunwayTile(runway: cached.data)]),
),
AsyncValueView(
value: portfolio,
@@ -175,7 +235,10 @@ class _HomePageState extends ConsumerState<HomePage> {
value: series,
onRetry: () => ref.invalidate(netWorthSeriesProvider),
data: (cached) => cached.data.isEmpty
? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.')
? const EmptyState(
icon: Icons.show_chart,
message: 'Пока нет данных.',
)
: _NetWorthChart(rows: cached.data),
),
),
@@ -188,7 +251,10 @@ class _HomePageState extends ConsumerState<HomePage> {
value: last12,
onRetry: () => ref.invalidate(cashflowLast12Provider),
data: (cached) => cached.data.isEmpty
? const EmptyState(icon: Icons.bar_chart, message: 'Пока нет данных.')
? const EmptyState(
icon: Icons.bar_chart,
message: 'Пока нет данных.',
)
: _IncomeExpenseChart(rows: cached.data),
),
),
@@ -204,7 +270,11 @@ class _HomePageState extends ConsumerState<HomePage> {
);
}
return Column(
children: [netWorthChart, const SizedBox(height: 16), cashflowChart],
children: [
netWorthChart,
const SizedBox(height: 16),
cashflowChart,
],
);
},
),
@@ -230,13 +300,30 @@ class _NetWorthTiles extends StatelessWidget {
@override
Widget build(BuildContext context) {
return TileCarousel(children: [
StatTile(label: 'Капитал сегодня', value: MoneyText(breakdown.totalRub, currency: 'RUB')),
StatTile(label: 'Ликвидные', value: MoneyText(breakdown.liquidRub, currency: 'RUB')),
StatTile(label: 'Сбережения', value: MoneyText(breakdown.savingsRub, currency: 'RUB')),
StatTile(label: 'Инвестиции', value: MoneyText(breakdown.investmentRub, currency: 'RUB')),
StatTile(label: 'Долги', value: MoneyText(breakdown.debtRub, currency: 'RUB')),
]);
return TileCarousel(
children: [
StatTile(
label: 'Капитал сегодня',
value: MoneyText(breakdown.totalRub, currency: 'RUB'),
),
StatTile(
label: 'Ликвидные',
value: MoneyText(breakdown.liquidRub, currency: 'RUB'),
),
StatTile(
label: 'Сбережения',
value: MoneyText(breakdown.savingsRub, currency: 'RUB'),
),
StatTile(
label: 'Инвестиции',
value: MoneyText(breakdown.investmentRub, currency: 'RUB'),
),
StatTile(
label: 'Долги',
value: MoneyText(breakdown.debtRub, currency: 'RUB'),
),
],
);
}
}
@@ -247,14 +334,27 @@ class _MonthTiles extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (month == null) {
return const EmptyState(icon: Icons.event_note_outlined, message: 'Данных за этот месяц нет.');
return const EmptyState(
icon: Icons.event_note_outlined,
message: 'Данных за этот месяц нет.',
);
}
final rateText = month!.savingsRate == null ? '' : '${(_d(month!.savingsRate!) * 100).toStringAsFixed(1)}%';
return TileCarousel(children: [
StatTile(label: 'Доход в этом месяце', value: MoneyText(month!.incomeRub, currency: 'RUB')),
StatTile(label: 'Расход в этом месяце', value: MoneyText(month!.expenseRub, currency: 'RUB')),
StatTile(label: 'Норма сбережений', value: Text(rateText)),
]);
final rateText = month!.savingsRate == null
? ''
: '${(_d(month!.savingsRate!) * 100).toStringAsFixed(1)}%';
return TileCarousel(
children: [
StatTile(
label: 'Доход в этом месяце',
value: MoneyText(month!.incomeRub, currency: 'RUB'),
),
StatTile(
label: 'Расход в этом месяце',
value: MoneyText(month!.expenseRub, currency: 'RUB'),
),
StatTile(label: 'Норма сбережений', value: Text(rateText)),
],
);
}
}
@@ -278,7 +378,8 @@ class _NetWorthChart extends StatelessWidget {
@override
Widget build(BuildContext context) {
final spots = [
for (var i = 0; i < rows.length; i++) FlSpot(i.toDouble(), _d(rows[i].totalRub)),
for (var i = 0; i < rows.length; i++)
FlSpot(i.toDouble(), _d(rows[i].totalRub)),
];
return LineChart(
LineChartData(
@@ -323,7 +424,11 @@ class _IncomeExpenseChart extends StatelessWidget {
Widget build(BuildContext context) {
final maxY = rows.fold<double>(
0,
(m, r) => [m, _d(r.incomeRub), _d(r.expenseRub)].reduce((a, b) => a > b ? a : b),
(m, r) => [
m,
_d(r.incomeRub),
_d(r.expenseRub),
].reduce((a, b) => a > b ? a : b),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -343,19 +448,28 @@ class _IncomeExpenseChart extends StatelessWidget {
gridData: const FlGridData(drawVerticalLine: false),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final i = value.toInt();
if (i < 0 || i >= rows.length) return const SizedBox.shrink();
if (i < 0 || i >= rows.length)
return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 6),
child:
Text(ruMonthYearShort(rows[i].month), style: const TextStyle(fontSize: 9)),
child: Text(
ruMonthYearShort(rows[i].month),
style: const TextStyle(fontSize: 9),
),
);
},
),
@@ -377,8 +491,16 @@ class _IncomeExpenseChart extends StatelessWidget {
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(toY: _d(rows[i].incomeRub), color: ChartColors.income, width: 6),
BarChartRodData(toY: _d(rows[i].expenseRub), color: ChartColors.expense, width: 6),
BarChartRodData(
toY: _d(rows[i].incomeRub),
color: ChartColors.income,
width: 6,
),
BarChartRodData(
toY: _d(rows[i].expenseRub),
color: ChartColors.expense,
width: 6,
),
],
barsSpace: 2,
),
@@ -401,7 +523,11 @@ class _LegendDot extends StatelessWidget {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
@@ -422,27 +548,23 @@ class _PortfolioTiles extends StatelessWidget {
return InkWell(
onTap: () => context.go('/portfolio'),
borderRadius: BorderRadius.circular(12),
child: TileCarousel(children: [
StatTile(
label: 'Портфель',
value: MoneyText(summary.totalRub, currency: 'RUB'),
),
StatTile(
label: 'Прибыль',
value: summary.pnlTotalRub == null
// null, not zero: something in the portfolio has no price today
? const Text('')
: MoneyText(summary.pnlTotalRub!, currency: 'RUB'),
),
StatTile(
label: 'XIRR, год',
value: Text(_percent(yearly?.xirr)),
),
StatTile(
label: 'TWR, год',
value: Text(_percent(yearly?.twr)),
),
]),
child: TileCarousel(
children: [
StatTile(
label: 'Портфель',
value: MoneyText(summary.totalRub, currency: 'RUB'),
),
StatTile(
label: 'Прибыль',
value: summary.pnlTotalRub == null
// null, not zero: something in the portfolio has no price today
? const Text('')
: MoneyText(summary.pnlTotalRub!, currency: 'RUB'),
),
StatTile(label: 'XIRR, год', value: Text(_percent(yearly?.xirr))),
StatTile(label: 'TWR, год', value: Text(_percent(yearly?.twr))),
],
),
);
}
+111 -44
View File
@@ -8,54 +8,102 @@ import '../../core/cache/cached.dart';
/// Every provider below returns `Cached<T>`, not `T`: `HomePage` reads
/// `.data` for the tiles and `.fetchedAt` (via `oldestFetch`) for the one
/// offline banner at the top. See `docs/ai/offline-cache.md`.
final netWorthBreakdownProvider = FutureProvider.autoDispose<Cached<NetWorthBreakdown>>((ref) async {
final r = await ref.watch(apiProvider).getNetworthApi().networthBreakdown();
return r.cached;
});
final netWorthBreakdownProvider =
FutureProvider.autoDispose<Cached<NetWorthBreakdown>>((ref) async {
final r = await ref
.watch(apiProvider)
.getNetworthApi()
.networthBreakdown();
return r.cached;
});
/// Daily net worth for the last 365 days.
final netWorthSeriesProvider = FutureProvider.autoDispose<Cached<List<NetWorthDay>>>((ref) async {
final now = DateTime.now();
final r = await ref.watch(apiProvider).getNetworthApi().networthSeries(
from: now.subtract(const Duration(days: 365)),
to: now,
final netWorthSeriesProvider =
FutureProvider.autoDispose<Cached<List<NetWorthDay>>>((ref) async {
final now = DateTime.now();
final r = await ref
.watch(apiProvider)
.getNetworthApi()
.networthSeries(
from: now.subtract(const Duration(days: 365)),
to: now,
);
return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?);
});
});
/// The current (partial) month's cashflow, or null before any data exists.
final cashflowThisMonthProvider = FutureProvider.autoDispose<Cached<CashFlowMonth?>>((ref) async {
final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 1);
final rows = r.data ?? const [];
return Cached(rows.isEmpty ? null : rows.last, fetchedAt: r.extra['fetchedAt'] as DateTime?);
});
final cashflowThisMonthProvider =
FutureProvider.autoDispose<Cached<CashFlowMonth?>>((ref) async {
final r = await ref
.watch(apiProvider)
.getCashflowApi()
.cashflowMonthly(months: 1);
final rows = r.data ?? const [];
return Cached(
rows.isEmpty ? null : rows.last,
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
});
/// The last 12 months, for the income-vs-expense bar chart.
final cashflowLast12Provider = FutureProvider.autoDispose<Cached<List<CashFlowMonth>>>((ref) async {
final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 12);
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?);
});
final cashflowLast12Provider =
FutureProvider.autoDispose<Cached<List<CashFlowMonth>>>((ref) async {
final r = await ref
.watch(apiProvider)
.getCashflowApi()
.cashflowMonthly(months: 12);
return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
});
final runwayProvider = FutureProvider.autoDispose<Cached<RunwayOut>>((ref) async {
final runwayProvider = FutureProvider.autoDispose<Cached<RunwayOut>>((
ref,
) async {
final r = await ref.watch(apiProvider).getCashflowApi().cashflowRunway();
return r.cached;
});
/// When the metric tables were last rebuilt; null before the first refresh.
final metricsStatusProvider = FutureProvider.autoDispose<Cached<RefreshLogOut?>>((ref) async {
try {
final r = await ref.watch(apiProvider).getMetricsApi().metricsStatus();
return r.cached;
} on DioException catch (e) {
if (e.response?.statusCode == 404) return const Cached(null);
rethrow;
}
});
/// When the metric tables were last rebuilt, whether they are one consistent snapshot, and
/// whether another rebuild is on its way.
final metricsStatusProvider =
FutureProvider.autoDispose<Cached<MetricsStatusOut>>((ref) async {
final r = await ref.watch(apiProvider).getMetricsApi().metricsStatus();
return r.cached;
});
final dataQualityProvider = FutureProvider.autoDispose<Cached<List<DataQualityRow>>>((ref) async {
final r = await ref.watch(apiProvider).getMetricsApi().metricsDataQuality();
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?);
});
/// Waits for a queued metrics rebuild to finish: `POST /metrics/refresh` only queues it, so the
/// numbers are stale until `refreshing` turns false. Returns the last status seen, or null if
/// [timeout] ran out first (a rebuild stuck behind a long sync, say).
Future<MetricsStatusOut?> waitForMetricsRefresh(
Future<MetricsStatusOut> Function() fetch, {
Duration interval = const Duration(seconds: 2),
Duration timeout = const Duration(minutes: 5),
}) async {
final clock = Stopwatch()..start();
while (true) {
final status = await fetch();
if (!status.refreshing) return status;
if (clock.elapsed >= timeout) return null;
await Future<void>.delayed(interval);
}
}
final dataQualityProvider =
FutureProvider.autoDispose<Cached<List<DataQualityRow>>>((ref) async {
final r = await ref
.watch(apiProvider)
.getMetricsApi()
.metricsDataQuality();
return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
});
/// Every dashboard provider, refreshed together after a manual
/// `POST /metrics/refresh` or a pull-to-refresh.
@@ -68,16 +116,35 @@ void invalidateHomeProviders(WidgetRef ref) {
ref.invalidate(metricsStatusProvider);
ref.invalidate(dataQualityProvider);
ref.invalidate(portfolioSummaryHomeProvider);
ref.invalidate(scopeCardsProvider);
}
/// The investment side of the dashboard: one scope-wide summary, `all` by default.
/// Null when the ledger is empty, which is the normal state before a broker sync.
final portfolioSummaryHomeProvider = FutureProvider.autoDispose<Cached<SummaryOut?>>((ref) async {
try {
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsSummary();
return r.cached;
} on DioException catch (e) {
if (e.response?.statusCode == 404) return const Cached(null);
rethrow;
}
});
final portfolioSummaryHomeProvider =
FutureProvider.autoDispose<Cached<SummaryOut?>>((ref) async {
try {
final r = await ref
.watch(apiProvider)
.getAnalyticsApi()
.analyticsSummary();
return r.cached;
} on DioException catch (e) {
if (e.response?.statusCode == 404) return const Cached(null);
rethrow;
}
});
/// One card per portfolio and account for the home grid (`GET /analytics/overview`): value,
/// result, the last day's change, return and expected passive income, in one round trip.
final scopeCardsProvider =
FutureProvider.autoDispose<Cached<List<ScopeCardOut>>>((ref) async {
final r = await ref
.watch(apiProvider)
.getAnalyticsApi()
.analyticsOverview();
return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
});
+194
View File
@@ -0,0 +1,194 @@
import 'package:decimal/decimal.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/theme/chart_colors.dart';
import '../../core/widgets/help_tip.dart';
import '../../core/widgets/money_text.dart';
import '../portfolio/labels.dart' show formatPercent, signColor;
import '../portfolio/providers.dart' show scopeProvider;
/// The home grid: a card per portfolio and account — value, result, the last day, return and
/// expected passive income at a glance. A tap opens Портфель scoped to that card.
class ScopeCards extends ConsumerWidget {
const ScopeCards({required this.cards, super.key});
final List<ScopeCardOut> cards;
static const _gap = 16.0;
@override
Widget build(BuildContext context, WidgetRef ref) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final columns = width >= 980 ? 3 : (width >= 620 ? 2 : 1);
final itemWidth = (width - _gap * (columns - 1)) / columns;
return Wrap(
spacing: _gap,
runSpacing: _gap,
children: [
for (final c in cards)
SizedBox(
width: itemWidth,
child: ScopeCard(
card: c,
onTap: () {
ref.read(scopeProvider.notifier).state = c.scope;
context.go('/portfolio');
},
),
),
],
);
},
);
}
}
IconData _icon(String kind) => switch (kind) {
'all' => Icons.layers_outlined,
'portfolio' => Icons.layers,
_ => Icons.account_balance_wallet_outlined,
};
class ScopeCard extends StatelessWidget {
const ScopeCard({required this.card, required this.onTap, super.key});
final ScopeCardOut card;
final VoidCallback onTap;
static String _money(String? v) =>
v == null ? '' : MoneyText.format(v, 'RUB');
/// `+6 643,47 ₽` — a signed amount; a plain minus and plus, never a bare number.
static String _signedMoney(String? v) {
if (v == null) return '';
final d = Decimal.parse(v);
final text = MoneyText.format(v, 'RUB');
return d > Decimal.zero ? '+$text' : text;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final label = theme.textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
);
Widget row(String name, Widget value) => Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
children: [
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: TermLabel(name, style: label),
),
),
value,
],
),
);
/// «+6 643,47 ₽ (▲ 1,5 %)» in the colour of its sign.
Widget change(String? amount, String? share) {
if (amount == null) return const Text('');
final color = signColor(context, amount) ?? scheme.onSurface;
final positive = Decimal.parse(amount) > Decimal.zero;
final negative = Decimal.parse(amount) < Decimal.zero;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(_signedMoney(amount), style: TextStyle(color: color)),
if (share != null) ...[
Text(' (', style: TextStyle(color: color)),
if (positive || negative)
Icon(
positive ? Icons.arrow_drop_up : Icons.arrow_drop_down,
size: 18,
color: color,
),
Text(
formatPercent(share, signed: false).replaceAll('-', ''),
style: TextStyle(color: color),
),
Text(')', style: TextStyle(color: color)),
],
],
);
}
final income = Decimal.parse(card.incomeYearRub);
return Card(
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_icon(card.kind),
size: 18,
color: scheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
card.name.toUpperCase(),
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelLarge?.copyWith(
color: scheme.onSurface,
letterSpacing: 0.4,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 12),
Text(
_money(card.totalRub),
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
row('Прибыль', change(card.pnlRub, card.pnlPct)),
row('За день', change(card.dayChangeRub, card.dayChangePct)),
row(
'Доходность',
Text(
card.xirr == null
? ''
: formatPercent(card.xirr, signed: false),
style: TextStyle(color: signColor(context, card.xirr)),
),
),
row(
'Пассивный доход',
Text(
income == Decimal.zero
? ''
: '${card.incomeYearPct == null ? '' : '${formatPercent(card.incomeYearPct, signed: false)} '}'
'(${MoneyText.format(card.incomeYearRub, 'RUB')})',
style: TextStyle(
color: income == Decimal.zero
? scheme.onSurfaceVariant
: ChartColors.gain,
),
),
),
],
),
),
),
);
}
}
+26 -10
View File
@@ -75,21 +75,24 @@ class _DimensionCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final onlyUnknown = buckets.every((b) => b.bucket == 'unknown' || b.bucket == 'cash');
final onlyUnknown = buckets.every(
(b) => b.bucket == 'unknown' || b.bucket == 'cash',
);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(dimensionLabel(dimension), style: Theme.of(context).textTheme.titleMedium),
Text(
dimensionLabel(dimension),
style: Theme.of(context).textTheme.titleMedium,
),
if (onlyUnknown) ...[
const SizedBox(height: 4),
Text(
'Атрибут не заполнен у инструментов — разрез пустой, а не нулевой.',
style: Theme.of(context)
.textTheme
.bodySmall
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: Theme.of(context).hintColor),
),
],
@@ -111,7 +114,9 @@ class _DimensionCard extends StatelessWidget {
],
);
}
return Column(children: [donut, const SizedBox(height: 12), legend]);
return Column(
children: [donut, const SizedBox(height: 12), legend],
);
},
),
],
@@ -133,7 +138,10 @@ class _Donut extends StatelessWidget {
// and the legend still lists it with its real value
final positive = buckets.where((b) => _d(b.valueRub) > 0).toList();
if (positive.isEmpty) {
return const EmptyState(icon: Icons.donut_large_outlined, message: 'Нечего показать.');
return const EmptyState(
icon: Icons.donut_large_outlined,
message: 'Нечего показать.',
);
}
return PieChart(
PieChartData(
@@ -194,10 +202,18 @@ class _Legend extends StatelessWidget {
if (buckets[i].holdingCount > 0)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text('${buckets[i].holdingCount}',
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)),
child: Text(
'${buckets[i].holdingCount}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
),
MoneyText(buckets[i].valueRub, currency: 'RUB', style: theme.textTheme.bodyMedium),
MoneyText(
buckets[i].valueRub,
currency: 'RUB',
style: theme.textTheme.bodyMedium,
),
const SizedBox(width: 12),
SizedBox(
width: 56,
+30 -18
View File
@@ -10,16 +10,18 @@ import 'data/benchmarks_api.dart';
import 'labels.dart';
import 'providers.dart';
final benchmarksApiProvider =
Provider<BenchmarksApi>((ref) => BenchmarksApi(ref.watch(apiProvider).dio));
final benchmarksApiProvider = Provider<BenchmarksApi>(
(ref) => BenchmarksApi(ref.watch(apiProvider).dio),
);
/// Benchmark comparison for the current scope. Part of Портфель, not a screen of its own:
/// «на сколько я обогнал индекс» is a property of the portfolio, not a separate subject. See
/// `docs/ai/offline-cache.md`.
final benchmarkRowsProvider = FutureProvider.autoDispose<Cached<List<BenchmarkRow>>>((ref) async {
final scope = ref.watch(scopeProvider);
return ref.watch(benchmarksApiProvider).compare(scope: scope);
});
final benchmarkRowsProvider =
FutureProvider.autoDispose<Cached<List<BenchmarkRow>>>((ref) async {
final scope = ref.watch(scopeProvider);
return ref.watch(benchmarksApiProvider).compare(scope: scope);
});
/// The comparison block on Портфель.
///
@@ -73,13 +75,16 @@ class _PeriodBlock extends StatelessWidget {
if (row.dateFrom != null && row.dateTo != null)
Text(
'${ruDate(row.dateFrom!)} ${ruDate(row.dateTo!)}',
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
const Spacer(),
Text(
'портфель ${formatPercent(row.portfolioTwr)}',
style: theme.textTheme.titleSmall
?.copyWith(color: signColor(context, row.portfolioTwr)),
style: theme.textTheme.titleSmall?.copyWith(
color: signColor(context, row.portfolioTwr),
),
),
if (row.portfolioDaysSkipped > 0) ...[
const SizedBox(width: 6),
@@ -89,13 +94,14 @@ class _PeriodBlock extends StatelessWidget {
),
const SizedBox(height: 6),
for (final b in row.benchmarks) _BenchmarkRowView(result: b),
if (row.hasSkippedDays)
if (row.benchmarksSkipDays)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
'Сетка дат не полностью совпадает: часть дней пропущена, '
'сравнение не строго like-for-like.',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
'У индекса нет котировок в часть дней окна — сравнение не строго день в день.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
),
const Divider(height: 20),
@@ -131,8 +137,9 @@ class _BenchmarkRowView extends StatelessWidget {
child: Text(
formatPercent(result.excess),
textAlign: TextAlign.right,
style: theme.textTheme.bodyMedium
?.copyWith(color: signColor(context, result.excess)),
style: theme.textTheme.bodyMedium?.copyWith(
color: signColor(context, result.excess),
),
),
),
],
@@ -148,7 +155,8 @@ class _PriceIndexChip extends StatelessWidget {
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Tooltip(
message: 'Ценовой индекс: не учитывает дивиденды и систематически занижает '
message:
'Ценовой индекс: не учитывает дивиденды и систематически занижает '
'результат держателя. Сравнение с ним — нижняя граница, а не эталон.',
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
@@ -156,7 +164,10 @@ class _PriceIndexChip extends StatelessWidget {
color: scheme.errorContainer,
borderRadius: BorderRadius.circular(6),
),
child: Text('ценовой индекс', style: Theme.of(context).textTheme.labelSmall),
child: Text(
'ценовой индекс',
style: Theme.of(context).textTheme.labelSmall,
),
),
);
}
@@ -171,7 +182,8 @@ class _SkippedChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Tooltip(
message: 'В расчёте $side пропущено $days дн. — в эти дни не было цены',
message:
'В расчёте $side не учтено $days дн.: в эти дни у части позиций не было цены',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -42,14 +42,14 @@ class BenchmarkResult {
bool get isPriceIndex => kind == 'price';
static BenchmarkResult fromJson(Map<String, dynamic> json) => BenchmarkResult(
benchmarkId: asInt(json['benchmark_id']) ?? 0,
code: asString(json['code']) ?? '',
kind: asString(json['kind']) ?? 'total_return',
twr: asString(json['twr']),
twrAnnualized: asString(json['twr_annualized']),
daysSkipped: asInt(json['days_skipped']) ?? 0,
excess: asString(json['excess']),
);
benchmarkId: asInt(json['benchmark_id']) ?? 0,
code: asString(json['code']) ?? '',
kind: asString(json['kind']) ?? 'total_return',
twr: asString(json['twr']),
twrAnnualized: asString(json['twr_annualized']),
daysSkipped: asInt(json['days_skipped']) ?? 0,
excess: asString(json['excess']),
);
}
class BenchmarkRow {
@@ -74,18 +74,22 @@ class BenchmarkRow {
final int portfolioDaysSkipped;
final List<BenchmarkResult> benchmarks;
bool get hasSkippedDays =>
portfolioDaysSkipped > 0 || benchmarks.any((b) => b.daysSkipped > 0);
bool get hasSkippedDays => portfolioDaysSkipped > 0 || benchmarksSkipDays;
/// Only the index side: the portfolio's own gaps are already marked next to its number.
bool get benchmarksSkipDays => benchmarks.any((b) => b.daysSkipped > 0);
static BenchmarkRow fromJson(Map<String, dynamic> json) => BenchmarkRow(
period: asString(json['period']) ?? 'all',
dateFrom: asDate(json['date_from']),
dateTo: asDate(json['date_to']),
portfolioTwr: asString(json['portfolio_twr']),
portfolioTwrAnnualized: asString(json['portfolio_twr_annualized']),
portfolioDaysSkipped: asInt(json['portfolio_days_skipped']) ?? 0,
benchmarks: asObjects(json['benchmarks']).map(BenchmarkResult.fromJson).toList(),
);
period: asString(json['period']) ?? 'all',
dateFrom: asDate(json['date_from']),
dateTo: asDate(json['date_to']),
portfolioTwr: asString(json['portfolio_twr']),
portfolioTwrAnnualized: asString(json['portfolio_twr_annualized']),
portfolioDaysSkipped: asInt(json['portfolio_days_skipped']) ?? 0,
benchmarks: asObjects(json['benchmarks'])
.map(BenchmarkResult.fromJson)
.toList(),
);
}
class BenchmarksApi {
@@ -104,7 +108,9 @@ class BenchmarksApi {
// `period` is repeatable; Dio serialises a list as repeated query parameters.
queryParameters: {'scope': scope, 'period': periods},
);
final rows = asObjects((r.data ?? const {})['rows']).map(BenchmarkRow.fromJson).toList();
final rows = asObjects((r.data ?? const {})['rows'])
.map(BenchmarkRow.fromJson)
.toList();
return Cached(rows, fetchedAt: r.extra['fetchedAt'] as DateTime?);
}
}
+167 -151
View File
@@ -3,22 +3,45 @@ import 'package:fintracker_api/fintracker_api.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/chart_colors.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/money_text.dart';
import '../../core/widgets/help_tip.dart';
import 'benchmarks_card.dart';
import 'holdings_table.dart';
import 'labels.dart';
import 'providers.dart';
double _d(String s) => Decimal.parse(s).toDouble();
/// Which parts of the screen a [HoldingsTab] draws. The same providers feed all of them, so
/// the numbers on Портфель and on Аналитика → Общее can never disagree.
enum HoldingsSections {
/// Everything, top to bottom.
all,
/// Only the assets table — the Портфель screen.
table,
/// The charts and returns, without the table — Аналитика → Общее.
overview,
}
/// Позиции: what the portfolio holds, what it is worth, and what it earned.
class HoldingsTab extends ConsumerWidget {
const HoldingsTab({super.key});
const HoldingsTab({
this.sections = HoldingsSections.all,
this.header,
super.key,
});
final HoldingsSections sections;
/// Drawn above everything else, inside the same scrolling list.
final Widget? header;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -26,58 +49,73 @@ class HoldingsTab extends ConsumerWidget {
final holdings = ref.watch(holdingsProvider);
final series = ref.watch(valueSeriesProvider);
final returns = ref.watch(portfolioReturnsProvider);
final all = sections == HoldingsSections.all;
final withTable = all || sections == HoldingsSections.table;
final withCharts = all || sections == HoldingsSections.overview;
return RefreshIndicator(
onRefresh: () async => invalidatePortfolioProviders(ref),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
AsyncValueView(
value: summary,
onRetry: () => ref.invalidate(portfolioSummaryProvider),
data: (cached) => _SummaryTiles(summary: cached.data),
),
const SizedBox(height: 20),
_Card(
title: 'Стоимость за 365 дней',
child: AsyncValueView(
value: series,
onRetry: () => ref.invalidate(valueSeriesProvider),
data: (cached) => cached.data.isEmpty
? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.')
: _ValueChart(rows: cached.data),
if (header != null) ...[header!, const SizedBox(height: 16)],
if (all) ...[
AsyncValueView(
value: summary,
onRetry: () => ref.invalidate(portfolioSummaryProvider),
data: (cached) => _SummaryTiles(summary: cached.data),
),
),
const SizedBox(height: 16),
_Card(
title: 'Доходность',
child: AsyncValueView(
value: returns,
onRetry: () => ref.invalidate(portfolioReturnsProvider),
data: (cached) => cached.data.isEmpty
? const EmptyState(icon: Icons.percent, message: 'Пока нечего считать.')
: _ReturnsTable(rows: cached.data),
const SizedBox(height: 20),
],
if (withCharts) ...[
_Card(
title: 'Стоимость за 365 дней',
child: AsyncValueView(
value: series,
onRetry: () => ref.invalidate(valueSeriesProvider),
data: (cached) => cached.data.isEmpty
? const EmptyState(
icon: Icons.show_chart,
message: 'Пока нет данных.',
)
: _ValueChart(rows: cached.data),
),
),
),
const SizedBox(height: 16),
const _Card(
title: 'Сравнение с бенчмарками',
child: BenchmarksCard(),
),
const SizedBox(height: 16),
_Card(
title: 'Позиции',
child: AsyncValueView(
value: holdings,
onRetry: () => ref.invalidate(holdingsProvider),
data: (cached) => cached.data.isEmpty
? const EmptyState(
icon: Icons.inventory_2_outlined,
message: 'Открытых позиций нет — нужна синхронизация брокера.',
)
: _HoldingsTable(rows: cached.data),
const SizedBox(height: 16),
_Card(
title: 'Доходность',
child: AsyncValueView(
value: returns,
onRetry: () => ref.invalidate(portfolioReturnsProvider),
data: (cached) => cached.data.isEmpty
? const EmptyState(
icon: Icons.percent,
message: 'Пока нечего считать.',
)
: _ReturnsTable(rows: cached.data),
),
),
const SizedBox(height: 16),
const _Card(
title: 'Сравнение с бенчмарками',
child: BenchmarksCard(),
),
],
if (withTable && withCharts) const SizedBox(height: 16),
if (withTable)
_Card(
title: all ? 'Позиции' : null,
child: AsyncValueView(
value: holdings,
onRetry: () => ref.invalidate(holdingsProvider),
data: (cached) => cached.data.isEmpty
? const EmptyState(
icon: Icons.inventory_2_outlined,
message: 'Открытых позиций нет — нужна синхронизация брокера.',
)
: HoldingsTable(rows: cached.data),
),
),
),
],
),
);
@@ -113,7 +151,9 @@ class _SummaryTiles extends StatelessWidget {
: MoneyText(
summary.pnlTotalRub!,
currency: 'RUB',
style: TextStyle(color: signColor(context, summary.pnlTotalRub)),
style: TextStyle(
color: signColor(context, summary.pnlTotalRub),
),
),
note: summary.pnlTotalRub == null
? 'часть позиций без цены'
@@ -171,14 +211,19 @@ class _Tile extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: theme.textTheme.bodySmall),
TermLabel(label, style: theme.textTheme.bodySmall),
const SizedBox(height: 4),
DefaultTextStyle(style: theme.textTheme.titleMedium!, child: value),
DefaultTextStyle(
style: theme.textTheme.titleMedium!,
child: value,
),
if (note != null) ...[
const SizedBox(height: 2),
Text(
note!,
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
maxLines: 2,
),
],
@@ -224,7 +269,10 @@ class _ValueChart extends StatelessWidget {
getTitlesWidget: (value, meta) {
final i = value.round();
if (i < 0 || i >= rows.length) return const SizedBox.shrink();
return Text(ruMonthYearShort(rows[i].d), style: theme.textTheme.bodySmall);
return Text(
ruMonthYearShort(rows[i].d),
style: theme.textTheme.bodySmall,
);
},
),
),
@@ -277,112 +325,78 @@ class _ReturnsTable extends StatelessWidget {
child: DataTable(
columns: [
DataColumn(label: Text('Период', style: headerStyle)),
DataColumn(label: Text('Прибыль', style: headerStyle), numeric: true),
DataColumn(label: Text('Потоки', style: headerStyle), numeric: true),
DataColumn(label: Text('XIRR', style: headerStyle), numeric: true),
DataColumn(label: Text('TWR', style: headerStyle), numeric: true),
DataColumn(
label: TermLabel(
'Прибыль',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'Потоки',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'XIRR',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'TWR',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
],
rows: [
for (final r in rows)
DataRow(
cells: [
DataCell(Text(periodLabel(r.period))),
DataCell(MoneyText(
r.absPnlRub,
currency: 'RUB',
style: TextStyle(color: signColor(context, r.absPnlRub)),
)),
DataCell(
MoneyText(
r.absPnlRub,
currency: 'RUB',
style: TextStyle(color: signColor(context, r.absPnlRub)),
),
),
DataCell(MoneyText(r.externalFlowRub, currency: 'RUB')),
DataCell(Text(
formatPercent(r.xirr),
style: TextStyle(color: signColor(context, r.xirr)),
)),
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
formatPercent(r.twr),
style: TextStyle(color: signColor(context, r.twr)),
),
if (r.twrDaysSkipped > 0) ...[
const SizedBox(width: 4),
Tooltip(
message: 'Пропущено ${r.twrDaysSkipped} дн.: '
'в эти дни часть позиции была без цены',
child: const Icon(Icons.info_outline, size: 14),
DataCell(
Text(
formatPercent(r.xirr),
style: TextStyle(color: signColor(context, r.xirr)),
),
),
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
formatPercent(r.twr),
style: TextStyle(color: signColor(context, r.twr)),
),
if (r.twrDaysSkipped > 0) ...[
const SizedBox(width: 4),
Tooltip(
message:
'Пропущено ${r.twrDaysSkipped} дн.: '
'в эти дни часть позиции была без цены',
child: const Icon(Icons.info_outline, size: 14),
),
],
],
],
)),
],
),
],
),
);
}
}
class _HoldingsTable extends StatelessWidget {
const _HoldingsTable({required this.rows});
final List<HoldingOut> rows;
@override
Widget build(BuildContext context) {
final headerStyle = Theme.of(context).textTheme.labelMedium;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columns: [
DataColumn(label: Text('Бумага', style: headerStyle)),
DataColumn(label: Text('Класс', style: headerStyle)),
DataColumn(label: Text('Кол-во', style: headerStyle), numeric: true),
DataColumn(label: Text('Цена', style: headerStyle), numeric: true),
DataColumn(label: Text('Стоимость', style: headerStyle), numeric: true),
DataColumn(label: Text('Доля', style: headerStyle), numeric: true),
DataColumn(label: Text('Нереализ.', style: headerStyle), numeric: true),
DataColumn(label: Text('XIRR', style: headerStyle), numeric: true),
],
rows: [
for (final r in rows)
DataRow(
onSelectChanged: (_) => context.push('/portfolio/instrument/${r.instrumentId}'),
cells: [
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
r.ticker ?? r.name,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
PriceStatusChip(status: r.priceStatus, priceDate: r.priceDate),
],
)),
DataCell(Text(assetClassLabel(r.assetClass))),
DataCell(Text(formatQty(r.qty))),
DataCell(r.marketPrice == null
? const Text('')
: MoneyText(r.marketPrice!, currency: r.priceCurrency ?? r.currency)),
// a position with no price shows nothing, never 0 ₽: it is absent from the
// totals above, and a zero would read as "worthless" instead of "unknown"
DataCell(r.valueRub == null
? const Text('')
: MoneyText(r.valueRub!, currency: 'RUB')),
DataCell(Text(formatPercent(r.weight, signed: false))),
DataCell(r.unrealizedPnlRub == null
? const Text('')
: MoneyText(
r.unrealizedPnlRub!,
currency: 'RUB',
style: TextStyle(color: signColor(context, r.unrealizedPnlRub)),
)),
DataCell(Text(
formatPercent(r.xirr),
style: TextStyle(color: signColor(context, r.xirr)),
)),
),
),
],
),
],
@@ -392,9 +406,9 @@ class _HoldingsTable extends StatelessWidget {
}
class _Card extends StatelessWidget {
const _Card({required this.title, required this.child});
const _Card({required this.child, this.title});
final String title;
final String? title;
final Widget child;
@override
@@ -405,8 +419,10 @@ class _Card extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
if (title != null) ...[
TermLabel(title!, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
],
child,
],
),
@@ -0,0 +1,466 @@
import 'package:decimal/decimal.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../core/widgets/asset_icon.dart';
import '../../core/widgets/money_text.dart';
import '../../core/widgets/help_tip.dart';
import 'labels.dart';
/// Which columns the table shows. Each preset answers one question about the same rows, the
/// way Snowball's «Мои активы | Общее | Дивиденды | Прибыль | Облигации» tabs do.
enum HoldingsPreset {
mine('Мои активы'),
overall('Общее'),
dividends('Дивиденды'),
profit('Прибыль'),
bonds('Облигации');
const HoldingsPreset(this.label);
final String label;
}
/// A column: a header, a width, and how a position renders in it. A cell is one or two lines —
/// the figure and, muted below it, the same figure per unit or as a share.
class _Col {
const _Col(this.title, this.width, this.cell, {this.alignEnd = true});
final String title;
final double width;
final Widget Function(BuildContext context, HoldingOut h) cell;
final bool alignEnd;
}
const _dash = Text('');
/// A cell whose figure is unknown is a dash, never a zero: the position is absent from the
/// totals, and 0 ₽ would read as «worthless» instead of «unknown».
Widget _money(
String? v, {
String currency = 'RUB',
TextStyle? style,
bool signed = false,
}) {
if (v == null) return _dash;
final d = Decimal.parse(v);
final text = MoneyText.format(v, currency);
return Text(signed && d > Decimal.zero ? '+$text' : text, style: style);
}
Widget _stack(BuildContext context, Widget top, Widget? bottom) {
final muted = Theme.of(context).textTheme.bodySmall
?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant);
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
DefaultTextStyle.merge(
style: const TextStyle(fontWeight: FontWeight.w600),
child: top,
),
if (bottom != null) DefaultTextStyle.merge(style: muted, child: bottom),
],
);
}
/// `share` of a decimal-string pair, or null when the base is missing or not positive.
String? _ratio(String? part, String? base) {
if (part == null || base == null) return null;
final b = Decimal.parse(base);
if (b <= Decimal.zero) return null;
return (Decimal.parse(part) / b)
.toDecimal(scaleOnInfinitePrecision: 10)
.toString();
}
Widget _signedPercent(BuildContext context, String? share) {
if (share == null) return _dash;
final color = signColor(context, share);
final d = Decimal.parse(share);
final arrow = d > Decimal.zero
? Icons.arrow_drop_up
: d < Decimal.zero
? Icons.arrow_drop_down
: null;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (arrow != null) Icon(arrow, size: 18, color: color),
Text(
formatPercent(share, signed: false).replaceAll('-', ''),
style: TextStyle(color: color),
),
],
);
}
Widget _pnl(BuildContext context, String? amount, String? share) {
if (amount == null) return _dash;
return _stack(
context,
_money(
amount,
signed: true,
style: TextStyle(color: signColor(context, amount)),
),
_signedPercent(context, share),
);
}
final _asset = _Col('Актив', 260, (context, h) {
final theme = Theme.of(context);
final title = h.name;
final ticker = h.ticker;
return Row(
children: [
AssetIcon(
assetClass: h.assetClass,
logoUrl: h.logoUrl,
logoColor: h.logoColor,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w500),
),
Row(
children: [
if (ticker != null && ticker != title)
Flexible(
child: Text(
ticker,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
PriceStatusChip(status: h.priceStatus, priceDate: h.priceDate),
],
),
],
),
),
],
);
}, alignEnd: false);
final _qty = _Col('Кол-во', 90, (c, h) => Text(formatQty(h.qty)));
final _invested = _Col(
'Вложено',
140,
(c, h) => _stack(
c,
_money(h.costTotalRub),
h.avgCost == null
? null
: _money(h.avgCost, currency: h.costCurrency ?? h.currency),
),
);
final _value = _Col(
'Текущая стоимость',
170,
(c, h) => h.valueRub == null
? _dash
: _stack(
c,
_money(h.valueRub),
h.marketPrice == null
? null
: _money(h.marketPrice, currency: h.priceCurrency ?? h.currency),
),
);
final _income = _Col('Дивиденды', 120, (c, h) => _money(_nonZero(h.incomeRub)));
final _incomeYield = _Col(
'Див. доходность',
130,
(c, h) => Text(
formatPercent(_nonZero(_ratio(h.incomeRub, h.costTotalRub)), signed: false),
),
);
final _profit = _Col(
'Прибыль',
130,
(c, h) =>
_pnl(c, h.unrealizedPnlRub, _ratio(h.unrealizedPnlRub, h.costTotalRub)),
);
final _weight = _Col(
'Доля в портфеле',
130,
(c, h) => Text(formatPercent(h.weight, signed: false)),
);
final _xirr = _Col(
'Доходность',
120,
(c, h) => Text(
formatPercent(h.xirr),
style: TextStyle(color: signColor(c, h.xirr)),
),
);
final _held = _Col(
'В портфеле',
120,
(c, h) => Text(h.daysHeld == null ? '' : '${h.daysHeld} дн.'),
);
final _realized = _Col(
'Реализовано',
130,
(c, h) => _money(
h.realizedPnlRub,
signed: true,
style: TextStyle(color: signColor(c, h.realizedPnlRub)),
),
);
final _accrued = _Col(
'НКД',
110,
(c, h) => _money(_nonZero(h.accruedInterestRub)),
);
/// A payout or accrual of zero is «none», shown as a dash; only a real amount is a figure.
String? _nonZero(String? v) =>
v == null || Decimal.parse(v) == Decimal.zero ? null : v;
final _columns = {
HoldingsPreset.mine: [
_asset,
_qty,
_invested,
_value,
_income,
_incomeYield,
_profit,
_weight,
],
HoldingsPreset.overall: [
_asset,
_qty,
_invested,
_value,
_profit,
_xirr,
_weight,
_held,
],
HoldingsPreset.dividends: [_asset, _qty, _income, _incomeYield],
HoldingsPreset.profit: [_asset, _profit, _realized, _income, _xirr],
HoldingsPreset.bonds: [
_asset,
_qty,
_invested,
_value,
_accrued,
_profit,
_xirr,
],
};
/// Позиции as a table: preset tabs, a search box, and one row per holding. A tap opens the
/// instrument card.
class HoldingsTable extends StatefulWidget {
const HoldingsTable({required this.rows, super.key});
final List<HoldingOut> rows;
@override
State<HoldingsTable> createState() => _HoldingsTableState();
}
class _HoldingsTableState extends State<HoldingsTable> {
HoldingsPreset _preset = HoldingsPreset.mine;
String _query = '';
List<HoldingOut> get _visible {
final q = _query.trim().toLowerCase();
return [
for (final h in widget.rows)
if ((_preset != HoldingsPreset.bonds || h.assetClass == 'bond') &&
(q.isEmpty ||
h.name.toLowerCase().contains(q) ||
(h.ticker ?? '').toLowerCase().contains(q)))
h,
];
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final columns = _columns[_preset]!;
final rows = _visible;
const pad = 32.0;
final natural = columns.fold<double>(0, (sum, c) => sum + c.width);
return LayoutBuilder(
builder: (context, constraints) {
// spread the columns over the card when there is room; scroll sideways when there is not
final room = constraints.maxWidth.isFinite
? constraints.maxWidth - pad
: natural;
final scale = room > natural ? room / natural : 1.0;
final tableWidth = natural * scale + pad;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: double.infinity,
child: Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
alignment: WrapAlignment.spaceBetween,
children: [
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final p in HoldingsPreset.values)
InkWell(
borderRadius: BorderRadius.circular(6),
onTap: () => setState(() => _preset = p),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
child: Text(
p.label,
style: TextStyle(
color: p == _preset
? scheme.onSurface
: scheme.onSurfaceVariant,
fontWeight: p == _preset
? FontWeight.w700
: FontWeight.w400,
),
),
),
),
],
),
SizedBox(
width: 260,
child: TextField(
onChanged: (v) => setState(() => _query = v),
decoration: const InputDecoration(
hintText: 'Найти…',
isDense: true,
prefixIcon: Icon(Icons.search, size: 20),
),
),
),
],
),
),
const SizedBox(height: 12),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
width: tableWidth,
child: Column(
children: [
_HeaderRow(columns: columns, scale: scale),
Divider(color: scheme.outlineVariant),
if (rows.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Text('Ничего не найдено'),
),
for (final h in rows)
InkWell(
onTap: () => context.push(
'/portfolio/instrument/${h.instrumentId}',
),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: scheme.outlineVariant),
),
),
child: Row(
children: [
for (final c in columns)
SizedBox(
width: c.width * scale,
child: Align(
alignment: c.alignEnd
? Alignment.centerRight
: Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.only(
right: c.alignEnd ? 8 : 0,
),
child: c.cell(context, h),
),
),
),
],
),
),
),
],
),
),
),
],
);
},
);
}
}
class _HeaderRow extends StatelessWidget {
const _HeaderRow({required this.columns, required this.scale});
final List<_Col> columns;
final double scale;
@override
Widget build(BuildContext context) {
final style = Theme.of(context).textTheme.labelMedium
?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
for (final c in columns)
SizedBox(
width: c.width * scale,
child: Padding(
padding: EdgeInsets.only(right: c.alignEnd ? 8 : 0),
child: TermLabel(
c.title,
textAlign: c.alignEnd ? TextAlign.end : TextAlign.start,
alignment: c.alignEnd
? MainAxisAlignment.end
: MainAxisAlignment.start,
style: style,
),
),
),
],
),
);
}
}
@@ -0,0 +1,147 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import 'labels.dart';
/// Edit form for the fields of an instrument a person may correct. Returns a patch holding
/// only what changed, or null when cancelled or nothing changed.
class InstrumentEditDialog extends StatefulWidget {
const InstrumentEditDialog({required this.instrument, super.key});
final InstrumentOut instrument;
@override
State<InstrumentEditDialog> createState() => _InstrumentEditDialogState();
}
class _InstrumentEditDialogState extends State<InstrumentEditDialog> {
final _formKey = GlobalKey<FormState>();
late final _name = TextEditingController(text: widget.instrument.name);
late final _board = TextEditingController(
text: widget.instrument.board ?? '',
);
late final _lot = TextEditingController(text: '${widget.instrument.lot}');
late final _sector = TextEditingController(
text: widget.instrument.sector ?? '',
);
late String _assetClass = widget.instrument.assetClass;
@override
void dispose() {
_name.dispose();
_board.dispose();
_lot.dispose();
_sector.dispose();
super.dispose();
}
InstrumentPatch? _patch() {
final i = widget.instrument;
final name = _name.text.trim();
final board = _board.text.trim();
final sector = _sector.text.trim();
final lot = int.parse(_lot.text.trim());
final patch = InstrumentPatch(
name: name != i.name ? name : null,
assetClass: _assetClass != i.assetClass ? _assetClass : null,
// an empty field means "leave as is": the wire format cannot express "clear"
board: board.isNotEmpty && board != (i.board ?? '') ? board : null,
lot: lot != i.lot ? lot : null,
sector: sector.isNotEmpty && sector != (i.sector ?? '') ? sector : null,
);
final changed =
patch.name != null ||
patch.assetClass != null ||
patch.board != null ||
patch.lot != null ||
patch.sector != null;
return changed ? patch : null;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Инструмент ${widget.instrument.ticker ?? ''}'.trim()),
content: SizedBox(
width: 420,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _name,
decoration: const InputDecoration(labelText: 'Название'),
validator: (v) =>
(v ?? '').trim().isEmpty ? 'Обязательное поле' : null,
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: assetClassLabels.containsKey(_assetClass)
? _assetClass
: null,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Класс актива'),
items: [
for (final e in assetClassLabels.entries)
DropdownMenuItem(
value: e.key,
child: Text('${e.value} (${e.key})'),
),
],
onChanged: (v) =>
setState(() => _assetClass = v ?? _assetClass),
),
const SizedBox(height: 12),
TextFormField(
controller: _board,
textCapitalization: TextCapitalization.characters,
decoration: const InputDecoration(
labelText: 'Доска (TQBR, TQTF…)',
helperText: 'По ней синк Мосбиржи находит котировки',
),
),
const SizedBox(height: 12),
TextFormField(
controller: _lot,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Лот',
helperText:
'Ребалансировка округляет сделки до целого числа лотов',
helperMaxLines: 2,
),
validator: (v) {
final n = int.tryParse((v ?? '').trim());
return n == null || n < 1
? 'Целое число не меньше 1'
: null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _sector,
decoration: const InputDecoration(labelText: 'Сектор'),
),
],
),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () {
if (!(_formKey.currentState?.validate() ?? false)) return;
Navigator.of(context).pop(_patch());
},
child: const Text('Сохранить'),
),
],
);
}
}
+166 -50
View File
@@ -1,15 +1,21 @@
import 'package:decimal/decimal.dart';
import 'package:dio/dio.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:fl_chart/fl_chart.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/theme/chart_colors.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/asset_icon.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/money_text.dart';
import '../../core/widgets/stale_banner.dart';
import '../../core/widgets/help_tip.dart';
import 'instrument_edit_dialog.dart';
import 'labels.dart';
import 'providers.dart';
@@ -23,15 +29,51 @@ class InstrumentPage extends ConsumerWidget {
final int instrumentId;
Future<void> _edit(
BuildContext context,
WidgetRef ref,
InstrumentOut instrument,
) async {
final patch = await showDialog<InstrumentPatch>(
context: context,
builder: (_) => InstrumentEditDialog(instrument: instrument),
);
if (patch == null || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
try {
final api = ref.read(apiProvider);
await api.getInstrumentsApi().instrumentsPatch(
instrumentId: instrumentId,
instrumentPatch: patch,
);
invalidatePortfolioProviders(ref);
try {
// the asset class feeds the allocation metrics; the lot only the live rebalancing
await api.getMetricsApi().metricsRefresh();
} on DioException {
// the next scheduled or manual refresh picks the change up
}
} on DioException catch (e) {
messenger.showSnackBar(SnackBar(content: Text(problemMessage(e))));
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final detail = ref.watch(instrumentProvider(instrumentId));
final instrument = detail.valueOrNull?.data.instrument;
return Scaffold(
appBar: AppBar(
title: Text(detail.valueOrNull?.data.instrument.ticker ??
detail.valueOrNull?.data.instrument.name ??
'Инструмент'),
title: Text(instrument?.ticker ?? instrument?.name ?? 'Инструмент'),
actions: [
if (instrument != null)
IconButton(
tooltip: 'Редактировать',
icon: const Icon(Icons.edit_outlined),
onPressed: () => _edit(context, ref, instrument),
),
],
),
body: AsyncValueView(
value: detail,
@@ -41,7 +83,8 @@ class InstrumentPage extends ConsumerWidget {
return ListView(
padding: const EdgeInsets.all(16),
children: [
if (cached.fetchedAt != null) StaleBanner(fetchedAt: cached.fetchedAt!),
if (cached.fetchedAt != null)
StaleBanner(fetchedAt: cached.fetchedAt!),
_Header(instrument: d.instrument, holding: d.holding),
const SizedBox(height: 16),
_Section(
@@ -57,14 +100,20 @@ class InstrumentPage extends ConsumerWidget {
_Section(
title: 'Лоты',
child: d.lots.isEmpty
? const EmptyState(icon: Icons.layers_outlined, message: 'Лотов нет.')
? const EmptyState(
icon: Icons.layers_outlined,
message: 'Лотов нет.',
)
: _LotsTable(lots: d.lots),
),
const SizedBox(height: 16),
_Section(
title: 'События',
child: d.events.isEmpty
? const EmptyState(icon: Icons.receipt_long, message: 'Событий нет.')
? const EmptyState(
icon: Icons.receipt_long,
message: 'Событий нет.',
)
: _EventsTable(events: d.events),
),
],
@@ -97,10 +146,32 @@ class _Header extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(instrument.name, style: theme.textTheme.titleLarge),
const SizedBox(height: 4),
Text(facts.join(' · '),
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)),
Row(
children: [
AssetIcon(
assetClass: instrument.assetClass,
logoUrl: instrument.logoUrl,
logoColor: instrument.logoColor,
size: 48,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(instrument.name, style: theme.textTheme.titleLarge),
const SizedBox(height: 4),
Text(
facts.join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
),
),
],
),
),
],
),
const SizedBox(height: 16),
if (holding == null)
Text('Позиция закрыта', style: theme.textTheme.bodyMedium)
@@ -139,7 +210,10 @@ class _HoldingFacts extends StatelessWidget {
children: [
h.marketPrice == null
? const Text('')
: MoneyText(h.marketPrice!, currency: h.priceCurrency ?? h.currency),
: MoneyText(
h.marketPrice!,
currency: h.priceCurrency ?? h.currency,
),
const SizedBox(width: 6),
PriceStatusChip(status: h.priceStatus, priceDate: h.priceDate),
],
@@ -147,7 +221,9 @@ class _HoldingFacts extends StatelessWidget {
),
_Fact(
label: 'Стоимость',
value: h.valueRub == null ? const Text('') : MoneyText(h.valueRub!, currency: 'RUB'),
value: h.valueRub == null
? const Text('')
: MoneyText(h.valueRub!, currency: 'RUB'),
),
_Fact(
label: 'Нереализованная',
@@ -156,14 +232,19 @@ class _HoldingFacts extends StatelessWidget {
: MoneyText(
h.unrealizedPnlRub!,
currency: 'RUB',
style: TextStyle(color: signColor(context, h.unrealizedPnlRub)),
style: TextStyle(
color: signColor(context, h.unrealizedPnlRub),
),
),
),
_Fact(
label: 'Реализованная',
value: MoneyText(h.realizedPnlRub ?? '0', currency: 'RUB'),
),
_Fact(label: 'Выплаты', value: MoneyText(h.incomeRub ?? '0', currency: 'RUB')),
_Fact(
label: 'Выплаты',
value: MoneyText(h.incomeRub ?? '0', currency: 'RUB'),
),
_Fact(
label: 'XIRR',
value: Text(
@@ -195,7 +276,10 @@ class _Fact extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)),
TermLabel(
label,
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
),
const SizedBox(height: 2),
DefaultTextStyle(style: theme.textTheme.titleSmall!, child: value),
],
@@ -230,8 +314,12 @@ class _PriceChart extends StatelessWidget {
interval: (prices.length / 4).clamp(1, double.infinity),
getTitlesWidget: (value, meta) {
final i = value.round();
if (i < 0 || i >= prices.length) return const SizedBox.shrink();
return Text(ruMonthYearShort(prices[i].d), style: theme.textTheme.bodySmall);
if (i < 0 || i >= prices.length)
return const SizedBox.shrink();
return Text(
ruMonthYearShort(prices[i].d),
style: theme.textTheme.bodySmall,
);
},
),
),
@@ -245,7 +333,7 @@ class _PriceChart extends StatelessWidget {
isCurved: false,
color: ChartColors.slot1Blue,
barWidth: 2,
dotData: const FlDotData(show: false),
dotData: FlDotData(show: prices.length < 2),
),
],
),
@@ -268,9 +356,15 @@ class _LotsTable extends StatelessWidget {
columns: [
DataColumn(label: Text('Открыт', style: headerStyle)),
DataColumn(label: Text('Куплено', style: headerStyle), numeric: true),
DataColumn(label: Text('Осталось', style: headerStyle), numeric: true),
DataColumn(
label: Text('Осталось', style: headerStyle),
numeric: true,
),
DataColumn(label: Text('Цена', style: headerStyle), numeric: true),
DataColumn(label: Text('Стоимость, ₽', style: headerStyle), numeric: true),
DataColumn(
label: Text('Стоимость, ₽', style: headerStyle),
numeric: true,
),
DataColumn(label: Text('Закрыт', style: headerStyle)),
],
rows: [
@@ -280,11 +374,17 @@ class _LotsTable extends StatelessWidget {
DataCell(Text(ruDate(lot.openDate))),
DataCell(Text(formatQty(lot.qtyOpen))),
DataCell(Text(formatQty(lot.qtyRemaining))),
DataCell(MoneyText(lot.costPerUnit, currency: lot.costCurrency)),
DataCell(lot.costTotalRub == null
? const Text('')
: MoneyText(lot.costTotalRub!, currency: 'RUB')),
DataCell(Text(lot.closedAt == null ? '' : ruDate(lot.closedAt!))),
DataCell(
MoneyText(lot.costPerUnit, currency: lot.costCurrency),
),
DataCell(
lot.costTotalRub == null
? const Text('')
: MoneyText(lot.costTotalRub!, currency: 'RUB'),
),
DataCell(
Text(lot.closedAt == null ? '' : ruDate(lot.closedAt!)),
),
],
),
],
@@ -317,32 +417,48 @@ class _EventsTable extends StatelessWidget {
DataRow(
cells: [
DataCell(Text(ruDate(e.tradeDate))),
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(eventKindLabel(e.kind)),
if (e.externalFlow) ...[
const SizedBox(width: 4),
const Tooltip(
message: 'Внешний поток — учитывается в XIRR',
child: Icon(Icons.swap_vert, size: 14),
),
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(eventKindLabel(e.kind)),
if (e.externalFlow) ...[
const SizedBox(width: 4),
const Tooltip(
message: 'Внешний поток — учитывается в XIRR',
child: Icon(Icons.swap_vert, size: 14),
),
],
],
],
)),
DataCell(Text(e.quantity == null ? '' : formatQty(e.quantity!))),
DataCell(e.price == null
? const Text('')
: MoneyText(e.price!, currency: e.priceCurrency ?? e.currency)),
DataCell(MoneyText(
e.amount,
currency: e.currency,
style: TextStyle(color: signColor(context, e.amount)),
)),
DataCell(SizedBox(
width: 260,
child: Text(e.description ?? '', overflow: TextOverflow.ellipsis),
)),
),
),
DataCell(
Text(e.quantity == null ? '' : formatQty(e.quantity!)),
),
DataCell(
e.price == null
? const Text('')
: MoneyText(
e.price!,
currency: e.priceCurrency ?? e.currency,
),
),
DataCell(
MoneyText(
e.amount,
currency: e.currency,
style: TextStyle(color: signColor(context, e.amount)),
),
),
DataCell(
SizedBox(
width: 260,
child: Text(
e.description ?? '',
overflow: TextOverflow.ellipsis,
),
),
),
],
),
],
+10 -4
View File
@@ -38,7 +38,8 @@ const dimensionLabels = {
'currency': 'Валюта',
};
String assetClassLabel(String? value) => assetClassLabels[value] ?? value ?? '';
String assetClassLabel(String? value) =>
assetClassLabels[value] ?? value ?? '';
/// A bucket key as a person reads it. `cash` and `unknown` are the two literals the
/// allocation step emits; everything else is the source's own value.
@@ -89,7 +90,8 @@ const eventKindLabels = {
'other': 'Прочее',
};
String eventKindLabel(EventKind kind) => eventKindLabels[kind.value] ?? kind.value;
String eventKindLabel(EventKind kind) =>
eventKindLabels[kind.value] ?? kind.value;
/// `'0.1234'` as `'+12,34 %'`. Null becomes an em dash: a return nobody could compute is
/// not zero percent.
@@ -111,13 +113,17 @@ Color? signColor(BuildContext context, String? value) {
if (value == null) return null;
final d = Decimal.parse(value);
if (d == Decimal.zero) return null;
return d > Decimal.zero ? ChartColors.slot3Aqua : ChartColors.slot2Orange;
return d > Decimal.zero ? ChartColors.gain : ChartColors.loss;
}
/// The price-status chip every value on screen depends on: a stale price still produces a
/// number, a missing one produces nothing at all, and both must be visible.
class PriceStatusChip extends StatelessWidget {
const PriceStatusChip({required this.status, required this.priceDate, super.key});
const PriceStatusChip({
required this.status,
required this.priceDate,
super.key,
});
final String status;
final DateTime? priceDate;
@@ -0,0 +1,222 @@
import 'package:decimal/decimal.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/cache/cached.dart';
import '../../core/theme/chart_colors.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/money_text.dart';
import '../home/providers.dart' show scopeCardsProvider;
import '../../core/widgets/help_tip.dart';
import 'labels.dart';
import 'providers.dart';
/// The four figures on top of Аналитика → Общее: what the scope is worth, what it earned, its
/// return and the passive income it should bring in a year. Everything comes from the same
/// providers as Портфель and the home cards, so the numbers cannot disagree between screens.
class OverviewTiles extends ConsumerWidget {
const OverviewTiles({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final summary = ref.watch(portfolioSummaryProvider);
final scope = ref.watch(scopeProvider);
final card = ref
.watch(scopeCardsProvider)
.valueOrNull
?.data
.where((c) => c.scope == scope)
.firstOrNull;
return AsyncValueView<Cached<SummaryOut>>(
value: summary,
onRetry: () => ref.invalidate(portfolioSummaryProvider),
data: (cached) => _Grid(summary: cached.data, card: card),
);
}
}
class _Grid extends StatelessWidget {
const _Grid({required this.summary, required this.card});
final SummaryOut summary;
final ScopeCardOut? card;
static const _gap = 16.0;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final yearly = summary.returns.where((r) => r.period == '1y').firstOrNull;
final all = summary.returns.where((r) => r.period == 'all').firstOrNull;
final xirr = card?.xirr ?? all?.xirr;
final pnl = summary.pnlTotalRub;
final pnlShare =
pnl != null && Decimal.parse(summary.investedNetRub) > Decimal.zero
? (Decimal.parse(pnl) / Decimal.parse(summary.investedNetRub))
.toDecimal(scaleOnInfinitePrecision: 10)
.toString()
: null;
final day = card?.dayChangeRub;
final income = card?.incomeYearRub;
Widget muted(String text, {Color? color}) => Text(
text,
style: theme.textTheme.bodyMedium?.copyWith(
color: color ?? scheme.onSurfaceVariant,
),
);
final tiles = <Widget>[
_Tile(
icon: Icons.account_balance_wallet_outlined,
label: 'Стоимость',
value: Text(MoneyText.format(summary.totalRub, 'RUB')),
note: muted(
'${MoneyText.format(summary.investedNetRub, 'RUB')} вложено',
),
),
_Tile(
icon: Icons.show_chart,
label: 'Прибыль',
value: pnl == null
? const Text('')
: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
(Decimal.parse(pnl) > Decimal.zero ? '+' : '') +
MoneyText.format(pnl, 'RUB'),
style: TextStyle(color: signColor(context, pnl)),
),
if (pnlShare != null)
Text(
' ${formatPercent(pnlShare)}',
style: theme.textTheme.titleSmall?.copyWith(
color: signColor(context, pnl),
),
),
],
),
note: day == null
? muted(pnl == null ? 'часть позиций без цены' : 'за день —')
: muted(
'${Decimal.parse(day) > Decimal.zero ? '+' : ''}${MoneyText.format(day, 'RUB')}'
'${card?.dayChangePct == null ? '' : ' ${formatPercent(card!.dayChangePct)}'} за день',
color: signColor(context, day),
),
),
_Tile(
icon: Icons.percent,
label: 'Доходность',
value: Text(
formatPercent(xirr, signed: false),
style: TextStyle(color: signColor(context, xirr)),
),
note: muted(
yearly?.twr == null
? 'денежно-взвешенная, с начала'
: 'рост активов ${formatPercent(yearly!.twr)} за год',
),
),
_Tile(
icon: Icons.savings_outlined,
label: 'Пассивный доход',
value: Text(
card?.incomeYearPct == null
? ''
: formatPercent(card!.incomeYearPct, signed: false),
),
note: muted(
income == null || Decimal.parse(income) == Decimal.zero
? 'прогноза выплат пока нет'
: '${MoneyText.format(income, 'RUB')} в год',
color: income == null || Decimal.parse(income) == Decimal.zero
? null
: ChartColors.gain,
),
),
];
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final columns = width >= 1000 ? 4 : (width >= 560 ? 2 : 1);
final itemWidth = (width - _gap * (columns - 1)) / columns;
return Wrap(
spacing: _gap,
runSpacing: _gap,
children: [
for (final t in tiles) SizedBox(width: itemWidth, child: t),
],
);
},
);
}
}
class _Tile extends StatelessWidget {
const _Tile({
required this.icon,
required this.label,
required this.value,
required this.note,
});
final IconData icon;
final String label;
final Widget value;
final Widget note;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 18, color: scheme.primary),
),
const SizedBox(width: 10),
TermLabel(
label,
hint: label == 'Стоимость'
? 'Стоимость — всё сразу: бумаги по последним ценам плюс деньги на счёте. '
'Ниже — сколько вы вложили (пополнения минус выводы).'
: null,
style: theme.textTheme.bodyLarge?.copyWith(
color: scheme.onSurface,
),
),
],
),
const SizedBox(height: 16),
DefaultTextStyle.merge(
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w500,
),
child: value,
),
const SizedBox(height: 8),
note,
],
),
),
);
}
}
+12 -41
View File
@@ -1,14 +1,13 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/cache/cached.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/stale_banner.dart';
import 'allocation_tab.dart';
import 'benchmarks_card.dart';
import 'holdings_tab.dart';
import 'providers.dart';
import 'scope_selector.dart';
/// Портфель: позиции и аллокация as two tabs of one screen, sharing one scope.
///
@@ -37,9 +36,12 @@ class PortfolioPage extends ConsumerWidget {
child: Scaffold(
appBar: AppBar(
title: const Text('Портфель'),
actions: const [_ScopeSelector(), SizedBox(width: 8)],
actions: const [ScopeSelector(), SizedBox(width: 8)],
bottom: const TabBar(
tabs: [Tab(text: 'Позиции'), Tab(text: 'Аллокация')],
tabs: [
Tab(text: 'Позиции'),
Tab(text: 'Аллокация'),
],
),
),
body: Column(
@@ -50,7 +52,12 @@ class PortfolioPage extends ConsumerWidget {
child: StaleBanner(fetchedAt: stale),
),
const Expanded(
child: TabBarView(children: [HoldingsTab(), AllocationTab()]),
child: TabBarView(
children: [
HoldingsTab(sections: HoldingsSections.table),
AllocationTab(),
],
),
),
],
),
@@ -58,39 +65,3 @@ class PortfolioPage extends ConsumerWidget {
);
}
}
/// Switches every portfolio screen at once. Hidden while there is nothing to choose
/// between — a dropdown with one option is furniture, not a control.
class _ScopeSelector extends ConsumerWidget {
const _ScopeSelector();
@override
Widget build(BuildContext context, WidgetRef ref) {
final scopes = ref.watch(scopesProvider);
final current = ref.watch(scopeProvider);
return AsyncValueView<List<ScopeOut>>(
value: scopes,
data: (rows) {
if (rows.length < 2) return const SizedBox.shrink();
final known = rows.any((s) => s.scope == current) ? current : rows.first.scope;
return DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: known,
borderRadius: BorderRadius.circular(8),
items: [
for (final s in rows)
DropdownMenuItem(
value: s.scope,
child: Text(s.name, overflow: TextOverflow.ellipsis),
),
],
onChanged: (value) {
if (value != null) ref.read(scopeProvider.notifier).state = value;
},
),
);
},
);
}
}
+66 -31
View File
@@ -20,51 +20,86 @@ final scopesProvider = FutureProvider.autoDispose<List<ScopeOut>>((ref) async {
});
/// See `docs/ai/offline-cache.md`.
final portfolioSummaryProvider = FutureProvider.autoDispose<Cached<SummaryOut>>((ref) async {
final portfolioSummaryProvider = FutureProvider.autoDispose<Cached<SummaryOut>>(
(ref) async {
final scope = ref.watch(scopeProvider);
final r = await ref
.watch(apiProvider)
.getAnalyticsApi()
.analyticsSummary(scope: scope);
return r.cached;
},
);
final holdingsProvider = FutureProvider.autoDispose<Cached<List<HoldingOut>>>((
ref,
) async {
final scope = ref.watch(scopeProvider);
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsSummary(scope: scope);
return r.cached;
final r = await ref
.watch(apiProvider)
.getAnalyticsApi()
.analyticsHoldings(scope: scope);
return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
});
final holdingsProvider = FutureProvider.autoDispose<Cached<List<HoldingOut>>>((ref) async {
final scope = ref.watch(scopeProvider);
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsHoldings(scope: scope);
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?);
});
final portfolioReturnsProvider =
FutureProvider.autoDispose<Cached<List<ReturnsOut>>>((ref) async {
final scope = ref.watch(scopeProvider);
final r = await ref
.watch(apiProvider)
.getAnalyticsApi()
.analyticsReturns(scope: scope);
return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
});
final portfolioReturnsProvider = FutureProvider.autoDispose<Cached<List<ReturnsOut>>>((ref) async {
final scope = ref.watch(scopeProvider);
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsReturns(scope: scope);
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?);
});
final allocationProvider = FutureProvider.autoDispose<Cached<List<AllocationBucket>>>((ref) async {
final scope = ref.watch(scopeProvider);
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsAllocation(scope: scope);
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?);
});
final allocationProvider =
FutureProvider.autoDispose<Cached<List<AllocationBucket>>>((ref) async {
final scope = ref.watch(scopeProvider);
final r = await ref
.watch(apiProvider)
.getAnalyticsApi()
.analyticsAllocation(scope: scope);
return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
});
/// Daily portfolio value for the last year, for the chart on Позиции.
final valueSeriesProvider = FutureProvider.autoDispose<Cached<List<ValueDay>>>((ref) async {
final valueSeriesProvider = FutureProvider.autoDispose<Cached<List<ValueDay>>>((
ref,
) async {
final scope = ref.watch(scopeProvider);
final now = DateTime.now();
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsValueSeries(
final r = await ref
.watch(apiProvider)
.getAnalyticsApi()
.analyticsValueSeries(
scope: scope,
from: now.subtract(const Duration(days: 365)),
to: now,
);
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?);
return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
});
final instrumentProvider =
FutureProvider.autoDispose.family<Cached<InstrumentDetail>, int>((ref, instrumentId) async {
final scope = ref.watch(scopeProvider);
final r = await ref
.watch(apiProvider)
.getInstrumentsApi()
.instrumentsGet(instrumentId: instrumentId, scope: scope);
return r.cached;
});
final instrumentProvider = FutureProvider.autoDispose
.family<Cached<InstrumentDetail>, int>((ref, instrumentId) async {
final scope = ref.watch(scopeProvider);
final r = await ref
.watch(apiProvider)
.getInstrumentsApi()
.instrumentsGet(instrumentId: instrumentId, scope: scope);
return r.cached;
});
/// Every portfolio provider, refreshed together after a metrics rebuild or a pull-to-refresh.
void invalidatePortfolioProviders(WidgetRef ref) {
@@ -0,0 +1,44 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/widgets/async_value_view.dart';
import 'providers.dart';
/// Switches every portfolio screen at once. Hidden while there is nothing to choose
/// between — a dropdown with one option is furniture, not a control.
class ScopeSelector extends ConsumerWidget {
const ScopeSelector({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final scopes = ref.watch(scopesProvider);
final current = ref.watch(scopeProvider);
return AsyncValueView<List<ScopeOut>>(
value: scopes,
data: (rows) {
if (rows.length < 2) return const SizedBox.shrink();
final known = rows.any((s) => s.scope == current)
? current
: rows.first.scope;
return DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: known,
borderRadius: BorderRadius.circular(8),
items: [
for (final s in rows)
DropdownMenuItem(
value: s.scope,
child: Text(s.name, overflow: TextOverflow.ellipsis),
),
],
onChanged: (value) {
if (value != null) ref.read(scopeProvider.notifier).state = value;
},
),
);
},
);
}
}
@@ -0,0 +1,130 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../accounts/accounts_page.dart' show accountKindLabel;
import '../accounts/providers.dart';
class PortfolioDraft {
const PortfolioDraft({required this.name, required this.accountIds});
final String name;
final Set<int> accountIds;
}
/// Create/edit form for a portfolio: a name and the accounts it is made of. Returns the
/// draft to save, or null when cancelled.
class PortfolioEditDialog extends ConsumerStatefulWidget {
const PortfolioEditDialog({super.key, this.initial});
final PortfolioOut? initial;
@override
ConsumerState<PortfolioEditDialog> createState() =>
_PortfolioEditDialogState();
}
class _PortfolioEditDialogState extends ConsumerState<PortfolioEditDialog> {
final _formKey = GlobalKey<FormState>();
late final _name = TextEditingController(text: widget.initial?.name ?? '');
late final Set<int> _selected = {...?widget.initial?.accountIds};
@override
void dispose() {
_name.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final accounts =
ref.watch(accountsProvider).valueOrNull?.data ?? const <AccountOut>[];
// Brokers first: that is what a portfolio to rebalance is made of. An archived account
// stays listed only while the portfolio already contains it, so it can be removed.
final shown =
[
for (final a in accounts)
if (!a.archived || _selected.contains(a.id)) a,
]..sort((a, b) {
final byBroker =
(b.kind == AccountKind.broker ? 1 : 0) -
(a.kind == AccountKind.broker ? 1 : 0);
return byBroker != 0 ? byBroker : a.name.compareTo(b.name);
});
return AlertDialog(
title: Text(widget.initial == null ? 'Новый портфель' : 'Портфель'),
content: SizedBox(
width: 420,
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _name,
decoration: const InputDecoration(labelText: 'Название'),
validator: (v) =>
(v ?? '').trim().isEmpty ? 'Обязательное поле' : null,
),
const SizedBox(height: 16),
Text(
'Счета в портфеле',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
Flexible(
child: shown.isEmpty
? const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Text('Счетов ещё нет — нужна синхронизация.'),
)
: ListView(
shrinkWrap: true,
children: [
for (final a in shown)
CheckboxListTile(
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
dense: true,
value: _selected.contains(a.id),
title: Text(a.name),
subtitle: Text(
'${accountKindLabel(a.kind)} · ${a.currency}',
),
onChanged: (v) => setState(() {
if (v ?? false) {
_selected.add(a.id);
} else {
_selected.remove(a.id);
}
}),
),
],
),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () {
if (!(_formKey.currentState?.validate() ?? false)) return;
Navigator.of(context).pop(
PortfolioDraft(
name: _name.text.trim(),
accountIds: {..._selected},
),
);
},
child: const Text('Сохранить'),
),
],
);
}
}
@@ -0,0 +1,197 @@
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:flutter/foundation.dart' show setEquals;
import '../../core/api/api_client.dart';
import '../../core/auth/auth_controller.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart';
import '../accounts/providers.dart';
import 'portfolio_edit_dialog.dart';
import 'providers.dart';
String _accountsLabel(int n) {
final mod10 = n % 10, mod100 = n % 100;
final word = mod10 == 1 && mod100 != 11
? 'счёт'
: (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14))
? 'счёта'
: 'счетов';
return '$n $word';
}
/// Портфели: a portfolio is a named set of accounts. Rebalancing and the portfolio scope of
/// the analytics screens are computed over it.
class PortfoliosPage extends ConsumerStatefulWidget {
const PortfoliosPage({super.key});
@override
ConsumerState<PortfoliosPage> createState() => _PortfoliosPageState();
}
class _PortfoliosPageState extends ConsumerState<PortfoliosPage> {
void _snack(String message) =>
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
/// Queues a metrics rebuild so the portfolio's value history follows the new account set.
/// Best effort: the portfolio itself is already saved, and the rebalance screen does not
/// depend on it.
Future<void> _queueMetricsRefresh() async {
try {
await ref.read(apiProvider).getMetricsApi().metricsRefresh();
} on DioException {
// the next scheduled or manual refresh picks the change up
}
}
Future<void> _run(Future<void> Function() action) async {
try {
await action();
if (!mounted) return;
invalidatePortfolios(ref);
await _queueMetricsRefresh();
} on DioException catch (e) {
if (mounted) _snack(problemMessage(e));
}
}
Future<void> _create() async {
final draft = await showDialog<PortfolioDraft>(
context: context,
builder: (_) => const PortfolioEditDialog(),
);
if (draft == null) return;
await _run(() async {
await ref
.read(apiProvider)
.getPortfoliosApi()
.portfoliosCreate(
portfolioCreate: PortfolioCreate(
name: draft.name,
accountIds: draft.accountIds.toList()..sort(),
),
);
});
}
Future<void> _edit(PortfolioOut portfolio) async {
final draft = await showDialog<PortfolioDraft>(
context: context,
builder: (_) => PortfolioEditDialog(initial: portfolio),
);
if (draft == null) return;
await _run(() async {
final api = ref.read(apiProvider).getPortfoliosApi();
if (draft.name != portfolio.name) {
await api.portfoliosPatch(
portfolioId: portfolio.id,
portfolioPatch: PortfolioPatch(name: draft.name),
);
}
if (!setEquals(draft.accountIds, portfolio.accountIds.toSet())) {
await api.portfoliosSetAccounts(
portfolioId: portfolio.id,
portfolioAccountsIn: PortfolioAccountsIn(
accountIds: draft.accountIds.toList()..sort(),
),
);
}
});
}
Future<void> _delete(PortfolioOut portfolio) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Удалить портфель?'),
content: Text(
'«${portfolio.name}» и его целевые доли будут удалены. Сами счета останутся.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Удалить'),
),
],
),
);
if (confirmed != true) return;
await _run(() async {
await ref
.read(apiProvider)
.getPortfoliosApi()
.portfoliosDelete(portfolioId: portfolio.id);
});
}
@override
Widget build(BuildContext context) {
final portfolios = ref.watch(portfolioListProvider);
final names = ref.watch(accountNamesProvider);
return Scaffold(
appBar: AppBar(title: const Text('Портфели')),
floatingActionButton: FloatingActionButton.extended(
onPressed: _create,
icon: const Icon(Icons.add),
label: const Text('Новый портфель'),
),
body: RefreshIndicator(
onRefresh: () async => ref.invalidate(portfolioListProvider),
child: AsyncValueView(
value: portfolios,
onRetry: () => ref.invalidate(portfolioListProvider),
data: (rows) {
if (rows.isEmpty) {
return ListView(
padding: const EdgeInsets.all(16),
children: const [
SizedBox(height: 48),
EmptyState(
icon: Icons.pie_chart_outline,
message:
'Портфелей пока нет.\n'
'Портфель — это набор счетов; по нему считаются ребалансировка '
'и аналитика. Создайте его и отметьте брокерские счета.',
),
],
);
}
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 88),
children: [
for (final p in rows)
Card(
child: ListTile(
title: Text(p.name),
subtitle: Text(
p.accountIds.isEmpty
? 'Нет счетов'
: '${_accountsLabel(p.accountIds.length)}: '
'${p.accountIds.map((id) => names[id] ?? '#$id').join(', ')}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
onTap: () => _edit(p),
trailing: IconButton(
tooltip: 'Удалить',
icon: const Icon(Icons.delete_outline),
onPressed: () => _delete(p),
),
),
),
],
);
},
),
),
);
}
}
@@ -0,0 +1,21 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
import '../portfolio/providers.dart' show scopesProvider;
import '../rebalance/providers.dart' show portfoliosProvider;
final portfolioListProvider = FutureProvider.autoDispose<List<PortfolioOut>>((
ref,
) async {
final r = await ref.watch(apiProvider).getPortfoliosApi().portfoliosList();
return r.data ?? const [];
});
/// After any change the list, the scope picker and the rebalance screen's portfolio list
/// (both derived from scopes) must be refetched.
void invalidatePortfolios(WidgetRef ref) {
ref.invalidate(portfolioListProvider);
ref.invalidate(scopesProvider);
ref.invalidate(portfoliosProvider);
}
+53 -10
View File
@@ -1,15 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
/// Аналитика: a hub for the phase-4 screens.
import '../portfolio/holdings_tab.dart';
import '../portfolio/overview_tiles.dart';
import '../portfolio/scope_selector.dart';
/// Аналитика → Общее. On a wide surface it is Snowball's overview — the four headline figures
/// for the chosen scope, then the value chart, returns and benchmark comparison; the other
/// analytics screens are one tab away (`AnalyticsTabs`). On a phone there is no tab strip, so
/// the same entry is a list of the sections instead.
///
/// Why a hub instead of four more destinations: the shell already carried eleven, which on
/// a phone leaves ~36 px per label in the bottom bar. Доходы, Ребалансировка, Цели and
/// Налоги are all "what do I do with the portfolio next" questions, so they live behind one
/// entry that highlights for all four (see `alsoMatches` in `app_shell.dart`), while each
/// keeps its own top-level route from the contract (`/income`, `/rebalance`, `/goals`,
/// `/tax`) and is deep-linkable.
class AnalyticsHubPage extends StatelessWidget {
/// The sections keep their own top-level routes from the contract (`/income`, `/rebalance`,
/// `/goals`, `/tax`) and are deep-linkable; `alsoMatches` in `nav_destinations.dart` keeps
/// Аналитика highlighted inside any of them.
class AnalyticsHubPage extends ConsumerWidget {
const AnalyticsHubPage({super.key});
static const _entries = [
@@ -23,7 +28,8 @@ class AnalyticsHubPage extends StatelessWidget {
path: '/rebalance',
icon: Icons.balance,
title: 'Ребалансировка',
subtitle: 'Целевые веса портфеля и рекомендации, что докупить или продать',
subtitle:
'Целевые веса портфеля и рекомендации, что докупить или продать',
),
(
path: '/goals',
@@ -39,6 +45,43 @@ class AnalyticsHubPage extends StatelessWidget {
),
];
@override
Widget build(BuildContext context, WidgetRef ref) {
if (MediaQuery.sizeOf(context).width >= 600) return const _Overview();
return const _SectionList();
}
}
class _Overview extends StatelessWidget {
const _Overview();
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Align(
alignment: Alignment.centerRight,
child: ScopeSelector(),
),
),
const Expanded(
child: HoldingsTab(
sections: HoldingsSections.overview,
header: OverviewTiles(),
),
),
],
),
);
}
}
class _SectionList extends StatelessWidget {
const _SectionList();
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -46,7 +89,7 @@ class AnalyticsHubPage extends StatelessWidget {
body: ListView(
padding: const EdgeInsets.all(16),
children: [
for (final e in _entries)
for (final e in AnalyticsHubPage._entries)
Card(
child: ListTile(
leading: Icon(e.icon),
+101
View File
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// The tab strip under the top bar on every «Аналитика» screen. Each tab keeps its own
/// top-level route (`/income`, `/rebalance`, …), so the screens stay deep-linkable; the strip
/// only makes them read as the parts of one section.
class AnalyticsTabs extends StatelessWidget {
const AnalyticsTabs({required this.location, super.key});
final String location;
static const tabs = [
(path: '/analytics', icon: Icons.work_outline, label: 'Общее'),
(path: '/income', icon: Icons.bar_chart, label: 'Дивиденды'),
(path: '/rebalance', icon: Icons.balance, label: 'Ребалансировка'),
(path: '/goals', icon: Icons.flag_outlined, label: 'Цели'),
(path: '/tax', icon: Icons.receipt_long_outlined, label: 'Налоги'),
(path: '/portfolios', icon: Icons.pie_chart_outline, label: 'Портфели'),
];
/// Whether [location] belongs to the analytics section at all.
static bool contains(String location) =>
tabs.any((t) => location.startsWith(t.path));
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: scheme.surfaceContainer,
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final t in tabs)
_Tab(
icon: t.icon,
label: t.label,
selected: location.startsWith(t.path),
onTap: () => context.go(t.path),
),
],
),
),
);
}
}
class _Tab extends StatelessWidget {
const _Tab({
required this.icon,
required this.label,
required this.selected,
required this.onTap,
});
final IconData icon;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final color = selected ? scheme.onSurface : scheme.onSurfaceVariant;
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: selected ? scheme.primary : Colors.transparent,
width: 2,
),
),
),
child: Row(
children: [
Icon(
icon,
size: 20,
color: selected ? scheme.primary : scheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
color: color,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
),
),
],
),
),
);
}
}
+34 -29
View File
@@ -1,21 +1,22 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'analytics_tabs.dart';
import 'nav_destinations.dart';
import 'nav_sidebar.dart';
import 'top_nav.dart';
/// Breakpoints per the plan: bottom bar under 600, collapsed rail 6001200,
/// extended rail at 1200 and above.
/// Below this width the shell falls back to a bottom [NavigationBar]; from here up it is the
/// [TopNav] bar.
const _narrowBreakpoint = 600.0;
const _wideBreakpoint = 1200.0;
/// Adaptive navigation shell around the current route: a bottom
/// [NavigationBar] on narrow surfaces, a collapsed [NavigationRail] on medium
/// ones, and the grouped [NavSidebar] on wide ones.
/// Adaptive navigation shell around the current route: a bottom [NavigationBar] on narrow
/// surfaces and the [TopNav] bar on wider ones, with the Аналитика tab strip under it while
/// one of the analytics screens is open.
///
/// The rail and the sidebar show every destination. The bottom bar shows the four primary
/// ones plus «Ещё», which opens the rest in a sheet: twelve destinations in a phone-width
/// bar would leave about 30 px per label, which is not navigation but decoration.
/// The top bar shows every destination (the ledger ones behind «Операции»). The bottom bar
/// shows the four primary ones plus «Ещё», which opens the rest in a sheet: twelve
/// destinations in a phone-width bar would leave about 30 px per label, which is not
/// navigation but decoration.
class AppShell extends StatelessWidget {
const AppShell({required this.location, required this.child, super.key});
@@ -80,28 +81,32 @@ class AppShell extends StatelessWidget {
);
}
final extended = width >= _wideBreakpoint;
return Scaffold(
body: Row(
body: Column(
children: [
if (extended)
NavSidebar(selectedIndex: _selectedIndex, onSelect: (i) => _onSelect(context, i))
else
NavigationRail(
selectedIndex: _selectedIndex,
onDestinationSelected: (i) => _onSelect(context, i),
labelType: NavigationRailLabelType.selected,
destinations: [
for (final d in navDestinations)
NavigationRailDestination(
icon: Icon(d.icon),
selectedIcon: Icon(d.selectedIcon),
label: Text(d.label),
),
],
TopNav(
selectedIndex: _selectedIndex,
onSelect: (i) => _onSelect(context, i),
),
Expanded(
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kContentMaxWidth),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (AnalyticsTabs.contains(location))
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: AnalyticsTabs(location: location),
),
Expanded(child: child),
],
),
),
),
const VerticalDivider(width: 1),
Expanded(child: SafeArea(child: child)),
),
],
),
);
+100 -30
View File
@@ -1,18 +1,19 @@
import 'package:flutter/material.dart';
/// The three groups the sidebar buckets destinations under (Grimmory-style caps headers).
/// Order here is display order, top to bottom.
/// The groups destinations belong to: the main tabs, the ledger screens behind «Операции», and
/// the system ones (Здоровье, Настройки) that sit as icons on the right of the top bar.
enum NavGroup { overview, ledger, system }
const navGroupLabels = {
NavGroup.overview: 'ОБЗОР',
NavGroup.ledger: 'ОПЕРАЦИИ',
NavGroup.system: 'СИСТЕМА',
};
class NavDestination {
const NavDestination(this.path, this.icon, this.selectedIcon, this.label, this.group,
{this.alsoMatches = const [], this.primary = false});
const NavDestination(
this.path,
this.icon,
this.selectedIcon,
this.label,
this.group, {
this.alsoMatches = const [],
this.primary = false,
});
final String path;
final IconData icon;
final IconData selectedIcon;
@@ -39,28 +40,97 @@ class NavDestination {
}
const navDestinations = [
NavDestination('/', Icons.dashboard_outlined, Icons.dashboard, 'Обзор', NavGroup.overview,
primary: true),
NavDestination(
'/accounts', Icons.account_balance_outlined, Icons.account_balance, 'Счета', NavGroup.overview,
primary: true),
'/',
Icons.dashboard_outlined,
Icons.dashboard,
'Обзор',
NavGroup.overview,
primary: true,
),
// One entry for the phase-4 screens. They keep their own top-level routes from the
// contract and stay deep-linkable; `/analytics` is what the bars point at, and
// `alsoMatches` keeps it highlighted while you are inside any of them.
NavDestination(
'/portfolio', Icons.pie_chart_outline, Icons.pie_chart, 'Портфель', NavGroup.overview,
primary: true),
// One entry for the four phase-4 screens. They keep their own top-level routes from the
// contract and stay deep-linkable; the hub at `/analytics` is what the bottom bar and the
// rail point at, and `alsoMatches` keeps it highlighted while you are inside any of them.
NavDestination('/analytics', Icons.insights_outlined, Icons.insights, 'Аналитика', NavGroup.overview,
alsoMatches: ['/income', '/rebalance', '/goals', '/tax'], primary: true),
NavDestination('/events', Icons.event_note_outlined, Icons.event_note, 'События', NavGroup.ledger),
NavDestination('/imports', Icons.upload_file_outlined, Icons.upload_file, 'Импорт', NavGroup.ledger,
alsoMatches: ['/instruments/pending']),
NavDestination('/cashflow', Icons.swap_horiz_outlined, Icons.swap_horiz, 'Потоки', NavGroup.ledger),
NavDestination('/categories', Icons.category_outlined, Icons.category, 'Категории', NavGroup.ledger),
'/analytics',
Icons.insights_outlined,
Icons.insights,
'Аналитика',
NavGroup.overview,
alsoMatches: ['/income', '/rebalance', '/goals', '/tax', '/portfolios'],
primary: true,
),
NavDestination(
'/transactions', Icons.receipt_long_outlined, Icons.receipt_long, 'Операции', NavGroup.ledger),
NavDestination('/rules', Icons.rule_folder_outlined, Icons.rule_folder, 'Правила', NavGroup.ledger),
'/portfolio',
Icons.pie_chart_outline,
Icons.pie_chart,
'Портфель',
NavGroup.overview,
primary: true,
),
NavDestination(
'/health', Icons.monitor_heart_outlined, Icons.monitor_heart, 'Здоровье', NavGroup.system),
NavDestination('/settings', Icons.settings_outlined, Icons.settings, 'Настройки', NavGroup.system),
'/accounts',
Icons.account_balance_outlined,
Icons.account_balance,
'Счета',
NavGroup.overview,
primary: true,
),
NavDestination(
'/events',
Icons.event_note_outlined,
Icons.event_note,
'События',
NavGroup.ledger,
),
NavDestination(
'/imports',
Icons.upload_file_outlined,
Icons.upload_file,
'Импорт',
NavGroup.ledger,
alsoMatches: ['/instruments/pending'],
),
NavDestination(
'/cashflow',
Icons.swap_horiz_outlined,
Icons.swap_horiz,
'Потоки',
NavGroup.ledger,
),
NavDestination(
'/categories',
Icons.category_outlined,
Icons.category,
'Категории',
NavGroup.ledger,
),
NavDestination(
'/transactions',
Icons.receipt_long_outlined,
Icons.receipt_long,
'Операции',
NavGroup.ledger,
),
NavDestination(
'/rules',
Icons.rule_folder_outlined,
Icons.rule_folder,
'Правила',
NavGroup.ledger,
),
NavDestination(
'/health',
Icons.monitor_heart_outlined,
Icons.monitor_heart,
'Здоровье',
NavGroup.system,
),
NavDestination(
'/settings',
Icons.settings_outlined,
Icons.settings,
'Настройки',
NavGroup.system,
),
];
-199
View File
@@ -1,199 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/common_providers.dart';
import '../home/providers.dart' show dataQualityProvider;
import 'nav_destinations.dart';
/// The Grimmory-style sidebar shown at the extended breakpoint (>=1200): destinations
/// grouped under muted caps headers, a left accent bar on the active row, a counter next
/// to a destination where one is meaningful, and the signed-in account pinned at the
/// bottom. Below 1200 the shell falls back to a plain [NavigationRail] — there is no room
/// for group headers or a profile block in an icon-only rail.
class NavSidebar extends ConsumerWidget {
const NavSidebar({required this.selectedIndex, required this.onSelect, super.key});
final int selectedIndex;
final ValueChanged<int> onSelect;
@override
Widget build(BuildContext context, WidgetRef ref) {
final issueCount = ref.watch(dataQualityProvider).valueOrNull?.data.length;
final byGroup = <NavGroup, List<int>>{};
for (var i = 0; i < navDestinations.length; i++) {
byGroup.putIfAbsent(navDestinations[i].group, () => []).add(i);
}
return Container(
width: 240,
color: Theme.of(context).colorScheme.surfaceContainerLow,
child: Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 12),
children: [
for (final group in NavGroup.values)
if (byGroup[group] case final indices?)
_NavGroupSection(
label: navGroupLabels[group]!,
children: [
for (final i in indices)
_NavRow(
destination: navDestinations[i],
selected: i == selectedIndex,
counter: navDestinations[i].path == '/health' ? issueCount : null,
onTap: () => onSelect(i),
),
],
),
],
),
),
const Divider(height: 1),
const _ProfileFooter(),
],
),
);
}
}
class _NavGroupSection extends StatelessWidget {
const _NavGroupSection({required this.label, required this.children});
final String label;
final List<Widget> children;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 12, 6),
child: Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
letterSpacing: 1.1,
fontWeight: FontWeight.w600,
),
),
),
...children,
],
);
}
}
class _NavRow extends StatelessWidget {
const _NavRow({required this.destination, required this.selected, required this.onTap, this.counter});
final NavDestination destination;
final bool selected;
final VoidCallback onTap;
final int? counter;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 12, 2),
child: Material(
color: selected ? scheme.primaryContainer.withValues(alpha: 0.35) : Colors.transparent,
borderRadius: BorderRadius.circular(10),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 9),
child: Row(
children: [
Container(
width: 3,
height: 18,
margin: const EdgeInsets.only(right: 9),
decoration: BoxDecoration(
color: selected ? scheme.primary : Colors.transparent,
borderRadius: BorderRadius.circular(2),
),
),
Icon(
selected ? destination.selectedIcon : destination.icon,
size: 20,
color: selected ? scheme.primary : scheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
destination.label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: selected ? scheme.primary : scheme.onSurface,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
),
),
),
if (counter != null && counter! > 0)
Text(
'$counter',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant),
),
],
),
),
),
),
);
}
}
class _ProfileFooter extends ConsumerWidget {
const _ProfileFooter();
@override
Widget build(BuildContext context, WidgetRef ref) {
final scheme = Theme.of(context).colorScheme;
final theme = Theme.of(context);
final email = ref.watch(meProvider).valueOrNull?.email;
final name = email?.split('@').first;
final initial = (name?.isNotEmpty ?? false) ? name![0].toUpperCase() : '?';
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: scheme.primaryContainer,
child: Text(
initial,
style: TextStyle(color: scheme.onPrimaryContainer, fontWeight: FontWeight.w700),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
name ?? '',
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
Text(
email ?? '',
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant),
),
],
),
),
],
),
);
}
}
+357
View File
@@ -0,0 +1,357 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/api/common_providers.dart';
import '../../core/auth/auth_controller.dart';
import '../../core/widgets/service_mark.dart';
import '../accounts/account_create_dialog.dart';
import '../home/providers.dart' show dataQualityProvider;
import 'nav_destinations.dart';
/// The widest the page content (and the content of the top bar) grows: on a big monitor a table
/// stretched edge to edge is harder to read than one that stops.
const kContentMaxWidth = 1440.0;
/// The top bar of the wide layout (>=600): the primary destinations as pills, the ledger
/// screens behind «Операции», and on the right «Добавить», the data-health counter, settings
/// and the profile. Below 600 the shell uses a bottom bar instead.
class TopNav extends ConsumerWidget {
const TopNav({
required this.selectedIndex,
required this.onSelect,
super.key,
});
final int selectedIndex;
final ValueChanged<int> onSelect;
int _indexOf(String path) =>
navDestinations.indexWhere((d) => d.path == path);
@override
Widget build(BuildContext context, WidgetRef ref) {
final scheme = Theme.of(context).colorScheme;
final compact = MediaQuery.sizeOf(context).width < 900;
final issueCount =
ref.watch(dataQualityProvider).valueOrNull?.data.length ?? 0;
final primary = [
for (var i = 0; i < navDestinations.length; i++)
if (navDestinations[i].primary) i,
];
final ledger = [
for (var i = 0; i < navDestinations.length; i++)
if (navDestinations[i].group == NavGroup.ledger) i,
];
final health = _indexOf('/health');
final settings = _indexOf('/settings');
return Container(
height: 64,
decoration: BoxDecoration(
color: scheme.surfaceContainerLowest,
border: Border(bottom: BorderSide(color: scheme.outlineVariant)),
),
// the band runs edge to edge, but what is on it lines up with the page content below:
// same maximum width, same 16 px side padding as the cards
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kContentMaxWidth),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Tooltip(
message: 'Обзор',
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => onSelect(_indexOf('/')),
child: const ServiceMark(),
),
),
SizedBox(width: compact ? 12 : 28),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final i in primary)
_Pill(
label: navDestinations[i].label,
selected: i == selectedIndex,
onTap: () => onSelect(i),
),
_LedgerMenu(
indices: ledger,
selectedIndex: selectedIndex,
onSelect: onSelect,
),
],
),
),
),
_AddMenu(compact: compact),
const SizedBox(width: 4),
IconButton(
tooltip: 'Здоровье данных',
onPressed: () => onSelect(health),
icon: Badge(
isLabelVisible: issueCount > 0,
label: Text('$issueCount'),
child: Icon(
Icons.monitor_heart_outlined,
color: selectedIndex == health
? scheme.primary
: scheme.onSurfaceVariant,
),
),
),
IconButton(
tooltip: 'Настройки',
onPressed: () => onSelect(settings),
icon: Icon(
Icons.settings_outlined,
color: selectedIndex == settings
? scheme.primary
: scheme.onSurfaceVariant,
),
),
const _ProfileMenu(),
],
),
),
),
),
);
}
}
class _Pill extends StatelessWidget {
const _Pill({
required this.label,
required this.selected,
required this.onTap,
});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(right: 4),
child: Material(
color: selected ? scheme.surfaceContainerHighest : Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Text(
label,
style: TextStyle(
color: selected ? scheme.onSurface : scheme.onSurfaceVariant,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
),
),
),
),
),
);
}
}
/// «Операции»: the ledger screens, which are visited less often than the four main tabs.
class _LedgerMenu extends StatelessWidget {
const _LedgerMenu({
required this.indices,
required this.selectedIndex,
required this.onSelect,
});
final List<int> indices;
final int selectedIndex;
final ValueChanged<int> onSelect;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final active = indices.contains(selectedIndex);
return PopupMenuButton<int>(
tooltip: 'Операции',
position: PopupMenuPosition.under,
onSelected: onSelect,
itemBuilder: (_) => [
for (final i in indices)
PopupMenuItem(
value: i,
child: Row(
children: [
Icon(
navDestinations[i].icon,
size: 20,
color: scheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Text(navDestinations[i].label),
],
),
),
],
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: active ? scheme.surfaceContainerHighest : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
active ? navDestinations[selectedIndex].label : 'Операции',
style: TextStyle(
color: active ? scheme.onSurface : scheme.onSurfaceVariant,
fontWeight: active ? FontWeight.w600 : FontWeight.w500,
),
),
Icon(
Icons.arrow_drop_down,
size: 20,
color: scheme.onSurfaceVariant,
),
],
),
),
);
}
}
/// «Добавить»: the four things a person creates by hand.
class _AddMenu extends StatelessWidget {
const _AddMenu({required this.compact});
final bool compact;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return PopupMenuButton<String>(
tooltip: 'Добавить',
position: PopupMenuPosition.under,
onSelected: (v) => switch (v) {
'event' => context.go('/events'),
'account' => showAccountCreateDialog(context),
'portfolio' => context.go('/portfolios'),
_ => context.go('/imports'),
},
itemBuilder: (_) => const [
PopupMenuItem(
value: 'event',
child: ListTile(
dense: true,
leading: Icon(Icons.swap_vert),
title: Text('Событие'),
subtitle: Text('Сделка, пополнение, выплата'),
),
),
PopupMenuItem(
value: 'account',
child: ListTile(
dense: true,
leading: Icon(Icons.account_balance_outlined),
title: Text('Брокерский счёт'),
),
),
PopupMenuItem(
value: 'portfolio',
child: ListTile(
dense: true,
leading: Icon(Icons.pie_chart_outline),
title: Text('Портфель'),
),
),
PopupMenuItem(
value: 'import',
child: ListTile(
dense: true,
leading: Icon(Icons.upload_file_outlined),
title: Text('Отчёт брокера'),
),
),
],
child: Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 10 : 16,
vertical: 10,
),
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add_circle_outline, size: 20, color: scheme.primary),
if (!compact) ...[
const SizedBox(width: 8),
Text(
'Добавить',
style: TextStyle(
color: scheme.primary,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
);
}
}
class _ProfileMenu extends ConsumerWidget {
const _ProfileMenu();
@override
Widget build(BuildContext context, WidgetRef ref) {
final scheme = Theme.of(context).colorScheme;
final email = ref.watch(meProvider).valueOrNull?.email;
final initial = (email?.isNotEmpty ?? false)
? email![0].toUpperCase()
: '?';
return PopupMenuButton<String>(
tooltip: email ?? 'Профиль',
position: PopupMenuPosition.under,
onSelected: (_) => ref.read(authControllerProvider.notifier).logout(),
itemBuilder: (_) => [
PopupMenuItem(enabled: false, child: Text(email ?? '')),
const PopupMenuItem(
value: 'logout',
child: ListTile(
dense: true,
leading: Icon(Icons.logout),
title: Text('Выйти'),
),
),
],
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: CircleAvatar(
radius: 18,
backgroundColor: scheme.primaryContainer,
child: Text(
initial,
style: TextStyle(
color: scheme.onPrimaryContainer,
fontWeight: FontWeight.w700,
),
),
),
),
);
}
}