forked from eatvio/eatvio_app
fix: diary loading
This commit is contained in:
@@ -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>(() => ProfileAvatarCubit(profileRepository: sl()));
|
||||
sl.registerLazySingleton<ChatsCubit>(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl()));
|
||||
sl.registerLazySingleton<InternetConnectionCubit>(() => InternetConnectionCubit());
|
||||
sl.registerLazySingleton<GlossarySyncCubit>(
|
||||
() => GlossarySyncCubit(
|
||||
syncService: sl<GlossarySyncService>(),
|
||||
connectionCubit: sl<InternetConnectionCubit>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Регистрация Cubit'ов как фабрик
|
||||
|
||||
@@ -81,7 +81,6 @@ class RepositoryRegistrations {
|
||||
localDataSource: sl(),
|
||||
diaryService: sl(),
|
||||
connectionCubit: sl(),
|
||||
glossarySyncService: sl<GlossarySyncService>(),
|
||||
),
|
||||
);
|
||||
sl.registerLazySingleton<GlossaryRepository>(
|
||||
|
||||
@@ -46,7 +46,10 @@ class InternetConnectionCubit extends Cubit<InternetConnectionState> {
|
||||
Future<void> _updateAccessStatus(List<ConnectivityResult> 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<InternetConnectionState> {
|
||||
}
|
||||
|
||||
Future<bool> _checkInternetAccess(List<ConnectivityResult> 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
|
||||
|
||||
@@ -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<void> _syncGlossary() async {
|
||||
try {
|
||||
await _glossarySyncService.sync();
|
||||
} catch (e) {
|
||||
debugPrint('[DiarySyncRunner] Glossary sync error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Остановить прослушивание.
|
||||
void dispose() {
|
||||
|
||||
@@ -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<int> sync() async {
|
||||
///
|
||||
/// Опциональные коллбэки для отслеживания прогресса в UI:
|
||||
/// - [onStageChanged] — смена стадии (checking → downloading → processing → saving)
|
||||
/// - [onDownloadProgress] — прогресс скачивания файла (0.0–1.0)
|
||||
/// - [onProcessingProgress] — прогресс парсинга/вставки (processed, total)
|
||||
Future<int> 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<String, dynamic> json;
|
||||
try {
|
||||
json = jsonDecode(content) as Map<String, dynamic>;
|
||||
} 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<dynamic>?) ?? [];
|
||||
@@ -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"'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> init({required AppBuildType buildType}) async {
|
||||
// Запуск FIFO-воркера синхронизации дневника
|
||||
// Подписывается на InternetConnectionCubit и обрабатывает очередь при появлении сети
|
||||
sl<DiarySyncRunner>().start();
|
||||
|
||||
// Запуск синхронизации глоссария с отслеживанием прогресса в UI
|
||||
sl<GlossarySyncCubit>().start();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -88,6 +88,7 @@ class DiaryCubit extends Cubit<DiaryState> {
|
||||
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<DiaryState> {
|
||||
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<DiaryState> {
|
||||
|
||||
Future<DiaryDayModel?> 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<DiaryState> {
|
||||
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(
|
||||
|
||||
122
lib/features/diary/presentation/bloc/glossary_sync_cubit.dart
Normal file
122
lib/features/diary/presentation/bloc/glossary_sync_cubit.dart
Normal file
@@ -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<GlossarySyncState> {
|
||||
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<void> 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<void> close() {
|
||||
_connectionSub?.cancel();
|
||||
_hideTimer?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<DiaryScreenContent> {
|
||||
log(state.diary.id.toString());
|
||||
}
|
||||
if (state is DiaryStateError) {
|
||||
log('DiaryScreen ERROR: ${state.errorMessage}');
|
||||
return Scaffold(
|
||||
body: DataErrorWidget(onReload: () => context.read<DiaryCubit>().fetchDiaryV2(needLoading: false, diaryId: widget.diaryId)),
|
||||
);
|
||||
@@ -94,6 +97,19 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
children: [
|
||||
// Офлайн-баннер (скользит сверху при потере сети)
|
||||
const OfflineBannerWidget(),
|
||||
// Прогресс синхронизации глоссария продуктов
|
||||
Builder(
|
||||
builder: (context) {
|
||||
try {
|
||||
return BlocProvider.value(
|
||||
value: sl<GlossarySyncCubit>(),
|
||||
child: const GlossarySyncBannerWidget(),
|
||||
);
|
||||
} catch (_) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 16, top: 8),
|
||||
|
||||
@@ -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<GlossarySyncCubit, GlossarySyncState>(
|
||||
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<double> _slideAnim;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
_slideAnim = Tween<double>(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<Color>(
|
||||
Colors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -63,20 +63,24 @@ class _InternetConnectionCheckerScreenState extends State<InternetConnectionChec
|
||||
}
|
||||
|
||||
Future<bool> _checkInternetAccess(List<ConnectivityResult> 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
|
||||
|
||||
Reference in New Issue
Block a user