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
+27 -24
View File
@@ -29,30 +29,33 @@ class IncomeCalendarTab extends ConsumerWidget {
child: AsyncValueView(
value: calendar,
onRetry: () => ref.invalidate(incomeCalendarProvider),
data: (data) => ListView(
padding: const EdgeInsets.all(16),
children: [
const _CalendarControls(),
const SizedBox(height: 12),
_Totals(data: data),
const SizedBox(height: 16),
if (data.entries.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 48),
child: EmptyState(
icon: Icons.event_available_outlined,
message: 'Ожидаемых выплат в этом окне нет.\n'
'Либо по бумагам нет объявленных выплат и истории, либо портфель пуст.',
),
)
else
for (final group in _groupByMonth(data.entries))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _MonthCard(month: group.key, entries: group.value),
),
],
),
data: (cached) {
final data = cached.data;
return ListView(
padding: const EdgeInsets.all(16),
children: [
const _CalendarControls(),
const SizedBox(height: 12),
_Totals(data: data),
const SizedBox(height: 16),
if (data.entries.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 48),
child: EmptyState(
icon: Icons.event_available_outlined,
message: 'Ожидаемых выплат в этом окне нет.\n'
'Либо по бумагам нет объявленных выплат и истории, либо портфель пуст.',
),
)
else
for (final group in _groupByMonth(data.entries))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _MonthCard(month: group.key, entries: group.value),
),
],
);
},
),
);
}
+18 -6
View File
@@ -11,6 +11,7 @@ library;
import 'package:dio/dio.dart';
import '../../../core/cache/cached.dart';
import '../../../core/utils/json.dart';
/// One expected (or already paid) payment.
@@ -205,7 +206,9 @@ class IncomeApi {
static const _base = '/api/v1/income';
Future<IncomeCalendar> calendar({
/// 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<IncomeCalendar>> calendar({
String scope = 'all',
DateTime? dateFrom,
DateTime? dateTo,
@@ -217,10 +220,13 @@ class IncomeApi {
'date_to': ?_isoDate(dateTo),
'include_paid': includePaid,
});
return IncomeCalendar.fromJson(r.data ?? const {});
return Cached(
IncomeCalendar.fromJson(r.data ?? const {}),
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
}
Future<IncomeHistory> history({
Future<Cached<IncomeHistory>> history({
String scope = 'all',
String group = 'month',
DateTime? dateFrom,
@@ -234,15 +240,21 @@ class IncomeApi {
'date_to': ?_isoDate(dateTo),
'kind': ?kind,
});
return IncomeHistory.fromJson(r.data ?? const {});
return Cached(
IncomeHistory.fromJson(r.data ?? const {}),
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
}
Future<IncomeForecast> forecast({String scope = 'all', int months = 12}) async {
Future<Cached<IncomeForecast>> forecast({String scope = 'all', int months = 12}) async {
final r = await _dio.get<Map<String, dynamic>>('$_base/forecast', queryParameters: {
'scope': scope,
'months': months,
});
return IncomeForecast.fromJson(r.data ?? const {});
return Cached(
IncomeForecast.fromJson(r.data ?? const {}),
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
}
/// `format: date` on the wire. `DateQueryInterceptor` does this for the generated client;
+2 -1
View File
@@ -30,7 +30,8 @@ class IncomeForecastTab extends ConsumerWidget {
child: AsyncValueView(
value: forecast,
onRetry: () => ref.invalidate(incomeForecastProvider),
data: (data) {
data: (cached) {
final data = cached.data;
final bases = data.bases;
return ListView(
padding: const EdgeInsets.all(16),
+2 -1
View File
@@ -26,7 +26,8 @@ class IncomeHistoryTab extends ConsumerWidget {
child: AsyncValueView(
value: history,
onRetry: () => ref.invalidate(incomeHistoryProvider),
data: (data) {
data: (cached) {
final data = cached.data;
if (data.rows.isEmpty) {
return ListView(
padding: const EdgeInsets.all(16),
+25 -2
View File
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/cache/cached.dart';
import '../../core/widgets/scope_selector.dart';
import '../../core/widgets/stale_banner.dart';
import 'calendar_tab.dart';
import 'forecast_tab.dart';
import 'history_tab.dart';
@@ -9,11 +11,21 @@ import 'providers.dart';
/// Доходы: the dividend/coupon calendar, the paid history and the forecast — three views of
/// one question, so three tabs of one screen rather than three navigation destinations.
///
/// One offline banner covers all three tabs (`docs/ai/offline-cache.md`): they share a
/// scope and `TabBarView` builds all of them eagerly anyway, so watching all three
/// providers here to compute it costs nothing extra.
class IncomePage extends ConsumerWidget {
const IncomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final stale = oldestFetch([
ref.watch(incomeCalendarProvider).valueOrNull?.fetchedAt,
ref.watch(incomeHistoryProvider).valueOrNull?.fetchedAt,
ref.watch(incomeForecastProvider).valueOrNull?.fetchedAt,
]);
return DefaultTabController(
length: 3,
child: Scaffold(
@@ -31,8 +43,19 @@ class IncomePage extends ConsumerWidget {
tabs: [Tab(text: 'Календарь'), Tab(text: 'История'), Tab(text: 'Прогноз')],
),
),
body: const TabBarView(
children: [IncomeCalendarTab(), IncomeHistoryTab(), IncomeForecastTab()],
body: Column(
children: [
if (stale != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: StaleBanner(fetchedAt: stale),
),
const Expanded(
child: TabBarView(
children: [IncomeCalendarTab(), IncomeHistoryTab(), IncomeForecastTab()],
),
),
],
),
),
);
+5 -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;
import 'data/income_api.dart';
@@ -12,8 +13,8 @@ final calendarMonthsProvider = StateProvider<int>((ref) => 12);
final calendarIncludePaidProvider = StateProvider<bool>((ref) => false);
/// Доходы shares the portfolio-wide [scopeProvider]: switching the scope on Портфель must
/// not leave the income calendar showing a different portfolio.
final incomeCalendarProvider = FutureProvider.autoDispose<IncomeCalendar>((ref) async {
/// not leave the income calendar showing a different portfolio. See `docs/ai/offline-cache.md`.
final incomeCalendarProvider = FutureProvider.autoDispose<Cached<IncomeCalendar>>((ref) async {
final scope = ref.watch(scopeProvider);
final months = ref.watch(calendarMonthsProvider);
final includePaid = ref.watch(calendarIncludePaidProvider);
@@ -29,7 +30,7 @@ final incomeCalendarProvider = FutureProvider.autoDispose<IncomeCalendar>((ref)
/// How far the history goes back, in months.
final historyMonthsProvider = StateProvider<int>((ref) => 24);
final incomeHistoryProvider = FutureProvider.autoDispose<IncomeHistory>((ref) async {
final incomeHistoryProvider = FutureProvider.autoDispose<Cached<IncomeHistory>>((ref) async {
final scope = ref.watch(scopeProvider);
final months = ref.watch(historyMonthsProvider);
final now = DateTime.now();
@@ -42,7 +43,7 @@ final incomeHistoryProvider = FutureProvider.autoDispose<IncomeHistory>((ref) as
final forecastMonthsProvider = StateProvider<int>((ref) => 12);
final incomeForecastProvider = FutureProvider.autoDispose<IncomeForecast>((ref) async {
final incomeForecastProvider = FutureProvider.autoDispose<Cached<IncomeForecast>>((ref) async {
final scope = ref.watch(scopeProvider);
return ref
.watch(incomeApiProvider)