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 createState() => _PortfoliosPageState(); } class _PortfoliosPageState extends ConsumerState { 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 _queueMetricsRefresh() async { try { await ref.read(apiProvider).getMetricsApi().metricsRefresh(); } on DioException { // the next scheduled or manual refresh picks the change up } } Future _run(Future Function() action) async { try { await action(); if (!mounted) return; invalidatePortfolios(ref); await _queueMetricsRefresh(); } on DioException catch (e) { if (mounted) _snack(problemMessage(e)); } } Future _create() async { final draft = await showDialog( 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 _edit(PortfolioOut portfolio) async { final draft = await showDialog( 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 _delete(PortfolioOut portfolio) async { final confirmed = await showDialog( 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), ), ), ), ], ); }, ), ), ); } }