feat(app): offline-кэш на остальных экранах — фаза 5
accounts, cashflow, categories, goals, income, portfolio (+instrument), rebalance, tax, rules переведены на Cached<T> по контракту docs/ai/offline-cache.md. Общие/второстепенные селекторы (scopesProvider, categoriesListProvider и т.п.) оставлены как есть — не основной контент экрана. sync_page.dart и settings_page.dart не тронуты (первый — заменён health-page отдельно, второй — чистые действия без списка для баннера).
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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};
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -29,7 +29,9 @@ class IncomeCalendarTab extends ConsumerWidget {
|
||||
child: AsyncValueView(
|
||||
value: calendar,
|
||||
onRetry: () => ref.invalidate(incomeCalendarProvider),
|
||||
data: (data) => ListView(
|
||||
data: (cached) {
|
||||
final data = cached.data;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const _CalendarControls(),
|
||||
@@ -52,7 +54,8 @@ class IncomeCalendarTab extends ConsumerWidget {
|
||||
child: _MonthCard(month: group.key, entries: group.value),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,10 +43,21 @@ class IncomePage extends ConsumerWidget {
|
||||
tabs: [Tab(text: 'Календарь'), Tab(text: 'История'), Tab(text: 'Прогноз')],
|
||||
),
|
||||
),
|
||||
body: const TabBarView(
|
||||
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()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,16 +29,19 @@ 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(
|
||||
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(
|
||||
@@ -64,7 +68,8 @@ class InstrumentPage extends ConsumerWidget {
|
||||
: _EventsTable(events: d.events),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,7 +48,15 @@ class RebalancePage extends ConsumerWidget {
|
||||
tabs: [Tab(text: 'Целевые веса'), Tab(text: 'Рекомендации')],
|
||||
),
|
||||
),
|
||||
body: AsyncValueView(
|
||||
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) {
|
||||
@@ -60,6 +79,9 @@ class RebalancePage extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
]),
|
||||
])),
|
||||
],
|
||||
));
|
||||
|
||||
|
||||
@@ -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()),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user