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,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);
}