Files
fin-tracker/app/lib/features/rebalance/rebalance_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

137 lines
5.1 KiB
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/empty_state.dart';
import '../../core/widgets/stale_banner.dart';
import '../portfolio/labels.dart' show dimensionLabels;
import 'data/rebalance_api.dart';
import 'plan_tab.dart';
import 'providers.dart';
import 'targets_tab.dart';
/// Ребалансировка: the target weights on one tab, the resulting recommendations on the
/// other. Both are per portfolio and per dimension, so the pickers live in the app bar and
/// drive both tabs at once.
///
/// One offline banner covers both tabs (`docs/ai/offline-cache.md`) — both providers are
/// already watched by their own tab, so watching them again here to compute it costs
/// nothing extra. `portfoliosProvider` itself is a selector derived from the un-cached
/// `scopesProvider` (see `portfolio/providers.dart`) and does not contribute a `fetchedAt`.
class RebalancePage extends ConsumerWidget {
const RebalancePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final portfolios = ref.watch(portfoliosProvider);
final stale = oldestFetch([
ref.watch(targetsProvider).valueOrNull?.fetchedAt,
ref.watch(rebalancePlanProvider).valueOrNull?.fetchedAt,
]);
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('Ребалансировка'),
actions: [
const _PortfolioSelector(),
const _DimensionSelector(),
IconButton(
tooltip: 'Обновить',
icon: const Icon(Icons.refresh),
onPressed: () => invalidateRebalanceProviders(ref),
),
],
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),
),
Expanded(
child: AsyncValueView(
value: portfolios,
onRetry: () => ref.invalidate(portfoliosProvider),
data: (list) {
if (list.isEmpty) {
return const EmptyState(
icon: Icons.pie_chart_outline,
message: 'Портфелей пока нет.\n'
'Ребалансировка считается по портфелю: заведите его в настройках счетов.',
);
}
final selected = ref.watch(selectedPortfolioProvider);
if (selected == null || !list.any((p) => p.id == selected)) {
// pick the first portfolio once, after the list is known
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(selectedPortfolioProvider.notifier).state = list.first.id;
});
return const Center(child: CircularProgressIndicator());
}
return const TabBarView(children: [TargetsTab(), RebalancePlanTab()]);
},
),
),
],
),
),
);
}
}
class _PortfolioSelector extends ConsumerWidget {
const _PortfolioSelector();
@override
Widget build(BuildContext context, WidgetRef ref) {
final portfolios = ref.watch(portfoliosProvider).valueOrNull ?? const <PortfolioRef>[];
final selected = ref.watch(selectedPortfolioProvider);
if (portfolios.length < 2 || selected == null) return const SizedBox.shrink();
return DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: portfolios.any((p) => p.id == selected) ? selected : portfolios.first.id,
borderRadius: BorderRadius.circular(8),
items: [
for (final p in portfolios)
DropdownMenuItem(value: p.id, child: Text(p.name, overflow: TextOverflow.ellipsis)),
],
onChanged: (v) {
if (v != null) ref.read(selectedPortfolioProvider.notifier).state = v;
},
),
);
}
}
class _DimensionSelector extends ConsumerWidget {
const _DimensionSelector();
@override
Widget build(BuildContext context, WidgetRef ref) {
final current = ref.watch(targetDimensionProvider);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: current,
borderRadius: BorderRadius.circular(8),
items: [
for (final d in targetDimensions)
DropdownMenuItem(value: d, child: Text(dimensionLabels[d] ?? d)),
],
onChanged: (v) {
if (v != null) ref.read(targetDimensionProvider.notifier).state = v;
},
),
),
);
}
}