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).
7.4 KiB
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']долетает до провайдера без единой правки в генерируемом коде.
Контракт для экрана
-
Провайдер оборачивает данные в
Cached<T>(core/cache/cached.dart) вместо гологоT: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?). -
Экран разворачивает
.dataтам, где раньше было голое значение (data: (cached) => Widget(x: cached.data)), и считает баннер один раз на весь экран: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»), без логики. -
В тестах экрана — оверрайдить КАЖДЫЙ провайдер, который экран смотрит, значением
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), это отдельная от кэша проблема.