11 KiB
Offline-First для модуля «Diary» — план реализации Контекст и описание задачи
Модуль Diary (дневник питания) приложения Eatvio полностью завязан на сетевые запросы через Retrofit/Dio. При отсутствии сети приложение падает в DiaryState.error и данные теряются. Необходимо реализовать стратегию Offline-First с тремя ключевыми компонентами:
Локальное хранение данных дневника и глоссария (Drift/SQLite)
Очередь мутаций (pending queue) — операции копятся офлайн и синхронизируются при появлении сети
Автосинхронизация при восстановлении сети через InternetConnectionCubit (уже существует)
Дедлайн утверждения плана: 14:00, 13 апреля 2026 г. ✅ Дедлайн готовности: 14:00, 12 мая 2026 г. Архитектурное решение
DiaryCubit │ └── DiaryRepository (MODIFY: добавить offline-логику) │ ├── DiaryService (Retrofit, доработка под Idempotency-Key) │ ├── DiaryLocalDataSource (NEW: фасад над Drift DAO) │ ├── DiaryDayDao → таблица diary_days │ ├── MealDao → таблица meals │ ├── PortionDao → таблица portions │ ├── GlossaryDao → таблица glossary_cache (201k записей) │ └── PendingOpsDao → таблица pending_operations │ └── DiarySyncService (NEW: FIFO-синхронизация) └── подписка на InternetConnectionCubit
Паттерн чтения: Cache-Aside → попытка загрузить из сети (учитывая ETag) → при успехе кэшируем → при ошибке сети возвращаем кэш
Паттерн записи: Write-Behind → немедленное локальное обновление + запись в очередь с уникальным Idempotency-Key → при наличии сети отправляем на сервер API Утвержденный Контракт и Требования к бэкенду
Caution
Статус Backend: На данный момент изучен код бекенда в папке eatvio. Все описанные ниже endpoints (/dump, /meta), использование Idempotency-Key, server_updated_at и единый формат ошибок в текущем коде Laravel отсутствуют (используется стандартная валидация FormRequest и ModelNotFound).
Таким образом, описанный ниже контракт является ЦЕЛЕВЫМ (To-Be) и Бэкенд-команда должна его имплементировать для успешной работы оффлайн-стратегии.
- Глоссарий для офлайн-кэша 1A. Meta-проверка версии данных
GET /api/glossaries/meta
Возвращает легкий объект сверки:
{ "total_count": 201691, "last_updated_at": "2026-04-08T10:30:00Z", "checksum": "sha256:..." }
Сценарий клиента: Сравниваем локальный checksum. Если отличается — вызываем /dump. 1B. Дамп продуктов
GET /api/glossaries/dump?updated_after=<iso_datetime> If-None-Match: "etag_из_предыдущего_ответа"
Сценарий клиента (Флаттер):
При ответе 304 Not Modified — локальный кэш актуален.
При ответе 200 OK — в фоне (в изоляте/через compute) вливаем данные в SQLite таблицу. Заголовки ETag и Last-Modified запоминаем.
- Дневник: идемпотентные операции
Мутирующие команды (create day, create portion и т.д.) теперь требуют заголовок:
Idempotency-Key:
Это позволит безопасно ретраить запросы (например, оборвавшиеся из-за скачка сети), не плодя дубли на сервере.
Сам API V2 для создания дня:
POST /api/v2/diary/create/day
Формат даты: Y-m-d\TH:i:sP (2026-04-08T07:30:00+03:00). 3. Инвалидация локального дневника
Во всех ответах DiaryResource (результат /index или /show) сервер обязан возвращать дату генерации/изменения:
{ ... "server_updated_at": "2026-04-08T10:30:00Z" }
Мобильный клиент будет сравнивать это число со своей записью в Drift diary_days.server_updated_at, чтобы решать, нужен ли merge/refresh. 4. Фото
Загрузка фото:
POST /api/diary/{diary}/meals/{type_no}/photo/upload
Ответ 200 с photo.id:
{ "photo": { "id": 456, "path": "...photo.jpg" } }
При загрузке из оффлайн-очереди, клиент сохранит этот ID к себе в локальную таблицу фотографий, чтобы иметь возможность позже выполнить удаление:
Удаление фото:
DELETE /api/diary/{diary}/meals/{type_no}/photo/{media}/delete
- Флоу очереди синхронизации (Формат ошибок)
Ответы с ошибками возвращаются в Envelope-формате:
{ "message": "Human readable message", "error": { "code": "validation_error", "message": "Human readable message", "retryable": false } }
Обработка на Флаттере (флоу Sync-провайдера):
Исключение DIO с кодом 4xx/5xx:
Если в ответе есть error.retryable == false (ошибки валидации, порция/дневник не найдены, конфликт payload):
→ Пометить PendingOperation как FAILED. Отправить стейт в UI с нотификацией или подсветить порцию красным ("Не удалось сохранить").
Если error.retryable == true или 500 Server Error или ConnectionTimeout
→ Оставить операцию в очереди (PENDING). Повторить попытку позже с backoff.
Proposed Changes (Изменения в Flutter приложении) Этап 1 — Инфраструктура Drift БД
Срок: 7–14 апреля 2026 (7 дней)
Подключить пакеты: drift, drift_flutter, sqlite3_flutter_libs, uuid.
Создать AppDatabase и схемы:
diary_days, meals, portions, media
glossary_cache
pending_operations (Колонки: id (UUID), type, payload, idempotency_key, retry_count, status, local_file_path)
sync_meta (для checksum и etag)
Этап 2 — DAO и LocalDataSource
Срок: 14–19 апреля 2026 (5 дней)
Написать методы DAO: сохранения/обновления дневника.
Написать методы Bulk Insert для glossary_cache.
Написать методы работы с очередью (markFailed, markSynced).
Этап 3 — Настройка Retrofit/Dio для Idempotency
Срок: 19–26 апреля 2026 (7 дней)
Обновить DiaryService.dart:
Добавить параметр @Header("Idempotency-Key") String idempotencyKey во все методы POST/PUT.
Обновить метод получения индекса дня под V2.
В DiaryRepository внедрить механизм: при мутировании данных, генерировать UUID → сохранить в pending_operations → отправить на сервер (передав туда UUID) → при успехе очистить → при ошибке ориентироваться на поле retryable из ответа.
Этап 4 — SyncService для Глоссария и Очереди
Срок: 26–30 апреля 2026 (4 дня)
Обернуть первичное скачивание /dump в compute(), разбирая JSON как stream либо большими чанками, чтобы 200к записей не заморозили фреймы.
Написать воркер синхронизации дневника (прослушивание InternetConnectionCubit), который проходит по таблице pending_operations и переотправляет POST-запросы.
Обработать удаление/загрузку фотографий.
Этап 5 — UI и оповещения (Обработка failed-статусов)
Срок: 30 апреля – 5 мая 2026 (5 дней)
На экранах: показать лоадер или жёлтую плашку "Оффлайн - N изменений в очереди".
Имплементировать механизм: если операция в статусе FAILED (error = validation_error), показывать toast "Ошибка валидации порции" или маркировать саму UI-строку порции красным.
Ограничение некоторых функций, если InternetConnectionCubit без связи (например, отправка на оценку консультанту).
Этап 6 & 7 — DI, Интеграция и Ретест
Срок: 5–12 мая 2026 (7 дней)
Инжектнуть зависимости через GetIt/Injectable.
Покрыть интеграционными тестами (diary_repository_offline_test.dart) поведение с retryable: true|false.
Подтвердить работоспособность на девайсе (заморозка сети руками, добавление, разморозка сети).
Итог для BE и FE
Клиент и Сервер разделяют логику retry/fail, используя поле retryable и Idempotency-Key. Это идеальная база для стабильного Offline-First.
Для старта работ FE ожидает завершения работ на BE (или хотя бы выкатывания моков/тестовой среды API по этому гайду).