Files
fin-tracker/docs/ai/offline-cache.md
T
Dmitry a559d6de3e feat(app): смена сервера API и очистка данных устройства при выходе
Адрес бэкенда хранится в shared_preferences и читается до runApp; switchApiBaseUrl сбрасывает токены и кэш старого сервера. Выход стирает refresh-токен и sqlite-кэш ответов. Экран настроек переделан под разделы.
2026-09-19 22:14:14 +03:00

106 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).
## Жизненный цикл
Кэш — это вся финансовая картина в открытом sqlite/IndexedDB, поэтому он живёт не дольше сеанса:
`AuthController` чистит его вместе с refresh-токеном при `logout()` и когда сервер ответил 401 на
refresh. Смена адреса API (`switchApiBaseUrl`) идёт через `logout()`, так что данные старого
сервера не показываются новому. Ошибка очистки не блокирует выход. TTL и лимита размера у кэша
по-прежнему нет.
## Адрес API
`apiBaseUrlProvider`: сохранённый в `shared_preferences` адрес (читается в `main()` до `runApp`),
иначе `--dart-define=API_BASE_URL`, иначе origin страницы (web) / `http://127.0.0.1:8000`.
Меняется в Настройках и на экране входа.
## Веб: 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), это отдельная от кэша проблема.