diff --git a/app/lib/features/accounts/accounts_page.dart b/app/lib/features/accounts/accounts_page.dart index 4cc636b..15b5cbf 100644 --- a/app/lib/features/accounts/accounts_page.dart +++ b/app/lib/features/accounts/accounts_page.dart @@ -5,9 +5,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/api/api_client.dart'; import '../../core/auth/auth_controller.dart'; +import '../../core/cache/cached.dart'; import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/money_text.dart'; +import '../../core/widgets/stale_banner.dart'; import 'providers.dart'; const _roleOrder = [AccountRole.liquid, AccountRole.savings, AccountRole.investment, AccountRole.debt]; @@ -42,6 +44,7 @@ class AccountsPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final accounts = ref.watch(accountsProvider); + final stale = oldestFetch([accounts.valueOrNull?.fetchedAt]); return Scaffold( appBar: AppBar(title: const Text('Счета')), @@ -50,7 +53,8 @@ class AccountsPage extends ConsumerWidget { child: AsyncValueView( value: accounts, onRetry: () => ref.invalidate(accountsProvider), - data: (rows) { + data: (cached) { + final rows = cached.data; if (rows.isEmpty) { return ListView( children: const [ @@ -66,6 +70,7 @@ class AccountsPage extends ConsumerWidget { return ListView( padding: const EdgeInsets.all(16), children: [ + if (stale != null) StaleBanner(fetchedAt: stale), for (final role in _roleOrder) if (active.any((a) => a.role == role)) _RoleSection( diff --git a/app/lib/features/accounts/providers.dart b/app/lib/features/accounts/providers.dart index 1f2e25f..0b83968 100644 --- a/app/lib/features/accounts/providers.dart +++ b/app/lib/features/accounts/providers.dart @@ -2,15 +2,18 @@ import 'package:fintracker_api/fintracker_api.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/api/api_client.dart'; +import '../../core/cache/cached.dart'; -/// Every account, archived included — screens decide what to show. -final accountsProvider = FutureProvider.autoDispose>((ref) async { +/// Every account, archived included — screens decide what to show. See +/// `docs/ai/offline-cache.md`: Счета reads `.data` and shows the offline banner; +/// `accountNamesProvider` and the other consumers (Транзакции, События) just unwrap `.data`. +final accountsProvider = FutureProvider.autoDispose>>((ref) async { final r = await ref.watch(apiProvider).getAccountsApi().accountsList(); - return r.data ?? const []; + return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); }); /// `account_id -> name`, for screens that only carry the id (transactions, rules). final accountNamesProvider = Provider.autoDispose>((ref) { - final accounts = ref.watch(accountsProvider).valueOrNull ?? const []; + final accounts = ref.watch(accountsProvider).valueOrNull?.data ?? const []; return {for (final a in accounts) a.id: a.name}; }); diff --git a/app/lib/features/cashflow/cashflow_page.dart b/app/lib/features/cashflow/cashflow_page.dart index 472c9a3..f0d2438 100644 --- a/app/lib/features/cashflow/cashflow_page.dart +++ b/app/lib/features/cashflow/cashflow_page.dart @@ -5,11 +5,13 @@ 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/theme/chart_colors.dart'; import '../../core/utils/ru_date.dart'; import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/money_text.dart'; +import '../../core/widgets/stale_banner.dart'; import 'providers.dart'; const _incomeColor = ChartColors.income; @@ -26,6 +28,7 @@ class CashflowPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final monthly = ref.watch(cashflowMonthly24Provider); + final stale = oldestFetch([monthly.valueOrNull?.fetchedAt]); return Scaffold( appBar: AppBar(title: const Text('Потоки')), @@ -34,7 +37,8 @@ class CashflowPage extends ConsumerWidget { child: AsyncValueView( value: monthly, onRetry: () => ref.invalidate(cashflowMonthly24Provider), - data: (rows) { + data: (cached) { + final rows = cached.data; if (rows.isEmpty) { return ListView( children: const [ @@ -48,6 +52,7 @@ class CashflowPage extends ConsumerWidget { return ListView( padding: const EdgeInsets.all(16), children: [ + if (stale != null) StaleBanner(fetchedAt: stale), const _Legend(), const SizedBox(height: 8), SizedBox( diff --git a/app/lib/features/cashflow/providers.dart b/app/lib/features/cashflow/providers.dart index 4d8a068..7b5d27d 100644 --- a/app/lib/features/cashflow/providers.dart +++ b/app/lib/features/cashflow/providers.dart @@ -2,9 +2,10 @@ import 'package:fintracker_api/fintracker_api.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/api/api_client.dart'; +import '../../core/cache/cached.dart'; -/// The last 24 months, oldest first (as the API returns them). -final cashflowMonthly24Provider = FutureProvider.autoDispose>((ref) async { +/// The last 24 months, oldest first (as the API returns them). See `docs/ai/offline-cache.md`. +final cashflowMonthly24Provider = FutureProvider.autoDispose>>((ref) async { final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 24); - return r.data ?? const []; + return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); }); diff --git a/app/lib/features/categories/categories_page.dart b/app/lib/features/categories/categories_page.dart index 6cf057a..121373c 100644 --- a/app/lib/features/categories/categories_page.dart +++ b/app/lib/features/categories/categories_page.dart @@ -3,11 +3,13 @@ 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/theme/chart_colors.dart'; import '../../core/utils/ru_date.dart'; import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/money_text.dart'; +import '../../core/widgets/stale_banner.dart'; import 'providers.dart'; class _Group { @@ -79,6 +81,7 @@ class _CategoriesPageState extends ConsumerState { Widget build(BuildContext context) { final month = ref.watch(selectedSpendingMonthProvider); final spending = ref.watch(spendingProvider(month)); + final stale = oldestFetch([spending.valueOrNull?.fetchedAt]); return Scaffold( appBar: AppBar(title: const Text('Категории')), @@ -108,10 +111,12 @@ class _CategoriesPageState extends ConsumerState { ], ), const SizedBox(height: 8), + if (stale != null) StaleBanner(fetchedAt: stale), AsyncValueView( value: spending, onRetry: () => ref.invalidate(spendingProvider(month)), - data: (rows) { + data: (cached) { + final rows = cached.data; if (rows.isEmpty) { return const EmptyState( icon: Icons.donut_small_outlined, diff --git a/app/lib/features/categories/providers.dart b/app/lib/features/categories/providers.dart index 1730378..29fda27 100644 --- a/app/lib/features/categories/providers.dart +++ b/app/lib/features/categories/providers.dart @@ -2,9 +2,11 @@ import 'package:fintracker_api/fintracker_api.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/api/api_client.dart'; +import '../../core/cache/cached.dart'; import '../../core/utils/ru_date.dart'; -/// Flat ZenMoney tag tree — the client nests it by `parent_id` where needed. +/// Flat ZenMoney tag tree — the client nests it by `parent_id` where needed. Shared with +/// Транзакции; not wrapped in `Cached` since it is a lookup, not a screen's own primary read. final categoriesListProvider = FutureProvider.autoDispose>((ref) async { final r = await ref.watch(apiProvider).getCategoriesApi().categoriesList(); return r.data ?? const []; @@ -16,11 +18,12 @@ final categoryNamesProvider = Provider.autoDispose>((ref) { return {for (final c in categories) c.id: c.name}; }); -/// Spending by category for one month (`YYYY-MM`); null means "the latest month". +/// Spending by category for one month (`YYYY-MM`); null means "the latest month". See +/// `docs/ai/offline-cache.md`. final spendingProvider = - FutureProvider.autoDispose.family, String?>((ref, month) async { + FutureProvider.autoDispose.family>, String?>((ref, month) async { final r = await ref.watch(apiProvider).getCashflowApi().cashflowSpending(month: month); - return r.data ?? const []; + return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); }); /// The month currently selected on the Категории screen, `YYYY-MM`. diff --git a/app/lib/features/events/events_page.dart b/app/lib/features/events/events_page.dart index e2e3e14..024a912 100644 --- a/app/lib/features/events/events_page.dart +++ b/app/lib/features/events/events_page.dart @@ -97,7 +97,7 @@ class _EventsPageState extends ConsumerState { final state = ref.watch(eventsControllerProvider); final filter = ref.watch(eventsControllerProvider.notifier).filter; final accountNames = ref.watch(accountNamesProvider); - final accounts = ref.watch(accountsProvider).valueOrNull ?? const []; + final accounts = ref.watch(accountsProvider).valueOrNull?.data ?? const []; return Scaffold( appBar: AppBar( diff --git a/app/lib/features/goals/data/goals_api.dart b/app/lib/features/goals/data/goals_api.dart index 208347b..1c2083d 100644 --- a/app/lib/features/goals/data/goals_api.dart +++ b/app/lib/features/goals/data/goals_api.dart @@ -11,6 +11,7 @@ library; import 'package:dio/dio.dart'; +import '../../../core/cache/cached.dart'; import '../../../core/utils/json.dart'; class Goal { @@ -125,11 +126,14 @@ class GoalsApi { static const _base = '/api/v1/goals'; - Future> list() async { + /// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached` directly + /// (there is no generated `Response` for `GoalsPage` to unwrap `r.cached` from). + Future>> list() async { final r = await _dio.get>(_base); - return (r.data ?? const []) + final goals = (r.data ?? const []) .map((e) => Goal.fromJson(Map.from(e as Map))) .toList(); + return Cached(goals, fetchedAt: r.extra['fetchedAt'] as DateTime?); } Future create(Goal goal) async { @@ -144,8 +148,11 @@ class GoalsApi { Future delete(int id) => _dio.delete('$_base/$id'); - Future progress(int id) async { + Future> progress(int id) async { final r = await _dio.get>('$_base/$id/progress'); - return GoalProgress.fromJson(r.data ?? const {}); + return Cached( + GoalProgress.fromJson(r.data ?? const {}), + fetchedAt: r.extra['fetchedAt'] as DateTime?, + ); } } diff --git a/app/lib/features/goals/goal_card.dart b/app/lib/features/goals/goal_card.dart index a925f4a..fc8b7bf 100644 --- a/app/lib/features/goals/goal_card.dart +++ b/app/lib/features/goals/goal_card.dart @@ -86,7 +86,7 @@ class GoalCard extends ConsumerWidget { AsyncValueView( value: progress, onRetry: () => ref.invalidate(goalProgressProvider(goal.id)), - data: (p) => GoalProgressView(goal: goal, progress: p), + data: (cached) => GoalProgressView(goal: goal, progress: cached.data), ), ], ), diff --git a/app/lib/features/goals/goals_page.dart b/app/lib/features/goals/goals_page.dart index 5448d3f..892971f 100644 --- a/app/lib/features/goals/goals_page.dart +++ b/app/lib/features/goals/goals_page.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/cache/cached.dart'; import '../../core/utils/json.dart'; import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/stale_banner.dart'; import 'data/goals_api.dart'; import 'goal_card.dart'; import 'goal_edit_dialog.dart'; @@ -78,6 +80,13 @@ class _GoalsPageState extends ConsumerState { @override Widget build(BuildContext context) { final goals = ref.watch(goalsProvider); + // Every card's own progress fetch counts toward the one banner too — a fresh list with a + // stale progress card is still an offline dashboard, just not a visibly empty one. + final goalIds = [for (final g in goals.valueOrNull?.data ?? const []) g.id]; + final stale = oldestFetch([ + goals.valueOrNull?.fetchedAt, + for (final id in goalIds) ref.watch(goalProgressProvider(id)).valueOrNull?.fetchedAt, + ]); return Scaffold( appBar: AppBar( @@ -105,12 +114,14 @@ class _GoalsPageState extends ConsumerState { child: AsyncValueView( value: goals, onRetry: () => ref.invalidate(goalsProvider), - data: (all) { + data: (cached) { + final all = cached.data; final rows = _showArchived ? all : all.where((g) => !g.archived).toList(); if (rows.isEmpty) { return ListView( padding: const EdgeInsets.all(16), children: [ + if (stale != null) StaleBanner(fetchedAt: stale), const SizedBox(height: 48), EmptyState( icon: Icons.flag_outlined, @@ -125,6 +136,7 @@ class _GoalsPageState extends ConsumerState { return ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 88), children: [ + if (stale != null) StaleBanner(fetchedAt: stale), for (final g in rows) Padding( padding: const EdgeInsets.only(bottom: 12), diff --git a/app/lib/features/goals/providers.dart b/app/lib/features/goals/providers.dart index 878bd6f..6e3f006 100644 --- a/app/lib/features/goals/providers.dart +++ b/app/lib/features/goals/providers.dart @@ -1,17 +1,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/api/api_client.dart'; +import '../../core/cache/cached.dart'; import 'data/goals_api.dart'; final goalsApiProvider = Provider((ref) => GoalsApi(ref.watch(apiProvider).dio)); +/// See `docs/ai/offline-cache.md`. final goalsProvider = - FutureProvider.autoDispose>((ref) => ref.watch(goalsApiProvider).list()); + FutureProvider.autoDispose>>((ref) => ref.watch(goalsApiProvider).list()); /// Progress is computed server-side and refetched per goal — the client never projects /// anything itself. final goalProgressProvider = - FutureProvider.autoDispose.family((ref, id) async { + FutureProvider.autoDispose.family, int>((ref, id) async { return ref.watch(goalsApiProvider).progress(id); }); diff --git a/app/lib/features/income/calendar_tab.dart b/app/lib/features/income/calendar_tab.dart index c9ced33..7cfeaa9 100644 --- a/app/lib/features/income/calendar_tab.dart +++ b/app/lib/features/income/calendar_tab.dart @@ -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), + ), + ], + ); + }, ), ); } diff --git a/app/lib/features/income/data/income_api.dart b/app/lib/features/income/data/income_api.dart index 1a976db..b509a99 100644 --- a/app/lib/features/income/data/income_api.dart +++ b/app/lib/features/income/data/income_api.dart @@ -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 calendar({ + /// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached` directly + /// (there is no generated `Response` for the screen to unwrap `r.cached` from). + Future> 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 history({ + Future> 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 forecast({String scope = 'all', int months = 12}) async { + Future> forecast({String scope = 'all', int months = 12}) async { final r = await _dio.get>('$_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; diff --git a/app/lib/features/income/forecast_tab.dart b/app/lib/features/income/forecast_tab.dart index 0839304..70e9472 100644 --- a/app/lib/features/income/forecast_tab.dart +++ b/app/lib/features/income/forecast_tab.dart @@ -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), diff --git a/app/lib/features/income/history_tab.dart b/app/lib/features/income/history_tab.dart index 3cf32e4..5a0b354 100644 --- a/app/lib/features/income/history_tab.dart +++ b/app/lib/features/income/history_tab.dart @@ -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), diff --git a/app/lib/features/income/income_page.dart b/app/lib/features/income/income_page.dart index 2485441..23ee364 100644 --- a/app/lib/features/income/income_page.dart +++ b/app/lib/features/income/income_page.dart @@ -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()], + ), + ), + ], ), ), ); diff --git a/app/lib/features/income/providers.dart b/app/lib/features/income/providers.dart index 6cdafa7..bb53902 100644 --- a/app/lib/features/income/providers.dart +++ b/app/lib/features/income/providers.dart @@ -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((ref) => 12); final calendarIncludePaidProvider = StateProvider((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((ref) async { +/// not leave the income calendar showing a different portfolio. See `docs/ai/offline-cache.md`. +final incomeCalendarProvider = FutureProvider.autoDispose>((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((ref) /// How far the history goes back, in months. final historyMonthsProvider = StateProvider((ref) => 24); -final incomeHistoryProvider = FutureProvider.autoDispose((ref) async { +final incomeHistoryProvider = FutureProvider.autoDispose>((ref) async { final scope = ref.watch(scopeProvider); final months = ref.watch(historyMonthsProvider); final now = DateTime.now(); @@ -42,7 +43,7 @@ final incomeHistoryProvider = FutureProvider.autoDispose((ref) as final forecastMonthsProvider = StateProvider((ref) => 12); -final incomeForecastProvider = FutureProvider.autoDispose((ref) async { +final incomeForecastProvider = FutureProvider.autoDispose>((ref) async { final scope = ref.watch(scopeProvider); return ref .watch(incomeApiProvider) diff --git a/app/lib/features/portfolio/allocation_tab.dart b/app/lib/features/portfolio/allocation_tab.dart index 86f53a2..94ed9eb 100644 --- a/app/lib/features/portfolio/allocation_tab.dart +++ b/app/lib/features/portfolio/allocation_tab.dart @@ -36,7 +36,8 @@ class AllocationTab extends ConsumerWidget { child: AsyncValueView( value: allocation, onRetry: () => ref.invalidate(allocationProvider), - data: (rows) { + data: (cached) { + final rows = cached.data; if (rows.isEmpty) { return ListView( children: const [ diff --git a/app/lib/features/portfolio/benchmarks_card.dart b/app/lib/features/portfolio/benchmarks_card.dart index b99560d..cf9cf68 100644 --- a/app/lib/features/portfolio/benchmarks_card.dart +++ b/app/lib/features/portfolio/benchmarks_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/api/api_client.dart'; +import '../../core/cache/cached.dart'; import '../../core/utils/ru_date.dart'; import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/empty_state.dart'; @@ -13,8 +14,9 @@ final benchmarksApiProvider = Provider((ref) => BenchmarksApi(ref.watch(apiProvider).dio)); /// Benchmark comparison for the current scope. Part of Портфель, not a screen of its own: -/// «на сколько я обогнал индекс» is a property of the portfolio, not a separate subject. -final benchmarkRowsProvider = FutureProvider.autoDispose>((ref) async { +/// «на сколько я обогнал индекс» is a property of the portfolio, not a separate subject. See +/// `docs/ai/offline-cache.md`. +final benchmarkRowsProvider = FutureProvider.autoDispose>>((ref) async { final scope = ref.watch(scopeProvider); return ref.watch(benchmarksApiProvider).compare(scope: scope); }); @@ -34,7 +36,7 @@ class BenchmarksCard extends ConsumerWidget { return AsyncValueView( value: rows, onRetry: () => ref.invalidate(benchmarkRowsProvider), - data: (data) => data.isEmpty + data: (cached) => cached.data.isEmpty ? const EmptyState( icon: Icons.compare_arrows, message: 'Бенчмарки не настроены — сравнивать не с чем.', @@ -42,7 +44,7 @@ class BenchmarksCard extends ConsumerWidget { : Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (final row in data) + for (final row in cached.data) Padding( padding: const EdgeInsets.only(bottom: 12), child: _PeriodBlock(row: row), diff --git a/app/lib/features/portfolio/data/benchmarks_api.dart b/app/lib/features/portfolio/data/benchmarks_api.dart index f82a066..04e1bed 100644 --- a/app/lib/features/portfolio/data/benchmarks_api.dart +++ b/app/lib/features/portfolio/data/benchmarks_api.dart @@ -10,6 +10,7 @@ library; import 'package:dio/dio.dart'; +import '../../../core/cache/cached.dart'; import '../../../core/utils/json.dart'; /// One benchmark inside one period row. @@ -92,7 +93,9 @@ class BenchmarksApi { final Dio _dio; - Future> compare({ + /// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached` directly + /// (there is no generated `Response` for `BenchmarksCard` to unwrap `r.cached` from). + Future>> compare({ String scope = 'all', List periods = const ['1m', 'ytd', '1y', 'all'], }) async { @@ -101,6 +104,7 @@ class BenchmarksApi { // `period` is repeatable; Dio serialises a list as repeated query parameters. queryParameters: {'scope': scope, 'period': periods}, ); - return asObjects((r.data ?? const {})['rows']).map(BenchmarkRow.fromJson).toList(); + final rows = asObjects((r.data ?? const {})['rows']).map(BenchmarkRow.fromJson).toList(); + return Cached(rows, fetchedAt: r.extra['fetchedAt'] as DateTime?); } } diff --git a/app/lib/features/portfolio/holdings_tab.dart b/app/lib/features/portfolio/holdings_tab.dart index 6ca1bf0..9936589 100644 --- a/app/lib/features/portfolio/holdings_tab.dart +++ b/app/lib/features/portfolio/holdings_tab.dart @@ -35,7 +35,7 @@ class HoldingsTab extends ConsumerWidget { AsyncValueView( value: summary, onRetry: () => ref.invalidate(portfolioSummaryProvider), - data: (s) => _SummaryTiles(summary: s), + data: (cached) => _SummaryTiles(summary: cached.data), ), const SizedBox(height: 20), _Card( @@ -43,9 +43,9 @@ class HoldingsTab extends ConsumerWidget { child: AsyncValueView( value: series, onRetry: () => ref.invalidate(valueSeriesProvider), - data: (rows) => rows.isEmpty + data: (cached) => cached.data.isEmpty ? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.') - : _ValueChart(rows: rows), + : _ValueChart(rows: cached.data), ), ), const SizedBox(height: 16), @@ -54,9 +54,9 @@ class HoldingsTab extends ConsumerWidget { child: AsyncValueView( value: returns, onRetry: () => ref.invalidate(portfolioReturnsProvider), - data: (rows) => rows.isEmpty + data: (cached) => cached.data.isEmpty ? const EmptyState(icon: Icons.percent, message: 'Пока нечего считать.') - : _ReturnsTable(rows: rows), + : _ReturnsTable(rows: cached.data), ), ), const SizedBox(height: 16), @@ -70,12 +70,12 @@ class HoldingsTab extends ConsumerWidget { child: AsyncValueView( value: holdings, onRetry: () => ref.invalidate(holdingsProvider), - data: (rows) => rows.isEmpty + data: (cached) => cached.data.isEmpty ? const EmptyState( icon: Icons.inventory_2_outlined, message: 'Открытых позиций нет — нужна синхронизация брокера.', ) - : _HoldingsTable(rows: rows), + : _HoldingsTable(rows: cached.data), ), ), ], diff --git a/app/lib/features/portfolio/instrument_page.dart b/app/lib/features/portfolio/instrument_page.dart index 5f1ce1f..51d4985 100644 --- a/app/lib/features/portfolio/instrument_page.dart +++ b/app/lib/features/portfolio/instrument_page.dart @@ -9,6 +9,7 @@ import '../../core/utils/ru_date.dart'; import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/money_text.dart'; +import '../../core/widgets/stale_banner.dart'; import 'labels.dart'; import 'providers.dart'; @@ -28,43 +29,47 @@ class InstrumentPage extends ConsumerWidget { return Scaffold( appBar: AppBar( - title: Text(detail.valueOrNull?.instrument.ticker ?? - detail.valueOrNull?.instrument.name ?? + title: Text(detail.valueOrNull?.data.instrument.ticker ?? + detail.valueOrNull?.data.instrument.name ?? 'Инструмент'), ), body: AsyncValueView( value: detail, onRetry: () => ref.invalidate(instrumentProvider(instrumentId)), - data: (d) => ListView( - padding: const EdgeInsets.all(16), - children: [ - _Header(instrument: d.instrument, holding: d.holding), - const SizedBox(height: 16), - _Section( - title: 'Цена', - child: d.prices.isEmpty - ? const EmptyState( - icon: Icons.show_chart, - message: 'Цен нет — стоимость позиции неизвестна.', - ) - : _PriceChart(prices: d.prices), - ), - const SizedBox(height: 16), - _Section( - title: 'Лоты', - child: d.lots.isEmpty - ? const EmptyState(icon: Icons.layers_outlined, message: 'Лотов нет.') - : _LotsTable(lots: d.lots), - ), - const SizedBox(height: 16), - _Section( - title: 'События', - child: d.events.isEmpty - ? const EmptyState(icon: Icons.receipt_long, message: 'Событий нет.') - : _EventsTable(events: d.events), - ), - ], - ), + data: (cached) { + final d = cached.data; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + if (cached.fetchedAt != null) StaleBanner(fetchedAt: cached.fetchedAt!), + _Header(instrument: d.instrument, holding: d.holding), + const SizedBox(height: 16), + _Section( + title: 'Цена', + child: d.prices.isEmpty + ? const EmptyState( + icon: Icons.show_chart, + message: 'Цен нет — стоимость позиции неизвестна.', + ) + : _PriceChart(prices: d.prices), + ), + const SizedBox(height: 16), + _Section( + title: 'Лоты', + child: d.lots.isEmpty + ? const EmptyState(icon: Icons.layers_outlined, message: 'Лотов нет.') + : _LotsTable(lots: d.lots), + ), + const SizedBox(height: 16), + _Section( + title: 'События', + child: d.events.isEmpty + ? const EmptyState(icon: Icons.receipt_long, message: 'Событий нет.') + : _EventsTable(events: d.events), + ), + ], + ); + }, ), ); } diff --git a/app/lib/features/portfolio/portfolio_page.dart b/app/lib/features/portfolio/portfolio_page.dart index 7a87036..d5620e2 100644 --- a/app/lib/features/portfolio/portfolio_page.dart +++ b/app/lib/features/portfolio/portfolio_page.dart @@ -2,8 +2,11 @@ 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'; @@ -12,11 +15,23 @@ import 'providers.dart'; /// 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( @@ -27,7 +42,18 @@ class PortfolioPage extends ConsumerWidget { tabs: [Tab(text: 'Позиции'), Tab(text: 'Аллокация')], ), ), - body: const TabBarView(children: [HoldingsTab(), AllocationTab()]), + 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()]), + ), + ], + ), ), ); } diff --git a/app/lib/features/portfolio/providers.dart b/app/lib/features/portfolio/providers.dart index d3eeade..3cf265e 100644 --- a/app/lib/features/portfolio/providers.dart +++ b/app/lib/features/portfolio/providers.dart @@ -2,6 +2,7 @@ import 'package:fintracker_api/fintracker_api.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/api/api_client.dart'; +import '../../core/cache/cached.dart'; import 'benchmarks_card.dart' show benchmarkRowsProvider; /// The reporting unit every portfolio screen is scoped to: `all`, `account:` or @@ -9,37 +10,42 @@ import 'benchmarks_card.dart' show benchmarkRowsProvider; /// and the instrument card — three screens showing different scopes would be a trap. final scopeProvider = StateProvider((ref) => 'all'); +/// A small cross-screen selector (Портфель, Доходы, Налоги, the goal dialog), not a screen's +/// own primary read — left un-cached per `docs/ai/offline-cache.md`. The underlying `GET` +/// still gets served from the shared cache transparently on a network failure; this provider +/// just does not surface `fetchedAt` for a banner. final scopesProvider = FutureProvider.autoDispose>((ref) async { final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsScopes(); return r.data ?? const []; }); -final portfolioSummaryProvider = FutureProvider.autoDispose((ref) async { +/// See `docs/ai/offline-cache.md`. +final portfolioSummaryProvider = FutureProvider.autoDispose>((ref) async { final scope = ref.watch(scopeProvider); final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsSummary(scope: scope); - return r.data!; + return r.cached; }); -final holdingsProvider = FutureProvider.autoDispose>((ref) async { +final holdingsProvider = FutureProvider.autoDispose>>((ref) async { final scope = ref.watch(scopeProvider); final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsHoldings(scope: scope); - return r.data ?? const []; + return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); }); -final portfolioReturnsProvider = FutureProvider.autoDispose>((ref) async { +final portfolioReturnsProvider = FutureProvider.autoDispose>>((ref) async { final scope = ref.watch(scopeProvider); final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsReturns(scope: scope); - return r.data ?? const []; + return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); }); -final allocationProvider = FutureProvider.autoDispose>((ref) async { +final allocationProvider = FutureProvider.autoDispose>>((ref) async { final scope = ref.watch(scopeProvider); final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsAllocation(scope: scope); - return r.data ?? const []; + return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); }); /// Daily portfolio value for the last year, for the chart on Позиции. -final valueSeriesProvider = FutureProvider.autoDispose>((ref) async { +final valueSeriesProvider = FutureProvider.autoDispose>>((ref) async { final scope = ref.watch(scopeProvider); final now = DateTime.now(); final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsValueSeries( @@ -47,17 +53,17 @@ final valueSeriesProvider = FutureProvider.autoDispose>((ref) asy from: now.subtract(const Duration(days: 365)), to: now, ); - return r.data ?? const []; + return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); }); final instrumentProvider = - FutureProvider.autoDispose.family((ref, instrumentId) async { + FutureProvider.autoDispose.family, int>((ref, instrumentId) async { final scope = ref.watch(scopeProvider); final r = await ref .watch(apiProvider) .getInstrumentsApi() .instrumentsGet(instrumentId: instrumentId, scope: scope); - return r.data!; + return r.cached; }); /// Every portfolio provider, refreshed together after a metrics rebuild or a pull-to-refresh. diff --git a/app/lib/features/rebalance/data/rebalance_api.dart b/app/lib/features/rebalance/data/rebalance_api.dart index a605c38..8f50352 100644 --- a/app/lib/features/rebalance/data/rebalance_api.dart +++ b/app/lib/features/rebalance/data/rebalance_api.dart @@ -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 getTargets(int portfolioId, {String dimension = 'asset_class'}) async { + /// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached` directly + /// (there is no generated `Response` for the screen to unwrap `r.cached` from). + Future> getTargets(int portfolioId, {String dimension = 'asset_class'}) async { final r = await _dio.get>( '$_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 plan( + Future> 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?, + ); } } diff --git a/app/lib/features/rebalance/plan_tab.dart b/app/lib/features/rebalance/plan_tab.dart index edf4086..6a57607 100644 --- a/app/lib/features/rebalance/plan_tab.dart +++ b/app/lib/features/rebalance/plan_tab.dart @@ -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, diff --git a/app/lib/features/rebalance/providers.dart b/app/lib/features/rebalance/providers.dart index bec6ba7..44eba70 100644 --- a/app/lib/features/rebalance/providers.dart +++ b/app/lib/features/rebalance/providers.dart @@ -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((ref) => 'asset_class'); /// What-if cash for the recommendations, as a decimal string. Null = use the real balance. final whatIfCashProvider = StateProvider((ref) => null); -final targetsProvider = FutureProvider.autoDispose((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>((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((ref) async { +final rebalancePlanProvider = FutureProvider.autoDispose>((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); }); diff --git a/app/lib/features/rebalance/rebalance_page.dart b/app/lib/features/rebalance/rebalance_page.dart index d51980b..77b3221 100644 --- a/app/lib/features/rebalance/rebalance_page.dart +++ b/app/lib/features/rebalance/rebalance_page.dart @@ -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()]); + }, + ), + ), + ], ), ), ); diff --git a/app/lib/features/rebalance/targets_tab.dart b/app/lib/features/rebalance/targets_tab.dart index f21bdc7..a9277d1 100644 --- a/app/lib/features/rebalance/targets_tab.dart +++ b/app/lib/features/rebalance/targets_tab.dart @@ -77,7 +77,8 @@ class _TargetsTabState extends ConsumerState { return AsyncValueView( value: targets, onRetry: () => ref.invalidate(targetsProvider), - data: (set) { + data: (cached) { + final set = cached.data; _seed(set, '$portfolioId/$dimension'); final draft = _draft ?? const []; return ListView( diff --git a/app/lib/features/rules/providers.dart b/app/lib/features/rules/providers.dart index 688f6cb..aece13b 100644 --- a/app/lib/features/rules/providers.dart +++ b/app/lib/features/rules/providers.dart @@ -2,19 +2,23 @@ import 'package:fintracker_api/fintracker_api.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/api/api_client.dart'; +import '../../core/cache/cached.dart'; -final rulesListProvider = FutureProvider.autoDispose>((ref) async { +/// See `docs/ai/offline-cache.md`. +final rulesListProvider = FutureProvider.autoDispose>>((ref) async { final r = await ref.watch(apiProvider).getRulesApi().rulesList(); - return r.data ?? const []; + return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); }); -/// Enabled rules that matched nothing in the latest refresh. -final rulesStaleProvider = FutureProvider.autoDispose>((ref) async { +/// Enabled rules that matched nothing in the latest refresh — «устарело» here is a business +/// concept (the rule looks dead), unrelated to the offline-cache staleness this file also +/// tracks; both happen to be named "stale" in their own domains. +final rulesStaleProvider = FutureProvider.autoDispose>>((ref) async { final r = await ref.watch(apiProvider).getRulesApi().rulesStale(); - return r.data ?? const []; + return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?); }); final staleRuleIdsProvider = Provider.autoDispose>((ref) { - final stale = ref.watch(rulesStaleProvider).valueOrNull ?? const []; + final stale = ref.watch(rulesStaleProvider).valueOrNull?.data ?? const []; return {for (final r in stale) r.id}; }); diff --git a/app/lib/features/rules/rules_page.dart b/app/lib/features/rules/rules_page.dart index 9bc39ea..795a4cb 100644 --- a/app/lib/features/rules/rules_page.dart +++ b/app/lib/features/rules/rules_page.dart @@ -5,9 +5,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/api/api_client.dart'; import '../../core/auth/auth_controller.dart'; +import '../../core/cache/cached.dart'; import '../../core/utils/ru_date.dart'; import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/stale_banner.dart'; import 'providers.dart'; String ruleKindLabel(RuleKind k) => switch (k) { @@ -77,6 +79,10 @@ class RulesPage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final rules = ref.watch(rulesListProvider); final staleIds = ref.watch(staleRuleIdsProvider); + final stale = oldestFetch([ + rules.valueOrNull?.fetchedAt, + ref.watch(rulesStaleProvider).valueOrNull?.fetchedAt, + ]); return Scaffold( appBar: AppBar( @@ -101,11 +107,13 @@ class RulesPage extends ConsumerWidget { child: AsyncValueView( value: rules, onRetry: () => ref.invalidate(rulesListProvider), - data: (rows) { + data: (cached) { + final rows = cached.data; if (rows.isEmpty) { return ListView( - children: const [ - EmptyState(icon: Icons.rule_folder_outlined, message: 'Правил ещё нет.'), + children: [ + if (stale != null) StaleBanner(fetchedAt: stale), + const EmptyState(icon: Icons.rule_folder_outlined, message: 'Правил ещё нет.'), ], ); } @@ -113,6 +121,7 @@ class RulesPage extends ConsumerWidget { return ListView( padding: const EdgeInsets.all(16), children: [ + if (stale != null) StaleBanner(fetchedAt: stale), for (final rule in sorted) _RuleTile( rule: rule, diff --git a/app/lib/features/tax/data/tax_api.dart b/app/lib/features/tax/data/tax_api.dart index e3254a2..3941dd6 100644 --- a/app/lib/features/tax/data/tax_api.dart +++ b/app/lib/features/tax/data/tax_api.dart @@ -11,6 +11,7 @@ library; import 'package:dio/dio.dart'; +import '../../../core/cache/cached.dart'; import '../../../core/utils/json.dart'; /// Per-account (or total) tax figures for a year. Every number is an **estimate**: the tax @@ -153,19 +154,22 @@ class TaxApi { static const _base = '/api/v1/tax'; - Future summary({required int year, int? accountId}) async { + /// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached` directly + /// (there is no generated `Response` for the screen to unwrap `r.cached` from). + Future> summary({required int year, int? accountId}) async { final r = await _dio.get>( _base, queryParameters: {'year': year, 'account_id': ?accountId}, ); - return TaxSummary.fromJson(r.data ?? const {}); + return Cached(TaxSummary.fromJson(r.data ?? const {}), fetchedAt: r.extra['fetchedAt'] as DateTime?); } - Future> lots({required int year, int? accountId}) async { + Future>> lots({required int year, int? accountId}) async { final r = await _dio.get>( '$_base/lots', queryParameters: {'year': year, 'account_id': ?accountId}, ); - return asObjects((r.data ?? const {})['lots']).map(TaxLot.fromJson).toList(); + final lots = asObjects((r.data ?? const {})['lots']).map(TaxLot.fromJson).toList(); + return Cached(lots, fetchedAt: r.extra['fetchedAt'] as DateTime?); } } diff --git a/app/lib/features/tax/lots_tab.dart b/app/lib/features/tax/lots_tab.dart index 995be73..3c8b435 100644 --- a/app/lib/features/tax/lots_tab.dart +++ b/app/lib/features/tax/lots_tab.dart @@ -35,7 +35,8 @@ class _TaxLotsTabState extends ConsumerState { child: AsyncValueView( value: lots, onRetry: () => ref.invalidate(taxLotsProvider), - data: (all) { + data: (cached) { + final all = cached.data; final near = all.where((l) => l.nearLdv).toList(); // a copy: the provider's list must not be reordered under other watchers final rows = [...(_onlyNearLdv ? near : all)]; diff --git a/app/lib/features/tax/providers.dart b/app/lib/features/tax/providers.dart index 6ab881b..f780f74 100644 --- a/app/lib/features/tax/providers.dart +++ b/app/lib/features/tax/providers.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/api/api_client.dart'; +import '../../core/cache/cached.dart'; import 'data/tax_api.dart'; final taxApiProvider = Provider((ref) => TaxApi(ref.watch(apiProvider).dio)); @@ -10,14 +11,15 @@ final taxYearProvider = StateProvider((ref) => DateTime.now().year); /// Optional account filter; null = all accounts. final taxAccountProvider = StateProvider((ref) => null); -final taxSummaryProvider = FutureProvider.autoDispose((ref) async { +/// See `docs/ai/offline-cache.md`. +final taxSummaryProvider = FutureProvider.autoDispose>((ref) async { return ref.watch(taxApiProvider).summary( year: ref.watch(taxYearProvider), accountId: ref.watch(taxAccountProvider), ); }); -final taxLotsProvider = FutureProvider.autoDispose>((ref) async { +final taxLotsProvider = FutureProvider.autoDispose>>((ref) async { return ref.watch(taxApiProvider).lots( year: ref.watch(taxYearProvider), accountId: ref.watch(taxAccountProvider), diff --git a/app/lib/features/tax/summary_tab.dart b/app/lib/features/tax/summary_tab.dart index aac1833..bb25dbc 100644 --- a/app/lib/features/tax/summary_tab.dart +++ b/app/lib/features/tax/summary_tab.dart @@ -22,7 +22,8 @@ class TaxSummaryTab extends ConsumerWidget { child: AsyncValueView( value: summary, onRetry: () => ref.invalidate(taxSummaryProvider), - data: (data) { + data: (cached) { + final data = cached.data; final totals = data.totals; return ListView( padding: const EdgeInsets.all(16), diff --git a/app/lib/features/tax/tax_page.dart b/app/lib/features/tax/tax_page.dart index 5ee3a78..fa1c322 100644 --- a/app/lib/features/tax/tax_page.dart +++ b/app/lib/features/tax/tax_page.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/cache/cached.dart'; +import '../../core/widgets/stale_banner.dart'; import 'data/tax_api.dart'; import 'lots_tab.dart'; import 'providers.dart'; @@ -16,6 +18,11 @@ class TaxPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final stale = oldestFetch([ + ref.watch(taxSummaryProvider).valueOrNull?.fetchedAt, + ref.watch(taxLotsProvider).valueOrNull?.fetchedAt, + ]); + return DefaultTabController( length: 2, child: Scaffold( @@ -33,10 +40,15 @@ class TaxPage extends ConsumerWidget { tabs: [Tab(text: 'Сводка за год'), Tab(text: 'Лоты и ЛДВ')], ), ), - body: const Column( + body: Column( children: [ - EstimateBanner(), - Expanded(child: TabBarView(children: [TaxSummaryTab(), TaxLotsTab()])), + const EstimateBanner(), + if (stale != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: StaleBanner(fetchedAt: stale), + ), + const Expanded(child: TabBarView(children: [TaxSummaryTab(), TaxLotsTab()])), ], ), ), @@ -50,7 +62,7 @@ class EstimateBanner extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final summary = ref.watch(taxSummaryProvider).valueOrNull; + final summary = ref.watch(taxSummaryProvider).valueOrNull?.data; final scheme = Theme.of(context).colorScheme; final text = summary?.disclaimer ?? TaxSummary.defaultDisclaimer; diff --git a/app/lib/features/transactions/transactions_page.dart b/app/lib/features/transactions/transactions_page.dart index f2b952f..f5742a2 100644 --- a/app/lib/features/transactions/transactions_page.dart +++ b/app/lib/features/transactions/transactions_page.dart @@ -118,7 +118,7 @@ class _TransactionsPageState extends ConsumerState { final state = ref.watch(transactionsControllerProvider); final controllerFilter = ref.watch(transactionsControllerProvider.notifier).filter; final categoryNames = ref.watch(categoryNamesProvider); - final accounts = ref.watch(accountsProvider).valueOrNull ?? const []; + final accounts = ref.watch(accountsProvider).valueOrNull?.data ?? const []; final categories = ref.watch(categoriesListProvider).valueOrNull ?? const []; return Scaffold( diff --git a/app/test/portfolio_test.dart b/app/test/portfolio_test.dart index d46f221..6a073e4 100644 --- a/app/test/portfolio_test.dart +++ b/app/test/portfolio_test.dart @@ -1,6 +1,9 @@ import 'package:fintracker_api/fintracker_api.dart'; +import 'package:fintracker_app/core/cache/cached.dart'; import 'package:fintracker_app/core/widgets/money_text.dart'; import 'package:fintracker_app/features/portfolio/allocation_tab.dart'; +import 'package:fintracker_app/features/portfolio/benchmarks_card.dart'; +import 'package:fintracker_app/features/portfolio/data/benchmarks_api.dart'; import 'package:fintracker_app/features/portfolio/holdings_tab.dart'; import 'package:fintracker_app/features/portfolio/labels.dart'; import 'package:fintracker_app/features/portfolio/providers.dart'; @@ -82,13 +85,16 @@ void main() { await pumpTall(tester, _wrap( const HoldingsTab(), [ - portfolioSummaryProvider.overrideWith((ref) => summary()), - valueSeriesProvider.overrideWith((ref) => const []), - portfolioReturnsProvider.overrideWith((ref) => const []), - holdingsProvider.overrideWith((ref) => [ + portfolioSummaryProvider.overrideWith((ref) => Cached(summary())), + valueSeriesProvider.overrideWith((ref) => const Cached([])), + portfolioReturnsProvider.overrideWith((ref) => const Cached([])), + holdingsProvider.overrideWith((ref) => Cached([ holding(id: 1, ticker: 'GAZP', valueRub: '11000', weight: '1'), holding(id: 2, ticker: 'SIBN6P4', priceStatus: 'missing'), - ]), + ])), + // HoldingsTab embeds BenchmarksCard, which watches this too — leaving it out would + // fall through to the real apiProvider (see docs/ai/offline-cache.md). + benchmarkRowsProvider.overrideWith((ref) => const Cached([])), ], )); @@ -105,10 +111,11 @@ void main() { await pumpTall(tester, _wrap( const HoldingsTab(), [ - portfolioSummaryProvider.overrideWith((ref) => summary()), - valueSeriesProvider.overrideWith((ref) => const []), - portfolioReturnsProvider.overrideWith((ref) => const []), - holdingsProvider.overrideWith((ref) => const []), + portfolioSummaryProvider.overrideWith((ref) => Cached(summary())), + valueSeriesProvider.overrideWith((ref) => const Cached([])), + portfolioReturnsProvider.overrideWith((ref) => const Cached([])), + holdingsProvider.overrideWith((ref) => const Cached([])), + benchmarkRowsProvider.overrideWith((ref) => const Cached([])), ], )); @@ -120,7 +127,7 @@ void main() { await pumpTall(tester, _wrap( const AllocationTab(), [ - allocationProvider.overrideWith((ref) => [ + allocationProvider.overrideWith((ref) => Cached([ AllocationBucket( bucket: 'share', dimension: AllocationDimension.assetClass, @@ -135,7 +142,7 @@ void main() { valueRub: '5700', weight: '0.3413', ), - ]), + ])), ], )); diff --git a/app/test/rules_page_test.dart b/app/test/rules_page_test.dart index 47038c8..e8a48ee 100644 --- a/app/test/rules_page_test.dart +++ b/app/test/rules_page_test.dart @@ -1,4 +1,5 @@ import 'package:fintracker_api/fintracker_api.dart'; +import 'package:fintracker_app/core/cache/cached.dart'; import 'package:fintracker_app/features/rules/providers.dart'; import 'package:fintracker_app/features/rules/rules_page.dart'; import 'package:flutter/material.dart'; @@ -10,8 +11,8 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ - rulesListProvider.overrideWith((ref) => const []), - rulesStaleProvider.overrideWith((ref) => const []), + rulesListProvider.overrideWith((ref) => const Cached([])), + rulesStaleProvider.overrideWith((ref) => const Cached([])), ], child: const MaterialApp(home: RulesPage()), ),