Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
198 lines
6.6 KiB
Dart
198 lines
6.6 KiB
Dart
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),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|