forked from eatvio/eatvio_app
fix(diary): some fixes offline-diary
This commit is contained in:
@@ -69,6 +69,12 @@ class DiaryDao extends DatabaseAccessor<AppDatabase> with _$DiaryDaoMixin {
|
||||
await (delete(db.mealsTable)..where((t) => t.diaryDayId.equals(diaryDayId))).go();
|
||||
}
|
||||
|
||||
/// Удалить все порции для конкретного приёма пищи.
|
||||
/// BUG-6 fix: позволяет атомарно перезаписать порции одного meal.
|
||||
Future<void> clearPortionsForMeal(int mealId) async {
|
||||
await (delete(db.portionsTable)..where((t) => t.mealId.equals(mealId))).go();
|
||||
}
|
||||
|
||||
/// Очистить кэш дней дневника старше [ttlDays] дней.
|
||||
/// Вызывается периодически для экономии места на устройстве.
|
||||
Future<int> clearExpiredCache({int ttlDays = 14}) {
|
||||
|
||||
@@ -115,6 +115,13 @@ class DiaryLocalDataSource {
|
||||
return _diaryDao.clearMealsForDay(diaryDayId);
|
||||
}
|
||||
|
||||
/// Удалить все порции для конкретного meal.
|
||||
/// BUG-6 fix: используется для атомарной перезаписи порций одного meal,
|
||||
/// без удаления данных остальных meals.
|
||||
Future<void> clearCachedPortions(int mealId) {
|
||||
return _diaryDao.clearPortionsForMeal(mealId);
|
||||
}
|
||||
|
||||
Future<int> clearExpiredDiaryCache({int ttlDays = 14}) {
|
||||
return _diaryDao.clearExpiredCache(ttlDays: ttlDays);
|
||||
}
|
||||
|
||||
@@ -156,7 +156,14 @@ class DiaryCubit extends Cubit<DiaryState> {
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
log('updateCurrentDiary error: $e');
|
||||
// BUG-1 fix: офлайн fallback — перезагружаем из кэша, чтобы UI
|
||||
// отразил изменения, выполненные пользователем.
|
||||
try {
|
||||
await fetchDiaryV2(needLoading: false, diaryId: diaryId);
|
||||
} catch (_) {
|
||||
// Не критично — UI останется на текущем состоянии
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,8 +358,9 @@ class DiaryCubit extends Cubit<DiaryState> {
|
||||
planDuration = chosenPlanModel.plan.duration;
|
||||
planName = chosenPlanModel.plan.name;
|
||||
currentPlanDay = consultantReview.currentPlanDay;
|
||||
} catch (_) {
|
||||
// Офлайн — данные плана недоступны
|
||||
} catch (e) {
|
||||
// BUG-9 fix: офлайн — данные плана недоступны, логируем для отладки
|
||||
log('[setDiary] Plan/comments data unavailable (offline?): $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,7 +439,11 @@ class DiaryCubit extends Cubit<DiaryState> {
|
||||
required int quantity,
|
||||
int? productNutritionalValueId,
|
||||
}) async {
|
||||
emit((state as DiaryStateLoaded).copyWith(isLoadingAddingProductInMeal: true));
|
||||
// BUG-2 fix: безопасная проверка типа стейта перед кастом
|
||||
final currentState = state;
|
||||
if (currentState is DiaryStateLoaded) {
|
||||
emit(currentState.copyWith(isLoadingAddingProductInMeal: true));
|
||||
}
|
||||
try {
|
||||
await diaryRepository.addPortionToMeal(
|
||||
diaryId: diaryId,
|
||||
|
||||
@@ -159,8 +159,8 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
});
|
||||
final isFinished = await context.read<DiaryCubit>().finishDiaryDay(
|
||||
diaryId: state.diary.id,
|
||||
wakeUp: state.diary.wakeup.toString(),
|
||||
retire: state.diary.retire.toString(),
|
||||
wakeUp: state.diary.wakeup ?? '06:30:00',
|
||||
retire: state.diary.retire ?? '22:30:00',
|
||||
);
|
||||
if (!isFinished && context.mounted) {
|
||||
showErrorDialog(context);
|
||||
|
||||
@@ -170,10 +170,19 @@ class _AddProductModalState extends State<AddProductModal> {
|
||||
if (widget.portion != null) const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
// BUG-4 fix: валидация полей перед отправкой
|
||||
final productName = productNameController.text.trim();
|
||||
if (productName.isEmpty) return;
|
||||
|
||||
final isGrams = selectedGlossary == null || selectedGlossary!.grInPortion == 0;
|
||||
final portionType = isGrams ? 'gr' : 'pcs';
|
||||
// BUG-4 fix: безопасный парсинг количества с дефолтом
|
||||
final quantity = int.tryParse(quantityController.text) ?? (isGrams ? 100 : 1);
|
||||
|
||||
// Автоматически выбираем первый элемент, если текст точно совпадает
|
||||
if (selectedGlossary == null &&
|
||||
glossariesList.isNotEmpty &&
|
||||
productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) {
|
||||
productName.toLowerCase() == glossariesList[0].name.toLowerCase()) {
|
||||
selectedGlossary = glossariesList[0];
|
||||
}
|
||||
|
||||
@@ -181,13 +190,9 @@ class _AddProductModalState extends State<AddProductModal> {
|
||||
widget.diaryCubit.addProductToMeal(
|
||||
diaryId: widget.state.diary.id,
|
||||
mealId: widget.state.selectedMeal,
|
||||
productName: productNameController.text,
|
||||
portion: selectedGlossary == null
|
||||
? 'gr'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? 'gr'
|
||||
: 'pcs',
|
||||
quantity: int.parse(quantityController.text),
|
||||
productName: productName,
|
||||
portion: portionType,
|
||||
quantity: quantity,
|
||||
productNutritionalValueId: selectedGlossary?.id,
|
||||
);
|
||||
} else {
|
||||
@@ -195,26 +200,22 @@ class _AddProductModalState extends State<AddProductModal> {
|
||||
diaryId: widget.state.diary.id,
|
||||
mealId: widget.state.selectedMeal,
|
||||
portionId: widget.portion!.id,
|
||||
productName: productNameController.text,
|
||||
portion: selectedGlossary == null
|
||||
? 'gr'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? 'gr'
|
||||
: 'pcs',
|
||||
quantity: int.parse(quantityController.text),
|
||||
productName: productName,
|
||||
portion: portionType,
|
||||
quantity: quantity,
|
||||
productNutritionalValueId: selectedGlossary?.id,
|
||||
portionState: widget.portion!.portionState,
|
||||
);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
if (selectedGlossary == null && _previousProductName != productNameController.text) {
|
||||
if (selectedGlossary == null && _previousProductName != productName) {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: GlossaryModerationRequestModalWidget(productName: productNameController.text, diaryCubit: widget.diaryCubit),
|
||||
child: GlossaryModerationRequestModalWidget(productName: productName, diaryCubit: widget.diaryCubit),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,7 +116,13 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
builder: (context, state) {
|
||||
if (state is DiaryStateLoaded) {
|
||||
int mealIndex = state.selectedMeal - 1;
|
||||
if (state.diary.meals[mealIndex].photos.isEmpty && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented) {
|
||||
final photos = state.diary.meals[mealIndex].photos;
|
||||
// BUG-5 fix: сбросить индекс, если он вышел за пределы
|
||||
// (например, после переключения на meal с меньшим числом фото)
|
||||
if (currentSelectedPhotoIndex >= photos.length) {
|
||||
currentSelectedPhotoIndex = 0;
|
||||
}
|
||||
if (photos.isEmpty && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented) {
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
showImagePicker(state.diary);
|
||||
|
||||
@@ -25,6 +25,7 @@ class ProductWidget extends StatefulWidget {
|
||||
class _ProductWidgetState extends State<ProductWidget> {
|
||||
late final DiaryCubit diaryCubit;
|
||||
late String portionName;
|
||||
// BUG-8 fix: инициализируем из актуального state порции
|
||||
late bool isEaten;
|
||||
bool showCommentWidget = false;
|
||||
@override
|
||||
@@ -32,7 +33,7 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
super.initState();
|
||||
diaryCubit = context.read<DiaryCubit>();
|
||||
portionName = widget.portion.portion == 'gr' ? 'гр' : 'шт';
|
||||
isEaten = widget.portion.portionState == PortionState.eatenFromDiet ? true : false;
|
||||
isEaten = widget.portion.portionState == PortionState.eatenFromDiet;
|
||||
}
|
||||
|
||||
Widget _buildNutritionalInfo() {
|
||||
@@ -93,7 +94,8 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
void didUpdateWidget(ProductWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
portionName = widget.portion.portion == 'gr' ? 'гр' : 'шт';
|
||||
isEaten = widget.portion.portionState == PortionState.eatenFromDiet ? true : false;
|
||||
// BUG-8 fix: синхронизируем isEaten с актуальным state порции
|
||||
isEaten = widget.portion.portionState == PortionState.eatenFromDiet;
|
||||
}
|
||||
|
||||
Future updateProductInMeal(DiaryStateLoaded state, PortionModel portion) {
|
||||
|
||||
@@ -23,6 +23,12 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
|
||||
diaryCubit = context.read<DiaryCubit>();
|
||||
}
|
||||
|
||||
/// Безопасное форматирование времени (HH:MM:SS → HH:MM, пустое → '--:--')
|
||||
String _formatTime(String time) {
|
||||
if (time.isEmpty || time.length < 5) return '--:--';
|
||||
return time.substring(0, 5);
|
||||
}
|
||||
|
||||
void showTimePicker(int diaryId) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -110,7 +116,7 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
state.diary.meals[state.selectedMeal - 1].time.substring(0, state.diary.meals[state.selectedMeal - 1].time.length - 3),
|
||||
_formatTime(state.diary.meals[state.selectedMeal - 1].time),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6D6F71),
|
||||
fontSize: 17,
|
||||
|
||||
@@ -26,6 +26,12 @@ class _WakeupAndRetireWidgetState extends State<WakeupAndRetireWidget> {
|
||||
diaryCubit = context.read<DiaryCubit>();
|
||||
}
|
||||
|
||||
/// BUG-3 fix: безопасное форматирование времени (HH:MM:SS → HH:MM, null → '--:--')
|
||||
String _formatTime(String? time) {
|
||||
if (time == null || time.length < 5) return '--:--';
|
||||
return time.substring(0, 5);
|
||||
}
|
||||
|
||||
void showTimePicker(int diaryId, bool isWakeup) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -142,10 +148,7 @@ class _WakeupAndRetireWidgetState extends State<WakeupAndRetireWidget> {
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
state.diary.wakeup.toString().substring(
|
||||
0,
|
||||
state.diary.wakeup.toString().length - 3,
|
||||
),
|
||||
_formatTime(state.diary.wakeup),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6D6F71),
|
||||
fontSize: 17,
|
||||
@@ -201,10 +204,7 @@ class _WakeupAndRetireWidgetState extends State<WakeupAndRetireWidget> {
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
state.diary.retire.toString().substring(
|
||||
0,
|
||||
state.diary.retire.toString().length - 3,
|
||||
),
|
||||
_formatTime(state.diary.retire),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6D6F71),
|
||||
fontSize: 17,
|
||||
|
||||
@@ -29,6 +29,11 @@ class DiaryRepository {
|
||||
|
||||
bool get _isOnline => connectionCubit.state.isInternetAvailable;
|
||||
|
||||
/// BUG-7 fix: отрицательный ID означает, что день был создан офлайн
|
||||
/// и ещё не синхронизирован с сервером. Операции с таким ID нельзя
|
||||
/// ставить в очередь — они будут отклонены сервером.
|
||||
bool _isOfflineDiaryId(int diaryId) => diaryId < 0;
|
||||
|
||||
DiaryRepository({
|
||||
required this.diaryService,
|
||||
required this.localDataSource,
|
||||
@@ -124,6 +129,11 @@ class DiaryRepository {
|
||||
required int quantity,
|
||||
int? productNutritionalValueId,
|
||||
}) async {
|
||||
// BUG-7 fix: нельзя мутировать офлайн-день без серверного ID
|
||||
if (_isOfflineDiaryId(diaryId)) {
|
||||
throw Exception('Для добавления продукта необходим интернет (день ещё не синхронизирован)');
|
||||
}
|
||||
|
||||
final payload = {
|
||||
'diary_id': diaryId,
|
||||
'meal_id': mealId,
|
||||
@@ -175,6 +185,11 @@ class DiaryRepository {
|
||||
int? productNutritionalValueId,
|
||||
required PortionState portionState,
|
||||
}) async {
|
||||
// BUG-7 fix: нельзя мутировать офлайн-день без серверного ID
|
||||
if (_isOfflineDiaryId(diaryId)) {
|
||||
throw Exception('Для редактирования продукта необходим интернет (день ещё не синхронизирован)');
|
||||
}
|
||||
|
||||
final payload = {
|
||||
'diary_id': diaryId,
|
||||
'meal_id': mealId,
|
||||
@@ -209,6 +224,7 @@ class DiaryRepository {
|
||||
|
||||
/// Write-Behind: отметить порцию как съеденную.
|
||||
Future<void> markEaten({required int diaryId, required int mealId, required int portionId}) async {
|
||||
if (_isOfflineDiaryId(diaryId)) return; // BUG-7 fix
|
||||
final payload = {'diary_id': diaryId, 'meal_id': mealId, 'portion_id': portionId};
|
||||
|
||||
if (!_isOnline) {
|
||||
@@ -231,6 +247,7 @@ class DiaryRepository {
|
||||
|
||||
/// Write-Behind: снять отметку "съедено".
|
||||
Future<void> unmarkEaten({required int diaryId, required int mealId, required int portionId}) async {
|
||||
if (_isOfflineDiaryId(diaryId)) return; // BUG-7 fix
|
||||
final payload = {'diary_id': diaryId, 'meal_id': mealId, 'portion_id': portionId};
|
||||
|
||||
if (!_isOnline) {
|
||||
@@ -253,6 +270,7 @@ class DiaryRepository {
|
||||
|
||||
/// Write-Behind: восстановить удалённую порцию.
|
||||
Future<void> restore({required int diaryId, required int mealId, required int portionId}) async {
|
||||
if (_isOfflineDiaryId(diaryId)) return; // BUG-7 fix
|
||||
final payload = {'diary_id': diaryId, 'meal_id': mealId, 'portion_id': portionId};
|
||||
|
||||
if (!_isOnline) {
|
||||
@@ -276,6 +294,11 @@ class DiaryRepository {
|
||||
/// Write-Behind: удалить порцию из приёма пищи.
|
||||
/// Использует idempotent API вариант для предотвращения дубликатов.
|
||||
Future<void> deleteProductFromMeal({required int diaryId, required int mealId, required int portionId}) async {
|
||||
// BUG-7 fix: нельзя мутировать офлайн-день без серверного ID
|
||||
if (_isOfflineDiaryId(diaryId)) {
|
||||
throw Exception('Для удаления продукта необходим интернет (день ещё не синхронизирован)');
|
||||
}
|
||||
|
||||
final payload = {
|
||||
'diary_id': diaryId,
|
||||
'meal_id': mealId,
|
||||
@@ -309,6 +332,7 @@ class DiaryRepository {
|
||||
/// Write-Behind: загрузить фото приёма пищи.
|
||||
/// Офлайн → сохраняем файл локально и ставим в очередь.
|
||||
Future<void> uploadPhotos({required int diaryId, required int mealId, required List<File> files}) async {
|
||||
if (_isOfflineDiaryId(diaryId)) return; // BUG-7 fix
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
final file = files[i];
|
||||
final payload = {'diary_id': diaryId, 'type_no': mealId};
|
||||
@@ -348,6 +372,7 @@ class DiaryRepository {
|
||||
|
||||
/// Write-Behind: удалить фото приёма пищи.
|
||||
Future<void> deletePhoto({required int diaryId, required int mealId, required DiaryPhotoModel photo}) async {
|
||||
if (_isOfflineDiaryId(diaryId)) return; // BUG-7 fix
|
||||
final payload = {'diary_id': diaryId, 'type_no': mealId, 'media_id': photo.id};
|
||||
|
||||
if (!_isOnline) {
|
||||
@@ -388,6 +413,7 @@ class DiaryRepository {
|
||||
}
|
||||
|
||||
Future<void> fillSatiety({required int diaryId, required int mealId, required int satiety}) async {
|
||||
if (_isOfflineDiaryId(diaryId)) return; // BUG-7 fix
|
||||
if (!_isOnline) {
|
||||
await localDataSource.enqueueOperation(type: 'fill_satiety', payload: {'diary_id': diaryId, 'type_no': mealId, 'satiety': satiety});
|
||||
return;
|
||||
@@ -403,6 +429,7 @@ class DiaryRepository {
|
||||
}
|
||||
|
||||
Future<void> fillMood({required int diaryId, required int mealId, required int mood}) async {
|
||||
if (_isOfflineDiaryId(diaryId)) return; // BUG-7 fix
|
||||
if (!_isOnline) {
|
||||
await localDataSource.enqueueOperation(type: 'fill_mood', payload: {'diary_id': diaryId, 'type_no': mealId, 'mood': mood});
|
||||
return;
|
||||
@@ -418,6 +445,7 @@ class DiaryRepository {
|
||||
}
|
||||
|
||||
Future<void> fillTime({required int diaryId, required int mealId, required String time}) async {
|
||||
if (_isOfflineDiaryId(diaryId)) return; // BUG-7 fix
|
||||
if (!_isOnline) {
|
||||
await localDataSource.enqueueOperation(type: 'fill_time', payload: {'diary_id': diaryId, 'type_no': mealId, 'time': time});
|
||||
return;
|
||||
@@ -433,6 +461,7 @@ class DiaryRepository {
|
||||
}
|
||||
|
||||
Future<void> fillWakeup({required int diaryId, required String time}) async {
|
||||
if (_isOfflineDiaryId(diaryId)) return; // BUG-7 fix
|
||||
if (!_isOnline) {
|
||||
await localDataSource.enqueueOperation(type: 'fill_wakeup', payload: {'diary_id': diaryId, 'wakeup': time});
|
||||
return;
|
||||
@@ -448,6 +477,7 @@ class DiaryRepository {
|
||||
}
|
||||
|
||||
Future<void> fillRetire({required int diaryId, required String time}) async {
|
||||
if (_isOfflineDiaryId(diaryId)) return; // BUG-7 fix
|
||||
if (!_isOnline) {
|
||||
await localDataSource.enqueueOperation(type: 'fill_retire', payload: {'diary_id': diaryId, 'retire': time});
|
||||
return;
|
||||
@@ -608,10 +638,12 @@ class DiaryRepository {
|
||||
final cachedDay = await localDataSource.getCachedDay(date);
|
||||
if (cachedDay == null) return;
|
||||
|
||||
// 3. Очищаем старые meals (и каскадно portions) перед записью свежих данных
|
||||
await localDataSource.clearCachedMeals(cachedDay.id);
|
||||
// BUG-6 fix: НЕ удаляем все meals разом — это приводит к потере данных
|
||||
// при ошибке записи. Вместо этого upsert-им каждый meal отдельно
|
||||
// (uniqueKeys [diaryDayId, typeNo] обеспечивают корректный upsert),
|
||||
// а порции очищаем и перезаписываем на уровне каждого meal.
|
||||
|
||||
// 4. Для каждого приёма пищи — сохраняем и его порции
|
||||
// 3. Для каждого приёма пищи — сохраняем и его порции
|
||||
for (final meal in model.meals) {
|
||||
await localDataSource.cacheMeal(
|
||||
diaryDayId: cachedDay.id,
|
||||
@@ -626,7 +658,9 @@ class DiaryRepository {
|
||||
final cachedMeal = cachedMeals.where((m) => m.typeNo == meal.typeNumber).firstOrNull;
|
||||
if (cachedMeal == null) continue;
|
||||
|
||||
// 4. Удаляем старые порции этого meal и записываем актуальные
|
||||
// 4. Очищаем порции ТОЛЬКО этого meal и записываем актуальные
|
||||
// (если запись одного meal упадёт, остальные не пострадают)
|
||||
await localDataSource.clearCachedPortions(cachedMeal.id);
|
||||
for (final portion in meal.portions) {
|
||||
await localDataSource.cachePortion(
|
||||
mealId: cachedMeal.id,
|
||||
@@ -651,6 +685,7 @@ class DiaryRepository {
|
||||
}
|
||||
} catch (e) {
|
||||
// Ошибка кэширования не должна ломать основной флоу
|
||||
Sentry.captureException(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
189
tz.md
189
tz.md
@@ -1,189 +0,0 @@
|
||||
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