Files
eatvio_app/lib/features/diary/presentation/widgets/offline_banner_widget.dart

201 lines
5.8 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
),
),
);
}
}