Files
fin-tracker/app/lib/features/portfolios/portfolios_page.dart
T
Dmitry 62d36aa3e8 feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
2026-09-19 22:14:21 +03:00

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