import 'dart:async'; import 'package:flutter/material.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; // These helpers are kept in an extension to keep the route screen readable; // they intentionally update the owning State after awaited API calls. // ignore_for_file: invalid_use_of_protected_member import '../core/app_theme.dart'; import '../core/api_client.dart'; import '../core/navigation_guard.dart'; import '../servicios/delivery_repository.dart'; import '../servicios/location_service.dart'; class RouteScreen extends StatefulWidget { const RouteScreen({required this.repository, super.key}); final DeliveryRepository repository; @override State createState() => _RouteScreenState(); } class _RouteScreenState extends State { final _location = LocationService(); bool _active = false; bool _loading = true; bool _busy = false; bool _trackingStale = false; bool _routeCompleted = false; String? _cashSettlementStatus; bool _containersSettled = false; String? _routeId; Timer? _healthTimer; double _cashCollected = 0; double _yapeCollected = 0; double _transferCollected = 0; double _creditCollected = 0; int _notCollected = 0; List> _containerTypes = const []; List> _containerLoads = const []; @override void initState() { super.initState(); _loadRoute(); _healthTimer = Timer.periodic( const Duration(seconds: 30), (_) => _checkTrackingHealth(), ); } Future _loadRoute() async { try { final route = await widget.repository.currentRouteData(); final routeId = route?['id']?.toString(); final active = route?['status'] == 'active'; final summary = route?['collection_summary']; final lastLocation = DateTime.tryParse( '${route?['last_location_at'] ?? ''}', ); final stale = active && (lastLocation == null || DateTime.now() .toUtc() .difference(lastLocation.toUtc()) .inMinutes >= 2); if (mounted) { setState(() { _routeId = routeId; _active = active; _routeCompleted = route?['status'] == 'completed'; final cashSettlement = route?['cash_settlement']; _cashSettlementStatus = cashSettlement is Map ? cashSettlement['status']?.toString() : null; final containerSettlement = route?['container_settlement']; _containersSettled = containerSettlement is Map && containerSettlement['status']?.toString() == 'settled'; _cashCollected = double.tryParse('${summary?['cash'] ?? 0}') ?? 0; _yapeCollected = double.tryParse('${summary?['yape'] ?? 0}') ?? 0; _transferCollected = double.tryParse('${summary?['transfer'] ?? 0}') ?? 0; _creditCollected = double.tryParse('${summary?['credit'] ?? 0}') ?? 0; _notCollected = int.tryParse('${summary?['not_collected'] ?? 0}') ?? 0; _containerTypes = (route?['container_types'] as List? ?? const []) .whereType() .map((item) => item.cast()) .toList(); _containerLoads = (route?['container_loads'] as List? ?? const []) .whereType() .map((item) => item.cast()) .toList(); _trackingStale = stale; }); } if (active && routeId != null && await _location.hasUsablePermission()) { await _location.startNativeTracking(routeId); } else if (!active) { // Clean up a foreground geolocator service left by an older app // session or by a fallback stream. A stopped route must not leave // Android's ongoing location notification visible. await _location.stopNativeTracking(); } } catch (error) { if (error is ApiException && error.statusCode == 403) { await _location.stopNativeTracking(); if (mounted) { setState(() { _active = false; _routeId = null; _trackingStale = false; }); } } // La pantalla sigue siendo útil aunque todavía no haya ruta asignada. } finally { if (mounted) setState(() => _loading = false); } } Future _checkTrackingHealth() async { if (!_active || _routeId == null || !mounted) return; try { final route = await widget.repository.currentRouteData(); if (route?['status'] != 'active') { await _location.stopNativeTracking(); if (mounted) setState(() => _active = false); return; } final backendLast = DateTime.tryParse( '${route?['last_location_at'] ?? ''}', ); final backendStale = backendLast == null || DateTime.now().toUtc().difference(backendLast.toUtc()).inMinutes >= 2; final nativeHealthy = await _location.nativeTrackingHealthy(_routeId!); if (!nativeHealthy && await _location.hasUsablePermission()) { await _location.startNativeTracking(_routeId!); } if (mounted) { setState(() => _trackingStale = backendStale || !nativeHealthy); } } catch (error) { if (error is ApiException && error.statusCode == 403) { await _location.stopNativeTracking(); if (mounted) { setState(() { _active = false; _routeId = null; _trackingStale = false; }); } return; } // La red puede estar temporalmente caída; el servicio conserva los // puntos localmente para reintentarlos cuando vuelva la conexión. } } // La ruta se inicia automáticamente al aceptar el primer pedido. // No existe una acción manual de inicio en la aplicación. Future _handleBack() async { if (!_active) return; final leave = await confirmNavigation( context, title: 'Ruta activa', message: 'La ubicación seguirá activa aunque salgas de esta pantalla. ¿Deseas salir?', confirmLabel: 'Salir de la pantalla', ); if (leave && mounted) Navigator.pop(context); } @override void dispose() { _healthTimer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) => PopScope( canPop: !_active, onPopInvokedWithResult: (didPop, result) { if (!didPop) _handleBack(); }, child: Scaffold( appBar: AppBar(title: const Text('Ruta de reparto')), body: ListView( padding: const EdgeInsets.all(16), children: [ Card( elevation: 0, child: Padding( padding: const EdgeInsets.all(20), child: Column( children: [ AnimatedSwitcher( duration: const Duration(milliseconds: 240), transitionBuilder: (child, animation) => ScaleTransition( scale: CurvedAnimation( parent: animation, curve: Curves.easeOutBack, ), child: FadeTransition(opacity: animation, child: child), ), child: Icon( _active ? Icons.location_searching : Icons.route_outlined, key: ValueKey(_active), size: 56, color: _trackingStale ? Colors.orange.shade800 : AppTheme.primary, ), ), const SizedBox(height: 16), AnimatedSwitcher( duration: const Duration(milliseconds: 220), child: Text( _loading ? 'Cargando ruta...' : (_routeCompleted ? 'Ruta completada' : (_active ? 'Ubicación activa' : 'Ruta detenida')), key: ValueKey('$_loading-$_active-$_routeCompleted'), style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w800, ), ), ), const SizedBox(height: 8), Text( _routeCompleted ? 'La ruta ya fue cerrada. Revisa la liquidación de efectivo y los bidones.' : (_active ? 'SmartPlus comparte tu ubicación mientras realizas esta ruta.' : 'Acepta un pedido asignado para iniciar la ruta automáticamente.'), textAlign: TextAlign.center, ), if (_active) Text( 'La ubicación la verá el vendedor durante la ruta y se detendrá al finalizarla.', textAlign: TextAlign.center, style: const TextStyle( fontSize: 12, color: AppTheme.muted, ), ), if (_active && _trackingStale) ...[ const SizedBox(height: 12), Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.orange.withValues(alpha: .12), borderRadius: BorderRadius.circular(10), ), child: const Text( 'La última ubicación tiene más de 2 minutos. Verifica que el GPS esté encendido y mantén activa la ruta.', textAlign: TextAlign.center, ), ), ], if (_cashCollected > 0 || _yapeCollected > 0 || _transferCollected > 0 || _creditCollected > 0 || _notCollected > 0) ...[ const SizedBox(height: 20), _CollectionSummary( cash: _cashCollected, yape: _yapeCollected, transfer: _transferCollected, credit: _creditCollected, notCollected: _notCollected, ), ], const SizedBox(height: 24), if (_containerTypes.isNotEmpty) ...[ OutlinedButton.icon( onPressed: _loading || _busy ? null : _showLoadDialog, icon: const Icon(Icons.inventory_2_outlined), label: const Text('Registrar carga de bidones'), ), if (_containerLoads.isNotEmpty) ...[ const SizedBox(height: 12), _ContainerLoadSummary(loads: _containerLoads), ], if (!_active && _routeId != null && _containerLoads.isNotEmpty && !_containersSettled) OutlinedButton.icon( onPressed: _busy ? null : _settleContainers, icon: const Icon(Icons.fact_check_outlined), label: const Text('Liquidar bidones de la ruta'), ) else if (!_active && _containerLoads.isNotEmpty) const Text('Bidones: liquidación enviada.'), const SizedBox(height: 12), ], if (_routeCompleted && _routeId != null && ![ 'submitted', 'approved', ].contains(_cashSettlementStatus)) ...[ OutlinedButton.icon( onPressed: _busy ? null : _showCashSettlementDialog, icon: const Icon(Icons.payments_outlined), label: const Text('Liquidar efectivo de la ruta'), ), const SizedBox(height: 12), ] else if (_routeCompleted) const Text('Efectivo: liquidación enviada a Caja.'), if (!_active && !_routeCompleted) const Text( 'La ruta se inicia automáticamente al aceptar el primer pedido.', textAlign: TextAlign.center, ), ], ), ), ), ], ), ), ); } extension on _RouteScreenState { Future _showLoadDialog() async { if (_routeId == null || _containerTypes.isEmpty) return; var selected = _containerTypes.first; final full = TextEditingController(text: '0'); final empty = TextEditingController(text: '0'); final physicalIds = []; final result = await showDialog( context: context, builder: (dialogContext) => StatefulBuilder( builder: (context, setDialogState) => AlertDialog( title: const Text('Carga de bidones'), content: Column( mainAxisSize: MainAxisSize.min, children: [ DropdownButtonFormField>( initialValue: selected, decoration: const InputDecoration(labelText: 'Tipo de bidón'), items: _containerTypes .map( (type) => DropdownMenuItem( value: type, child: Text('${type['name']}'), ), ) .toList(), onChanged: (value) => setDialogState(() => selected = value ?? selected), ), TextField( controller: full, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Bidones llenos'), ), TextField( controller: empty, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Bidones vacíos'), ), const SizedBox(height: 8), OutlinedButton.icon( onPressed: () async { final id = await _scanRouteContainerQr(); if (id == null || !dialogContext.mounted) return; if (physicalIds.contains(id)) { ScaffoldMessenger.of(dialogContext).showSnackBar( const SnackBar( content: Text('Ese bidón ya fue escaneado.'), ), ); return; } setDialogState(() { physicalIds.add(id); full.text = '${physicalIds.length}'; empty.text = '0'; }); }, icon: const Icon(Icons.qr_code_scanner), label: Text('Escanear bidón lleno (${physicalIds.length})'), ), if (physicalIds.isNotEmpty) const Text( 'Los QR escaneados quedarán vinculados físicamente a esta ruta.', style: TextStyle(fontSize: 12), ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext, false), child: const Text('Cancelar'), ), FilledButton( onPressed: () => Navigator.pop(dialogContext, true), child: const Text('Guardar'), ), ], ), ), ); if (result != true || !mounted) return; final loadedFull = double.tryParse(full.text.replaceAll(',', '.')) ?? 0; final loadedEmpty = double.tryParse(empty.text.replaceAll(',', '.')) ?? 0; if (loadedFull <= 0 && loadedEmpty <= 0) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Ingresa una cantidad mayor que cero.')), ); return; } setState(() => _busy = true); try { await widget.repository.loadRouteContainers( routeId: _routeId!, containerTypeId: int.parse('${selected['id']}'), loadedFull: loadedFull, loadedEmpty: loadedEmpty, containerIds: physicalIds, ); final route = await widget.repository.currentRouteData(); if (mounted) { setState( () => _containerLoads = (route?['container_loads'] as List? ?? const []) .whereType() .map((item) => item.cast()) .toList(), ); } } catch (error) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('No se pudo registrar la carga: $error')), ); } } finally { if (mounted) setState(() => _busy = false); } } Future _scanRouteContainerQr() async { final qr = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('Escanear QR del bidón'), content: SizedBox( width: 280, height: 280, child: MobileScanner( onDetect: (capture) { final value = capture.barcodes.firstOrNull?.rawValue?.trim(); if (value != null && value.isNotEmpty) { Navigator.pop(dialogContext, value); } }, ), ), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancelar'), ), ], ), ); if (qr == null || !mounted) return null; try { final container = await widget.repository.lookupContainerQr(qr); final id = int.tryParse('${container['id']}'); if (id == null) throw Exception('QR no válido'); return id; } catch (error) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('No se pudo leer el bidón: $error')), ); } return null; } } Future _settleContainers() async { if (_routeId == null) return; setState(() => _busy = true); try { await widget.repository.settleRouteContainers(routeId: _routeId); if (mounted) { setState(() => _containersSettled = true); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Bidones liquidados correctamente.')), ); } } catch (error) { if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('No se pudo liquidar: $error'))); } } finally { if (mounted) setState(() => _busy = false); } } Future _showCashSettlementDialog() async { if (_routeId == null) return; final received = TextEditingController( text: _cashCollected.toStringAsFixed(2), ); final observation = TextEditingController(); final result = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('Liquidar efectivo'), content: Column( mainAxisSize: MainAxisSize.min, children: [ Text('Efectivo esperado: S/ ${_cashCollected.toStringAsFixed(2)}'), const SizedBox(height: 12), TextField( controller: received, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), decoration: const InputDecoration( labelText: 'Efectivo entregado', prefixText: 'S/ ', ), ), TextField( controller: observation, maxLines: 2, decoration: const InputDecoration( labelText: 'Observación (opcional)', ), ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext, false), child: const Text('Cancelar'), ), FilledButton( onPressed: () => Navigator.pop(dialogContext, true), child: const Text('Enviar a caja'), ), ], ), ); if (result != true || !mounted) return; final amount = double.tryParse(received.text.trim().replaceAll(',', '.')); if (amount == null || amount < 0) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Ingresa un monto válido.'))); return; } setState(() => _busy = true); try { await widget.repository.submitCashSettlement( routeId: _routeId, receivedCash: amount, observation: observation.text, ); if (mounted) { setState(() => _cashSettlementStatus = 'submitted'); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Liquidación enviada a caja.')), ); } } catch (error) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('No se pudo liquidar el efectivo: $error')), ); } } finally { received.dispose(); observation.dispose(); if (mounted) setState(() => _busy = false); } } } class _CollectionSummary extends StatelessWidget { const _CollectionSummary({ required this.cash, required this.yape, required this.transfer, required this.credit, required this.notCollected, }); final double cash; final double yape; final double transfer; final double credit; final int notCollected; @override Widget build(BuildContext context) => Container( width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: Colors.blueGrey.withValues(alpha: .08), borderRadius: BorderRadius.circular(12), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Recaudación de la ruta', style: TextStyle(fontWeight: FontWeight.w800), ), const SizedBox(height: 8), Text('Efectivo: S/ ${cash.toStringAsFixed(2)}'), Text('Yape/Plin: S/ ${yape.toStringAsFixed(2)}'), Text('Transferencia: S/ ${transfer.toStringAsFixed(2)}'), Text('Crédito autorizado: S/ ${credit.toStringAsFixed(2)}'), if (notCollected > 0) Text('No cobrados: $notCollected'), const Divider(), Text( 'Total: S/ ${(cash + yape + transfer + credit).toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w800), ), ], ), ); } class _ContainerLoadSummary extends StatelessWidget { const _ContainerLoadSummary({required this.loads}); final List> loads; @override Widget build(BuildContext context) => Card( elevation: 0, child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Control de bidones', style: TextStyle(fontWeight: FontWeight.w800), ), const SizedBox(height: 6), ...loads.map( (load) => Text( '${load['name'] ?? 'Tipo'}: llenos ${load['loaded_full'] ?? 0}, vacíos ${load['loaded_empty'] ?? 0}', ), ), ], ), ), ); }