forked from eatvio/eatvio_app
feat: implement offline-first architecture with local database synchronization and diary caching
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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>(
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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()));
|
||||
|
||||
96
lib/core/interceptors/offline_error_interceptor.dart
Normal file
96
lib/core/interceptors/offline_error_interceptor.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
76
lib/core/local_db/app_database.dart
Normal file
76
lib/core/local_db/app_database.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
79
lib/core/local_db/dao/diary_dao.dart
Normal file
79
lib/core/local_db/dao/diary_dao.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
58
lib/core/local_db/dao/glossary_dao.dart
Normal file
58
lib/core/local_db/dao/glossary_dao.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
75
lib/core/local_db/dao/pending_ops_dao.dart
Normal file
75
lib/core/local_db/dao/pending_ops_dao.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
46
lib/core/local_db/dao/sync_meta_dao.dart
Normal file
46
lib/core/local_db/dao/sync_meta_dao.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
206
lib/core/local_db/diary_local_data_source.dart
Normal file
206
lib/core/local_db/diary_local_data_source.dart
Normal 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(',')}}';
|
||||
}
|
||||
}
|
||||
224
lib/core/local_db/services/diary_sync_runner.dart
Normal file
224
lib/core/local_db/services/diary_sync_runner.dart
Normal 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;
|
||||
}
|
||||
84
lib/core/local_db/services/glossary_dump_response.dart
Normal file
84
lib/core/local_db/services/glossary_dump_response.dart
Normal 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);
|
||||
}
|
||||
25
lib/core/local_db/services/glossary_meta_model.dart
Normal file
25
lib/core/local_db/services/glossary_meta_model.dart
Normal 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);
|
||||
}
|
||||
165
lib/core/local_db/services/glossary_sync_runner.dart
Normal file
165
lib/core/local_db/services/glossary_sync_runner.dart
Normal 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});
|
||||
}
|
||||
|
||||
27
lib/core/local_db/services/glossary_sync_service.dart
Normal file
27
lib/core/local_db/services/glossary_sync_service.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
49
lib/core/local_db/tables/diary_days_table.dart
Normal file
49
lib/core/local_db/tables/diary_days_table.dart
Normal 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},
|
||||
];
|
||||
}
|
||||
40
lib/core/local_db/tables/glossary_cache_table.dart
Normal file
40
lib/core/local_db/tables/glossary_cache_table.dart
Normal 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};
|
||||
}
|
||||
29
lib/core/local_db/tables/meals_table.dart
Normal file
29
lib/core/local_db/tables/meals_table.dart
Normal 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},
|
||||
];
|
||||
}
|
||||
40
lib/core/local_db/tables/pending_operations_table.dart
Normal file
40
lib/core/local_db/tables/pending_operations_table.dart
Normal 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};
|
||||
}
|
||||
28
lib/core/local_db/tables/portions_table.dart
Normal file
28
lib/core/local_db/tables/portions_table.dart
Normal 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')();
|
||||
}
|
||||
16
lib/core/local_db/tables/sync_meta_table.dart
Normal file
16
lib/core/local_db/tables/sync_meta_table.dart
Normal 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};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}) =
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user