diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart index 97549230..da878271 100644 --- a/lib/core/di/cubit_registrations.dart +++ b/lib/core/di/cubit_registrations.dart @@ -1,6 +1,7 @@ import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart'; import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart'; import 'package:eatvio_app/core/local_db/diary_local_data_source.dart'; +import 'package:eatvio_app/core/local_db/services/glossary_sync_runner.dart'; import 'package:eatvio_app/core/views/cubit/views_cubit.dart'; import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart'; import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart'; @@ -22,6 +23,7 @@ import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_c import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart'; import 'package:eatvio_app/features/diary/presentation/bloc/consultant_comments_cubit/consultant_comments_cubit.dart'; import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart'; +import 'package:eatvio_app/features/diary/presentation/bloc/glossary_sync_cubit.dart'; import 'package:eatvio_app/features/diets/presentation/cubit/about_diet_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/consultants_cubit/consultants_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart'; @@ -72,6 +74,12 @@ class CubitRegistrations { sl.registerLazySingleton(() => ProfileAvatarCubit(profileRepository: sl())); sl.registerLazySingleton(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl())); sl.registerLazySingleton(() => InternetConnectionCubit()); + sl.registerLazySingleton( + () => GlossarySyncCubit( + syncService: sl(), + connectionCubit: sl(), + ), + ); } /// Регистрация Cubit'ов как фабрик diff --git a/lib/core/di/repository_registrations.dart b/lib/core/di/repository_registrations.dart index b5850837..69dbb3d0 100644 --- a/lib/core/di/repository_registrations.dart +++ b/lib/core/di/repository_registrations.dart @@ -81,7 +81,6 @@ class RepositoryRegistrations { localDataSource: sl(), diaryService: sl(), connectionCubit: sl(), - glossarySyncService: sl(), ), ); sl.registerLazySingleton( diff --git a/lib/core/internet_connection_cubit/internet_connection_cubit.dart b/lib/core/internet_connection_cubit/internet_connection_cubit.dart index 4ff3eea8..4f87468e 100644 --- a/lib/core/internet_connection_cubit/internet_connection_cubit.dart +++ b/lib/core/internet_connection_cubit/internet_connection_cubit.dart @@ -46,7 +46,10 @@ class InternetConnectionCubit extends Cubit { Future _updateAccessStatus(List result) async { try { final hasInternet = await _checkInternetAccess(result); - if (!hasInternet) showInternetConnectionToast(); + final wasOnline = state.isInternetAvailable; + // Показываем toast только при переходе online → offline, + // а не при каждом срабатывании проверки + if (wasOnline && !hasInternet) showInternetConnectionToast(); emit(state.copyWith(isInternetAvailable: hasInternet)); } catch (e, st) { await reportFrom(e, st, this); @@ -54,21 +57,27 @@ class InternetConnectionCubit extends Cubit { } Future _checkInternetAccess(List result) async { - if (result.contains(ConnectivityResult.none)) { - return false; - } + // connectivity_plus может ошибочно возвращать 'none' на эмуляторах + // и некоторых устройствах, поэтому ВСЕГДА проверяем через HTTP. + // Если connectivity говорит 'none' — используем короткий таймаут. + final isLikelyOffline = result.contains(ConnectivityResult.none); + final timeout = Duration(seconds: isLikelyOffline ? 2 : 3); - final uris = [Uri.parse(EnvConfig.googleCheckUrl), Uri.parse(EnvConfig.yandexCheckUrl), Uri.parse(EnvConfig.devBaseUrl), Uri.parse(EnvConfig.baseUrl)]; - for (final uri in uris) { - try { - final response = await http.get(uri).timeout(const Duration(seconds: 1)); - return response.statusCode >= 100 && response.statusCode < 600; - } catch (e, st) { - await reportFrom(e, st, this); - continue; - } - } - return false; + final uris = [ + Uri.parse(EnvConfig.googleCheckUrl), + Uri.parse(EnvConfig.yandexCheckUrl), + Uri.parse(EnvConfig.baseUrl), + ]; + + final futures = uris.map((uri) => + http.get(uri) + .timeout(timeout) + .then((r) => r.statusCode >= 100 && r.statusCode < 600) + .catchError((_) => false) + ); + + final results = await Future.wait(futures); + return results.any((ok) => ok); } @override diff --git a/lib/core/local_db/services/diary_sync_runner.dart b/lib/core/local_db/services/diary_sync_runner.dart index 9b362d8f..0a0fdd84 100644 --- a/lib/core/local_db/services/diary_sync_runner.dart +++ b/lib/core/local_db/services/diary_sync_runner.dart @@ -5,7 +5,6 @@ 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'; import 'package:eatvio_app/core/local_db/diary_local_data_source.dart'; -import 'package:eatvio_app/core/local_db/services/glossary_sync_runner.dart'; import 'package:eatvio_app/features/diary/models/create_day_model.dart'; import 'package:eatvio_app/features/diary/services/diary_service.dart'; import 'package:flutter/foundation.dart'; @@ -23,7 +22,6 @@ class DiarySyncRunner { final DiaryLocalDataSource _localDataSource; final DiaryService _diaryService; final InternetConnectionCubit _connectionCubit; - final GlossarySyncService _glossarySyncService; StreamSubscription? _subscription; bool _isSyncing = false; @@ -34,36 +32,25 @@ class DiarySyncRunner { required DiaryLocalDataSource localDataSource, required DiaryService diaryService, required InternetConnectionCubit connectionCubit, - required GlossarySyncService glossarySyncService, }) : _localDataSource = localDataSource, _diaryService = diaryService, - _connectionCubit = connectionCubit, - _glossarySyncService = glossarySyncService; + _connectionCubit = connectionCubit; /// Начать прослушивание изменений сети. void start() { _subscription = _connectionCubit.stream.listen((state) { if (state.isInternetAvailable && !_isSyncing) { _processQueue(); - _syncGlossary(); } }); // Запустить немедленно если уже online if (_connectionCubit.state.isInternetAvailable) { _processQueue(); - _syncGlossary(); } } - /// Синхронизировать глоссарий продуктов (checksum → dump → bulk insert). - Future _syncGlossary() async { - try { - await _glossarySyncService.sync(); - } catch (e) { - debugPrint('[DiarySyncRunner] Glossary sync error: $e'); - } - } + /// Остановить прослушивание. void dispose() { diff --git a/lib/core/local_db/services/glossary_sync_runner.dart b/lib/core/local_db/services/glossary_sync_runner.dart index 6edde2c5..6533dc18 100644 --- a/lib/core/local_db/services/glossary_sync_runner.dart +++ b/lib/core/local_db/services/glossary_sync_runner.dart @@ -11,6 +11,14 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:path_provider/path_provider.dart'; +/// Стадии синхронизации глоссария (для UI-отслеживания). +enum GlossarySyncStage { checking, downloading, processing, saving } + +/// Callback-типы для отслеживания прогресса синхронизации. +typedef SyncStageCallback = void Function(GlossarySyncStage stage); +typedef DownloadProgressCallback = void Function(double progress); +typedef ProcessingProgressCallback = void Function(int processed, int total); + /// Сервис синхронизации глоссария продуктов. /// /// Стратегия: @@ -33,9 +41,19 @@ class GlossarySyncService { /// Запустить проверку и синхронизацию глоссария. /// Возвращает количество обновлённых записей (0 если кэш актуален). - Future sync() async { + /// + /// Опциональные коллбэки для отслеживания прогресса в UI: + /// - [onStageChanged] — смена стадии (checking → downloading → processing → saving) + /// - [onDownloadProgress] — прогресс скачивания файла (0.0–1.0) + /// - [onProcessingProgress] — прогресс парсинга/вставки (processed, total) + Future sync({ + SyncStageCallback? onStageChanged, + DownloadProgressCallback? onDownloadProgress, + ProcessingProgressCallback? onProcessingProgress, + }) async { try { // Шаг 1: Получить мета-данные (лёгкий запрос ~100 байт) + onStageChanged?.call(GlossarySyncStage.checking); final meta = await _api.getMeta(); // Шаг 2: Сравнить checksum @@ -52,6 +70,7 @@ class GlossarySyncService { final etag = await _localDataSource.getGlossaryEtag(); // Шаг 4: Скачать дамп в ФАЙЛ (не в память!) — избегаем OOM на ~200k записей + onStageChanged?.call(GlossarySyncStage.downloading); final tempDir = await getTemporaryDirectory(); final dumpFile = File('${tempDir.path}/glossary_dump.json'); @@ -65,12 +84,18 @@ class GlossarySyncService { if (etag != null) 'If-None-Match': etag, }, ), + onReceiveProgress: (received, total) { + if (total > 0 && onDownloadProgress != null) { + onDownloadProgress(received / total); + } + }, ); debugPrint('[GlossarySyncService] Дамп скачан в ${dumpFile.path}'); // Шаг 5: Парсинг файла + bulk-insert в изоляте // Передаём RootIsolateToken чтобы изолят мог использовать platform channels (drift) + onStageChanged?.call(GlossarySyncStage.processing); final token = RootIsolateToken.instance!; final result = await compute( _parseAndInsertInIsolate, @@ -78,12 +103,15 @@ class GlossarySyncService { ); if (result.isServerError) { - // Сервер вернул ошибку внутри стрима — дамп неполный, пропускаем debugPrint('[GlossarySyncService] Сервер вернул ошибку в теле дампа, синхронизация пропущена'); return 0; } + // Уведомляем о финальном прогрессе + onProcessingProgress?.call(result.count, result.count); + // Шаг 6: Сохранить актуальные meta-данные + onStageChanged?.call(GlossarySyncStage.saving); await _localDataSource.saveGlossaryMeta( checksum: meta.checksum, etag: etag, @@ -113,26 +141,18 @@ class GlossarySyncService { final rawContent = file.readAsStringSync(); // ─── Защита от «склеенных» ответов сервера ───────────────────────────── - // Сервер иногда падает в процессе стриминга дампа и дописывает - // {"message":"Server error.",...} прямо в тело — без разделителя. - // Пример: ...,"deleted_at":null}{"message":"Server error.",...} - // Такой контент невалиден как JSON целиком, поэтому: - // 1. Обнаруживаем склейку по паттерну `}{` - // 2. Обрезаем строку до первого закрывающего `}` корневого объекта. - // 3. Если файл не парсится вообще — возвращаем isServerError=true. final content = _sanitizeJsonResponse(rawContent); if (content == null) { debugPrint('[GlossarySyncService] Файл дампа повреждён (невозможно восстановить JSON)'); - return _IsolateResult(count: 0, updatedAt: null, isServerError: true); + return const _IsolateResult(count: 0, updatedAt: null, isServerError: true); } - // ─────────────────────────────────────────────────────────────────────── final Map json; try { json = jsonDecode(content) as Map; } catch (e) { debugPrint('[GlossarySyncService] Не удалось распарсить дамп: $e'); - return _IsolateResult(count: 0, updatedAt: null, isServerError: true); + return const _IsolateResult(count: 0, updatedAt: null, isServerError: true); } final dataList = (json['data'] as List?) ?? []; @@ -180,28 +200,37 @@ class GlossarySyncService { /// Обрезает невалидный «склеенный» JSON до первой валидной части. /// - /// Сервер иногда дописывает ошибку после корректного JSON: - /// `...null}{"message":"Server error."}` — это два объекта подряд. - /// Метод находит границу и возвращает только первый объект. - /// Если восстановить невозможно — возвращает null. + /// Сервер иногда падает в процессе стриминга дампа и дописывает + /// {"message":"Server error.",...} прямо в тело — без разделителя. + /// Пример: ...,"deleted_at":null}{"message":"Server error.",...} + /// + /// Формат дампа: {"total":N,"updated_at":"...","data":[{...},{...},...]} + /// После обрезки на `}{` получается: {"total":N,...,"data":[...,{..."deleted_at":null} + /// Нужно закрыть массив и объект: добавить `]}` static String? _sanitizeJsonResponse(String raw) { - // Быстрый путь: если нет признаков склейки — возвращаем как есть - if (!raw.contains('}\u007b') && !_hasServerErrorMarker(raw)) { + if (!raw.contains('}{') && !_hasServerErrorMarker(raw)) { return raw; } - // Ищем паттерн конца первого объекта + начала второго: `}{` - // с возможными пробелами между ними: `} {` или `}\n{` - final splicePattern = RegExp(r'\}\s*\{'); + final splicePattern = RegExp(r'}\s*{'); final match = splicePattern.firstMatch(raw); if (match != null) { - // Берём только часть до начала второго объекта - final truncated = raw.substring(0, match.start + 1); + var truncated = raw.substring(0, match.start + 1); debugPrint('[GlossarySyncService] Обнаружена склейка на позиции ${match.start}, обрезаем дамп'); + + // Закрываем незавершённую JSON-структуру. + // Дамп имеет строго определённый формат: + // {"total":N,"updated_at":"...","data":[{...},...,{...}]} + // После обрезки на `}{` получается: + // {"total":N,"updated_at":"...","data":[...,{..."deleted_at":null} + // Нужно закрыть массив data и корневой объект → дописываем ']}' + if (truncated.contains('"data":[')) { + truncated += ']}'; + debugPrint('[GlossarySyncService] Дописали ]} для закрытия JSON-структуры'); + } return truncated; } - // Нет явной границы, но контент содержит признаки ошибки — не парсим if (_hasServerErrorMarker(raw)) { return null; } @@ -212,7 +241,6 @@ class GlossarySyncService { static bool _hasServerErrorMarker(String content) { return content.contains('"Server error"') || content.contains('"Server Error"') || - // Проверяем наличие ключей типичного error-ответа не в начале строки (content.indexOf('"message"') > 1000 && content.contains('"error"')); } } diff --git a/lib/core/locator_service.dart b/lib/core/locator_service.dart index f7cc92d4..c43ec93c 100644 --- a/lib/core/locator_service.dart +++ b/lib/core/locator_service.dart @@ -5,6 +5,7 @@ import 'package:eatvio_app/core/di/external_registrations.dart'; import 'package:eatvio_app/core/di/repository_registrations.dart'; import 'package:eatvio_app/core/di/service_registrations.dart'; import 'package:eatvio_app/core/local_db/services/diary_sync_runner.dart'; +import 'package:eatvio_app/features/diary/presentation/bloc/glossary_sync_cubit.dart'; import 'package:eatvio_app/core/prefs.dart'; import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:get_it/get_it.dart'; @@ -38,4 +39,7 @@ Future init({required AppBuildType buildType}) async { // Запуск FIFO-воркера синхронизации дневника // Подписывается на InternetConnectionCubit и обрабатывает очередь при появлении сети sl().start(); + + // Запуск синхронизации глоссария с отслеживанием прогресса в UI + sl().start(); } diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index 58369798..dbf856a3 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -80,6 +80,12 @@ class OtherRoutes { } return RouteConstants.verifyEmailScreen; } catch (e) { + // Offline-first: если токен есть, но сеть недоступна — + // пускаем в приложение с кэшированными данными. + // На auth-screen отправляем только если токена нет. + if (await TokenStorage.haveTokenInStorage()) { + return RouteConstants.feedScreen; + } return '/auth-screen'; } } else { diff --git a/lib/features/diary/presentation/bloc/diary_cubit.dart b/lib/features/diary/presentation/bloc/diary_cubit.dart index 503edb4e..de24b818 100644 --- a/lib/features/diary/presentation/bloc/diary_cubit.dart +++ b/lib/features/diary/presentation/bloc/diary_cubit.dart @@ -88,6 +88,7 @@ class DiaryCubit extends Cubit { emit(const DiaryState.loading()); } if (diaryId != null) { + log('[fetchDiaryV2] Loading by diaryId=$diaryId'); final diaryDayById = await diaryRepository.getDiaryDay(diaryId); final diaryEntity = diaryDayById.toEntity(); if (diaryEntity != null) { @@ -102,24 +103,29 @@ class DiaryCubit extends Cubit { final msg = connectionCubit.state.isInternetAvailable ? 'Дневник не найден' : 'Нет интернета. Откройте дневник онлайн хотя бы один раз, чтобы он был доступен офлайн.'; + log('[fetchDiaryV2] diaryDay is null, msg=$msg'); emit(DiaryState.error(errorMessage: msg)); return true; } + log('[fetchDiaryV2] diaryDay loaded, id=${diaryDay.id}, converting to entity...'); final diaryEntity = diaryDay.toEntity(); if (diaryEntity != null) { - setDiary(diaryDay: diaryEntity, diaryId: diaryId); + log('[fetchDiaryV2] entity OK, calling setDiary...'); + await setDiary(diaryDay: diaryEntity, diaryId: diaryId); + log('[fetchDiaryV2] setDiary completed'); return true; } if (diaryDay.typeOfDiary.hasChosenDiet == true && diaryDay.typeOfDiary.diaryState == null) { setDiaryForDietOnly(diaryDay.toDiaryFutureDayEntity()); return true; } + log('[fetchDiaryV2] no entity, emitting previous state'); emit(currentState); return false; } catch (e, st) { + log('[fetchDiaryV2] CRASH: $e', stackTrace: st); await reportFrom(e, st, this); - log(e.toString(), stackTrace: st); emit(DiaryState.error(errorMessage: e.toString())); return false; } @@ -142,37 +148,49 @@ class DiaryCubit extends Cubit { Future getDiary() async { _lastFetchWasOffline = false; + final dayStr = selectedDay.toISO8601WithTimeZoneOffset(); + log('[getDiary] START: day=$dayStr, isOnline=${connectionCubit.state.isInternetAvailable}'); // Офлайн: СРАЗУ в кэш, не трогаем сеть if (!connectionCubit.state.isInternetAvailable) { _lastFetchWasOffline = true; try { - return await diaryRepository.getDiaryDayV2( - day: selectedDay.toISO8601WithTimeZoneOffset(), - ); - } catch (_) { + final result = await diaryRepository.getDiaryDayV2(day: dayStr); + log('[getDiary] OFFLINE: loaded from cache OK'); + return result; + } catch (e) { + log('[getDiary] OFFLINE: no cache: $e'); return null; // Нет кэша — вернём null, UI покажет offline-заглушку } } // Онлайн: создаём день + грузим с сервера try { - await diaryRepository.createDiaryDay(day: selectedDay.toISO8601WithTimeZoneOffset()); - return await diaryRepository.getDiaryDayV2(day: selectedDay.toISO8601WithTimeZoneOffset()); + log('[getDiary] ONLINE: creating diary day...'); + await diaryRepository.createDiaryDay(day: dayStr); + log('[getDiary] ONLINE: day created, loading...'); + final result = await diaryRepository.getDiaryDayV2(day: dayStr); + log('[getDiary] ONLINE: loaded OK, id=${result.id}'); + return result; } catch (e, st) { + log('[getDiary] ONLINE ERROR: $e'); await reportFrom(e, st, this); - log(e.toString(), stackTrace: st); if (e is DioException && e.response?.statusCode == 422) { // День уже существует — просто загружаем try { - return await diaryRepository.getDiaryDayV2(day: selectedDay.toISO8601WithTimeZoneOffset()); - } catch (_) {} + log('[getDiary] 422 fallback: loading existing day...'); + return await diaryRepository.getDiaryDayV2(day: dayStr); + } catch (e2) { + log('[getDiary] 422 fallback failed: $e2'); + } } // Другая сетевая ошибка → пробуем кэш _lastFetchWasOffline = true; try { - return await diaryRepository.getDiaryDayV2(day: selectedDay.toISO8601WithTimeZoneOffset()); - } catch (_) { + log('[getDiary] Error fallback: trying cache...'); + return await diaryRepository.getDiaryDayV2(day: dayStr); + } catch (e3) { + log('[getDiary] Cache fallback failed: $e3'); return null; } } @@ -325,7 +343,12 @@ class DiaryCubit extends Cubit { diaryDay.typeOfDiary.diaryState == DiaryStateEnum.reviewedByUser; // Offline-first: получаем кол-во операций в очереди - final pendingCount = await localDataSource.getPendingCount(); + int pendingCount = 0; + try { + pendingCount = await localDataSource.getPendingCount(); + } catch (_) { + // Ошибка чтения pending_operations — не критично + } emit( DiaryState.loaded( diff --git a/lib/features/diary/presentation/bloc/glossary_sync_cubit.dart b/lib/features/diary/presentation/bloc/glossary_sync_cubit.dart new file mode 100644 index 00000000..adfba540 --- /dev/null +++ b/lib/features/diary/presentation/bloc/glossary_sync_cubit.dart @@ -0,0 +1,122 @@ +import 'dart:async'; + +import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart'; +import 'package:eatvio_app/core/local_db/services/glossary_sync_runner.dart'; +import 'package:eatvio_app/features/diary/presentation/bloc/glossary_sync_state.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Кубит для отслеживания синхронизации глоссария продуктов. +/// +/// Подписывается на [InternetConnectionCubit] — при появлении сети +/// автоматически запускает синхронизацию. Эмитит детальные стадии: +/// checking → downloading → processing → completed/error. +/// +/// Используется в [GlossarySyncBannerWidget] для показа прогресса в UI. +class GlossarySyncCubit extends Cubit { + final GlossarySyncService _syncService; + final InternetConnectionCubit _connectionCubit; + + StreamSubscription? _connectionSub; + bool _isSyncing = false; + + /// Таймер автоскрытия баннера после успешной синхронизации + Timer? _hideTimer; + + GlossarySyncCubit({ + required GlossarySyncService syncService, + required InternetConnectionCubit connectionCubit, + }) : _syncService = syncService, + _connectionCubit = connectionCubit, + super(const GlossarySyncState.idle()); + + /// Начать прослушивание сети и автозапуск синхронизации. + void start() { + _connectionSub = _connectionCubit.stream.listen((state) { + if (state.isInternetAvailable && !_isSyncing) { + syncNow(); + } + }); + + // Запустить немедленно если уже online + if (_connectionCubit.state.isInternetAvailable) { + syncNow(); + } + } + + /// Принудительно запустить синхронизацию (можно вызвать из UI). + Future syncNow() async { + if (_isSyncing) return; + _isSyncing = true; + _hideTimer?.cancel(); + + try { + final count = await _syncService.sync( + onStageChanged: _onStageChanged, + onDownloadProgress: _onDownloadProgress, + onProcessingProgress: _onProcessingProgress, + ); + + if (count == 0) { + emit(const GlossarySyncState.upToDate()); + } else { + emit(GlossarySyncState.completed(syncedCount: count)); + } + + // Автоскрытие через 4 секунды + _hideTimer = Timer(const Duration(seconds: 4), () { + if (!isClosed) emit(const GlossarySyncState.idle()); + }); + } catch (e) { + emit(GlossarySyncState.error(message: _humanReadableError(e))); + // Скрыть ошибку через 6 секунд + _hideTimer = Timer(const Duration(seconds: 6), () { + if (!isClosed) emit(const GlossarySyncState.idle()); + }); + } finally { + _isSyncing = false; + } + } + + void _onStageChanged(GlossarySyncStage stage) { + if (isClosed) return; + switch (stage) { + case GlossarySyncStage.checking: + emit(const GlossarySyncState.checking()); + case GlossarySyncStage.downloading: + emit(const GlossarySyncState.downloading()); + case GlossarySyncStage.processing: + emit(const GlossarySyncState.processing(processed: 0, total: 0)); + case GlossarySyncStage.saving: + // Остаёмся на processing — saving идёт мгновенно + break; + } + } + + void _onDownloadProgress(double progress) { + if (isClosed) return; + emit(GlossarySyncState.downloading(progress: progress)); + } + + void _onProcessingProgress(int processed, int total) { + if (isClosed) return; + emit(GlossarySyncState.processing(processed: processed, total: total)); + } + + String _humanReadableError(Object e) { + final msg = e.toString(); + if (msg.contains('SocketException') || msg.contains('connection')) { + return 'Нет соединения с сервером'; + } + if (msg.contains('TimeoutException') || msg.contains('timeout')) { + return 'Сервер не отвечает'; + } + return 'Ошибка синхронизации глоссария'; + } + + @override + Future close() { + _connectionSub?.cancel(); + _hideTimer?.cancel(); + return super.close(); + } +} diff --git a/lib/features/diary/presentation/bloc/glossary_sync_state.dart b/lib/features/diary/presentation/bloc/glossary_sync_state.dart new file mode 100644 index 00000000..50a05a86 --- /dev/null +++ b/lib/features/diary/presentation/bloc/glossary_sync_state.dart @@ -0,0 +1,41 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'glossary_sync_state.freezed.dart'; + +/// Состояние синхронизации глоссария продуктов. +/// +/// idle → ничего не происходит +/// checking → проверяем checksum через /meta +/// downloading → скачиваем дамп в файл +/// processing → парсим JSON + bulk-insert в БД (progress: 0.0–1.0) +/// completed → синхронизация завершена (syncedCount записей) +/// error → ошибка синхронизации +@freezed +abstract class GlossarySyncState with _$GlossarySyncState { + const factory GlossarySyncState.idle() = GlossarySyncIdle; + + const factory GlossarySyncState.checking() = GlossarySyncChecking; + + const factory GlossarySyncState.downloading({ + /// Прогресс загрузки файла: 0.0 – 1.0 (null если Content-Length неизвестен) + double? progress, + }) = GlossarySyncDownloading; + + const factory GlossarySyncState.processing({ + /// Кол-во обработанных записей + required int processed, + /// Общее кол-во записей в дампе + required int total, + }) = GlossarySyncProcessing; + + const factory GlossarySyncState.completed({ + /// Кол-во синхронизированных записей + required int syncedCount, + }) = GlossarySyncCompleted; + + const factory GlossarySyncState.error({ + required String message, + }) = GlossarySyncError; + + const factory GlossarySyncState.upToDate() = GlossarySyncUpToDate; +} diff --git a/lib/features/diary/presentation/pages/diary_screen.dart b/lib/features/diary/presentation/pages/diary_screen.dart index ef7347a2..72a6679f 100644 --- a/lib/features/diary/presentation/pages/diary_screen.dart +++ b/lib/features/diary/presentation/pages/diary_screen.dart @@ -20,6 +20,8 @@ import 'package:eatvio_app/features/diary/presentation/widgets/day_comment_widge import 'package:eatvio_app/features/diary/presentation/widgets/finish_day_button_widget.dart'; import 'package:eatvio_app/features/diary/presentation/widgets/meal_widget.dart'; import 'package:eatvio_app/features/diary/presentation/widgets/offline_banner_widget.dart'; +import 'package:eatvio_app/features/diary/presentation/widgets/glossary_sync_banner_widget.dart'; +import 'package:eatvio_app/features/diary/presentation/bloc/glossary_sync_cubit.dart'; import 'package:eatvio_app/features/diary/presentation/widgets/wakeup_and_retire_widget.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; @@ -74,6 +76,7 @@ class _DiaryScreenContentState extends State { log(state.diary.id.toString()); } if (state is DiaryStateError) { + log('DiaryScreen ERROR: ${state.errorMessage}'); return Scaffold( body: DataErrorWidget(onReload: () => context.read().fetchDiaryV2(needLoading: false, diaryId: widget.diaryId)), ); @@ -94,6 +97,19 @@ class _DiaryScreenContentState extends State { children: [ // Офлайн-баннер (скользит сверху при потере сети) const OfflineBannerWidget(), + // Прогресс синхронизации глоссария продуктов + Builder( + builder: (context) { + try { + return BlocProvider.value( + value: sl(), + child: const GlossarySyncBannerWidget(), + ); + } catch (_) { + return const SizedBox.shrink(); + } + }, + ), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.only(bottom: 16, top: 8), diff --git a/lib/features/diary/presentation/widgets/glossary_sync_banner_widget.dart b/lib/features/diary/presentation/widgets/glossary_sync_banner_widget.dart new file mode 100644 index 00000000..db843a58 --- /dev/null +++ b/lib/features/diary/presentation/widgets/glossary_sync_banner_widget.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:eatvio_app/features/diary/presentation/bloc/glossary_sync_cubit.dart'; +import 'package:eatvio_app/features/diary/presentation/bloc/glossary_sync_state.dart'; + +/// Компактный баннер синхронизации глоссария. +/// +/// Показывает текущий статус: +/// - checking → «Проверяю обновления…» +/// - downloading → «Загрузка продуктов: 45%» +/// - processing → «Обработка: 12 500 / 200 000» +/// - completed → «✓ Синхронизировано 200 000 продуктов» +/// - error → «✕ Ошибка синхронизации» +/// - idle/upToDate → скрыт +class GlossarySyncBannerWidget extends StatelessWidget { + const GlossarySyncBannerWidget({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (prev, curr) => prev.runtimeType != curr.runtimeType || prev != curr, + builder: (context, state) { + return state.when( + idle: () => const SizedBox.shrink(), + upToDate: () => const SizedBox.shrink(), + checking: () => _buildBanner( + color: const Color(0xFF3B82F6), + icon: Icons.sync_rounded, + text: 'Проверяю обновления продуктов…', + showSpinner: true, + ), + downloading: (progress) => _buildBanner( + color: const Color(0xFF3B82F6), + icon: Icons.cloud_download_rounded, + text: progress != null + ? 'Загрузка продуктов: ${(progress * 100).toInt()}%' + : 'Загрузка продуктов…', + showSpinner: true, + progress: progress, + ), + processing: (processed, total) => _buildBanner( + color: const Color(0xFF8B5CF6), + icon: Icons.storage_rounded, + text: total > 0 + ? 'Обработка: ${_formatNumber(processed)} / ${_formatNumber(total)}' + : 'Обработка данных…', + showSpinner: true, + progress: total > 0 ? processed / total : null, + ), + completed: (syncedCount) => _buildBanner( + color: const Color(0xFF059669), + icon: Icons.check_circle_rounded, + text: 'Синхронизировано ${_formatNumber(syncedCount)} продуктов', + ), + error: (message) => _buildBanner( + color: const Color(0xFFDC2626), + icon: Icons.error_outline_rounded, + text: message, + ), + ); + }, + ); + } + + Widget _buildBanner({ + required Color color, + required IconData icon, + required String text, + bool showSpinner = false, + double? progress, + }) { + return _AnimatedBanner( + color: color, + icon: icon, + text: text, + showSpinner: showSpinner, + progress: progress, + ); + } + + static String _formatNumber(int n) { + if (n < 1000) return '$n'; + if (n < 1000000) { + final thousands = n ~/ 1000; + final remainder = (n % 1000) ~/ 100; + return remainder > 0 ? '$thousands ${remainder}00' : '${thousands}k'; + } + return '${(n / 1000000).toStringAsFixed(1)}M'; + } +} + +class _AnimatedBanner extends StatefulWidget { + final Color color; + final IconData icon; + final String text; + final bool showSpinner; + final double? progress; + + const _AnimatedBanner({ + required this.color, + required this.icon, + required this.text, + this.showSpinner = false, + this.progress, + }); + + @override + State<_AnimatedBanner> createState() => _AnimatedBannerState(); +} + +class _AnimatedBannerState extends State<_AnimatedBanner> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _slideAnim; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + _slideAnim = Tween(begin: -1.0, end: 0.0).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic), + ); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _slideAnim, + builder: (context, child) => Transform.translate( + offset: Offset(0, _slideAnim.value * 36), + child: child, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: widget.color, + boxShadow: [ + BoxShadow( + color: widget.color.withValues(alpha: 0.3), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + if (widget.showSpinner) + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + value: widget.progress, + ), + ) + else + Icon(widget.icon, color: Colors.white, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + widget.text, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + ), + ), + ], + ), + ), + // Тонкий прогресс-бар под баннером + if (widget.progress != null) + LinearProgressIndicator( + value: widget.progress, + minHeight: 2, + backgroundColor: widget.color.withValues(alpha: 0.3), + valueColor: AlwaysStoppedAnimation( + Colors.white.withValues(alpha: 0.8), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/internet_connection_checker/internet_connection_checker_screen.dart b/lib/features/internet_connection_checker/internet_connection_checker_screen.dart index 576eb891..c45537ef 100644 --- a/lib/features/internet_connection_checker/internet_connection_checker_screen.dart +++ b/lib/features/internet_connection_checker/internet_connection_checker_screen.dart @@ -63,20 +63,24 @@ class _InternetConnectionCheckerScreenState extends State _checkInternetAccess(List result) async { - if (result.contains(ConnectivityResult.none)) { - return false; - } + final isLikelyOffline = result.contains(ConnectivityResult.none); + final timeout = Duration(seconds: isLikelyOffline ? 2 : 3); - final uris = [Uri.parse(EnvConfig.googleCheckUrl), Uri.parse(EnvConfig.yandexCheckUrl), Uri.parse(EnvConfig.devBaseUrl), Uri.parse(EnvConfig.baseUrl)]; - for (final uri in uris) { - try { - final response = await http.get(uri).timeout(const Duration(seconds: 3)); - return response.statusCode >= 100 && response.statusCode < 600; - } catch (e) { - continue; - } - } - return false; + final uris = [ + Uri.parse(EnvConfig.googleCheckUrl), + Uri.parse(EnvConfig.yandexCheckUrl), + Uri.parse(EnvConfig.baseUrl), + ]; + + final futures = uris.map((uri) => + http.get(uri) + .timeout(timeout) + .then((r) => r.statusCode >= 100 && r.statusCode < 600) + .catchError((_) => false) + ); + + final results = await Future.wait(futures); + return results.any((ok) => ok); } @override