Merge: offline-кэш на остальных экранах

This commit is contained in:
Dmitry
2026-09-19 14:00:24 +03:00
39 changed files with 388 additions and 186 deletions
+6 -1
View File
@@ -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(
+7 -4
View File
@@ -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<List<AccountOut>>((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<Cached<List<AccountOut>>>((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<Map<int, String>>((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};
});
+6 -1
View File
@@ -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(
+4 -3
View File
@@ -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<List<CashFlowMonth>>((ref) async {
/// The last 24 months, oldest first (as the API returns them). See `docs/ai/offline-cache.md`.
final cashflowMonthly24Provider = FutureProvider.autoDispose<Cached<List<CashFlowMonth>>>((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?);
});
@@ -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<CategoriesPage> {
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<CategoriesPage> {
],
),
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,
+7 -4
View File
@@ -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<List<CategoryOut>>((ref) async {
final r = await ref.watch(apiProvider).getCategoriesApi().categoriesList();
return r.data ?? const [];
@@ -16,11 +18,12 @@ final categoryNamesProvider = Provider.autoDispose<Map<int, String>>((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<List<SpendingRow>, String?>((ref, month) async {
FutureProvider.autoDispose.family<Cached<List<SpendingRow>>, 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`.
+1 -1
View File
@@ -97,7 +97,7 @@ class _EventsPageState extends ConsumerState<EventsPage> {
final state = ref.watch(eventsControllerProvider);
final filter = ref.watch(eventsControllerProvider.notifier).filter;
final accountNames = ref.watch(accountNamesProvider);
final accounts = ref.watch(accountsProvider).valueOrNull ?? const <AccountOut>[];
final accounts = ref.watch(accountsProvider).valueOrNull?.data ?? const <AccountOut>[];
return Scaffold(
appBar: AppBar(
+11 -4
View File
@@ -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<Goal>> list() async {
/// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached<T>` directly
/// (there is no generated `Response<T>` for `GoalsPage` to unwrap `r.cached` from).
Future<Cached<List<Goal>>> list() async {
final r = await _dio.get<List<dynamic>>(_base);
return (r.data ?? const [])
final goals = (r.data ?? const [])
.map((e) => Goal.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
return Cached(goals, fetchedAt: r.extra['fetchedAt'] as DateTime?);
}
Future<Goal> create(Goal goal) async {
@@ -144,8 +148,11 @@ class GoalsApi {
Future<void> delete(int id) => _dio.delete<void>('$_base/$id');
Future<GoalProgress> progress(int id) async {
Future<Cached<GoalProgress>> progress(int id) async {
final r = await _dio.get<Map<String, dynamic>>('$_base/$id/progress');
return GoalProgress.fromJson(r.data ?? const {});
return Cached(
GoalProgress.fromJson(r.data ?? const {}),
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
}
}
+1 -1
View File
@@ -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),
),
],
),
+13 -1
View File
@@ -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<GoalsPage> {
@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 <Goal>[]) 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<GoalsPage> {
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<GoalsPage> {
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),
+4 -2
View File
@@ -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<GoalsApi>((ref) => GoalsApi(ref.watch(apiProvider).dio));
/// See `docs/ai/offline-cache.md`.
final goalsProvider =
FutureProvider.autoDispose<List<Goal>>((ref) => ref.watch(goalsApiProvider).list());
FutureProvider.autoDispose<Cached<List<Goal>>>((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<GoalProgress, int>((ref, id) async {
FutureProvider.autoDispose.family<Cached<GoalProgress>, int>((ref, id) async {
return ref.watch(goalsApiProvider).progress(id);
});
+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)
@@ -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 [
@@ -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<BenchmarksApi>((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<List<BenchmarkRow>>((ref) async {
/// «на сколько я обогнал индекс» is a property of the portfolio, not a separate subject. See
/// `docs/ai/offline-cache.md`.
final benchmarkRowsProvider = FutureProvider.autoDispose<Cached<List<BenchmarkRow>>>((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),
@@ -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<List<BenchmarkRow>> compare({
/// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached<T>` directly
/// (there is no generated `Response<T>` for `BenchmarksCard` to unwrap `r.cached` from).
Future<Cached<List<BenchmarkRow>>> compare({
String scope = 'all',
List<String> 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?);
}
}
+7 -7
View File
@@ -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),
),
),
],
+37 -32
View File
@@ -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),
),
],
);
},
),
);
}
+27 -1
View File
@@ -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()]),
),
],
),
),
);
}
+18 -12
View File
@@ -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:<id>` 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<String>((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<List<ScopeOut>>((ref) async {
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsScopes();
return r.data ?? const [];
});
final portfolioSummaryProvider = FutureProvider.autoDispose<SummaryOut>((ref) async {
/// See `docs/ai/offline-cache.md`.
final portfolioSummaryProvider = FutureProvider.autoDispose<Cached<SummaryOut>>((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<List<HoldingOut>>((ref) async {
final holdingsProvider = FutureProvider.autoDispose<Cached<List<HoldingOut>>>((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<List<ReturnsOut>>((ref) async {
final portfolioReturnsProvider = FutureProvider.autoDispose<Cached<List<ReturnsOut>>>((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<List<AllocationBucket>>((ref) async {
final allocationProvider = FutureProvider.autoDispose<Cached<List<AllocationBucket>>>((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<List<ValueDay>>((ref) async {
final valueSeriesProvider = FutureProvider.autoDispose<Cached<List<ValueDay>>>((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<List<ValueDay>>((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<InstrumentDetail, int>((ref, instrumentId) async {
FutureProvider.autoDispose.family<Cached<InstrumentDetail>, 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.
@@ -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(
+10 -6
View File
@@ -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<List<RuleOut>>((ref) async {
/// See `docs/ai/offline-cache.md`.
final rulesListProvider = FutureProvider.autoDispose<Cached<List<RuleOut>>>((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<List<RuleOut>>((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<Cached<List<RuleOut>>>((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<Set<int>>((ref) {
final stale = ref.watch(rulesStaleProvider).valueOrNull ?? const [];
final stale = ref.watch(rulesStaleProvider).valueOrNull?.data ?? const [];
return {for (final r in stale) r.id};
});
+12 -3
View File
@@ -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,
+8 -4
View File
@@ -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<TaxSummary> summary({required int year, int? accountId}) 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<TaxSummary>> summary({required int year, int? accountId}) async {
final r = await _dio.get<Map<String, dynamic>>(
_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<List<TaxLot>> lots({required int year, int? accountId}) async {
Future<Cached<List<TaxLot>>> lots({required int year, int? accountId}) async {
final r = await _dio.get<Map<String, dynamic>>(
'$_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?);
}
}
+2 -1
View File
@@ -35,7 +35,8 @@ class _TaxLotsTabState extends ConsumerState<TaxLotsTab> {
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)];
+4 -2
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 'data/tax_api.dart';
final taxApiProvider = Provider<TaxApi>((ref) => TaxApi(ref.watch(apiProvider).dio));
@@ -10,14 +11,15 @@ final taxYearProvider = StateProvider<int>((ref) => DateTime.now().year);
/// Optional account filter; null = all accounts.
final taxAccountProvider = StateProvider<int?>((ref) => null);
final taxSummaryProvider = FutureProvider.autoDispose<TaxSummary>((ref) async {
/// See `docs/ai/offline-cache.md`.
final taxSummaryProvider = FutureProvider.autoDispose<Cached<TaxSummary>>((ref) async {
return ref.watch(taxApiProvider).summary(
year: ref.watch(taxYearProvider),
accountId: ref.watch(taxAccountProvider),
);
});
final taxLotsProvider = FutureProvider.autoDispose<List<TaxLot>>((ref) async {
final taxLotsProvider = FutureProvider.autoDispose<Cached<List<TaxLot>>>((ref) async {
return ref.watch(taxApiProvider).lots(
year: ref.watch(taxYearProvider),
accountId: ref.watch(taxAccountProvider),
+2 -1
View File
@@ -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),
+16 -4
View File
@@ -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;
@@ -118,7 +118,7 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
final state = ref.watch(transactionsControllerProvider);
final controllerFilter = ref.watch(transactionsControllerProvider.notifier).filter;
final categoryNames = ref.watch(categoryNamesProvider);
final accounts = ref.watch(accountsProvider).valueOrNull ?? const <AccountOut>[];
final accounts = ref.watch(accountsProvider).valueOrNull?.data ?? const <AccountOut>[];
final categories = ref.watch(categoriesListProvider).valueOrNull ?? const <CategoryOut>[];
return Scaffold(
+18 -11
View File
@@ -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 <ValueDay>[]),
portfolioReturnsProvider.overrideWith((ref) => const <ReturnsOut>[]),
holdingsProvider.overrideWith((ref) => [
portfolioSummaryProvider.overrideWith((ref) => Cached(summary())),
valueSeriesProvider.overrideWith((ref) => const Cached(<ValueDay>[])),
portfolioReturnsProvider.overrideWith((ref) => const Cached(<ReturnsOut>[])),
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(<BenchmarkRow>[])),
],
));
@@ -105,10 +111,11 @@ void main() {
await pumpTall(tester, _wrap(
const HoldingsTab(),
[
portfolioSummaryProvider.overrideWith((ref) => summary()),
valueSeriesProvider.overrideWith((ref) => const <ValueDay>[]),
portfolioReturnsProvider.overrideWith((ref) => const <ReturnsOut>[]),
holdingsProvider.overrideWith((ref) => const <HoldingOut>[]),
portfolioSummaryProvider.overrideWith((ref) => Cached(summary())),
valueSeriesProvider.overrideWith((ref) => const Cached(<ValueDay>[])),
portfolioReturnsProvider.overrideWith((ref) => const Cached(<ReturnsOut>[])),
holdingsProvider.overrideWith((ref) => const Cached(<HoldingOut>[])),
benchmarkRowsProvider.overrideWith((ref) => const Cached(<BenchmarkRow>[])),
],
));
@@ -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',
),
]),
])),
],
));
+3 -2
View File
@@ -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 <RuleOut>[]),
rulesStaleProvider.overrideWith((ref) => const <RuleOut>[]),
rulesListProvider.overrideWith((ref) => const Cached(<RuleOut>[])),
rulesStaleProvider.overrideWith((ref) => const Cached(<RuleOut>[])),
],
child: const MaterialApp(home: RulesPage()),
),