feat(app): offline-кэш GET-запросов на дашборде — фаза 5
CacheInterceptor кэширует каждый успешный GET по путь+параметры в drift (sqlite нативно, wasm+OPFS в браузере) и подменяет им сетевую ошибку; провайдер отдаёт Cached<T>, экран показывает баннер «данные на …». Контракт для остальных экранов — docs/ai/offline-cache.md. flake.nix: libsecret/pkg-config для линуксовой сборки, jq/curl для just app-web-assets (сборка sqlite3.wasm + drift_worker.js).
This commit is contained in:
@@ -3,9 +3,22 @@ import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../auth/auth_controller.dart';
|
||||
import '../cache/cache_interceptor.dart';
|
||||
import '../cache/response_cache.dart';
|
||||
import '../config.dart';
|
||||
|
||||
/// Authenticated client: bearer header on every call, one transparent refresh on 401.
|
||||
/// One sqlite-backed cache of the last successful GET per endpoint+params,
|
||||
/// shared by every screen. See `docs/ai/offline-cache.md`.
|
||||
final responseCacheDbProvider = Provider<ResponseCacheDatabase>((ref) {
|
||||
final db = ResponseCacheDatabase();
|
||||
ref.onDispose(db.close);
|
||||
return db;
|
||||
});
|
||||
|
||||
/// Authenticated client: bearer header on every call, one transparent refresh
|
||||
/// on 401, and — since [CacheInterceptor] is added last, so it sees a request
|
||||
/// only after auth has already handled it — the last cached answer when the
|
||||
/// network itself is unreachable.
|
||||
final apiProvider = Provider<FintrackerApi>((ref) {
|
||||
final dio = Dio(BaseOptions(
|
||||
baseUrl: apiBaseUrl(),
|
||||
@@ -14,6 +27,7 @@ final apiProvider = Provider<FintrackerApi>((ref) {
|
||||
));
|
||||
dio.interceptors.add(DateQueryInterceptor());
|
||||
dio.interceptors.add(_AuthInterceptor(ref));
|
||||
dio.interceptors.add(CacheInterceptor(ref.watch(responseCacheDbProvider)));
|
||||
return FintrackerApi(dio: dio, interceptors: const []);
|
||||
});
|
||||
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'response_cache.dart';
|
||||
|
||||
/// Persists every successful GET body by endpoint+params, and serves the last
|
||||
/// one back when the network itself is unreachable — see
|
||||
/// `docs/ai/offline-cache.md` for the whole contract.
|
||||
///
|
||||
/// Mutations (anything but GET) are never cached and never served stale: a
|
||||
/// failed POST/PATCH/DELETE must surface as an error, not as an invented
|
||||
/// success. A response resolved from the cache carries `extra['fetchedAt']`
|
||||
/// (a [DateTime]); a live response carries no such key. That is the one flag
|
||||
/// a screen needs to decide whether to show a [StaleBanner].
|
||||
class CacheInterceptor extends Interceptor {
|
||||
CacheInterceptor(this._db);
|
||||
|
||||
final ResponseCacheDatabase _db;
|
||||
|
||||
/// Failure shapes that mean "no network", as opposed to a real server error
|
||||
/// (4xx/5xx) or a bug in this client — those must keep failing loudly.
|
||||
static const _offlineTypes = {
|
||||
DioExceptionType.connectionError,
|
||||
DioExceptionType.connectionTimeout,
|
||||
DioExceptionType.receiveTimeout,
|
||||
DioExceptionType.sendTimeout,
|
||||
};
|
||||
|
||||
@override
|
||||
void onResponse(Response<dynamic> response, ResponseInterceptorHandler handler) {
|
||||
if (response.requestOptions.method == 'GET' && response.statusCode == 200) {
|
||||
// Never block the UI on the write: the caller already has its data.
|
||||
unawaited(_db.put(requestKey(response.requestOptions), response.data));
|
||||
}
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
if (err.requestOptions.method != 'GET' || !_offlineTypes.contains(err.type)) {
|
||||
return handler.next(err);
|
||||
}
|
||||
final cached = await _db.get(requestKey(err.requestOptions));
|
||||
if (cached == null) return handler.next(err);
|
||||
handler.resolve(
|
||||
Response<dynamic>(
|
||||
requestOptions: err.requestOptions,
|
||||
data: cached.body,
|
||||
statusCode: 200,
|
||||
extra: {'fetchedAt': cached.fetchedAt},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// `path?sorted=query` — deliberately excludes the host, so switching the API
|
||||
/// origin (dev vs. prod) does not orphan a device's cache.
|
||||
static String requestKey(RequestOptions options) {
|
||||
final params = options.queryParameters.entries.toList()..sort((a, b) => a.key.compareTo(b.key));
|
||||
final query = params.map((e) => '${e.key}=${e.value}').join('&');
|
||||
return '${options.path}?$query';
|
||||
}
|
||||
}
|
||||
Vendored
+31
@@ -0,0 +1,31 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// A value from the API, or the last one [CacheInterceptor] had on file for the
|
||||
/// same endpoint+params when the live request failed. `fetchedAt` is null for
|
||||
/// a live answer — that is the one bit a screen needs to decide whether to
|
||||
/// show a `StaleBanner`. See `docs/ai/offline-cache.md`.
|
||||
class Cached<T> {
|
||||
const Cached(this.data, {this.fetchedAt});
|
||||
|
||||
final T data;
|
||||
final DateTime? fetchedAt;
|
||||
}
|
||||
|
||||
extension CachedResponseX<T> on Response<T> {
|
||||
/// Wraps this response's data with the staleness [CacheInterceptor] recorded
|
||||
/// in `extra['fetchedAt']`. A provider that wants offline support returns
|
||||
/// `r.cached` instead of `r.data!`.
|
||||
Cached<T> get cached => Cached(data as T, fetchedAt: extra['fetchedAt'] as DateTime?);
|
||||
}
|
||||
|
||||
/// The oldest of several fetch times, or null if none of them is stale.
|
||||
/// Screens with several cached providers show one banner for the lot rather
|
||||
/// than one per tile.
|
||||
DateTime? oldestFetch(Iterable<DateTime?> fetchedAt) {
|
||||
DateTime? oldest;
|
||||
for (final at in fetchedAt) {
|
||||
if (at == null) continue;
|
||||
if (oldest == null || at.isBefore(oldest)) oldest = at;
|
||||
}
|
||||
return oldest;
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift_flutter/drift_flutter.dart';
|
||||
|
||||
part 'response_cache.g.dart';
|
||||
|
||||
/// The last successful body of one GET, keyed by endpoint+params exactly as
|
||||
/// [CacheInterceptor] builds the key. See `docs/ai/offline-cache.md` for the
|
||||
/// whole contract this table is part of.
|
||||
class CachedResponses extends Table {
|
||||
TextColumn get requestKey => text()();
|
||||
TextColumn get body => text()();
|
||||
DateTimeColumn get fetchedAt => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {requestKey};
|
||||
}
|
||||
|
||||
/// A cached body plus when it was fetched — what [CacheInterceptor] needs to
|
||||
/// rebuild a synthetic [Response] when the network itself is unreachable.
|
||||
///
|
||||
/// Not named `CachedResponse`: drift already generates a row class with that
|
||||
/// name for the `CachedResponses` table above.
|
||||
class CacheEntry {
|
||||
const CacheEntry(this.body, this.fetchedAt);
|
||||
final Object? body;
|
||||
final DateTime fetchedAt;
|
||||
}
|
||||
|
||||
@DriftDatabase(tables: [CachedResponses])
|
||||
class ResponseCacheDatabase extends _$ResponseCacheDatabase {
|
||||
ResponseCacheDatabase() : super(_openConnection());
|
||||
ResponseCacheDatabase.forTesting(super.executor);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
|
||||
/// Replaces whatever was cached for [requestKey] — one row per endpoint+params,
|
||||
/// never a history.
|
||||
Future<void> put(String requestKey, Object? body) => into(cachedResponses).insertOnConflictUpdate(
|
||||
CachedResponsesCompanion.insert(
|
||||
requestKey: requestKey,
|
||||
body: jsonEncode(body),
|
||||
fetchedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
/// The body last stored for [requestKey], or null if this endpoint+params was
|
||||
/// never fetched successfully on this device.
|
||||
Future<CacheEntry?> get(String requestKey) async {
|
||||
final row = await (select(
|
||||
cachedResponses,
|
||||
)..where((t) => t.requestKey.equals(requestKey))).getSingleOrNull();
|
||||
if (row == null) return null;
|
||||
return CacheEntry(jsonDecode(row.body), row.fetchedAt);
|
||||
}
|
||||
}
|
||||
|
||||
/// One sqlite file via native FFI on Android/iOS/desktop; wasm+OPFS (falling
|
||||
/// back to IndexedDB) on web — `driftDatabase` from `package:drift_flutter`
|
||||
/// picks the right backend, `web/sqlite3.wasm` and `web/drift_worker.js` are
|
||||
/// the compiled assets it loads there (regenerate with `just app-web-assets`
|
||||
/// whenever the `drift`/`sqlite3` package versions change).
|
||||
QueryExecutor _openConnection() => driftDatabase(
|
||||
name: 'response_cache',
|
||||
web: DriftWebOptions(
|
||||
sqlite3Wasm: Uri.parse('sqlite3.wasm'),
|
||||
driftWorker: Uri.parse('drift_worker.js'),
|
||||
),
|
||||
);
|
||||
+478
@@ -0,0 +1,478 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'response_cache.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
class $CachedResponsesTable extends CachedResponses
|
||||
with TableInfo<$CachedResponsesTable, CachedResponse> {
|
||||
@override
|
||||
final GeneratedDatabase attachedDatabase;
|
||||
final String? _alias;
|
||||
$CachedResponsesTable(this.attachedDatabase, [this._alias]);
|
||||
static const VerificationMeta _requestKeyMeta = const VerificationMeta(
|
||||
'requestKey',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> requestKey = GeneratedColumn<String>(
|
||||
'request_key',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _bodyMeta = const VerificationMeta('body');
|
||||
@override
|
||||
late final GeneratedColumn<String> body = GeneratedColumn<String>(
|
||||
'body',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _fetchedAtMeta = const VerificationMeta(
|
||||
'fetchedAt',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<DateTime> fetchedAt = GeneratedColumn<DateTime>(
|
||||
'fetched_at',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.dateTime,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
@override
|
||||
List<GeneratedColumn> get $columns => [requestKey, body, fetchedAt];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@override
|
||||
String get actualTableName => $name;
|
||||
static const String $name = 'cached_responses';
|
||||
@override
|
||||
VerificationContext validateIntegrity(
|
||||
Insertable<CachedResponse> instance, {
|
||||
bool isInserting = false,
|
||||
}) {
|
||||
final context = VerificationContext();
|
||||
final data = instance.toColumns(true);
|
||||
if (data.containsKey('request_key')) {
|
||||
context.handle(
|
||||
_requestKeyMeta,
|
||||
requestKey.isAcceptableOrUnknown(data['request_key']!, _requestKeyMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_requestKeyMeta);
|
||||
}
|
||||
if (data.containsKey('body')) {
|
||||
context.handle(
|
||||
_bodyMeta,
|
||||
body.isAcceptableOrUnknown(data['body']!, _bodyMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_bodyMeta);
|
||||
}
|
||||
if (data.containsKey('fetched_at')) {
|
||||
context.handle(
|
||||
_fetchedAtMeta,
|
||||
fetchedAt.isAcceptableOrUnknown(data['fetched_at']!, _fetchedAtMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_fetchedAtMeta);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@override
|
||||
Set<GeneratedColumn> get $primaryKey => {requestKey};
|
||||
@override
|
||||
CachedResponse map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
||||
return CachedResponse(
|
||||
requestKey: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}request_key'],
|
||||
)!,
|
||||
body: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}body'],
|
||||
)!,
|
||||
fetchedAt: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.dateTime,
|
||||
data['${effectivePrefix}fetched_at'],
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
$CachedResponsesTable createAlias(String alias) {
|
||||
return $CachedResponsesTable(attachedDatabase, alias);
|
||||
}
|
||||
}
|
||||
|
||||
class CachedResponse extends DataClass implements Insertable<CachedResponse> {
|
||||
final String requestKey;
|
||||
final String body;
|
||||
final DateTime fetchedAt;
|
||||
const CachedResponse({
|
||||
required this.requestKey,
|
||||
required this.body,
|
||||
required this.fetchedAt,
|
||||
});
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
map['request_key'] = Variable<String>(requestKey);
|
||||
map['body'] = Variable<String>(body);
|
||||
map['fetched_at'] = Variable<DateTime>(fetchedAt);
|
||||
return map;
|
||||
}
|
||||
|
||||
CachedResponsesCompanion toCompanion(bool nullToAbsent) {
|
||||
return CachedResponsesCompanion(
|
||||
requestKey: Value(requestKey),
|
||||
body: Value(body),
|
||||
fetchedAt: Value(fetchedAt),
|
||||
);
|
||||
}
|
||||
|
||||
factory CachedResponse.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
ValueSerializer? serializer,
|
||||
}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return CachedResponse(
|
||||
requestKey: serializer.fromJson<String>(json['requestKey']),
|
||||
body: serializer.fromJson<String>(json['body']),
|
||||
fetchedAt: serializer.fromJson<DateTime>(json['fetchedAt']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
|
||||
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||
return <String, dynamic>{
|
||||
'requestKey': serializer.toJson<String>(requestKey),
|
||||
'body': serializer.toJson<String>(body),
|
||||
'fetchedAt': serializer.toJson<DateTime>(fetchedAt),
|
||||
};
|
||||
}
|
||||
|
||||
CachedResponse copyWith({
|
||||
String? requestKey,
|
||||
String? body,
|
||||
DateTime? fetchedAt,
|
||||
}) => CachedResponse(
|
||||
requestKey: requestKey ?? this.requestKey,
|
||||
body: body ?? this.body,
|
||||
fetchedAt: fetchedAt ?? this.fetchedAt,
|
||||
);
|
||||
CachedResponse copyWithCompanion(CachedResponsesCompanion data) {
|
||||
return CachedResponse(
|
||||
requestKey: data.requestKey.present
|
||||
? data.requestKey.value
|
||||
: this.requestKey,
|
||||
body: data.body.present ? data.body.value : this.body,
|
||||
fetchedAt: data.fetchedAt.present ? data.fetchedAt.value : this.fetchedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('CachedResponse(')
|
||||
..write('requestKey: $requestKey, ')
|
||||
..write('body: $body, ')
|
||||
..write('fetchedAt: $fetchedAt')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(requestKey, body, fetchedAt);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is CachedResponse &&
|
||||
other.requestKey == this.requestKey &&
|
||||
other.body == this.body &&
|
||||
other.fetchedAt == this.fetchedAt);
|
||||
}
|
||||
|
||||
class CachedResponsesCompanion extends UpdateCompanion<CachedResponse> {
|
||||
final Value<String> requestKey;
|
||||
final Value<String> body;
|
||||
final Value<DateTime> fetchedAt;
|
||||
final Value<int> rowid;
|
||||
const CachedResponsesCompanion({
|
||||
this.requestKey = const Value.absent(),
|
||||
this.body = const Value.absent(),
|
||||
this.fetchedAt = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
});
|
||||
CachedResponsesCompanion.insert({
|
||||
required String requestKey,
|
||||
required String body,
|
||||
required DateTime fetchedAt,
|
||||
this.rowid = const Value.absent(),
|
||||
}) : requestKey = Value(requestKey),
|
||||
body = Value(body),
|
||||
fetchedAt = Value(fetchedAt);
|
||||
static Insertable<CachedResponse> custom({
|
||||
Expression<String>? requestKey,
|
||||
Expression<String>? body,
|
||||
Expression<DateTime>? fetchedAt,
|
||||
Expression<int>? rowid,
|
||||
}) {
|
||||
return RawValuesInsertable({
|
||||
if (requestKey != null) 'request_key': requestKey,
|
||||
if (body != null) 'body': body,
|
||||
if (fetchedAt != null) 'fetched_at': fetchedAt,
|
||||
if (rowid != null) 'rowid': rowid,
|
||||
});
|
||||
}
|
||||
|
||||
CachedResponsesCompanion copyWith({
|
||||
Value<String>? requestKey,
|
||||
Value<String>? body,
|
||||
Value<DateTime>? fetchedAt,
|
||||
Value<int>? rowid,
|
||||
}) {
|
||||
return CachedResponsesCompanion(
|
||||
requestKey: requestKey ?? this.requestKey,
|
||||
body: body ?? this.body,
|
||||
fetchedAt: fetchedAt ?? this.fetchedAt,
|
||||
rowid: rowid ?? this.rowid,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
final map = <String, Expression>{};
|
||||
if (requestKey.present) {
|
||||
map['request_key'] = Variable<String>(requestKey.value);
|
||||
}
|
||||
if (body.present) {
|
||||
map['body'] = Variable<String>(body.value);
|
||||
}
|
||||
if (fetchedAt.present) {
|
||||
map['fetched_at'] = Variable<DateTime>(fetchedAt.value);
|
||||
}
|
||||
if (rowid.present) {
|
||||
map['rowid'] = Variable<int>(rowid.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return (StringBuffer('CachedResponsesCompanion(')
|
||||
..write('requestKey: $requestKey, ')
|
||||
..write('body: $body, ')
|
||||
..write('fetchedAt: $fetchedAt, ')
|
||||
..write('rowid: $rowid')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _$ResponseCacheDatabase extends GeneratedDatabase {
|
||||
_$ResponseCacheDatabase(QueryExecutor e) : super(e);
|
||||
$ResponseCacheDatabaseManager get managers =>
|
||||
$ResponseCacheDatabaseManager(this);
|
||||
late final $CachedResponsesTable cachedResponses = $CachedResponsesTable(
|
||||
this,
|
||||
);
|
||||
@override
|
||||
Iterable<TableInfo<Table, Object?>> get allTables =>
|
||||
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
||||
@override
|
||||
List<DatabaseSchemaEntity> get allSchemaEntities => [cachedResponses];
|
||||
}
|
||||
|
||||
typedef $$CachedResponsesTableCreateCompanionBuilder =
|
||||
CachedResponsesCompanion Function({
|
||||
required String requestKey,
|
||||
required String body,
|
||||
required DateTime fetchedAt,
|
||||
Value<int> rowid,
|
||||
});
|
||||
typedef $$CachedResponsesTableUpdateCompanionBuilder =
|
||||
CachedResponsesCompanion Function({
|
||||
Value<String> requestKey,
|
||||
Value<String> body,
|
||||
Value<DateTime> fetchedAt,
|
||||
Value<int> rowid,
|
||||
});
|
||||
|
||||
class $$CachedResponsesTableFilterComposer
|
||||
extends Composer<_$ResponseCacheDatabase, $CachedResponsesTable> {
|
||||
$$CachedResponsesTableFilterComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
ColumnFilters<String> get requestKey => $composableBuilder(
|
||||
column: $table.requestKey,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get body => $composableBuilder(
|
||||
column: $table.body,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<DateTime> get fetchedAt => $composableBuilder(
|
||||
column: $table.fetchedAt,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$CachedResponsesTableOrderingComposer
|
||||
extends Composer<_$ResponseCacheDatabase, $CachedResponsesTable> {
|
||||
$$CachedResponsesTableOrderingComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
ColumnOrderings<String> get requestKey => $composableBuilder(
|
||||
column: $table.requestKey,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get body => $composableBuilder(
|
||||
column: $table.body,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<DateTime> get fetchedAt => $composableBuilder(
|
||||
column: $table.fetchedAt,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$CachedResponsesTableAnnotationComposer
|
||||
extends Composer<_$ResponseCacheDatabase, $CachedResponsesTable> {
|
||||
$$CachedResponsesTableAnnotationComposer({
|
||||
required super.$db,
|
||||
required super.$table,
|
||||
super.joinBuilder,
|
||||
super.$addJoinBuilderToRootComposer,
|
||||
super.$removeJoinBuilderFromRootComposer,
|
||||
});
|
||||
GeneratedColumn<String> get requestKey => $composableBuilder(
|
||||
column: $table.requestKey,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<String> get body =>
|
||||
$composableBuilder(column: $table.body, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<DateTime> get fetchedAt =>
|
||||
$composableBuilder(column: $table.fetchedAt, builder: (column) => column);
|
||||
}
|
||||
|
||||
class $$CachedResponsesTableTableManager
|
||||
extends
|
||||
RootTableManager<
|
||||
_$ResponseCacheDatabase,
|
||||
$CachedResponsesTable,
|
||||
CachedResponse,
|
||||
$$CachedResponsesTableFilterComposer,
|
||||
$$CachedResponsesTableOrderingComposer,
|
||||
$$CachedResponsesTableAnnotationComposer,
|
||||
$$CachedResponsesTableCreateCompanionBuilder,
|
||||
$$CachedResponsesTableUpdateCompanionBuilder,
|
||||
(
|
||||
CachedResponse,
|
||||
BaseReferences<
|
||||
_$ResponseCacheDatabase,
|
||||
$CachedResponsesTable,
|
||||
CachedResponse
|
||||
>,
|
||||
),
|
||||
CachedResponse,
|
||||
PrefetchHooks Function()
|
||||
> {
|
||||
$$CachedResponsesTableTableManager(
|
||||
_$ResponseCacheDatabase db,
|
||||
$CachedResponsesTable table,
|
||||
) : super(
|
||||
TableManagerState(
|
||||
db: db,
|
||||
table: table,
|
||||
createFilteringComposer: () =>
|
||||
$$CachedResponsesTableFilterComposer($db: db, $table: table),
|
||||
createOrderingComposer: () =>
|
||||
$$CachedResponsesTableOrderingComposer($db: db, $table: table),
|
||||
createComputedFieldComposer: () =>
|
||||
$$CachedResponsesTableAnnotationComposer($db: db, $table: table),
|
||||
updateCompanionCallback:
|
||||
({
|
||||
Value<String> requestKey = const Value.absent(),
|
||||
Value<String> body = const Value.absent(),
|
||||
Value<DateTime> fetchedAt = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) => CachedResponsesCompanion(
|
||||
requestKey: requestKey,
|
||||
body: body,
|
||||
fetchedAt: fetchedAt,
|
||||
rowid: rowid,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
required String requestKey,
|
||||
required String body,
|
||||
required DateTime fetchedAt,
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) => CachedResponsesCompanion.insert(
|
||||
requestKey: requestKey,
|
||||
body: body,
|
||||
fetchedAt: fetchedAt,
|
||||
rowid: rowid,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map(
|
||||
(e) => (
|
||||
e.readTable<$CachedResponsesTable, CachedResponse>(table),
|
||||
BaseReferences<
|
||||
_$ResponseCacheDatabase,
|
||||
$CachedResponsesTable,
|
||||
CachedResponse
|
||||
>(db, table, e),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
prefetchHooksCallback: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
typedef $$CachedResponsesTableProcessedTableManager =
|
||||
ProcessedTableManager<
|
||||
_$ResponseCacheDatabase,
|
||||
$CachedResponsesTable,
|
||||
CachedResponse,
|
||||
$$CachedResponsesTableFilterComposer,
|
||||
$$CachedResponsesTableOrderingComposer,
|
||||
$$CachedResponsesTableAnnotationComposer,
|
||||
$$CachedResponsesTableCreateCompanionBuilder,
|
||||
$$CachedResponsesTableUpdateCompanionBuilder,
|
||||
(
|
||||
CachedResponse,
|
||||
BaseReferences<
|
||||
_$ResponseCacheDatabase,
|
||||
$CachedResponsesTable,
|
||||
CachedResponse
|
||||
>,
|
||||
),
|
||||
CachedResponse,
|
||||
PrefetchHooks Function()
|
||||
>;
|
||||
|
||||
class $ResponseCacheDatabaseManager {
|
||||
final _$ResponseCacheDatabase _db;
|
||||
$ResponseCacheDatabaseManager(this._db);
|
||||
$$CachedResponsesTableTableManager get cachedResponses =>
|
||||
$$CachedResponsesTableTableManager(_db, _db.cachedResponses);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../utils/ru_date.dart';
|
||||
|
||||
/// "Нет соединения — данные на …": shown instead of pretending a screen built
|
||||
/// from the offline cache is current. Pass the oldest `fetchedAt` among the
|
||||
/// providers a screen watches (`core/cache/cached.dart`'s `oldestFetch`).
|
||||
class StaleBanner extends StatelessWidget {
|
||||
const StaleBanner({required this.fetchedAt, super.key});
|
||||
|
||||
final DateTime fetchedAt;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final at = fetchedAt.toLocal();
|
||||
final time = '${at.hour.toString().padLeft(2, '0')}:${at.minute.toString().padLeft(2, '0')}';
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(color: scheme.errorContainer, borderRadius: BorderRadius.circular(8)),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.cloud_off, size: 18, color: scheme.onErrorContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Нет соединения — данные на ${ruDate(at)} $time',
|
||||
style: TextStyle(color: scheme.onErrorContainer),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/auth/auth_controller.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 '../../core/api/api_client.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
@@ -109,6 +111,17 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
final dataQuality = ref.watch(dataQualityProvider);
|
||||
final portfolio = ref.watch(portfolioSummaryHomeProvider);
|
||||
|
||||
final stale = oldestFetch([
|
||||
breakdown.valueOrNull?.fetchedAt,
|
||||
series.valueOrNull?.fetchedAt,
|
||||
thisMonth.valueOrNull?.fetchedAt,
|
||||
last12.valueOrNull?.fetchedAt,
|
||||
runway.valueOrNull?.fetchedAt,
|
||||
status.valueOrNull?.fetchedAt,
|
||||
dataQuality.valueOrNull?.fetchedAt,
|
||||
portfolio.valueOrNull?.fetchedAt,
|
||||
]);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Обзор'),
|
||||
@@ -128,13 +141,15 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (stale != null) StaleBanner(fetchedAt: stale),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AsyncValueView(
|
||||
value: status,
|
||||
onRetry: () => ref.invalidate(metricsStatusProvider),
|
||||
data: (log) {
|
||||
data: (cached) {
|
||||
final log = cached.data;
|
||||
final at = log?.finishedAt ?? log?.startedAt;
|
||||
final text = at == null
|
||||
? 'Данные ещё не пересчитывались'
|
||||
@@ -145,15 +160,18 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
),
|
||||
AsyncValueView(
|
||||
value: dataQuality,
|
||||
data: (rows) => ActionChip(
|
||||
avatar: Icon(
|
||||
rows.isEmpty ? Icons.check_circle_outline : Icons.warning_amber_outlined,
|
||||
size: 18,
|
||||
color: rows.isEmpty ? Colors.green : _severityColor(rows.first.severity),
|
||||
),
|
||||
label: Text(rows.isEmpty ? 'ок' : '${rows.length} замечаний'),
|
||||
onPressed: rows.isEmpty ? null : () => _showDataQuality(rows),
|
||||
),
|
||||
data: (cached) {
|
||||
final rows = cached.data;
|
||||
return ActionChip(
|
||||
avatar: Icon(
|
||||
rows.isEmpty ? Icons.check_circle_outline : Icons.warning_amber_outlined,
|
||||
size: 18,
|
||||
color: rows.isEmpty ? Colors.green : _severityColor(rows.first.severity),
|
||||
),
|
||||
label: Text(rows.isEmpty ? 'ок' : '${rows.length} замечаний'),
|
||||
onPressed: rows.isEmpty ? null : () => _showDataQuality(rows),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -161,7 +179,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
AsyncValueView(
|
||||
value: breakdown,
|
||||
onRetry: () => ref.invalidate(netWorthBreakdownProvider),
|
||||
data: (b) => _NetWorthTiles(breakdown: b),
|
||||
data: (cached) => _NetWorthTiles(breakdown: cached.data),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
@@ -170,7 +188,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
child: AsyncValueView(
|
||||
value: thisMonth,
|
||||
onRetry: () => ref.invalidate(cashflowThisMonthProvider),
|
||||
data: (m) => _MonthTiles(month: m),
|
||||
data: (cached) => _MonthTiles(month: cached.data),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -179,13 +197,14 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
AsyncValueView(
|
||||
value: portfolio,
|
||||
onRetry: () => ref.invalidate(portfolioSummaryHomeProvider),
|
||||
data: (s) => s == null ? const SizedBox.shrink() : _PortfolioTiles(summary: s),
|
||||
data: (cached) =>
|
||||
cached.data == null ? const SizedBox.shrink() : _PortfolioTiles(summary: cached.data!),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AsyncValueView(
|
||||
value: runway,
|
||||
onRetry: () => ref.invalidate(runwayProvider),
|
||||
data: (r) => _RunwayTile(runway: r),
|
||||
data: (cached) => _RunwayTile(runway: cached.data),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
LayoutBuilder(
|
||||
@@ -196,9 +215,9 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
child: AsyncValueView(
|
||||
value: series,
|
||||
onRetry: () => ref.invalidate(netWorthSeriesProvider),
|
||||
data: (rows) => rows.isEmpty
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.')
|
||||
: _NetWorthChart(rows: rows),
|
||||
: _NetWorthChart(rows: cached.data),
|
||||
),
|
||||
);
|
||||
final cashflowChart = _ChartCard(
|
||||
@@ -206,9 +225,9 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
child: AsyncValueView(
|
||||
value: last12,
|
||||
onRetry: () => ref.invalidate(cashflowLast12Provider),
|
||||
data: (rows) => rows.isEmpty
|
||||
data: (cached) => cached.data.isEmpty
|
||||
? const EmptyState(icon: Icons.bar_chart, message: 'Пока нет данных.')
|
||||
: _IncomeExpenseChart(rows: rows),
|
||||
: _IncomeExpenseChart(rows: cached.data),
|
||||
),
|
||||
);
|
||||
if (wide) {
|
||||
|
||||
@@ -3,54 +3,58 @@ 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 netWorthBreakdownProvider = FutureProvider.autoDispose<NetWorthBreakdown>((ref) async {
|
||||
/// 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.data!;
|
||||
return r.cached;
|
||||
});
|
||||
|
||||
/// Daily net worth for the last 365 days.
|
||||
final netWorthSeriesProvider = FutureProvider.autoDispose<List<NetWorthDay>>((ref) async {
|
||||
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 r.data ?? const [];
|
||||
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<CashFlowMonth?>((ref) async {
|
||||
final cashflowThisMonthProvider = FutureProvider.autoDispose<Cached<CashFlowMonth?>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 1);
|
||||
final rows = r.data ?? const [];
|
||||
return rows.isEmpty ? null : rows.last;
|
||||
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<List<CashFlowMonth>>((ref) async {
|
||||
final cashflowLast12Provider = FutureProvider.autoDispose<Cached<List<CashFlowMonth>>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 12);
|
||||
return r.data ?? const [];
|
||||
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?);
|
||||
});
|
||||
|
||||
final runwayProvider = FutureProvider.autoDispose<RunwayOut>((ref) async {
|
||||
final runwayProvider = FutureProvider.autoDispose<Cached<RunwayOut>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getCashflowApi().cashflowRunway();
|
||||
return r.data!;
|
||||
return r.cached;
|
||||
});
|
||||
|
||||
/// When the metric tables were last rebuilt; null before the first refresh.
|
||||
final metricsStatusProvider = FutureProvider.autoDispose<RefreshLogOut?>((ref) async {
|
||||
final metricsStatusProvider = FutureProvider.autoDispose<Cached<RefreshLogOut?>>((ref) async {
|
||||
try {
|
||||
final r = await ref.watch(apiProvider).getMetricsApi().metricsStatus();
|
||||
return r.data;
|
||||
return r.cached;
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) return null;
|
||||
if (e.response?.statusCode == 404) return const Cached(null);
|
||||
rethrow;
|
||||
}
|
||||
});
|
||||
|
||||
final dataQualityProvider = FutureProvider.autoDispose<List<DataQualityRow>>((ref) async {
|
||||
final dataQualityProvider = FutureProvider.autoDispose<Cached<List<DataQualityRow>>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getMetricsApi().metricsDataQuality();
|
||||
return r.data ?? const [];
|
||||
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?);
|
||||
});
|
||||
|
||||
/// Every dashboard provider, refreshed together after a manual
|
||||
@@ -68,12 +72,12 @@ void invalidateHomeProviders(WidgetRef ref) {
|
||||
|
||||
/// 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<SummaryOut?>((ref) async {
|
||||
final portfolioSummaryHomeProvider = FutureProvider.autoDispose<Cached<SummaryOut?>>((ref) async {
|
||||
try {
|
||||
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsSummary();
|
||||
return r.data;
|
||||
return r.cached;
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) return null;
|
||||
if (e.response?.statusCode == 404) return const Cached(null);
|
||||
rethrow;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user