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 отдельно, второй — чистые действия без списка для баннера).
This commit is contained in:
Dmitry
2026-09-19 14:00:13 +03:00
parent ffc5ed959a
commit 7b419f4188
39 changed files with 388 additions and 186 deletions
@@ -12,6 +12,7 @@ library;
import 'package:decimal/decimal.dart';
import 'package:dio/dio.dart';
import '../../../core/cache/cached.dart';
import '../../../core/utils/json.dart';
/// The dimensions targets can be set along, as wire strings (`AssetClass` is never exposed
@@ -214,12 +215,14 @@ class RebalanceApi {
/// is per-dimension, so the set has to be addressable per-dimension as well. Sending
/// `dimension` is harmless for a server that ignores it and necessary for one that does
/// not — revisit once the route is in the spec.
Future<TargetSet> getTargets(int portfolioId, {String dimension = 'asset_class'}) async {
/// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached<T>` directly
/// (there is no generated `Response<T>` for the screen to unwrap `r.cached` from).
Future<Cached<TargetSet>> getTargets(int portfolioId, {String dimension = 'asset_class'}) async {
final r = await _dio.get<Map<String, dynamic>>(
'$_base/$portfolioId/targets',
queryParameters: {'dimension': dimension},
);
return TargetSet.fromJson(r.data ?? const {});
return Cached(TargetSet.fromJson(r.data ?? const {}), fetchedAt: r.extra['fetchedAt'] as DateTime?);
}
/// Full replacement of one dimension — partial updates are not supported by the contract.
@@ -231,7 +234,7 @@ class RebalanceApi {
return TargetSet.fromJson(r.data ?? const {});
}
Future<RebalancePlan> plan(
Future<Cached<RebalancePlan>> plan(
int portfolioId, {
String dimension = 'asset_class',
String? cashAvailable,
@@ -240,6 +243,9 @@ class RebalanceApi {
'$_base/$portfolioId/rebalance',
queryParameters: {'dimension': dimension, 'cash_available': ?cashAvailable},
);
return RebalancePlan.fromJson(r.data ?? const {});
return Cached(
RebalancePlan.fromJson(r.data ?? const {}),
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
}
}
+2 -1
View File
@@ -31,7 +31,8 @@ class RebalancePlanTab extends ConsumerWidget {
child: AsyncValueView(
value: plan,
onRetry: () => ref.invalidate(rebalancePlanProvider),
data: (data) {
data: (cached) {
final data = cached.data;
if (data == null) {
return const EmptyState(
icon: Icons.balance,
+7 -4
View File
@@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
import '../../core/cache/cached.dart';
import '../portfolio/providers.dart' show scopeProvider, scopesProvider;
import 'data/rebalance_api.dart';
@@ -38,18 +39,20 @@ final targetDimensionProvider = StateProvider<String>((ref) => 'asset_class');
/// What-if cash for the recommendations, as a decimal string. Null = use the real balance.
final whatIfCashProvider = StateProvider<String?>((ref) => null);
final targetsProvider = FutureProvider.autoDispose<TargetSet>((ref) async {
/// See `docs/ai/offline-cache.md`. No portfolio selected yet is a live, empty answer — not a
/// stale one — so it is wrapped with `fetchedAt: null` rather than left unwrapped.
final targetsProvider = FutureProvider.autoDispose<Cached<TargetSet>>((ref) async {
final id = ref.watch(selectedPortfolioProvider);
final dimension = ref.watch(targetDimensionProvider);
if (id == null) return TargetSet(dimension: dimension);
if (id == null) return Cached(TargetSet(dimension: dimension));
return ref.watch(rebalanceApiProvider).getTargets(id, dimension: dimension);
});
final rebalancePlanProvider = FutureProvider.autoDispose<RebalancePlan?>((ref) async {
final rebalancePlanProvider = FutureProvider.autoDispose<Cached<RebalancePlan?>>((ref) async {
final id = ref.watch(selectedPortfolioProvider);
final dimension = ref.watch(targetDimensionProvider);
final cash = ref.watch(whatIfCashProvider);
if (id == null) return null;
if (id == null) return const Cached(null);
return ref.watch(rebalanceApiProvider).plan(id, dimension: dimension, cashAvailable: cash);
});
+43 -21
View File
@@ -1,8 +1,10 @@
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';
@@ -12,12 +14,21 @@ 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,
@@ -37,27 +48,38 @@ class RebalancePage extends ConsumerWidget {
tabs: [Tab(text: 'Целевые веса'), Tab(text: 'Рекомендации')],
),
),
body: 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()]);
},
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()]);
},
),
),
],
),
),
);
+2 -1
View File
@@ -77,7 +77,8 @@ class _TargetsTabState extends ConsumerState<TargetsTab> {
return AsyncValueView(
value: targets,
onRetry: () => ref.invalidate(targetsProvider),
data: (set) {
data: (cached) {
final set = cached.data;
_seed(set, '$portfolioId/$dimension');
final draft = _draft ?? const <TargetWeight>[];
return ListView(