forked from eatvio/eatvio_app
feat: implement offline-first architecture with local database synchronization and diary caching
This commit is contained in:
@@ -13,6 +13,10 @@ class CaloriesChartWidget extends StatelessWidget {
|
||||
return BlocBuilder<DiaryCubit, DiaryState>(
|
||||
builder: (BuildContext context, DiaryState state) {
|
||||
if (state is DiaryStateLoaded) {
|
||||
// В офлайн-режиме данные о питании недоступны — скрываем блок
|
||||
final nutrition = state.nutrition;
|
||||
if (nutrition == null) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -72,9 +76,9 @@ class CaloriesChartWidget extends StatelessWidget {
|
||||
cornerStyle: CornerStyle.bothCurve,
|
||||
),
|
||||
RangePointer(
|
||||
value: (state.nutrition.dailyNutritionIntake.carbohydrates / state.nutrition.dailyNutritionRecommendation.carbohydrates) * 100,
|
||||
value: (nutrition.dailyNutritionIntake.carbohydrates / nutrition.dailyNutritionRecommendation.carbohydrates) * 100,
|
||||
width: 0.16,
|
||||
color: state.nutrition.dailyNutritionIntake.carbohydrates < state.nutrition.dailyNutritionRecommendation.carbohydrates
|
||||
color: nutrition.dailyNutritionIntake.carbohydrates < nutrition.dailyNutritionRecommendation.carbohydrates
|
||||
? appTheme.blueA400
|
||||
: Colors.red,
|
||||
sizeUnit: GaugeSizeUnit.factor,
|
||||
@@ -92,9 +96,9 @@ class CaloriesChartWidget extends StatelessWidget {
|
||||
pointerOffset: -0.18,
|
||||
),
|
||||
RangePointer(
|
||||
value: (state.nutrition.dailyNutritionIntake.fats / state.nutrition.dailyNutritionRecommendation.fats) * 100,
|
||||
value: (nutrition.dailyNutritionIntake.fats / nutrition.dailyNutritionRecommendation.fats) * 100,
|
||||
width: 0.16,
|
||||
color: state.nutrition.dailyNutritionIntake.fats < state.nutrition.dailyNutritionRecommendation.fats
|
||||
color: nutrition.dailyNutritionIntake.fats < nutrition.dailyNutritionRecommendation.fats
|
||||
? appTheme.blueA400
|
||||
: Colors.red,
|
||||
sizeUnit: GaugeSizeUnit.factor,
|
||||
@@ -113,9 +117,9 @@ class CaloriesChartWidget extends StatelessWidget {
|
||||
pointerOffset: -0.36,
|
||||
),
|
||||
RangePointer(
|
||||
value: (state.nutrition.dailyNutritionIntake.proteins / state.nutrition.dailyNutritionRecommendation.proteins) * 100,
|
||||
value: (nutrition.dailyNutritionIntake.proteins / nutrition.dailyNutritionRecommendation.proteins) * 100,
|
||||
width: 0.16,
|
||||
color: state.nutrition.dailyNutritionIntake.proteins < state.nutrition.dailyNutritionRecommendation.proteins
|
||||
color: nutrition.dailyNutritionIntake.proteins < nutrition.dailyNutritionRecommendation.proteins
|
||||
? appTheme.blueA400
|
||||
: Colors.red,
|
||||
sizeUnit: GaugeSizeUnit.factor,
|
||||
@@ -134,9 +138,9 @@ class CaloriesChartWidget extends StatelessWidget {
|
||||
pointerOffset: -0.54,
|
||||
),
|
||||
RangePointer(
|
||||
value: (state.nutrition.dailyNutritionIntake.energy / state.nutrition.dailyNutritionRecommendation.energy) * 100,
|
||||
value: (nutrition.dailyNutritionIntake.energy / nutrition.dailyNutritionRecommendation.energy) * 100,
|
||||
width: 0.16,
|
||||
color: state.nutrition.dailyNutritionIntake.energy < state.nutrition.dailyNutritionRecommendation.energy
|
||||
color: nutrition.dailyNutritionIntake.energy < nutrition.dailyNutritionRecommendation.energy
|
||||
? appTheme.blueA400
|
||||
: Colors.red,
|
||||
sizeUnit: GaugeSizeUnit.factor,
|
||||
@@ -210,24 +214,22 @@ class CaloriesChartWidget extends StatelessWidget {
|
||||
children: [
|
||||
_chartData(
|
||||
'Калории',
|
||||
'${state.nutrition.dailyNutritionIntake.energy.toStringAsFixed(0)} из ${state.nutrition.dailyNutritionRecommendation.energy.toStringAsFixed(0)} ккал',
|
||||
'${nutrition.dailyNutritionIntake.energy.toStringAsFixed(0)} из ${nutrition.dailyNutritionRecommendation.energy.toStringAsFixed(0)} ккал',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_chartData(
|
||||
'Белки',
|
||||
'${state.nutrition.dailyNutritionIntake.proteins.toStringAsFixed(0)} из ${state.nutrition.dailyNutritionRecommendation.proteins.toStringAsFixed(
|
||||
0,
|
||||
)} грамм',
|
||||
'${nutrition.dailyNutritionIntake.proteins.toStringAsFixed(0)} из ${nutrition.dailyNutritionRecommendation.proteins.toStringAsFixed(0)} грамм',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_chartData(
|
||||
'Жиры',
|
||||
'${state.nutrition.dailyNutritionIntake.fats.toStringAsFixed(0)} из ${state.nutrition.dailyNutritionRecommendation.fats.toStringAsFixed(0)} грамм',
|
||||
'${nutrition.dailyNutritionIntake.fats.toStringAsFixed(0)} из ${nutrition.dailyNutritionRecommendation.fats.toStringAsFixed(0)} грамм',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_chartData(
|
||||
'Углеводы',
|
||||
'${state.nutrition.dailyNutritionIntake.carbohydrates.toStringAsFixed(0)} из ${state.nutrition.dailyNutritionRecommendation.carbohydrates.toStringAsFixed(0)} грамм',
|
||||
'${nutrition.dailyNutritionIntake.carbohydrates.toStringAsFixed(0)} из ${nutrition.dailyNutritionRecommendation.carbohydrates.toStringAsFixed(0)} грамм',
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -238,6 +240,7 @@ class CaloriesChartWidget extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart';
|
||||
import 'package:eatvio_app/features/diary/presentation/bloc/diary_state.dart';
|
||||
|
||||
/// Тонкий баннер вверху экрана дневника, указывающий на офлайн-режим
|
||||
/// и количество операций, ожидающих синхронизацию.
|
||||
///
|
||||
/// Показывается только когда нет интернета или есть pending операции.
|
||||
/// Автоматически скрывается при восстановлении соединения и пустой очереди.
|
||||
class OfflineBannerWidget extends StatelessWidget {
|
||||
const OfflineBannerWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<DiaryCubit, DiaryState>(
|
||||
buildWhen: (prev, curr) {
|
||||
if (prev is DiaryStateLoaded && curr is DiaryStateLoaded) {
|
||||
return prev.pendingOpsCount != curr.pendingOpsCount ||
|
||||
prev.isOfflineMode != curr.isOfflineMode;
|
||||
}
|
||||
return prev.runtimeType != curr.runtimeType;
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is! DiaryStateLoaded) return const SizedBox.shrink();
|
||||
|
||||
final isOffline = state.isOfflineMode;
|
||||
final pending = state.pendingOpsCount;
|
||||
|
||||
// Не показываем если онлайн и очердь пуста
|
||||
if (!isOffline && pending == 0) return const SizedBox.shrink();
|
||||
|
||||
return _OfflineBanner(isOffline: isOffline, pendingCount: pending);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OfflineBanner extends StatefulWidget {
|
||||
final bool isOffline;
|
||||
final int pendingCount;
|
||||
|
||||
const _OfflineBanner({required this.isOffline, required this.pendingCount});
|
||||
|
||||
@override
|
||||
State<_OfflineBanner> createState() => _OfflineBannerState();
|
||||
}
|
||||
|
||||
class _OfflineBannerState extends State<_OfflineBanner>
|
||||
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) {
|
||||
final (color, icon, text) = _resolveStyle();
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _slideAnim,
|
||||
builder: (context, child) => Transform.translate(
|
||||
offset: Offset(0, _slideAnim.value * 40),
|
||||
child: child,
|
||||
),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: color.withValues(alpha: 0.35),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: Colors.white, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.isOffline)
|
||||
const _PulsingDot(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
(Color, IconData, String) _resolveStyle() {
|
||||
if (widget.isOffline && widget.pendingCount > 0) {
|
||||
return (
|
||||
const Color(0xFFE85D04),
|
||||
Icons.cloud_off_rounded,
|
||||
'Офлайн · ${widget.pendingCount} ${_inflect(widget.pendingCount)} в очереди',
|
||||
);
|
||||
} else if (widget.isOffline) {
|
||||
return (
|
||||
const Color(0xFF6B7280),
|
||||
Icons.cloud_off_rounded,
|
||||
'Офлайн · данные из кэша',
|
||||
);
|
||||
} else {
|
||||
// Онлайн, но есть pending операции (только что восстановилось соединение)
|
||||
return (
|
||||
const Color(0xFF059669),
|
||||
Icons.sync_rounded,
|
||||
'Синхронизация: ${widget.pendingCount} ${_inflect(widget.pendingCount)}…',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _inflect(int n) {
|
||||
if (n % 100 >= 11 && n % 100 <= 19) return 'изменений';
|
||||
switch (n % 10) {
|
||||
case 1:
|
||||
return 'изменение';
|
||||
case 2:
|
||||
case 3:
|
||||
case 4:
|
||||
return 'изменения';
|
||||
default:
|
||||
return 'изменений';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Мигающая точка — визуальный индикатор офлайн-статуса.
|
||||
class _PulsingDot extends StatefulWidget {
|
||||
const _PulsingDot();
|
||||
|
||||
@override
|
||||
State<_PulsingDot> createState() => _PulsingDotState();
|
||||
}
|
||||
|
||||
class _PulsingDotState extends State<_PulsingDot>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctrl;
|
||||
late final Animation<double> _anim;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
)..repeat(reverse: true);
|
||||
_anim = Tween<double>(begin: 0.3, end: 1.0).animate(_ctrl);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeTransition(
|
||||
opacity: _anim,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user