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 отдельно, второй — чистые действия без списка для баннера).
97 lines
3.3 KiB
Dart
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;
|
|
},
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|