import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:path_provider/path_provider.dart'; import 'package:url_launcher/url_launcher.dart'; import '../core/app_theme.dart'; import '../modelos/delivery_order.dart'; import '../servicios/delivery_repository.dart'; import '../servicios/location_service.dart'; class OrderDetailScreen extends StatefulWidget { const OrderDetailScreen({ required this.order, required this.repository, super.key, }); final DeliveryOrder order; final DeliveryRepository repository; @override State createState() => _OrderDetailScreenState(); } class _OrderDetailScreenState extends State { bool _busy = false; List> _containerOptions = const []; List> _substitutionOptions = const []; final Map> _containerControllers = {}; final TextEditingController _qrController = TextEditingController(); List> _associatedContainers = const []; final TextEditingController _deliveryObservationController = TextEditingController(); late final List _deliveredControllers; late final List _preparedControllers; late final List _additionalControllers; late final List _rejectedControllers; late final List _returnedControllers; late final List _substitutionQuantityControllers; late final List _differenceReasonControllers; late final List _substitutionItemIds; late final List _substitutionPresentationIds; @override void initState() { super.initState(); _deliveredControllers = _controllersFor( (item) => _formatQuantity(item.quantity), ); _preparedControllers = _controllersFor( (item) => _formatQuantity(item.quantity), ); _additionalControllers = _controllersFor((_) => '0'); _rejectedControllers = _controllersFor((_) => '0'); _returnedControllers = _controllersFor((_) => '0'); _substitutionQuantityControllers = _controllersFor((_) => '0'); _differenceReasonControllers = _controllersFor((_) => ''); _substitutionItemIds = List.filled(widget.order.items.length, null); _substitutionPresentationIds = List.filled( widget.order.items.length, null, ); unawaited(_loadContainerOptions()); unawaited(_loadSubstitutionOptions()); } Future _loadContainerOptions() async { try { final options = await widget.repository.containerOptions(widget.order.id); if (!mounted) return; for (final option in options) { final id = int.tryParse('${option['id']}'); if (id == null) continue; _containerControllers[id] = { 'delivered_quantity': TextEditingController(text: '0'), 'received_empty_quantity': TextEditingController(text: '0'), 'received_damaged_quantity': TextEditingController(text: '0'), 'loaned_quantity': TextEditingController(text: '0'), 'sold_quantity': TextEditingController(text: '0'), }; } setState(() => _containerOptions = options); } catch (_) { // El catálogo de bidones se habilita por migración/feature flag del tenant. } } Future _loadSubstitutionOptions() async { try { final options = await widget.repository.substitutionOptions( widget.order.id, ); if (mounted) setState(() => _substitutionOptions = options); } catch (_) { // Las sustituciones siguen siendo opcionales si el catalogo no esta disponible. } } List _controllersFor( String Function(DeliveryOrderItem item) value, ) => widget.order.items .map((item) => TextEditingController(text: value(item))) .toList(); @override void dispose() { for (final controller in [ _deliveryObservationController, _qrController, ..._deliveredControllers, ..._preparedControllers, ..._additionalControllers, ..._rejectedControllers, ..._returnedControllers, ..._substitutionQuantityControllers, ..._differenceReasonControllers, ..._containerControllers.values.expand((fields) => fields.values), ]) { controller.dispose(); } super.dispose(); } Future _associateContainerQr() async { final qr = _qrController.text.trim(); if (qr.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Ingresa o escanea el código QR del bidón.'), ), ); return; } final result = await _action(() async { final association = await widget.repository.associateContainerQr( widget.order.id, qr, ); if (mounted) { setState(() { _associatedContainers = [..._associatedContainers, association]; }); } _qrController.clear(); }, 'Bidón asociado a la entrega'); if (!result) return; } Future _scanContainerQr() async { final value = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (sheetContext) => SizedBox( height: MediaQuery.sizeOf(sheetContext).height * .72, child: SafeArea( child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 8, 8), child: Row( children: [ const Expanded( child: Text( 'Escanear QR del bidón', style: TextStyle( fontWeight: FontWeight.w800, fontSize: 18, ), ), ), IconButton( tooltip: 'Cerrar escáner', onPressed: () => Navigator.pop(sheetContext), icon: const Icon(Icons.close), ), ], ), ), const Divider(height: 1), Expanded( child: MobileScanner( onDetect: (capture) { final raw = capture.barcodes .map((barcode) => barcode.rawValue?.trim()) .whereType() .firstWhere( (code) => code.isNotEmpty, orElse: () => '', ); if (raw.isNotEmpty && sheetContext.mounted) { Navigator.pop(sheetContext, raw); } }, ), ), const Padding( padding: EdgeInsets.all(12), child: Text('Centra el código dentro del recuadro.'), ), ], ), ), ), ); if (!mounted || value == null || value.isEmpty) return; _qrController.text = value; await _associateContainerQr(); } Future _action(Future Function() action, String success) async { setState(() => _busy = true); try { await action(); if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(success))); } return true; } catch (error) { if (mounted) { final message = error is OfflineQueuedException ? 'Sin conexión. La acción quedó guardada y se enviará al recuperar internet.' : 'No se pudo completar: $error'; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } return false; } finally { if (mounted) setState(() => _busy = false); } } Future _deliverAndReport() async { final lines = _deliveryLines(); for (final line in lines) { final available = (line['requested_quantity'] as num).toDouble() + (line['additional_quantity'] as num).toDouble(); final accounted = (line['delivered_quantity'] as num).toDouble() + (line['rejected_quantity'] as num).toDouble() + (line['returned_quantity'] as num).toDouble(); if (accounted > available) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'Revisa las cantidades: entregado, rechazado y devuelto no pueden superar lo solicitado más lo adicional.', ), ), ); } return false; } } final hasPermission = await LocationService().requestPermission(); final position = hasPermission ? await LocationService().currentPosition() : null; final hasLocation = position != null; final observation = _deliveryObservationController.text.trim(); if (!hasLocation && mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'No se obtuvo GPS. Puedes confirmar sin ubicación; se registrará como entrega manual.', ), ), ); } return _action( () => widget.repository.deliverOrder( widget.order.id, latitude: position?.latitude, longitude: position?.longitude, accuracy: position?.accuracy, observation: hasLocation ? (observation.isEmpty ? null : observation) : 'GPS no disponible o permiso de ubicación no concedido.' '${observation.isEmpty ? '' : ' $observation'}', items: lines, containers: _containerLines(), ), hasLocation ? 'Entrega confirmada con ubicación' : 'Entrega confirmada sin ubicación', ); } List> _deliveryLines() => [ for (var index = 0; index < widget.order.items.length; index++) if (widget.order.items[index].itemId != null) { 'order_note_item_id': widget.order.items[index].orderNoteItemId, 'item_id': widget.order.items[index].itemId, if (widget.order.items[index].presentationId != null) 'item_unit_type_id': widget.order.items[index].presentationId, 'requested_quantity': widget.order.items[index].quantity, 'prepared_quantity': _number(_preparedControllers[index]), 'delivered_quantity': _number(_deliveredControllers[index]), 'additional_quantity': _number(_additionalControllers[index]), 'rejected_quantity': _number(_rejectedControllers[index]), 'returned_quantity': _number(_returnedControllers[index]), if (_substitutionItemIds[index] != null) 'substitution_item_id': _substitutionItemIds[index], if (_substitutionPresentationIds[index] != null) 'substitution_item_unit_type_id': _substitutionPresentationIds[index], 'substitution_quantity': _number( _substitutionQuantityControllers[index], ), 'difference_reason': _differenceReasonControllers[index].text.trim(), 'unit_price': widget.order.items[index].unitPrice, 'total': (max( 0, _number(_deliveredControllers[index]) + _number(_additionalControllers[index]) - _number(_returnedControllers[index]), ) * widget.order.items[index].unitPrice) + (_number(_substitutionQuantityControllers[index]) * _substitutionPrice(index)), }, ]; List> _containerLines() => [ for (final option in _containerOptions) if (_containerControllers[int.tryParse('${option['id']}')] != null) { 'container_type_id': int.parse('${option['id']}'), if (_associatedContainers .cast>() .where( (association) => '${association['container']?['container_type_id'] ?? association['container']?['type']?['id']}' == '${option['id']}', ) .isNotEmpty) 'container_ids': _associatedContainers .cast>() .where( (association) => '${association['container']?['container_type_id'] ?? association['container']?['type']?['id']}' == '${option['id']}', ) .map((association) => association['container']['id']) .toList(), for (final field in [ 'delivered_quantity', 'received_empty_quantity', 'received_damaged_quantity', 'loaned_quantity', 'sold_quantity', ]) field: _number( _containerControllers[int.parse('${option['id']}')]![field]!, ), 'deposit_amount': (_number( _containerControllers[int.parse( '${option['id']}', )]!['delivered_quantity']!, ) + _number( _containerControllers[int.parse( '${option['id']}', )]!['loaned_quantity']!, )) * (double.tryParse('${option['deposit_amount'] ?? 0}') ?? 0), }, ]; double _number(TextEditingController controller) => max(0, double.tryParse(controller.text.replaceAll(',', '.')) ?? 0); double _deliveryTotal() => widget.order.items.fold(0, (total, item) { final index = widget.order.items.indexOf(item); return total + max( 0, _number(_deliveredControllers[index]) + _number(_additionalControllers[index]) - _number(_returnedControllers[index]), ) * item.unitPrice + _number(_substitutionQuantityControllers[index]) * _substitutionPrice(index); }); double _substitutionPrice(int index) { final substitution = _substitutionOptions .cast?>() .firstWhere( (option) => '${option?['id']}' == '${_substitutionItemIds[index]}', orElse: () => null, ); var price = double.tryParse('${substitution?['sale_unit_price'] ?? 0}') ?? 0; final presentations = substitution?['presentations']; if (presentations is List && _substitutionPresentationIds[index] != null) { final presentation = presentations.whereType().firstWhere( (value) => '${value['id']}' == '${_substitutionPresentationIds[index]}', orElse: () => const {}, ); price = double.tryParse('${presentation['price'] ?? price}') ?? price; } return price; } Future _acceptOrder() async { final ok = await _action( () => widget.repository.acceptOrder(widget.order.id), 'Pedido aceptado', ); if (!mounted || !ok) return; // El repositorio inicia el tracking online u offline; esta pantalla solo // confirma la aceptacion y vuelve al listado. // El inicio del GPS no debe deshacer la aceptación del pedido. if (!mounted) return; Navigator.pop(context, true); } Future _rejectOrder() async { final reason = await showDialog( context: context, builder: (_) => const _RejectOrderDialog(), ); if (reason == null) return; final ok = await _action( () => widget.repository.updateStatus( widget.order.id, OrderStatus.rejected, observation: reason, ), 'Pedido rechazado', ); if (ok && mounted) Navigator.pop(context, true); } Future _deliverOrder() async { final ok = await _deliverAndReport(); if (!mounted || !ok) return; Navigator.pop(context, true); } Future _reportOutcome() async { final result = await showDialog<_OutcomeData>( context: context, builder: (_) => const _OutcomeDialog(), ); if (result == null) return; final ok = await _action( () => widget.repository.updateStatus( widget.order.id, result.status, observation: result.observation, ), '${result.status.label} registrado', ); if (ok && mounted) Navigator.pop(context, true); } Future _openExternal(Uri uri) async { if (!await launchUrl(uri, mode: LaunchMode.externalApplication) && mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('No se pudo abrir la aplicación.')), ); } } Future _payment() async { final paidAlready = widget.order.total - (widget.order.balanceDue ?? widget.order.total); final amountDue = (_deliveryTotal() - paidAlready) .clamp(0, double.infinity) .toDouble(); final result = await showDialog<_PaymentData>( context: context, builder: (_) => _PaymentDialog(total: amountDue), ); if (result == null) return; String? evidencePath; final needsEvidence = { PaymentMethod.yape, PaymentMethod.plin, PaymentMethod.transfer, }.contains(result.method); if (needsEvidence) { final photo = await ImagePicker().pickImage( source: ImageSource.camera, imageQuality: 85, ); evidencePath = photo == null ? null : await _persistEvidence(photo); } if (needsEvidence && evidencePath == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('El comprobante del pago digital es obligatorio.'), ), ); } return; } final ok = await _action( () => widget.repository.reportPayment( widget.order.id, result.method, receivedAmount: result.receivedAmount, reference: result.reference, evidencePath: evidencePath, observation: result.observation, ), 'Cobro reportado', ); if (ok && mounted) Navigator.pop(context, true); } Future _persistEvidence(XFile photo) async { final directory = await getApplicationDocumentsDirectory(); final evidenceDirectory = Directory('${directory.path}/delivery_evidence'); await evidenceDirectory.create(recursive: true); final destination = File( '${evidenceDirectory.path}/yape_${widget.order.id}_${DateTime.now().millisecondsSinceEpoch}.jpg', ); return (await File(photo.path).copy(destination.path)).path; } @override Widget build(BuildContext context) => Scaffold( appBar: AppBar(title: Text(widget.order.number)), body: ListView( padding: const EdgeInsets.all(16), children: [ Card( elevation: 0, child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.order.customer, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w800, color: AppTheme.ink, ), ), const SizedBox(height: 16), _InfoRow( icon: Icons.location_on_outlined, label: 'Dirección', value: widget.order.address, ), if (widget.order.reference != null && widget.order.reference!.trim().isNotEmpty) _InfoRow( icon: Icons.notes_outlined, label: 'Referencia', value: widget.order.reference!.trim(), ), if (widget.order.phone != null) _InfoRow( icon: Icons.phone_outlined, label: 'Contacto', value: widget.order.phone!, ), if (widget.order.phone != null || widget.order.address.isNotEmpty) Wrap( spacing: 8, runSpacing: 8, children: [ if (widget.order.phone != null) OutlinedButton.icon( onPressed: () => _openExternal( Uri.parse('tel:${widget.order.phone}'), ), icon: const Icon(Icons.phone_outlined), label: const Text('Llamar'), ), if (widget.order.phone != null) OutlinedButton.icon( onPressed: () => _openExternal( Uri.parse( 'https://wa.me/${widget.order.phone!.replaceAll(RegExp(r'[^0-9+]'), '')}', ), ), icon: const Icon(Icons.chat_outlined), label: const Text('WhatsApp'), ), OutlinedButton.icon( onPressed: () { final query = widget.order.latitude != null && widget.order.longitude != null ? '${widget.order.latitude},${widget.order.longitude}' : widget.order.address; _openExternal( Uri.https('www.google.com', '/maps/search/', { 'api': '1', 'query': query, }), ); }, icon: const Icon(Icons.navigation_outlined), label: const Text('Navegar'), ), ], ), const Divider(height: 28), if (widget.order.items.isNotEmpty) ...[ Text( 'Productos a entregar', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w800, ), ), const SizedBox(height: 8), ...List.generate( widget.order.items.length, (index) => _DeliveryLineEditor( item: widget.order.items[index], substitutionOptions: _substitutionOptions, selectedSubstitutionItemId: _substitutionItemIds[index], selectedSubstitutionPresentationId: _substitutionPresentationIds[index], onSubstitutionItemChanged: (value) => setState(() { _substitutionItemIds[index] = value; _substitutionPresentationIds[index] = null; }), onSubstitutionPresentationChanged: (value) => setState( () => _substitutionPresentationIds[index] = value, ), substitutionQuantityController: _substitutionQuantityControllers[index], differenceReasonController: _differenceReasonControllers[index], deliveredController: _deliveredControllers[index], preparedController: _preparedControllers[index], additionalController: _additionalControllers[index], rejectedController: _rejectedControllers[index], returnedController: _returnedControllers[index], onChanged: () => setState(() {}), ), ), const Divider(height: 20), ], if (widget.order.deliveryAdjustments.isNotEmpty) ...[ Text( 'Ajustes registrados', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w800, ), ), const SizedBox(height: 8), ...widget.order.deliveryAdjustments.map( (adjustment) => ListTile( dense: true, contentPadding: EdgeInsets.zero, leading: const Icon(Icons.edit_note_outlined), title: Text( 'Entregados ${adjustment['delivered_quantity'] ?? 0} · ' 'adicionales ${adjustment['additional_quantity'] ?? 0}', ), subtitle: Text( 'Sustitución: ${adjustment['substitution_quantity'] ?? 0} · ' '${adjustment['difference_reason'] ?? 'Sin motivo'}', ), ), ), const Divider(height: 20), ], if (_containerOptions.isNotEmpty) ...[ Text( 'Bidones de la entrega', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w800, ), ), const SizedBox(height: 8), if (widget.order.status == OrderStatus.pending || widget.order.status == OrderStatus.assigned || widget.order.status == OrderStatus.enRoute) ...[ Row( children: [ Expanded( child: TextField( controller: _qrController, textInputAction: TextInputAction.done, onSubmitted: (_) => _associateContainerQr(), decoration: const InputDecoration( labelText: 'QR del bidón físico', hintText: 'SPB-...', prefixIcon: Icon(Icons.qr_code_2), ), ), ), const SizedBox(width: 8), IconButton.filled( onPressed: _busy ? null : _associateContainerQr, tooltip: 'Asociar bidón', icon: const Icon(Icons.add_link), ), const SizedBox(width: 4), IconButton.filled( onPressed: _busy ? null : _scanContainerQr, tooltip: 'Escanear QR', icon: const Icon(Icons.document_scanner_outlined), ), ], ), if (_associatedContainers.isNotEmpty) ..._associatedContainers.map( (association) => ListTile( dense: true, leading: const Icon(Icons.local_drink_outlined), title: Text( '${association['container']?['code'] ?? association['container']?['qr_code'] ?? 'Bidón'}', ), subtitle: Text( '${association['container']?['type']?['name'] ?? 'Tipo no indicado'}', ), ), ), const SizedBox(height: 8), ], ..._containerOptions.map(_buildContainerEditor), const Divider(height: 20), ], _InfoRow( icon: Icons.payments_outlined, label: 'Total', value: 'S/ ${_deliveryTotal().toStringAsFixed(2)}', ), if (widget.order.adjustmentStatus != null && widget.order.adjustmentStatus != 'none') _InfoRow( icon: Icons.rule_outlined, label: 'Ajuste', value: widget.order.adjustmentStatus == 'approved' ? 'Aprobado por Caja' : widget.order.adjustmentStatus == 'pending' ? 'Pendiente de aprobación' : 'Rechazado', ), if (widget.order.balanceDue != null && widget.order.balanceDue! < widget.order.total) _InfoRow( icon: Icons.account_balance_wallet_outlined, label: 'Saldo pendiente', value: 'S/ ${widget.order.balanceDue!.toStringAsFixed(2)}', ), ], ), ), ), const SizedBox(height: 16), if (widget.order.status == OrderStatus.assigned || widget.order.status == OrderStatus.pending) ElevatedButton.icon( onPressed: _busy ? null : _acceptOrder, icon: const Icon(Icons.check_circle_outline), label: const Text('Aceptar pedido'), ), if (widget.order.status == OrderStatus.assigned || widget.order.status == OrderStatus.pending) OutlinedButton.icon( onPressed: _busy ? null : _rejectOrder, icon: const Icon(Icons.block_outlined), label: const Text('Rechazar pedido'), ), if ((widget.order.status == OrderStatus.enRoute || widget.order.status == OrderStatus.delivered) && (widget.order.paymentStatus == null || widget.order.paymentStatus == 'unpaid' || widget.order.paymentStatus == 'rejected')) ElevatedButton.icon( onPressed: _busy ? null : _payment, icon: const Icon(Icons.account_balance_wallet_outlined), label: const Text('Registrar cobro'), ), if (widget.order.status == OrderStatus.enRoute) ...[ TextField( controller: _deliveryObservationController, maxLines: 2, maxLength: 500, decoration: const InputDecoration( labelText: 'Observación de entrega (opcional)', hintText: 'Indica faltantes, adicionales o devoluciones', ), ), const SizedBox(height: 12), ElevatedButton.icon( onPressed: _busy ? null : _deliverOrder, icon: const Icon(Icons.done_all), label: const Text('Confirmar entrega'), ), const SizedBox(height: 8), OutlinedButton.icon( onPressed: _busy ? null : _reportOutcome, icon: const Icon(Icons.report_problem_outlined), label: const Text('Reportar incidencia'), ), ], if (widget.order.status == OrderStatus.delivered) const _DoneBanner(), ], ), ); Widget _buildContainerEditor(Map option) { final id = int.parse('${option['id']}'); final fields = _containerControllers[id]!; return Padding( padding: const EdgeInsets.only(bottom: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${option['name']} · Cliente tiene: ${option['customer_with_quantity'] ?? 0}', style: const TextStyle(fontWeight: FontWeight.w700), ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ _QuantityField( label: 'Lleno', controller: fields['delivered_quantity']!, ), _QuantityField( label: 'Vacío', controller: fields['received_empty_quantity']!, ), _QuantityField( label: 'Dañado', controller: fields['received_damaged_quantity']!, ), _QuantityField( label: 'Préstamo', controller: fields['loaned_quantity']!, ), _QuantityField( label: 'Venta', controller: fields['sold_quantity']!, ), ], ), ], ), ); } } class _DeliveryLineEditor extends StatelessWidget { const _DeliveryLineEditor({ required this.item, required this.substitutionOptions, required this.selectedSubstitutionItemId, required this.selectedSubstitutionPresentationId, required this.onSubstitutionItemChanged, required this.onSubstitutionPresentationChanged, required this.substitutionQuantityController, required this.differenceReasonController, required this.preparedController, required this.deliveredController, required this.additionalController, required this.rejectedController, required this.returnedController, this.onChanged, }); final DeliveryOrderItem item; final List> substitutionOptions; final int? selectedSubstitutionItemId; final int? selectedSubstitutionPresentationId; final ValueChanged onSubstitutionItemChanged; final ValueChanged onSubstitutionPresentationChanged; final TextEditingController substitutionQuantityController; final TextEditingController differenceReasonController; final TextEditingController preparedController; final TextEditingController deliveredController; final TextEditingController additionalController; final TextEditingController rejectedController; final TextEditingController returnedController; final VoidCallback? onChanged; @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.only(bottom: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Text( item.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600), ), ), const SizedBox(width: 12), Text( 'Solicitado: ${_formatQuantity(item.quantity)}', style: const TextStyle(fontWeight: FontWeight.w700), ), ], ), const SizedBox(height: 8), if (item.presentationDescription != null && item.presentationDescription!.trim().isNotEmpty) Text( 'Presentacion: ${item.presentationDescription!}' '${item.presentationFactor != null ? ' (${_formatQuantity(item.presentationFactor!)})' : ''}', style: const TextStyle( color: AppTheme.muted, fontSize: 13, fontWeight: FontWeight.w600, ), ), if (item.presentationDescription != null && item.presentationDescription!.trim().isNotEmpty) const SizedBox(height: 6), if (item.pointsEarn != null || item.pointsRedeem != null) Text( item.pointRule == 'redemption' ? 'Canje: ${_formatQuantity(item.pointsRedeem ?? 0)} puntos' : 'Puntos: ${_formatQuantity(item.pointsEarn ?? 0)} por unidad', style: const TextStyle( color: AppTheme.muted, fontSize: 13, fontWeight: FontWeight.w600, ), ), if (item.pointsEarn != null || item.pointsRedeem != null) const SizedBox(height: 6), Wrap( spacing: 8, runSpacing: 8, children: [ _QuantityField( label: 'Preparado', controller: preparedController, onChanged: (_) => onChanged?.call(), ), _QuantityField( label: 'Entregado', controller: deliveredController, onChanged: (_) => onChanged?.call(), ), _QuantityField( label: 'Adicional', controller: additionalController, onChanged: (_) => onChanged?.call(), ), _QuantityField( label: 'Rechazado', controller: rejectedController, onChanged: (_) => onChanged?.call(), ), _QuantityField( label: 'Devuelto', controller: returnedController, onChanged: (_) => onChanged?.call(), ), ], ), if (substitutionOptions.isNotEmpty) ...[ const SizedBox(height: 8), DropdownButtonFormField( initialValue: selectedSubstitutionItemId, isExpanded: true, decoration: const InputDecoration( labelText: 'Producto sustituto (opcional)', helperText: 'Selecciona otro producto solo si cambia la entrega', ), hint: const Text('Sin sustitución'), items: substitutionOptions .map((option) { final id = int.tryParse('${option['id']}'); return DropdownMenuItem( value: id, child: Text( '${option['name'] ?? 'Producto'}${option['internal_id'] != null ? ' · ${option['internal_id']}' : ''}', overflow: TextOverflow.ellipsis, ), ); }) .where((item) => item.value != null) .toList(), onChanged: onSubstitutionItemChanged, ), if (selectedSubstitutionItemId != null) ...[ const SizedBox(height: 8), Builder( builder: (context) { final selected = substitutionOptions.firstWhere( (option) => '${option['id']}' == '$selectedSubstitutionItemId', orElse: () => const {}, ); final presentations = (selected['presentations'] is List) ? (selected['presentations'] as List) .whereType() .map((value) => value.cast()) .toList() : const >[]; if (presentations.isEmpty) return const SizedBox.shrink(); return DropdownButtonFormField( initialValue: selectedSubstitutionPresentationId, isExpanded: true, decoration: const InputDecoration( labelText: 'Presentación sustituta', ), hint: const Text('Unidad base'), items: presentations .map((presentation) { final id = int.tryParse('${presentation['id']}'); return DropdownMenuItem( value: id, child: Text( '${presentation['description'] ?? 'Presentación'}', ), ); }) .where((item) => item.value != null) .toList(), onChanged: onSubstitutionPresentationChanged, ); }, ), const SizedBox(height: 8), _QuantityField( label: 'Cantidad sustituta', controller: substitutionQuantityController, onChanged: (_) => onChanged?.call(), ), ], ], TextField( controller: differenceReasonController, maxLength: 500, decoration: const InputDecoration( labelText: 'Motivo del ajuste (si aplica)', hintText: 'Ej. cliente pidió otra presentación', ), onChanged: (_) => onChanged?.call(), ), const SizedBox(height: 4), Text( 'Precio: S/ ${item.unitPrice.toStringAsFixed(2)} · Total: S/ ${item.total.toStringAsFixed(2)}', style: const TextStyle(color: AppTheme.muted, fontSize: 13), ), ], ), ); } class _QuantityField extends StatelessWidget { const _QuantityField({ required this.label, required this.controller, this.onChanged, }); final String label; final TextEditingController controller; final ValueChanged? onChanged; @override Widget build(BuildContext context) => SizedBox( width: 136, child: TextField( controller: controller, onChanged: onChanged, keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: const [], decoration: InputDecoration(labelText: label), ), ); } class _InfoRow extends StatelessWidget { const _InfoRow({ required this.icon, required this.label, required this.value, }); final IconData icon; final String label; final String value; @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.only(bottom: 14), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, color: AppTheme.primary), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: Theme.of(context).textTheme.labelMedium), const SizedBox(height: 3), Text(value, style: const TextStyle(fontWeight: FontWeight.w600)), ], ), ), ], ), ); } String _formatQuantity(double value) => value == value.roundToDouble() ? value.toInt().toString() : value.toString(); class _DoneBanner extends StatelessWidget { const _DoneBanner(); @override Widget build(BuildContext context) => Card( color: Colors.green.shade50, elevation: 0, child: const Padding( padding: EdgeInsets.all(16), child: Row( children: [ Icon(Icons.check_circle, color: Colors.green), SizedBox(width: 12), Expanded(child: Text('Entrega completada.')), ], ), ), ); } class _OutcomeData { const _OutcomeData({required this.status, required this.observation}); final OrderStatus status; final String observation; } class _RejectOrderDialog extends StatefulWidget { const _RejectOrderDialog(); @override State<_RejectOrderDialog> createState() => _RejectOrderDialogState(); } class _RejectOrderDialogState extends State<_RejectOrderDialog> { final _reason = TextEditingController(); @override void dispose() { _reason.dispose(); super.dispose(); } @override Widget build(BuildContext context) => AlertDialog( title: const Text('Rechazar pedido'), content: TextField( controller: _reason, minLines: 2, maxLines: 4, autofocus: true, decoration: const InputDecoration( labelText: 'Motivo obligatorio', hintText: 'Ejemplo: vehículo averiado', ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancelar'), ), FilledButton( onPressed: () { final reason = _reason.text.trim(); if (reason.length < 3) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Ingresa un motivo válido.')), ); return; } Navigator.pop(context, 'Rechazado por el repartidor: $reason'); }, child: const Text('Rechazar'), ), ], ); } class _OutcomeDialog extends StatefulWidget { const _OutcomeDialog(); @override State<_OutcomeDialog> createState() => _OutcomeDialogState(); } class _OutcomeDialogState extends State<_OutcomeDialog> { OrderStatus _status = OrderStatus.notDelivered; String _reason = 'Cliente ausente'; final _observation = TextEditingController(); @override void dispose() { _observation.dispose(); super.dispose(); } @override Widget build(BuildContext context) => AlertDialog( title: const Text('Reportar incidencia'), content: Column( mainAxisSize: MainAxisSize.min, children: [ DropdownButtonFormField( initialValue: _status, decoration: const InputDecoration(labelText: 'Resultado'), items: const [ DropdownMenuItem( value: OrderStatus.notDelivered, child: Text('No entregado'), ), DropdownMenuItem( value: OrderStatus.rescheduled, child: Text('Reprogramado'), ), DropdownMenuItem( value: OrderStatus.cancelled, child: Text('Cancelado'), ), ], onChanged: (value) => setState(() => _status = value ?? OrderStatus.notDelivered), ), const SizedBox(height: 12), DropdownButtonFormField( initialValue: _reason, decoration: const InputDecoration(labelText: 'Motivo'), items: const [ DropdownMenuItem( value: 'Cliente ausente', child: Text('Cliente ausente'), ), DropdownMenuItem( value: 'Dirección incorrecta', child: Text('Dirección incorrecta'), ), DropdownMenuItem( value: 'Cliente rechazó el pedido', child: Text('Cliente rechazó el pedido'), ), DropdownMenuItem(value: 'Otro', child: Text('Otro')), ], onChanged: (value) => setState(() => _reason = value ?? 'Otro'), ), const SizedBox(height: 12), TextField( controller: _observation, minLines: 2, maxLines: 4, decoration: const InputDecoration( labelText: 'Motivo u observación', hintText: 'Ejemplo: cliente ausente', ), ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancelar'), ), FilledButton( onPressed: () { final detail = _observation.text.trim(); final observation = detail.isEmpty || _reason == 'Otro' ? (detail.isEmpty ? _reason : detail) : '$_reason: $detail'; if (observation.length < 3) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Ingresa un motivo.'))); return; } Navigator.pop( context, _OutcomeData(status: _status, observation: observation), ); }, child: const Text('Guardar'), ), ], ); } class _PaymentData { const _PaymentData({ required this.method, required this.receivedAmount, this.reference, this.observation, }); final PaymentMethod method; final double receivedAmount; final String? reference; final String? observation; } class _PaymentDialog extends StatefulWidget { const _PaymentDialog({required this.total}); final double total; @override State<_PaymentDialog> createState() => _PaymentDialogState(); } class _PaymentDialogState extends State<_PaymentDialog> { PaymentMethod _method = PaymentMethod.cash; late final TextEditingController _amount = TextEditingController( text: widget.total.toStringAsFixed(2), ); final _reference = TextEditingController(); final _observation = TextEditingController(); @override void dispose() { _amount.dispose(); _reference.dispose(); _observation.dispose(); super.dispose(); } @override Widget build(BuildContext context) => AlertDialog( title: const Text('Registrar cobro'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ DropdownButtonFormField( initialValue: _method, decoration: const InputDecoration(labelText: 'Método de pago'), items: const [ DropdownMenuItem( value: PaymentMethod.cash, child: Text('Efectivo'), ), DropdownMenuItem(value: PaymentMethod.yape, child: Text('Yape')), DropdownMenuItem(value: PaymentMethod.plin, child: Text('Plin')), DropdownMenuItem( value: PaymentMethod.transfer, child: Text('Transferencia'), ), DropdownMenuItem( value: PaymentMethod.credit, child: Text('Crédito autorizado'), ), DropdownMenuItem( value: PaymentMethod.notCollected, child: Text('No cobrado'), ), ], onChanged: (value) => setState(() => _method = value ?? PaymentMethod.cash), ), const SizedBox(height: 12), if (_method != PaymentMethod.notCollected) TextField( controller: _amount, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), decoration: const InputDecoration(labelText: 'Monto recibido'), ), const SizedBox(height: 8), ValueListenableBuilder( valueListenable: _amount, builder: (_, value, _) { final amount = double.tryParse(value.text.replaceAll(',', '.')) ?? 0; final change = amount - widget.total; return Align( alignment: Alignment.centerLeft, child: Text( 'Vuelto: S/ ${change > 0 ? change.toStringAsFixed(2) : '0.00'}', style: TextStyle( fontWeight: FontWeight.w700, color: change >= 0 ? Colors.green.shade700 : Colors.red.shade700, ), ), ); }, ), const SizedBox(height: 12), TextField( controller: _reference, decoration: InputDecoration( labelText: _method == PaymentMethod.credit || _method == PaymentMethod.notCollected ? 'Referencia' : 'Referencia', ), ), if (_method == PaymentMethod.notCollected) TextField( controller: _observation, maxLines: 2, decoration: const InputDecoration( labelText: 'Motivo del no cobro', ), ), if ({ PaymentMethod.yape, PaymentMethod.plin, PaymentMethod.transfer, }.contains(_method)) const Padding( padding: EdgeInsets.only(top: 12), child: Text( 'Al continuar se abrirá la cámara para fotografiar el comprobante.', style: TextStyle(fontSize: 12), ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancelar'), ), FilledButton( onPressed: () { final amount = double.tryParse(_amount.text.replaceAll(',', '.')); if (_method != PaymentMethod.notCollected && (amount == null || amount <= 0)) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Ingresa un monto válido.')), ); return; } if (_method != PaymentMethod.notCollected && amount! < widget.total) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'El monto no puede ser menor al total del pedido.', ), ), ); return; } if (_method == PaymentMethod.notCollected && _observation.text.trim().length < 3) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Indica el motivo del no cobro.')), ); return; } if ({ PaymentMethod.yape, PaymentMethod.plin, PaymentMethod.transfer, }.contains(_method) && _reference.text.trim().isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Ingresa la referencia del pago digital.'), ), ); return; } Navigator.pop( context, _PaymentData( method: _method, receivedAmount: _method == PaymentMethod.notCollected ? 0 : amount!, reference: _reference.text.trim(), observation: _observation.text.trim(), ), ); }, child: const Text('Continuar'), ), ], ); }