Адрес бэкенда хранится в shared_preferences и читается до runApp; switchApiBaseUrl сбрасывает токены и кэш старого сервера. Выход стирает refresh-токен и sqlite-кэш ответов. Экран настроек переделан под разделы.
8.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).
Жизненный цикл
Кэш — это вся финансовая картина в открытом 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), это отдельная от кэша проблема.