Files
hackathon-innovaflow5.0-cdf…/recolecta_app/lib/features/home/citizen_home_screen.dart
shinra32 56c51378b8 Co-authored-by: MENDOZA BALLARDO GAEL RICARDO <gael-meb123@users.noreply.github.com>
version final final ya enserio la final del proyecto :)
2026-05-23 08:42:27 -06:00

994 lines
36 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 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../core/theme/app_theme.dart';
import 'colonias_data.dart';
import '../../core/widgets/app_widgets.dart';
import '../../core/network/api_client.dart';
import '../notifications/notification_service.dart';
import '../../shared/widgets/prevention_banner.dart';
import '../../shared/widgets/progress_steps.dart';
import '../separation_guide/ai_pet_chat_screen.dart';
// ─────────────────────────────────────────────────────────────────────────────
// Modelo de resultado ETA
// ─────────────────────────────────────────────────────────────────────────────
class _EtaResult {
final String mensaje;
final String status;
final String direccion;
final String colonia;
final bool hasAddress;
final double? lat;
final double? lng;
const _EtaResult({
required this.mensaje,
required this.status,
required this.direccion,
required this.colonia,
required this.hasAddress,
this.lat,
this.lng,
});
const _EtaResult.noAddress()
: mensaje = '',
status = '',
direccion = '',
colonia = '',
hasAddress = false,
lat = null,
lng = null;
// ── Utilidades derivadas ───────────────────────────────────────────────────
bool get isCompleted => status == 'completada';
bool get isNearby =>
mensaje.contains('15 minutos') || mensaje.contains('Está atendiendo');
double get progreso {
if (status == 'diferida' || status == 'reasignada') return 0.0;
if (isNearby) return 0.85;
if (isCompleted) return 1.0;
return 0.35;
}
/// Índice para el widget ProgressSteps (0 = inicio, 1 = en ruta, 2 = cerca,
/// 3 = atendiendo, 4 = completado). Ajusta los valores según tu enum real.
int get stepIndex {
if (status == 'diferida' || status == 'reasignada') return 0;
if (isCompleted) return 4;
if (isNearby) return 3;
if (status == 'en_ruta') return 2;
return 1;
}
String get etiquetaEstado {
if (isCompleted) return 'Finalizado';
if (status == 'en_ruta') return 'En ruta';
return 'Pendiente';
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Provider de ETA
// ─────────────────────────────────────────────────────────────────────────────
class _EtaNotifier extends AsyncNotifier<_EtaResult> {
Timer? _timer;
@override
Future<_EtaResult> build() {
// Consulta silenciosa cada 10 segundos para ver el avance en tiempo real
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 10), (_) => refresh());
ref.onDispose(() => _timer?.cancel());
return _fetch();
}
Future<void> refresh() async {
try {
final newData = await _fetch();
state = AsyncValue.data(newData);
} catch (e) {
// HACKATHON: Si hay un micro-corte (backend reiniciando), conservamos los datos previos
if (!state.hasValue) state = const AsyncValue.loading();
}
}
Future<_EtaResult> _fetch() async {
final dio = ref.read(apiClientProvider);
final addressesResp = await dio.get<dynamic>('/addresses');
final raw = addressesResp.data;
List<dynamic> items = const [];
if (raw is List) {
items = raw;
} else if (raw is Map && raw['data'] is List) {
items = raw['data'] as List;
} else if (raw is Map && raw['addresses'] is List) {
items = raw['addresses'] as List;
}
if (items.isEmpty) return const _EtaResult.noAddress();
final addressId = items.first['id'] as String;
final rawRoute = items.first['route_id'] ?? items.first['routeId'] ?? items.first['route'];
String? routeId = rawRoute?.toString();
// 🚨 HACKATHON FALLBACK: Si el backend no envía la ruta, la deducimos por la colonia
if (routeId == null || routeId.isEmpty) {
final col = items.first['colonia']?.toString().toLowerCase() ?? '';
if (col.contains('centro')) routeId = 'RUTA-01';
else if (col.contains('arboledas')) routeId = 'RUTA-03';
else if (col.contains('juanico')) routeId = 'RUTA-04';
else if (col.contains('olivos')) routeId = 'RUTA-05';
else if (col.contains('seco')) routeId = 'RUTA-12';
else if (col.contains('insurgentes')) routeId = 'RUTA-13';
}
Future.microtask(() {
ref.read(activeRouteIdProvider.notifier).set(routeId);
});
final etaResp = await dio.get<dynamic>(
'/eta',
queryParameters: {'address_id': addressId},
);
final data = etaResp.data as Map<String, dynamic>;
return _EtaResult(
mensaje: data['mensaje'] as String? ?? '',
status: data['status'] as String? ?? '',
direccion: items.first['calle'] as String? ?? '',
colonia: items.first['colonia'] as String? ?? '',
lat: (items.first['lat'] as num?)?.toDouble(),
lng: (items.first['lng'] as num?)?.toDouble(),
hasAddress: true,
);
}
}
final etaProvider = AsyncNotifierProvider<_EtaNotifier, _EtaResult>(
_EtaNotifier.new,
);
// Expone el routeId activo (se puebla desde el provider de sesión/domicilio)
class ActiveRouteIdNotifier extends Notifier<String?> {
@override
String? build() => null;
void set(String? value) {
debugPrint('📡 [FCM] Evaluando suscripción a la ruta: $value');
if (state != value) {
final oldRoute = state;
state = value;
if (oldRoute != null) NotificationService.unsubscribeFromRoute(oldRoute);
if (value != null) {
NotificationService.subscribeToRoute(
value,
).catchError((e) => debugPrint('❌ Error FCM: $e'));
}
}
}
}
final activeRouteIdProvider = NotifierProvider<ActiveRouteIdNotifier, String?>(
ActiveRouteIdNotifier.new,
);
// ─────────────────────────────────────────────────────────────────────────────
// Pantalla principal
// ─────────────────────────────────────────────────────────────────────────────
class CitizenHomeScreen extends ConsumerStatefulWidget {
const CitizenHomeScreen({super.key});
@override
ConsumerState<CitizenHomeScreen> createState() => _CitizenHomeScreenState();
}
class _CitizenHomeScreenState extends ConsumerState<CitizenHomeScreen>
with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// Refresca al recibir push FCM (RUTA_PROXIMITY, ROUTE_START, etc.)F
NotificationService.onFcmMessage.addListener(_onPush);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
NotificationService.onFcmMessage.removeListener(_onPush);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
ref.read(etaProvider.notifier).refresh();
}
}
void _onPush() => ref.read(etaProvider.notifier).refresh();
@override
Widget build(BuildContext context) {
final etaAsync = ref.watch(etaProvider);
return Scaffold(
backgroundColor: AppTheme.background,
appBar: AppBar(
title: const Text('Mi recolección'),
actions: [
IconButton(
icon: const Icon(Icons.refresh_rounded),
tooltip: 'Actualizar',
onPressed: () => ref.read(etaProvider.notifier).refresh(),
),
],
),
body: etaAsync.when(
loading: () => const _EtaLoading(),
error: (e, _) => const _EtaLoading(), // Si hay error, mostramos carga infinita hasta que el backend despierte
data: (result) => result.hasAddress
? _EtaContent(result: result)
: _NoAddressState(onAdd: () => context.go('/addresses/new')),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Contenido principal
// ─────────────────────────────────────────────────────────────────────────────
class _EtaContent extends StatelessWidget {
final _EtaResult result;
const _EtaContent({required this.result});
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: () => ProviderScope.containerOf(
context,
).read(etaProvider.notifier).refresh(),
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
children: [
// ── 1. Hero card ────────────────────────────────────────────────
_EtaHeroCard(result: result),
const SizedBox(height: 16),
// ── 2. Domicilio registrado ─────────────────────────────────────
AppInfoRow(
icon: Icons.home_outlined,
label: 'Col. ${result.colonia}',
value: result.direccion.isEmpty ? 'Mi domicilio' : result.direccion,
trailing: AppStatusBadge.green('Activo'),
),
const SizedBox(height: 12),
// ── 2.5. Mapa de ubicación ─────────────────────────────────
_MapaUbicacion(
colonia: result.colonia,
lat: result.lat,
lng: result.lng,
),
const SizedBox(height: 12),
// ── 3. Pasos de progreso (justo debajo del domicilio) ───────────
if (result.status != 'diferida') ...[
ProgressSteps(stepIndex: result.stepIndex),
const SizedBox(height: 12),
],
// ── 4. Banner de prevención ─────────────────────────────────────
const PreventionBanner(),
const SizedBox(height: 12),
// ── 5. Banner del Chat IA (Eco) ─────────────────────────────────
const _EcoChatBanner(),
const SizedBox(height: 12),
// ── 6. Badge de suscripción FCM ─────────────────────────────────
const _FcmStatusBadge(),
const SizedBox(height: 16),
// ── 7. Horario semanal ──────────────────────────────────────────
AppSectionTitle(title: 'Horario del camión'),
_HorarioCard(),
const SizedBox(height: 24),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Banner de Eco (Chat IA)
// ─────────────────────────────────────────────────────────────────────────────
class _EcoChatBanner extends StatelessWidget {
const _EcoChatBanner();
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AiPetChatScreen()),
);
},
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.primaryDark,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
boxShadow: AppTheme.softShadow,
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: const BoxDecoration(
color: Colors.white24,
shape: BoxShape.circle,
),
child: const Icon(
Icons.delete_outline,
color: Colors.white,
size: 28,
),
),
const SizedBox(width: 16),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'¿Dudas sobre reciclaje?',
style: TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 4),
Text(
'Pregúntale a Eco, tu asistente inteligente',
style: TextStyle(color: Colors.white70, fontSize: 13),
),
],
),
),
const Icon(Icons.chevron_right, color: Colors.white),
],
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Mapa de ubicación del domicilio (no interactivo)
// ─────────────────────────────────────────────────────────────────────────────
class _MapaUbicacion extends StatelessWidget {
final String colonia;
final double? lat;
final double? lng;
const _MapaUbicacion({required this.colonia, this.lat, this.lng});
@override
Widget build(BuildContext context) {
// Usar coordenadas del usuario si están disponibles, sino usar centro de colonia
final center = kColoniaCenter(colonia);
final pin = (lat != null && lng != null) ? LatLng(lat!, lng!) : center;
return Container(
height: 200,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: AppTheme.border, width: 1),
),
clipBehavior: Clip.hardEdge,
child: FlutterMap(
options: MapOptions(
initialCenter: pin,
initialZoom: 16.0,
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.none,
),
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.onlineshack.recolecta',
),
MarkerLayer(
markers: [
Marker(
point: pin,
width: 36,
height: 36,
child: const Icon(
Icons.home_rounded,
color: AppTheme.primary,
size: 36,
),
),
],
),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Hero card: estado + ventana horaria + barra de progreso
// ─────────────────────────────────────────────────────────────────────────────
class _EtaHeroCard extends StatelessWidget {
final _EtaResult result;
const _EtaHeroCard({required this.result});
Color _bgColor(BuildContext context) {
final cs = Theme.of(context).colorScheme;
if (result.status == 'diferida') return const Color(0xFFFFEBEE); // Alerta roja suave
if (result.isCompleted) return cs.surfaceContainerHighest;
if (result.isNearby) return const Color(0xFFFFF8E1); // amber-50
return const Color(0xFFE8D5DB); // rosa claro institucional
}
Color _accentColor(BuildContext context) {
if (result.status == 'diferida') return AppTheme.danger; // Rojo de alerta
if (result.isCompleted) return Theme.of(context).colorScheme.outline;
if (result.isNearby) return const Color(0xFFC8A36A); // beige dorado
return const Color(0xFF9B1B4A); // vino principal
}
@override
Widget build(BuildContext context) {
final accent = _accentColor(context);
final textTheme = Theme.of(context).textTheme;
return AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: _bgColor(context),
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: accent.withOpacity(0.3)),
boxShadow: AppTheme.softShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Cabecera: icono + etiqueta + punto vivo
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: accent,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.delete_outline_rounded,
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Camión recolector',
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: accent,
),
),
const SizedBox(height: 2),
_StatusPill(result: result, accent: accent),
],
),
),
_LiveDot(active: result.status == 'en_ruta'),
],
),
const SizedBox(height: 16),
// Ventana horaria o mensaje de estado
Text(
result.mensaje.isNotEmpty
? result.mensaje
: _windowLabel(result.status),
style: textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
color: accent,
height: 1.2,
),
),
const SizedBox(height: 16),
// Barra de progreso
if (result.status != 'diferida') ...[
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: result.progreso,
backgroundColor: accent.withOpacity(0.2),
valueColor: AlwaysStoppedAnimation<Color>(accent),
minHeight: 8,
),
),
const SizedBox(height: 6),
Row(
children: [
Text(
'Inicio de ruta',
style: TextStyle(fontSize: 10, color: accent.withOpacity(0.7)),
),
const Spacer(),
Text(
'Tu casa',
style: TextStyle(fontSize: 10, color: accent.withOpacity(0.7)),
),
],
),
] else ...[
Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(Icons.access_time_filled, size: 16, color: accent),
const SizedBox(width: 8),
Expanded(
child: Text(
'Servicio matutino suspendido. Se retomará en la tarde.',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: accent,
),
),
),
],
),
),
],
],
),
);
}
String _windowLabel(String s) {
switch (s) {
case 'completada':
return 'Servicio finalizado';
case 'diferida':
return 'Servicio diferido';
case 'reasignada':
return 'Ruta reasignada';
default:
return 'En camino';
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Pill de estado con punto pulsante
// ─────────────────────────────────────────────────────────────────────────────
class _StatusPill extends StatelessWidget {
final _EtaResult result;
final Color accent;
const _StatusPill({required this.result, required this.accent});
@override
Widget build(BuildContext context) {
final label = result.status == 'diferida'
? 'Servicio interrumpido'
: result.status == 'reasignada'
? 'Ruta reasignada'
: result.isNearby
? 'Cerca de tu domicilio'
: result.isCompleted
? 'Servicio completado'
: 'En camino a tu sector';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!result.isCompleted && result.status != 'diferida') _PulsingDot(color: accent),
if (!result.isCompleted && result.status != 'diferida') const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: accent.withOpacity(0.15),
borderRadius: BorderRadius.circular(100),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: accent,
),
),
),
],
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Punto pulsante (animación de opacidad)
// ─────────────────────────────────────────────────────────────────────────────
class _PulsingDot extends StatefulWidget {
final Color color;
const _PulsingDot({required this.color});
@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: 1200),
)..repeat(reverse: true);
_anim = Tween<double>(begin: 1.0, end: 0.3).animate(_ctrl);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _anim,
builder: (_, __) => Opacity(
opacity: _anim.value,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: widget.color,
shape: BoxShape.circle,
),
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Punto vivo "EN VIVO" (escala + opacidad)
// ─────────────────────────────────────────────────────────────────────────────
class _LiveDot extends StatefulWidget {
final bool active;
const _LiveDot({required this.active});
@override
State<_LiveDot> createState() => _LiveDotState();
}
class _LiveDotState extends State<_LiveDot>
with SingleTickerProviderStateMixin {
late final AnimationController _anim;
@override
void initState() {
super.initState();
_anim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat(reverse: true);
}
@override
void dispose() {
_anim.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!widget.active) return const SizedBox.shrink();
return AnimatedBuilder(
animation: _anim,
builder: (_, __) => Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppTheme.primary.withValues(alpha: 0.5 + _anim.value * 0.5),
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Badge de suscripción FCM
// ─────────────────────────────────────────────────────────────────────────────
class _FcmStatusBadge extends ConsumerWidget {
const _FcmStatusBadge();
@override
Widget build(BuildContext context, WidgetRef ref) {
final routeId = ref.watch(activeRouteIdProvider);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFF1E7A46),
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
Expanded(
child: Text.rich(
TextSpan(
children: [
const TextSpan(
text: 'Notificaciones activas ',
style: TextStyle(fontWeight: FontWeight.w500),
),
TextSpan(
text: routeId != null
? 'para topic_$routeId'
: '— suscribiendo...',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
style: const TextStyle(fontSize: 12),
),
),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Horario semanal
// ─────────────────────────────────────────────────────────────────────────────
class _HorarioCard extends StatelessWidget {
static const _dias = [
_HorarioDia(dia: 'Lunes', hora: '8:00 10:00 a.m.', activo: true),
_HorarioDia(dia: 'Martes', hora: '8:00 10:00 a.m.', activo: true),
_HorarioDia(dia: 'Miércoles', hora: 'Sin servicio', activo: false),
_HorarioDia(dia: 'Jueves', hora: '8:00 10:00 a.m.', activo: true),
_HorarioDia(dia: 'Viernes', hora: '8:00 10:00 a.m.', activo: true),
_HorarioDia(dia: 'Sábado', hora: '9:00 11:00 a.m.', activo: true),
_HorarioDia(dia: 'Domingo', hora: 'Sin servicio', activo: false),
];
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: AppTheme.border, width: 0.5),
boxShadow: AppTheme.softShadow,
),
child: Column(
children: _dias.map((d) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Row(
children: [
Text(
d.dia,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: d.activo
? AppTheme.textPrimary
: AppTheme.textSecondary,
),
),
const Spacer(),
Text(
d.hora,
style: TextStyle(
fontSize: 13,
color: d.activo ? AppTheme.primary : AppTheme.textSecondary,
),
),
],
),
);
}).toList(),
),
);
}
}
class _HorarioDia {
final String dia;
final String hora;
final bool activo;
const _HorarioDia({
required this.dia,
required this.hora,
required this.activo,
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Sin domicilio registrado
// ─────────────────────────────────────────────────────────────────────────────
class _NoAddressState extends StatelessWidget {
final VoidCallback onAdd;
const _NoAddressState({required this.onAdd});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: const BoxDecoration(
color: AppTheme.primaryLight,
shape: BoxShape.circle,
),
child: const Icon(
Icons.home_outlined,
color: AppTheme.primary,
size: 40,
),
),
const SizedBox(height: 20),
const Text(
'Sin domicilio registrado',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
const Text(
'Registra tu domicilio para\nrecibir el ETA de tu ruta.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
height: 1.5,
),
),
const SizedBox(height: 24),
SizedBox(
width: 200,
child: ElevatedButton(
onPressed: onAdd,
child: const Text('Agregar domicilio'),
),
),
],
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Cargando
// ─────────────────────────────────────────────────────────────────────────────
class _EtaLoading extends StatelessWidget {
const _EtaLoading();
@override
Widget build(BuildContext context) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: AppTheme.primary),
SizedBox(height: 16),
Text(
'Consultando estado del servicio...',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 14),
),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Error
// ─────────────────────────────────────────────────────────────────────────────
class _EtaError extends StatelessWidget {
final String error;
final VoidCallback onRetry;
const _EtaError({required this.error, required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.wifi_off_rounded,
color: AppTheme.textSecondary,
size: 48,
),
const SizedBox(height: 16),
const Text(
'No se pudo obtener el estado',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
Text(
error,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 20),
SizedBox(
width: 160,
child: FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Reintentar'),
),
),
],
),
),
);
}
}