Files
Dmitry 0708e8e88b
ci / backend (push) Canceled after 0s
ci / backend-image (push) Canceled after 0s
ci / app (push) Canceled after 0s
ci / app-build-linux (push) Canceled after 0s
ci / app-build-windows (push) Canceled after 0s
chore(just): рецепт dev, build-web не собирает ассеты drift
just dev поднимает БД, миграции, сборку web и API на :8000. build-web больше не зависит от app-web-assets (sqlite3.wasm и drift_worker.js закоммичены и пересобираются вручную), в docs/ai/offline-cache.md это отражено.
2026-09-20 10:50:40 +03:00

8.4 KiB
Raw Permalink Blame History

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:

    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)), и считает баннер один раз на весь экран:

    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), это отдельная от кэша проблема.