forked from eatvio/eatvio_app
feat(diary): fixes
This commit is contained in:
@@ -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(),
|
||||
),
|
||||
|
||||
@@ -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 — позволяет параллельные чтение/запись из разных изолятов
|
||||
|
||||
31
lib/core/local_db/dao/media_dao.dart
Normal file
31
lib/core/local_db/dao/media_dao.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
35
lib/core/local_db/tables/media_table.dart
Normal file
35
lib/core/local_db/tables/media_table.dart
Normal 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')();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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!,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
1
libsqlite3.so
Symbolic link
@@ -0,0 +1 @@
|
||||
/usr/lib64/libsqlite3.so.0
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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
189
tz.md
Normal 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 по этому гайду).
|
||||
Reference in New Issue
Block a user