# 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` без изменений (проверено в `networth_api.dart` и остальных) — значит, `extra['fetchedAt']` долетает до провайдера без единой правки в генерируемом коде. ## Контракт для экрана 1. **Провайдер оборачивает данные в `Cached`** (`core/cache/cached.dart`) вместо голого `T`: ```dart final xProvider = FutureProvider.autoDispose>((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), это отдельная от кэша проблема.