fix: diary loading

This commit is contained in:
mso7nueh
2026-04-24 16:54:04 +03:00
parent 10c7e0a584
commit 9ef9a7289c
13 changed files with 532 additions and 83 deletions

View File

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

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

View File

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

View File

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

View File

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