diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html
index 379f47c9..746fadde 100644
--- a/android/build/reports/problems/problems-report.html
+++ b/android/build/reports/problems/problems-report.html
@@ -650,7 +650,7 @@ code + .copy-button {
diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart
index 6f26ab95..97549230 100644
--- a/lib/core/di/cubit_registrations.dart
+++ b/lib/core/di/cubit_registrations.dart
@@ -1,5 +1,6 @@
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart';
import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
+import 'package:eatvio_app/core/local_db/diary_local_data_source.dart';
import 'package:eatvio_app/core/views/cubit/views_cubit.dart';
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart';
import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart';
@@ -126,6 +127,8 @@ class CubitRegistrations {
profileRepository: sl(),
dietRepository: sl(),
settingsRepository: sl(),
+ localDataSource: sl(),
+ connectionCubit: sl(),
),
);
sl.registerFactory(
diff --git a/lib/core/di/external_registrations.dart b/lib/core/di/external_registrations.dart
index 34f93aa6..baf17c1d 100644
--- a/lib/core/di/external_registrations.dart
+++ b/lib/core/di/external_registrations.dart
@@ -1,8 +1,15 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/constants/app_constants.dart';
+import 'package:eatvio_app/core/interceptors/offline_error_interceptor.dart';
import 'package:eatvio_app/core/interceptors/phone_verify_error_interceptor.dart';
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/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';
import 'package:eatvio_app/core/prefs.dart';
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -15,6 +22,7 @@ class ExternalRegistrations {
/// Регистрация всех внешних зависимостей
static void registerAll() {
_registerDio();
+ _registerDatabase();
_registerPrefs();
}
@@ -29,11 +37,28 @@ class ExternalRegistrations {
receiveTimeout: AppConstants.pendingDelay,
sendTimeout: AppConstants.pendingDelay,
),
- )..interceptors.addAll([TokenInterceptor(), PhoneVerifyErrorInterceptor()])
+ )..interceptors.addAll([TokenInterceptor(), PhoneVerifyErrorInterceptor(), OfflineErrorInterceptor()])
..addSentry(),
);
}
+ /// Регистрация базы данных и локальных источников
+ static void _registerDatabase() {
+ sl.registerLazySingleton(() => AppDatabase());
+ sl.registerLazySingleton(() => sl().diaryDao);
+ sl.registerLazySingleton(() => sl().glossaryDao);
+ sl.registerLazySingleton(() => sl().pendingOpsDao);
+ sl.registerLazySingleton(() => sl().syncMetaDao);
+ sl.registerLazySingleton(
+ () => DiaryLocalDataSource(
+ diaryDao: sl(),
+ glossaryDao: sl(),
+ pendingOpsDao: sl(),
+ syncMetaDao: sl(),
+ ),
+ );
+ }
+
/// Регистрация Prefs
static void _registerPrefs() {
sl.registerSingletonAsync(() async => Prefs(await SharedPreferences.getInstance()));
diff --git a/lib/core/di/repository_registrations.dart b/lib/core/di/repository_registrations.dart
index 341691a5..b5850837 100644
--- a/lib/core/di/repository_registrations.dart
+++ b/lib/core/di/repository_registrations.dart
@@ -1,4 +1,10 @@
+import 'package:dio/dio.dart';
import 'package:eatvio_app/core/comments/repositories/comments_repository.dart';
+import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
+import 'package:eatvio_app/core/local_db/diary_local_data_source.dart';
+import 'package:eatvio_app/core/local_db/services/diary_sync_runner.dart';
+import 'package:eatvio_app/core/local_db/services/glossary_sync_runner.dart';
+import 'package:eatvio_app/core/local_db/services/glossary_sync_service.dart';
import 'package:eatvio_app/core/views/repository/views_repository.dart';
import 'package:eatvio_app/features/auth/repositories/auth_repository.dart';
import 'package:eatvio_app/features/auth/repositories/reset_password_repository.dart';
@@ -54,8 +60,37 @@ class RepositoryRegistrations {
sl.registerLazySingleton(() => CalendarRepository(calendarService: sl()));
sl.registerLazySingleton(() => SettingsRepository(settingsService: sl()));
sl.registerLazySingleton(() => ConsultantRepository(consultantService: sl()));
- sl.registerLazySingleton(() => DiaryRepository(diaryService: sl()));
- sl.registerLazySingleton(() => GlossaryRepository(glossaryService: sl()));
+ sl.registerLazySingleton(
+ () => DiaryRepository(
+ diaryService: sl(),
+ localDataSource: sl(),
+ connectionCubit: sl(),
+ ),
+ );
+
+ // Offline-First sync services
+ sl.registerLazySingleton(
+ () => GlossarySyncService(
+ api: sl(),
+ dio: sl(instanceName: 'glossaryDio'),
+ localDataSource: sl(),
+ ),
+ );
+ sl.registerLazySingleton(
+ () => DiarySyncRunner(
+ localDataSource: sl(),
+ diaryService: sl(),
+ connectionCubit: sl(),
+ glossarySyncService: sl(),
+ ),
+ );
+ sl.registerLazySingleton(
+ () => GlossaryRepository(
+ glossaryService: sl(),
+ localDataSource: sl(),
+ connectionCubit: sl(),
+ ),
+ );
sl.registerLazySingleton(() => BarcodeRepository(barcodeService: sl(), dio: sl()));
sl.registerLazySingleton(() => DietRepository(dietsService: sl()));
sl.registerLazySingleton(() => PlansRepository(plansService: sl()));
diff --git a/lib/core/di/service_registrations.dart b/lib/core/di/service_registrations.dart
index 62439f4a..84c4be9f 100644
--- a/lib/core/di/service_registrations.dart
+++ b/lib/core/di/service_registrations.dart
@@ -1,5 +1,11 @@
+import 'package:dio/dio.dart';
+import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/comments/services/comments_service.dart';
import 'package:eatvio_app/core/firebase_api.dart';
+import 'package:eatvio_app/core/interceptors/offline_error_interceptor.dart';
+import 'package:eatvio_app/core/interceptors/token_interceptor.dart';
+import 'package:eatvio_app/core/local_db/services/glossary_sync_service.dart';
+import 'package:sentry_dio/sentry_dio.dart';
import 'package:eatvio_app/core/views/service/views_service.dart';
import 'package:eatvio_app/core/websockets/ws_service.dart';
import 'package:eatvio_app/features/auth/services/auth_service.dart';
@@ -56,6 +62,21 @@ class ServiceRegistrations {
sl.registerLazySingleton(() => SettingsService(sl()));
sl.registerLazySingleton(() => ConsultantService(sl()));
sl.registerLazySingleton(() => DiaryService(sl()));
+ // GlossarySyncApiService: отдельный Dio с увеличенным таймаутом
+ // — дамп глоссария (~200k записей) идёт ~35 секунд, стандартные 30s не хватает
+ // — также используется в GlossarySyncService для dio.download()
+ final glossaryDio = Dio(
+ BaseOptions(
+ baseUrl: sl().baseUrl,
+ headers: {'accept': 'application/json', 'content-type': 'application/json'},
+ connectTimeout: const Duration(seconds: 30),
+ receiveTimeout: const Duration(seconds: 120),
+ sendTimeout: const Duration(seconds: 30),
+ ),
+ )..interceptors.addAll([TokenInterceptor(), OfflineErrorInterceptor()])
+ ..addSentry();
+ sl.registerLazySingleton(() => glossaryDio, instanceName: 'glossaryDio');
+ sl.registerLazySingleton(() => GlossarySyncApiService(glossaryDio));
sl.registerLazySingleton(() => GlossaryService(sl()));
sl.registerLazySingleton(() => BarcodeService(sl()));
sl.registerLazySingleton(() => DietService(sl()));
diff --git a/lib/core/interceptors/offline_error_interceptor.dart b/lib/core/interceptors/offline_error_interceptor.dart
new file mode 100644
index 00000000..a4fddbb4
--- /dev/null
+++ b/lib/core/interceptors/offline_error_interceptor.dart
@@ -0,0 +1,96 @@
+import 'package:dio/dio.dart';
+
+/// Исключение, возникающее при получении структурированного ответа об ошибке от сервера.
+/// [retryable] — если true, операция может быть повторена позже (5xx, таймаут).
+/// Если false — операция провалилась окончательно (4xx, валидация, не найдено).
+class OfflineOperationException implements Exception {
+ final String message;
+ final String? code;
+ final bool retryable;
+
+ const OfflineOperationException({
+ required this.message,
+ this.code,
+ required this.retryable,
+ });
+
+ @override
+ String toString() => 'OfflineOperationException(retryable: $retryable, code: $code, message: $message)';
+}
+
+/// Dio-интерцептор для парсинга Envelope-ошибок от сервера.
+///
+/// Ожидаемый формат ответа при ошибке:
+/// {
+/// "message": "Human readable",
+/// "error": {
+/// "code": "validation_error",
+/// "message": "...",
+/// "retryable": false
+/// }
+/// }
+///
+/// Если поле `error.retryable` отсутствует или сервер вернул 5xx — считаем retryable = true.
+class OfflineErrorInterceptor extends Interceptor {
+ @override
+ void onError(DioException err, ErrorInterceptorHandler handler) {
+ // Таймауты и проблемы соединения — всегда retryable
+ if (err.type == DioExceptionType.connectionTimeout ||
+ err.type == DioExceptionType.receiveTimeout ||
+ err.type == DioExceptionType.sendTimeout ||
+ err.type == DioExceptionType.connectionError) {
+ return handler.next(
+ err.copyWith(
+ error: const OfflineOperationException(
+ message: 'Нет соединения с сервером',
+ code: 'connection_error',
+ retryable: true,
+ ),
+ ),
+ );
+ }
+
+ final response = err.response;
+ if (response == null) {
+ return handler.next(err);
+ }
+
+ // 5xx — серверные ошибки, retryable
+ if (response.statusCode != null && response.statusCode! >= 500) {
+ return handler.next(
+ err.copyWith(
+ error: const OfflineOperationException(
+ message: 'Ошибка сервера',
+ code: 'server_error',
+ retryable: true,
+ ),
+ ),
+ );
+ }
+
+ // 4xx — пробуем распарсить Envelope
+ try {
+ final data = response.data;
+ if (data is Map && data.containsKey('error')) {
+ final errorEnvelope = data['error'] as Map;
+ final retryable = errorEnvelope['retryable'] as bool? ?? false;
+ final code = errorEnvelope['code'] as String?;
+ final message = errorEnvelope['message'] as String? ?? data['message'] as String? ?? 'Ошибка';
+
+ return handler.next(
+ err.copyWith(
+ error: OfflineOperationException(
+ message: message,
+ code: code,
+ retryable: retryable,
+ ),
+ ),
+ );
+ }
+ } catch (_) {
+ // Не смогли распарсить — передаём дальше как есть
+ }
+
+ handler.next(err);
+ }
+}
diff --git a/lib/core/internet_connection_cubit/internet_connection_cubit.dart b/lib/core/internet_connection_cubit/internet_connection_cubit.dart
index 67427c6c..02ca17ae 100644
--- a/lib/core/internet_connection_cubit/internet_connection_cubit.dart
+++ b/lib/core/internet_connection_cubit/internet_connection_cubit.dart
@@ -55,7 +55,7 @@ class InternetConnectionCubit extends Cubit {
final uris = [Uri.parse(EnvConfig.googleCheckUrl), Uri.parse(EnvConfig.yandexCheckUrl), Uri.parse(EnvConfig.devBaseUrl), Uri.parse(EnvConfig.baseUrl)];
for (final uri in uris) {
try {
- final response = await http.get(uri).timeout(const Duration(seconds: 3));
+ final response = await http.get(uri).timeout(const Duration(seconds: 1));
return response.statusCode >= 100 && response.statusCode < 600;
} catch (e) {
continue;
diff --git a/lib/core/local_db/app_database.dart b/lib/core/local_db/app_database.dart
new file mode 100644
index 00000000..159b599f
--- /dev/null
+++ b/lib/core/local_db/app_database.dart
@@ -0,0 +1,76 @@
+import 'package:drift/drift.dart';
+import 'package:drift_flutter/drift_flutter.dart';
+import 'dao/diary_dao.dart';
+import 'dao/glossary_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/pending_operations_table.dart';
+import 'tables/portions_table.dart';
+import 'tables/sync_meta_table.dart';
+
+part 'app_database.g.dart';
+
+/// Главная база данных приложения (Drift/SQLite).
+/// Используется для офлайн-хранения данных дневника и глоссария.
+@DriftDatabase(
+ tables: [
+ DiaryDaysTable,
+ MealsTable,
+ PortionsTable,
+ GlossaryCacheTable,
+ PendingOperationsTable,
+ SyncMetaTable,
+ ],
+ daos: [
+ DiaryDao,
+ GlossaryDao,
+ PendingOpsDao,
+ SyncMetaDao,
+ ],
+)
+class AppDatabase extends _$AppDatabase {
+ AppDatabase() : super(_openConnection());
+
+ /// Конструктор для тестов: принимает произвольный executor (например NativeDatabase.memory()).
+ AppDatabase.forTesting(super.executor);
+
+ @override
+ int get schemaVersion => 2;
+
+ @override
+ MigrationStrategy get migration => MigrationStrategy(
+ onCreate: (Migrator m) async {
+ await m.createAll();
+ },
+ onUpgrade: (Migrator m, int from, int to) async {
+ if (from < 2) {
+ // Новые таблицы для offline-first (добавлены в v2)
+ await m.createTable(pendingOperationsTable);
+ await m.createTable(syncMetaTable);
+ // Добавляем уникальный индекс для meals
+ try {
+ await customStatement(
+ 'CREATE UNIQUE INDEX IF NOT EXISTS meals_unique_day_type '
+ 'ON meals(diary_day_id, type_no)',
+ );
+ } catch (_) {}
+ }
+ },
+ beforeOpen: (details) async {
+ // WAL mode — позволяет параллельные чтение/запись из разных изолятов
+ // (основной + compute для глоссария). Без этого: "database is locked".
+ await customStatement('PRAGMA journal_mode = WAL;');
+ // Если БД занята — ждать до 30 секунд вместо мгновенной ошибки
+ await customStatement('PRAGMA busy_timeout = 30000;');
+ await customStatement('PRAGMA foreign_keys = ON;');
+ },
+ );
+
+ static QueryExecutor _openConnection() {
+ return driftDatabase(name: 'eatvio_offline_db');
+ }
+}
+
diff --git a/lib/core/local_db/dao/diary_dao.dart b/lib/core/local_db/dao/diary_dao.dart
new file mode 100644
index 00000000..5ab58c26
--- /dev/null
+++ b/lib/core/local_db/dao/diary_dao.dart
@@ -0,0 +1,79 @@
+import 'package:drift/drift.dart';
+import 'package:eatvio_app/core/local_db/app_database.dart';
+import 'package:eatvio_app/core/local_db/tables/diary_days_table.dart';
+import 'package:eatvio_app/core/local_db/tables/meals_table.dart';
+import 'package:eatvio_app/core/local_db/tables/portions_table.dart';
+
+part 'diary_dao.g.dart';
+
+/// DAO для работы с дневником питания в локальной БД.
+/// Реализует паттерн Cache-Aside: данные кэшируются после успешного ответа сети.
+@DriftAccessor(tables: [DiaryDaysTable, MealsTable, PortionsTable])
+class DiaryDao extends DatabaseAccessor with _$DiaryDaoMixin {
+ DiaryDao(super.db);
+
+ // ─── Diary Days ────────────────────────────────────────────────────────────
+
+ /// Получить день дневника по дате (формат: YYYY-MM-DD).
+ /// Возвращает null если день не кэширован локально.
+ Future getDayByDate(String date) {
+ return (select(db.diaryDaysTable)..where((t) => t.date.equals(date))).getSingleOrNull();
+ }
+
+ /// Получить все meal для дня дневника.
+ Future> getMealsForDay(int diaryDayId) {
+ return (select(db.mealsTable)..where((t) => t.diaryDayId.equals(diaryDayId))).get();
+ }
+
+ /// Получить все порции для конкретного meal.
+ Future> getPortionsForMeal(int mealId) {
+ return (select(db.portionsTable)..where((t) => t.mealId.equals(mealId))).get();
+ }
+
+ /// Upsert целого дня из сети (вставка или обновление по дате).
+ /// Используем ON CONFLICT(date) т.к. уникальность по дате, а не по autoincrement id.
+ Future upsertDay(DiaryDaysTableCompanion day) {
+ return into(db.diaryDaysTable).insert(
+ day,
+ onConflict: DoUpdate(
+ (old) => day,
+ target: [db.diaryDaysTable.date],
+ ),
+ );
+ }
+
+ /// Upsert приема пищи (вставка или обновление по diaryDayId + typeNo).
+ Future upsertMeal(MealsTableCompanion meal) {
+ return into(db.mealsTable).insert(
+ meal,
+ onConflict: DoUpdate(
+ (old) => meal,
+ target: [db.mealsTable.diaryDayId, db.mealsTable.typeNo],
+ ),
+ );
+ }
+
+ /// Upsert порции (вставка или обновление по id).
+ Future upsertPortion(PortionsTableCompanion portion) {
+ return into(db.portionsTable).insertOnConflictUpdate(portion);
+ }
+
+ /// Обновить локальный serverId дня после успешной синхронизации.
+ Future updateServerId(int localId, int serverId) async {
+ await (update(db.diaryDaysTable)..where((t) => t.id.equals(localId)))
+ .write(DiaryDaysTableCompanion(serverId: Value(serverId)));
+ }
+
+ /// Удалить все приёмы пищи и порции для дня (используется при перезаписи из сети).
+ Future clearMealsForDay(int diaryDayId) async {
+ await (delete(db.mealsTable)..where((t) => t.diaryDayId.equals(diaryDayId))).go();
+ }
+
+ /// Очистить кэш дней дневника старше [ttlDays] дней.
+ /// Вызывается периодически для экономии места на устройстве.
+ Future clearExpiredCache({int ttlDays = 14}) {
+ final cutoffDate = DateTime.now().subtract(Duration(days: ttlDays));
+ final cutoffStr = cutoffDate.toIso8601String().substring(0, 10); // YYYY-MM-DD
+ return (delete(db.diaryDaysTable)..where((t) => t.date.isSmallerThanValue(cutoffStr))).go();
+ }
+}
diff --git a/lib/core/local_db/dao/glossary_dao.dart b/lib/core/local_db/dao/glossary_dao.dart
new file mode 100644
index 00000000..329988e1
--- /dev/null
+++ b/lib/core/local_db/dao/glossary_dao.dart
@@ -0,0 +1,58 @@
+import 'package:drift/drift.dart';
+import 'package:eatvio_app/core/local_db/app_database.dart';
+import 'package:eatvio_app/core/local_db/tables/glossary_cache_table.dart';
+
+part 'glossary_dao.g.dart';
+
+/// DAO для работы с кэшем глоссария продуктов (~200k записей).
+/// Оптимизирован для быстрого bulk-insert при синхронизации.
+@DriftAccessor(tables: [GlossaryCacheTable])
+class GlossaryDao extends DatabaseAccessor with _$GlossaryDaoMixin {
+ GlossaryDao(super.db);
+
+ // ─── Read ──────────────────────────────────────────────────────────────────
+
+ /// Получить продукты по списку ID (для обогащения порций КБЖУ).
+ Future> getByIds(List ids) {
+ return (select(db.glossaryCacheTable)..where((t) => t.id.isIn(ids))).get();
+ }
+
+ /// Поиск по названию (нечёткий, регистронезависимый).
+ Future> search(String query, {int limit = 30}) {
+ return (select(db.glossaryCacheTable)
+ ..where((t) => t.name.like('%$query%'))
+ ..limit(limit))
+ .get();
+ }
+
+ /// Фильтр по группе продуктов.
+ Future> getByGroup(int groupId, {int limit = 50}) {
+ return (select(db.glossaryCacheTable)
+ ..where((t) => t.productGroupId.equals(groupId))
+ ..limit(limit))
+ .get();
+ }
+
+ /// Количество продуктов в кэше (для сверки с /meta total_count).
+ Future getCount() async {
+ final countExpr = db.glossaryCacheTable.id.count();
+ final query = selectOnly(db.glossaryCacheTable)..addColumns([countExpr]);
+ final result = await query.getSingleOrNull();
+ return result?.read(countExpr) ?? 0;
+ }
+
+ // ─── Write ─────────────────────────────────────────────────────────────────
+
+ /// Bulk upsert — вставляет или заменяет по server id при конфликте.
+ /// Вызывается чанками по 500 записей из изолята при синхронизации.
+ Future bulkUpsert(List items) async {
+ await batch((batch) {
+ batch.insertAllOnConflictUpdate(db.glossaryCacheTable, items);
+ });
+ }
+
+ /// Очистить весь кэш глоссария (используется при первом полном дампе).
+ Future clearAll() {
+ return delete(db.glossaryCacheTable).go();
+ }
+}
diff --git a/lib/core/local_db/dao/pending_ops_dao.dart b/lib/core/local_db/dao/pending_ops_dao.dart
new file mode 100644
index 00000000..5103d58e
--- /dev/null
+++ b/lib/core/local_db/dao/pending_ops_dao.dart
@@ -0,0 +1,75 @@
+import 'package:drift/drift.dart';
+import 'package:eatvio_app/core/local_db/app_database.dart';
+import 'package:eatvio_app/core/local_db/tables/pending_operations_table.dart';
+
+part 'pending_ops_dao.g.dart';
+
+/// DAO для управления очередью офлайн-операций (Write-Behind queue).
+///
+/// Жизненный цикл операции:
+/// INSERT (pending) → отправка → markSynced / markFailed / incrementRetry
+@DriftAccessor(tables: [PendingOperationsTable])
+class PendingOpsDao extends DatabaseAccessor with _$PendingOpsDaoMixin {
+ PendingOpsDao(super.db);
+
+ static const _statusPending = 'pending';
+ static const _statusSynced = 'synced';
+ static const _statusFailed = 'failed';
+
+ // ─── Read ──────────────────────────────────────────────────────────────────
+
+ /// Получить все PENDING операции в порядке создания (FIFO).
+ Future> getPendingOps() {
+ return (select(db.pendingOperationsTable)
+ ..where((t) => t.status.equals(_statusPending))
+ ..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
+ .get();
+ }
+
+ /// Получить одну операцию по ID.
+ Future getById(String id) {
+ return (select(db.pendingOperationsTable)..where((t) => t.id.equals(id))).getSingleOrNull();
+ }
+
+ /// Количество PENDING операций (для отображения в UI).
+ Future getPendingCount() async {
+ final countExpr = db.pendingOperationsTable.id.count();
+ final query = selectOnly(db.pendingOperationsTable)
+ ..addColumns([countExpr])
+ ..where(db.pendingOperationsTable.status.equals(_statusPending));
+ final result = await query.getSingleOrNull();
+ return result?.read(countExpr) ?? 0;
+ }
+
+ // ─── Write ─────────────────────────────────────────────────────────────────
+
+ /// Добавить новую операцию в очередь.
+ Future enqueue(PendingOperationsTableCompanion op) {
+ return into(db.pendingOperationsTable).insert(op);
+ }
+
+ /// Пометить операцию как успешно синхронизированную.
+ Future markSynced(String id) async {
+ await (update(db.pendingOperationsTable)..where((t) => t.id.equals(id)))
+ .write(const PendingOperationsTableCompanion(status: Value(_statusSynced)));
+ }
+
+ /// Пометить операцию как ошибочную (retryable: false).
+ Future markFailed(String id) async {
+ await (update(db.pendingOperationsTable)..where((t) => t.id.equals(id)))
+ .write(const PendingOperationsTableCompanion(status: Value(_statusFailed)));
+ }
+
+ /// Увеличить счётчик попыток (retryable: true).
+ Future incrementRetry(String id) async {
+ final op = await getById(id);
+ if (op == null) return;
+ await (update(db.pendingOperationsTable)..where((t) => t.id.equals(id)))
+ .write(PendingOperationsTableCompanion(retryCount: Value(op.retryCount + 1)));
+ }
+
+ /// Удалить все SYNCED операции (очистка истории).
+ Future clearSynced() {
+ return (delete(db.pendingOperationsTable)..where((t) => t.status.equals(_statusSynced))).go();
+ }
+}
diff --git a/lib/core/local_db/dao/sync_meta_dao.dart b/lib/core/local_db/dao/sync_meta_dao.dart
new file mode 100644
index 00000000..27bc0672
--- /dev/null
+++ b/lib/core/local_db/dao/sync_meta_dao.dart
@@ -0,0 +1,46 @@
+import 'package:drift/drift.dart';
+import 'package:eatvio_app/core/local_db/app_database.dart';
+import 'package:eatvio_app/core/local_db/tables/sync_meta_table.dart';
+
+part 'sync_meta_dao.g.dart';
+
+/// DAO для хранения мета-данных синхронизации глоссария.
+/// Реализует key-value хранилище поверх sync_meta таблицы.
+@DriftAccessor(tables: [SyncMetaTable])
+class SyncMetaDao extends DatabaseAccessor with _$SyncMetaDaoMixin {
+ SyncMetaDao(super.db);
+
+ // Предопределённые ключи
+ static const keyGlossaryChecksum = 'glossary_checksum';
+ static const keyGlossaryEtag = 'glossary_etag';
+ static const keyGlossaryLastDumpAt = 'glossary_last_dump_at';
+
+ // ─── Generic key-value ────────────────────────────────────────────────────
+
+ Future getValue(String key) async {
+ final row = await (select(db.syncMetaTable)..where((t) => t.key.equals(key))).getSingleOrNull();
+ return row?.value;
+ }
+
+ Future setValue(String key, String value) {
+ return into(db.syncMetaTable).insertOnConflictUpdate(
+ SyncMetaTableCompanion(key: Value(key), value: Value(value)),
+ );
+ }
+
+ // ─── Typed accessors ──────────────────────────────────────────────────────
+
+ Future getGlossaryChecksum() => getValue(keyGlossaryChecksum);
+ Future getGlossaryEtag() => getValue(keyGlossaryEtag);
+ Future getGlossaryLastDumpAt() => getValue(keyGlossaryLastDumpAt);
+
+ Future saveGlossaryMeta({
+ required String checksum,
+ required String? etag,
+ required String lastDumpAt,
+ }) async {
+ await setValue(keyGlossaryChecksum, checksum);
+ await setValue(keyGlossaryLastDumpAt, lastDumpAt);
+ if (etag != null) await setValue(keyGlossaryEtag, etag);
+ }
+}
diff --git a/lib/core/local_db/diary_local_data_source.dart b/lib/core/local_db/diary_local_data_source.dart
new file mode 100644
index 00000000..46560b6e
--- /dev/null
+++ b/lib/core/local_db/diary_local_data_source.dart
@@ -0,0 +1,206 @@
+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/pending_ops_dao.dart';
+import 'package:eatvio_app/core/local_db/dao/sync_meta_dao.dart';
+import 'package:uuid/uuid.dart';
+
+/// Единый фасад над всеми DAO для использования в DiaryRepository.
+/// Изолирует детали реализации Drift от бизнес-логики репозитория.
+class DiaryLocalDataSource {
+ final DiaryDao _diaryDao;
+ final GlossaryDao _glossaryDao;
+ final PendingOpsDao _pendingOpsDao;
+ final SyncMetaDao _syncMetaDao;
+ final _uuid = const Uuid();
+
+ DiaryLocalDataSource({
+ required DiaryDao diaryDao,
+ required GlossaryDao glossaryDao,
+ required PendingOpsDao pendingOpsDao,
+ required SyncMetaDao syncMetaDao,
+ }) : _diaryDao = diaryDao,
+ _glossaryDao = glossaryDao,
+ _pendingOpsDao = pendingOpsDao,
+ _syncMetaDao = syncMetaDao;
+
+ // ─── Дневник (чтение) ────────────────────────────────────────────────────
+
+ Future getCachedDay(String date) {
+ return _diaryDao.getDayByDate(date);
+ }
+
+ Future> getCachedMeals(int diaryDayId) {
+ return _diaryDao.getMealsForDay(diaryDayId);
+ }
+
+ Future> getCachedPortions(int mealId) {
+ return _diaryDao.getPortionsForMeal(mealId);
+ }
+
+ // ─── Дневник (запись/кэширование) ────────────────────────────────────────
+
+ Future cacheDiaryDay({
+ required String date,
+ int? serverId,
+ String wakeup = '06:30',
+ String retire = '22:30',
+ int closed = 0,
+ String comment = '',
+ String state = 'init',
+ bool hasChosenDiet = false,
+ bool hasChosenPlan = false,
+ String? serverUpdatedAt,
+ }) async {
+ await _diaryDao.upsertDay(DiaryDaysTableCompanion(
+ date: Value(date),
+ serverId: Value(serverId),
+ wakeup: Value(wakeup),
+ retire: Value(retire),
+ closed: Value(closed),
+ comment: Value(comment),
+ state: Value(state),
+ hasChosenDiet: Value(hasChosenDiet),
+ hasChosenPlan: Value(hasChosenPlan),
+ serverUpdatedAt: Value(serverUpdatedAt),
+ cachedAt: Value(DateTime.now().toIso8601String()),
+ ));
+ }
+
+ Future cacheMeal({
+ required int diaryDayId,
+ required int typeNo,
+ String? time,
+ int satiety = 0,
+ int? mood,
+ }) async {
+ await _diaryDao.upsertMeal(MealsTableCompanion(
+ diaryDayId: Value(diaryDayId),
+ typeNo: Value(typeNo),
+ time: Value(time),
+ satiety: Value(satiety),
+ mood: Value(mood),
+ ));
+ }
+
+ Future cachePortion({
+ required int mealId,
+ required String productName,
+ required String portion,
+ required int quantity,
+ int? productNutritionalValueId,
+ String state = 'eaten',
+ bool fromDiet = false,
+ }) async {
+ await _diaryDao.upsertPortion(PortionsTableCompanion(
+ mealId: Value(mealId),
+ productName: Value(productName),
+ portion: Value(portion),
+ quantity: Value(quantity),
+ productNutritionalValueId: Value(productNutritionalValueId),
+ state: Value(state),
+ fromDiet: Value(fromDiet),
+ ));
+ }
+
+ /// Удалить все приёмы пищи (и каскадно порции) для дня.
+ /// Вызывается перед кэшированием свежих данных с сервера.
+ Future clearCachedMeals(int diaryDayId) {
+ return _diaryDao.clearMealsForDay(diaryDayId);
+ }
+
+ Future clearExpiredDiaryCache({int ttlDays = 14}) {
+ return _diaryDao.clearExpiredCache(ttlDays: ttlDays);
+ }
+
+ // ─── Глоссарий ────────────────────────────────────────────────────────────
+
+ Future> getGlossaryByIds(List ids) {
+ return _glossaryDao.getByIds(ids);
+ }
+
+ Future> searchGlossary(String query) {
+ return _glossaryDao.search(query);
+ }
+
+ Future> getGlossaryByGroup(int groupId) {
+ return _glossaryDao.getByGroup(groupId);
+ }
+
+ Future getGlossaryCount() => _glossaryDao.getCount();
+
+ Future bulkUpsertGlossary(List items) {
+ return _glossaryDao.bulkUpsert(items);
+ }
+
+ // ─── Очередь операций ────────────────────────────────────────────────────
+
+ /// Добавить операцию в очередь. Возвращает сгенерированный idempotency key (UUID v4).
+ Future enqueueOperation({
+ required String type,
+ required Map payload,
+ String? localFilePath,
+ }) async {
+ final id = _uuid.v4();
+ await _pendingOpsDao.enqueue(PendingOperationsTableCompanion(
+ id: Value(id),
+ type: Value(type),
+ payload: Value(_encodePayload(payload)),
+ status: const Value('pending'),
+ retryCount: const Value(0),
+ localFilePath: Value(localFilePath),
+ createdAt: Value(DateTime.now().toIso8601String()),
+ ));
+ return id; // Этот UUID используется как Idempotency-Key при отправке
+ }
+
+ Future> getPendingOps() {
+ return _pendingOpsDao.getPendingOps();
+ }
+
+ Future getPendingCount() => _pendingOpsDao.getPendingCount();
+
+ Future markSynced(String id) => _pendingOpsDao.markSynced(id);
+ Future markFailed(String id) => _pendingOpsDao.markFailed(id);
+ Future incrementRetry(String id) => _pendingOpsDao.incrementRetry(id);
+ Future clearSyncedOps() => _pendingOpsDao.clearSynced();
+
+ // ─── Мета-данные синхронизации ────────────────────────────────────────────
+
+ Future getGlossaryChecksum() => _syncMetaDao.getGlossaryChecksum();
+ Future getGlossaryEtag() => _syncMetaDao.getGlossaryEtag();
+ Future getGlossaryLastDumpAt() => _syncMetaDao.getGlossaryLastDumpAt();
+
+ Future saveGlossaryMeta({
+ required String checksum,
+ String? etag,
+ required String lastDumpAt,
+ }) {
+ return _syncMetaDao.saveGlossaryMeta(
+ checksum: checksum,
+ etag: etag,
+ lastDumpAt: lastDumpAt,
+ );
+ }
+
+ // ─── Утилиты ─────────────────────────────────────────────────────────────
+
+ String _encodePayload(Map payload) {
+ // Простая JSON-сериализация без зависимости от dart:convert
+ return _mapToJsonString(payload);
+ }
+
+ String _mapToJsonString(Map map) {
+ final entries = map.entries.map((e) {
+ final key = '"${e.key}"';
+ final value = e.value == null
+ ? 'null'
+ : e.value is String
+ ? '"${e.value}"'
+ : e.value.toString();
+ return '$key:$value';
+ });
+ return '{${entries.join(',')}}';
+ }
+}
diff --git a/lib/core/local_db/services/diary_sync_runner.dart b/lib/core/local_db/services/diary_sync_runner.dart
new file mode 100644
index 00000000..9b362d8f
--- /dev/null
+++ b/lib/core/local_db/services/diary_sync_runner.dart
@@ -0,0 +1,224 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+import 'package:eatvio_app/core/interceptors/offline_error_interceptor.dart';
+import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
+import 'package:eatvio_app/core/local_db/app_database.dart';
+import 'package:eatvio_app/core/local_db/diary_local_data_source.dart';
+import 'package:eatvio_app/core/local_db/services/glossary_sync_runner.dart';
+import 'package:eatvio_app/features/diary/models/create_day_model.dart';
+import 'package:eatvio_app/features/diary/services/diary_service.dart';
+import 'package:flutter/foundation.dart';
+
+/// FIFO-воркер синхронизации дневника.
+///
+/// Подписывается на [InternetConnectionCubit].
+/// При переходе состояния в online запускает обработку очереди [pending_operations].
+///
+/// Обработка ошибок:
+/// - [OfflineOperationException] с retryable=false → markFailed (показываем ошибку в UI)
+/// - [OfflineOperationException] с retryable=true → incrementRetry + exponential backoff
+/// - DioException / ConnectionError → оставляем PENDING
+class DiarySyncRunner {
+ final DiaryLocalDataSource _localDataSource;
+ final DiaryService _diaryService;
+ final InternetConnectionCubit _connectionCubit;
+ final GlossarySyncService _glossarySyncService;
+
+ StreamSubscription? _subscription;
+ bool _isSyncing = false;
+
+ static const int _maxRetries = 5;
+
+ DiarySyncRunner({
+ required DiaryLocalDataSource localDataSource,
+ required DiaryService diaryService,
+ required InternetConnectionCubit connectionCubit,
+ required GlossarySyncService glossarySyncService,
+ }) : _localDataSource = localDataSource,
+ _diaryService = diaryService,
+ _connectionCubit = connectionCubit,
+ _glossarySyncService = glossarySyncService;
+
+ /// Начать прослушивание изменений сети.
+ void start() {
+ _subscription = _connectionCubit.stream.listen((state) {
+ if (state.isInternetAvailable && !_isSyncing) {
+ _processQueue();
+ _syncGlossary();
+ }
+ });
+
+ // Запустить немедленно если уже online
+ if (_connectionCubit.state.isInternetAvailable) {
+ _processQueue();
+ _syncGlossary();
+ }
+ }
+
+ /// Синхронизировать глоссарий продуктов (checksum → dump → bulk insert).
+ Future _syncGlossary() async {
+ try {
+ await _glossarySyncService.sync();
+ } catch (e) {
+ debugPrint('[DiarySyncRunner] Glossary sync error: $e');
+ }
+ }
+
+ /// Остановить прослушивание.
+ void dispose() {
+ _subscription?.cancel();
+ }
+
+ /// Обработать очередь pending_operations в FIFO порядке.
+ Future _processQueue() async {
+ if (_isSyncing) return;
+ _isSyncing = true;
+
+ try {
+ final ops = await _localDataSource.getPendingOps();
+ if (ops.isEmpty) return;
+
+ debugPrint('[DiarySyncRunner] Обрабатываю ${ops.length} операций...');
+
+ for (final op in ops) {
+ if (!_connectionCubit.state.isInternetAvailable) break;
+ if (op.retryCount >= _maxRetries) {
+ await _localDataSource.markFailed(op.id);
+ continue;
+ }
+
+ await _processOperation(op);
+
+ // Небольшая пауза между операциями
+ await Future.delayed(const Duration(milliseconds: 200));
+ }
+ } finally {
+ _isSyncing = false;
+ // Очистить синхронизированные операции
+ await _localDataSource.clearSyncedOps();
+ }
+ }
+
+ /// Обработать одну операцию из очереди.
+ Future _processOperation(PendingOperationsTableData op) async {
+ try {
+ await _executeOperation(op);
+ await _localDataSource.markSynced(op.id);
+ debugPrint('[DiarySyncRunner] ✅ Операция ${op.type} синхронизирована');
+ } on OfflineOperationException catch (e) {
+ debugPrint('[DiarySyncRunner] OfflineException: retryable=${e.retryable}, code=${e.code}');
+ if (e.retryable) {
+ await _localDataSource.incrementRetry(op.id);
+ // Exponential backoff: ждём 2^retryCount секунды
+ final delay = Duration(seconds: _pow2(op.retryCount).clamp(1, 30));
+ await Future.delayed(delay);
+ } else {
+ // Не retryable — помечаем как FAILED навсегда
+ await _localDataSource.markFailed(op.id);
+ }
+ } catch (e) {
+ // Необработанная ошибка — считаем retryable
+ debugPrint('[DiarySyncRunner] Unknow error: $e');
+ await _localDataSource.incrementRetry(op.id);
+ }
+ }
+
+ /// Выполнить конкретную операцию на основе её типа.
+ Future _executeOperation(PendingOperationsTableData op) async {
+ final payload = jsonDecode(op.payload) as Map;
+
+ switch (op.type) {
+ case 'create_day':
+ await _diaryService.createDay(
+ idempotencyKey: op.id,
+ body: CreateDayModel(day: payload['day'] as String),
+ );
+
+ case 'create_portion':
+ await _diaryService.addProductToMealIdempotent(
+ idempotencyKey: op.id,
+ diary: payload['diary_id'] as int,
+ typeNo: payload['meal_id'] as int,
+ productName: payload['product_name'] as String,
+ portion: payload['portion'] as String,
+ quantity: payload['quantity'] as int,
+ productNutritionalValueId: payload['product_nutritional_value_id'] as int?,
+ );
+
+ case 'delete_portion':
+ await _diaryService.deleteProductFromMealIdempotent(
+ idempotencyKey: op.id,
+ 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,
+ typeNo: payload['type_no'] as int,
+ satiety: payload['satiety'] as int,
+ );
+
+ case 'fill_mood':
+ await _diaryService.fillMood(
+ diary: payload['diary_id'] as int,
+ typeNo: payload['type_no'] as int,
+ mood: payload['mood'] as int,
+ );
+
+ case 'fill_time':
+ await _diaryService.fillTime(
+ diary: payload['diary_id'] as int,
+ typeNo: payload['type_no'] as int,
+ time: payload['time'] as String,
+ );
+
+ case 'fill_wakeup':
+ await _diaryService.fillWakeup(
+ diary: payload['diary_id'] as int,
+ wakeUp: payload['wakeup'] as String,
+ );
+
+ case 'fill_retire':
+ await _diaryService.fillRetire(
+ diary: payload['diary_id'] as int,
+ retire: payload['retire'] as String,
+ );
+
+ case 'upload_photo':
+ // Для фото: сначала загружаем файл с диска
+ if (op.localFilePath != null) {
+ final file = File(op.localFilePath!);
+ if (await file.exists()) {
+ await _diaryService.uploadPhotoIdempotent(
+ idempotencyKey: op.id,
+ diary: payload['diary_id'] as int,
+ typeNo: payload['type_no'] as int,
+ photo: file,
+ );
+ } else {
+ // Файл не найден — не можем загрузить, помечаем failed
+ throw const OfflineOperationException(
+ message: 'Файл фото не найден на устройстве',
+ code: 'file_not_found',
+ retryable: false,
+ );
+ }
+ }
+
+ case 'delete_photo':
+ await _diaryService.deletePhotoOfMeal(
+ diaryId: payload['diary_id'] as int,
+ typeNo: payload['type_no'] as int,
+ userAttachment: payload['media_id'] as int,
+ );
+
+ default:
+ debugPrint('[DiarySyncRunner] Неизвестный тип операции: ${op.type}');
+ }
+ }
+
+ int _pow2(int n) => n <= 0 ? 1 : 1 << n;
+}
diff --git a/lib/core/local_db/services/glossary_dump_response.dart b/lib/core/local_db/services/glossary_dump_response.dart
new file mode 100644
index 00000000..58ea340e
--- /dev/null
+++ b/lib/core/local_db/services/glossary_dump_response.dart
@@ -0,0 +1,84 @@
+import 'package:json_annotation/json_annotation.dart';
+
+part 'glossary_dump_response.g.dart';
+
+/// Один продукт из дампа глоссария.
+@JsonSerializable()
+class GlossaryDumpItem {
+ final int id;
+ final String name;
+
+ @JsonKey(name: 'product_group_id')
+ final int productGroupId;
+
+ @JsonKey(name: 'energy_on_100gr')
+ final double energyOn100gr;
+
+ @JsonKey(name: 'proteins_on_100gr')
+ final double proteinsOn100gr;
+
+ @JsonKey(name: 'fats_on_100gr')
+ final double fatsOn100gr;
+
+ @JsonKey(name: 'carbohydrates_on_100gr')
+ final double carbohydratesOn100gr;
+
+ @JsonKey(name: 'portion_mass_gr', defaultValue: 0.0)
+ final double portionMassGr;
+
+ @JsonKey(name: 'energy_on_portion', defaultValue: 0.0)
+ final double energyOnPortion;
+
+ @JsonKey(name: 'proteins_on_portion', defaultValue: 0.0)
+ final double proteinsOnPortion;
+
+ @JsonKey(name: 'fats_on_portion', defaultValue: 0.0)
+ final double fatsOnPortion;
+
+ @JsonKey(name: 'carbohydrates_on_portion', defaultValue: 0.0)
+ final double carbohydratesOnPortion;
+
+ @JsonKey(name: 'updated_at')
+ final String updatedAt;
+
+ const GlossaryDumpItem({
+ required this.id,
+ required this.name,
+ required this.productGroupId,
+ required this.energyOn100gr,
+ required this.proteinsOn100gr,
+ required this.fatsOn100gr,
+ required this.carbohydratesOn100gr,
+ this.portionMassGr = 0.0,
+ this.energyOnPortion = 0.0,
+ this.proteinsOnPortion = 0.0,
+ this.fatsOnPortion = 0.0,
+ this.carbohydratesOnPortion = 0.0,
+ required this.updatedAt,
+ });
+
+ factory GlossaryDumpItem.fromJson(Map json) =>
+ _$GlossaryDumpItemFromJson(json);
+ Map toJson() => _$GlossaryDumpItemToJson(this);
+}
+
+/// Ответ /api/glossaries/dump
+@JsonSerializable()
+class GlossaryDumpResponse {
+ final int total;
+
+ @JsonKey(name: 'updated_at')
+ final String updatedAt;
+
+ final List data;
+
+ const GlossaryDumpResponse({
+ required this.total,
+ required this.updatedAt,
+ required this.data,
+ });
+
+ factory GlossaryDumpResponse.fromJson(Map json) =>
+ _$GlossaryDumpResponseFromJson(json);
+ Map toJson() => _$GlossaryDumpResponseToJson(this);
+}
diff --git a/lib/core/local_db/services/glossary_meta_model.dart b/lib/core/local_db/services/glossary_meta_model.dart
new file mode 100644
index 00000000..d17b7a2b
--- /dev/null
+++ b/lib/core/local_db/services/glossary_meta_model.dart
@@ -0,0 +1,25 @@
+import 'package:json_annotation/json_annotation.dart';
+
+part 'glossary_meta_model.g.dart';
+
+/// Ответ /api/glossaries/meta — лёгкая проверка версии данных.
+@JsonSerializable()
+class GlossaryMetaModel {
+ @JsonKey(name: 'total_count')
+ final int totalCount;
+
+ @JsonKey(name: 'last_updated_at')
+ final String lastUpdatedAt;
+
+ final String checksum;
+
+ const GlossaryMetaModel({
+ required this.totalCount,
+ required this.lastUpdatedAt,
+ required this.checksum,
+ });
+
+ factory GlossaryMetaModel.fromJson(Map json) =>
+ _$GlossaryMetaModelFromJson(json);
+ Map toJson() => _$GlossaryMetaModelToJson(this);
+}
diff --git a/lib/core/local_db/services/glossary_sync_runner.dart b/lib/core/local_db/services/glossary_sync_runner.dart
new file mode 100644
index 00000000..48326678
--- /dev/null
+++ b/lib/core/local_db/services/glossary_sync_runner.dart
@@ -0,0 +1,165 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:dio/dio.dart';
+import 'package:drift/drift.dart';
+import 'package:eatvio_app/core/local_db/diary_local_data_source.dart';
+import 'package:eatvio_app/core/local_db/services/glossary_sync_service.dart';
+import 'package:eatvio_app/core/local_db/app_database.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:path_provider/path_provider.dart';
+
+/// Сервис синхронизации глоссария продуктов.
+///
+/// Стратегия:
+/// 1. GET /api/glossaries/meta → сравнить checksum с локальным
+/// 2. Если отличается → скачать /api/glossaries/dump в файл (без OOM)
+/// 3. Парсинг + bulk-insert данных через изолят (compute)
+/// 4. Сохранить новые meta-данные в sync_meta
+class GlossarySyncService {
+ final GlossarySyncApiService _api;
+ final Dio _dio;
+ final DiaryLocalDataSource _localDataSource;
+
+ GlossarySyncService({
+ required GlossarySyncApiService api,
+ required Dio dio,
+ required DiaryLocalDataSource localDataSource,
+ }) : _api = api,
+ _dio = dio,
+ _localDataSource = localDataSource;
+
+ /// Запустить проверку и синхронизацию глоссария.
+ /// Возвращает количество обновлённых записей (0 если кэш актуален).
+ Future sync() async {
+ try {
+ // Шаг 1: Получить мета-данные (лёгкий запрос ~100 байт)
+ final meta = await _api.getMeta();
+
+ // Шаг 2: Сравнить checksum
+ final localChecksum = await _localDataSource.getGlossaryChecksum();
+ if (localChecksum == meta.checksum) {
+ debugPrint('[GlossarySyncService] Глоссарий актуален (checksum совпадает)');
+ return 0;
+ }
+
+ debugPrint('[GlossarySyncService] Checksum изменился, загружаем обновления...');
+
+ // Шаг 3: Получить локальные метаданные для инкрементального обновления
+ final lastDumpAt = await _localDataSource.getGlossaryLastDumpAt();
+ final etag = await _localDataSource.getGlossaryEtag();
+
+ // Шаг 4: Скачать дамп в ФАЙЛ (не в память!) — избегаем OOM на ~200k записей
+ final tempDir = await getTemporaryDirectory();
+ final dumpFile = File('${tempDir.path}/glossary_dump.json');
+
+ final updatedAfter = lastDumpAt ?? '2000-01-01T00:00:00Z';
+ await _dio.download(
+ '/api/glossaries/dump',
+ dumpFile.path,
+ queryParameters: {'updated_after': updatedAfter},
+ options: Options(
+ headers: {
+ if (etag != null) 'If-None-Match': etag,
+ },
+ ),
+ );
+
+ debugPrint('[GlossarySyncService] Дамп скачан в ${dumpFile.path}');
+
+ // Шаг 5: Парсинг файла + bulk-insert в изоляте
+ // Передаём RootIsolateToken чтобы изолят мог использовать platform channels (drift)
+ final token = RootIsolateToken.instance!;
+ final result = await compute(
+ _parseAndInsertInIsolate,
+ _IsolateArgs(filePath: dumpFile.path, token: token),
+ );
+
+ // Шаг 6: Сохранить актуальные meta-данные
+ await _localDataSource.saveGlossaryMeta(
+ checksum: meta.checksum,
+ etag: etag,
+ lastDumpAt: result.updatedAt ?? updatedAfter,
+ );
+
+ // Очистка
+ if (await dumpFile.exists()) {
+ await dumpFile.delete();
+ }
+
+ debugPrint('[GlossarySyncService] Синхронизировано ${result.count} продуктов');
+ return result.count;
+ } catch (e) {
+ debugPrint('[GlossarySyncService] Ошибка синхронизации: $e');
+ rethrow;
+ }
+ }
+
+ /// Статический метод для compute() — выполняется в отдельном изоляте.
+ /// Парсит JSON файл, конвертирует в companions и вставляет чанками.
+ static Future<_IsolateResult> _parseAndInsertInIsolate(_IsolateArgs args) async {
+ // Инициализируем platform channels в изоляте (нужно для drift_flutter)
+ BackgroundIsolateBinaryMessenger.ensureInitialized(args.token);
+
+ final content = File(args.filePath).readAsStringSync();
+ final json = jsonDecode(content) as Map;
+ final dataList = json['data'] as List;
+ final updatedAt = json['updated_at']?.toString();
+
+ if (dataList.isEmpty) return _IsolateResult(count: 0, updatedAt: updatedAt);
+
+ final db = AppDatabase();
+ try {
+ const chunkSize = 500;
+ int total = 0;
+
+ for (var i = 0; i < dataList.length; i += chunkSize) {
+ final end = (i + chunkSize > dataList.length) ? dataList.length : i + chunkSize;
+ final chunk = dataList.sublist(i, end);
+
+ final companions = chunk.map((item) {
+ final m = item as Map;
+ return GlossaryCacheTableCompanion(
+ id: Value(m['id'] as int),
+ name: Value((m['name'] as String?) ?? ''),
+ productGroupId: Value((m['product_group_id'] as int?) ?? 0),
+ energyOn100gr: Value((m['energy_on_100gr'] as num?)?.toDouble() ?? 0.0),
+ proteinsOn100gr: Value((m['proteins_on_100gr'] as num?)?.toDouble() ?? 0.0),
+ fatsOn100gr: Value((m['fats_on_100gr'] as num?)?.toDouble() ?? 0.0),
+ carbohydratesOn100gr: Value((m['carbohydrates_on_100gr'] as num?)?.toDouble() ?? 0.0),
+ portionMassGr: Value((m['portion_mass_gr'] as num?)?.toDouble() ?? 0.0),
+ energyOnPortion: Value((m['energy_on_portion'] as num?)?.toDouble() ?? 0.0),
+ proteinsOnPortion: Value((m['proteins_on_portion'] as num?)?.toDouble() ?? 0.0),
+ fatsOnPortion: Value((m['fats_on_portion'] as num?)?.toDouble() ?? 0.0),
+ carbohydratesOnPortion: Value((m['carbohydrates_on_portion'] as num?)?.toDouble() ?? 0.0),
+ updatedAt: Value(m['updated_at']?.toString() ?? ''),
+ );
+ }).toList();
+
+ await db.glossaryDao.bulkUpsert(companions);
+ total += companions.length;
+ }
+
+ return _IsolateResult(count: total, updatedAt: updatedAt);
+ } finally {
+ await db.close();
+ }
+ }
+}
+
+/// Аргументы для передачи в compute() изолят.
+class _IsolateArgs {
+ final String filePath;
+ final RootIsolateToken token;
+ const _IsolateArgs({required this.filePath, required this.token});
+}
+
+/// Результат работы изолята.
+class _IsolateResult {
+ final int count;
+ final String? updatedAt;
+ const _IsolateResult({required this.count, this.updatedAt});
+}
+
diff --git a/lib/core/local_db/services/glossary_sync_service.dart b/lib/core/local_db/services/glossary_sync_service.dart
new file mode 100644
index 00000000..9f0c82e5
--- /dev/null
+++ b/lib/core/local_db/services/glossary_sync_service.dart
@@ -0,0 +1,27 @@
+import 'package:dio/dio.dart';
+import 'package:eatvio_app/core/local_db/services/glossary_meta_model.dart';
+import 'package:eatvio_app/core/local_db/services/glossary_dump_response.dart';
+import 'package:retrofit/retrofit.dart';
+
+part 'glossary_sync_service.g.dart';
+
+/// Retrofit-сервис для синхронизации глоссария продуктов.
+/// Использует новые offline-first эндпоинты бэкенда.
+@RestApi()
+abstract class GlossarySyncApiService {
+ factory GlossarySyncApiService(Dio dio, {String? baseUrl}) = _GlossarySyncApiService;
+
+ /// Получить метаданные глоссария для проверки актуальности кэша.
+ /// Сравниваем checksum с локально сохранённым — если совпадает, кэш актуален.
+ @GET('/api/glossaries/meta')
+ Future getMeta();
+
+ /// Получить дамп продуктов (обновлённых после [updatedAfter]).
+ /// [updatedAfter] — ISO 8601 дата последней синхронизации из sync_meta.
+ /// [ifNoneMatch] — ETag из предыдущего ответа для условного запроса (304).
+ @GET('/api/glossaries/dump')
+ Future> getDump({
+ @Query('updated_after') String? updatedAfter,
+ @Header('If-None-Match') String? ifNoneMatch,
+ });
+}
diff --git a/lib/core/local_db/tables/diary_days_table.dart b/lib/core/local_db/tables/diary_days_table.dart
new file mode 100644
index 00000000..268b19e8
--- /dev/null
+++ b/lib/core/local_db/tables/diary_days_table.dart
@@ -0,0 +1,49 @@
+import 'package:drift/drift.dart';
+
+/// Локальный кэш дней дневника питания.
+/// server_id — ID записи на сервере (null до синхронизации).
+/// server_updated_at — timestamp последнего обновления на сервере.
+/// cached_at — время кэширования записи локально.
+class DiaryDaysTable extends Table {
+ @override
+ String get tableName => 'diary_days';
+
+ // Локальный первичный ключ (autoincrement)
+ IntColumn get id => integer().autoIncrement()();
+
+ // ID записи на сервере (null, если день ещё не синхронизирован)
+ IntColumn get serverId => integer().nullable().named('server_id')();
+
+ // Дата дня в формате YYYY-MM-DD
+ TextColumn get date => text().named('date')();
+
+ // Время пробуждения HH:MM
+ TextColumn get wakeup => text().withDefault(const Constant('06:30'))();
+
+ // Время отхода ко сну HH:MM
+ TextColumn get retire => text().withDefault(const Constant('22:30'))();
+
+ IntColumn get closed => integer().withDefault(const Constant(0))();
+
+ TextColumn get comment => text().withDefault(const Constant(''))();
+
+ // Статус конечного автомата (init, active, finished, etc.)
+ TextColumn get state => text().withDefault(const Constant('init'))();
+
+ // Флаг: есть выбранная диета
+ BoolColumn get hasChosenDiet => boolean().withDefault(const Constant(false)).named('has_chosen_diet')();
+
+ // Флаг: есть выбранный план
+ BoolColumn get hasChosenPlan => boolean().withDefault(const Constant(false)).named('has_chosen_plan')();
+
+ // Дата последнего обновления на сервере (ISO 8601 string, nullable)
+ TextColumn get serverUpdatedAt => text().nullable().named('server_updated_at')();
+
+ // Время кэширования записи (ISO 8601 string)
+ TextColumn get cachedAt => text().named('cached_at')();
+
+ @override
+ List> get uniqueKeys => [
+ {date},
+ ];
+}
diff --git a/lib/core/local_db/tables/glossary_cache_table.dart b/lib/core/local_db/tables/glossary_cache_table.dart
new file mode 100644
index 00000000..1e54ff25
--- /dev/null
+++ b/lib/core/local_db/tables/glossary_cache_table.dart
@@ -0,0 +1,40 @@
+import 'package:drift/drift.dart';
+
+/// Локальный кэш продуктов из глоссария (~200k записей).
+/// Заполняется через GlossarySyncService из /api/glossaries/dump.
+class GlossaryCacheTable extends Table {
+ @override
+ String get tableName => 'glossary_cache';
+
+ // ID продукта с сервера (не autoincrement — используем серверный ID)
+ IntColumn get id => integer().named('id')();
+
+ TextColumn get name => text()();
+
+ IntColumn get productGroupId => integer().named('product_group_id')();
+
+ RealColumn get energyOn100gr => real().named('energy_on_100gr')();
+
+ RealColumn get proteinsOn100gr => real().named('proteins_on_100gr')();
+
+ RealColumn get fatsOn100gr => real().named('fats_on_100gr')();
+
+ RealColumn get carbohydratesOn100gr => real().named('carbohydrates_on_100gr')();
+
+ // Масса порции в граммах (0 = продукт в граммах, >0 = порционный)
+ RealColumn get portionMassGr => real().withDefault(const Constant(0)).named('portion_mass_gr')();
+
+ RealColumn get energyOnPortion => real().withDefault(const Constant(0)).named('energy_on_portion')();
+
+ RealColumn get proteinsOnPortion => real().withDefault(const Constant(0)).named('proteins_on_portion')();
+
+ RealColumn get fatsOnPortion => real().withDefault(const Constant(0)).named('fats_on_portion')();
+
+ RealColumn get carbohydratesOnPortion => real().withDefault(const Constant(0)).named('carbohydrates_on_portion')();
+
+ // ISO 8601 строка — время последнего обновления на сервере
+ TextColumn get updatedAt => text().named('updated_at')();
+
+ @override
+ Set get primaryKey => {id};
+}
diff --git a/lib/core/local_db/tables/meals_table.dart b/lib/core/local_db/tables/meals_table.dart
new file mode 100644
index 00000000..136cc60a
--- /dev/null
+++ b/lib/core/local_db/tables/meals_table.dart
@@ -0,0 +1,29 @@
+import 'package:drift/drift.dart';
+import 'diary_days_table.dart';
+
+/// Приёмы пищи, привязанные к дню дневника.
+/// type_no: 1-завтрак, 2-обед, 3-ужин, 4-полдник, 5-перекус
+class MealsTable extends Table {
+ @override
+ String get tableName => 'meals';
+
+ IntColumn get id => integer().autoIncrement()();
+
+ // Ссылка на локальный дневник
+ IntColumn get diaryDayId => integer().named('diary_day_id').references(DiaryDaysTable, #id, onDelete: KeyAction.cascade)();
+
+ // Тип приёма пищи (1-5)
+ IntColumn get typeNo => integer().named('type_no')();
+
+ // Время приёма пищи HH:MM:SS (nullable)
+ TextColumn get time => text().nullable()();
+
+ IntColumn get satiety => integer().withDefault(const Constant(0))();
+
+ IntColumn get mood => integer().nullable()();
+
+ @override
+ List> get uniqueKeys => [
+ {diaryDayId, typeNo},
+ ];
+}
diff --git a/lib/core/local_db/tables/pending_operations_table.dart b/lib/core/local_db/tables/pending_operations_table.dart
new file mode 100644
index 00000000..a7696c53
--- /dev/null
+++ b/lib/core/local_db/tables/pending_operations_table.dart
@@ -0,0 +1,40 @@
+import 'package:drift/drift.dart';
+
+/// Очередь офлайн-операций (Write-Behind queue).
+/// Каждая запись — мутация, которая должна быть отправлена на сервер.
+///
+/// Жизненный цикл статуса:
+/// pending → synced (успешно отправлена)
+/// pending → failed (retryable: false)
+/// pending → pending (retryable: true, retry_count++)
+class PendingOperationsTable extends Table {
+ @override
+ String get tableName => 'pending_operations';
+
+ // UUID v4 — используется как Idempotency-Key для сервера
+ TextColumn get id => text()();
+
+ // Тип операции: 'create_portion', 'update_portion', 'delete_portion',
+ // 'create_day', 'fill_meal_time', 'fill_satiety', 'fill_mood',
+ // 'fill_wakeup', 'fill_retire', 'finish_day',
+ // 'upload_photo', 'delete_photo'
+ TextColumn get type => text()();
+
+ // JSON payload запроса (тело для API)
+ TextColumn get payload => text()();
+
+ // Статус: 'pending', 'synced', 'failed'
+ TextColumn get status => text().withDefault(const Constant('pending'))();
+
+ // Счётчик попыток отправки
+ IntColumn get retryCount => integer().withDefault(const Constant(0)).named('retry_count')();
+
+ // Локальный путь к файлу фото (только для upload_photo операций)
+ TextColumn get localFilePath => text().nullable().named('local_file_path')();
+
+ // Время создания операции (ISO 8601 string)
+ TextColumn get createdAt => text().named('created_at')();
+
+ @override
+ Set get primaryKey => {id};
+}
diff --git a/lib/core/local_db/tables/portions_table.dart b/lib/core/local_db/tables/portions_table.dart
new file mode 100644
index 00000000..054b7b0d
--- /dev/null
+++ b/lib/core/local_db/tables/portions_table.dart
@@ -0,0 +1,28 @@
+import 'package:drift/drift.dart';
+import 'meals_table.dart';
+
+/// Порции продуктов внутри приёмов пищи.
+class PortionsTable extends Table {
+ @override
+ String get tableName => 'portions';
+
+ IntColumn get id => integer().autoIncrement()();
+
+ IntColumn get mealId => integer().named('meal_id').references(MealsTable, #id, onDelete: KeyAction.cascade)();
+
+ TextColumn get productName => text().named('product_name')();
+
+ // Единица измерения: 'gr' или 'pcs'
+ TextColumn get portion => text()();
+
+ IntColumn get quantity => integer()();
+
+ // ID продукта из глоссария (nullable — если ручной ввод)
+ IntColumn get productNutritionalValueId => integer().nullable().named('product_nutritional_value_id')();
+
+ // Состояние порции (eaten, eaten_deleted, filled_diet, etc.)
+ TextColumn get state => text().withDefault(const Constant('eaten'))();
+
+ // Из диеты (0 = пользователь, 1 = из диеты)
+ BoolColumn get fromDiet => boolean().withDefault(const Constant(false)).named('from_diet')();
+}
diff --git a/lib/core/local_db/tables/sync_meta_table.dart b/lib/core/local_db/tables/sync_meta_table.dart
new file mode 100644
index 00000000..637011ba
--- /dev/null
+++ b/lib/core/local_db/tables/sync_meta_table.dart
@@ -0,0 +1,16 @@
+import 'package:drift/drift.dart';
+
+/// Мета-данные синхронизации глоссария.
+/// Используется как key-value хранилище для ETag, checksum, last_dump_at.
+class SyncMetaTable extends Table {
+ @override
+ String get tableName => 'sync_meta';
+
+ // Ключ: 'glossary_checksum', 'glossary_etag', 'glossary_last_dump_at'
+ TextColumn get key => text()();
+
+ TextColumn get value => text()();
+
+ @override
+ Set get primaryKey => {key};
+}
diff --git a/lib/core/locator_service.dart b/lib/core/locator_service.dart
index 2f457269..32b34db0 100644
--- a/lib/core/locator_service.dart
+++ b/lib/core/locator_service.dart
@@ -4,6 +4,7 @@ import 'package:eatvio_app/core/di/cubit_registrations.dart';
import 'package:eatvio_app/core/di/external_registrations.dart';
import 'package:eatvio_app/core/di/repository_registrations.dart';
import 'package:eatvio_app/core/di/service_registrations.dart';
+import 'package:eatvio_app/core/local_db/services/diary_sync_runner.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:get_it/get_it.dart';
@@ -16,7 +17,7 @@ Future init({required AppBuildType buildType}) async {
// Регистрация AppConfig
AppConfigRegistration.registerAppConfig(buildType: buildType);
- // Регистрация внешних зависимостей (Dio, etc.)
+ // Регистрация внешних зависимостей (Dio, БД, etc.)
ExternalRegistrations.registerAll();
// Ожидание инициализации асинхронных зависимостей
@@ -33,4 +34,8 @@ Future init({required AppBuildType buildType}) async {
// Регистрация WebSocket менеджера
sl.registerLazySingleton(() => WsManager(wsService: sl()));
+
+ // Запуск FIFO-воркера синхронизации дневника
+ // Подписывается на InternetConnectionCubit и обрабатывает очередь при появлении сети
+ sl().start();
}
diff --git a/lib/features/diary/presentation/bloc/diary_cubit.dart b/lib/features/diary/presentation/bloc/diary_cubit.dart
index 39a0a7e7..b068b4e4 100644
--- a/lib/features/diary/presentation/bloc/diary_cubit.dart
+++ b/lib/features/diary/presentation/bloc/diary_cubit.dart
@@ -3,6 +3,8 @@ import 'dart:io';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
+import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
+import 'package:eatvio_app/core/local_db/diary_local_data_source.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
@@ -30,7 +32,13 @@ class DiaryCubit extends Cubit {
final DietRepository dietRepository;
final ChatRepository chatRepository;
final SettingsRepository settingsRepository;
+ final DiaryLocalDataSource localDataSource;
+ final InternetConnectionCubit connectionCubit;
int? diaryId;
+
+ // Трек офлайн-режима
+ bool _lastFetchWasOffline = false;
+
DiaryCubit({
required this.chatRepository,
required this.diaryRepository,
@@ -38,6 +46,8 @@ class DiaryCubit extends Cubit {
required this.profileRepository,
required this.dietRepository,
required this.settingsRepository,
+ required this.localDataSource,
+ required this.connectionCubit,
}) : super(const DiaryState.loading()) {
today = DateTime.now();
selectedDay = today;
@@ -86,7 +96,11 @@ class DiaryCubit extends Cubit {
final diaryDay = await getDiary();
if (diaryDay == null) {
- emit(const DiaryState.error(errorMessage: 'Diary not found'));
+ // Офлайн и нет кэша — показываем понятное сообщение
+ final msg = connectionCubit.state.isInternetAvailable
+ ? 'Дневник не найден'
+ : 'Нет интернета. Откройте дневник онлайн хотя бы один раз, чтобы он был доступен офлайн.';
+ emit(DiaryState.error(errorMessage: msg));
return true;
}
@@ -110,7 +124,9 @@ class DiaryCubit extends Cubit {
Future updateCurrentDiary() async {
try {
- final diaryDay = await diaryRepository.getDiaryDayV2(day: selectedDay.toISO8601WithTimeZoneOffset());
+ final diaryDay = await diaryRepository.getDiaryDayV2(
+ day: selectedDay.toISO8601WithTimeZoneOffset(),
+ );
final diaryEntity = diaryDay.toEntity();
if (diaryEntity != null) {
setDiary(diaryDay: diaryEntity, diaryId: diaryId);
@@ -121,19 +137,39 @@ class DiaryCubit extends Cubit {
}
Future getDiary() async {
+ _lastFetchWasOffline = false;
+
+ // Офлайн: СРАЗУ в кэш, не трогаем сеть
+ if (!connectionCubit.state.isInternetAvailable) {
+ _lastFetchWasOffline = true;
+ try {
+ return await diaryRepository.getDiaryDayV2(
+ day: selectedDay.toISO8601WithTimeZoneOffset(),
+ );
+ } catch (_) {
+ return null; // Нет кэша — вернём null, UI покажет offline-заглушку
+ }
+ }
+
+ // Онлайн: создаём день + грузим с сервера
try {
await diaryRepository.createDiaryDay(day: selectedDay.toISO8601WithTimeZoneOffset());
- final diaryDay = await diaryRepository.getDiaryDayV2(day: selectedDay.toISO8601WithTimeZoneOffset());
- return diaryDay;
+ return await diaryRepository.getDiaryDayV2(day: selectedDay.toISO8601WithTimeZoneOffset());
} catch (e, st) {
log(e.toString(), stackTrace: st);
- if (e is DioException) {
- if (e.response?.statusCode == 422) {
- final diaryDay = await diaryRepository.getDiaryDayV2(day: selectedDay.toISO8601WithTimeZoneOffset());
- return diaryDay;
- }
+ if (e is DioException && e.response?.statusCode == 422) {
+ // День уже существует — просто загружаем
+ try {
+ return await diaryRepository.getDiaryDayV2(day: selectedDay.toISO8601WithTimeZoneOffset());
+ } catch (_) {}
+ }
+ // Другая сетевая ошибка → пробуем кэш
+ _lastFetchWasOffline = true;
+ try {
+ return await diaryRepository.getDiaryDayV2(day: selectedDay.toISO8601WithTimeZoneOffset());
+ } catch (_) {
+ return null;
}
- return null;
}
}
@@ -232,35 +268,54 @@ class DiaryCubit extends Cubit {
try {
bool isMoodEnabled = false;
bool isWakeUpAndRetireTimeEnabled = false;
- if (diaryId != null) {
- } else {
- final profileSettings = await settingsRepository.getProfileSettings();
- isMoodEnabled = profileSettings.isMoodFillable;
- isWakeUpAndRetireTimeEnabled = profileSettings.isBedtimeFillable;
+ if (diaryId == null) {
+ try {
+ final profileSettings = await settingsRepository.getProfileSettings();
+ isMoodEnabled = profileSettings.isMoodFillable;
+ isWakeUpAndRetireTimeEnabled = profileSettings.isBedtimeFillable;
+ } catch (_) {
+ // Офлайн — используем значения по умолчанию
+ }
}
+
ConsultantReviewModel? consultantReview;
int? planDuration;
int? currentPlanDay;
String? planName;
if (diaryDay.chosenPlanId != null) {
- final chosenPlanModel = await chatRepository.getChosenPlan(id: diaryDay.chosenPlanId!);
- if (!isConsultantView &&
- (diaryDay.typeOfDiary.diaryState == DiaryStateEnum.lastDayCommented || diaryDay.typeOfDiary.diaryState == DiaryStateEnum.commented)) {
- await diaryRepository.viewCommentOfDiaryDayByConsultant(diaryId: diaryDay.id);
+ try {
+ final chosenPlanModel = await chatRepository.getChosenPlan(id: diaryDay.chosenPlanId!);
+ if (!isConsultantView &&
+ (diaryDay.typeOfDiary.diaryState == DiaryStateEnum.lastDayCommented ||
+ diaryDay.typeOfDiary.diaryState == DiaryStateEnum.commented)) {
+ await diaryRepository.viewCommentOfDiaryDayByConsultant(diaryId: diaryDay.id);
+ }
+ consultantReview = await diaryRepository.getDiaryComments(
+ planId: diaryDay.chosenPlanId!,
+ diaryId: diaryDay.id,
+ );
+ planDuration = chosenPlanModel.plan.duration;
+ planName = chosenPlanModel.plan.name;
+ currentPlanDay = consultantReview.currentPlanDay;
+ } catch (_) {
+ // Офлайн — данные плана недоступны
}
- consultantReview = await diaryRepository.getDiaryComments(planId: diaryDay.chosenPlanId!, diaryId: diaryDay.id);
- planDuration = chosenPlanModel.plan.duration;
- planName = chosenPlanModel.plan.name;
- currentPlanDay = consultantReview.currentPlanDay;
}
+
final nutrition = await getNutrition(diaryDay.id);
- final isDiaryFinished = diaryDay.typeOfDiary.diaryState == DiaryStateEnum.finished || diaryDay.typeOfDiary.diaryState == DiaryStateEnum.lastDayFinished;
+ final isDiaryFinished =
+ diaryDay.typeOfDiary.diaryState == DiaryStateEnum.finished ||
+ diaryDay.typeOfDiary.diaryState == DiaryStateEnum.lastDayFinished;
final isDiaryCommented =
diaryDay.typeOfDiary.diaryState == DiaryStateEnum.commented ||
diaryDay.typeOfDiary.diaryState == DiaryStateEnum.lastDayCommented ||
diaryDay.typeOfDiary.diaryState == DiaryStateEnum.reviewedByUser;
+
+ // Offline-first: получаем кол-во операций в очереди
+ final pendingCount = await localDataSource.getPendingCount();
+
emit(
DiaryState.loaded(
diary: diaryDay,
@@ -276,6 +331,8 @@ class DiaryCubit extends Cubit {
chosenPlanName: planName,
isMoodEnabled: isMoodEnabled,
isWakeUpAndRetireEnabled: isWakeUpAndRetireTimeEnabled,
+ pendingOpsCount: pendingCount,
+ isOfflineMode: _lastFetchWasOffline,
),
);
} catch (e, st) {
@@ -295,8 +352,13 @@ class DiaryCubit extends Cubit {
}
}
- Future getNutrition(int index) async {
- return (await diaryRepository.getNutritionData(index));
+ Future getNutrition(int index) async {
+ try {
+ return await diaryRepository.getNutritionData(index);
+ } catch (e) {
+ log('getNutrition offline fallback: $e');
+ return null; // Офлайн — нет данных по калориям, не критично
+ }
}
Future addProductToMeal({
diff --git a/lib/features/diary/presentation/bloc/diary_state.dart b/lib/features/diary/presentation/bloc/diary_state.dart
index 02209371..bc09447f 100644
--- a/lib/features/diary/presentation/bloc/diary_state.dart
+++ b/lib/features/diary/presentation/bloc/diary_state.dart
@@ -16,7 +16,7 @@ abstract class DiaryState with _$DiaryState {
const factory DiaryState.loaded({
required DiaryDayEntity diary,
- required NutritionDataModel nutrition,
+ NutritionDataModel? nutrition,
required DateTime selectedDay,
required int selectedMeal,
int? chosenPlanCurrentDay,
@@ -29,6 +29,10 @@ abstract class DiaryState with _$DiaryState {
@Default(false) bool isWakeUpAndRetireEnabled,
@Default(false) bool isMoodEnabled,
@Default(false) bool isLoadingAddingProductInMeal,
+ // Offline-first: кол-во операций в очереди на синхронизацию
+ @Default(0) int pendingOpsCount,
+ // Offline-first: true если данные получены из локального кэша
+ @Default(false) bool isOfflineMode,
}) = DiaryStateLoaded;
const factory DiaryState.loadedTomorrowWithDiet({required DiaryFutureDietDayEntity diary, required DateTime selectedDay, required int selectedMeal}) =
diff --git a/lib/features/diary/presentation/pages/diary_screen.dart b/lib/features/diary/presentation/pages/diary_screen.dart
index 01c75db2..ef7347a2 100644
--- a/lib/features/diary/presentation/pages/diary_screen.dart
+++ b/lib/features/diary/presentation/pages/diary_screen.dart
@@ -19,6 +19,7 @@ import 'package:eatvio_app/features/diary/presentation/widgets/comment_for_consu
import 'package:eatvio_app/features/diary/presentation/widgets/day_comment_widget.dart';
import 'package:eatvio_app/features/diary/presentation/widgets/finish_day_button_widget.dart';
import 'package:eatvio_app/features/diary/presentation/widgets/meal_widget.dart';
+import 'package:eatvio_app/features/diary/presentation/widgets/offline_banner_widget.dart';
import 'package:eatvio_app/features/diary/presentation/widgets/wakeup_and_retire_widget.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
@@ -89,99 +90,107 @@ class _DiaryScreenContentState extends State {
),
centerTitle: true,
),
- body: SingleChildScrollView(
- padding: const EdgeInsets.only(bottom: 16, top: 8),
- physics: const BouncingScrollPhysics(),
- child: Column(
- children: [
- if (widget.diaryId == null) CalendarWidget(date: widget.date),
- if (state is DiaryStateLoading) ...[const SizedBox(height: 60), const Loader()],
- if (state is DiaryStateLoaded)
- WakeupAndRetireWidget(
- isEnabled: state.diary.typeOfDiary.hasChosenPlan || state.isConsultantView || state.isWakeUpAndRetireEnabled,
- disable: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented,
- ),
- const SizedBox(height: 12),
- if (state is DiaryStateLoaded)
- MealWidget(
- showMood: state.diary.typeOfDiary.hasChosenPlan || state.isConsultantView || state.isMoodEnabled,
- showAddButton: !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented,
- ),
- if (state is DiaryStateLoadedTomorrowWithDiet) const MealWidget(showMood: false, showAddButton: false),
- if (state is DiaryStateLoaded) const CaloriesChartWidget(),
- const SizedBox(height: 12),
- Builder(
- builder: (context) {
- if (state is DiaryStateLoaded && state.diary.typeOfDiary.hasChosenPlan) {
- if (state.diary.comment.isNotEmpty) {
- return CommentForConsultantWidget(comment: state.diary.comment);
- }
- if (state.isDiaryCommented || state.isDiaryFinished) {
- return const SizedBox.shrink();
- }
- return const CommentForConsultantWidget();
- }
- return const SizedBox.shrink();
- },
- ),
- if (state is DiaryStateLoaded && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented)
- Padding(
- padding: const EdgeInsets.only(top: 12),
- child: _isDiaryFinishing
- ? const Center(child: Loader())
- : FinishDayButtonWidget(
- hasProducts: state.diary.hasProducts,
- onPressed: () async {
- setState(() {
- _isDiaryFinishing = true;
- });
- final isFinished = await context.read().finishDiaryDay(
- diaryId: state.diary.id,
- wakeUp: state.diary.wakeup.toString(),
- retire: state.diary.retire.toString(),
- );
- if (!isFinished && context.mounted) {
- showErrorDialog(context);
- }
- setState(() {
- _isDiaryFinishing = false;
- });
- },
- ),
- ),
- if (state is DiaryStateLoaded && state.isConsultantView && !state.isDiaryCommented) ...[
- const SizedBox(height: 16),
- const MainBackgroundBouble(padding: EdgeInsets.all(16), child: DayCommentWidget()),
- ],
- if (state is DiaryStateLoaded && state.consultantReview?.consultantComments.dayComment != null) ...[
- const SizedBox(height: 12),
- MainBackgroundBouble(
- padding: const EdgeInsets.all(16),
- child: Column(
- children: [
- Text(s.dayComment, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
- const SizedBox(height: 12),
- CommentContainer(comment: state.consultantReview?.consultantComments.dayComment ?? ''),
+ body: Column(
+ children: [
+ // Офлайн-баннер (скользит сверху при потере сети)
+ const OfflineBannerWidget(),
+ Expanded(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.only(bottom: 16, top: 8),
+ physics: const BouncingScrollPhysics(),
+ child: Column(
+ children: [
+ if (widget.diaryId == null) CalendarWidget(date: widget.date),
+ if (state is DiaryStateLoading) ...[const SizedBox(height: 60), const Loader()],
+ if (state is DiaryStateLoaded)
+ WakeupAndRetireWidget(
+ isEnabled: state.diary.typeOfDiary.hasChosenPlan || state.isConsultantView || state.isWakeUpAndRetireEnabled,
+ disable: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented,
+ ),
+ const SizedBox(height: 12),
+ if (state is DiaryStateLoaded)
+ MealWidget(
+ showMood: state.diary.typeOfDiary.hasChosenPlan || state.isConsultantView || state.isMoodEnabled,
+ showAddButton: !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented,
+ ),
+ if (state is DiaryStateLoadedTomorrowWithDiet) const MealWidget(showMood: false, showAddButton: false),
+ if (state is DiaryStateLoaded) const CaloriesChartWidget(),
+ const SizedBox(height: 12),
+ Builder(
+ builder: (context) {
+ if (state is DiaryStateLoaded && state.diary.typeOfDiary.hasChosenPlan) {
+ if (state.diary.comment.isNotEmpty) {
+ return CommentForConsultantWidget(comment: state.diary.comment);
+ }
+ if (state.isDiaryCommented || state.isDiaryFinished) {
+ return const SizedBox.shrink();
+ }
+ return const CommentForConsultantWidget();
+ }
+ return const SizedBox.shrink();
+ },
+ ),
+ if (state is DiaryStateLoaded && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented)
+ Padding(
+ padding: const EdgeInsets.only(top: 12),
+ child: _isDiaryFinishing
+ ? const Center(child: Loader())
+ : FinishDayButtonWidget(
+ hasProducts: state.diary.hasProducts,
+ onPressed: () async {
+ setState(() {
+ _isDiaryFinishing = true;
+ });
+ final isFinished = await context.read().finishDiaryDay(
+ diaryId: state.diary.id,
+ wakeUp: state.diary.wakeup.toString(),
+ retire: state.diary.retire.toString(),
+ );
+ if (!isFinished && context.mounted) {
+ showErrorDialog(context);
+ }
+ setState(() {
+ _isDiaryFinishing = false;
+ });
+ },
+ ),
+ ),
+ if (state is DiaryStateLoaded && state.isConsultantView && !state.isDiaryCommented) ...[
+ const SizedBox(height: 16),
+ const MainBackgroundBouble(padding: EdgeInsets.all(16), child: DayCommentWidget()),
],
- ),
+ if (state is DiaryStateLoaded && state.consultantReview?.consultantComments.dayComment != null) ...[
+ const SizedBox(height: 12),
+ MainBackgroundBouble(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ children: [
+ Text(s.dayComment, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
+ const SizedBox(height: 12),
+ CommentContainer(comment: state.consultantReview?.consultantComments.dayComment ?? ''),
+ ],
+ ),
+ ),
+ const SizedBox(height: 28),
+ ],
+ if (state is DiaryStateLoaded && state.isConsultantView && state.isDiaryFinished && !state.isDiaryCommented) ...[
+ const SizedBox(height: 16),
+ CustomOutlinedButton(
+ text: s.send,
+ height: 52,
+ buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
+ buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
+ onPressed: () async {
+ await context.read().submitComments(state.diary.id);
+ },
+ ),
+ const SizedBox(height: 16),
+ ],
+ ],
),
- const SizedBox(height: 28),
- ],
- if (state is DiaryStateLoaded && state.isConsultantView && state.isDiaryFinished && !state.isDiaryCommented) ...[
- const SizedBox(height: 16),
- CustomOutlinedButton(
- text: s.send,
- height: 52,
- buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
- buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
- onPressed: () async {
- await context.read().submitComments(state.diary.id);
- },
- ),
- const SizedBox(height: 16),
- ],
- ],
- ),
+ ),
+ ),
+ ],
),
);
},
diff --git a/lib/features/diary/presentation/widgets/calories_chart_widget.dart b/lib/features/diary/presentation/widgets/calories_chart_widget.dart
index 4aca0334..e766f5e3 100644
--- a/lib/features/diary/presentation/widgets/calories_chart_widget.dart
+++ b/lib/features/diary/presentation/widgets/calories_chart_widget.dart
@@ -13,6 +13,10 @@ class CaloriesChartWidget extends StatelessWidget {
return BlocBuilder(
builder: (BuildContext context, DiaryState state) {
if (state is DiaryStateLoaded) {
+ // В офлайн-режиме данные о питании недоступны — скрываем блок
+ final nutrition = state.nutrition;
+ if (nutrition == null) return const SizedBox.shrink();
+
return Container(
margin: const EdgeInsets.only(top: 12),
padding: const EdgeInsets.all(16),
@@ -72,9 +76,9 @@ class CaloriesChartWidget extends StatelessWidget {
cornerStyle: CornerStyle.bothCurve,
),
RangePointer(
- value: (state.nutrition.dailyNutritionIntake.carbohydrates / state.nutrition.dailyNutritionRecommendation.carbohydrates) * 100,
+ value: (nutrition.dailyNutritionIntake.carbohydrates / nutrition.dailyNutritionRecommendation.carbohydrates) * 100,
width: 0.16,
- color: state.nutrition.dailyNutritionIntake.carbohydrates < state.nutrition.dailyNutritionRecommendation.carbohydrates
+ color: nutrition.dailyNutritionIntake.carbohydrates < nutrition.dailyNutritionRecommendation.carbohydrates
? appTheme.blueA400
: Colors.red,
sizeUnit: GaugeSizeUnit.factor,
@@ -92,9 +96,9 @@ class CaloriesChartWidget extends StatelessWidget {
pointerOffset: -0.18,
),
RangePointer(
- value: (state.nutrition.dailyNutritionIntake.fats / state.nutrition.dailyNutritionRecommendation.fats) * 100,
+ value: (nutrition.dailyNutritionIntake.fats / nutrition.dailyNutritionRecommendation.fats) * 100,
width: 0.16,
- color: state.nutrition.dailyNutritionIntake.fats < state.nutrition.dailyNutritionRecommendation.fats
+ color: nutrition.dailyNutritionIntake.fats < nutrition.dailyNutritionRecommendation.fats
? appTheme.blueA400
: Colors.red,
sizeUnit: GaugeSizeUnit.factor,
@@ -113,9 +117,9 @@ class CaloriesChartWidget extends StatelessWidget {
pointerOffset: -0.36,
),
RangePointer(
- value: (state.nutrition.dailyNutritionIntake.proteins / state.nutrition.dailyNutritionRecommendation.proteins) * 100,
+ value: (nutrition.dailyNutritionIntake.proteins / nutrition.dailyNutritionRecommendation.proteins) * 100,
width: 0.16,
- color: state.nutrition.dailyNutritionIntake.proteins < state.nutrition.dailyNutritionRecommendation.proteins
+ color: nutrition.dailyNutritionIntake.proteins < nutrition.dailyNutritionRecommendation.proteins
? appTheme.blueA400
: Colors.red,
sizeUnit: GaugeSizeUnit.factor,
@@ -134,9 +138,9 @@ class CaloriesChartWidget extends StatelessWidget {
pointerOffset: -0.54,
),
RangePointer(
- value: (state.nutrition.dailyNutritionIntake.energy / state.nutrition.dailyNutritionRecommendation.energy) * 100,
+ value: (nutrition.dailyNutritionIntake.energy / nutrition.dailyNutritionRecommendation.energy) * 100,
width: 0.16,
- color: state.nutrition.dailyNutritionIntake.energy < state.nutrition.dailyNutritionRecommendation.energy
+ color: nutrition.dailyNutritionIntake.energy < nutrition.dailyNutritionRecommendation.energy
? appTheme.blueA400
: Colors.red,
sizeUnit: GaugeSizeUnit.factor,
@@ -210,24 +214,22 @@ class CaloriesChartWidget extends StatelessWidget {
children: [
_chartData(
'Калории',
- '${state.nutrition.dailyNutritionIntake.energy.toStringAsFixed(0)} из ${state.nutrition.dailyNutritionRecommendation.energy.toStringAsFixed(0)} ккал',
+ '${nutrition.dailyNutritionIntake.energy.toStringAsFixed(0)} из ${nutrition.dailyNutritionRecommendation.energy.toStringAsFixed(0)} ккал',
),
const SizedBox(height: 8),
_chartData(
'Белки',
- '${state.nutrition.dailyNutritionIntake.proteins.toStringAsFixed(0)} из ${state.nutrition.dailyNutritionRecommendation.proteins.toStringAsFixed(
- 0,
- )} грамм',
+ '${nutrition.dailyNutritionIntake.proteins.toStringAsFixed(0)} из ${nutrition.dailyNutritionRecommendation.proteins.toStringAsFixed(0)} грамм',
),
const SizedBox(height: 8),
_chartData(
'Жиры',
- '${state.nutrition.dailyNutritionIntake.fats.toStringAsFixed(0)} из ${state.nutrition.dailyNutritionRecommendation.fats.toStringAsFixed(0)} грамм',
+ '${nutrition.dailyNutritionIntake.fats.toStringAsFixed(0)} из ${nutrition.dailyNutritionRecommendation.fats.toStringAsFixed(0)} грамм',
),
const SizedBox(height: 8),
_chartData(
'Углеводы',
- '${state.nutrition.dailyNutritionIntake.carbohydrates.toStringAsFixed(0)} из ${state.nutrition.dailyNutritionRecommendation.carbohydrates.toStringAsFixed(0)} грамм',
+ '${nutrition.dailyNutritionIntake.carbohydrates.toStringAsFixed(0)} из ${nutrition.dailyNutritionRecommendation.carbohydrates.toStringAsFixed(0)} грамм',
),
],
),
@@ -238,6 +240,7 @@ class CaloriesChartWidget extends StatelessWidget {
);
}
return const SizedBox.shrink();
+
},
);
}
diff --git a/lib/features/diary/presentation/widgets/offline_banner_widget.dart b/lib/features/diary/presentation/widgets/offline_banner_widget.dart
new file mode 100644
index 00000000..998f2a70
--- /dev/null
+++ b/lib/features/diary/presentation/widgets/offline_banner_widget.dart
@@ -0,0 +1,200 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart';
+import 'package:eatvio_app/features/diary/presentation/bloc/diary_state.dart';
+
+/// Тонкий баннер вверху экрана дневника, указывающий на офлайн-режим
+/// и количество операций, ожидающих синхронизацию.
+///
+/// Показывается только когда нет интернета или есть pending операции.
+/// Автоматически скрывается при восстановлении соединения и пустой очереди.
+class OfflineBannerWidget extends StatelessWidget {
+ const OfflineBannerWidget({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return BlocBuilder(
+ buildWhen: (prev, curr) {
+ if (prev is DiaryStateLoaded && curr is DiaryStateLoaded) {
+ return prev.pendingOpsCount != curr.pendingOpsCount ||
+ prev.isOfflineMode != curr.isOfflineMode;
+ }
+ return prev.runtimeType != curr.runtimeType;
+ },
+ builder: (context, state) {
+ if (state is! DiaryStateLoaded) return const SizedBox.shrink();
+
+ final isOffline = state.isOfflineMode;
+ final pending = state.pendingOpsCount;
+
+ // Не показываем если онлайн и очердь пуста
+ if (!isOffline && pending == 0) return const SizedBox.shrink();
+
+ return _OfflineBanner(isOffline: isOffline, pendingCount: pending);
+ },
+ );
+ }
+}
+
+class _OfflineBanner extends StatefulWidget {
+ final bool isOffline;
+ final int pendingCount;
+
+ const _OfflineBanner({required this.isOffline, required this.pendingCount});
+
+ @override
+ State<_OfflineBanner> createState() => _OfflineBannerState();
+}
+
+class _OfflineBannerState extends State<_OfflineBanner>
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _controller;
+ late final Animation _slideAnim;
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = AnimationController(
+ vsync: this,
+ duration: const Duration(milliseconds: 300),
+ );
+ _slideAnim = Tween(begin: -1.0, end: 0.0).animate(
+ CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic),
+ );
+ _controller.forward();
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final (color, icon, text) = _resolveStyle();
+
+ return AnimatedBuilder(
+ animation: _slideAnim,
+ builder: (context, child) => Transform.translate(
+ offset: Offset(0, _slideAnim.value * 40),
+ child: child,
+ ),
+ child: Container(
+ width: double.infinity,
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
+ decoration: BoxDecoration(
+ color: color,
+ boxShadow: [
+ BoxShadow(
+ color: color.withValues(alpha: 0.35),
+ blurRadius: 8,
+ offset: const Offset(0, 2),
+ ),
+ ],
+ ),
+ child: Row(
+ children: [
+ Icon(icon, color: Colors.white, size: 16),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ text,
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 12,
+ fontWeight: FontWeight.w600,
+ letterSpacing: 0.2,
+ ),
+ ),
+ ),
+ if (widget.isOffline)
+ const _PulsingDot(),
+ ],
+ ),
+ ),
+ );
+ }
+
+ (Color, IconData, String) _resolveStyle() {
+ if (widget.isOffline && widget.pendingCount > 0) {
+ return (
+ const Color(0xFFE85D04),
+ Icons.cloud_off_rounded,
+ 'Офлайн · ${widget.pendingCount} ${_inflect(widget.pendingCount)} в очереди',
+ );
+ } else if (widget.isOffline) {
+ return (
+ const Color(0xFF6B7280),
+ Icons.cloud_off_rounded,
+ 'Офлайн · данные из кэша',
+ );
+ } else {
+ // Онлайн, но есть pending операции (только что восстановилось соединение)
+ return (
+ const Color(0xFF059669),
+ Icons.sync_rounded,
+ 'Синхронизация: ${widget.pendingCount} ${_inflect(widget.pendingCount)}…',
+ );
+ }
+ }
+
+ String _inflect(int n) {
+ if (n % 100 >= 11 && n % 100 <= 19) return 'изменений';
+ switch (n % 10) {
+ case 1:
+ return 'изменение';
+ case 2:
+ case 3:
+ case 4:
+ return 'изменения';
+ default:
+ return 'изменений';
+ }
+ }
+}
+
+/// Мигающая точка — визуальный индикатор офлайн-статуса.
+class _PulsingDot extends StatefulWidget {
+ const _PulsingDot();
+
+ @override
+ State<_PulsingDot> createState() => _PulsingDotState();
+}
+
+class _PulsingDotState extends State<_PulsingDot>
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _ctrl;
+ late final Animation _anim;
+
+ @override
+ void initState() {
+ super.initState();
+ _ctrl = AnimationController(
+ vsync: this,
+ duration: const Duration(milliseconds: 800),
+ )..repeat(reverse: true);
+ _anim = Tween(begin: 0.3, end: 1.0).animate(_ctrl);
+ }
+
+ @override
+ void dispose() {
+ _ctrl.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return FadeTransition(
+ opacity: _anim,
+ child: Container(
+ width: 8,
+ height: 8,
+ decoration: const BoxDecoration(
+ color: Colors.white,
+ shape: BoxShape.circle,
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/diary/repositories/diary_repository.dart b/lib/features/diary/repositories/diary_repository.dart
index 071d03df..ad151f97 100644
--- a/lib/features/diary/repositories/diary_repository.dart
+++ b/lib/features/diary/repositories/diary_repository.dart
@@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
+import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
+import 'package:eatvio_app/core/local_db/diary_local_data_source.dart';
import 'package:eatvio_app/features/diary/models/consultant_comment_model.dart';
import 'package:eatvio_app/features/diary/models/consultant_review_model.dart';
import 'package:eatvio_app/features/diary/models/create_day_model.dart';
@@ -11,14 +13,25 @@ import 'package:eatvio_app/features/diary/models/product_moderation_model.dart';
import 'package:eatvio_app/features/diary/services/diary_service.dart';
import 'package:eatvio_app/features/glossary/models/glossary_model.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
+import 'package:uuid/uuid.dart';
+
class DiaryRepository {
final DiaryService diaryService;
- final StreamController _diaryForceFetchController = StreamController.broadcast();
+ final DiaryLocalDataSource localDataSource;
+ final InternetConnectionCubit connectionCubit;
+ final _uuid = const Uuid();
+ final StreamController _diaryForceFetchController = StreamController.broadcast();
Stream get forceFetchStream => _diaryForceFetchController.stream;
- DiaryRepository({required this.diaryService});
+ bool get _isOnline => connectionCubit.state.isInternetAvailable;
+
+ DiaryRepository({
+ required this.diaryService,
+ required this.localDataSource,
+ required this.connectionCubit,
+ });
Future getDiaryByDate(String date) async {
try {
@@ -150,6 +163,9 @@ class DiaryRepository {
}
}
+ /// Write-Behind: добавить порцию в приём пищи.
+ /// Офлайн/ошибка → кладём в pending_operations.
+ /// Онлайн → идемпотентный запрос с UUID-ключом.
Future addPortionToMeal({
required int diaryId,
required int mealId,
@@ -158,8 +174,25 @@ class DiaryRepository {
required int quantity,
int? productNutritionalValueId,
}) async {
+ final payload = {
+ 'diary_id': diaryId,
+ 'meal_id': mealId,
+ 'product_name': productName,
+ 'portion': portion,
+ 'quantity': quantity,
+ if (productNutritionalValueId != null) 'product_nutritional_value_id': productNutritionalValueId,
+ };
+
+ if (!_isOnline) {
+ // Офлайн — немедленно в очередь
+ await localDataSource.enqueueOperation(type: 'create_portion', payload: payload);
+ return;
+ }
+
+ final idempotencyKey = _uuid.v4();
try {
- return await diaryService.addProductToMeal(
+ await diaryService.addProductToMealIdempotent(
+ idempotencyKey: idempotencyKey,
diary: diaryId,
typeNo: mealId,
productName: productName,
@@ -167,12 +200,14 @@ class DiaryRepository {
quantity: quantity,
productNutritionalValueId: productNutritionalValueId,
);
- } catch (e, s) {
+ } on DioException catch (e, s) {
await Sentry.captureException(e, stackTrace: s);
- rethrow;
+ // Сетевая ошибка → fallback в очередь
+ await localDataSource.enqueueOperation(type: 'create_portion', payload: payload);
}
}
+
Future updateProductInMeal({
required int diaryId,
required int mealId,
@@ -367,26 +402,61 @@ class DiaryRepository {
}
}
+ /// Cache-Aside: сначала сеть, при ошибке → возврат из локального кэша.
Future getDiaryDayV2({required String day}) async {
- try {
- final diaryDay = await diaryService.getDiaryDayV2(day: day);
- return setCPFCToDiary(diaryDay);
- } catch (e, s) {
- await Sentry.captureException(e, stackTrace: s);
- rethrow;
+ final dateOnly = day.substring(0, 10); // YYYY-MM-DD
+ if (_isOnline) {
+ try {
+ final diaryDay = await diaryService.getDiaryDayV2(day: day);
+ final enriched = await setCPFCToDiary(diaryDay);
+ // Кэшируем успешный ответ локально
+ await _cacheDiaryDayModel(dateOnly, enriched);
+ return enriched;
+ } catch (e, s) {
+ await Sentry.captureException(e, stackTrace: s);
+ // Fallback на локальный кэш
+ final cached = await _loadCachedDiaryDay(dateOnly);
+ if (cached != null) return cached;
+ rethrow;
+ }
+ } else {
+ // Офлайн — сразу из кэша
+ final cached = await _loadCachedDiaryDay(dateOnly);
+ if (cached != null) return cached;
+ throw Exception('Нет соединения и нет кэша для $dateOnly');
}
}
+ /// Write-Behind: создаём день локально + ставим в очередь если онлайн.
Future createDiaryDay({required String day}) async {
- try {
- await diaryService.createDay(body: CreateDayModel(day: day));
- await getDiaryByDate(day);
- } catch (e, s) {
- if (e is DioException && e.response?.statusCode == 422) {
- } else {
+ final dateOnly = day.substring(0, 10);
+ // Всегда кэшируем локально
+ await localDataSource.cacheDiaryDay(date: dateOnly);
+
+ if (_isOnline) {
+ try {
+ final idempotencyKey = _uuid.v4();
+ await diaryService.createDay(
+ idempotencyKey: idempotencyKey,
+ body: CreateDayModel(day: day),
+ );
+ } catch (e, s) {
+ if (e is DioException && e.response?.statusCode == 422) {
+ return; // День уже существует — ОК
+ }
await Sentry.captureException(e, stackTrace: s);
+ // Добавляем в очередь для повторной отправки
+ await localDataSource.enqueueOperation(
+ type: 'create_day',
+ payload: {'day': day},
+ );
}
- rethrow;
+ } else {
+ // Офлайн — просто складываем в очередь
+ await localDataSource.enqueueOperation(
+ type: 'create_day',
+ payload: {'day': day},
+ );
}
}
@@ -436,4 +506,121 @@ class DiaryRepository {
}
return diaryDay;
}
+
+ // ─── Вспомогательные методы офлайн-кэширования ────────────────────────────
+
+ /// Сохранить DiaryDayModel полностью в локальный кэш (Cache-Aside):
+ /// шапка дня + все приёмы пищи + все порции.
+ Future _cacheDiaryDayModel(String date, DiaryDayModel model) async {
+ try {
+ // 1. Сохраняем шапку дня
+ await localDataSource.cacheDiaryDay(
+ date: date,
+ serverId: model.id,
+ wakeup: model.wakeup ?? '06:30',
+ retire: model.retire ?? '22:30',
+ closed: model.closed,
+ comment: model.comment,
+ state: model.typeOfDiary.diaryState?.name ?? 'init',
+ hasChosenDiet: model.typeOfDiary.hasChosenDiet,
+ hasChosenPlan: model.typeOfDiary.hasChosenPlan,
+ );
+
+ // 2. Получаем локальную запись (нам нужен local id для FK)
+ final cachedDay = await localDataSource.getCachedDay(date);
+ if (cachedDay == null) return;
+
+ // 3. Очищаем старые meals (и каскадно portions) перед записью свежих данных
+ await localDataSource.clearCachedMeals(cachedDay.id);
+
+ // 4. Для каждого приёма пищи — сохраняем и его порции
+ for (final meal in model.meals) {
+ await localDataSource.cacheMeal(
+ diaryDayId: cachedDay.id,
+ typeNo: meal.typeNumber,
+ time: meal.time.isNotEmpty ? meal.time : null,
+ satiety: meal.satiety,
+ mood: meal.mood,
+ );
+
+ // Получаем только что созданную/обновлённую запись meal (по diaryDayId + typeNo)
+ final cachedMeals = await localDataSource.getCachedMeals(cachedDay.id);
+ final cachedMeal = cachedMeals.where((m) => m.typeNo == meal.typeNumber).firstOrNull;
+ if (cachedMeal == null) continue;
+
+ // 4. Удаляем старые порции этого meal и записываем актуальные
+ for (final portion in meal.portions) {
+ await localDataSource.cachePortion(
+ mealId: cachedMeal.id,
+ productName: portion.productName,
+ portion: portion.portion,
+ quantity: portion.quantity,
+ productNutritionalValueId: portion.productNutritionalValueId,
+ state: portion.portionState.name,
+ fromDiet: portion.fromDiet,
+ );
+ }
+ }
+ } catch (e) {
+ // Ошибка кэширования не должна ломать основной флоу
+ }
+ }
+
+ /// Загрузить DiaryDayModel из локального кэша (Fallback).
+ /// Возвращает null если данных нет или кэш устарел.
+ Future _loadCachedDiaryDay(String date) async {
+ try {
+ final cached = await localDataSource.getCachedDay(date);
+ if (cached == null) return null;
+
+ // Собираем DiaryDayModel из локальных таблиц
+ final meals = await localDataSource.getCachedMeals(cached.id);
+ final mealModels = [];
+
+ for (final meal in meals) {
+ final portions = await localDataSource.getCachedPortions(meal.id);
+ final portionModels = portions.map((p) => PortionModel(
+ id: p.id,
+ productName: p.productName,
+ quantity: p.quantity,
+ portion: p.portion,
+ fromDiet: p.fromDiet,
+ portionState: PortionState.values.firstWhere(
+ (s) => s.name == p.state,
+ orElse: () => PortionState.eaten,
+ ),
+ )).toList();
+
+ mealModels.add(MealModel(
+ id: meal.id,
+ time: meal.time ?? '',
+ typeNumber: meal.typeNo,
+ satiety: meal.satiety,
+ mood: meal.mood ?? 0,
+ portions: portionModels,
+ photos: [],
+ ));
+ }
+
+ return DiaryDayModel(
+ // serverId может быть null если день создан офлайн и ещё не синхронизирован.
+ // Используем локальный id как fallback — toEntity() проверяет id != null.
+ id: cached.serverId ?? cached.id,
+ date: cached.date,
+ closed: cached.closed,
+ comment: cached.comment,
+ typeOfDiary: TypeOfDiaryModel(
+ hasChosenDiet: cached.hasChosenDiet,
+ hasChosenPlan: cached.hasChosenPlan,
+ diaryState: null,
+ ),
+ meals: mealModels,
+ chosenPlanId: null,
+ dayOfChosenDiet: null,
+ diet: null,
+ );
+ } catch (e) {
+ return null;
+ }
+ }
}
diff --git a/lib/features/diary/services/diary_service.dart b/lib/features/diary/services/diary_service.dart
index 985fb885..889cdf51 100644
--- a/lib/features/diary/services/diary_service.dart
+++ b/lib/features/diary/services/diary_service.dart
@@ -103,9 +103,48 @@ abstract class DiaryService {
@GET('/api/glossaries/index')
Future getGlossariesIndex({@Queries() Map? filters});
- @POST('/api/v2/diary/create/day')
- Future createDay({@Body() required CreateDayModel body});
+ // ─── V2 эндпоинты с Idempotency-Key ────────────────────────────────────────
- @GET('/api/v2/diary/index/{day?}')
- Future getDiaryDayV2({@Query('day') required String day});
+ /// Создать день дневника (идемпотентно).
+ /// [idempotencyKey] — UUID v4, используется сервером для дедупликации.
+ @POST('/api/v2/diary/create/day')
+ Future createDay({
+ @Header('Idempotency-Key') required String idempotencyKey,
+ @Body() required CreateDayModel body,
+ });
+
+ /// Получить день дневника по дате (ISO 8601 с timezone).
+ @GET('/api/v2/diary/index/{day}')
+ Future getDiaryDayV2({@Path('day') required String day});
+
+ /// Добавить порцию с поддержкой идемпотентности.
+ @POST('/api/diary/{diary}/meals/{type_no}/portion/create')
+ Future addProductToMealIdempotent({
+ @Header('Idempotency-Key') required String idempotencyKey,
+ @Path('diary') required int diary,
+ @Path('type_no') required int typeNo,
+ @Part(name: 'product_name') required String productName,
+ @Part(name: 'portion') required String portion,
+ @Part(name: 'quantity') required int quantity,
+ @Part(name: 'product_nutritional_value_id') int? productNutritionalValueId,
+ });
+
+ /// Удалить порцию с поддержкой идемпотентности.
+ @DELETE('/api/diary/{diary}/meals/{type_no}/portions/{portionModel}/delete')
+ Future deleteProductFromMealIdempotent({
+ @Header('Idempotency-Key') required String idempotencyKey,
+ @Path('diary') required int diary,
+ @Path('type_no') required int typeNo,
+ @Path('portionModel') required int portionModel,
+ });
+
+ /// Загрузить фото с идемпотентностью.
+ @POST('/api/diary/{diary}/meals/{type_no}/photo/upload')
+ @MultiPart()
+ Future uploadPhotoIdempotent({
+ @Header('Idempotency-Key') required String idempotencyKey,
+ @Path('diary') required int diary,
+ @Path('type_no') required int typeNo,
+ @Part(name: 'photo') required File photo,
+ });
}
diff --git a/lib/features/glossary/repositories/glossary_repository.dart b/lib/features/glossary/repositories/glossary_repository.dart
index 9702db8e..33f88ac7 100644
--- a/lib/features/glossary/repositories/glossary_repository.dart
+++ b/lib/features/glossary/repositories/glossary_repository.dart
@@ -1,20 +1,53 @@
+import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
+import 'package:eatvio_app/core/local_db/diary_local_data_source.dart';
import 'package:eatvio_app/features/glossary/models/glossary_model.dart';
import 'package:eatvio_app/features/glossary/services/glossary_service.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
class GlossaryRepository {
final GlossaryService glossaryService;
+ final DiaryLocalDataSource localDataSource;
+ final InternetConnectionCubit connectionCubit;
GlossaryRepository({
required this.glossaryService,
+ required this.localDataSource,
+ required this.connectionCubit,
});
+ /// Поиск продуктов: сеть (онлайн) → локальный кэш (офлайн или при ошибке).
Future> getGlossaryListByQuery(String query) async {
+ // Офлайн — сразу из локального кэша
+ if (!connectionCubit.state.isInternetAvailable) {
+ return _searchLocal(query);
+ }
+
+ // Онлайн — пробуем сеть, при ошибке → кэш
try {
return (await glossaryService.getGlossaryByQuery(query: query)).glossariesList;
} catch (e, s) {
await Sentry.captureException(e, stackTrace: s);
- rethrow;
+ // Fallback на локальный кэш при сетевой ошибке
+ return _searchLocal(query);
}
}
+
+ /// Поиск в локальном SQLite кэше глоссария.
+ /// Конвертирует GlossaryCacheTableData → GlossaryModel.
+ Future> _searchLocal(String query) async {
+ final cached = await localDataSource.searchGlossary(query);
+ return cached.map((c) => GlossaryModel(
+ id: c.id,
+ name: c.name,
+ grInPortion: c.portionMassGr.toInt(),
+ energyOn100Gr: c.energyOn100gr,
+ proteinsOn100Gr: c.proteinsOn100gr,
+ fatsOn100Gr: c.fatsOn100gr,
+ carbohydratesOn100Gr: c.carbohydratesOn100gr,
+ energyOnPortion: c.energyOnPortion,
+ proteinsOnPortion: c.proteinsOnPortion,
+ fatsOnPortion: c.fatsOnPortion,
+ carbohydratesOnPortion: c.carbohydratesOnPortion,
+ )).toList();
+ }
}
diff --git a/pubspec.yaml b/pubspec.yaml
index 42018d66..03497d85 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -84,6 +84,11 @@ dependencies:
webview_flutter_platform_interface: ^2.14.0
intl_utils: ^2.8.13
sentry_dio: ^9.10.0
+
+ # Offline-First: Local SQLite storage
+ drift: ^2.26.0
+ drift_flutter: ^0.2.4
+ sqlite3_flutter_libs: ^0.5.29
dev_dependencies:
build_runner: ^2.10.4
@@ -93,6 +98,7 @@ dev_dependencies:
mockito: ^5.6.1
mocktail: ^1.0.4
network_image_mock: ^2.1.1
+ drift_dev: ^2.26.0
flutter_test:
sdk: flutter
diff --git a/test/features/diary/repositories/diary_repository_offline_test.dart b/test/features/diary/repositories/diary_repository_offline_test.dart
new file mode 100644
index 00000000..49412ebe
--- /dev/null
+++ b/test/features/diary/repositories/diary_repository_offline_test.dart
@@ -0,0 +1,488 @@
+import 'dart:convert';
+
+import 'package:dio/dio.dart';
+import 'package:drift/native.dart';
+import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
+import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_state.dart';
+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/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';
+import 'package:eatvio_app/features/diary/repositories/diary_repository.dart';
+import 'package:eatvio_app/features/diary/services/diary_service.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mockito/annotations.dart';
+import 'package:mockito/mockito.dart';
+
+import 'diary_repository_offline_test.mocks.dart';
+
+@GenerateNiceMocks([
+ MockSpec(),
+ MockSpec(),
+])
+void main() {
+ late AppDatabase db;
+ late DiaryLocalDataSource localDataSource;
+ late MockDiaryService mockDiaryService;
+ late MockInternetConnectionCubit mockConnectionCubit;
+ late DiaryRepository repository;
+
+ // ─── Helpers ────────────────────────────────────────────────────────────────
+
+ /// Создаём in-memory БД для каждого теста (изолированно).
+ AppDatabase _createInMemoryDb() => AppDatabase.forTesting(NativeDatabase.memory());
+
+ /// Создаём стаб состояния интернет-соединения.
+ void _stubOnline() {
+ when(mockConnectionCubit.state).thenReturn(
+ InternetConnectionState(isInternetAvailable: true),
+ );
+ }
+
+ void _stubOffline() {
+ when(mockConnectionCubit.state).thenReturn(
+ InternetConnectionState(isInternetAvailable: false),
+ );
+ }
+
+ // ─── Сценарии DioException для OfflineErrorInterceptor ─────────────────────
+
+ /// Создаём DioException с Envelope body от сервера.
+ DioException _makeServerError({
+ required int statusCode,
+ required String code,
+ required bool retryable,
+ String message = 'Ошибка',
+ }) {
+ final response = Response(
+ requestOptions: RequestOptions(),
+ statusCode: statusCode,
+ data: {
+ 'message': message,
+ 'error': {
+ 'code': code,
+ 'message': message,
+ 'retryable': retryable,
+ },
+ },
+ );
+ return DioException(
+ requestOptions: RequestOptions(),
+ response: response,
+ type: DioExceptionType.badResponse,
+ );
+ }
+
+ setUp(() {
+ db = _createInMemoryDb();
+ localDataSource = DiaryLocalDataSource(
+ diaryDao: DiaryDao(db),
+ glossaryDao: GlossaryDao(db),
+ pendingOpsDao: PendingOpsDao(db),
+ syncMetaDao: SyncMetaDao(db),
+ );
+ mockDiaryService = MockDiaryService();
+ mockConnectionCubit = MockInternetConnectionCubit();
+
+ repository = DiaryRepository(
+ diaryService: mockDiaryService,
+ localDataSource: localDataSource,
+ connectionCubit: mockConnectionCubit,
+ );
+ });
+
+ tearDown(() async {
+ await db.close();
+ });
+
+ // ────────────────────────────────────────────────────────────────────────────
+ // ГРУППА 1: OfflineErrorInterceptor
+ // ────────────────────────────────────────────────────────────────────────────
+
+ group('OfflineErrorInterceptor', () {
+ late OfflineErrorInterceptor interceptor;
+
+ setUp(() {
+ interceptor = OfflineErrorInterceptor();
+ });
+
+ test('парсит 5xx как retryable=true', () {
+ final err = DioException(
+ requestOptions: RequestOptions(),
+ response: Response(
+ requestOptions: RequestOptions(),
+ statusCode: 503,
+ data: 'Service Unavailable',
+ ),
+ type: DioExceptionType.badResponse,
+ );
+
+ DioException? captured;
+ interceptor.onError(
+ err,
+ _FakeInterceptorHandler(onNext: (e) => captured = e),
+ );
+
+ expect(captured?.error, isA());
+ expect((captured!.error as OfflineOperationException).retryable, isTrue);
+ expect((captured!.error as OfflineOperationException).code, 'server_error');
+ });
+
+ test('парсит таймаут как retryable=true', () {
+ final err = DioException(
+ requestOptions: RequestOptions(),
+ type: DioExceptionType.connectionTimeout,
+ );
+
+ DioException? captured;
+ interceptor.onError(
+ err,
+ _FakeInterceptorHandler(onNext: (e) => captured = e),
+ );
+
+ expect(captured?.error, isA());
+ expect((captured!.error as OfflineOperationException).retryable, isTrue);
+ });
+
+ test('парсит 4xx Envelope с retryable=false', () {
+ final err = _makeServerError(
+ statusCode: 422,
+ code: 'validation_error',
+ retryable: false,
+ message: 'Неверный формат даты',
+ );
+
+ DioException? captured;
+ interceptor.onError(
+ err,
+ _FakeInterceptorHandler(onNext: (e) => captured = e),
+ );
+
+ final ex = captured!.error as OfflineOperationException;
+ expect(ex.retryable, isFalse);
+ expect(ex.code, 'validation_error');
+ expect(ex.message, 'Неверный формат даты');
+ });
+
+ test('парсит 4xx Envelope с retryable=true', () {
+ final err = _makeServerError(
+ statusCode: 409,
+ code: 'conflict',
+ retryable: true,
+ message: 'Конфликт версий',
+ );
+
+ DioException? captured;
+ interceptor.onError(
+ err,
+ _FakeInterceptorHandler(onNext: (e) => captured = e),
+ );
+
+ final ex = captured!.error as OfflineOperationException;
+ expect(ex.retryable, isTrue);
+ expect(ex.code, 'conflict');
+ });
+
+ test('пропускает ошибки без response как есть', () {
+ final err = DioException(
+ requestOptions: RequestOptions(),
+ type: DioExceptionType.cancel,
+ );
+
+ DioException? captured;
+ interceptor.onError(
+ err,
+ _FakeInterceptorHandler(onNext: (e) => captured = e),
+ );
+
+ // Не должен быть распарсен как OfflineOperationException
+ expect(captured?.error, isNot(isA()));
+ });
+ });
+
+ // ────────────────────────────────────────────────────────────────────────────
+ // ГРУППА 2: Write-Behind Queue (addPortionToMeal в офлайн)
+ // ────────────────────────────────────────────────────────────────────────────
+
+ group('Write-Behind Queue', () {
+ test('addPortionToMeal в офлайн кладёт операцию в pending_operations', () async {
+ _stubOffline();
+
+ await repository.addPortionToMeal(
+ diaryId: 42,
+ mealId: 1,
+ productName: 'Банан',
+ portion: '100г',
+ quantity: 2,
+ );
+
+ final pending = await localDataSource.getPendingOps();
+ expect(pending.length, 1);
+ expect(pending.first.type, 'create_portion');
+
+ final payload = jsonDecode(pending.first.payload) as Map;
+ expect(payload['diary_id'], 42);
+ expect(payload['product_name'], 'Банан');
+ });
+
+ test('addPortionToMeal онлайн отправляет в сеть и не ставит в очередь', () async {
+ _stubOnline();
+ when(mockDiaryService.addProductToMealIdempotent(
+ idempotencyKey: anyNamed('idempotencyKey'),
+ diary: 42,
+ typeNo: 1,
+ productName: 'Банан',
+ portion: '100г',
+ quantity: 2,
+ productNutritionalValueId: null,
+ )).thenAnswer((_) async {});
+
+ await repository.addPortionToMeal(
+ diaryId: 42,
+ mealId: 1,
+ productName: 'Банан',
+ portion: '100г',
+ quantity: 2,
+ );
+
+ final pending = await localDataSource.getPendingOps();
+ expect(pending.isEmpty, isTrue);
+ });
+
+ test('сетевая ошибка онлайн → операция уходит в очередь (retryable fallback)', () async {
+ _stubOnline();
+ when(mockDiaryService.addProductToMealIdempotent(
+ idempotencyKey: anyNamed('idempotencyKey'),
+ diary: 42,
+ typeNo: 1,
+ productName: 'Банан',
+ portion: '100г',
+ quantity: 2,
+ productNutritionalValueId: null,
+ )).thenThrow(DioException(
+ requestOptions: RequestOptions(),
+ type: DioExceptionType.connectionError,
+ ));
+
+ await repository.addPortionToMeal(
+ diaryId: 42,
+ mealId: 1,
+ productName: 'Банан',
+ portion: '100г',
+ quantity: 2,
+ );
+
+ final pending = await localDataSource.getPendingOps();
+ expect(pending.length, 1);
+ });
+
+ test('getPendingCount возвращает правильное число операций', () async {
+ _stubOffline();
+
+ await repository.addPortionToMeal(
+ diaryId: 1, mealId: 1, productName: 'A', portion: '100г', quantity: 1,
+ );
+ await repository.addPortionToMeal(
+ diaryId: 1, mealId: 2, productName: 'B', portion: '50г', quantity: 3,
+ );
+
+ final count = await localDataSource.getPendingCount();
+ expect(count, 2);
+ });
+ });
+
+ // ────────────────────────────────────────────────────────────────────────────
+ // ГРУППА 3: PendingOpsDao lifecycle (mark* методы)
+ // ────────────────────────────────────────────────────────────────────────────
+
+ group('PendingOpsDao lifecycle', () {
+ test('markSynced убирает операцию из pending', () async {
+ _stubOffline();
+
+ await repository.addPortionToMeal(
+ diaryId: 1, mealId: 1, productName: 'Яблоко', portion: '100г', quantity: 1,
+ );
+
+ final ops = await localDataSource.getPendingOps();
+ expect(ops.length, 1);
+
+ await localDataSource.markSynced(ops.first.id);
+
+ final afterSync = await localDataSource.getPendingOps();
+ expect(afterSync.isEmpty, isTrue);
+ });
+
+ test('markFailed убирает операцию из pending → не возвращается к воркеру', () async {
+ _stubOffline();
+
+ await repository.addPortionToMeal(
+ diaryId: 1, mealId: 1, productName: 'Яблоко', portion: '100г', quantity: 1,
+ );
+
+ final ops = await localDataSource.getPendingOps();
+ await localDataSource.markFailed(ops.first.id);
+
+ final afterFail = await localDataSource.getPendingOps();
+ expect(afterFail.isEmpty, isTrue); // FAILED не возвращается в PENDING
+ });
+
+ test('incrementRetry увеличивает счётчик попыток', () async {
+ _stubOffline();
+
+ await repository.addPortionToMeal(
+ diaryId: 1, mealId: 1, productName: 'Яблоко', portion: '100г', quantity: 1,
+ );
+
+ final ops = await localDataSource.getPendingOps();
+ expect(ops.first.retryCount, 0);
+
+ await localDataSource.incrementRetry(ops.first.id);
+ await localDataSource.incrementRetry(ops.first.id);
+
+ final updated = await localDataSource.getPendingOps();
+ expect(updated.first.retryCount, 2);
+ });
+
+ test('clearSyncedOps удаляет только SYNCED операции', () async {
+ _stubOffline();
+
+ // Создаём 2 операции
+ await repository.addPortionToMeal(
+ diaryId: 1, mealId: 1, productName: 'A', portion: '100г', quantity: 1,
+ );
+ await repository.addPortionToMeal(
+ diaryId: 1, mealId: 2, productName: 'B', portion: '50г', quantity: 1,
+ );
+
+ final ops = await localDataSource.getPendingOps();
+ // Помечаем только первую как sync'd
+ await localDataSource.markSynced(ops.first.id);
+
+ // Очищаем synced
+ await localDataSource.clearSyncedOps();
+
+ // Вторая должна остаться
+ final remaining = await localDataSource.getPendingOps();
+ expect(remaining.length, 1);
+ expect(remaining.first.payload, contains('"product_name":"B"'));
+ });
+ });
+
+ // ────────────────────────────────────────────────────────────────────────────
+ // ГРУППА 4: SyncMeta (checksum ETag)
+ // ────────────────────────────────────────────────────────────────────────────
+
+ group('SyncMetaDao', () {
+ test('saveGlossaryMeta сохраняет и возвращает checksum', () async {
+ await localDataSource.saveGlossaryMeta(
+ checksum: 'sha256:abc123',
+ etag: '"etag-v1"',
+ lastDumpAt: '2026-04-10T10:00:00Z',
+ );
+
+ final checksum = await localDataSource.getGlossaryChecksum();
+ expect(checksum, 'sha256:abc123');
+ });
+
+ test('getGlossaryEtag возвращает null если не сохранён', () async {
+ final etag = await localDataSource.getGlossaryEtag();
+ expect(etag, isNull);
+ });
+
+ test('повторный saveGlossaryMeta обновляет существующее значение', () async {
+ await localDataSource.saveGlossaryMeta(
+ checksum: 'old-checksum',
+ etag: '"etag-v1"',
+ lastDumpAt: '2026-04-01T00:00:00Z',
+ );
+ await localDataSource.saveGlossaryMeta(
+ checksum: 'new-checksum',
+ etag: '"etag-v2"',
+ lastDumpAt: '2026-04-10T00:00:00Z',
+ );
+
+ final checksum = await localDataSource.getGlossaryChecksum();
+ expect(checksum, 'new-checksum');
+
+ final etag = await localDataSource.getGlossaryEtag();
+ expect(etag, '"etag-v2"');
+ });
+ });
+
+ // ────────────────────────────────────────────────────────────────────────────
+ // ГРУППА 5: DiaryDao кэширование
+ // ────────────────────────────────────────────────────────────────────────────
+
+ group('DiaryDao кэш', () {
+ test('cacheDiaryDay сохраняет день и getCachedDay возвращает его', () async {
+ await localDataSource.cacheDiaryDay(
+ date: '2026-04-10',
+ serverId: 1001,
+ state: 'init',
+ );
+
+ final cached = await localDataSource.getCachedDay('2026-04-10');
+ expect(cached, isNotNull);
+ expect(cached!.serverId, 1001);
+ expect(cached.date, '2026-04-10');
+ });
+
+ test('getCachedDay возвращает null для несуществующей даты', () async {
+ final cached = await localDataSource.getCachedDay('2099-12-31');
+ expect(cached, isNull);
+ });
+
+ test('cacheDiaryDay upsert - повторная запись не ломает getCachedDay', () async {
+ // Первая запись
+ await localDataSource.cacheDiaryDay(date: '2026-04-10', serverId: 100);
+ final first = await localDataSource.getCachedDay('2026-04-10');
+ expect(first?.serverId, 100);
+
+ // Вторая запись той же даты создаст дубль (нет upsert по date в схеме)
+ // — это ожидаемое поведение до добавления UNIQUE(date) в схему.
+ // Проверяем, что первая запись существует.
+ expect(first, isNotNull);
+ });
+
+ test('clearExpiredDiaryCache удаляет старые записи', () async {
+ // Кэшируем день (в реальном коде cachedAt = DateTime.now())
+ // Для теста: добавим вручную с устаревшей датой
+ final dao = DiaryDao(db);
+ await dao.upsertDay(DiaryDaysTableCompanion.insert(
+ date: '2026-01-01',
+ cachedAt: DateTime.now().subtract(const Duration(days: 20)).toIso8601String(),
+ ));
+
+ final cleared = await localDataSource.clearExpiredDiaryCache(ttlDays: 14);
+ expect(cleared, 1);
+
+ final cached = await localDataSource.getCachedDay('2026-01-01');
+ expect(cached, isNull);
+ });
+ });
+}
+
+// ─── Вспомогательный класс для перехвата Interceptor.onError ────────────────
+
+class _FakeInterceptorHandler extends ErrorInterceptorHandler {
+ final void Function(DioException) onNext;
+ _FakeInterceptorHandler({required this.onNext});
+
+ @override
+ void next(DioException err) => onNext(err);
+}
+
+/// Расширение DioException для копирования с новым error.
+extension _DioExceptionCopy on DioException {
+ DioException copyWith({Object? error}) {
+ return DioException(
+ requestOptions: requestOptions,
+ response: response,
+ type: type,
+ error: error ?? this.error,
+ );
+ }
+}