Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
151 lines
5.2 KiB
Dart
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?,
|
|
);
|
|
});
|