Files
fin-tracker/app/lib/features/portfolio/portfolio_page.dart
T
Dmitry 7b419f4188 feat(app): offline-кэш на остальных экранах — фаза 5
accounts, cashflow, categories, goals, income, portfolio (+instrument),
rebalance, tax, rules переведены на Cached<T> по контракту
docs/ai/offline-cache.md. Общие/второстепенные селекторы (scopesProvider,
categoriesListProvider и т.п.) оставлены как есть — не основной контент
экрана. sync_page.dart и settings_page.dart не тронуты (первый — заменён
health-page отдельно, второй — чистые действия без списка для баннера).
2026-09-19 14:00:13 +03:00

97 lines
3.3 KiB
Dart

import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/cache/cached.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/stale_banner.dart';
import 'allocation_tab.dart';
import 'benchmarks_card.dart';
import 'holdings_tab.dart';
import 'providers.dart';
/// Портфель: позиции и аллокация as two tabs of one screen, sharing one scope.
///
/// They are tabs rather than two navigation destinations because they answer two halves of
/// the same question, and because a tenth item in the bottom bar would leave 40 px per
/// label on a phone.
///
/// One offline banner covers both tabs (`docs/ai/offline-cache.md`) — `TabBarView` builds
/// both eagerly anyway, so watching every provider here to compute it costs nothing extra.
class PortfolioPage extends ConsumerWidget {
const PortfolioPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final stale = oldestFetch([
ref.watch(portfolioSummaryProvider).valueOrNull?.fetchedAt,
ref.watch(holdingsProvider).valueOrNull?.fetchedAt,
ref.watch(valueSeriesProvider).valueOrNull?.fetchedAt,
ref.watch(portfolioReturnsProvider).valueOrNull?.fetchedAt,
ref.watch(benchmarkRowsProvider).valueOrNull?.fetchedAt,
ref.watch(allocationProvider).valueOrNull?.fetchedAt,
]);
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('Портфель'),
actions: const [_ScopeSelector(), SizedBox(width: 8)],
bottom: const TabBar(
tabs: [Tab(text: 'Позиции'), Tab(text: 'Аллокация')],
),
),
body: Column(
children: [
if (stale != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: StaleBanner(fetchedAt: stale),
),
const Expanded(
child: TabBarView(children: [HoldingsTab(), AllocationTab()]),
),
],
),
),
);
}
}
/// Switches every portfolio screen at once. Hidden while there is nothing to choose
/// between — a dropdown with one option is furniture, not a control.
class _ScopeSelector extends ConsumerWidget {
const _ScopeSelector();
@override
Widget build(BuildContext context, WidgetRef ref) {
final scopes = ref.watch(scopesProvider);
final current = ref.watch(scopeProvider);
return AsyncValueView<List<ScopeOut>>(
value: scopes,
data: (rows) {
if (rows.length < 2) return const SizedBox.shrink();
final known = rows.any((s) => s.scope == current) ? current : rows.first.scope;
return DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: known,
borderRadius: BorderRadius.circular(8),
items: [
for (final s in rows)
DropdownMenuItem(
value: s.scope,
child: Text(s.name, overflow: TextOverflow.ellipsis),
),
],
onChanged: (value) {
if (value != null) ref.read(scopeProvider.notifier).state = value;
},
),
);
},
);
}
}