feat(diary): improve stability

This commit is contained in:
mso7nueh
2026-05-05 15:57:12 +03:00
parent 91c8300da7
commit 48af13e37c
7 changed files with 148 additions and 710 deletions

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -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>;

View File

@@ -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);

View File

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

View File

@@ -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(

View File

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