forked from eatvio/eatvio_app
feat(diary): improve stability
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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<String, dynamic> payload) {
|
||||
// Простая JSON-сериализация без зависимости от dart:convert
|
||||
return _mapToJsonString(payload);
|
||||
}
|
||||
|
||||
String _mapToJsonString(Map<String, dynamic> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> _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<void> _executeOperation(PendingOperationsTableData op) async {
|
||||
final payload = jsonDecode(op.payload) as Map<String, dynamic>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -45,6 +45,8 @@ class DiaryCubit extends Cubit<DiaryState> {
|
||||
|
||||
// Подписка на изменения connectivity
|
||||
StreamSubscription<InternetConnectionState>? _connectivitySubscription;
|
||||
// Подписка на принудительное обновление дневника
|
||||
StreamSubscription<String>? _forceFetchSubscription;
|
||||
|
||||
DiaryCubit({
|
||||
required this.chatRepository,
|
||||
@@ -58,7 +60,7 @@ class DiaryCubit extends Cubit<DiaryState> {
|
||||
}) : 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<DiaryState> {
|
||||
: 'Нет интернета. Откройте дневник онлайн хотя бы один раз, чтобы он был доступен офлайн.';
|
||||
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<DiaryState> {
|
||||
@override
|
||||
Future<void> close() {
|
||||
_connectivitySubscription?.cancel();
|
||||
_forceFetchSubscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,8 +114,8 @@ class DiaryRepository {
|
||||
}
|
||||
|
||||
/// Write-Behind: добавить порцию в приём пищи.
|
||||
/// Офлайн/ошибка → кладём в pending_operations.
|
||||
/// Онлайн → идемпотентный запрос с UUID-ключом.
|
||||
/// Офлайн/сетевая ошибка → кладём в pending_operations.
|
||||
/// 4xx ошибки НЕ ставим в очередь — это ошибки валидации/бизнес-логики.
|
||||
Future<void> 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<void> 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<void> uploadPhotos({required int diaryId, required int mealId, required List<File> files}) async {
|
||||
@@ -220,23 +246,78 @@ class DiaryRepository {
|
||||
}
|
||||
|
||||
Future<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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(
|
||||
|
||||
@@ -1007,7 +1007,7 @@ void main() {
|
||||
|
||||
final result = await diaryCubit!.fetchDiaryV2(needLoading: false);
|
||||
|
||||
expect(result, true);
|
||||
expect(result, false);
|
||||
expect(diaryCubit!.state, isA<DiaryStateError>());
|
||||
});
|
||||
|
||||
@@ -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<DiaryStateError>());
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user