forked from eatvio/eatvio_app
201 lines
5.8 KiB
Dart
201 lines
5.8 KiB
Dart
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,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|