fix(diary): some fixes offline-diary

This commit is contained in:
mso7nueh
2026-05-14 19:52:15 +03:00
parent 63fc824449
commit af3b40d5f2
11 changed files with 114 additions and 228 deletions

View File

@@ -69,6 +69,12 @@ class DiaryDao extends DatabaseAccessor<AppDatabase> with _$DiaryDaoMixin {
await (delete(db.mealsTable)..where((t) => t.diaryDayId.equals(diaryDayId))).go();
}
/// Удалить все порции для конкретного приёма пищи.
/// BUG-6 fix: позволяет атомарно перезаписать порции одного meal.
Future<void> clearPortionsForMeal(int mealId) async {
await (delete(db.portionsTable)..where((t) => t.mealId.equals(mealId))).go();
}
/// Очистить кэш дней дневника старше [ttlDays] дней.
/// Вызывается периодически для экономии места на устройстве.
Future<int> clearExpiredCache({int ttlDays = 14}) {

View File

@@ -115,6 +115,13 @@ class DiaryLocalDataSource {
return _diaryDao.clearMealsForDay(diaryDayId);
}
/// Удалить все порции для конкретного meal.
/// BUG-6 fix: используется для атомарной перезаписи порций одного meal,
/// без удаления данных остальных meals.
Future<void> clearCachedPortions(int mealId) {
return _diaryDao.clearPortionsForMeal(mealId);
}
Future<int> clearExpiredDiaryCache({int ttlDays = 14}) {
return _diaryDao.clearExpiredCache(ttlDays: ttlDays);
}

View File

@@ -156,7 +156,14 @@ class DiaryCubit extends Cubit<DiaryState> {
}
} 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<DiaryState> {
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<DiaryState> {
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,

View File

@@ -159,8 +159,8 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
});
final isFinished = await context.read<DiaryCubit>().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);

View File

@@ -170,10 +170,19 @@ class _AddProductModalState extends State<AddProductModal> {
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<AddProductModal> {
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<AddProductModal> {
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),
),
);
}

View File

@@ -116,7 +116,13 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
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);

View File

@@ -25,6 +25,7 @@ class ProductWidget extends StatefulWidget {
class _ProductWidgetState extends State<ProductWidget> {
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<ProductWidget> {
super.initState();
diaryCubit = context.read<DiaryCubit>();
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<ProductWidget> {
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) {

View File

@@ -23,6 +23,12 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
diaryCubit = context.read<DiaryCubit>();
}
/// Безопасное форматирование времени (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<void>(
context: context,
@@ -110,7 +116,7 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
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,

View File

@@ -26,6 +26,12 @@ class _WakeupAndRetireWidgetState extends State<WakeupAndRetireWidget> {
diaryCubit = context.read<DiaryCubit>();
}
/// 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<void>(
context: context,
@@ -142,10 +148,7 @@ class _WakeupAndRetireWidgetState extends State<WakeupAndRetireWidget> {
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<WakeupAndRetireWidget> {
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,

View File

@@ -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<void> 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<void> 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<void> 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<void> 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<void> uploadPhotos({required int diaryId, required int mealId, required List<File> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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);
}
}

189
tz.md
View File

@@ -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=<iso_datetime>
If-None-Match: "etag_из_предыдущего_ответа"
Сценарий клиента (Флаттер):
При ответе 304 Not Modified — локальный кэш актуален.
При ответе 200 OK — в фоне (в изоляте/через compute) вливаем данные в SQLite таблицу. Заголовки ETag и Last-Modified запоминаем.
2. Дневник: идемпотентные операции
Мутирующие команды (create day, create portion и т.д.) теперь требуют заголовок:
Idempotency-Key: <uuid-v4>
Это позволит безопасно ретраить запросы (например, оборвавшиеся из-за скачка сети), не плодя дубли на сервере.
Сам 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 по этому гайду).