import 'dart:async'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../core/api_client.dart'; import '../core/app_theme.dart'; import '../modelos/delivery_order.dart'; import '../servicios/delivery_repository.dart'; import '../servicios/location_service.dart'; import 'order_detail_screen.dart'; import 'route_screen.dart'; import 'login_screen.dart'; import '../servicios/push_notification_service.dart'; class OrdersScreen extends StatefulWidget { const OrdersScreen({required this.repository, this.initialOrder, super.key}); final DeliveryRepository repository; final DeliveryOrder? initialOrder; @override State createState() => _OrdersScreenState(); } class _OrdersScreenState extends State { late Future> _orders; List? _cachedOrders; bool _logoutBusy = false; bool _refreshing = false; bool _deliveryUnavailable = false; int _pendingOffline = 0; String? _refreshError; Timer? _pollTimer; StreamSubscription? _notificationSubscription; StreamSubscription? _openedNotificationSubscription; StreamSubscription? _openedOrderSubscription; @override void initState() { super.initState(); _orders = widget.repository.assignedOrders(); _orders .then((orders) { _cachedOrders = orders; if (mounted) setState(() {}); }) .catchError((_) {}); unawaited(_startWorkdayTracking()); unawaited(_updatePendingOffline()); unawaited(_registerPushDevice()); _pollTimer = Timer.periodic( const Duration(seconds: 10), (_) => _refresh(silent: true), ); _notificationSubscription = PushNotificationService.events.listen(( message, ) { if (!mounted) return; unawaited(PushNotificationService.showForegroundNotification(message)); _refresh(silent: true); final title = message.notification?.title ?? 'Nuevo pedido'; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(title))); }); _openedNotificationSubscription = PushNotificationService.openedEvents .listen(_openNotificationOrder); _openedOrderSubscription = PushNotificationService.openedOrderEvents.listen( (orderId) async { if (!mounted) return; final message = RemoteMessage(data: {'order_id': orderId}); await _openNotificationOrder(message); }, ); for (final orderId in PushNotificationService.takePendingOpenedOrderIds()) { final message = RemoteMessage(data: {'order_id': orderId}); unawaited(_openNotificationOrder(message)); } final initialMessage = PushNotificationService.takeInitialMessage(); if (initialMessage != null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) unawaited(_openNotificationOrder(initialMessage)); }); } } Future _openNotificationOrder(RemoteMessage message) async { if (_deliveryUnavailable) return; final orderId = message.data['order_id']?.toString(); if (orderId == null || orderId.isEmpty || !mounted) return; unawaited( widget.repository.notificationOpened( orderId: orderId, messageId: message.messageId, ), ); await _refresh(silent: true); if (_deliveryUnavailable) return; if (!mounted) return; final orders = await _orders; final matches = orders.where((order) => order.id == orderId); if (matches.isNotEmpty) await _openOrder(matches.first); } Future _registerPushDevice() async { try { final push = PushNotificationService(); await push.requestPermission(); await push.registerWithBackend(widget.repository); } catch (error) { debugPrint('FCM backend registration failed: $error'); } } Future _resumeActiveRouteTracking() async { try { final prefs = await SharedPreferences.getInstance(); if (prefs.getBool('delivery_workday_tracking_enabled') != true) { await _resumeLegacyRouteTracking(); return; } Map? workday; try { workday = await widget.repository.currentWorkdayTracking(); } catch (error) { if (error is ApiException && error.statusCode != 503) rethrow; } if (workday?['status'] == 'active' && workday?['id'] != null) { if (await LocationService().hasUsablePermission()) { await LocationService().startNativeWorkdayTracking( '${workday!['id']}', ); } return; } final route = await widget.repository.currentRouteData(); final routeId = route?['id']?.toString(); if (route?['status'] == 'active' && routeId != null && routeId.isNotEmpty) { if (await LocationService().hasUsablePermission()) { await LocationService().startNativeTracking(routeId); } return; } // Si el backend confirma que no hay ruta activa, elimina cualquier // estado nativo antiguo para evitar reanudar una ruta ya terminada. await LocationService().stopNativeTracking(); } catch (error) { if (error is ApiException && error.statusCode == 403) { await _markDeliveryUnavailable(error.message); } // Una caída temporal de red no debe detener un rastreo que ya estaba // activo; el servicio nativo conserva la ruta y reintenta el envío. } } Future _resumeLegacyRouteTracking() async { final route = await widget.repository.currentRouteData(); final routeId = route?['id']?.toString(); if (route?['status'] == 'active' && routeId != null && routeId.isNotEmpty) { if (await LocationService().hasUsablePermission()) { await LocationService().startNativeTracking(routeId); } return; } await LocationService().stopNativeTracking(); } Future _startWorkdayTracking() async { try { final session = await widget.repository.startWorkdayTracking(); if (await LocationService().requestPermission()) { await LocationService().startNativeWorkdayTracking('${session['id']}'); } } catch (error) { if (error is ApiException && error.statusCode == 403) { await _markDeliveryUnavailable(error.message); } if (error is ApiException && error.statusCode == 503) { await _resumeActiveRouteTracking(); } // A 503 means the tenant has not received the new migration yet. The // existing route tracking remains available until the release is aligned. } } Future _markDeliveryUnavailable(String message) async { await LocationService().stopNativeTracking(); // A tenant-level 403 means the water-distribution feature is disabled. // Never replay delivery mutations after that switch is turned off. await widget.repository.clearPendingOffline(); if (!mounted) return; setState(() { _deliveryUnavailable = true; _pendingOffline = 0; _cachedOrders = const []; _refreshError = message.isNotEmpty ? message : 'El reparto de agua no esta habilitado para este tenant.'; }); } Future _refresh({bool silent = false}) async { if (_refreshing || !mounted) return; _refreshing = true; final request = widget.repository.assignedOrders(); setState(() { _orders = request; }); try { _cachedOrders = await request; _refreshError = null; _deliveryUnavailable = false; await _updatePendingOffline(); } catch (error) { if (error is ApiException && error.statusCode == 403) { await _markDeliveryUnavailable(error.message); } if (_cachedOrders != null) { _refreshError = error is ApiException ? error.message : 'No se pudo actualizar la lista.'; } } finally { if (mounted) setState(() => _refreshing = false); } } Future _updatePendingOffline() async { final count = await widget.repository.pendingOfflineCount(); if (mounted && count != _pendingOffline) { setState(() => _pendingOffline = count); } } @override void dispose() { _pollTimer?.cancel(); _notificationSubscription?.cancel(); _openedNotificationSubscription?.cancel(); _openedOrderSubscription?.cancel(); super.dispose(); } Future _openOrder(DeliveryOrder order) async { if (_deliveryUnavailable) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('El reparto de agua no esta habilitado.')), ); return; } final changed = await Navigator.push( context, MaterialPageRoute( builder: (_) => OrderDetailScreen(order: order, repository: widget.repository), ), ); if (changed == true && mounted) await _refresh(); } Future _confirmExit() async { final exit = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Salir de SmartPlus'), content: const Text( '¿Deseas cerrar la aplicación? La sesión se mantendrá iniciada.', ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Cancelar'), ), FilledButton( onPressed: () => Navigator.pop(context, true), child: const Text('Salir'), ), ], ), ); return exit ?? false; } Future _logout() async { if (_logoutBusy) return; setState(() => _logoutBusy = true); try { if (await widget.repository.hasActiveRoute()) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'No puedes cerrar sesión mientras tengas una ruta activa. ' 'Entrega, reprograma o cancela todos sus pedidos; la ruta ' 'se cerrará automáticamente.', ), ), ); } return; } } catch (_) { if (mounted) { await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('No se pudo validar la ruta'), content: const Text( 'Por seguridad, la sesión no se cerró. Revisa tu conexión e inténtalo nuevamente.', ), actions: [ FilledButton( onPressed: () => Navigator.pop(context), child: const Text('Entendido'), ), ], ), ); } return; } finally { if (mounted) setState(() => _logoutBusy = false); } if (!mounted) return; final confirm = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Cerrar sesión'), content: const Text( '¿Deseas cerrar la sesión de este dispositivo?', ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Cancelar'), ), FilledButton( onPressed: () => Navigator.pop(context, true), child: const Text('Cerrar sesión'), ), ], ), ) ?? false; if (!confirm || !mounted) return; try { await widget.repository.stopWorkdayTracking(); } catch (_) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('No se pudo cerrar la jornada. Revisa tu conexión.'), ), ); } return; } await LocationService().stopNativeTracking(); final prefs = await SharedPreferences.getInstance(); final deviceUuid = prefs.getString('delivery_device_uuid'); if (deviceUuid != null && deviceUuid.isNotEmpty) { try { await widget.repository.revokeDevice(deviceUuid: deviceUuid); } catch (_) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'No se pudo revocar el dispositivo. Revisa tu conexión.', ), ), ); } return; } } await prefs.remove('delivery_api_token'); await prefs.remove('delivery_workday_tracking_enabled'); await widget.repository.clearPendingOffline(); await PushNotificationService().clearSession(); if (!mounted) return; Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute( builder: (_) => LoginScreen(repository: widget.repository), ), (_) => false, ); } @override Widget build(BuildContext context) => PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) { if (!didPop) { _confirmExit().then((exit) { if (exit && mounted) SystemNavigator.pop(); }); } }, child: Scaffold( appBar: AppBar( title: Text( _pendingOffline > 0 ? 'Mis pedidos · $_pendingOffline pendiente(s)' : 'Mis pedidos', ), actions: [ IconButton( tooltip: 'Ruta activa', onPressed: _deliveryUnavailable ? null : () => Navigator.push( context, MaterialPageRoute( builder: (_) => RouteScreen(repository: widget.repository), ), ), icon: const Icon(Icons.route_outlined), ), PopupMenuButton( enabled: !_logoutBusy, onSelected: (value) { if (value == 'logout') _logout(); }, itemBuilder: (_) => const [ PopupMenuItem(value: 'logout', child: Text('Cerrar sesión')), ], ), ], bottom: (_refreshing || _refreshError != null) ? PreferredSize( preferredSize: const Size.fromHeight(34), child: AnimatedSwitcher( duration: const Duration(milliseconds: 180), child: _refreshing ? const LinearProgressIndicator(minHeight: 3) : Material( color: Theme.of(context).colorScheme.errorContainer, child: InkWell( onTap: _refresh, child: SizedBox( height: 34, child: Row( children: [ const SizedBox(width: 16), Icon( Icons.sync_problem_outlined, size: 18, color: Theme.of( context, ).colorScheme.onErrorContainer, ), const SizedBox(width: 8), Expanded( child: Text( _deliveryUnavailable ? (_refreshError ?? 'El reparto de agua no esta habilitado.') : 'No se pudo actualizar. Toca para reintentar.', style: TextStyle( color: Theme.of( context, ).colorScheme.onErrorContainer, fontSize: 12, fontWeight: FontWeight.w600, ), ), ), ], ), ), ), ), ), ) : null, ), body: RefreshIndicator( onRefresh: _refresh, child: FutureBuilder>( future: _orders, builder: (context, snapshot) { Widget content; final orders = _cachedOrders ?? snapshot.data; if (snapshot.connectionState == ConnectionState.waiting && orders == null) { content = const _OrdersLoadingState(); } else if (snapshot.hasError && orders == null) { content = _ErrorState( message: snapshot.error is ApiException ? (snapshot.error as ApiException).message : 'No se pudieron cargar los pedidos', onRetry: _refresh, ); } else { final visibleOrders = orders ?? const []; content = visibleOrders.isEmpty ? const _EmptyState() : ListView.separated( padding: const EdgeInsets.fromLTRB(16, 16, 16, 28), itemCount: visibleOrders.length, separatorBuilder: (context, index) => const SizedBox(height: 12), itemBuilder: (_, index) => TweenAnimationBuilder( duration: Duration(milliseconds: 180 + (index * 35)), curve: Curves.easeOutCubic, tween: Tween(begin: 0, end: 1), builder: (context, value, child) => Opacity( opacity: value, child: Transform.translate( offset: Offset(0, 10 * (1 - value)), child: child, ), ), child: _OrderCard( order: visibleOrders[index], onOpen: () => _openOrder(visibleOrders[index]), ), ), ); } return AnimatedSwitcher( duration: const Duration(milliseconds: 220), switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeIn, child: KeyedSubtree( key: ValueKey( _cachedOrders == null && snapshot.connectionState == ConnectionState.waiting ? 'loading' : snapshot.hasError && _cachedOrders == null ? 'error' : 'content', ), child: content, ), ); }, ), ), ), ); } class _OrderCard extends StatelessWidget { const _OrderCard({required this.order, required this.onOpen}); final DeliveryOrder order; final VoidCallback onOpen; @override Widget build(BuildContext context) { final visual = _StatusVisual.forStatus(order.status); return AnimatedContainer( duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, decoration: BoxDecoration( color: visual.surface, borderRadius: BorderRadius.circular(16), border: Border.all(color: visual.border), boxShadow: [ BoxShadow( color: visual.accent.withValues(alpha: .08), blurRadius: 14, offset: const Offset(0, 5), ), ], ), child: Semantics( button: true, label: '${order.number}, ${order.customer}, ${order.status.label}', hint: 'Abrir detalle del pedido', child: ClipRRect( borderRadius: BorderRadius.circular(16), child: Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(16), onTap: onOpen, child: IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ AnimatedContainer( duration: const Duration(milliseconds: 220), width: 6, color: visual.accent, ), Expanded( child: Padding( padding: const EdgeInsets.fromLTRB(14, 14, 16, 15), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( visual.icon, size: 19, color: visual.accent, ), const SizedBox(width: 7), Expanded( child: Text( order.number, style: const TextStyle( fontWeight: FontWeight.w800, color: AppTheme.ink, ), ), ), _StatusChip(status: order.status), ], ), const SizedBox(height: 12), Text( order.customer, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 6), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( Icons.location_on_outlined, size: 20, color: visual.accent, ), const SizedBox(width: 8), Expanded(child: Text(order.address)), ], ), if (order.items.isNotEmpty) ...[ const SizedBox(height: 8), Text( '${order.items.length} producto(s): ${order.items.take(2).map((item) => '${_formatOrderQuantity(item.quantity)}x ${item.name}').join(', ')}${order.items.length > 2 ? '…' : ''}', maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(color: AppTheme.muted), ), ], const SizedBox(height: 14), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Total S/ ${order.total.toStringAsFixed(2)}', style: const TextStyle( fontWeight: FontWeight.w800, ), ), Icon( Icons.arrow_forward_rounded, size: 20, color: visual.accent, ), ], ), ], ), ), ), ], ), ), ), ), ), ), ); } } class _StatusVisual { const _StatusVisual({ required this.accent, required this.surface, required this.border, required this.icon, }); final Color accent; final Color surface; final Color border; final IconData icon; static _StatusVisual forStatus(OrderStatus status) => switch (status) { OrderStatus.pending => const _StatusVisual( accent: Color(0xFFB7791F), surface: Color(0xFFFFFBF2), border: Color(0xFFF2D49B), icon: Icons.schedule_outlined, ), OrderStatus.assigned => const _StatusVisual( accent: Color(0xFF6B46C1), surface: Color(0xFFFAF7FF), border: Color(0xFFD9C8F4), icon: Icons.assignment_ind_outlined, ), OrderStatus.accepted => const _StatusVisual( accent: Color(0xFF0F766E), surface: Color(0xFFF2FCFA), border: Color(0xFFA7E3DA), icon: Icons.check_circle_outline, ), OrderStatus.enRoute => const _StatusVisual( accent: Color(0xFF0B5F92), surface: Color(0xFFF2F9FE), border: Color(0xFFB8DDF3), icon: Icons.navigation_outlined, ), OrderStatus.delivered => const _StatusVisual( accent: Color(0xFF176B42), surface: Color(0xFFF2FBF6), border: Color(0xFFA9DFC0), icon: Icons.done_all_rounded, ), OrderStatus.notDelivered => const _StatusVisual( accent: Color(0xFFB42318), surface: Color(0xFFFFF7F6), border: Color(0xFFF3B7B1), icon: Icons.report_problem_outlined, ), OrderStatus.rescheduled => const _StatusVisual( accent: Color(0xFFC05621), surface: Color(0xFFFFF8F2), border: Color(0xFFF2C3A3), icon: Icons.event_repeat_outlined, ), OrderStatus.cancelled => const _StatusVisual( accent: Color(0xFF475569), surface: Color(0xFFF7F9FB), border: Color(0xFFC9D2DC), icon: Icons.cancel_outlined, ), OrderStatus.rejected => const _StatusVisual( accent: Color(0xFF9B2C25), surface: Color(0xFFFFF7F7), border: Color(0xFFE9B6B3), icon: Icons.block_outlined, ), }; } class _StatusChip extends StatelessWidget { const _StatusChip({required this.status}); final OrderStatus status; @override Widget build(BuildContext context) { final visual = _StatusVisual.forStatus(status); return Semantics( label: 'Estado: ${status.label}', child: Container( constraints: const BoxConstraints(minHeight: 32), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: visual.accent.withValues(alpha: .12), border: Border.all(color: visual.accent.withValues(alpha: .22)), borderRadius: BorderRadius.circular(999), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(visual.icon, size: 15, color: visual.accent), const SizedBox(width: 5), Text( status.label, style: TextStyle( color: visual.accent, fontSize: 12, fontWeight: FontWeight.w700, ), ), ], ), ), ); } } String _formatOrderQuantity(double value) => value == value.roundToDouble() ? value.toInt().toString() : value.toString(); class _EmptyState extends StatelessWidget { const _EmptyState(); @override Widget build(BuildContext context) => ListView( padding: const EdgeInsets.all(24), children: [ const SizedBox(height: 120), Icon(Icons.inbox_outlined, size: 56, color: AppTheme.muted), const SizedBox(height: 16), Text( 'No tienes pedidos asignados', textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, color: AppTheme.ink, ), ), const SizedBox(height: 8), const Text( 'Cuando te asignen un pedido aparecerá aquí.', textAlign: TextAlign.center, style: TextStyle(color: AppTheme.muted), ), ], ); } class _OrdersLoadingState extends StatelessWidget { const _OrdersLoadingState(); @override Widget build(BuildContext context) => ListView.separated( padding: const EdgeInsets.all(16), itemCount: 3, separatorBuilder: (_, _) => const SizedBox(height: 12), itemBuilder: (_, index) => TweenAnimationBuilder( duration: Duration(milliseconds: 300 + (index * 80)), tween: Tween(begin: .45, end: 1), curve: Curves.easeInOut, builder: (context, opacity, child) => Opacity(opacity: opacity, child: const _OrderSkeleton()), ), ); } class _OrderSkeleton extends StatelessWidget { const _OrderSkeleton(); @override Widget build(BuildContext context) => Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Expanded(child: _SkeletonLine(width: 120, height: 16)), Container( width: 78, height: 28, decoration: BoxDecoration( color: AppTheme.border.withValues(alpha: .6), borderRadius: BorderRadius.circular(999), ), ), ], ), const SizedBox(height: 16), const _SkeletonLine(width: 190, height: 16), const SizedBox(height: 10), const _SkeletonLine(width: double.infinity, height: 14), const SizedBox(height: 8), const _SkeletonLine(width: 230, height: 14), const SizedBox(height: 18), const _SkeletonLine(width: 110, height: 16), ], ), ), ); } class _SkeletonLine extends StatelessWidget { const _SkeletonLine({required this.width, required this.height}); final double width; final double height; @override Widget build(BuildContext context) => Container( width: width, height: height, decoration: BoxDecoration( color: AppTheme.border.withValues(alpha: .6), borderRadius: BorderRadius.circular(6), ), ); } class _ErrorState extends StatelessWidget { const _ErrorState({required this.message, required this.onRetry}); final String message; final VoidCallback onRetry; @override Widget build(BuildContext context) => ListView( padding: const EdgeInsets.all(24), children: [ const SizedBox(height: 80), const Icon(Icons.wifi_off_rounded, size: 52), const SizedBox(height: 16), Text(message, textAlign: TextAlign.center), const SizedBox(height: 20), OutlinedButton(onPressed: onRetry, child: const Text('Reintentar')), ], ); }