diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html
deleted file mode 100644
index 746fadde..00000000
--- a/android/build/reports/problems/problems-report.html
+++ /dev/null
@@ -1,663 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
- Gradle Configuration Cache
-
-
-
-
-
-
- Loading...
-
-
-
-
-
-
-
diff --git a/lib/core/local_db/diary_local_data_source.dart b/lib/core/local_db/diary_local_data_source.dart
index 46560b6e..4f0c7ca8 100644
--- a/lib/core/local_db/diary_local_data_source.dart
+++ b/lib/core/local_db/diary_local_data_source.dart
@@ -1,3 +1,4 @@
+import 'dart:convert';
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';
@@ -187,20 +188,6 @@ class DiaryLocalDataSource {
// ─── Утилиты ─────────────────────────────────────────────────────────────
String _encodePayload(Map payload) {
- // Простая JSON-сериализация без зависимости от dart:convert
- return _mapToJsonString(payload);
- }
-
- String _mapToJsonString(Map map) {
- final entries = map.entries.map((e) {
- final key = '"${e.key}"';
- final value = e.value == null
- ? 'null'
- : e.value is String
- ? '"${e.value}"'
- : e.value.toString();
- return '$key:$value';
- });
- return '{${entries.join(',')}}';
+ return jsonEncode(payload);
}
}
diff --git a/lib/core/local_db/services/diary_sync_runner.dart b/lib/core/local_db/services/diary_sync_runner.dart
index 0a0fdd84..94b7abf1 100644
--- a/lib/core/local_db/services/diary_sync_runner.dart
+++ b/lib/core/local_db/services/diary_sync_runner.dart
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
+import 'package:dio/dio.dart';
import 'package:eatvio_app/core/interceptors/offline_error_interceptor.dart';
import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
import 'package:eatvio_app/core/local_db/app_database.dart';
@@ -80,10 +81,11 @@ class DiarySyncRunner {
// Небольшая пауза между операциями
await Future.delayed(const Duration(milliseconds: 200));
}
- } finally {
- _isSyncing = false;
+
// Очистить синхронизированные операции
await _localDataSource.clearSyncedOps();
+ } finally {
+ _isSyncing = false;
}
}
@@ -94,23 +96,36 @@ class DiarySyncRunner {
await _localDataSource.markSynced(op.id);
debugPrint('[DiarySyncRunner] ✅ Операция ${op.type} синхронизирована');
} on OfflineOperationException catch (e) {
- debugPrint('[DiarySyncRunner] OfflineException: retryable=${e.retryable}, code=${e.code}');
- if (e.retryable) {
- await _localDataSource.incrementRetry(op.id);
- // Exponential backoff: ждём 2^retryCount секунды
- final delay = Duration(seconds: _pow2(op.retryCount).clamp(1, 30));
- await Future.delayed(delay);
+ _handleOfflineException(op, e);
+ } on DioException catch (e) {
+ // OfflineErrorInterceptor заворачивает OfflineOperationException в DioException.error
+ if (e.error is OfflineOperationException) {
+ await _handleOfflineException(op, e.error as OfflineOperationException);
} else {
- // Не retryable — помечаем как FAILED навсегда
- await _localDataSource.markFailed(op.id);
+ // Сетевая ошибка без Envelope — retryable
+ debugPrint('[DiarySyncRunner] DioException: $e');
+ await _localDataSource.incrementRetry(op.id);
}
} catch (e) {
// Необработанная ошибка — считаем retryable
- debugPrint('[DiarySyncRunner] Unknow error: $e');
+ debugPrint('[DiarySyncRunner] Unknown error: $e');
await _localDataSource.incrementRetry(op.id);
}
}
+ Future _handleOfflineException(PendingOperationsTableData op, OfflineOperationException e) async {
+ debugPrint('[DiarySyncRunner] OfflineException: retryable=${e.retryable}, code=${e.code}');
+ if (e.retryable) {
+ await _localDataSource.incrementRetry(op.id);
+ // Exponential backoff: ждём 2^retryCount секунды
+ final delay = Duration(seconds: _pow2(op.retryCount).clamp(1, 30));
+ await Future.delayed(delay);
+ } else {
+ // Не retryable — помечаем как FAILED навсегда
+ await _localDataSource.markFailed(op.id);
+ }
+ }
+
/// Выполнить конкретную операцию на основе её типа.
Future _executeOperation(PendingOperationsTableData op) async {
final payload = jsonDecode(op.payload) as Map;
diff --git a/lib/core/local_db/services/glossary_sync_runner.dart b/lib/core/local_db/services/glossary_sync_runner.dart
index 6533dc18..f2959d04 100644
--- a/lib/core/local_db/services/glossary_sync_runner.dart
+++ b/lib/core/local_db/services/glossary_sync_runner.dart
@@ -30,6 +30,7 @@ class GlossarySyncService {
final GlossarySyncApiService _api;
final Dio _dio;
final DiaryLocalDataSource _localDataSource;
+ bool _isSyncing = false;
GlossarySyncService({
required GlossarySyncApiService api,
@@ -51,6 +52,9 @@ class GlossarySyncService {
DownloadProgressCallback? onDownloadProgress,
ProcessingProgressCallback? onProcessingProgress,
}) async {
+ // Защита от повторного запуска (например при многократном нажатии кнопки)
+ if (_isSyncing) return 0;
+ _isSyncing = true;
try {
// Шаг 1: Получить мета-данные (лёгкий запрос ~100 байт)
onStageChanged?.call(GlossarySyncStage.checking);
@@ -75,7 +79,7 @@ class GlossarySyncService {
final dumpFile = File('${tempDir.path}/glossary_dump.json');
final updatedAfter = lastDumpAt ?? '2000-01-01T00:00:00Z';
- await _dio.download(
+ final response = await _dio.download(
'/api/glossaries/dump',
dumpFile.path,
queryParameters: {'updated_after': updatedAfter},
@@ -91,6 +95,9 @@ class GlossarySyncService {
},
);
+ // Сохраняем ETag из ответа для следующего условного запроса
+ final responseEtag = response.headers.value('etag') ?? etag;
+
debugPrint('[GlossarySyncService] Дамп скачан в ${dumpFile.path}');
// Шаг 5: Парсинг файла + bulk-insert в изоляте
@@ -110,11 +117,11 @@ class GlossarySyncService {
// Уведомляем о финальном прогрессе
onProcessingProgress?.call(result.count, result.count);
- // Шаг 6: Сохранить актуальные meta-данные
+ // Шаг 6: Сохранить актуальные meta-данные (включая etag из ответа)
onStageChanged?.call(GlossarySyncStage.saving);
await _localDataSource.saveGlossaryMeta(
checksum: meta.checksum,
- etag: etag,
+ etag: responseEtag,
lastDumpAt: result.updatedAt ?? updatedAfter,
);
@@ -128,6 +135,8 @@ class GlossarySyncService {
} catch (e) {
debugPrint('[GlossarySyncService] Ошибка синхронизации: $e');
rethrow;
+ } finally {
+ _isSyncing = false;
}
}
@@ -190,6 +199,7 @@ class GlossarySyncService {
await db.glossaryDao.bulkUpsert(companions);
total += companions.length;
+ debugPrint('[GlossarySyncService] Обработано $total/${dataList.length} продуктов');
}
return _IsolateResult(count: total, updatedAt: updatedAt);
diff --git a/lib/features/diary/presentation/bloc/diary_cubit.dart b/lib/features/diary/presentation/bloc/diary_cubit.dart
index af3e9490..b460c6e5 100644
--- a/lib/features/diary/presentation/bloc/diary_cubit.dart
+++ b/lib/features/diary/presentation/bloc/diary_cubit.dart
@@ -45,6 +45,8 @@ class DiaryCubit extends Cubit {
// Подписка на изменения connectivity
StreamSubscription? _connectivitySubscription;
+ // Подписка на принудительное обновление дневника
+ StreamSubscription? _forceFetchSubscription;
DiaryCubit({
required this.chatRepository,
@@ -58,7 +60,7 @@ class DiaryCubit extends Cubit {
}) : super(const DiaryState.loading()) {
today = DateTime.now();
selectedDay = today;
- diaryRepository.forceFetchStream.listen((_) {
+ _forceFetchSubscription = diaryRepository.forceFetchStream.listen((_) {
fetchDiaryV2(needLoading: true, diaryId: diaryId);
});
// При переходе offline→online перезагружаем дневник
@@ -117,7 +119,7 @@ class DiaryCubit extends Cubit {
: 'Нет интернета. Откройте дневник онлайн хотя бы один раз, чтобы он был доступен офлайн.';
log('[fetchDiaryV2] diaryDay is null, msg=$msg');
emit(DiaryState.error(errorMessage: msg));
- return true;
+ return false;
}
log('[fetchDiaryV2] diaryDay loaded, id=${diaryDay.id}, converting to entity...');
@@ -609,6 +611,7 @@ class DiaryCubit extends Cubit {
@override
Future close() {
_connectivitySubscription?.cancel();
+ _forceFetchSubscription?.cancel();
return super.close();
}
}
diff --git a/lib/features/diary/repositories/diary_repository.dart b/lib/features/diary/repositories/diary_repository.dart
index d7578e43..0296c6b8 100644
--- a/lib/features/diary/repositories/diary_repository.dart
+++ b/lib/features/diary/repositories/diary_repository.dart
@@ -114,8 +114,8 @@ class DiaryRepository {
}
/// Write-Behind: добавить порцию в приём пищи.
- /// Офлайн/ошибка → кладём в pending_operations.
- /// Онлайн → идемпотентный запрос с UUID-ключом.
+ /// Офлайн/сетевая ошибка → кладём в pending_operations.
+ /// 4xx ошибки НЕ ставим в очередь — это ошибки валидации/бизнес-логики.
Future addPortionToMeal({
required int diaryId,
required int mealId,
@@ -134,7 +134,6 @@ class DiaryRepository {
};
if (!_isOnline) {
- // Офлайн — немедленно в очередь
await localDataSource.enqueueOperation(type: 'create_portion', payload: payload);
return;
}
@@ -151,8 +150,14 @@ class DiaryRepository {
productNutritionalValueId: productNutritionalValueId,
);
} on DioException catch (e, s) {
+ // 4xx ошибки не ставим в очередь — это ошибки клиента
+ final statusCode = e.response?.statusCode;
+ if (statusCode != null && statusCode >= 400 && statusCode < 500) {
+ await Sentry.captureException(e, stackTrace: s);
+ rethrow;
+ }
+ // Сетевая ошибка (5xx, timeout, connection) → fallback в очередь
await Sentry.captureException(e, stackTrace: s);
- // Сетевая ошибка → fallback в очередь
await localDataSource.enqueueOperation(type: 'create_portion', payload: payload);
}
}
@@ -187,7 +192,28 @@ class DiaryRepository {
}
Future deleteProductFromMeal({required int diaryId, required int mealId, required int portionId}) async {
- return diaryService.deleteProductFromMeal(diary: diaryId, typeNo: mealId, portionModel: portionId);
+ final payload = {
+ 'diary_id': diaryId,
+ 'meal_id': mealId,
+ 'portion_id': portionId,
+ };
+
+ if (!_isOnline) {
+ await localDataSource.enqueueOperation(type: 'delete_portion', payload: payload);
+ return;
+ }
+
+ try {
+ await diaryService.deleteProductFromMeal(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: 'delete_portion', payload: payload);
+ }
}
Future uploadPhotos({required int diaryId, required int mealId, required List files}) async {
@@ -220,23 +246,78 @@ class DiaryRepository {
}
Future fillSatiety({required int diaryId, required int mealId, required int satiety}) async {
- return diaryService.fillSatiety(diary: diaryId, typeNo: mealId, satiety: satiety);
+ if (!_isOnline) {
+ await localDataSource.enqueueOperation(type: 'fill_satiety', payload: {'diary_id': diaryId, 'type_no': mealId, 'satiety': satiety});
+ return;
+ }
+ try {
+ await diaryService.fillSatiety(diary: diaryId, typeNo: mealId, satiety: satiety);
+ } 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: 'fill_satiety', payload: {'diary_id': diaryId, 'type_no': mealId, 'satiety': satiety});
+ }
}
Future fillMood({required int diaryId, required int mealId, required int mood}) async {
- return diaryService.fillMood(diary: diaryId, typeNo: mealId, mood: mood);
+ if (!_isOnline) {
+ await localDataSource.enqueueOperation(type: 'fill_mood', payload: {'diary_id': diaryId, 'type_no': mealId, 'mood': mood});
+ return;
+ }
+ try {
+ await diaryService.fillMood(diary: diaryId, typeNo: mealId, mood: mood);
+ } 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: 'fill_mood', payload: {'diary_id': diaryId, 'type_no': mealId, 'mood': mood});
+ }
}
Future fillTime({required int diaryId, required int mealId, required String time}) async {
- return diaryService.fillTime(diary: diaryId, typeNo: mealId, time: time);
+ if (!_isOnline) {
+ await localDataSource.enqueueOperation(type: 'fill_time', payload: {'diary_id': diaryId, 'type_no': mealId, 'time': time});
+ return;
+ }
+ try {
+ await diaryService.fillTime(diary: diaryId, typeNo: mealId, time: time);
+ } 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: 'fill_time', payload: {'diary_id': diaryId, 'type_no': mealId, 'time': time});
+ }
}
Future fillWakeup({required int diaryId, required String time}) async {
- return diaryService.fillWakeup(diary: diaryId, wakeUp: time);
+ if (!_isOnline) {
+ await localDataSource.enqueueOperation(type: 'fill_wakeup', payload: {'diary_id': diaryId, 'wakeup': time});
+ return;
+ }
+ try {
+ await diaryService.fillWakeup(diary: diaryId, wakeUp: time);
+ } 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: 'fill_wakeup', payload: {'diary_id': diaryId, 'wakeup': time});
+ }
}
Future fillRetire({required int diaryId, required String time}) async {
- return diaryService.fillRetire(diary: diaryId, retire: time);
+ if (!_isOnline) {
+ await localDataSource.enqueueOperation(type: 'fill_retire', payload: {'diary_id': diaryId, 'retire': time});
+ return;
+ }
+ try {
+ await diaryService.fillRetire(diary: diaryId, retire: time);
+ } 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: 'fill_retire', payload: {'diary_id': diaryId, 'retire': time});
+ }
}
Future commentUserDay({required int diaryId, required ConsultantCommentModel commentModel}) async {
@@ -414,6 +495,7 @@ class DiaryRepository {
quantity: p.quantity,
portion: p.portion,
fromDiet: p.fromDiet,
+ productNutritionalValueId: p.productNutritionalValueId,
portionState: PortionState.values.firstWhere(
(s) => s.name == p.state,
orElse: () => PortionState.eaten,
@@ -431,11 +513,15 @@ class DiaryRepository {
));
}
+ // Если день создан офлайн (serverId == null), возвращаем null —
+ // нельзя использовать local id для API-запросов.
+ if (cached.serverId == null) return null;
+
return DiaryDayModel(
- // serverId может быть null если день создан офлайн и ещё не синхронизирован.
- // Используем локальный id как fallback — toEntity() проверяет id != null.
- id: cached.serverId ?? cached.id,
+ id: cached.serverId!,
date: cached.date,
+ wakeup: cached.wakeup,
+ retire: cached.retire,
closed: cached.closed,
comment: cached.comment,
typeOfDiary: TypeOfDiaryModel(
diff --git a/test/features/diary/presentation/bloc/diary_cubit_test.dart b/test/features/diary/presentation/bloc/diary_cubit_test.dart
index 23f7cc1b..c05627bd 100644
--- a/test/features/diary/presentation/bloc/diary_cubit_test.dart
+++ b/test/features/diary/presentation/bloc/diary_cubit_test.dart
@@ -1007,7 +1007,7 @@ void main() {
final result = await diaryCubit!.fetchDiaryV2(needLoading: false);
- expect(result, true);
+ expect(result, false);
expect(diaryCubit!.state, isA());
});
@@ -1049,7 +1049,7 @@ void main() {
verify(mockDiaryRepository.deletePhoto(photo: photo, diaryId: 1, mealId: 1)).called(1);
});
- test('setSelectedDay - возвращает true даже если getDiary возвращает null', () async {
+ test('setSelectedDay - returns false and reverts day when getDiary returns null', () async {
diaryCubit = DiaryCubit(
diaryRepository: mockDiaryRepository,
glossaryRepository: mockGlossaryRepository,
@@ -1073,7 +1073,7 @@ void main() {
final result = await diaryCubit!.setSelectedDay(DateTime(2024, 1, 20));
// fetchDiaryV2 возвращает true когда getDiary() == null, так как эмитит состояние ошибки
- expect(result, true);
+ expect(result, false);
expect(diaryCubit!.state, isA());
});