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:
@@ -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), это отдельная от кэша проблема.
|
||||
Reference in New Issue
Block a user