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