926 lines
33 KiB
Dart
926 lines
33 KiB
Dart
// lib/features/eta/eta_screen.dart
|
||
// Vista principal del ciudadano: ETA con mapa de domicilio y progreso de ruta.
|
||
// Fusiona eta_screen.dart (doc-1) + eta_screen_v2.dart (doc-2).
|
||
// Orden visual:
|
||
// 1. Hero card (estado + ventana horaria)
|
||
// 2. Domicilio registrado
|
||
// 3. ProgressSteps ← nuevo: justo debajo del mapa/dirección
|
||
// 4. PreventionBanner
|
||
// 5. FCM badge
|
||
// 6. Horario semanal
|
||
|
||
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 '../home/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 (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 (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> {
|
||
@override
|
||
Future<_EtaResult> build() => _fetch();
|
||
|
||
Future<void> refresh() async {
|
||
state = const AsyncValue.loading();
|
||
state = await AsyncValue.guard(_fetch);
|
||
}
|
||
|
||
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 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;
|
||
}
|
||
|
||
final activeRouteIdProvider = NotifierProvider<ActiveRouteIdNotifier, String?>(
|
||
ActiveRouteIdNotifier.new,
|
||
);
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Pantalla principal
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
class EtaScreen extends ConsumerStatefulWidget {
|
||
const EtaScreen({super.key});
|
||
|
||
@override
|
||
ConsumerState<EtaScreen> createState() => _EtaScreenState();
|
||
}
|
||
|
||
class _EtaScreenState extends ConsumerState<EtaScreen>
|
||
with WidgetsBindingObserver {
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addObserver(this);
|
||
// Refresca al recibir push FCM (RUTA_PROXIMITY, ROUTE_START, etc.)
|
||
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, _) => _EtaError(
|
||
error: e.toString(),
|
||
onRetry: () => ref.read(etaProvider.notifier).refresh(),
|
||
),
|
||
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) ───────────
|
||
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.pets, 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.isCompleted) return cs.surfaceContainerHighest;
|
||
if (result.isNearby) return const Color(0xFFFFF8E1); // amber-50
|
||
return const Color(0xFFE1F5EE); // teal-50
|
||
}
|
||
|
||
Color _accentColor(BuildContext context) {
|
||
if (result.isCompleted) return Theme.of(context).colorScheme.outline;
|
||
if (result.isNearby) return const Color(0xFFBA7517); // amber-400
|
||
return const Color(0xFF1D9E75); // teal-400
|
||
}
|
||
|
||
@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
|
||
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)),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
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.isNearby
|
||
? 'Cerca de tu domicilio'
|
||
: result.isCompleted
|
||
? 'Servicio completado'
|
||
: 'En camino a tu sector';
|
||
|
||
return Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (!result.isCompleted) _PulsingDot(color: accent),
|
||
if (!result.isCompleted) 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(0xFF1D9E75),
|
||
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'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|