feat(diary): fixes

This commit is contained in:
mso7nueh
2026-05-10 20:08:51 +03:00
parent 2859607f4d
commit 63fc824449
17 changed files with 628 additions and 48 deletions

View File

@@ -9,6 +9,7 @@ import 'package:eatvio_app/core/interceptors/token_interceptor.dart';
import 'package:eatvio_app/core/local_db/app_database.dart';
import 'package:eatvio_app/core/local_db/dao/diary_dao.dart';
import 'package:eatvio_app/core/local_db/dao/glossary_dao.dart';
import 'package:eatvio_app/core/local_db/dao/media_dao.dart';
import 'package:eatvio_app/core/local_db/dao/pending_ops_dao.dart';
import 'package:eatvio_app/core/local_db/dao/sync_meta_dao.dart';
import 'package:eatvio_app/core/local_db/diary_local_data_source.dart';
@@ -53,12 +54,14 @@ class ExternalRegistrations {
sl.registerLazySingleton<AppDatabase>(() => AppDatabase());
sl.registerLazySingleton<DiaryDao>(() => sl<AppDatabase>().diaryDao);
sl.registerLazySingleton<GlossaryDao>(() => sl<AppDatabase>().glossaryDao);
sl.registerLazySingleton<MediaDao>(() => sl<AppDatabase>().mediaDao);
sl.registerLazySingleton<PendingOpsDao>(() => sl<AppDatabase>().pendingOpsDao);
sl.registerLazySingleton<SyncMetaDao>(() => sl<AppDatabase>().syncMetaDao);
sl.registerLazySingleton<DiaryLocalDataSource>(
() => DiaryLocalDataSource(
diaryDao: sl(),
glossaryDao: sl(),
mediaDao: sl(),
pendingOpsDao: sl(),
syncMetaDao: sl(),
),

View File

@@ -2,11 +2,13 @@ import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'dao/diary_dao.dart';
import 'dao/glossary_dao.dart';
import 'dao/media_dao.dart';
import 'dao/pending_ops_dao.dart';
import 'dao/sync_meta_dao.dart';
import 'tables/diary_days_table.dart';
import 'tables/glossary_cache_table.dart';
import 'tables/meals_table.dart';
import 'tables/media_table.dart';
import 'tables/pending_operations_table.dart';
import 'tables/portions_table.dart';
import 'tables/sync_meta_table.dart';
@@ -23,12 +25,14 @@ part 'app_database.g.dart';
GlossaryCacheTable,
PendingOperationsTable,
SyncMetaTable,
MediaTable,
],
daos: [
DiaryDao,
GlossaryDao,
PendingOpsDao,
SyncMetaDao,
MediaDao,
],
)
class AppDatabase extends _$AppDatabase {
@@ -38,7 +42,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase.forTesting(super.executor);
@override
int get schemaVersion => 2;
int get schemaVersion => 3;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -58,6 +62,10 @@ class AppDatabase extends _$AppDatabase {
);
} catch (_) {}
}
if (from < 3) {
// Таблица медиа для фото дневника (добавлена в v3)
await m.createTable(mediaTable);
}
},
beforeOpen: (details) async {
// WAL mode — позволяет параллельные чтение/запись из разных изолятов

View File

@@ -0,0 +1,31 @@
import 'package:drift/drift.dart';
import 'package:eatvio_app/core/local_db/app_database.dart';
import 'package:eatvio_app/core/local_db/tables/media_table.dart';
part 'media_dao.g.dart';
/// DAO для работы с медиа-файлами (фото) приёмов пищи в локальной БД.
@DriftAccessor(tables: [MediaTable])
class MediaDao extends DatabaseAccessor<AppDatabase> with _$MediaDaoMixin {
MediaDao(super.db);
/// Получить все медиа для конкретного meal.
Future<List<MediaTableData>> getMediaForMeal(int mealId) {
return (select(db.mediaTable)..where((t) => t.mealId.equals(mealId))).get();
}
/// Вставить или обновить медиа-запись.
Future<int> upsertMedia(MediaTableCompanion media) {
return into(db.mediaTable).insertOnConflictUpdate(media);
}
/// Удалить все медиа для конкретного meal.
Future<void> clearMediaForMeal(int mealId) async {
await (delete(db.mediaTable)..where((t) => t.mealId.equals(mealId))).go();
}
/// Удалить конкретную медиа-запись по серверному ID.
Future<void> deleteByServerId(int serverId) async {
await (delete(db.mediaTable)..where((t) => t.serverId.equals(serverId))).go();
}
}

View File

@@ -3,6 +3,7 @@ import 'package:drift/drift.dart';
import 'package:eatvio_app/core/local_db/app_database.dart';
import 'package:eatvio_app/core/local_db/dao/diary_dao.dart';
import 'package:eatvio_app/core/local_db/dao/glossary_dao.dart';
import 'package:eatvio_app/core/local_db/dao/media_dao.dart';
import 'package:eatvio_app/core/local_db/dao/pending_ops_dao.dart';
import 'package:eatvio_app/core/local_db/dao/sync_meta_dao.dart';
import 'package:uuid/uuid.dart';
@@ -12,6 +13,7 @@ import 'package:uuid/uuid.dart';
class DiaryLocalDataSource {
final DiaryDao _diaryDao;
final GlossaryDao _glossaryDao;
final MediaDao _mediaDao;
final PendingOpsDao _pendingOpsDao;
final SyncMetaDao _syncMetaDao;
final _uuid = const Uuid();
@@ -19,10 +21,12 @@ class DiaryLocalDataSource {
DiaryLocalDataSource({
required DiaryDao diaryDao,
required GlossaryDao glossaryDao,
required MediaDao mediaDao,
required PendingOpsDao pendingOpsDao,
required SyncMetaDao syncMetaDao,
}) : _diaryDao = diaryDao,
_glossaryDao = glossaryDao,
_mediaDao = mediaDao,
_pendingOpsDao = pendingOpsDao,
_syncMetaDao = syncMetaDao;
@@ -185,6 +189,33 @@ class DiaryLocalDataSource {
);
}
// ─── Медиа (фото дневника) ─────────────────────────────────────────────
Future<List<MediaTableData>> getCachedMedia(int mealId) {
return _mediaDao.getMediaForMeal(mealId);
}
Future<void> cacheMedia({
required int mealId,
int? serverId,
String? url,
String? localPath,
String status = 'synced',
}) async {
await _mediaDao.upsertMedia(MediaTableCompanion(
mealId: Value(mealId),
serverId: Value(serverId),
url: Value(url),
localPath: Value(localPath),
status: Value(status),
createdAt: Value(DateTime.now().toIso8601String()),
));
}
Future<void> clearCachedMedia(int mealId) {
return _mediaDao.clearMediaForMeal(mealId);
}
// ─── Утилиты ─────────────────────────────────────────────────────────────
String _encodePayload(Map<String, dynamic> payload) {

View File

@@ -156,6 +156,44 @@ class DiarySyncRunner {
portionModel: payload['portion_id'] as int,
);
case 'update_portion':
final data = <String, dynamic>{
'product_name': payload['product_name'] as String,
'portion': payload['portion'] as String,
'quantity': payload['quantity'] as int,
};
if (payload['product_nutritional_value_id'] != null) {
data['product_nutritional_value_id'] = payload['product_nutritional_value_id'] as int;
}
data['state'] = payload['state'] as String;
await _diaryService.updateProductInMeal(
diary: payload['diary_id'] as int,
typeNo: payload['meal_id'] as int,
portionModel: payload['portion_id'] as int,
productInMeal: data,
);
case 'mark_eaten':
await _diaryService.markEaten(
diary: payload['diary_id'] as int,
typeNo: payload['meal_id'] as int,
portionModel: payload['portion_id'] as int,
);
case 'unmark_eaten':
await _diaryService.unmarkEaten(
diary: payload['diary_id'] as int,
typeNo: payload['meal_id'] as int,
portionModel: payload['portion_id'] as int,
);
case 'restore':
await _diaryService.restore(
diary: payload['diary_id'] as int,
typeNo: payload['meal_id'] as int,
portionModel: payload['portion_id'] as int,
);
case 'fill_satiety':
await _diaryService.fillSatiety(
diary: payload['diary_id'] as int,

View File

@@ -0,0 +1,35 @@
import 'package:drift/drift.dart';
import 'meals_table.dart';
/// Кэш медиа-файлов (фото) приёмов пищи.
/// Хранит мета-данные фотографий для офлайн-доступа.
///
/// status:
/// 'synced' — фото загружено на сервер
/// 'pending_upload' — фото создано офлайн, ожидает загрузки
/// 'pending_delete' — фото помечено на удаление, ожидает синхронизации
class MediaTable extends Table {
@override
String get tableName => 'media';
IntColumn get id => integer().autoIncrement()();
// Ссылка на локальный приём пищи
IntColumn get mealId => integer().named('meal_id')
.references(MealsTable, #id, onDelete: KeyAction.cascade)();
// ID записи на сервере (null до синхронизации)
IntColumn get serverId => integer().nullable().named('server_id')();
// URL фото на сервере (null для неотправленных)
TextColumn get url => text().nullable()();
// Локальный путь к файлу (null для фото только с сервера)
TextColumn get localPath => text().nullable().named('local_path')();
// Статус синхронизации: 'synced', 'pending_upload', 'pending_delete'
TextColumn get status => text().withDefault(const Constant('synced'))();
// Время создания записи (ISO 8601 string)
TextColumn get createdAt => text().named('created_at')();
}

View File

@@ -174,12 +174,22 @@ class DiaryCubit extends Cubit<DiaryState> {
_lastFetchWasOffline = true;
return result;
} catch (_) {
// Кэша нет — попробуем сеть на случай ложного "offline"
log('[getDiary] OFFLINE: no cache, trying network as fallback...');
// Кэша нет — создаём день локально, чтобы он был видим
log('[getDiary] OFFLINE: no cache, creating day locally...');
try {
await diaryRepository.createDiaryDay(day: dayStr);
final result = await diaryRepository.getDiaryDayV2(day: dayStr);
log('[getDiary] OFFLINE: local day created and loaded OK');
_lastFetchWasOffline = true;
return result;
} catch (e2) {
log('[getDiary] OFFLINE: local day creation failed: $e2');
return null;
}
}
}
// Онлайн (или fallback после неудачного offline): создаём день + грузим с сервера
// Онлайн: создаём день + грузим с сервера
try {
log('[getDiary] ONLINE: creating diary day...');
await diaryRepository.createDiaryDay(day: dayStr);

View File

@@ -29,6 +29,10 @@ class GlossarySyncCubit extends Cubit<GlossarySyncState> {
_connectionCubit = connectionCubit,
super(const GlossarySyncState.idle());
/// Готов ли глоссарий к использованию (есть ли хотя бы 1 запись в локальном кэше).
bool get isGlossaryReady => _glossaryReady;
bool _glossaryReady = false;
/// Начать прослушивание сети и автозапуск синхронизации.
void start() {
_connectionSub = _connectionCubit.stream.listen((state) {
@@ -56,6 +60,8 @@ class GlossarySyncCubit extends Cubit<GlossarySyncState> {
onProcessingProgress: _onProcessingProgress,
);
_glossaryReady = true;
if (count == 0) {
emit(const GlossarySyncState.upToDate());
} else {
@@ -68,10 +74,13 @@ class GlossarySyncCubit extends Cubit<GlossarySyncState> {
});
} catch (e) {
emit(GlossarySyncState.error(message: _humanReadableError(e)));
// Скрыть ошибку через 6 секунд
_hideTimer = Timer(const Duration(seconds: 6), () {
if (!isClosed) emit(const GlossarySyncState.idle());
});
// Если глоссарий ещё не синхронизирован — НЕ скрываем ошибку,
// чтобы пользователь мог нажать «Повторить».
if (_glossaryReady) {
_hideTimer = Timer(const Duration(seconds: 6), () {
if (!isClosed) emit(const GlossarySyncState.idle());
});
}
} finally {
_isSyncing = false;
}

View File

@@ -56,6 +56,24 @@ class GlossarySyncBannerWidget extends StatelessWidget {
color: const Color(0xFFDC2626),
icon: Icons.error_outline_rounded,
text: message,
action: TextButton(
onPressed: () => context.read<GlossarySyncCubit>().syncNow(),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text(
'Повторить',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w700,
decoration: TextDecoration.underline,
decorationColor: Colors.white,
),
),
),
),
);
},
@@ -68,6 +86,7 @@ class GlossarySyncBannerWidget extends StatelessWidget {
required String text,
bool showSpinner = false,
double? progress,
Widget? action,
}) {
return _AnimatedBanner(
color: color,
@@ -75,6 +94,7 @@ class GlossarySyncBannerWidget extends StatelessWidget {
text: text,
showSpinner: showSpinner,
progress: progress,
action: action,
);
}
@@ -95,6 +115,7 @@ class _AnimatedBanner extends StatefulWidget {
final String text;
final bool showSpinner;
final double? progress;
final Widget? action;
const _AnimatedBanner({
required this.color,
@@ -102,6 +123,7 @@ class _AnimatedBanner extends StatefulWidget {
required this.text,
this.showSpinner = false,
this.progress,
this.action,
});
@override
@@ -182,6 +204,7 @@ class _AnimatedBannerState extends State<_AnimatedBanner>
),
),
),
if (widget.action != null) widget.action!,
],
),
),

View File

@@ -163,6 +163,8 @@ class DiaryRepository {
}
/// Write-Behind: обновить порцию в приёме пищи.
/// Офлайн/сетевая ошибка → кладём в pending_operations.
Future<void> updateProductInMeal({
required int diaryId,
required int mealId,
@@ -173,24 +175,106 @@ class DiaryRepository {
int? productNutritionalValueId,
required PortionState portionState,
}) async {
final data = ProductInMeal(productName: productName, portion: portion, quantity: quantity).toJson();
if (productNutritionalValueId != null) data['product_nutritional_value_id'] = productNutritionalValueId;
data['state'] = portionState.name;
return diaryService.updateProductInMeal(diary: diaryId, typeNo: mealId, portionModel: portionId, productInMeal: data);
final payload = {
'diary_id': diaryId,
'meal_id': mealId,
'portion_id': portionId,
'product_name': productName,
'portion': portion,
'quantity': quantity,
if (productNutritionalValueId != null) 'product_nutritional_value_id': productNutritionalValueId,
'state': portionState.name,
};
if (!_isOnline) {
await localDataSource.enqueueOperation(type: 'update_portion', payload: payload);
return;
}
try {
final data = ProductInMeal(productName: productName, portion: portion, quantity: quantity).toJson();
if (productNutritionalValueId != null) data['product_nutritional_value_id'] = productNutritionalValueId;
data['state'] = portionState.name;
await diaryService.updateProductInMeal(diary: diaryId, typeNo: mealId, portionModel: portionId, productInMeal: data);
} on DioException catch (e, s) {
final statusCode = e.response?.statusCode;
if (statusCode != null && statusCode >= 400 && statusCode < 500) {
await Sentry.captureException(e, stackTrace: s);
rethrow;
}
await Sentry.captureException(e, stackTrace: s);
await localDataSource.enqueueOperation(type: 'update_portion', payload: payload);
}
}
/// Write-Behind: отметить порцию как съеденную.
Future<void> markEaten({required int diaryId, required int mealId, required int portionId}) async {
return diaryService.markEaten(diary: diaryId, typeNo: mealId, portionModel: portionId);
final payload = {'diary_id': diaryId, 'meal_id': mealId, 'portion_id': portionId};
if (!_isOnline) {
await localDataSource.enqueueOperation(type: 'mark_eaten', payload: payload);
return;
}
try {
await diaryService.markEaten(diary: diaryId, typeNo: mealId, portionModel: portionId);
} on DioException catch (e, s) {
final statusCode = e.response?.statusCode;
if (statusCode != null && statusCode >= 400 && statusCode < 500) {
await Sentry.captureException(e, stackTrace: s);
rethrow;
}
await Sentry.captureException(e, stackTrace: s);
await localDataSource.enqueueOperation(type: 'mark_eaten', payload: payload);
}
}
/// Write-Behind: снять отметку "съедено".
Future<void> unmarkEaten({required int diaryId, required int mealId, required int portionId}) async {
return diaryService.unmarkEaten(diary: diaryId, typeNo: mealId, portionModel: portionId);
final payload = {'diary_id': diaryId, 'meal_id': mealId, 'portion_id': portionId};
if (!_isOnline) {
await localDataSource.enqueueOperation(type: 'unmark_eaten', payload: payload);
return;
}
try {
await diaryService.unmarkEaten(diary: diaryId, typeNo: mealId, portionModel: portionId);
} on DioException catch (e, s) {
final statusCode = e.response?.statusCode;
if (statusCode != null && statusCode >= 400 && statusCode < 500) {
await Sentry.captureException(e, stackTrace: s);
rethrow;
}
await Sentry.captureException(e, stackTrace: s);
await localDataSource.enqueueOperation(type: 'unmark_eaten', payload: payload);
}
}
/// Write-Behind: восстановить удалённую порцию.
Future<void> restore({required int diaryId, required int mealId, required int portionId}) async {
return diaryService.restore(diary: diaryId, typeNo: mealId, portionModel: portionId);
final payload = {'diary_id': diaryId, 'meal_id': mealId, 'portion_id': portionId};
if (!_isOnline) {
await localDataSource.enqueueOperation(type: 'restore', payload: payload);
return;
}
try {
await diaryService.restore(diary: diaryId, typeNo: mealId, portionModel: portionId);
} on DioException catch (e, s) {
final statusCode = e.response?.statusCode;
if (statusCode != null && statusCode >= 400 && statusCode < 500) {
await Sentry.captureException(e, stackTrace: s);
rethrow;
}
await Sentry.captureException(e, stackTrace: s);
await localDataSource.enqueueOperation(type: 'restore', payload: payload);
}
}
/// Write-Behind: удалить порцию из приёма пищи.
/// Использует idempotent API вариант для предотвращения дубликатов.
Future<void> deleteProductFromMeal({required int diaryId, required int mealId, required int portionId}) async {
final payload = {
'diary_id': diaryId,
@@ -203,8 +287,14 @@ class DiaryRepository {
return;
}
final idempotencyKey = _uuid.v4();
try {
await diaryService.deleteProductFromMeal(diary: diaryId, typeNo: mealId, portionModel: portionId);
await diaryService.deleteProductFromMealIdempotent(
idempotencyKey: idempotencyKey,
diary: diaryId,
typeNo: mealId,
portionModel: portionId,
);
} on DioException catch (e, s) {
final statusCode = e.response?.statusCode;
if (statusCode != null && statusCode >= 400 && statusCode < 500) {
@@ -216,14 +306,66 @@ class DiaryRepository {
}
}
/// Write-Behind: загрузить фото приёма пищи.
/// Офлайн → сохраняем файл локально и ставим в очередь.
Future<void> uploadPhotos({required int diaryId, required int mealId, required List<File> files}) async {
for (var i = 0; i < files.length; i++) {
await diaryService.uploadPhotoOfMeal(diary: diaryId, typeNo: mealId, photo: files[i]);
final file = files[i];
final payload = {'diary_id': diaryId, 'type_no': mealId};
if (!_isOnline) {
await localDataSource.enqueueOperation(
type: 'upload_photo',
payload: payload,
localFilePath: file.path,
);
continue;
}
final idempotencyKey = _uuid.v4();
try {
await diaryService.uploadPhotoIdempotent(
idempotencyKey: idempotencyKey,
diary: diaryId,
typeNo: mealId,
photo: file,
);
} on DioException catch (e, s) {
final statusCode = e.response?.statusCode;
if (statusCode != null && statusCode >= 400 && statusCode < 500) {
await Sentry.captureException(e, stackTrace: s);
rethrow;
}
await Sentry.captureException(e, stackTrace: s);
await localDataSource.enqueueOperation(
type: 'upload_photo',
payload: payload,
localFilePath: file.path,
);
}
}
}
/// Write-Behind: удалить фото приёма пищи.
Future<void> deletePhoto({required int diaryId, required int mealId, required DiaryPhotoModel photo}) async {
await diaryService.deletePhotoOfMeal(diaryId: diaryId, typeNo: mealId, userAttachment: photo.id);
final payload = {'diary_id': diaryId, 'type_no': mealId, 'media_id': photo.id};
if (!_isOnline) {
await localDataSource.enqueueOperation(type: 'delete_photo', payload: payload);
return;
}
try {
await diaryService.deletePhotoOfMeal(diaryId: diaryId, typeNo: mealId, userAttachment: photo.id);
} on DioException catch (e, s) {
final statusCode = e.response?.statusCode;
if (statusCode != null && statusCode >= 400 && statusCode < 500) {
await Sentry.captureException(e, stackTrace: s);
rethrow;
}
await Sentry.captureException(e, stackTrace: s);
await localDataSource.enqueueOperation(type: 'delete_photo', payload: payload);
}
}
Future<void> finishDiaryDay({required int diaryId, required String wakeUp, required String retire, String? comment}) async {
@@ -320,16 +462,26 @@ class DiaryRepository {
}
}
/// Комментарий консультанта — только онлайн.
Future<void> commentUserDay({required int diaryId, required ConsultantCommentModel commentModel}) async {
if (!_isOnline) {
throw Exception('Комментирование невозможно без интернета');
}
await diaryService.commentUserDay(id: diaryId, body: commentModel);
_diaryForceFetchController.add('force');
}
/// Получить комментарии — только онлайн.
Future<ConsultantReviewModel> getDiaryComments({required int planId, required int diaryId}) async {
if (!_isOnline) {
throw Exception('Просмотр комментариев невозможен без интернета');
}
return diaryService.getDiaryComments(planId: planId, diaryId: diaryId);
}
/// Отметить просмотр комментария — только онлайн.
Future<void> viewCommentOfDiaryDayByConsultant({required int diaryId}) async {
if (!_isOnline) return; // Тихо игнорируем — не критично
await diaryService.viewCommentOfDiaryDayByConsultant(diaryId: diaryId);
}
@@ -370,12 +522,28 @@ class DiaryRepository {
}
}
/// Write-Behind: создаём день локально + ставим в очередь если онлайн.
/// Write-Behind: создаём день локально (с 5 пустыми meals) + отправляем на сервер.
/// Офлайн → ставим в очередь для повторной отправки.
Future<void> createDiaryDay({required String day}) async {
final dateOnly = day.substring(0, 10);
// Всегда кэшируем локально
// Всегда кэшируем локально — полную структуру с 5 meals
await localDataSource.cacheDiaryDay(date: dateOnly);
// Создаём 5 пустых meals (как делает сервер)
final cachedDay = await localDataSource.getCachedDay(dateOnly);
if (cachedDay != null) {
// Проверяем, есть ли уже meals — если нет, создаём
final existingMeals = await localDataSource.getCachedMeals(cachedDay.id);
if (existingMeals.isEmpty) {
for (int typeNo = 1; typeNo <= 5; typeNo++) {
await localDataSource.cacheMeal(
diaryDayId: cachedDay.id,
typeNo: typeNo,
);
}
}
}
// Всегда пробуем сеть (connectivity может ошибаться)
try {
final idempotencyKey = _uuid.v4();
@@ -470,6 +638,16 @@ class DiaryRepository {
fromDiet: portion.fromDiet,
);
}
// 5. Кэшируем фото приёма пищи
await localDataSource.clearCachedMedia(cachedMeal.id);
for (final photo in meal.photos) {
await localDataSource.cacheMedia(
mealId: cachedMeal.id,
serverId: photo.id,
url: photo.path,
);
}
}
} catch (e) {
// Ошибка кэширования не должна ломать основной флоу
@@ -477,7 +655,8 @@ class DiaryRepository {
}
/// Загрузить DiaryDayModel из локального кэша (Fallback).
/// Возвращает null если данных нет или кэш устарел.
/// Возвращает null если данных нет.
/// Для офлайн-дней (serverId == null) использует отрицательный временный ID.
Future<DiaryDayModel?> _loadCachedDiaryDay(String date) async {
try {
final cached = await localDataSource.getCachedDay(date);
@@ -502,6 +681,18 @@ class DiaryRepository {
),
)).toList();
// Загружаем фото из локального кэша
final media = await localDataSource.getCachedMedia(meal.id);
final photoModels = media
.where((m) => m.status != 'pending_delete')
.map((m) => DiaryPhotoModel(
id: m.serverId ?? m.id,
originalName: '',
path: m.url ?? m.localPath ?? '',
size: 0,
))
.toList();
mealModels.add(MealModel(
id: meal.id,
time: meal.time ?? '',
@@ -509,16 +700,17 @@ class DiaryRepository {
satiety: meal.satiety,
mood: meal.mood ?? 0,
portions: portionModels,
photos: [],
photos: photoModels,
));
}
// Если день создан офлайн (serverId == null), возвращаем null —
// нельзя использовать local id для API-запросов.
if (cached.serverId == null) return null;
// Если день создан офлайн (serverId == null) — используем отрицательный
// временный ID, чтобы отличить от серверных. Операции с таким ID
// автоматически попадут в очередь pending_operations.
final diaryId = cached.serverId ?? -cached.id;
return DiaryDayModel(
id: cached.serverId!,
id: diaryId,
date: cached.date,
wakeup: cached.wakeup,
retire: cached.retire,

1
libsqlite3.so Symbolic link
View File

@@ -0,0 +1 @@
/usr/lib64/libsqlite3.so.0

View File

@@ -3,6 +3,7 @@ import 'package:drift/native.dart';
import 'package:eatvio_app/core/local_db/app_database.dart';
import 'package:eatvio_app/core/local_db/dao/diary_dao.dart';
import 'package:eatvio_app/core/local_db/dao/glossary_dao.dart';
import 'package:eatvio_app/core/local_db/dao/media_dao.dart';
import 'package:eatvio_app/core/local_db/dao/pending_ops_dao.dart';
import 'package:eatvio_app/core/local_db/dao/sync_meta_dao.dart';
import 'package:eatvio_app/core/local_db/diary_local_data_source.dart';
@@ -17,6 +18,7 @@ void main() {
ds = DiaryLocalDataSource(
diaryDao: DiaryDao(db),
glossaryDao: GlossaryDao(db),
mediaDao: MediaDao(db),
pendingOpsDao: PendingOpsDao(db),
syncMetaDao: SyncMetaDao(db),
);

View File

@@ -6,6 +6,7 @@ import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_st
import 'package:eatvio_app/core/local_db/app_database.dart';
import 'package:eatvio_app/core/local_db/dao/diary_dao.dart';
import 'package:eatvio_app/core/local_db/dao/glossary_dao.dart';
import 'package:eatvio_app/core/local_db/dao/media_dao.dart';
import 'package:eatvio_app/core/local_db/dao/pending_ops_dao.dart';
import 'package:eatvio_app/core/local_db/dao/sync_meta_dao.dart';
import 'package:eatvio_app/core/local_db/diary_local_data_source.dart';
@@ -33,6 +34,7 @@ void main() {
localDataSource = DiaryLocalDataSource(
diaryDao: DiaryDao(db),
glossaryDao: GlossaryDao(db),
mediaDao: MediaDao(db),
pendingOpsDao: PendingOpsDao(db),
syncMetaDao: SyncMetaDao(db),
);

View File

@@ -7,6 +7,7 @@ import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_st
import 'package:eatvio_app/core/local_db/app_database.dart';
import 'package:eatvio_app/core/local_db/dao/diary_dao.dart';
import 'package:eatvio_app/core/local_db/dao/glossary_dao.dart';
import 'package:eatvio_app/core/local_db/dao/media_dao.dart';
import 'package:eatvio_app/core/local_db/dao/pending_ops_dao.dart';
import 'package:eatvio_app/core/local_db/dao/sync_meta_dao.dart';
import 'package:eatvio_app/core/local_db/diary_local_data_source.dart';
@@ -50,6 +51,7 @@ void main() {
localDataSource = DiaryLocalDataSource(
diaryDao: DiaryDao(db),
glossaryDao: GlossaryDao(db),
mediaDao: MediaDao(db),
pendingOpsDao: PendingOpsDao(db),
syncMetaDao: SyncMetaDao(db),
);
@@ -86,22 +88,27 @@ void main() {
expect(payload['portion_id'], 55);
});
test('онлайн — вызывает сервис напрямую', () async {
test('онлайн — вызывает idempotent сервис напрямую', () async {
stubOnline();
when(mockDiaryService.deleteProductFromMeal(
when(mockDiaryService.deleteProductFromMealIdempotent(
idempotencyKey: anyNamed('idempotencyKey'),
diary: 10, typeNo: 2, portionModel: 55,
)).thenAnswer((_) async {});
await repository.deleteProductFromMeal(diaryId: 10, mealId: 2, portionId: 55);
verify(mockDiaryService.deleteProductFromMeal(diary: 10, typeNo: 2, portionModel: 55)).called(1);
verify(mockDiaryService.deleteProductFromMealIdempotent(
idempotencyKey: anyNamed('idempotencyKey'),
diary: 10, typeNo: 2, portionModel: 55,
)).called(1);
final ops = await localDataSource.getPendingOps();
expect(ops.isEmpty, isTrue);
});
test('онлайн 5xx — кладёт в очередь', () async {
stubOnline();
when(mockDiaryService.deleteProductFromMeal(
when(mockDiaryService.deleteProductFromMealIdempotent(
idempotencyKey: anyNamed('idempotencyKey'),
diary: anyNamed('diary'), typeNo: anyNamed('typeNo'), portionModel: anyNamed('portionModel'),
)).thenThrow(DioException(
requestOptions: RequestOptions(),
@@ -118,7 +125,8 @@ void main() {
test('онлайн 4xx — пробрасывает ошибку, не кладёт в очередь', () async {
stubOnline();
when(mockDiaryService.deleteProductFromMeal(
when(mockDiaryService.deleteProductFromMealIdempotent(
idempotencyKey: anyNamed('idempotencyKey'),
diary: anyNamed('diary'), typeNo: anyNamed('typeNo'), portionModel: anyNamed('portionModel'),
)).thenThrow(DioException(
requestOptions: RequestOptions(),
@@ -467,24 +475,16 @@ void main() {
// ──────────────────────────────────────────────────────────────────────────
group('_loadCachedDiaryDay edge cases', () {
test('возвращает null если serverId == null (офлайн-созданный день)', () async {
test('serverId == null (офлайн-созданный день) — возвращает отрицательный ID', () async {
// Кэшируем день без serverId
await localDataSource.cacheDiaryDay(date: '2026-05-02');
stubOffline();
// getDiaryDayV2 должен попробовать загрузить из кэша, но serverId == null → null
// Потом пробует сеть
when(mockDiaryService.getDiaryDayV2(day: anyNamed('day')))
.thenThrow(DioException(
requestOptions: RequestOptions(),
type: DioExceptionType.connectionError,
));
expect(
() => repository.getDiaryDayV2(day: '2026-05-02T00:00:00+00:00'),
throwsA(isA<DioException>()),
);
// getDiaryDayV2 должен вернуть день с отрицательным ID
final result = await repository.getDiaryDayV2(day: '2026-05-02T00:00:00+00:00');
expect(result.id, isNegative);
expect(result.date, '2026-05-02');
});
});

View File

@@ -8,6 +8,7 @@ import 'package:eatvio_app/core/interceptors/offline_error_interceptor.dart';
import 'package:eatvio_app/core/local_db/app_database.dart';
import 'package:eatvio_app/core/local_db/dao/diary_dao.dart';
import 'package:eatvio_app/core/local_db/dao/glossary_dao.dart';
import 'package:eatvio_app/core/local_db/dao/media_dao.dart';
import 'package:eatvio_app/core/local_db/dao/pending_ops_dao.dart';
import 'package:eatvio_app/core/local_db/dao/sync_meta_dao.dart';
import 'package:eatvio_app/core/local_db/diary_local_data_source.dart';
@@ -81,6 +82,7 @@ void main() {
localDataSource = DiaryLocalDataSource(
diaryDao: DiaryDao(db),
glossaryDao: GlossaryDao(db),
mediaDao: MediaDao(db),
pendingOpsDao: PendingOpsDao(db),
syncMetaDao: SyncMetaDao(db),
);

View File

@@ -384,7 +384,8 @@ void main() {
const mealId = 2;
const portionId = 5;
when(mockDiaryService.deleteProductFromMeal(
when(mockDiaryService.deleteProductFromMealIdempotent(
idempotencyKey: anyNamed('idempotencyKey'),
diary: diaryId,
typeNo: mealId,
portionModel: portionId,
@@ -396,7 +397,8 @@ void main() {
portionId: portionId,
);
verify(mockDiaryService.deleteProductFromMeal(
verify(mockDiaryService.deleteProductFromMealIdempotent(
idempotencyKey: anyNamed('idempotencyKey'),
diary: diaryId,
typeNo: mealId,
portionModel: portionId,
@@ -408,7 +410,8 @@ void main() {
const mealId = 2;
final files = [File('test1.jpg'), File('test2.jpg')];
when(mockDiaryService.uploadPhotoOfMeal(
when(mockDiaryService.uploadPhotoIdempotent(
idempotencyKey: anyNamed('idempotencyKey'),
diary: diaryId,
typeNo: mealId,
photo: anyNamed('photo'),
@@ -420,7 +423,8 @@ void main() {
files: files,
);
verify(mockDiaryService.uploadPhotoOfMeal(
verify(mockDiaryService.uploadPhotoIdempotent(
idempotencyKey: anyNamed('idempotencyKey'),
diary: diaryId,
typeNo: mealId,
photo: anyNamed('photo'),

189
tz.md Normal file
View File

@@ -0,0 +1,189 @@
Offline-First для модуля «Diary» — план реализации
Контекст и описание задачи
Модуль Diary (дневник питания) приложения Eatvio полностью завязан на сетевые запросы через Retrofit/Dio. При отсутствии сети приложение падает в DiaryState.error и данные теряются. Необходимо реализовать стратегию Offline-First с тремя ключевыми компонентами:
Локальное хранение данных дневника и глоссария (Drift/SQLite)
Очередь мутаций (pending queue) — операции копятся офлайн и синхронизируются при появлении сети
Автосинхронизация при восстановлении сети через InternetConnectionCubit (уже существует)
Дедлайн утверждения плана: 14:00, 13 апреля 2026 г. ✅
Дедлайн готовности: 14:00, 12 мая 2026 г.
Архитектурное решение
DiaryCubit
│
└── DiaryRepository (MODIFY: добавить offline-логику)
│
├── DiaryService (Retrofit, доработка под Idempotency-Key)
│
├── DiaryLocalDataSource (NEW: фасад над Drift DAO)
│ ├── DiaryDayDao → таблица diary_days
│ ├── MealDao → таблица meals
│ ├── PortionDao → таблица portions
│ ├── GlossaryDao → таблица glossary_cache (201k записей)
│ └── PendingOpsDao → таблица pending_operations
│
└── DiarySyncService (NEW: FIFO-синхронизация)
└── подписка на InternetConnectionCubit
Паттерн чтения: Cache-Aside
→ попытка загрузить из сети (учитывая ETag) → при успехе кэшируем → при ошибке сети возвращаем кэш
Паттерн записи: Write-Behind
→ немедленное локальное обновление + запись в очередь с уникальным Idempotency-Key → при наличии сети отправляем на сервер
API Утвержденный Контракт и Требования к бэкенду
Caution
Статус Backend: На данный момент изучен код бекенда в папке eatvio. Все описанные ниже endpoints (/dump, /meta), использование Idempotency-Key, server_updated_at и единый формат ошибок в текущем коде Laravel отсутствуют (используется стандартная валидация FormRequest и ModelNotFound).
Таким образом, описанный ниже контракт является ЦЕЛЕВЫМ (To-Be) и Бэкенд-команда должна его имплементировать для успешной работы оффлайн-стратегии.
1. Глоссарий для офлайн-кэша
1A. Meta-проверка версии данных
GET /api/glossaries/meta
Возвращает легкий объект сверки:
{
"total_count": 201691,
"last_updated_at": "2026-04-08T10:30:00Z",
"checksum": "sha256:..."
}
Сценарий клиента: Сравниваем локальный checksum. Если отличается — вызываем /dump.
1B. Дамп продуктов
GET /api/glossaries/dump?updated_after=<iso_datetime>
If-None-Match: "etag_из_предыдущего_ответа"
Сценарий клиента (Флаттер):
При ответе 304 Not Modified — локальный кэш актуален.
При ответе 200 OK — в фоне (в изоляте/через compute) вливаем данные в SQLite таблицу. Заголовки ETag и Last-Modified запоминаем.
2. Дневник: идемпотентные операции
Мутирующие команды (create day, create portion и т.д.) теперь требуют заголовок:
Idempotency-Key: <uuid-v4>
Это позволит безопасно ретраить запросы (например, оборвавшиеся из-за скачка сети), не плодя дубли на сервере.
Сам API V2 для создания дня:
POST /api/v2/diary/create/day
Формат даты: Y-m-d\TH:i:sP (2026-04-08T07:30:00+03:00).
3. Инвалидация локального дневника
Во всех ответах DiaryResource (результат /index или /show) сервер обязан возвращать дату генерации/изменения:
{
...
"server_updated_at": "2026-04-08T10:30:00Z"
}
Мобильный клиент будет сравнивать это число со своей записью в Drift diary_days.server_updated_at, чтобы решать, нужен ли merge/refresh.
4. Фото
Загрузка фото:
POST /api/diary/{diary}/meals/{type_no}/photo/upload
Ответ 200 с photo.id:
{
"photo": {
"id": 456,
"path": "...photo.jpg"
}
}
При загрузке из оффлайн-очереди, клиент сохранит этот ID к себе в локальную таблицу фотографий, чтобы иметь возможность позже выполнить удаление:
Удаление фото:
DELETE /api/diary/{diary}/meals/{type_no}/photo/{media}/delete
5. Флоу очереди синхронизации (Формат ошибок)
Ответы с ошибками возвращаются в Envelope-формате:
{
"message": "Human readable message",
"error": {
"code": "validation_error",
"message": "Human readable message",
"retryable": false
}
}
Обработка на Флаттере (флоу Sync-провайдера):
Исключение DIO с кодом 4xx/5xx:
Если в ответе есть error.retryable == false (ошибки валидации, порция/дневник не найдены, конфликт payload):
→ Пометить PendingOperation как FAILED. Отправить стейт в UI с нотификацией или подсветить порцию красным ("Не удалось сохранить").
Если error.retryable == true или 500 Server Error или ConnectionTimeout
→ Оставить операцию в очереди (PENDING). Повторить попытку позже с backoff.
Proposed Changes (Изменения в Flutter приложении)
Этап 1 — Инфраструктура Drift БД
Срок: 7–14 апреля 2026 (7 дней)
Подключить пакеты: drift, drift_flutter, sqlite3_flutter_libs, uuid.
Создать AppDatabase и схемы:
diary_days, meals, portions, media
glossary_cache
pending_operations (Колонки: id (UUID), type, payload, idempotency_key, retry_count, status, local_file_path)
sync_meta (для checksum и etag)
Этап 2 — DAO и LocalDataSource
Срок: 14–19 апреля 2026 (5 дней)
Написать методы DAO: сохранения/обновления дневника.
Написать методы Bulk Insert для glossary_cache.
Написать методы работы с очередью (markFailed, markSynced).
Этап 3 — Настройка Retrofit/Dio для Idempotency
Срок: 19–26 апреля 2026 (7 дней)
Обновить DiaryService.dart:
Добавить параметр @Header("Idempotency-Key") String idempotencyKey во все методы POST/PUT.
Обновить метод получения индекса дня под V2.
В DiaryRepository внедрить механизм: при мутировании данных, генерировать UUID → сохранить в pending_operations → отправить на сервер (передав туда UUID) → при успехе очистить → при ошибке ориентироваться на поле retryable из ответа.
Этап 4 — SyncService для Глоссария и Очереди
Срок: 26–30 апреля 2026 (4 дня)
Обернуть первичное скачивание /dump в compute(), разбирая JSON как stream либо большими чанками, чтобы 200к записей не заморозили фреймы.
Написать воркер синхронизации дневника (прослушивание InternetConnectionCubit), который проходит по таблице pending_operations и переотправляет POST-запросы.
Обработать удаление/загрузку фотографий.
Этап 5 — UI и оповещения (Обработка failed-статусов)
Срок: 30 апреля – 5 мая 2026 (5 дней)
На экранах: показать лоадер или жёлтую плашку "Оффлайн - N изменений в очереди".
Имплементировать механизм: если операция в статусе FAILED (error = validation_error), показывать toast "Ошибка валидации порции" или маркировать саму UI-строку порции красным.
Ограничение некоторых функций, если InternetConnectionCubit без связи (например, отправка на оценку консультанту).
Этап 6 & 7 — DI, Интеграция и Ретест
Срок: 5–12 мая 2026 (7 дней)
Инжектнуть зависимости через GetIt/Injectable.
Покрыть интеграционными тестами (diary_repository_offline_test.dart) поведение с retryable: true|false.
Подтвердить работоспособность на девайсе (заморозка сети руками, добавление, разморозка сети).
Итог для BE и FE
Клиент и Сервер разделяют логику retry/fail, используя поле retryable и Idempotency-Key. Это идеальная база для стабильного Offline-First.
Для старта работ FE ожидает завершения работ на BE (или хотя бы выкатывания моков/тестовой среды API по этому гайду).