diff --git a/lib/core/local_db/dao/diary_dao.dart b/lib/core/local_db/dao/diary_dao.dart index 5ab58c26..2693e8af 100644 --- a/lib/core/local_db/dao/diary_dao.dart +++ b/lib/core/local_db/dao/diary_dao.dart @@ -69,6 +69,12 @@ class DiaryDao extends DatabaseAccessor with _$DiaryDaoMixin { await (delete(db.mealsTable)..where((t) => t.diaryDayId.equals(diaryDayId))).go(); } + /// Удалить все порции для конкретного приёма пищи. + /// BUG-6 fix: позволяет атомарно перезаписать порции одного meal. + Future clearPortionsForMeal(int mealId) async { + await (delete(db.portionsTable)..where((t) => t.mealId.equals(mealId))).go(); + } + /// Очистить кэш дней дневника старше [ttlDays] дней. /// Вызывается периодически для экономии места на устройстве. Future clearExpiredCache({int ttlDays = 14}) { diff --git a/lib/core/local_db/diary_local_data_source.dart b/lib/core/local_db/diary_local_data_source.dart index 7c79dca1..76d2e5c3 100644 --- a/lib/core/local_db/diary_local_data_source.dart +++ b/lib/core/local_db/diary_local_data_source.dart @@ -115,6 +115,13 @@ class DiaryLocalDataSource { return _diaryDao.clearMealsForDay(diaryDayId); } + /// Удалить все порции для конкретного meal. + /// BUG-6 fix: используется для атомарной перезаписи порций одного meal, + /// без удаления данных остальных meals. + Future clearCachedPortions(int mealId) { + return _diaryDao.clearPortionsForMeal(mealId); + } + Future clearExpiredDiaryCache({int ttlDays = 14}) { return _diaryDao.clearExpiredCache(ttlDays: ttlDays); } diff --git a/lib/features/diary/presentation/bloc/diary_cubit.dart b/lib/features/diary/presentation/bloc/diary_cubit.dart index 01ed0eab..e3ca599c 100644 --- a/lib/features/diary/presentation/bloc/diary_cubit.dart +++ b/lib/features/diary/presentation/bloc/diary_cubit.dart @@ -156,7 +156,14 @@ class DiaryCubit extends Cubit { } } catch (e, st) { await reportFrom(e, st, this); - log(e.toString()); + log('updateCurrentDiary error: $e'); + // BUG-1 fix: офлайн fallback — перезагружаем из кэша, чтобы UI + // отразил изменения, выполненные пользователем. + try { + await fetchDiaryV2(needLoading: false, diaryId: diaryId); + } catch (_) { + // Не критично — UI останется на текущем состоянии + } } } @@ -351,8 +358,9 @@ class DiaryCubit extends Cubit { planDuration = chosenPlanModel.plan.duration; planName = chosenPlanModel.plan.name; currentPlanDay = consultantReview.currentPlanDay; - } catch (_) { - // Офлайн — данные плана недоступны + } catch (e) { + // BUG-9 fix: офлайн — данные плана недоступны, логируем для отладки + log('[setDiary] Plan/comments data unavailable (offline?): $e'); } } @@ -431,7 +439,11 @@ class DiaryCubit extends Cubit { required int quantity, int? productNutritionalValueId, }) async { - emit((state as DiaryStateLoaded).copyWith(isLoadingAddingProductInMeal: true)); + // BUG-2 fix: безопасная проверка типа стейта перед кастом + final currentState = state; + if (currentState is DiaryStateLoaded) { + emit(currentState.copyWith(isLoadingAddingProductInMeal: true)); + } try { await diaryRepository.addPortionToMeal( diaryId: diaryId, diff --git a/lib/features/diary/presentation/pages/diary_screen.dart b/lib/features/diary/presentation/pages/diary_screen.dart index 72a6679f..9f2171a8 100644 --- a/lib/features/diary/presentation/pages/diary_screen.dart +++ b/lib/features/diary/presentation/pages/diary_screen.dart @@ -159,8 +159,8 @@ class _DiaryScreenContentState extends State { }); final isFinished = await context.read().finishDiaryDay( diaryId: state.diary.id, - wakeUp: state.diary.wakeup.toString(), - retire: state.diary.retire.toString(), + wakeUp: state.diary.wakeup ?? '06:30:00', + retire: state.diary.retire ?? '22:30:00', ); if (!isFinished && context.mounted) { showErrorDialog(context); diff --git a/lib/features/diary/presentation/widgets/add_product_modal.dart b/lib/features/diary/presentation/widgets/add_product_modal.dart index a0895bf9..220c788f 100644 --- a/lib/features/diary/presentation/widgets/add_product_modal.dart +++ b/lib/features/diary/presentation/widgets/add_product_modal.dart @@ -170,10 +170,19 @@ class _AddProductModalState extends State { if (widget.portion != null) const SizedBox(width: 8), InkWell( onTap: () { + // BUG-4 fix: валидация полей перед отправкой + final productName = productNameController.text.trim(); + if (productName.isEmpty) return; + + final isGrams = selectedGlossary == null || selectedGlossary!.grInPortion == 0; + final portionType = isGrams ? 'gr' : 'pcs'; + // BUG-4 fix: безопасный парсинг количества с дефолтом + final quantity = int.tryParse(quantityController.text) ?? (isGrams ? 100 : 1); + // Автоматически выбираем первый элемент, если текст точно совпадает if (selectedGlossary == null && glossariesList.isNotEmpty && - productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) { + productName.toLowerCase() == glossariesList[0].name.toLowerCase()) { selectedGlossary = glossariesList[0]; } @@ -181,13 +190,9 @@ class _AddProductModalState extends State { widget.diaryCubit.addProductToMeal( diaryId: widget.state.diary.id, mealId: widget.state.selectedMeal, - productName: productNameController.text, - portion: selectedGlossary == null - ? 'gr' - : selectedGlossary!.grInPortion == 0 - ? 'gr' - : 'pcs', - quantity: int.parse(quantityController.text), + productName: productName, + portion: portionType, + quantity: quantity, productNutritionalValueId: selectedGlossary?.id, ); } else { @@ -195,26 +200,22 @@ class _AddProductModalState extends State { diaryId: widget.state.diary.id, mealId: widget.state.selectedMeal, portionId: widget.portion!.id, - productName: productNameController.text, - portion: selectedGlossary == null - ? 'gr' - : selectedGlossary!.grInPortion == 0 - ? 'gr' - : 'pcs', - quantity: int.parse(quantityController.text), + productName: productName, + portion: portionType, + quantity: quantity, productNutritionalValueId: selectedGlossary?.id, portionState: widget.portion!.portionState, ); } Navigator.of(context).pop(); - if (selectedGlossary == null && _previousProductName != productNameController.text) { + if (selectedGlossary == null && _previousProductName != productName) { showModalBottomSheet( isScrollControlled: true, useRootNavigator: true, context: context, builder: (context) => Padding( padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), - child: GlossaryModerationRequestModalWidget(productName: productNameController.text, diaryCubit: widget.diaryCubit), + child: GlossaryModerationRequestModalWidget(productName: productName, diaryCubit: widget.diaryCubit), ), ); } diff --git a/lib/features/diary/presentation/widgets/photo_gallery_widget.dart b/lib/features/diary/presentation/widgets/photo_gallery_widget.dart index bdb7c53a..42266f27 100644 --- a/lib/features/diary/presentation/widgets/photo_gallery_widget.dart +++ b/lib/features/diary/presentation/widgets/photo_gallery_widget.dart @@ -116,7 +116,13 @@ class _PhotoGalleryWidgetState extends State { builder: (context, state) { if (state is DiaryStateLoaded) { int mealIndex = state.selectedMeal - 1; - if (state.diary.meals[mealIndex].photos.isEmpty && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented) { + final photos = state.diary.meals[mealIndex].photos; + // BUG-5 fix: сбросить индекс, если он вышел за пределы + // (например, после переключения на meal с меньшим числом фото) + if (currentSelectedPhotoIndex >= photos.length) { + currentSelectedPhotoIndex = 0; + } + if (photos.isEmpty && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented) { return GestureDetector( onTap: () async { showImagePicker(state.diary); diff --git a/lib/features/diary/presentation/widgets/product_widget.dart b/lib/features/diary/presentation/widgets/product_widget.dart index cdf34f28..bb04ab87 100644 --- a/lib/features/diary/presentation/widgets/product_widget.dart +++ b/lib/features/diary/presentation/widgets/product_widget.dart @@ -25,6 +25,7 @@ class ProductWidget extends StatefulWidget { class _ProductWidgetState extends State { late final DiaryCubit diaryCubit; late String portionName; + // BUG-8 fix: инициализируем из актуального state порции late bool isEaten; bool showCommentWidget = false; @override @@ -32,7 +33,7 @@ class _ProductWidgetState extends State { super.initState(); diaryCubit = context.read(); portionName = widget.portion.portion == 'gr' ? 'гр' : 'шт'; - isEaten = widget.portion.portionState == PortionState.eatenFromDiet ? true : false; + isEaten = widget.portion.portionState == PortionState.eatenFromDiet; } Widget _buildNutritionalInfo() { @@ -93,7 +94,8 @@ class _ProductWidgetState extends State { void didUpdateWidget(ProductWidget oldWidget) { super.didUpdateWidget(oldWidget); portionName = widget.portion.portion == 'gr' ? 'гр' : 'шт'; - isEaten = widget.portion.portionState == PortionState.eatenFromDiet ? true : false; + // BUG-8 fix: синхронизируем isEaten с актуальным state порции + isEaten = widget.portion.portionState == PortionState.eatenFromDiet; } Future updateProductInMeal(DiaryStateLoaded state, PortionModel portion) { diff --git a/lib/features/diary/presentation/widgets/time_of_meal_picker_widget.dart b/lib/features/diary/presentation/widgets/time_of_meal_picker_widget.dart index a9086d88..afe1bc2a 100644 --- a/lib/features/diary/presentation/widgets/time_of_meal_picker_widget.dart +++ b/lib/features/diary/presentation/widgets/time_of_meal_picker_widget.dart @@ -23,6 +23,12 @@ class _TimeOfMealPickerWidgetState extends State { diaryCubit = context.read(); } + /// Безопасное форматирование времени (HH:MM:SS → HH:MM, пустое → '--:--') + String _formatTime(String time) { + if (time.isEmpty || time.length < 5) return '--:--'; + return time.substring(0, 5); + } + void showTimePicker(int diaryId) { showModalBottomSheet( context: context, @@ -110,7 +116,7 @@ class _TimeOfMealPickerWidgetState extends State { alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( - state.diary.meals[state.selectedMeal - 1].time.substring(0, state.diary.meals[state.selectedMeal - 1].time.length - 3), + _formatTime(state.diary.meals[state.selectedMeal - 1].time), style: const TextStyle( color: Color(0xFF6D6F71), fontSize: 17, diff --git a/lib/features/diary/presentation/widgets/wakeup_and_retire_widget.dart b/lib/features/diary/presentation/widgets/wakeup_and_retire_widget.dart index 6e74f5ec..7d7299e3 100644 --- a/lib/features/diary/presentation/widgets/wakeup_and_retire_widget.dart +++ b/lib/features/diary/presentation/widgets/wakeup_and_retire_widget.dart @@ -26,6 +26,12 @@ class _WakeupAndRetireWidgetState extends State { diaryCubit = context.read(); } + /// BUG-3 fix: безопасное форматирование времени (HH:MM:SS → HH:MM, null → '--:--') + String _formatTime(String? time) { + if (time == null || time.length < 5) return '--:--'; + return time.substring(0, 5); + } + void showTimePicker(int diaryId, bool isWakeup) { showModalBottomSheet( context: context, @@ -142,10 +148,7 @@ class _WakeupAndRetireWidgetState extends State { alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( - state.diary.wakeup.toString().substring( - 0, - state.diary.wakeup.toString().length - 3, - ), + _formatTime(state.diary.wakeup), style: const TextStyle( color: Color(0xFF6D6F71), fontSize: 17, @@ -201,10 +204,7 @@ class _WakeupAndRetireWidgetState extends State { alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( - state.diary.retire.toString().substring( - 0, - state.diary.retire.toString().length - 3, - ), + _formatTime(state.diary.retire), style: const TextStyle( color: Color(0xFF6D6F71), fontSize: 17, diff --git a/lib/features/diary/repositories/diary_repository.dart b/lib/features/diary/repositories/diary_repository.dart index 88aecb0e..a0a1c667 100644 --- a/lib/features/diary/repositories/diary_repository.dart +++ b/lib/features/diary/repositories/diary_repository.dart @@ -29,6 +29,11 @@ class DiaryRepository { bool get _isOnline => connectionCubit.state.isInternetAvailable; + /// BUG-7 fix: отрицательный ID означает, что день был создан офлайн + /// и ещё не синхронизирован с сервером. Операции с таким ID нельзя + /// ставить в очередь — они будут отклонены сервером. + bool _isOfflineDiaryId(int diaryId) => diaryId < 0; + DiaryRepository({ required this.diaryService, required this.localDataSource, @@ -124,6 +129,11 @@ class DiaryRepository { required int quantity, int? productNutritionalValueId, }) async { + // BUG-7 fix: нельзя мутировать офлайн-день без серверного ID + if (_isOfflineDiaryId(diaryId)) { + throw Exception('Для добавления продукта необходим интернет (день ещё не синхронизирован)'); + } + final payload = { 'diary_id': diaryId, 'meal_id': mealId, @@ -175,6 +185,11 @@ class DiaryRepository { int? productNutritionalValueId, required PortionState portionState, }) async { + // BUG-7 fix: нельзя мутировать офлайн-день без серверного ID + if (_isOfflineDiaryId(diaryId)) { + throw Exception('Для редактирования продукта необходим интернет (день ещё не синхронизирован)'); + } + final payload = { 'diary_id': diaryId, 'meal_id': mealId, @@ -209,6 +224,7 @@ class DiaryRepository { /// Write-Behind: отметить порцию как съеденную. Future markEaten({required int diaryId, required int mealId, required int portionId}) async { + if (_isOfflineDiaryId(diaryId)) return; // BUG-7 fix final payload = {'diary_id': diaryId, 'meal_id': mealId, 'portion_id': portionId}; if (!_isOnline) { @@ -231,6 +247,7 @@ class DiaryRepository { /// Write-Behind: снять отметку "съедено". Future unmarkEaten({required int diaryId, required int mealId, required int portionId}) async { + if (_isOfflineDiaryId(diaryId)) return; // BUG-7 fix final payload = {'diary_id': diaryId, 'meal_id': mealId, 'portion_id': portionId}; if (!_isOnline) { @@ -253,6 +270,7 @@ class DiaryRepository { /// Write-Behind: восстановить удалённую порцию. Future restore({required int diaryId, required int mealId, required int portionId}) async { + if (_isOfflineDiaryId(diaryId)) return; // BUG-7 fix final payload = {'diary_id': diaryId, 'meal_id': mealId, 'portion_id': portionId}; if (!_isOnline) { @@ -276,6 +294,11 @@ class DiaryRepository { /// Write-Behind: удалить порцию из приёма пищи. /// Использует idempotent API вариант для предотвращения дубликатов. Future deleteProductFromMeal({required int diaryId, required int mealId, required int portionId}) async { + // BUG-7 fix: нельзя мутировать офлайн-день без серверного ID + if (_isOfflineDiaryId(diaryId)) { + throw Exception('Для удаления продукта необходим интернет (день ещё не синхронизирован)'); + } + final payload = { 'diary_id': diaryId, 'meal_id': mealId, @@ -309,6 +332,7 @@ class DiaryRepository { /// Write-Behind: загрузить фото приёма пищи. /// Офлайн → сохраняем файл локально и ставим в очередь. Future uploadPhotos({required int diaryId, required int mealId, required List files}) async { + if (_isOfflineDiaryId(diaryId)) return; // BUG-7 fix for (var i = 0; i < files.length; i++) { final file = files[i]; final payload = {'diary_id': diaryId, 'type_no': mealId}; @@ -348,6 +372,7 @@ class DiaryRepository { /// Write-Behind: удалить фото приёма пищи. Future deletePhoto({required int diaryId, required int mealId, required DiaryPhotoModel photo}) async { + if (_isOfflineDiaryId(diaryId)) return; // BUG-7 fix final payload = {'diary_id': diaryId, 'type_no': mealId, 'media_id': photo.id}; if (!_isOnline) { @@ -388,6 +413,7 @@ class DiaryRepository { } Future fillSatiety({required int diaryId, required int mealId, required int satiety}) async { + if (_isOfflineDiaryId(diaryId)) return; // BUG-7 fix if (!_isOnline) { await localDataSource.enqueueOperation(type: 'fill_satiety', payload: {'diary_id': diaryId, 'type_no': mealId, 'satiety': satiety}); return; @@ -403,6 +429,7 @@ class DiaryRepository { } Future fillMood({required int diaryId, required int mealId, required int mood}) async { + if (_isOfflineDiaryId(diaryId)) return; // BUG-7 fix if (!_isOnline) { await localDataSource.enqueueOperation(type: 'fill_mood', payload: {'diary_id': diaryId, 'type_no': mealId, 'mood': mood}); return; @@ -418,6 +445,7 @@ class DiaryRepository { } Future fillTime({required int diaryId, required int mealId, required String time}) async { + if (_isOfflineDiaryId(diaryId)) return; // BUG-7 fix if (!_isOnline) { await localDataSource.enqueueOperation(type: 'fill_time', payload: {'diary_id': diaryId, 'type_no': mealId, 'time': time}); return; @@ -433,6 +461,7 @@ class DiaryRepository { } Future fillWakeup({required int diaryId, required String time}) async { + if (_isOfflineDiaryId(diaryId)) return; // BUG-7 fix if (!_isOnline) { await localDataSource.enqueueOperation(type: 'fill_wakeup', payload: {'diary_id': diaryId, 'wakeup': time}); return; @@ -448,6 +477,7 @@ class DiaryRepository { } Future fillRetire({required int diaryId, required String time}) async { + if (_isOfflineDiaryId(diaryId)) return; // BUG-7 fix if (!_isOnline) { await localDataSource.enqueueOperation(type: 'fill_retire', payload: {'diary_id': diaryId, 'retire': time}); return; @@ -608,10 +638,12 @@ class DiaryRepository { final cachedDay = await localDataSource.getCachedDay(date); if (cachedDay == null) return; - // 3. Очищаем старые meals (и каскадно portions) перед записью свежих данных - await localDataSource.clearCachedMeals(cachedDay.id); + // BUG-6 fix: НЕ удаляем все meals разом — это приводит к потере данных + // при ошибке записи. Вместо этого upsert-им каждый meal отдельно + // (uniqueKeys [diaryDayId, typeNo] обеспечивают корректный upsert), + // а порции очищаем и перезаписываем на уровне каждого meal. - // 4. Для каждого приёма пищи — сохраняем и его порции + // 3. Для каждого приёма пищи — сохраняем и его порции for (final meal in model.meals) { await localDataSource.cacheMeal( diaryDayId: cachedDay.id, @@ -626,7 +658,9 @@ class DiaryRepository { final cachedMeal = cachedMeals.where((m) => m.typeNo == meal.typeNumber).firstOrNull; if (cachedMeal == null) continue; - // 4. Удаляем старые порции этого meal и записываем актуальные + // 4. Очищаем порции ТОЛЬКО этого meal и записываем актуальные + // (если запись одного meal упадёт, остальные не пострадают) + await localDataSource.clearCachedPortions(cachedMeal.id); for (final portion in meal.portions) { await localDataSource.cachePortion( mealId: cachedMeal.id, @@ -651,6 +685,7 @@ class DiaryRepository { } } catch (e) { // Ошибка кэширования не должна ломать основной флоу + Sentry.captureException(e); } } diff --git a/tz.md b/tz.md deleted file mode 100644 index dd94969a..00000000 --- a/tz.md +++ /dev/null @@ -1,189 +0,0 @@ -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) и Бэкенд-команда должна его имплементировать для успешной работы оффлайн-стратегии. - -1. Глоссарий для офлайн-кэша -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= -If-None-Match: "etag_из_предыдущего_ответа" - -Сценарий клиента (Флаттер): - - При ответе 304 Not Modified — локальный кэш актуален. - При ответе 200 OK — в фоне (в изоляте/через compute) вливаем данные в SQLite таблицу. Заголовки ETag и Last-Modified запоминаем. - -2. Дневник: идемпотентные операции - -Мутирующие команды (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 - -5. Флоу очереди синхронизации (Формат ошибок) - -Ответы с ошибками возвращаются в 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 по этому гайду). \ No newline at end of file