import 'dart:async'; import 'dart:io'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import '../core/api_client.dart'; import '../core/api_contract.dart'; import '../modelos/delivery_order.dart'; import 'location_service.dart'; import 'offline_queue.dart'; class OfflineQueuedException implements Exception { const OfflineQueuedException(); @override String toString() => 'La acción quedó pendiente por falta de conexión.'; } class DeliveryRepository { DeliveryRepository({ApiClient? client, LocationService? locationService}) : _client = client ?? ApiClient(), _locationService = locationService ?? LocationService(); final ApiClient _client; final LocationService _locationService; final OfflineQueue _offlineQueue = OfflineQueue(); Future? _syncOperation; String _eventId(String _) => OfflineQueue.uuidV4(); Future> resolveTenant(String ruc) async { final response = await _client.postForm( ApiContract.resolveTenant, {'ruc': ruc}, baseUrlOverride: ApiClient.authBaseUrl, hostOverride: ApiClient.tenantHost, ); final data = (response['data'] as Map?)?.cast(); if (data == null || data['host'] == null || data['base_url'] == null) { throw const ApiException( 502, 'El servidor no devolvió un tenant válido.', ); } return data; } Future> login(String email, String password) async { final data = await _client.postForm(ApiContract.login, { 'email': email, 'password': password, }); final token = '${data['token'] ?? ''}'; if (token.isEmpty) { throw const ApiException(401, 'No se recibió token de acceso'); } return data; } Future saveTenant(Map tenant) async { final prefs = await SharedPreferences.getInstance(); // En pruebas locales el teléfono llega al servidor por adb reverse o por // la IP de desarrollo; el Host sigue siendo el dominio real del tenant. // En staging/producción se conserva la URL devuelta por el resolver. final configured = Uri.tryParse(ApiClient.authBaseUrl); final localBase = configured != null && (configured.host == '127.0.0.1' || configured.host == 'localhost'); await prefs.setString( 'delivery_api_base_url', localBase ? ApiClient.authBaseUrl : ApiClient.normalizeBaseUrl('${tenant['base_url']}'), ); await prefs.setString( 'delivery_tenant_host', localBase && ApiClient.tenantHost.isNotEmpty ? ApiClient.tenantHost : '${tenant['host']}', ); await prefs.setString('delivery_tenant_ruc', '${tenant['ruc'] ?? ''}'); await prefs.setString( 'delivery_company_name', '${tenant['company_name'] ?? ''}', ); } Future clearTenant() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove('delivery_api_base_url'); await prefs.remove('delivery_tenant_host'); await prefs.remove('delivery_tenant_ruc'); await prefs.remove('delivery_company_name'); } Future> assignedOrders() async { await syncPending(); final data = await _client.get(ApiContract.orders); final records = (data['data'] ?? data['orders'] ?? data) as dynamic; if (records is! List) return const []; final orders = records .map((item) => DeliveryOrder.fromJson(item as Map)) .toList(); try { final routeResponse = await _client.get(ApiContract.currentRoute); final route = routeResponse['data']; if (route is Map && route['orders'] is List) { final acceptedIds = (route['orders'] as List) .whereType>() .map((item) => '${item['id'] ?? ''}') .where((id) => id.isNotEmpty) .toSet(); return orders .map( (order) => order.status == OrderStatus.pending && acceptedIds.contains(order.id) ? order.copyWith(status: OrderStatus.assigned) : order, ) .toList(); } } catch (_) {} return orders; } Future>> containerOptions(String orderId) async { final response = await _client.get(ApiContract.containerOptions(orderId)); final data = response['data']; if (data is! List) return const []; return data .whereType() .map((item) => item.cast()) .toList(); } Future>> substitutionOptions(String orderId) async { final response = await _client.get( ApiContract.substitutionOptions(orderId), ); final data = response['data']; if (data is! List) return const []; return data .whereType() .map((item) => item.cast()) .toList(); } Future> lookupContainerQr(String qrCode) async { final response = await _client.get( '${ApiContract.lookupContainerQr}?qr_code=${Uri.encodeQueryComponent(qrCode)}', ); return (response['data'] as Map?)?.cast() ?? const {}; } Future> associateContainerQr( String orderId, String qrCode, { int? containerTypeId, }) async { final associationUuid = _eventId('container-association'); final body = { 'association_uuid': associationUuid, 'qr_code': qrCode.trim(), }; if (containerTypeId != null) { body['container_type_id'] = containerTypeId; } final response = await _client.post( ApiContract.associateContainerQr(orderId), body, associationUuid, ); _requireMutationResult(response); return (response['data'] as Map?)?.cast() ?? const {}; } Future acceptOrder(String id) { final eventId = _eventId('order-accept'); return _sendOrQueue( 'accept_order', {'id': id, 'event_id': eventId}, () async { final response = await _client.post(ApiContract.acceptOrder(id), { 'event_id': eventId, }); _requireMutationResult(response); return response; }, ).then((_) => _startTrackingAfterAccepted()); } Future updateStatus( String id, OrderStatus status, { String? observation, }) { final eventId = _eventId('order-status'); final payload = { 'id': id, 'status': status.apiValue, 'event_id': eventId, 'observation': observation, }; return _sendOrQueue('update_status', payload, () async { final response = await _client.post(ApiContract.orderStatus(id), { 'status': status.apiValue, 'event_id': eventId, 'observation': observation, }); _requireMutationResult(response); return response; }); } Future deliverOrder( String id, { double? latitude, double? longitude, double? accuracy, String? observation, List>? items, List>? containers, }) { final eventId = _eventId('order-deliver'); final event = { 'id': id, 'event_id': eventId, 'latitude': latitude, 'longitude': longitude, 'accuracy': accuracy, 'observation': observation, 'items': items, 'containers': containers, }; return _sendOrQueue('deliver_order', event, () async { final response = await _client.post(ApiContract.deliverOrder(id), { 'event_id': eventId, 'latitude': latitude, 'longitude': longitude, 'accuracy': accuracy, 'observation': observation, 'items': items, 'containers': containers, }); _requireMutationResult(response, expectedStatus: 'delivered'); return response; }); } Future reportPayment( String id, PaymentMethod method, { required double receivedAmount, String? reference, String? evidencePath, String? observation, }) { final eventId = _eventId('payment'); final fields = { 'payment_method': method.name, 'received_amount': receivedAmount.toStringAsFixed(2), if (reference != null && reference.isNotEmpty) 'reference': reference, if (observation != null && observation.isNotEmpty) 'observation': observation, }; return _sendOrQueue( 'report_payment', { 'id': id, 'event_id': eventId, 'payment_method': method.name, 'received_amount': receivedAmount, 'reference': reference, 'evidence_path': evidencePath, 'observation': observation, }, () async { final response = await _client.multipartPost( ApiContract.orderPayment(id), fields: fields, fileField: 'evidence', filePath: evidencePath, idempotencyKey: eventId, ); _requireMutationResult(response); return response; }, ); } Future currentRouteId() async { final route = await currentRouteData(); if (route is Map && route['id'] != null) { return '${route['id']}'; } throw const ApiException(404, 'No hay una ruta activa asignada'); } Future?> currentRouteData() async { final data = await _client.get(ApiContract.currentRoute); final route = data['route'] ?? data['data'] ?? data; return route is Map ? route : null; } Future> startWorkdayTracking() async { final prefs = await SharedPreferences.getInstance(); var deviceUuid = prefs.getString('delivery_device_uuid'); if (deviceUuid == null || deviceUuid.isEmpty) { deviceUuid = 'android-${DateTime.now().microsecondsSinceEpoch}'; await prefs.setString('delivery_device_uuid', deviceUuid); } final response = await _client.post(ApiContract.trackingStart, { 'device_uuid': deviceUuid, }); final data = (response['data'] ?? response) as Map?; if (data == null || data['id'] == null) { throw const ApiException( 502, 'El servidor no devolvió una jornada válida.', ); } await prefs.setBool('delivery_workday_tracking_enabled', true); return data.cast(); } Future?> currentWorkdayTracking() async { final response = await _client.get(ApiContract.trackingCurrent); final data = response['data'] ?? response; return data is Map ? data.cast() : null; } Future stopWorkdayTracking() async { final prefs = await SharedPreferences.getInstance(); try { await _client.post(ApiContract.trackingStop, const {}); } on ApiException catch (error) { // If the tenant switched off water distribution, the delivery gate // intentionally returns 403. The native service must still be stopped // locally so the driver can close the session and the device cannot // keep collecting GPS points for a disabled feature. if (error.statusCode != 403) rethrow; } await prefs.remove('delivery_workday_tracking_enabled'); } Future sendWorkdayLocations( String sessionId, List> points, ) async { final response = await _client.post(ApiContract.trackingLocations, { 'session_id': int.tryParse(sessionId) ?? sessionId, 'points': points, }); _requireMutationResult(response); } Future hasActiveRoute() async { final data = await _client.get(ApiContract.currentRoute); final route = data['route'] ?? data['data']; return route is Map && route['status'] == 'active'; } Future sendLocation( String routeId, double latitude, double longitude, double accuracy, ) async { final point = { 'event_id': _eventId('location'), 'latitude': latitude, 'longitude': longitude, 'accuracy': accuracy, 'recorded_at': DateTime.now().toUtc().toIso8601String(), }; final event = { 'id': routeId, 'event_id': point['event_id'], 'points': [point], }; try { final response = await _client.post(ApiContract.routeLocation(routeId), { 'points': [point], }); _requireMutationResult(response); } catch (error) { if (!_isNetworkError(error)) rethrow; await _offlineQueue.enqueue({'operation': 'location_batch', ...event}); } } Future loadRouteContainers({ required String routeId, required int containerTypeId, required double loadedFull, required double loadedEmpty, List containerIds = const [], }) async { final eventId = _eventId('route-container-load'); final event = { 'id': routeId, 'event_id': eventId, 'container_type_id': containerTypeId, 'loaded_full': loadedFull, 'loaded_empty': loadedEmpty, 'container_ids': containerIds, }; return _sendOrQueue('route_container_load', event, () async { final response = await _client .post(ApiContract.routeContainerLoad(routeId), { 'container_type_id': containerTypeId, 'loaded_full': loadedFull, 'loaded_empty': loadedEmpty, 'container_ids': containerIds, 'event_id': eventId, }); _requireMutationResult(response); return response; }); } Future settleRouteContainers({ String? routeId, String? observation, }) async { final id = routeId ?? await currentRouteId(); final event = { 'id': id, if (observation != null && observation.trim().isNotEmpty) 'observation': observation.trim(), }; return _sendOrQueue('route_container_settle', event, () async { final response = await _client .post(ApiContract.routeContainerSettle(id), { if (observation != null && observation.trim().isNotEmpty) 'observation': observation.trim(), }); _requireMutationResult(response); return response; }); } Future submitCashSettlement({ String? routeId, required double receivedCash, String? observation, }) async { if (receivedCash < 0) { throw const FormatException( 'El efectivo recibido no puede ser negativo.', ); } final id = routeId ?? await currentRouteId(); final eventId = _eventId('route-cash-settle'); final event = { 'id': id, 'event_id': eventId, 'operation_uuid': eventId, 'received_cash': receivedCash, if (observation != null && observation.trim().isNotEmpty) 'observation': observation.trim(), }; return _sendOrQueue('route_cash_settle', event, () async { final response = await _client.post(ApiContract.routeCashSettle(id), { 'operation_uuid': eventId, 'received_cash': receivedCash, if (observation != null && observation.trim().isNotEmpty) 'observation': observation.trim(), }, eventId); _requireMutationResult(response); return response; }); } Future syncPending() { final active = _syncOperation; if (active != null) return active; final operation = _syncPendingInternal(); _syncOperation = operation; return operation.whenComplete(() { if (identical(_syncOperation, operation)) _syncOperation = null; }); } Future _syncPendingInternal() async { var synced = 0; final events = await _offlineQueue.pending(); for (final event in events) { try { await _dispatchQueued(event); await _offlineQueue.removeFirst(); synced++; } catch (_) { // No eliminar una acción fallida: conserva la operación para que el // siguiente intento pueda reintentarse sin perder la entrega o el cobro. break; } } return synced; } Future pendingOfflineCount() => _offlineQueue.count(); Future clearPendingOffline() => _offlineQueue.clear(); Future notificationOpened({ required String orderId, String? messageId, }) => _client.post(ApiContract.notificationOpened, { 'order_id': int.tryParse(orderId) ?? orderId, if (messageId != null && messageId.isNotEmpty) 'message_id': messageId, }); Future _dispatchQueued(Map event) async { final type = event['operation']; final id = '${event['id']}'; final eventId = '${event['event_id']}'; switch (type) { case 'accept_order': _requireMutationResult( await _client.post(ApiContract.acceptOrder(id), { 'event_id': eventId, }), ); // La aceptación puede haber quedado en cola mientras no había red. // Cuando se sincroniza, repetir el inicio del tracking evita que la // ruta quede activa sin ubicación hasta que el usuario abra otra // pantalla manualmente. await _startTrackingAfterAccepted(); case 'update_status': _requireMutationResult( await _client.post(ApiContract.orderStatus(id), { 'status': _apiStatusValue(event['status']), 'event_id': eventId, 'observation': event['observation'], }), ); case 'deliver_order': final response = await _client.post(ApiContract.deliverOrder(id), { 'event_id': eventId, 'latitude': event['latitude'], 'longitude': event['longitude'], 'accuracy': event['accuracy'], 'observation': event['observation'], if (event['items'] is List) 'items': event['items'], if (event['containers'] is List) 'containers': event['containers'], }); _requireMutationResult(response, expectedStatus: 'delivered'); case 'report_payment': _requireMutationResult( await _client.multipartPost( ApiContract.orderPayment(id), fields: { 'payment_method': '${event['payment_method']}', 'received_amount': '${event['received_amount']}', if (event['reference'] != null && '${event['reference']}'.isNotEmpty) 'reference': '${event['reference']}', if (event['observation'] != null && '${event['observation']}'.isNotEmpty) 'observation': '${event['observation']}', }, fileField: 'evidence', filePath: event['evidence_path']?.toString(), idempotencyKey: eventId, ), ); case 'route_container_load': _requireMutationResult( await _client.post(ApiContract.routeContainerLoad(id), { 'container_type_id': event['container_type_id'], 'loaded_full': event['loaded_full'], 'loaded_empty': event['loaded_empty'], 'container_ids': event['container_ids'], 'event_id': eventId, }), ); case 'route_container_settle': _requireMutationResult( await _client.post(ApiContract.routeContainerSettle(id), { if (event['observation'] != null) 'observation': event['observation'], }), ); case 'route_cash_settle': _requireMutationResult( await _client.post( ApiContract.routeCashSettle(id), { 'operation_uuid': event['operation_uuid'] ?? eventId, 'received_cash': event['received_cash'], if (event['observation'] != null) 'observation': event['observation'], }, '${event['operation_uuid'] ?? eventId}', ), ); case 'location_batch': final points = event['points']; if (points is! List || points.isEmpty) { throw const FormatException('Lote de ubicaciones inválido.'); } _requireMutationResult( await _client.post(ApiContract.routeLocation(id), {'points': points}), ); default: throw const FormatException('Acción offline desconocida.'); } } Future _sendOrQueue( String operation, Map event, Future> Function() send, ) async { try { await send(); } catch (error) { if (!_isNetworkError(error)) rethrow; await _offlineQueue.enqueue({'operation': operation, ...event}); throw const OfflineQueuedException(); } } Future _startTrackingAfterAccepted() async { try { final prefs = await SharedPreferences.getInstance(); if (prefs.getBool('delivery_workday_tracking_enabled') != true) { final route = await currentRouteData(); final routeId = route?['id']?.toString(); if (routeId != null && routeId.isNotEmpty && route?['status'] == 'active') { if (await _locationService.requestPermission()) { await _locationService.startNativeTracking(routeId); } } return; } Map? workday; try { workday = await currentWorkdayTracking(); } catch (error) { if (error is ApiException && error.statusCode != 503) rethrow; } if (workday?['status'] == 'active' && workday?['id'] != null) { if (await _locationService.requestPermission()) { await _locationService.startNativeWorkdayTracking( '${workday!['id']}', ); } return; } final route = await currentRouteData(); final routeId = route?['id']?.toString(); if (routeId == null || routeId.isEmpty || route?['status'] != 'active') { return; } if (await _locationService.requestPermission()) { await _locationService.startNativeTracking(routeId); } } catch (_) { // La aceptación ya fue confirmada. Un fallo de GPS o de la consulta de // ruta no debe devolver el evento a la cola ni duplicar la aceptación. } } bool _isNetworkError(Object error) => error is SocketException || error is http.ClientException || error is TimeoutException; String _apiStatusValue(Object? value) => switch ('$value') { 'enRoute' => 'en_route', 'notDelivered' => 'not_delivered', _ => '$value', }; void _requireMutationResult( Map response, { String? expectedStatus, }) { if (response['success'] == false) { throw ApiException( 502, response['message']?.toString() ?? 'El servidor no confirmó la operación.', ); } if (expectedStatus == null) return; final data = response['data']; final status = data is Map ? data['delivery_status']?.toString() : null; if (status != null && status != expectedStatus) { throw ApiException( 409, 'El servidor devolvió un estado inesperado: $status.', ); } } Future registerDevice({ required String deviceUuid, required String fcmToken, }) => _client.post(ApiContract.registerDevice, { 'device_uuid': deviceUuid, 'fcm_token': fcmToken, 'platform': 'android', }); Future revokeDevice({required String deviceUuid}) async { await _client.post(ApiContract.revokeDevice, {'device_uuid': deviceUuid}); } }