Files
fin-tracker/app/lib/features/home/providers.dart
T
Dmitry 62d36aa3e8 feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
2026-09-19 22:14:21 +03:00

151 lines
5.2 KiB
Dart

import 'package:dio/dio.dart';
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 provider below returns `Cached<T>`, not `T`: `HomePage` reads
/// `.data` for the tiles and `.fetchedAt` (via `oldestFetch`) for the one
/// offline banner at the top. See `docs/ai/offline-cache.md`.
final netWorthBreakdownProvider =
FutureProvider.autoDispose<Cached<NetWorthBreakdown>>((ref) async {
final r = await ref
.watch(apiProvider)
.getNetworthApi()
.networthBreakdown();
return r.cached;
});
/// Daily net worth for the last 365 days.
final netWorthSeriesProvider =
FutureProvider.autoDispose<Cached<List<NetWorthDay>>>((ref) async {
final now = DateTime.now();
final r = await ref
.watch(apiProvider)
.getNetworthApi()
.networthSeries(
from: now.subtract(const Duration(days: 365)),
to: now,
);
return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
});
/// The current (partial) month's cashflow, or null before any data exists.
final cashflowThisMonthProvider =
FutureProvider.autoDispose<Cached<CashFlowMonth?>>((ref) async {
final r = await ref
.watch(apiProvider)
.getCashflowApi()
.cashflowMonthly(months: 1);
final rows = r.data ?? const [];
return Cached(
rows.isEmpty ? null : rows.last,
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
});
/// The last 12 months, for the income-vs-expense bar chart.
final cashflowLast12Provider =
FutureProvider.autoDispose<Cached<List<CashFlowMonth>>>((ref) async {
final r = await ref
.watch(apiProvider)
.getCashflowApi()
.cashflowMonthly(months: 12);
return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
});
final runwayProvider = FutureProvider.autoDispose<Cached<RunwayOut>>((
ref,
) async {
final r = await ref.watch(apiProvider).getCashflowApi().cashflowRunway();
return r.cached;
});
/// When the metric tables were last rebuilt, whether they are one consistent snapshot, and
/// whether another rebuild is on its way.
final metricsStatusProvider =
FutureProvider.autoDispose<Cached<MetricsStatusOut>>((ref) async {
final r = await ref.watch(apiProvider).getMetricsApi().metricsStatus();
return r.cached;
});
/// Waits for a queued metrics rebuild to finish: `POST /metrics/refresh` only queues it, so the
/// numbers are stale until `refreshing` turns false. Returns the last status seen, or null if
/// [timeout] ran out first (a rebuild stuck behind a long sync, say).
Future<MetricsStatusOut?> waitForMetricsRefresh(
Future<MetricsStatusOut> Function() fetch, {
Duration interval = const Duration(seconds: 2),
Duration timeout = const Duration(minutes: 5),
}) async {
final clock = Stopwatch()..start();
while (true) {
final status = await fetch();
if (!status.refreshing) return status;
if (clock.elapsed >= timeout) return null;
await Future<void>.delayed(interval);
}
}
final dataQualityProvider =
FutureProvider.autoDispose<Cached<List<DataQualityRow>>>((ref) async {
final r = await ref
.watch(apiProvider)
.getMetricsApi()
.metricsDataQuality();
return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
});
/// Every dashboard provider, refreshed together after a manual
/// `POST /metrics/refresh` or a pull-to-refresh.
void invalidateHomeProviders(WidgetRef ref) {
ref.invalidate(netWorthBreakdownProvider);
ref.invalidate(netWorthSeriesProvider);
ref.invalidate(cashflowThisMonthProvider);
ref.invalidate(cashflowLast12Provider);
ref.invalidate(runwayProvider);
ref.invalidate(metricsStatusProvider);
ref.invalidate(dataQualityProvider);
ref.invalidate(portfolioSummaryHomeProvider);
ref.invalidate(scopeCardsProvider);
}
/// The investment side of the dashboard: one scope-wide summary, `all` by default.
/// Null when the ledger is empty, which is the normal state before a broker sync.
final portfolioSummaryHomeProvider =
FutureProvider.autoDispose<Cached<SummaryOut?>>((ref) async {
try {
final r = await ref
.watch(apiProvider)
.getAnalyticsApi()
.analyticsSummary();
return r.cached;
} on DioException catch (e) {
if (e.response?.statusCode == 404) return const Cached(null);
rethrow;
}
});
/// One card per portfolio and account for the home grid (`GET /analytics/overview`): value,
/// result, the last day's change, return and expected passive income, in one round trip.
final scopeCardsProvider =
FutureProvider.autoDispose<Cached<List<ScopeCardOut>>>((ref) async {
final r = await ref
.watch(apiProvider)
.getAnalyticsApi()
.analyticsOverview();
return Cached(
r.data ?? const [],
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
});