feat: implement offline-first architecture with local database synchronization and diary caching

This commit is contained in:
mso7nueh
2026-04-20 11:33:34 +03:00
parent 4f241f7db6
commit ed60588988
35 changed files with 2644 additions and 161 deletions

File diff suppressed because one or more lines are too long

View File

@@ -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<DiaryLocalDataSource>(),
connectionCubit: sl<InternetConnectionCubit>(),
),
);
sl.registerFactory<SettingsCubit>(

View File

@@ -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>(() => AppDatabase());
sl.registerLazySingleton<DiaryDao>(() => sl<AppDatabase>().diaryDao);
sl.registerLazySingleton<GlossaryDao>(() => sl<AppDatabase>().glossaryDao);
sl.registerLazySingleton<PendingOpsDao>(() => sl<AppDatabase>().pendingOpsDao);
sl.registerLazySingleton<SyncMetaDao>(() => sl<AppDatabase>().syncMetaDao);
sl.registerLazySingleton<DiaryLocalDataSource>(
() => DiaryLocalDataSource(
diaryDao: sl(),
glossaryDao: sl(),
pendingOpsDao: sl(),
syncMetaDao: sl(),
),
);
}
/// Регистрация Prefs
static void _registerPrefs() {
sl.registerSingletonAsync<Prefs>(() async => Prefs(await SharedPreferences.getInstance()));

View File

@@ -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>(() => CalendarRepository(calendarService: sl()));
sl.registerLazySingleton<SettingsRepository>(() => SettingsRepository(settingsService: sl()));
sl.registerLazySingleton<ConsultantRepository>(() => ConsultantRepository(consultantService: sl()));
sl.registerLazySingleton<DiaryRepository>(() => DiaryRepository(diaryService: sl()));
sl.registerLazySingleton<GlossaryRepository>(() => GlossaryRepository(glossaryService: sl()));
sl.registerLazySingleton<DiaryRepository>(
() => DiaryRepository(
diaryService: sl(),
localDataSource: sl<DiaryLocalDataSource>(),
connectionCubit: sl<InternetConnectionCubit>(),
),
);
// Offline-First sync services
sl.registerLazySingleton<GlossarySyncService>(
() => GlossarySyncService(
api: sl<GlossarySyncApiService>(),
dio: sl<Dio>(instanceName: 'glossaryDio'),
localDataSource: sl(),
),
);
sl.registerLazySingleton<DiarySyncRunner>(
() => DiarySyncRunner(
localDataSource: sl(),
diaryService: sl(),
connectionCubit: sl(),
glossarySyncService: sl<GlossarySyncService>(),
),
);
sl.registerLazySingleton<GlossaryRepository>(
() => GlossaryRepository(
glossaryService: sl(),
localDataSource: sl<DiaryLocalDataSource>(),
connectionCubit: sl<InternetConnectionCubit>(),
),
);
sl.registerLazySingleton<BarcodeRepository>(() => BarcodeRepository(barcodeService: sl(), dio: sl()));
sl.registerLazySingleton<DietRepository>(() => DietRepository(dietsService: sl()));
sl.registerLazySingleton<PlansRepository>(() => PlansRepository(plansService: sl()));

View File

@@ -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>(() => SettingsService(sl()));
sl.registerLazySingleton<ConsultantService>(() => ConsultantService(sl()));
sl.registerLazySingleton<DiaryService>(() => DiaryService(sl()));
// GlossarySyncApiService: отдельный Dio с увеличенным таймаутом
// — дамп глоссария (~200k записей) идёт ~35 секунд, стандартные 30s не хватает
// — также используется в GlossarySyncService для dio.download()
final glossaryDio = Dio(
BaseOptions(
baseUrl: sl<AppConfig>().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<Dio>(() => glossaryDio, instanceName: 'glossaryDio');
sl.registerLazySingleton<GlossarySyncApiService>(() => GlossarySyncApiService(glossaryDio));
sl.registerLazySingleton<GlossaryService>(() => GlossaryService(sl()));
sl.registerLazySingleton<BarcodeService>(() => BarcodeService(sl()));
sl.registerLazySingleton<DietService>(() => DietService(sl()));

View File

@@ -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<String, dynamic> && data.containsKey('error')) {
final errorEnvelope = data['error'] as Map<String, dynamic>;
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);
}
}

View File

@@ -55,7 +55,7 @@ class InternetConnectionCubit extends Cubit<InternetConnectionState> {
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;

View File

@@ -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');
}
}

View File

@@ -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<AppDatabase> with _$DiaryDaoMixin {
DiaryDao(super.db);
// ─── Diary Days ────────────────────────────────────────────────────────────
/// Получить день дневника по дате (формат: YYYY-MM-DD).
/// Возвращает null если день не кэширован локально.
Future<DiaryDaysTableData?> getDayByDate(String date) {
return (select(db.diaryDaysTable)..where((t) => t.date.equals(date))).getSingleOrNull();
}
/// Получить все meal для дня дневника.
Future<List<MealsTableData>> getMealsForDay(int diaryDayId) {
return (select(db.mealsTable)..where((t) => t.diaryDayId.equals(diaryDayId))).get();
}
/// Получить все порции для конкретного meal.
Future<List<PortionsTableData>> getPortionsForMeal(int mealId) {
return (select(db.portionsTable)..where((t) => t.mealId.equals(mealId))).get();
}
/// Upsert целого дня из сети (вставка или обновление по дате).
/// Используем ON CONFLICT(date) т.к. уникальность по дате, а не по autoincrement id.
Future<int> upsertDay(DiaryDaysTableCompanion day) {
return into(db.diaryDaysTable).insert(
day,
onConflict: DoUpdate(
(old) => day,
target: [db.diaryDaysTable.date],
),
);
}
/// Upsert приема пищи (вставка или обновление по diaryDayId + typeNo).
Future<int> upsertMeal(MealsTableCompanion meal) {
return into(db.mealsTable).insert(
meal,
onConflict: DoUpdate(
(old) => meal,
target: [db.mealsTable.diaryDayId, db.mealsTable.typeNo],
),
);
}
/// Upsert порции (вставка или обновление по id).
Future<int> upsertPortion(PortionsTableCompanion portion) {
return into(db.portionsTable).insertOnConflictUpdate(portion);
}
/// Обновить локальный serverId дня после успешной синхронизации.
Future<void> updateServerId(int localId, int serverId) async {
await (update(db.diaryDaysTable)..where((t) => t.id.equals(localId)))
.write(DiaryDaysTableCompanion(serverId: Value(serverId)));
}
/// Удалить все приёмы пищи и порции для дня (используется при перезаписи из сети).
Future<void> clearMealsForDay(int diaryDayId) async {
await (delete(db.mealsTable)..where((t) => t.diaryDayId.equals(diaryDayId))).go();
}
/// Очистить кэш дней дневника старше [ttlDays] дней.
/// Вызывается периодически для экономии места на устройстве.
Future<int> 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();
}
}

View File

@@ -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<AppDatabase> with _$GlossaryDaoMixin {
GlossaryDao(super.db);
// ─── Read ──────────────────────────────────────────────────────────────────
/// Получить продукты по списку ID (для обогащения порций КБЖУ).
Future<List<GlossaryCacheTableData>> getByIds(List<int> ids) {
return (select(db.glossaryCacheTable)..where((t) => t.id.isIn(ids))).get();
}
/// Поиск по названию (нечёткий, регистронезависимый).
Future<List<GlossaryCacheTableData>> search(String query, {int limit = 30}) {
return (select(db.glossaryCacheTable)
..where((t) => t.name.like('%$query%'))
..limit(limit))
.get();
}
/// Фильтр по группе продуктов.
Future<List<GlossaryCacheTableData>> getByGroup(int groupId, {int limit = 50}) {
return (select(db.glossaryCacheTable)
..where((t) => t.productGroupId.equals(groupId))
..limit(limit))
.get();
}
/// Количество продуктов в кэше (для сверки с /meta total_count).
Future<int> 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<void> bulkUpsert(List<GlossaryCacheTableCompanion> items) async {
await batch((batch) {
batch.insertAllOnConflictUpdate(db.glossaryCacheTable, items);
});
}
/// Очистить весь кэш глоссария (используется при первом полном дампе).
Future<int> clearAll() {
return delete(db.glossaryCacheTable).go();
}
}

View File

@@ -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<AppDatabase> with _$PendingOpsDaoMixin {
PendingOpsDao(super.db);
static const _statusPending = 'pending';
static const _statusSynced = 'synced';
static const _statusFailed = 'failed';
// ─── Read ──────────────────────────────────────────────────────────────────
/// Получить все PENDING операции в порядке создания (FIFO).
Future<List<PendingOperationsTableData>> getPendingOps() {
return (select(db.pendingOperationsTable)
..where((t) => t.status.equals(_statusPending))
..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
.get();
}
/// Получить одну операцию по ID.
Future<PendingOperationsTableData?> getById(String id) {
return (select(db.pendingOperationsTable)..where((t) => t.id.equals(id))).getSingleOrNull();
}
/// Количество PENDING операций (для отображения в UI).
Future<int> 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<void> enqueue(PendingOperationsTableCompanion op) {
return into(db.pendingOperationsTable).insert(op);
}
/// Пометить операцию как успешно синхронизированную.
Future<void> markSynced(String id) async {
await (update(db.pendingOperationsTable)..where((t) => t.id.equals(id)))
.write(const PendingOperationsTableCompanion(status: Value(_statusSynced)));
}
/// Пометить операцию как ошибочную (retryable: false).
Future<void> markFailed(String id) async {
await (update(db.pendingOperationsTable)..where((t) => t.id.equals(id)))
.write(const PendingOperationsTableCompanion(status: Value(_statusFailed)));
}
/// Увеличить счётчик попыток (retryable: true).
Future<void> 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<int> clearSynced() {
return (delete(db.pendingOperationsTable)..where((t) => t.status.equals(_statusSynced))).go();
}
}

View File

@@ -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<AppDatabase> 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<String?> getValue(String key) async {
final row = await (select(db.syncMetaTable)..where((t) => t.key.equals(key))).getSingleOrNull();
return row?.value;
}
Future<void> setValue(String key, String value) {
return into(db.syncMetaTable).insertOnConflictUpdate(
SyncMetaTableCompanion(key: Value(key), value: Value(value)),
);
}
// ─── Typed accessors ──────────────────────────────────────────────────────
Future<String?> getGlossaryChecksum() => getValue(keyGlossaryChecksum);
Future<String?> getGlossaryEtag() => getValue(keyGlossaryEtag);
Future<String?> getGlossaryLastDumpAt() => getValue(keyGlossaryLastDumpAt);
Future<void> 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);
}
}

View File

@@ -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<DiaryDaysTableData?> getCachedDay(String date) {
return _diaryDao.getDayByDate(date);
}
Future<List<MealsTableData>> getCachedMeals(int diaryDayId) {
return _diaryDao.getMealsForDay(diaryDayId);
}
Future<List<PortionsTableData>> getCachedPortions(int mealId) {
return _diaryDao.getPortionsForMeal(mealId);
}
// ─── Дневник (запись/кэширование) ────────────────────────────────────────
Future<void> 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<void> 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<void> 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<void> clearCachedMeals(int diaryDayId) {
return _diaryDao.clearMealsForDay(diaryDayId);
}
Future<int> clearExpiredDiaryCache({int ttlDays = 14}) {
return _diaryDao.clearExpiredCache(ttlDays: ttlDays);
}
// ─── Глоссарий ────────────────────────────────────────────────────────────
Future<List<GlossaryCacheTableData>> getGlossaryByIds(List<int> ids) {
return _glossaryDao.getByIds(ids);
}
Future<List<GlossaryCacheTableData>> searchGlossary(String query) {
return _glossaryDao.search(query);
}
Future<List<GlossaryCacheTableData>> getGlossaryByGroup(int groupId) {
return _glossaryDao.getByGroup(groupId);
}
Future<int> getGlossaryCount() => _glossaryDao.getCount();
Future<void> bulkUpsertGlossary(List<GlossaryCacheTableCompanion> items) {
return _glossaryDao.bulkUpsert(items);
}
// ─── Очередь операций ────────────────────────────────────────────────────
/// Добавить операцию в очередь. Возвращает сгенерированный idempotency key (UUID v4).
Future<String> enqueueOperation({
required String type,
required Map<String, dynamic> 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<List<PendingOperationsTableData>> getPendingOps() {
return _pendingOpsDao.getPendingOps();
}
Future<int> getPendingCount() => _pendingOpsDao.getPendingCount();
Future<void> markSynced(String id) => _pendingOpsDao.markSynced(id);
Future<void> markFailed(String id) => _pendingOpsDao.markFailed(id);
Future<void> incrementRetry(String id) => _pendingOpsDao.incrementRetry(id);
Future<void> clearSyncedOps() => _pendingOpsDao.clearSynced();
// ─── Мета-данные синхронизации ────────────────────────────────────────────
Future<String?> getGlossaryChecksum() => _syncMetaDao.getGlossaryChecksum();
Future<String?> getGlossaryEtag() => _syncMetaDao.getGlossaryEtag();
Future<String?> getGlossaryLastDumpAt() => _syncMetaDao.getGlossaryLastDumpAt();
Future<void> saveGlossaryMeta({
required String checksum,
String? etag,
required String lastDumpAt,
}) {
return _syncMetaDao.saveGlossaryMeta(
checksum: checksum,
etag: etag,
lastDumpAt: lastDumpAt,
);
}
// ─── Утилиты ─────────────────────────────────────────────────────────────
String _encodePayload(Map<String, dynamic> payload) {
// Простая JSON-сериализация без зависимости от dart:convert
return _mapToJsonString(payload);
}
String _mapToJsonString(Map<String, dynamic> 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(',')}}';
}
}

View File

@@ -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<void> _syncGlossary() async {
try {
await _glossarySyncService.sync();
} catch (e) {
debugPrint('[DiarySyncRunner] Glossary sync error: $e');
}
}
/// Остановить прослушивание.
void dispose() {
_subscription?.cancel();
}
/// Обработать очередь pending_operations в FIFO порядке.
Future<void> _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<void> _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<void> _executeOperation(PendingOperationsTableData op) async {
final payload = jsonDecode(op.payload) as Map<String, dynamic>;
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;
}

View File

@@ -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<String, dynamic> json) =>
_$GlossaryDumpItemFromJson(json);
Map<String, dynamic> toJson() => _$GlossaryDumpItemToJson(this);
}
/// Ответ /api/glossaries/dump
@JsonSerializable()
class GlossaryDumpResponse {
final int total;
@JsonKey(name: 'updated_at')
final String updatedAt;
final List<GlossaryDumpItem> data;
const GlossaryDumpResponse({
required this.total,
required this.updatedAt,
required this.data,
});
factory GlossaryDumpResponse.fromJson(Map<String, dynamic> json) =>
_$GlossaryDumpResponseFromJson(json);
Map<String, dynamic> toJson() => _$GlossaryDumpResponseToJson(this);
}

View File

@@ -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<String, dynamic> json) =>
_$GlossaryMetaModelFromJson(json);
Map<String, dynamic> toJson() => _$GlossaryMetaModelToJson(this);
}

View File

@@ -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<int> 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<String, dynamic>;
final dataList = json['data'] as List<dynamic>;
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<String, dynamic>;
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});
}

View File

@@ -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<GlossaryMetaModel> getMeta();
/// Получить дамп продуктов (обновлённых после [updatedAfter]).
/// [updatedAfter] — ISO 8601 дата последней синхронизации из sync_meta.
/// [ifNoneMatch] — ETag из предыдущего ответа для условного запроса (304).
@GET('/api/glossaries/dump')
Future<HttpResponse<GlossaryDumpResponse>> getDump({
@Query('updated_after') String? updatedAfter,
@Header('If-None-Match') String? ifNoneMatch,
});
}

View File

@@ -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<Set<Column>> get uniqueKeys => [
{date},
];
}

View File

@@ -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<Column> get primaryKey => {id};
}

View File

@@ -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<Set<Column>> get uniqueKeys => [
{diaryDayId, typeNo},
];
}

View File

@@ -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<Column> get primaryKey => {id};
}

View File

@@ -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')();
}

View File

@@ -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<Column> get primaryKey => {key};
}

View File

@@ -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<void> init({required AppBuildType buildType}) async {
// Регистрация AppConfig
AppConfigRegistration.registerAppConfig(buildType: buildType);
// Регистрация внешних зависимостей (Dio, etc.)
// Регистрация внешних зависимостей (Dio, БД, etc.)
ExternalRegistrations.registerAll();
// Ожидание инициализации асинхронных зависимостей
@@ -33,4 +34,8 @@ Future<void> init({required AppBuildType buildType}) async {
// Регистрация WebSocket менеджера
sl.registerLazySingleton<WsManager>(() => WsManager(wsService: sl()));
// Запуск FIFO-воркера синхронизации дневника
// Подписывается на InternetConnectionCubit и обрабатывает очередь при появлении сети
sl<DiarySyncRunner>().start();
}

View File

@@ -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<DiaryState> {
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<DiaryState> {
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<DiaryState> {
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<DiaryState> {
Future<void> 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<DiaryState> {
}
Future<DiaryDayModel?> 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<DiaryState> {
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<DiaryState> {
chosenPlanName: planName,
isMoodEnabled: isMoodEnabled,
isWakeUpAndRetireEnabled: isWakeUpAndRetireTimeEnabled,
pendingOpsCount: pendingCount,
isOfflineMode: _lastFetchWasOffline,
),
);
} catch (e, st) {
@@ -295,8 +352,13 @@ class DiaryCubit extends Cubit<DiaryState> {
}
}
Future<NutritionDataModel> getNutrition(int index) async {
return (await diaryRepository.getNutritionData(index));
Future<NutritionDataModel?> getNutrition(int index) async {
try {
return await diaryRepository.getNutritionData(index);
} catch (e) {
log('getNutrition offline fallback: $e');
return null; // Офлайн — нет данных по калориям, не критично
}
}
Future<void> addProductToMeal({

View File

@@ -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}) =

View File

@@ -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<DiaryScreenContent> {
),
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<DiaryCubit>().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<DiaryCubit>().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<ConsultantCommentsCubit>().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<ConsultantCommentsCubit>().submitComments(state.diary.id);
},
),
const SizedBox(height: 16),
],
],
),
),
),
],
),
);
},

View File

@@ -13,6 +13,10 @@ class CaloriesChartWidget extends StatelessWidget {
return BlocBuilder<DiaryCubit, DiaryState>(
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();
},
);
}

View File

@@ -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<DiaryCubit, DiaryState>(
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<double> _slideAnim;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_slideAnim = Tween<double>(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<double> _anim;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
)..repeat(reverse: true);
_anim = Tween<double>(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,
),
),
);
}
}

View File

@@ -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<String> _diaryForceFetchController = StreamController<String>.broadcast();
final DiaryLocalDataSource localDataSource;
final InternetConnectionCubit connectionCubit;
final _uuid = const Uuid();
final StreamController<String> _diaryForceFetchController = StreamController<String>.broadcast();
Stream<String> 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<DiaryDayModel> getDiaryByDate(String date) async {
try {
@@ -150,6 +163,9 @@ class DiaryRepository {
}
}
/// Write-Behind: добавить порцию в приём пищи.
/// Офлайн/ошибка → кладём в pending_operations.
/// Онлайн → идемпотентный запрос с UUID-ключом.
Future<void> 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<void> updateProductInMeal({
required int diaryId,
required int mealId,
@@ -367,26 +402,61 @@ class DiaryRepository {
}
}
/// Cache-Aside: сначала сеть, при ошибке → возврат из локального кэша.
Future<DiaryDayModel> 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<void> 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<void> _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<DiaryDayModel?> _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 = <MealModel>[];
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;
}
}
}

View File

@@ -103,9 +103,48 @@ abstract class DiaryService {
@GET('/api/glossaries/index')
Future<GlossaryPaginationModel> getGlossariesIndex({@Queries() Map<String, dynamic>? filters});
@POST('/api/v2/diary/create/day')
Future<void> createDay({@Body() required CreateDayModel body});
// ─── V2 эндпоинты с Idempotency-Key ────────────────────────────────────────
@GET('/api/v2/diary/index/{day?}')
Future<DiaryDayModel> getDiaryDayV2({@Query('day') required String day});
/// Создать день дневника (идемпотентно).
/// [idempotencyKey] — UUID v4, используется сервером для дедупликации.
@POST('/api/v2/diary/create/day')
Future<void> createDay({
@Header('Idempotency-Key') required String idempotencyKey,
@Body() required CreateDayModel body,
});
/// Получить день дневника по дате (ISO 8601 с timezone).
@GET('/api/v2/diary/index/{day}')
Future<DiaryDayModel> getDiaryDayV2({@Path('day') required String day});
/// Добавить порцию с поддержкой идемпотентности.
@POST('/api/diary/{diary}/meals/{type_no}/portion/create')
Future<void> 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<void> 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<void> uploadPhotoIdempotent({
@Header('Idempotency-Key') required String idempotencyKey,
@Path('diary') required int diary,
@Path('type_no') required int typeNo,
@Part(name: 'photo') required File photo,
});
}

View File

@@ -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<List<GlossaryModel>> 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<List<GlossaryModel>> _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();
}
}

View File

@@ -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

View File

@@ -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<DiaryService>(),
MockSpec<InternetConnectionCubit>(),
])
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<OfflineOperationException>());
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<OfflineOperationException>());
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<OfflineOperationException>()));
});
});
// ────────────────────────────────────────────────────────────────────────────
// ГРУППА 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<String, dynamic>;
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,
);
}
}