diff --git a/lib/core/di/external_registrations.dart b/lib/core/di/external_registrations.dart index 972a7468..7d99085d 100644 --- a/lib/core/di/external_registrations.dart +++ b/lib/core/di/external_registrations.dart @@ -9,6 +9,7 @@ import 'package:eatvio_app/core/interceptors/token_interceptor.dart'; import 'package:eatvio_app/core/local_db/app_database.dart'; import 'package:eatvio_app/core/local_db/dao/diary_dao.dart'; import 'package:eatvio_app/core/local_db/dao/glossary_dao.dart'; +import 'package:eatvio_app/core/local_db/dao/media_dao.dart'; import 'package:eatvio_app/core/local_db/dao/pending_ops_dao.dart'; import 'package:eatvio_app/core/local_db/dao/sync_meta_dao.dart'; import 'package:eatvio_app/core/local_db/diary_local_data_source.dart'; @@ -53,12 +54,14 @@ class ExternalRegistrations { sl.registerLazySingleton(() => AppDatabase()); sl.registerLazySingleton(() => sl().diaryDao); sl.registerLazySingleton(() => sl().glossaryDao); + sl.registerLazySingleton(() => sl().mediaDao); sl.registerLazySingleton(() => sl().pendingOpsDao); sl.registerLazySingleton(() => sl().syncMetaDao); sl.registerLazySingleton( () => DiaryLocalDataSource( diaryDao: sl(), glossaryDao: sl(), + mediaDao: sl(), pendingOpsDao: sl(), syncMetaDao: sl(), ), diff --git a/lib/core/local_db/app_database.dart b/lib/core/local_db/app_database.dart index 159b599f..52a31cca 100644 --- a/lib/core/local_db/app_database.dart +++ b/lib/core/local_db/app_database.dart @@ -2,11 +2,13 @@ import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; import 'dao/diary_dao.dart'; import 'dao/glossary_dao.dart'; +import 'dao/media_dao.dart'; import 'dao/pending_ops_dao.dart'; import 'dao/sync_meta_dao.dart'; import 'tables/diary_days_table.dart'; import 'tables/glossary_cache_table.dart'; import 'tables/meals_table.dart'; +import 'tables/media_table.dart'; import 'tables/pending_operations_table.dart'; import 'tables/portions_table.dart'; import 'tables/sync_meta_table.dart'; @@ -23,12 +25,14 @@ part 'app_database.g.dart'; GlossaryCacheTable, PendingOperationsTable, SyncMetaTable, + MediaTable, ], daos: [ DiaryDao, GlossaryDao, PendingOpsDao, SyncMetaDao, + MediaDao, ], ) class AppDatabase extends _$AppDatabase { @@ -38,7 +42,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase.forTesting(super.executor); @override - int get schemaVersion => 2; + int get schemaVersion => 3; @override MigrationStrategy get migration => MigrationStrategy( @@ -58,6 +62,10 @@ class AppDatabase extends _$AppDatabase { ); } catch (_) {} } + if (from < 3) { + // Таблица медиа для фото дневника (добавлена в v3) + await m.createTable(mediaTable); + } }, beforeOpen: (details) async { // WAL mode — позволяет параллельные чтение/запись из разных изолятов diff --git a/lib/core/local_db/dao/media_dao.dart b/lib/core/local_db/dao/media_dao.dart new file mode 100644 index 00000000..34afa4db --- /dev/null +++ b/lib/core/local_db/dao/media_dao.dart @@ -0,0 +1,31 @@ +import 'package:drift/drift.dart'; +import 'package:eatvio_app/core/local_db/app_database.dart'; +import 'package:eatvio_app/core/local_db/tables/media_table.dart'; + +part 'media_dao.g.dart'; + +/// DAO для работы с медиа-файлами (фото) приёмов пищи в локальной БД. +@DriftAccessor(tables: [MediaTable]) +class MediaDao extends DatabaseAccessor with _$MediaDaoMixin { + MediaDao(super.db); + + /// Получить все медиа для конкретного meal. + Future> getMediaForMeal(int mealId) { + return (select(db.mediaTable)..where((t) => t.mealId.equals(mealId))).get(); + } + + /// Вставить или обновить медиа-запись. + Future upsertMedia(MediaTableCompanion media) { + return into(db.mediaTable).insertOnConflictUpdate(media); + } + + /// Удалить все медиа для конкретного meal. + Future clearMediaForMeal(int mealId) async { + await (delete(db.mediaTable)..where((t) => t.mealId.equals(mealId))).go(); + } + + /// Удалить конкретную медиа-запись по серверному ID. + Future deleteByServerId(int serverId) async { + await (delete(db.mediaTable)..where((t) => t.serverId.equals(serverId))).go(); + } +} diff --git a/lib/core/local_db/diary_local_data_source.dart b/lib/core/local_db/diary_local_data_source.dart index 4f0c7ca8..7c79dca1 100644 --- a/lib/core/local_db/diary_local_data_source.dart +++ b/lib/core/local_db/diary_local_data_source.dart @@ -3,6 +3,7 @@ import 'package:drift/drift.dart'; import 'package:eatvio_app/core/local_db/app_database.dart'; import 'package:eatvio_app/core/local_db/dao/diary_dao.dart'; import 'package:eatvio_app/core/local_db/dao/glossary_dao.dart'; +import 'package:eatvio_app/core/local_db/dao/media_dao.dart'; import 'package:eatvio_app/core/local_db/dao/pending_ops_dao.dart'; import 'package:eatvio_app/core/local_db/dao/sync_meta_dao.dart'; import 'package:uuid/uuid.dart'; @@ -12,6 +13,7 @@ import 'package:uuid/uuid.dart'; class DiaryLocalDataSource { final DiaryDao _diaryDao; final GlossaryDao _glossaryDao; + final MediaDao _mediaDao; final PendingOpsDao _pendingOpsDao; final SyncMetaDao _syncMetaDao; final _uuid = const Uuid(); @@ -19,10 +21,12 @@ class DiaryLocalDataSource { DiaryLocalDataSource({ required DiaryDao diaryDao, required GlossaryDao glossaryDao, + required MediaDao mediaDao, required PendingOpsDao pendingOpsDao, required SyncMetaDao syncMetaDao, }) : _diaryDao = diaryDao, _glossaryDao = glossaryDao, + _mediaDao = mediaDao, _pendingOpsDao = pendingOpsDao, _syncMetaDao = syncMetaDao; @@ -185,6 +189,33 @@ class DiaryLocalDataSource { ); } + // ─── Медиа (фото дневника) ───────────────────────────────────────────── + + Future> getCachedMedia(int mealId) { + return _mediaDao.getMediaForMeal(mealId); + } + + Future cacheMedia({ + required int mealId, + int? serverId, + String? url, + String? localPath, + String status = 'synced', + }) async { + await _mediaDao.upsertMedia(MediaTableCompanion( + mealId: Value(mealId), + serverId: Value(serverId), + url: Value(url), + localPath: Value(localPath), + status: Value(status), + createdAt: Value(DateTime.now().toIso8601String()), + )); + } + + Future clearCachedMedia(int mealId) { + return _mediaDao.clearMediaForMeal(mealId); + } + // ─── Утилиты ───────────────────────────────────────────────────────────── String _encodePayload(Map payload) { diff --git a/lib/core/local_db/services/diary_sync_runner.dart b/lib/core/local_db/services/diary_sync_runner.dart index 94b7abf1..d6ba1e49 100644 --- a/lib/core/local_db/services/diary_sync_runner.dart +++ b/lib/core/local_db/services/diary_sync_runner.dart @@ -156,6 +156,44 @@ class DiarySyncRunner { portionModel: payload['portion_id'] as int, ); + case 'update_portion': + final data = { + 'product_name': payload['product_name'] as String, + 'portion': payload['portion'] as String, + 'quantity': payload['quantity'] as int, + }; + if (payload['product_nutritional_value_id'] != null) { + data['product_nutritional_value_id'] = payload['product_nutritional_value_id'] as int; + } + data['state'] = payload['state'] as String; + await _diaryService.updateProductInMeal( + diary: payload['diary_id'] as int, + typeNo: payload['meal_id'] as int, + portionModel: payload['portion_id'] as int, + productInMeal: data, + ); + + case 'mark_eaten': + await _diaryService.markEaten( + diary: payload['diary_id'] as int, + typeNo: payload['meal_id'] as int, + portionModel: payload['portion_id'] as int, + ); + + case 'unmark_eaten': + await _diaryService.unmarkEaten( + diary: payload['diary_id'] as int, + typeNo: payload['meal_id'] as int, + portionModel: payload['portion_id'] as int, + ); + + case 'restore': + await _diaryService.restore( + diary: payload['diary_id'] as int, + typeNo: payload['meal_id'] as int, + portionModel: payload['portion_id'] as int, + ); + case 'fill_satiety': await _diaryService.fillSatiety( diary: payload['diary_id'] as int, diff --git a/lib/core/local_db/tables/media_table.dart b/lib/core/local_db/tables/media_table.dart new file mode 100644 index 00000000..ba6d6bf3 --- /dev/null +++ b/lib/core/local_db/tables/media_table.dart @@ -0,0 +1,35 @@ +import 'package:drift/drift.dart'; +import 'meals_table.dart'; + +/// Кэш медиа-файлов (фото) приёмов пищи. +/// Хранит мета-данные фотографий для офлайн-доступа. +/// +/// status: +/// 'synced' — фото загружено на сервер +/// 'pending_upload' — фото создано офлайн, ожидает загрузки +/// 'pending_delete' — фото помечено на удаление, ожидает синхронизации +class MediaTable extends Table { + @override + String get tableName => 'media'; + + IntColumn get id => integer().autoIncrement()(); + + // Ссылка на локальный приём пищи + IntColumn get mealId => integer().named('meal_id') + .references(MealsTable, #id, onDelete: KeyAction.cascade)(); + + // ID записи на сервере (null до синхронизации) + IntColumn get serverId => integer().nullable().named('server_id')(); + + // URL фото на сервере (null для неотправленных) + TextColumn get url => text().nullable()(); + + // Локальный путь к файлу (null для фото только с сервера) + TextColumn get localPath => text().nullable().named('local_path')(); + + // Статус синхронизации: 'synced', 'pending_upload', 'pending_delete' + TextColumn get status => text().withDefault(const Constant('synced'))(); + + // Время создания записи (ISO 8601 string) + TextColumn get createdAt => text().named('created_at')(); +} diff --git a/lib/features/diary/presentation/bloc/diary_cubit.dart b/lib/features/diary/presentation/bloc/diary_cubit.dart index b460c6e5..01ed0eab 100644 --- a/lib/features/diary/presentation/bloc/diary_cubit.dart +++ b/lib/features/diary/presentation/bloc/diary_cubit.dart @@ -174,12 +174,22 @@ class DiaryCubit extends Cubit { _lastFetchWasOffline = true; return result; } catch (_) { - // Кэша нет — попробуем сеть на случай ложного "offline" - log('[getDiary] OFFLINE: no cache, trying network as fallback...'); + // Кэша нет — создаём день локально, чтобы он был видим + log('[getDiary] OFFLINE: no cache, creating day locally...'); + try { + await diaryRepository.createDiaryDay(day: dayStr); + final result = await diaryRepository.getDiaryDayV2(day: dayStr); + log('[getDiary] OFFLINE: local day created and loaded OK'); + _lastFetchWasOffline = true; + return result; + } catch (e2) { + log('[getDiary] OFFLINE: local day creation failed: $e2'); + return null; + } } } - // Онлайн (или fallback после неудачного offline): создаём день + грузим с сервера + // Онлайн: создаём день + грузим с сервера try { log('[getDiary] ONLINE: creating diary day...'); await diaryRepository.createDiaryDay(day: dayStr); diff --git a/lib/features/diary/presentation/bloc/glossary_sync_cubit.dart b/lib/features/diary/presentation/bloc/glossary_sync_cubit.dart index adfba540..0ab138c8 100644 --- a/lib/features/diary/presentation/bloc/glossary_sync_cubit.dart +++ b/lib/features/diary/presentation/bloc/glossary_sync_cubit.dart @@ -29,6 +29,10 @@ class GlossarySyncCubit extends Cubit { _connectionCubit = connectionCubit, super(const GlossarySyncState.idle()); + /// Готов ли глоссарий к использованию (есть ли хотя бы 1 запись в локальном кэше). + bool get isGlossaryReady => _glossaryReady; + bool _glossaryReady = false; + /// Начать прослушивание сети и автозапуск синхронизации. void start() { _connectionSub = _connectionCubit.stream.listen((state) { @@ -56,6 +60,8 @@ class GlossarySyncCubit extends Cubit { onProcessingProgress: _onProcessingProgress, ); + _glossaryReady = true; + if (count == 0) { emit(const GlossarySyncState.upToDate()); } else { @@ -68,10 +74,13 @@ class GlossarySyncCubit extends Cubit { }); } catch (e) { emit(GlossarySyncState.error(message: _humanReadableError(e))); - // Скрыть ошибку через 6 секунд - _hideTimer = Timer(const Duration(seconds: 6), () { - if (!isClosed) emit(const GlossarySyncState.idle()); - }); + // Если глоссарий ещё не синхронизирован — НЕ скрываем ошибку, + // чтобы пользователь мог нажать «Повторить». + if (_glossaryReady) { + _hideTimer = Timer(const Duration(seconds: 6), () { + if (!isClosed) emit(const GlossarySyncState.idle()); + }); + } } finally { _isSyncing = false; } diff --git a/lib/features/diary/presentation/widgets/glossary_sync_banner_widget.dart b/lib/features/diary/presentation/widgets/glossary_sync_banner_widget.dart index db843a58..19dbfdf5 100644 --- a/lib/features/diary/presentation/widgets/glossary_sync_banner_widget.dart +++ b/lib/features/diary/presentation/widgets/glossary_sync_banner_widget.dart @@ -56,6 +56,24 @@ class GlossarySyncBannerWidget extends StatelessWidget { color: const Color(0xFFDC2626), icon: Icons.error_outline_rounded, text: message, + action: TextButton( + onPressed: () => context.read().syncNow(), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text( + 'Повторить', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w700, + decoration: TextDecoration.underline, + decorationColor: Colors.white, + ), + ), + ), ), ); }, @@ -68,6 +86,7 @@ class GlossarySyncBannerWidget extends StatelessWidget { required String text, bool showSpinner = false, double? progress, + Widget? action, }) { return _AnimatedBanner( color: color, @@ -75,6 +94,7 @@ class GlossarySyncBannerWidget extends StatelessWidget { text: text, showSpinner: showSpinner, progress: progress, + action: action, ); } @@ -95,6 +115,7 @@ class _AnimatedBanner extends StatefulWidget { final String text; final bool showSpinner; final double? progress; + final Widget? action; const _AnimatedBanner({ required this.color, @@ -102,6 +123,7 @@ class _AnimatedBanner extends StatefulWidget { required this.text, this.showSpinner = false, this.progress, + this.action, }); @override @@ -182,6 +204,7 @@ class _AnimatedBannerState extends State<_AnimatedBanner> ), ), ), + if (widget.action != null) widget.action!, ], ), ), diff --git a/lib/features/diary/repositories/diary_repository.dart b/lib/features/diary/repositories/diary_repository.dart index 0296c6b8..88aecb0e 100644 --- a/lib/features/diary/repositories/diary_repository.dart +++ b/lib/features/diary/repositories/diary_repository.dart @@ -163,6 +163,8 @@ class DiaryRepository { } + /// Write-Behind: обновить порцию в приёме пищи. + /// Офлайн/сетевая ошибка → кладём в pending_operations. Future updateProductInMeal({ required int diaryId, required int mealId, @@ -173,24 +175,106 @@ class DiaryRepository { int? productNutritionalValueId, required PortionState portionState, }) async { - final data = ProductInMeal(productName: productName, portion: portion, quantity: quantity).toJson(); - if (productNutritionalValueId != null) data['product_nutritional_value_id'] = productNutritionalValueId; - data['state'] = portionState.name; - return diaryService.updateProductInMeal(diary: diaryId, typeNo: mealId, portionModel: portionId, productInMeal: data); + final payload = { + 'diary_id': diaryId, + 'meal_id': mealId, + 'portion_id': portionId, + 'product_name': productName, + 'portion': portion, + 'quantity': quantity, + if (productNutritionalValueId != null) 'product_nutritional_value_id': productNutritionalValueId, + 'state': portionState.name, + }; + + if (!_isOnline) { + await localDataSource.enqueueOperation(type: 'update_portion', payload: payload); + return; + } + + try { + final data = ProductInMeal(productName: productName, portion: portion, quantity: quantity).toJson(); + if (productNutritionalValueId != null) data['product_nutritional_value_id'] = productNutritionalValueId; + data['state'] = portionState.name; + await diaryService.updateProductInMeal(diary: diaryId, typeNo: mealId, portionModel: portionId, productInMeal: data); + } on DioException catch (e, s) { + final statusCode = e.response?.statusCode; + if (statusCode != null && statusCode >= 400 && statusCode < 500) { + await Sentry.captureException(e, stackTrace: s); + rethrow; + } + await Sentry.captureException(e, stackTrace: s); + await localDataSource.enqueueOperation(type: 'update_portion', payload: payload); + } } + /// Write-Behind: отметить порцию как съеденную. Future markEaten({required int diaryId, required int mealId, required int portionId}) async { - return diaryService.markEaten(diary: diaryId, typeNo: mealId, portionModel: portionId); + final payload = {'diary_id': diaryId, 'meal_id': mealId, 'portion_id': portionId}; + + if (!_isOnline) { + await localDataSource.enqueueOperation(type: 'mark_eaten', payload: payload); + return; + } + + try { + await diaryService.markEaten(diary: diaryId, typeNo: mealId, portionModel: portionId); + } on DioException catch (e, s) { + final statusCode = e.response?.statusCode; + if (statusCode != null && statusCode >= 400 && statusCode < 500) { + await Sentry.captureException(e, stackTrace: s); + rethrow; + } + await Sentry.captureException(e, stackTrace: s); + await localDataSource.enqueueOperation(type: 'mark_eaten', payload: payload); + } } + /// Write-Behind: снять отметку "съедено". Future unmarkEaten({required int diaryId, required int mealId, required int portionId}) async { - return diaryService.unmarkEaten(diary: diaryId, typeNo: mealId, portionModel: portionId); + final payload = {'diary_id': diaryId, 'meal_id': mealId, 'portion_id': portionId}; + + if (!_isOnline) { + await localDataSource.enqueueOperation(type: 'unmark_eaten', payload: payload); + return; + } + + try { + await diaryService.unmarkEaten(diary: diaryId, typeNo: mealId, portionModel: portionId); + } on DioException catch (e, s) { + final statusCode = e.response?.statusCode; + if (statusCode != null && statusCode >= 400 && statusCode < 500) { + await Sentry.captureException(e, stackTrace: s); + rethrow; + } + await Sentry.captureException(e, stackTrace: s); + await localDataSource.enqueueOperation(type: 'unmark_eaten', payload: payload); + } } + /// Write-Behind: восстановить удалённую порцию. Future restore({required int diaryId, required int mealId, required int portionId}) async { - return diaryService.restore(diary: diaryId, typeNo: mealId, portionModel: portionId); + final payload = {'diary_id': diaryId, 'meal_id': mealId, 'portion_id': portionId}; + + if (!_isOnline) { + await localDataSource.enqueueOperation(type: 'restore', payload: payload); + return; + } + + try { + await diaryService.restore(diary: diaryId, typeNo: mealId, portionModel: portionId); + } on DioException catch (e, s) { + final statusCode = e.response?.statusCode; + if (statusCode != null && statusCode >= 400 && statusCode < 500) { + await Sentry.captureException(e, stackTrace: s); + rethrow; + } + await Sentry.captureException(e, stackTrace: s); + await localDataSource.enqueueOperation(type: 'restore', payload: payload); + } } + /// Write-Behind: удалить порцию из приёма пищи. + /// Использует idempotent API вариант для предотвращения дубликатов. Future deleteProductFromMeal({required int diaryId, required int mealId, required int portionId}) async { final payload = { 'diary_id': diaryId, @@ -203,8 +287,14 @@ class DiaryRepository { return; } + final idempotencyKey = _uuid.v4(); try { - await diaryService.deleteProductFromMeal(diary: diaryId, typeNo: mealId, portionModel: portionId); + await diaryService.deleteProductFromMealIdempotent( + idempotencyKey: idempotencyKey, + diary: diaryId, + typeNo: mealId, + portionModel: portionId, + ); } on DioException catch (e, s) { final statusCode = e.response?.statusCode; if (statusCode != null && statusCode >= 400 && statusCode < 500) { @@ -216,14 +306,66 @@ class DiaryRepository { } } + /// Write-Behind: загрузить фото приёма пищи. + /// Офлайн → сохраняем файл локально и ставим в очередь. Future uploadPhotos({required int diaryId, required int mealId, required List files}) async { for (var i = 0; i < files.length; i++) { - await diaryService.uploadPhotoOfMeal(diary: diaryId, typeNo: mealId, photo: files[i]); + final file = files[i]; + final payload = {'diary_id': diaryId, 'type_no': mealId}; + + if (!_isOnline) { + await localDataSource.enqueueOperation( + type: 'upload_photo', + payload: payload, + localFilePath: file.path, + ); + continue; + } + + final idempotencyKey = _uuid.v4(); + try { + await diaryService.uploadPhotoIdempotent( + idempotencyKey: idempotencyKey, + diary: diaryId, + typeNo: mealId, + photo: file, + ); + } on DioException catch (e, s) { + final statusCode = e.response?.statusCode; + if (statusCode != null && statusCode >= 400 && statusCode < 500) { + await Sentry.captureException(e, stackTrace: s); + rethrow; + } + await Sentry.captureException(e, stackTrace: s); + await localDataSource.enqueueOperation( + type: 'upload_photo', + payload: payload, + localFilePath: file.path, + ); + } } } + /// Write-Behind: удалить фото приёма пищи. Future deletePhoto({required int diaryId, required int mealId, required DiaryPhotoModel photo}) async { - await diaryService.deletePhotoOfMeal(diaryId: diaryId, typeNo: mealId, userAttachment: photo.id); + final payload = {'diary_id': diaryId, 'type_no': mealId, 'media_id': photo.id}; + + if (!_isOnline) { + await localDataSource.enqueueOperation(type: 'delete_photo', payload: payload); + return; + } + + try { + await diaryService.deletePhotoOfMeal(diaryId: diaryId, typeNo: mealId, userAttachment: photo.id); + } on DioException catch (e, s) { + final statusCode = e.response?.statusCode; + if (statusCode != null && statusCode >= 400 && statusCode < 500) { + await Sentry.captureException(e, stackTrace: s); + rethrow; + } + await Sentry.captureException(e, stackTrace: s); + await localDataSource.enqueueOperation(type: 'delete_photo', payload: payload); + } } Future finishDiaryDay({required int diaryId, required String wakeUp, required String retire, String? comment}) async { @@ -320,16 +462,26 @@ class DiaryRepository { } } + /// Комментарий консультанта — только онлайн. Future commentUserDay({required int diaryId, required ConsultantCommentModel commentModel}) async { + if (!_isOnline) { + throw Exception('Комментирование невозможно без интернета'); + } await diaryService.commentUserDay(id: diaryId, body: commentModel); _diaryForceFetchController.add('force'); } + /// Получить комментарии — только онлайн. Future getDiaryComments({required int planId, required int diaryId}) async { + if (!_isOnline) { + throw Exception('Просмотр комментариев невозможен без интернета'); + } return diaryService.getDiaryComments(planId: planId, diaryId: diaryId); } + /// Отметить просмотр комментария — только онлайн. Future viewCommentOfDiaryDayByConsultant({required int diaryId}) async { + if (!_isOnline) return; // Тихо игнорируем — не критично await diaryService.viewCommentOfDiaryDayByConsultant(diaryId: diaryId); } @@ -370,12 +522,28 @@ class DiaryRepository { } } - /// Write-Behind: создаём день локально + ставим в очередь если онлайн. + /// Write-Behind: создаём день локально (с 5 пустыми meals) + отправляем на сервер. + /// Офлайн → ставим в очередь для повторной отправки. Future createDiaryDay({required String day}) async { final dateOnly = day.substring(0, 10); - // Всегда кэшируем локально + // Всегда кэшируем локально — полную структуру с 5 meals await localDataSource.cacheDiaryDay(date: dateOnly); + // Создаём 5 пустых meals (как делает сервер) + final cachedDay = await localDataSource.getCachedDay(dateOnly); + if (cachedDay != null) { + // Проверяем, есть ли уже meals — если нет, создаём + final existingMeals = await localDataSource.getCachedMeals(cachedDay.id); + if (existingMeals.isEmpty) { + for (int typeNo = 1; typeNo <= 5; typeNo++) { + await localDataSource.cacheMeal( + diaryDayId: cachedDay.id, + typeNo: typeNo, + ); + } + } + } + // Всегда пробуем сеть (connectivity может ошибаться) try { final idempotencyKey = _uuid.v4(); @@ -470,6 +638,16 @@ class DiaryRepository { fromDiet: portion.fromDiet, ); } + + // 5. Кэшируем фото приёма пищи + await localDataSource.clearCachedMedia(cachedMeal.id); + for (final photo in meal.photos) { + await localDataSource.cacheMedia( + mealId: cachedMeal.id, + serverId: photo.id, + url: photo.path, + ); + } } } catch (e) { // Ошибка кэширования не должна ломать основной флоу @@ -477,7 +655,8 @@ class DiaryRepository { } /// Загрузить DiaryDayModel из локального кэша (Fallback). - /// Возвращает null если данных нет или кэш устарел. + /// Возвращает null если данных нет. + /// Для офлайн-дней (serverId == null) использует отрицательный временный ID. Future _loadCachedDiaryDay(String date) async { try { final cached = await localDataSource.getCachedDay(date); @@ -502,6 +681,18 @@ class DiaryRepository { ), )).toList(); + // Загружаем фото из локального кэша + final media = await localDataSource.getCachedMedia(meal.id); + final photoModels = media + .where((m) => m.status != 'pending_delete') + .map((m) => DiaryPhotoModel( + id: m.serverId ?? m.id, + originalName: '', + path: m.url ?? m.localPath ?? '', + size: 0, + )) + .toList(); + mealModels.add(MealModel( id: meal.id, time: meal.time ?? '', @@ -509,16 +700,17 @@ class DiaryRepository { satiety: meal.satiety, mood: meal.mood ?? 0, portions: portionModels, - photos: [], + photos: photoModels, )); } - // Если день создан офлайн (serverId == null), возвращаем null — - // нельзя использовать local id для API-запросов. - if (cached.serverId == null) return null; + // Если день создан офлайн (serverId == null) — используем отрицательный + // временный ID, чтобы отличить от серверных. Операции с таким ID + // автоматически попадут в очередь pending_operations. + final diaryId = cached.serverId ?? -cached.id; return DiaryDayModel( - id: cached.serverId!, + id: diaryId, date: cached.date, wakeup: cached.wakeup, retire: cached.retire, diff --git a/libsqlite3.so b/libsqlite3.so new file mode 120000 index 00000000..30e7b73c --- /dev/null +++ b/libsqlite3.so @@ -0,0 +1 @@ +/usr/lib64/libsqlite3.so.0 \ No newline at end of file diff --git a/test/core/local_db/diary_local_data_source_test.dart b/test/core/local_db/diary_local_data_source_test.dart index 5e3c59f9..4c140055 100644 --- a/test/core/local_db/diary_local_data_source_test.dart +++ b/test/core/local_db/diary_local_data_source_test.dart @@ -3,6 +3,7 @@ import 'package:drift/native.dart'; import 'package:eatvio_app/core/local_db/app_database.dart'; import 'package:eatvio_app/core/local_db/dao/diary_dao.dart'; import 'package:eatvio_app/core/local_db/dao/glossary_dao.dart'; +import 'package:eatvio_app/core/local_db/dao/media_dao.dart'; import 'package:eatvio_app/core/local_db/dao/pending_ops_dao.dart'; import 'package:eatvio_app/core/local_db/dao/sync_meta_dao.dart'; import 'package:eatvio_app/core/local_db/diary_local_data_source.dart'; @@ -17,6 +18,7 @@ void main() { ds = DiaryLocalDataSource( diaryDao: DiaryDao(db), glossaryDao: GlossaryDao(db), + mediaDao: MediaDao(db), pendingOpsDao: PendingOpsDao(db), syncMetaDao: SyncMetaDao(db), ); diff --git a/test/core/local_db/services/diary_sync_runner_test.dart b/test/core/local_db/services/diary_sync_runner_test.dart index bfeffec0..84101bb9 100644 --- a/test/core/local_db/services/diary_sync_runner_test.dart +++ b/test/core/local_db/services/diary_sync_runner_test.dart @@ -6,6 +6,7 @@ import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_st import 'package:eatvio_app/core/local_db/app_database.dart'; import 'package:eatvio_app/core/local_db/dao/diary_dao.dart'; import 'package:eatvio_app/core/local_db/dao/glossary_dao.dart'; +import 'package:eatvio_app/core/local_db/dao/media_dao.dart'; import 'package:eatvio_app/core/local_db/dao/pending_ops_dao.dart'; import 'package:eatvio_app/core/local_db/dao/sync_meta_dao.dart'; import 'package:eatvio_app/core/local_db/diary_local_data_source.dart'; @@ -33,6 +34,7 @@ void main() { localDataSource = DiaryLocalDataSource( diaryDao: DiaryDao(db), glossaryDao: GlossaryDao(db), + mediaDao: MediaDao(db), pendingOpsDao: PendingOpsDao(db), syncMetaDao: SyncMetaDao(db), ); diff --git a/test/features/diary/repositories/diary_repository_offline_operations_test.dart b/test/features/diary/repositories/diary_repository_offline_operations_test.dart index c43c7bc2..483d67fb 100644 --- a/test/features/diary/repositories/diary_repository_offline_operations_test.dart +++ b/test/features/diary/repositories/diary_repository_offline_operations_test.dart @@ -7,6 +7,7 @@ import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_st import 'package:eatvio_app/core/local_db/app_database.dart'; import 'package:eatvio_app/core/local_db/dao/diary_dao.dart'; import 'package:eatvio_app/core/local_db/dao/glossary_dao.dart'; +import 'package:eatvio_app/core/local_db/dao/media_dao.dart'; import 'package:eatvio_app/core/local_db/dao/pending_ops_dao.dart'; import 'package:eatvio_app/core/local_db/dao/sync_meta_dao.dart'; import 'package:eatvio_app/core/local_db/diary_local_data_source.dart'; @@ -50,6 +51,7 @@ void main() { localDataSource = DiaryLocalDataSource( diaryDao: DiaryDao(db), glossaryDao: GlossaryDao(db), + mediaDao: MediaDao(db), pendingOpsDao: PendingOpsDao(db), syncMetaDao: SyncMetaDao(db), ); @@ -86,22 +88,27 @@ void main() { expect(payload['portion_id'], 55); }); - test('онлайн — вызывает сервис напрямую', () async { + test('онлайн — вызывает idempotent сервис напрямую', () async { stubOnline(); - when(mockDiaryService.deleteProductFromMeal( + when(mockDiaryService.deleteProductFromMealIdempotent( + idempotencyKey: anyNamed('idempotencyKey'), diary: 10, typeNo: 2, portionModel: 55, )).thenAnswer((_) async {}); await repository.deleteProductFromMeal(diaryId: 10, mealId: 2, portionId: 55); - verify(mockDiaryService.deleteProductFromMeal(diary: 10, typeNo: 2, portionModel: 55)).called(1); + verify(mockDiaryService.deleteProductFromMealIdempotent( + idempotencyKey: anyNamed('idempotencyKey'), + diary: 10, typeNo: 2, portionModel: 55, + )).called(1); final ops = await localDataSource.getPendingOps(); expect(ops.isEmpty, isTrue); }); test('онлайн 5xx — кладёт в очередь', () async { stubOnline(); - when(mockDiaryService.deleteProductFromMeal( + when(mockDiaryService.deleteProductFromMealIdempotent( + idempotencyKey: anyNamed('idempotencyKey'), diary: anyNamed('diary'), typeNo: anyNamed('typeNo'), portionModel: anyNamed('portionModel'), )).thenThrow(DioException( requestOptions: RequestOptions(), @@ -118,7 +125,8 @@ void main() { test('онлайн 4xx — пробрасывает ошибку, не кладёт в очередь', () async { stubOnline(); - when(mockDiaryService.deleteProductFromMeal( + when(mockDiaryService.deleteProductFromMealIdempotent( + idempotencyKey: anyNamed('idempotencyKey'), diary: anyNamed('diary'), typeNo: anyNamed('typeNo'), portionModel: anyNamed('portionModel'), )).thenThrow(DioException( requestOptions: RequestOptions(), @@ -467,24 +475,16 @@ void main() { // ────────────────────────────────────────────────────────────────────────── group('_loadCachedDiaryDay edge cases', () { - test('возвращает null если serverId == null (офлайн-созданный день)', () async { + test('serverId == null (офлайн-созданный день) — возвращает отрицательный ID', () async { // Кэшируем день без serverId await localDataSource.cacheDiaryDay(date: '2026-05-02'); stubOffline(); - // getDiaryDayV2 должен попробовать загрузить из кэша, но serverId == null → null - // Потом пробует сеть - when(mockDiaryService.getDiaryDayV2(day: anyNamed('day'))) - .thenThrow(DioException( - requestOptions: RequestOptions(), - type: DioExceptionType.connectionError, - )); - - expect( - () => repository.getDiaryDayV2(day: '2026-05-02T00:00:00+00:00'), - throwsA(isA()), - ); + // getDiaryDayV2 должен вернуть день с отрицательным ID + final result = await repository.getDiaryDayV2(day: '2026-05-02T00:00:00+00:00'); + expect(result.id, isNegative); + expect(result.date, '2026-05-02'); }); }); diff --git a/test/features/diary/repositories/diary_repository_offline_test.dart b/test/features/diary/repositories/diary_repository_offline_test.dart index 5aabdf1d..f373d7c5 100644 --- a/test/features/diary/repositories/diary_repository_offline_test.dart +++ b/test/features/diary/repositories/diary_repository_offline_test.dart @@ -8,6 +8,7 @@ import 'package:eatvio_app/core/interceptors/offline_error_interceptor.dart'; import 'package:eatvio_app/core/local_db/app_database.dart'; import 'package:eatvio_app/core/local_db/dao/diary_dao.dart'; import 'package:eatvio_app/core/local_db/dao/glossary_dao.dart'; +import 'package:eatvio_app/core/local_db/dao/media_dao.dart'; import 'package:eatvio_app/core/local_db/dao/pending_ops_dao.dart'; import 'package:eatvio_app/core/local_db/dao/sync_meta_dao.dart'; import 'package:eatvio_app/core/local_db/diary_local_data_source.dart'; @@ -81,6 +82,7 @@ void main() { localDataSource = DiaryLocalDataSource( diaryDao: DiaryDao(db), glossaryDao: GlossaryDao(db), + mediaDao: MediaDao(db), pendingOpsDao: PendingOpsDao(db), syncMetaDao: SyncMetaDao(db), ); diff --git a/test/features/diary/repositories/diary_repository_test.dart b/test/features/diary/repositories/diary_repository_test.dart index 3421f3bc..678de157 100644 --- a/test/features/diary/repositories/diary_repository_test.dart +++ b/test/features/diary/repositories/diary_repository_test.dart @@ -384,7 +384,8 @@ void main() { const mealId = 2; const portionId = 5; - when(mockDiaryService.deleteProductFromMeal( + when(mockDiaryService.deleteProductFromMealIdempotent( + idempotencyKey: anyNamed('idempotencyKey'), diary: diaryId, typeNo: mealId, portionModel: portionId, @@ -396,7 +397,8 @@ void main() { portionId: portionId, ); - verify(mockDiaryService.deleteProductFromMeal( + verify(mockDiaryService.deleteProductFromMealIdempotent( + idempotencyKey: anyNamed('idempotencyKey'), diary: diaryId, typeNo: mealId, portionModel: portionId, @@ -408,7 +410,8 @@ void main() { const mealId = 2; final files = [File('test1.jpg'), File('test2.jpg')]; - when(mockDiaryService.uploadPhotoOfMeal( + when(mockDiaryService.uploadPhotoIdempotent( + idempotencyKey: anyNamed('idempotencyKey'), diary: diaryId, typeNo: mealId, photo: anyNamed('photo'), @@ -420,7 +423,8 @@ void main() { files: files, ); - verify(mockDiaryService.uploadPhotoOfMeal( + verify(mockDiaryService.uploadPhotoIdempotent( + idempotencyKey: anyNamed('idempotencyKey'), diary: diaryId, typeNo: mealId, photo: anyNamed('photo'), diff --git a/tz.md b/tz.md new file mode 100644 index 00000000..dd94969a --- /dev/null +++ b/tz.md @@ -0,0 +1,189 @@ +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