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()); });