Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
176 lines
6.7 KiB
Dart
176 lines
6.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.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 '../portfolios/providers.dart' show portfolioListProvider;
|
|
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) {
|
|
// A portfolio can exist yet be absent here: this list comes from the
|
|
// analytics scopes, which only cover accounts that have ledger events.
|
|
final created =
|
|
ref
|
|
.watch(portfolioListProvider)
|
|
.valueOrNull
|
|
?.isNotEmpty ??
|
|
false;
|
|
return Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
EmptyState(
|
|
icon: Icons.pie_chart_outline,
|
|
message: created
|
|
? 'Портфели есть, но в них нет счетов с бумагами.\n'
|
|
'Добавьте брокерские счета: у счетов ZenMoney нет сделок, '
|
|
'считать по ним нечего.'
|
|
: 'Портфелей пока нет.\n'
|
|
'Ребалансировка считается по портфелю — набору счетов.',
|
|
),
|
|
FilledButton.icon(
|
|
onPressed: () => context.go('/portfolios'),
|
|
icon: Icon(created ? Icons.edit_outlined : Icons.add),
|
|
label: Text(
|
|
created ? 'Изменить портфели' : 'Создать портфель',
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
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;
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|