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:
@@ -36,6 +36,7 @@ just revision "msg" # новая alembic-миграция из изме
|
|||||||
| Модель данных / миграции (`models/`, `alembic/`) | architecture.md §Домен, [conventions.md](docs/ai/conventions.md) |
|
| Модель данных / миграции (`models/`, `alembic/`) | architecture.md §Домен, [conventions.md](docs/ai/conventions.md) |
|
||||||
| Аналитика / метрики | architecture.md §Аналитика, [conventions.md](docs/ai/conventions.md) §Деньги |
|
| Аналитика / метрики | architecture.md §Аналитика, [conventions.md](docs/ai/conventions.md) §Деньги |
|
||||||
| API / Flutter-клиент | architecture.md §API, `openapi/openapi.json` |
|
| API / Flutter-клиент | architecture.md §API, `openapi/openapi.json` |
|
||||||
|
| Offline-кэш экрана (фаза 5) | [offline-cache.md](docs/ai/offline-cache.md) — контракт `Cached<T>` + `CacheInterceptor` |
|
||||||
| Деплой | `docker-compose.yml`, `deploy/Caddyfile`, [ops.md](docs/ai/ops.md) |
|
| Деплой | `docker-compose.yml`, `deploy/Caddyfile`, [ops.md](docs/ai/ops.md) |
|
||||||
|
|
||||||
## Обязательные ограничения
|
## Обязательные ограничения
|
||||||
|
|||||||
@@ -3,9 +3,22 @@ import 'package:fintracker_api/fintracker_api.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../auth/auth_controller.dart';
|
import '../auth/auth_controller.dart';
|
||||||
|
import '../cache/cache_interceptor.dart';
|
||||||
|
import '../cache/response_cache.dart';
|
||||||
import '../config.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 apiProvider = Provider<FintrackerApi>((ref) {
|
||||||
final dio = Dio(BaseOptions(
|
final dio = Dio(BaseOptions(
|
||||||
baseUrl: apiBaseUrl(),
|
baseUrl: apiBaseUrl(),
|
||||||
@@ -14,6 +27,7 @@ final apiProvider = Provider<FintrackerApi>((ref) {
|
|||||||
));
|
));
|
||||||
dio.interceptors.add(DateQueryInterceptor());
|
dio.interceptors.add(DateQueryInterceptor());
|
||||||
dio.interceptors.add(_AuthInterceptor(ref));
|
dio.interceptors.add(_AuthInterceptor(ref));
|
||||||
|
dio.interceptors.add(CacheInterceptor(ref.watch(responseCacheDbProvider)));
|
||||||
return FintrackerApi(dio: dio, interceptors: const []);
|
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 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../core/auth/auth_controller.dart';
|
import '../../core/auth/auth_controller.dart';
|
||||||
|
import '../../core/cache/cached.dart';
|
||||||
import '../../core/theme/chart_colors.dart';
|
import '../../core/theme/chart_colors.dart';
|
||||||
import '../../core/utils/ru_date.dart';
|
import '../../core/utils/ru_date.dart';
|
||||||
import '../../core/widgets/async_value_view.dart';
|
import '../../core/widgets/async_value_view.dart';
|
||||||
import '../../core/widgets/empty_state.dart';
|
import '../../core/widgets/empty_state.dart';
|
||||||
import '../../core/widgets/money_text.dart';
|
import '../../core/widgets/money_text.dart';
|
||||||
|
import '../../core/widgets/stale_banner.dart';
|
||||||
import '../../core/api/api_client.dart';
|
import '../../core/api/api_client.dart';
|
||||||
import 'providers.dart';
|
import 'providers.dart';
|
||||||
|
|
||||||
@@ -109,6 +111,17 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
final dataQuality = ref.watch(dataQualityProvider);
|
final dataQuality = ref.watch(dataQualityProvider);
|
||||||
final portfolio = ref.watch(portfolioSummaryHomeProvider);
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Обзор'),
|
title: const Text('Обзор'),
|
||||||
@@ -128,13 +141,15 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
|
if (stale != null) StaleBanner(fetchedAt: stale),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AsyncValueView(
|
child: AsyncValueView(
|
||||||
value: status,
|
value: status,
|
||||||
onRetry: () => ref.invalidate(metricsStatusProvider),
|
onRetry: () => ref.invalidate(metricsStatusProvider),
|
||||||
data: (log) {
|
data: (cached) {
|
||||||
|
final log = cached.data;
|
||||||
final at = log?.finishedAt ?? log?.startedAt;
|
final at = log?.finishedAt ?? log?.startedAt;
|
||||||
final text = at == null
|
final text = at == null
|
||||||
? 'Данные ещё не пересчитывались'
|
? 'Данные ещё не пересчитывались'
|
||||||
@@ -145,7 +160,9 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
),
|
),
|
||||||
AsyncValueView(
|
AsyncValueView(
|
||||||
value: dataQuality,
|
value: dataQuality,
|
||||||
data: (rows) => ActionChip(
|
data: (cached) {
|
||||||
|
final rows = cached.data;
|
||||||
|
return ActionChip(
|
||||||
avatar: Icon(
|
avatar: Icon(
|
||||||
rows.isEmpty ? Icons.check_circle_outline : Icons.warning_amber_outlined,
|
rows.isEmpty ? Icons.check_circle_outline : Icons.warning_amber_outlined,
|
||||||
size: 18,
|
size: 18,
|
||||||
@@ -153,7 +170,8 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
),
|
),
|
||||||
label: Text(rows.isEmpty ? 'ок' : '${rows.length} замечаний'),
|
label: Text(rows.isEmpty ? 'ок' : '${rows.length} замечаний'),
|
||||||
onPressed: rows.isEmpty ? null : () => _showDataQuality(rows),
|
onPressed: rows.isEmpty ? null : () => _showDataQuality(rows),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -161,7 +179,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
AsyncValueView(
|
AsyncValueView(
|
||||||
value: breakdown,
|
value: breakdown,
|
||||||
onRetry: () => ref.invalidate(netWorthBreakdownProvider),
|
onRetry: () => ref.invalidate(netWorthBreakdownProvider),
|
||||||
data: (b) => _NetWorthTiles(breakdown: b),
|
data: (cached) => _NetWorthTiles(breakdown: cached.data),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
@@ -170,7 +188,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
child: AsyncValueView(
|
child: AsyncValueView(
|
||||||
value: thisMonth,
|
value: thisMonth,
|
||||||
onRetry: () => ref.invalidate(cashflowThisMonthProvider),
|
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(
|
AsyncValueView(
|
||||||
value: portfolio,
|
value: portfolio,
|
||||||
onRetry: () => ref.invalidate(portfolioSummaryHomeProvider),
|
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),
|
const SizedBox(height: 12),
|
||||||
AsyncValueView(
|
AsyncValueView(
|
||||||
value: runway,
|
value: runway,
|
||||||
onRetry: () => ref.invalidate(runwayProvider),
|
onRetry: () => ref.invalidate(runwayProvider),
|
||||||
data: (r) => _RunwayTile(runway: r),
|
data: (cached) => _RunwayTile(runway: cached.data),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
LayoutBuilder(
|
LayoutBuilder(
|
||||||
@@ -196,9 +215,9 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
child: AsyncValueView(
|
child: AsyncValueView(
|
||||||
value: series,
|
value: series,
|
||||||
onRetry: () => ref.invalidate(netWorthSeriesProvider),
|
onRetry: () => ref.invalidate(netWorthSeriesProvider),
|
||||||
data: (rows) => rows.isEmpty
|
data: (cached) => cached.data.isEmpty
|
||||||
? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.')
|
? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.')
|
||||||
: _NetWorthChart(rows: rows),
|
: _NetWorthChart(rows: cached.data),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final cashflowChart = _ChartCard(
|
final cashflowChart = _ChartCard(
|
||||||
@@ -206,9 +225,9 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
child: AsyncValueView(
|
child: AsyncValueView(
|
||||||
value: last12,
|
value: last12,
|
||||||
onRetry: () => ref.invalidate(cashflowLast12Provider),
|
onRetry: () => ref.invalidate(cashflowLast12Provider),
|
||||||
data: (rows) => rows.isEmpty
|
data: (cached) => cached.data.isEmpty
|
||||||
? const EmptyState(icon: Icons.bar_chart, message: 'Пока нет данных.')
|
? const EmptyState(icon: Icons.bar_chart, message: 'Пока нет данных.')
|
||||||
: _IncomeExpenseChart(rows: rows),
|
: _IncomeExpenseChart(rows: cached.data),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (wide) {
|
if (wide) {
|
||||||
|
|||||||
@@ -3,54 +3,58 @@ import 'package:fintracker_api/fintracker_api.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../core/api/api_client.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();
|
final r = await ref.watch(apiProvider).getNetworthApi().networthBreakdown();
|
||||||
return r.data!;
|
return r.cached;
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Daily net worth for the last 365 days.
|
/// 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 now = DateTime.now();
|
||||||
final r = await ref.watch(apiProvider).getNetworthApi().networthSeries(
|
final r = await ref.watch(apiProvider).getNetworthApi().networthSeries(
|
||||||
from: now.subtract(const Duration(days: 365)),
|
from: now.subtract(const Duration(days: 365)),
|
||||||
to: now,
|
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.
|
/// 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 r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 1);
|
||||||
final rows = r.data ?? const [];
|
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.
|
/// 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);
|
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();
|
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.
|
/// 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 {
|
try {
|
||||||
final r = await ref.watch(apiProvider).getMetricsApi().metricsStatus();
|
final r = await ref.watch(apiProvider).getMetricsApi().metricsStatus();
|
||||||
return r.data;
|
return r.cached;
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
if (e.response?.statusCode == 404) return null;
|
if (e.response?.statusCode == 404) return const Cached(null);
|
||||||
rethrow;
|
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();
|
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
|
/// 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.
|
/// 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.
|
/// 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 {
|
try {
|
||||||
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsSummary();
|
final r = await ref.watch(apiProvider).getAnalyticsApi().analyticsSummary();
|
||||||
return r.data;
|
return r.cached;
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
if (e.response?.statusCode == 404) return null;
|
if (e.response?.statusCode == 404) return const Cached(null);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+297
-1
@@ -1,6 +1,22 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
_fe_analyzer_shared:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: _fe_analyzer_shared
|
||||||
|
sha256: fdcd9f70f9eb80df3bc5ed0fa67280df2595fd481948df8a9ab082e6a40ad04b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "108.0.0"
|
||||||
|
analyzer:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: analyzer
|
||||||
|
sha256: a51c769bff3b6dfbe9d60199b8606d808290702a296bef0c26a4ca391d414e46
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "14.4.0"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -25,6 +41,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
|
build:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build
|
||||||
|
sha256: "8a5c5761af8e31748bba3c82f68925ace40f3225c3eea25be9beb57eca7cd7a8"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.11"
|
||||||
|
build_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build_config
|
||||||
|
sha256: d466ed2dc9c6cd1d169948879b84ee061eb5e22c64a7c6089879c6296d272a8d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.3"
|
||||||
|
build_daemon:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build_daemon
|
||||||
|
sha256: e1d40ef3f7934986d5da2271b1ba07794921ce263e44d622fb6c406d76589e33
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.6"
|
||||||
|
build_runner:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: build_runner
|
||||||
|
sha256: "894c243f6bc32015fec466ce30a6925bd537a77a426ee5bf481120477eb3de67"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.16.1"
|
||||||
|
built_collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: built_collection
|
||||||
|
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.1"
|
||||||
|
built_value:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: built_value
|
||||||
|
sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.13.0"
|
||||||
characters:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -33,6 +97,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
charcode:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: charcode
|
||||||
|
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
|
checked_yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: checked_yaml
|
||||||
|
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.4"
|
||||||
|
cli_util:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cli_util
|
||||||
|
sha256: "71e43f1976c3eae2d9468a5b4d6600bf8cae61508b87f27fa1799228935d48ab"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.2"
|
||||||
clock:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -57,6 +145,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
convert:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: convert
|
||||||
|
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.2"
|
||||||
copy_with_extension:
|
copy_with_extension:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -81,6 +177,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
|
dart_style:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dart_style
|
||||||
|
sha256: "82ade9fc4273f29ed673e33166944465225b4f7fc5d4aaef48605cc751c18fc1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.13"
|
||||||
dbus:
|
dbus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -113,6 +217,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.2"
|
version: "2.2.2"
|
||||||
|
drift:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: drift
|
||||||
|
sha256: e3b6352aa02e60bed6ffc3cc9894c421578dad5fbef21b1f7f57cdc0e4edabcb
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.35.0"
|
||||||
|
drift_dev:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: drift_dev
|
||||||
|
sha256: fbad5c1e3a73820cdb1f32161eb2f40bb5e5f03c0c1a3d236595725aec7efc6d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.35.0"
|
||||||
|
drift_flutter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: drift_flutter
|
||||||
|
sha256: "91acf4bee7c3c84467cba46455aa70e5292a3b889f4582645d74f2e5a8c106f2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.1"
|
||||||
equatable:
|
equatable:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -160,6 +288,14 @@ packages:
|
|||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.1.0"
|
version: "0.1.0"
|
||||||
|
fixnum:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fixnum
|
||||||
|
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
fl_chart:
|
fl_chart:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -260,6 +396,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
glob:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: glob
|
||||||
|
sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
go_router:
|
go_router:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -268,6 +412,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "14.8.1"
|
version: "14.8.1"
|
||||||
|
graphs:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: graphs
|
||||||
|
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.2"
|
||||||
hooks:
|
hooks:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -276,6 +428,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "2.0.2"
|
||||||
|
http_multi_server:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_multi_server
|
||||||
|
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.2"
|
||||||
http_parser:
|
http_parser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -292,6 +452,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.20.3"
|
version: "0.20.3"
|
||||||
|
io:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: io
|
||||||
|
sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
jni:
|
jni:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -404,6 +572,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
version: "2.1.0"
|
||||||
|
native_toolchain_c:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: native_toolchain_c
|
||||||
|
sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.19.2"
|
||||||
objective_c:
|
objective_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -429,7 +605,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
path_provider:
|
path_provider:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path_provider
|
name: path_provider
|
||||||
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||||
@@ -500,6 +676,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
pool:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pool
|
||||||
|
sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.5.3"
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -508,6 +692,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.1"
|
version: "2.2.1"
|
||||||
|
pubspec_parse:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pubspec_parse
|
||||||
|
sha256: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.0"
|
||||||
rational:
|
rational:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -516,6 +708,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.3"
|
version: "2.2.3"
|
||||||
|
recase:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: recase
|
||||||
|
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.0"
|
||||||
record_use:
|
record_use:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -588,11 +788,35 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.1"
|
||||||
|
shelf:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shelf
|
||||||
|
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.2"
|
||||||
|
shelf_web_socket:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shelf_web_socket
|
||||||
|
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
sky_engine:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
source_gen:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_gen
|
||||||
|
sha256: "11b6047da8e4eb6c643ccac3d0fda3f9c9e11651695f7da24a85ade8aff15a44"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.3.0"
|
||||||
source_span:
|
source_span:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -601,6 +825,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.2"
|
version: "1.10.2"
|
||||||
|
sqlcipher_flutter_libs:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqlcipher_flutter_libs
|
||||||
|
sha256: "38d62d659d2fb8739bf25a42c9a350d1fdd6c29a5a61f13a946778ec75d27929"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.0+eol"
|
||||||
|
sqlite3:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqlite3
|
||||||
|
sha256: "4c7fe79840389aaeaf05fd093f795b631b5a98e2bd28d54e555c100f4a9c7a1c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.5.2"
|
||||||
|
sqlite3_flutter_libs:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqlite3_flutter_libs
|
||||||
|
sha256: "3ed7553eee7bb368f8950f58ba29f634e06e813c029aff6a0d60862b96de8454"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.6.0+eol"
|
||||||
|
sqlparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqlparser
|
||||||
|
sha256: "772bb2f6f5bce0631a60f26b57d6e8882e1105c22345b05c163ee6ee5d7ba32d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.45.0"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -625,6 +881,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.1.4"
|
||||||
|
stream_transform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_transform
|
||||||
|
sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
string_scanner:
|
string_scanner:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -673,6 +937,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.3.0"
|
version: "15.3.0"
|
||||||
|
watcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: watcher
|
||||||
|
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -681,6 +953,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
web_socket:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web_socket
|
||||||
|
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
|
web_socket_channel:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web_socket_channel
|
||||||
|
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.3"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -713,6 +1001,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.4"
|
version: "3.1.4"
|
||||||
|
yaml_edit:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: yaml_edit
|
||||||
|
sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.4"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.13.3 <4.0.0"
|
dart: ">=3.13.3 <4.0.0"
|
||||||
flutter: ">=3.44.0"
|
flutter: ">=3.44.0"
|
||||||
|
|||||||
@@ -22,11 +22,16 @@ dependencies:
|
|||||||
fl_chart: ^0.69.0
|
fl_chart: ^0.69.0
|
||||||
shared_preferences: ^2.3.4
|
shared_preferences: ^2.3.4
|
||||||
file_picker: ^11.0.3
|
file_picker: ^11.0.3
|
||||||
|
drift: ^2.35.0
|
||||||
|
drift_flutter: ^0.3.1
|
||||||
|
path_provider: ^2.1.6
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
drift_dev: ^2.35.0
|
||||||
|
build_runner: ^2.16.1
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:fintracker_app/core/cache/cache_interceptor.dart';
|
||||||
|
import 'package:fintracker_app/core/cache/response_cache.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
/// `onResponse`/`onError` complete synchronously in `CacheInterceptor` — no
|
||||||
|
/// real Dio transport needed, same approach as `date_query_interceptor_test.dart`.
|
||||||
|
class _ResponseCapture extends ResponseInterceptorHandler {
|
||||||
|
Response<dynamic>? passed;
|
||||||
|
@override
|
||||||
|
void next(Response response) => passed = response;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErrorCapture extends ErrorInterceptorHandler {
|
||||||
|
Response<dynamic>? resolvedResponse;
|
||||||
|
DioException? forwarded;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void resolve(Response response) => resolvedResponse = response;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void next(DioException error) => forwarded = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
RequestOptions _request(String method, String path, [Map<String, dynamic> query = const {}]) =>
|
||||||
|
RequestOptions(path: path, method: method, queryParameters: query);
|
||||||
|
|
||||||
|
DioException _offline(RequestOptions options) =>
|
||||||
|
DioException(requestOptions: options, type: DioExceptionType.connectionError);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late ResponseCacheDatabase db;
|
||||||
|
late CacheInterceptor interceptor;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
db = ResponseCacheDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
interceptor = CacheInterceptor(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() => db.close());
|
||||||
|
|
||||||
|
test('a successful GET is written to the cache', () async {
|
||||||
|
final options = _request('GET', '/networth/breakdown');
|
||||||
|
|
||||||
|
interceptor.onResponse(
|
||||||
|
Response<dynamic>(requestOptions: options, statusCode: 200, data: {'totalRub': '100'}),
|
||||||
|
_ResponseCapture(),
|
||||||
|
);
|
||||||
|
await pumpEventQueue(); // the write is fire-and-forget
|
||||||
|
|
||||||
|
final cached = await db.get(CacheInterceptor.requestKey(options));
|
||||||
|
expect(cached?.body, {'totalRub': '100'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a connection error is answered from the cache, with fetchedAt set', () async {
|
||||||
|
final options = _request('GET', '/networth/breakdown');
|
||||||
|
await db.put(CacheInterceptor.requestKey(options), {'totalRub': '100'});
|
||||||
|
|
||||||
|
final handler = _ErrorCapture();
|
||||||
|
await interceptor.onError(_offline(options), handler);
|
||||||
|
|
||||||
|
expect(handler.resolvedResponse?.data, {'totalRub': '100'});
|
||||||
|
expect(handler.resolvedResponse?.extra['fetchedAt'], isA<DateTime>());
|
||||||
|
expect(handler.forwarded, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a connection error with nothing cached still fails', () async {
|
||||||
|
final options = _request('GET', '/networth/breakdown', {'never': 'asked'});
|
||||||
|
final failure = _offline(options);
|
||||||
|
final handler = _ErrorCapture();
|
||||||
|
|
||||||
|
await interceptor.onError(failure, handler);
|
||||||
|
|
||||||
|
expect(handler.resolvedResponse, isNull);
|
||||||
|
expect(handler.forwarded, same(failure));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a real server error is never masked by a cached answer', () async {
|
||||||
|
final options = _request('GET', '/networth/breakdown');
|
||||||
|
await db.put(CacheInterceptor.requestKey(options), {'totalRub': '100'});
|
||||||
|
|
||||||
|
final serverError = DioException(
|
||||||
|
requestOptions: options,
|
||||||
|
type: DioExceptionType.badResponse,
|
||||||
|
response: Response<dynamic>(requestOptions: options, statusCode: 500),
|
||||||
|
);
|
||||||
|
final handler = _ErrorCapture();
|
||||||
|
await interceptor.onError(serverError, handler);
|
||||||
|
|
||||||
|
expect(handler.resolvedResponse, isNull);
|
||||||
|
expect(handler.forwarded, same(serverError));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed mutation is never served from the cache', () async {
|
||||||
|
final options = _request('POST', '/links');
|
||||||
|
final failure = _offline(options);
|
||||||
|
final handler = _ErrorCapture();
|
||||||
|
|
||||||
|
await interceptor.onError(failure, handler);
|
||||||
|
|
||||||
|
expect(handler.resolvedResponse, isNull);
|
||||||
|
expect(handler.forwarded, same(failure));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('query parameters are part of the cache key', () async {
|
||||||
|
final page1 = _request('GET', '/events', {'page': 1});
|
||||||
|
final page2 = _request('GET', '/events', {'page': 2});
|
||||||
|
await db.put(CacheInterceptor.requestKey(page1), 'page1');
|
||||||
|
|
||||||
|
final handler = _ErrorCapture();
|
||||||
|
await interceptor.onError(_offline(page2), handler);
|
||||||
|
|
||||||
|
expect(handler.resolvedResponse, isNull); // page=2 was never cached
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the key ignores query parameter order', () {
|
||||||
|
final a = _request('GET', '/events', {'a': 1, 'b': 2});
|
||||||
|
final b = _request('GET', '/events', {'b': 2, 'a': 1});
|
||||||
|
expect(CacheInterceptor.requestKey(a), CacheInterceptor.requestKey(b));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:fintracker_api/fintracker_api.dart';
|
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/core/widgets/money_text.dart';
|
||||||
import 'package:fintracker_app/features/home/home_page.dart';
|
import 'package:fintracker_app/features/home/home_page.dart';
|
||||||
import 'package:fintracker_app/features/home/providers.dart';
|
import 'package:fintracker_app/features/home/providers.dart';
|
||||||
@@ -6,9 +7,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() {
|
NetWorthBreakdown _breakdown() => NetWorthBreakdown(
|
||||||
testWidgets('Обзор renders stat tiles from a net worth breakdown', (tester) async {
|
|
||||||
final breakdown = NetWorthBreakdown(
|
|
||||||
accounts: const [],
|
accounts: const [],
|
||||||
byCurrency: const {'RUB': '123456.78'},
|
byCurrency: const {'RUB': '123456.78'},
|
||||||
d: DateTime(2026, 9, 17),
|
d: DateTime(2026, 9, 17),
|
||||||
@@ -20,22 +19,33 @@ void main() {
|
|||||||
totalRub: '123456.78',
|
totalRub: '123456.78',
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
RunwayOut _runway() => RunwayOut(
|
||||||
ProviderScope(
|
|
||||||
overrides: [
|
|
||||||
netWorthBreakdownProvider.overrideWith((ref) => breakdown),
|
|
||||||
netWorthSeriesProvider.overrideWith((ref) => const <NetWorthDay>[]),
|
|
||||||
cashflowThisMonthProvider.overrideWith((ref) => null),
|
|
||||||
cashflowLast12Provider.overrideWith((ref) => const <CashFlowMonth>[]),
|
|
||||||
runwayProvider.overrideWith((ref) => RunwayOut(
|
|
||||||
asOf: DateTime(2026, 9, 17),
|
asOf: DateTime(2026, 9, 17),
|
||||||
avgBaseline3mRub: '0',
|
avgBaseline3mRub: '0',
|
||||||
liquidReserveRub: '0',
|
liquidReserveRub: '0',
|
||||||
runwayMonths: null,
|
runwayMonths: null,
|
||||||
)),
|
);
|
||||||
metricsStatusProvider.overrideWith((ref) => null),
|
|
||||||
dataQualityProvider.overrideWith((ref) => const <DataQualityRow>[]),
|
// Every provider HomePage watches needs an override — including
|
||||||
],
|
// portfolioSummaryHomeProvider, easy to forget since it renders nothing on an
|
||||||
|
// empty ledger. Without it, HomePage falls through to the real apiProvider,
|
||||||
|
// which now also opens a real ResponseCacheDatabase (see docs/ai/offline-cache.md).
|
||||||
|
List<Override> _overrides({DateTime? staleAt}) => [
|
||||||
|
netWorthBreakdownProvider.overrideWith((ref) => Cached(_breakdown(), fetchedAt: staleAt)),
|
||||||
|
netWorthSeriesProvider.overrideWith((ref) => Cached(const <NetWorthDay>[], fetchedAt: staleAt)),
|
||||||
|
cashflowThisMonthProvider.overrideWith((ref) => Cached(null, fetchedAt: staleAt)),
|
||||||
|
cashflowLast12Provider.overrideWith((ref) => Cached(const <CashFlowMonth>[], fetchedAt: staleAt)),
|
||||||
|
runwayProvider.overrideWith((ref) => Cached(_runway(), fetchedAt: staleAt)),
|
||||||
|
metricsStatusProvider.overrideWith((ref) => Cached(null, fetchedAt: staleAt)),
|
||||||
|
dataQualityProvider.overrideWith((ref) => Cached(const <DataQualityRow>[], fetchedAt: staleAt)),
|
||||||
|
portfolioSummaryHomeProvider.overrideWith((ref) => Cached(null, fetchedAt: staleAt)),
|
||||||
|
];
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('Обзор renders stat tiles from a net worth breakdown', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: _overrides(),
|
||||||
child: const MaterialApp(home: HomePage()),
|
child: const MaterialApp(home: HomePage()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -47,5 +57,18 @@ void main() {
|
|||||||
expect(find.text(MoneyText.format('3000', 'RUB')), findsOneWidget);
|
expect(find.text(MoneyText.format('3000', 'RUB')), findsOneWidget);
|
||||||
expect(find.text('Долги'), findsOneWidget);
|
expect(find.text('Долги'), findsOneWidget);
|
||||||
expect(find.text(MoneyText.format('1000', 'RUB')), findsOneWidget);
|
expect(find.text(MoneyText.format('1000', 'RUB')), findsOneWidget);
|
||||||
|
expect(find.textContaining('Нет соединения'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows the offline banner when the dashboard came from the cache', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: _overrides(staleAt: DateTime(2026, 9, 10, 8, 30)),
|
||||||
|
child: const MaterialApp(home: HomePage()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('Нет соединения — данные на 10.09.2026 08:30'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+14492
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -35,3 +35,5 @@ worker (APScheduler, advisory locks) ▼
|
|||||||
- [plan.md](plan.md) — фазы и чек-листы проверки.
|
- [plan.md](plan.md) — фазы и чек-листы проверки.
|
||||||
- [ops.md](ops.md) — деплой на VPS, бэкапы, секреты.
|
- [ops.md](ops.md) — деплой на VPS, бэкапы, секреты.
|
||||||
- [links.md](links.md) — внешние API и референсы.
|
- [links.md](links.md) — внешние API и референсы.
|
||||||
|
- [offline-cache.md](offline-cache.md) — офлайн-кэш Flutter-клиента: контракт `Cached<T>`,
|
||||||
|
`CacheInterceptor`, баннер «данные на …» (фаза 5).
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Offline-кэш (фаза 5)
|
||||||
|
|
||||||
|
Контракт, по которому каждый экран Flutter-клиента получает поддержку офлайна. Реализован
|
||||||
|
целиком на `core/cache/*` + один Dio-интерцептор — экрану достаточно поменять тип провайдера
|
||||||
|
и один раз показать баннер, ничего не меняя в том, как он делает запросы.
|
||||||
|
|
||||||
|
## Идея
|
||||||
|
|
||||||
|
Кэш живёт не в провайдерах и не в экранах, а в `Dio` — на уровне HTTP-транспорта, ниже
|
||||||
|
сгенерированного `fintracker_api`-клиента:
|
||||||
|
|
||||||
|
- **`CacheInterceptor`** (`core/cache/cache_interceptor.dart`) добавлен в `apiProvider`
|
||||||
|
(`core/api/api_client.dart`) последним, после `DateQueryInterceptor` и `_AuthInterceptor`.
|
||||||
|
- `onResponse`: успешный `GET` (статус 200) пишется в кэш по ключу `путь?отсортированные_параметры`
|
||||||
|
(`CacheInterceptor.requestKey`) — fire-and-forget, ответ не ждёт записи.
|
||||||
|
- `onError`: если запрос — `GET` и ошибка сетевая (`connectionError`, `connectionTimeout`,
|
||||||
|
`receiveTimeout`, `sendTimeout`), интерцептор ищет последний закэшированный ответ по тому же
|
||||||
|
ключу и, если он есть, подменяет ошибку успешным `Response` с `extra['fetchedAt']` —
|
||||||
|
временем, когда этот ответ был реально получен от сервера. Если в кэше пусто — ошибка идёт
|
||||||
|
дальше как обычно.
|
||||||
|
- Мутации (не-`GET`) никогда не кэшируются и никогда не подменяются: упавший `POST/PATCH/DELETE`
|
||||||
|
обязан остаться ошибкой.
|
||||||
|
- Реальная ошибка сервера (4xx/5xx, `DioExceptionType.badResponse`) кэшем не перекрывается —
|
||||||
|
подменяется только сетевая недоступность.
|
||||||
|
- **`ResponseCacheDatabase`** (`core/cache/response_cache.dart`) — таблица `drift` с одной строкой
|
||||||
|
на ключ (`requestKey`, `body` как JSON-строка, `fetchedAt`). `driftDatabase()` из
|
||||||
|
`package:drift_flutter` сам выбирает бэкенд: нативный sqlite (FFI) на Android/iOS/desktop,
|
||||||
|
wasm+OPFS (с падением на IndexedDB) в браузере.
|
||||||
|
- Сгенерированный API-клиент (`app/packages/api_client`) копирует `Response.extra` в возвращаемый
|
||||||
|
`Response<T>` без изменений (проверено в `networth_api.dart` и остальных) — значит,
|
||||||
|
`extra['fetchedAt']` долетает до провайдера без единой правки в генерируемом коде.
|
||||||
|
|
||||||
|
## Контракт для экрана
|
||||||
|
|
||||||
|
1. **Провайдер оборачивает данные в `Cached<T>`** (`core/cache/cached.dart`) вместо голого `T`:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final xProvider = FutureProvider.autoDispose<Cached<X>>((ref) async {
|
||||||
|
final r = await ref.watch(apiProvider).getXApi().x();
|
||||||
|
return r.cached; // extension: Cached(r.data as T, fetchedAt: r.extra['fetchedAt'])
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Для эндпоинтов, которые сами обрабатывают `null`/404 (см. `metricsStatusProvider`,
|
||||||
|
`portfolioSummaryHomeProvider` в `features/home/providers.dart`), оборачивайте вручную:
|
||||||
|
`Cached(value, fetchedAt: r.extra['fetchedAt'] as DateTime?)`.
|
||||||
|
|
||||||
|
2. **Экран разворачивает `.data`** там, где раньше было голое значение
|
||||||
|
(`data: (cached) => Widget(x: cached.data)`), и считает баннер один раз на весь экран:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final stale = oldestFetch([a.valueOrNull?.fetchedAt, b.valueOrNull?.fetchedAt, ...]);
|
||||||
|
// ...
|
||||||
|
if (stale != null) StaleBanner(fetchedAt: stale),
|
||||||
|
```
|
||||||
|
|
||||||
|
`oldestFetch` берёт самое старое время среди тех провайдеров, что реально пришли из кэша
|
||||||
|
(`fetchedAt != null`); один баннер на экран — не один на плитку, иначе на дашборде из 8
|
||||||
|
виджетов это станет шумом. `StaleBanner` (`core/widgets/stale_banner.dart`) — тонкий
|
||||||
|
контейнер в цветах темы («Нет соединения — данные на 12.09.2026 10:31»), без логики.
|
||||||
|
|
||||||
|
3. **В тестах экрана — оверрайдить КАЖДЫЙ провайдер**, который экран смотрит, значением
|
||||||
|
`Cached(...)`. Если забыть один (как было с `portfolioSummaryHomeProvider` до этой фазы),
|
||||||
|
тест провалится не сразу, а тихо создаст настоящий `apiProvider` → настоящий
|
||||||
|
`ResponseCacheDatabase` — drift выведет предупреждение "multiple databases" и тест будет
|
||||||
|
трогать реальный sqlite-файл вместо теста в изоляции. Смотрите `test/home_page_test.dart`
|
||||||
|
и `_overrides()` в нём как образец.
|
||||||
|
|
||||||
|
Эталонная реализация — весь `features/home/` (провайдеры + `HomePage`). Остальные ~24 экрана
|
||||||
|
на паттерне `AsyncValueView`/`EmptyState` переводятся по этому же рецепту, независимо друг от
|
||||||
|
друга (можно параллельно, в отдельных worktree).
|
||||||
|
|
||||||
|
## Веб: sqlite3.wasm + drift_worker.js
|
||||||
|
|
||||||
|
Собранные ассеты лежат в `app/web/sqlite3.wasm` и `app/web/drift_worker.js`, закоммичены (как
|
||||||
|
`openapi/openapi.json` и Dart-клиент). Пересобирать `just app-web-assets`, когда обновляются
|
||||||
|
версии пакетов `drift`/`sqlite3` — recipe качает `sqlite3.wasm` с GitHub-релиза пакета
|
||||||
|
`sqlite3.dart`, версия релиза берётся из `flutter pub deps --json`, и компилирует
|
||||||
|
`drift_worker.js` из исходника `package:drift/web/drift_worker.dart` через `dart compile js`.
|
||||||
|
`just build-web` вызывает это автоматически.
|
||||||
|
|
||||||
|
## Что не проверено
|
||||||
|
|
||||||
|
`flutter build linux --debug` на сборке нативного sqlite3 не дошёл до проверки — упал раньше,
|
||||||
|
на несвязанной с кэшем проблеме `flutter_secure_storage_linux` (старый `nlohmann/json.hpp`,
|
||||||
|
`-Werror=deprecated-literal-operator` на новых компиляторах; плюс сначала не хватало
|
||||||
|
`libsecret`/`pkg-config` в `flake.nix` — это уже добавлено). Сам `sqlite3`/drift на Dart VM
|
||||||
|
(native FFI) отработал корректно — это видно по `flutter test`, где `ResponseCacheDatabase`
|
||||||
|
реально открывает файл через нативный бэкенд без ошибок. Кто будет делать пункт «Linux/Windows
|
||||||
|
в CI» — начните с починки `flutter_secure_storage_linux` (обновить пакет или добавить
|
||||||
|
`-Wno-deprecated-literal-operator` в его CMake), это отдельная от кэша проблема.
|
||||||
+1
-2
@@ -21,8 +21,7 @@ docker compose up -d db
|
|||||||
docker compose exec -T db pg_restore -U $POSTGRES_USER -d $POSTGRES_DB --clean < backups/fintracker_<ts>.dump
|
docker compose exec -T db pg_restore -U $POSTGRES_USER -d $POSTGRES_DB --clean < backups/fintracker_<ts>.dump
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
Метрики пересчитываются из raw/core: `docker compose exec worker fintracker metrics refresh`
|
Метрики пересчитываются из raw/core: `docker compose exec worker fintracker metrics refresh`.
|
||||||
(появится в фазе 2).
|
|
||||||
|
|
||||||
## Секреты
|
## Секреты
|
||||||
|
|
||||||
|
|||||||
@@ -523,6 +523,33 @@ backup/restore, страница здоровья (`/sync/status`, счётчи
|
|||||||
Проверка: приложение работает offline на кэше; APK подписывается и ставится; restore из
|
Проверка: приложение работает offline на кэше; APK подписывается и ставится; restore из
|
||||||
ночного `pg_dump` на чистом VPS + `fintracker metrics refresh` воспроизводит все метрики.
|
ночного `pg_dump` на чистом VPS + `fintracker metrics refresh` воспроизводит все метрики.
|
||||||
|
|
||||||
|
**Статус: в работе.** Готово до этой правки (не с нуля):
|
||||||
|
адаптивные раскладки (`app_shell.dart`, breakpoints 600/1200), тёмная тема
|
||||||
|
(`theme_controller.dart`), общий паттерн пустых/ошибочных состояний
|
||||||
|
(`AsyncValueView` — 25 экранов, `EmptyState` — 22), backup — `pg-backup`-сайдкар в
|
||||||
|
`docker-compose.yml` (ночной `pg_dump -Fc`, retention 14 дней) и runbook в `ops.md`.
|
||||||
|
|
||||||
|
Сделано в этом заходе:
|
||||||
|
|
||||||
|
- **Offline-кэш** — контракт целиком в [offline-cache.md](docs/ai/offline-cache.md):
|
||||||
|
`CacheInterceptor` в `apiProvider` кэширует каждый успешный `GET` по `путь+параметры` в
|
||||||
|
`drift` (sqlite нативно, wasm+OPFS в браузере) и подменяет им сетевую ошибку;
|
||||||
|
провайдер отдаёт `Cached<T>`, экран показывает один баннер «Нет соединения — данные на …».
|
||||||
|
Реализовано и протестировано целиком на `features/home/` (дашборд — самый частый экран);
|
||||||
|
остальные ~24 экрана переводятся по тому же контракту независимо друг от друга.
|
||||||
|
`app/web/sqlite3.wasm` + `app/web/drift_worker.js` закоммичены, пересборка —
|
||||||
|
`just app-web-assets`.
|
||||||
|
- `flutter build linux --debug` не пройден до конца: `flutter_secure_storage_linux` падает на
|
||||||
|
устаревшем `nlohmann/json.hpp` под `-Werror=deprecated-literal-operator` современных
|
||||||
|
компиляторов — не связано с кэшем, чинить отдельно перед пунктом CI/Linux. `libsecret` +
|
||||||
|
`pkg-config` для линуксовой сборки уже добавлены в `flake.nix`.
|
||||||
|
- `docs/ai/ops.md` поправлен: убрана ссылка на «появится в фазе 2» у `metrics refresh`.
|
||||||
|
|
||||||
|
Не начато: CI-джоба для Flutter (`just app-check` в CI, сборка Linux/Windows), консолидация
|
||||||
|
страницы здоровья (сейчас статусы синка на `/sync`, data quality — блоком на дашборде),
|
||||||
|
Android release-подпись (нужен keystore от пользователя), off-site бэкап (см. открытый
|
||||||
|
вопрос §7.9 — решения ещё нет).
|
||||||
|
|
||||||
## 7. Открытые вопросы (дефолт в скобках, можно менять по ходу)
|
## 7. Открытые вопросы (дефолт в скобках, можно менять по ходу)
|
||||||
|
|
||||||
1. Хранение refresh-токена в web: `localStorage` (дефолт: да, один пользователь, TLS) или
|
1. Хранение refresh-токена в web: `localStorage` (дефолт: да, один пользователь, TLS) или
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
pkgs.just
|
pkgs.just
|
||||||
pkgs.postgresql_17 # local dev cluster (`just db-start`) and psql
|
pkgs.postgresql_17 # local dev cluster (`just db-start`) and psql
|
||||||
pkgs.openapi-generator-cli # `just gen-client`
|
pkgs.openapi-generator-cli # `just gen-client`
|
||||||
|
pkgs.jq # `just app-web-assets` reads package_config.json / pub deps
|
||||||
|
pkgs.curl # `just app-web-assets` fetches sqlite3.wasm from GitHub releases
|
||||||
];
|
];
|
||||||
|
|
||||||
commonHook = ''
|
commonHook = ''
|
||||||
@@ -49,7 +51,12 @@
|
|||||||
};
|
};
|
||||||
# backend + Flutter toolchain (large download on first use)
|
# backend + Flutter toolchain (large download on first use)
|
||||||
app = pkgs.mkShell {
|
app = pkgs.mkShell {
|
||||||
packages = backendTools ++ [ pkgs.flutter ];
|
packages = backendTools ++ [
|
||||||
|
pkgs.flutter
|
||||||
|
# Linux desktop build: flutter_secure_storage_linux links libsecret via pkg-config
|
||||||
|
pkgs.pkg-config
|
||||||
|
pkgs.libsecret
|
||||||
|
];
|
||||||
shellHook = commonHook;
|
shellHook = commonHook;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,11 +22,22 @@ web:
|
|||||||
|
|
||||||
# --- flutter ---
|
# --- flutter ---
|
||||||
|
|
||||||
build-web:
|
build-web: app-web-assets
|
||||||
cd app && flutter pub get && flutter build web --release
|
cd app && flutter pub get && flutter build web --release
|
||||||
|
|
||||||
|
# fetch sqlite3.wasm + compile drift_worker.js into app/web/ (offline cache, drift/wasm+OPFS —
|
||||||
|
# see docs/ai/offline-cache.md). Re-run whenever the drift/sqlite3 package versions change;
|
||||||
|
# both files are committed, so this is not needed on every build.
|
||||||
|
app-web-assets:
|
||||||
|
cd app && flutter pub get
|
||||||
|
v=$(cd app && flutter pub deps --json | jq -r '.packages[] | select(.name=="sqlite3") | .version') && \
|
||||||
|
curl -fL -o app/web/sqlite3.wasm "https://github.com/simolus3/sqlite3.dart/releases/download/sqlite3-$v/sqlite3.wasm"
|
||||||
|
d=$(jq -r '.packages[] | select(.name=="drift") | .rootUri' app/.dart_tool/package_config.json | sed 's#^file://##') && \
|
||||||
|
cd app && dart compile js -O2 --packages=.dart_tool/package_config.json -o web/drift_worker.js "$d/web/drift_worker.dart" && \
|
||||||
|
rm -f web/drift_worker.js.deps web/drift_worker.js.map
|
||||||
|
|
||||||
app-check:
|
app-check:
|
||||||
cd app && flutter analyze && flutter test
|
cd app && flutter pub get && flutter analyze && flutter test
|
||||||
|
|
||||||
# one-off source sync against the local dev DB (zenmoney | cbr)
|
# one-off source sync against the local dev DB (zenmoney | cbr)
|
||||||
pull SOURCE:
|
pull SOURCE:
|
||||||
|
|||||||
Reference in New Issue
Block a user