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:
Dmitry
2026-09-19 12:40:08 +03:00
parent 95cd6f176e
commit ffc5ed959a
21 changed files with 15852 additions and 60 deletions
+91
View File
@@ -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), это отдельная от кэша проблема.