import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; import 'package:intl/intl.dart'; import 'package:uuid/uuid.dart'; import '../data/app_database.dart'; import '../models/work_order.dart'; import '../services/email_service.dart'; import '../services/dictation_text_accumulator.dart'; import '../services/image_storage_service.dart'; import '../services/pdf_service.dart'; import '../services/speech_dictation_service.dart'; import 'pdf_preview_screen.dart'; const _predefinedWorkOrderTitles = [ 'Instalación Básica', 'Mantención Preventiva', 'Mantención Correctiva', 'Reubicación Equipo', ]; const _predefinedTaskTitles = [ 'Cambiar mecanismos del estanque del WC', 'Cambiar sifones y desagües', 'Cambiar válvulas, flexibles y conexiones', 'Conectar cocinas, hornos o calefont', 'Detectar fugas de gas', 'Instalar lavamanos, lavaplatos o lavaderos', 'Instalar o cambiar calefont', 'Instalar puntos adicionales de agua', 'Instalar termos eléctricos', 'Instalar y cambiar llaves de paso', 'Instalar y reparar inodoros', 'Mantención de calefont', 'Realizar mantenimiento preventivo', 'Reparar tuberías y conexiones de gas', 'Reparar WC que pierde agua o no descarga', ]; enum _TitleInputMode { manual, predefined } final _clpFormat = NumberFormat.decimalPattern('es_CL'); String _formatClp(int value) => '\$${_clpFormat.format(value)}'; int _parsePrice(String value) { return int.tryParse(value.replaceAll(RegExp(r'[^0-9]'), '')) ?? 0; } class WorkOrderScreen extends StatefulWidget { const WorkOrderScreen({required this.details, this.order, super.key}); final WorkOrderDetails details; final WorkOrder? order; @override State createState() => _WorkOrderScreenState(); } class _WorkOrderScreenState extends State { final _database = AppDatabase.instance; final _imagePicker = ImagePicker(); final _imageStorage = ImageStorageService(); final _pdfService = PdfService(); final _emailService = EmailService(); final _dictationService = SpeechDictationService.instance; final _dictationText = DictationTextAccumulator(); final _uuid = const Uuid(); final _formKey = GlobalKey(); late final String _workOrderId; late final TextEditingController _titleController; late final TextEditingController _laborCostController; late _TitleInputMode _titleInputMode; late String _manualTitle; String? _selectedPredefinedTitle; late List<_TaskDraft> _tasks; late Set _originalImagePaths; final Set _newImagePaths = {}; WorkOrder? _order; String? _dictatingTaskId; bool _dictationButtonHeld = false; bool _dictationStarting = false; bool _dictationRestarting = false; bool _suppressDictationRestart = false; bool _dirty = false; bool _busy = false; bool get _readOnly => _order?.isCompleted ?? false; @override void initState() { super.initState(); _order = widget.order; _dirty = widget.order != null && (widget.order!.details.client != widget.details.client || widget.order!.details.phone != widget.details.phone || widget.order!.details.address != widget.details.address); _workOrderId = widget.order?.id ?? _uuid.v4(); final initialTitle = widget.order?.title ?? ''; _selectedPredefinedTitle = _predefinedWorkOrderTitles.contains(initialTitle) ? initialTitle : null; _titleInputMode = initialTitle.isEmpty || _selectedPredefinedTitle != null ? _TitleInputMode.predefined : _TitleInputMode.manual; _manualTitle = _titleInputMode == _TitleInputMode.manual ? initialTitle : ''; _titleController = TextEditingController(text: initialTitle); _titleController.addListener(_onTitleChanged); final initialLaborCost = widget.order?.laborCost ?? 0; _laborCostController = TextEditingController( text: initialLaborCost > 0 ? initialLaborCost.toString() : '', ); _laborCostController.addListener(_markDirty); _tasks = widget.order?.tasks .map((task) => _TaskDraft.fromTask(task, _markDirty)) .toList() ?? [_TaskDraft.empty(_uuid.v4(), _markDirty)]; _originalImagePaths = widget.order?.tasks .expand((task) => task.photos) .map((photo) => photo.path) .toSet() ?? {}; } @override void dispose() { unawaited(_dictationService.endSession()); _titleController.removeListener(_onTitleChanged); _titleController.dispose(); _laborCostController.removeListener(_markDirty); _laborCostController.dispose(); for (final task in _tasks) { task.dispose(); } super.dispose(); } void _markDirty() { if (!_readOnly && mounted && !_dirty) { setState(() => _dirty = true); } } void _onTitleChanged() { if (_titleInputMode == _TitleInputMode.manual) { _manualTitle = _titleController.text; } _markDirty(); } void _changeTitleInputMode(_TitleInputMode mode) { if (mode == _titleInputMode) return; final nextTitle = mode == _TitleInputMode.manual ? _manualTitle : _selectedPredefinedTitle ?? ''; setState(() => _titleInputMode = mode); _titleController.text = nextTitle; } void _toggleTitleInputMode() { _changeTitleInputMode( _titleInputMode == _TitleInputMode.predefined ? _TitleInputMode.manual : _TitleInputMode.predefined, ); } void _selectPredefinedTitle(String? title) { if (title == null) return; setState(() => _selectedPredefinedTitle = title); _titleController.text = title; } Future _startDictation(_TaskDraft task) async { if (_dictationStarting) return; _dictationButtonHeld = true; _dictationStarting = true; if (mounted) { setState(() => _dictatingTaskId = task.id); } try { if (_dictationService.isListening) { _suppressDictationRestart = true; try { await _dictationService.stop(); } finally { _suppressDictationRestart = false; } } final available = await _dictationService.initialize( onStatusChanged: _handleDictationStatus, onError: _handleDictationError, ); if (!available) { _dictationButtonHeld = false; if (mounted) setState(() => _dictatingTaskId = null); _showMessage( 'El dictado no está disponible. Revisa el permiso del micrófono.', ); return; } if (!_dictationButtonHeld || !mounted) { if (mounted) setState(() => _dictatingTaskId = null); return; } _dictationText.begin(task.observationController.text); setState(() => _dictatingTaskId = task.id); await _dictationService.listen(onResult: _handleDictationResult); } catch (_) { if (!mounted) return; _dictationButtonHeld = false; setState(() => _dictatingTaskId = null); _showMessage('No fue posible iniciar el dictado.'); } finally { _dictationStarting = false; } } Future _stopDictation() async { _dictationButtonHeld = false; if (_dictationService.isListening) { await _dictationService.stop(); } if (mounted && _dictatingTaskId != null) { setState(() => _dictatingTaskId = null); } } void _handleDictationResult(String words, bool isFinalResult) { final taskId = _dictatingTaskId; final recognizedWords = words.trim(); if (taskId == null || recognizedWords.isEmpty) return; _TaskDraft? activeTask; for (final task in _tasks) { if (task.id == taskId) { activeTask = task; break; } } if (activeTask == null) return; final updatedText = _dictationText.addResult(recognizedWords); activeTask.observationController.value = TextEditingValue( text: updatedText, selection: TextSelection.collapsed(offset: updatedText.length), ); } void _handleDictationStatus(bool isListening) { if (isListening || !mounted) return; final taskId = _dictatingTaskId; if (taskId == null) return; if (_dictationButtonHeld && !_suppressDictationRestart) { unawaited(_restartDictationAfterPause(taskId)); } } Future _restartDictationAfterPause(String taskId) async { if (_dictationRestarting) return; _dictationRestarting = true; try { await Future.delayed(const Duration(milliseconds: 800)); if (!_dictationButtonHeld || !mounted || _dictatingTaskId != taskId) { return; } _TaskDraft? activeTask; for (final task in _tasks) { if (task.id == taskId) { activeTask = task; break; } } if (activeTask == null) return; _dictationText.begin(activeTask.observationController.text); await _dictationService.listen(onResult: _handleDictationResult); } catch (_) { if (!mounted) return; _dictationButtonHeld = false; setState(() => _dictatingTaskId = null); _showMessage( 'El dictado se interrumpió. Mantén presionado para intentar nuevamente.', ); } finally { _dictationRestarting = false; } } void _handleDictationError(String errorCode) { if (!mounted) return; final transientError = errorCode == 'error_no_match' || errorCode == 'error_speech_timeout'; if (transientError && _dictationButtonHeld && _dictatingTaskId != null) { return; } _dictationButtonHeld = false; if (_dictatingTaskId != null) { setState(() => _dictatingTaskId = null); } final message = switch (errorCode) { 'error_permission' || 'error_permission_denied' => 'Autoriza el micrófono para usar el dictado.', 'error_no_match' || 'error_speech_timeout' => 'No se detectó una opción. Intenta nuevamente.', _ => 'El dictado se interrumpió. Intenta nuevamente.', }; _showMessage(message); } void _addTask() { setState(() { _tasks.add(_TaskDraft.empty(_uuid.v4(), _markDirty)); _dirty = true; }); } Future _removeTask(int index) async { final task = _tasks[index]; final hasContent = task.titleController.text.trim().isNotEmpty || task.observationController.text.trim().isNotEmpty || task.photos.isNotEmpty || task.parts.isNotEmpty; if (hasContent) { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Quitar tarea'), content: const Text( 'La tarea y sus fotografías se quitarán de esta orden.', ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Cancelar'), ), FilledButton( onPressed: () => Navigator.pop(context, true), child: const Text('Quitar'), ), ], ), ); if (confirmed != true) return; } if (_dictatingTaskId == task.id) { await _stopDictation(); if (!mounted) return; } setState(() { final removed = _tasks.removeAt(index); removed.dispose(); _dirty = true; }); } Future _choosePhotos(_TaskDraft task, PhotoKind kind) async { final source = await showModalBottomSheet( context: context, showDragHandle: true, builder: (context) => SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Agregar evidencia', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w800), ), const SizedBox(height: 6), const Text( 'Puedes tomar una foto o seleccionar varias desde la galería.', style: TextStyle(color: Color(0xFF627372)), ), const SizedBox(height: 16), ListTile( contentPadding: EdgeInsets.zero, leading: const _SourceIcon(icon: Icons.photo_camera_outlined), title: const Text('Tomar fotografía'), subtitle: const Text('Usar la cámara del dispositivo'), onTap: () => Navigator.pop(context, ImageSource.camera), ), ListTile( contentPadding: EdgeInsets.zero, leading: const _SourceIcon(icon: Icons.photo_library_outlined), title: const Text('Elegir de la galería'), subtitle: const Text('Seleccionar una o varias imágenes'), onTap: () => Navigator.pop(context, ImageSource.gallery), ), ], ), ), ), ); if (source == null) return; try { setState(() => _busy = true); final selected = []; if (source == ImageSource.camera) { final photo = await _imagePicker.pickImage( source: source, maxWidth: 1920, imageQuality: 82, ); if (photo != null) selected.add(photo); } else { selected.addAll( await _imagePicker.pickMultiImage(maxWidth: 1920, imageQuality: 82), ); } for (final sourceFile in selected) { final storedPath = await _imageStorage.storeImage( sourcePath: sourceFile.path, workOrderId: _workOrderId, taskId: task.id, ); _newImagePaths.add(storedPath); final photo = TaskPhoto(id: _uuid.v4(), path: storedPath, kind: kind); if (kind == PhotoKind.before) { task.beforePhotos.add(photo); } else { task.afterPhotos.add(photo); } } if (!mounted) return; setState(() { if (selected.isNotEmpty) _dirty = true; _busy = false; }); } catch (_) { if (!mounted) return; setState(() => _busy = false); _showMessage('No fue posible agregar las fotografías.'); } } void _removePhoto(_TaskDraft task, TaskPhoto photo) { setState(() { task.beforePhotos.removeWhere((item) => item.id == photo.id); task.afterPhotos.removeWhere((item) => item.id == photo.id); _dirty = true; }); } void _addPart(_TaskDraft task) { setState(() { task.parts.add(_PartDraft.empty(_uuid.v4(), _markDirty)); _dirty = true; }); } void _removePart(_TaskDraft task, _PartDraft part) { setState(() { task.parts.remove(part); part.dispose(); _dirty = true; }); } bool _validateDraft() { if (!_formKey.currentState!.validate()) return false; if (_tasks.isEmpty) { _showMessage('Agrega al menos una tarea.'); return false; } for (var index = 0; index < _tasks.length; index++) { final task = _tasks[index]; if (task.titleController.text.trim().isEmpty) { _showMessage('Escribe el nombre de la tarea ${index + 1}.'); return false; } for (var partIndex = 0; partIndex < task.parts.length; partIndex++) { final part = task.parts[partIndex]; if (part.nameController.text.trim().isEmpty) { _showMessage( 'Escribe el nombre del repuesto ${partIndex + 1} ' 'de la tarea ${index + 1}.', ); return false; } if (_parsePrice(part.priceController.text) <= 0) { _showMessage( 'Ingresa un precio válido para el repuesto ${partIndex + 1} ' 'de la tarea ${index + 1}.', ); return false; } } } return true; } bool _validateCompletion() { if (!_validateDraft()) return false; for (var index = 0; index < _tasks.length; index++) { final task = _tasks[index]; if (task.observationController.text.trim().isEmpty) { _showMessage('Registra la observación de la tarea ${index + 1}.'); return false; } if (task.beforePhotos.isEmpty) { _showMessage('Agrega una foto “antes” en la tarea ${index + 1}.'); return false; } if (task.afterPhotos.isEmpty) { _showMessage('Agrega una foto “después” en la tarea ${index + 1}.'); return false; } } return true; } WorkOrder _buildOrder({ required WorkOrderStatus status, String? recipientEmail, String? pdfPath, DateTime? completedAt, }) { final now = DateTime.now(); return WorkOrder( id: _workOrderId, title: _titleController.text.trim(), createdAt: _order?.createdAt ?? now, updatedAt: now, completedAt: completedAt ?? _order?.completedAt, status: status, details: widget.details, laborCost: _parsePrice(_laborCostController.text), recipientEmail: recipientEmail ?? _order?.recipientEmail, pdfPath: pdfPath ?? _order?.pdfPath, tasks: [ for (var index = 0; index < _tasks.length; index++) WorkTask( id: _tasks[index].id, title: _tasks[index].titleController.text.trim(), observation: _tasks[index].observationController.text.trim(), position: index, photos: _tasks[index].photos, parts: [ for (final part in _tasks[index].parts) TaskPart( id: part.id, name: part.nameController.text.trim(), price: _parsePrice(part.priceController.text), ), ], ), ], ); } Future _saveDraft({bool showConfirmation = true}) async { if (!_validateDraft()) return null; final draft = _buildOrder(status: WorkOrderStatus.open); try { setState(() => _busy = true); await _database.saveWorkOrder(draft); await _cleanupUnusedImages(); if (!mounted) return null; setState(() { _order = draft; _dirty = false; _busy = false; }); if (showConfirmation) { _showMessage('Borrador guardado en el dispositivo.'); } return draft; } catch (_) { if (!mounted) return null; setState(() => _busy = false); _showMessage('No fue posible guardar el borrador.'); return null; } } Future _finalize() async { if (!_validateCompletion()) return; final reportType = await _requestPdfReportType(); if (reportType == null || !mounted) return; final recipient = await _requestEmail(_order?.recipientEmail); if (recipient == null || !mounted) return; setState(() => _busy = true); try { final completionTime = DateTime.now(); final reportOrder = _buildOrder( status: WorkOrderStatus.completed, recipientEmail: recipient, completedAt: completionTime, ); final pdfPath = await _pdfService.generate(reportOrder, type: reportType); final completedOrder = reportOrder.copyWith( updatedAt: DateTime.now(), pdfPath: pdfPath, ); await _database.saveWorkOrder(completedOrder); await _cleanupUnusedImages(); if (!mounted) return; setState(() { _order = completedOrder; _dirty = false; }); try { await _emailService.sendReport( order: completedOrder, recipient: recipient, pdfPath: pdfPath, reportType: reportType, ); if (!mounted) return; _showMessage('OT finalizada. El correo quedó listo para enviar.'); } catch (_) { if (!mounted) return; _showMessage( 'La OT y el PDF se guardaron, pero no se encontró una aplicación ' 'de correo configurada.', ); } } catch (_) { if (!mounted) return; _showMessage('No fue posible finalizar la orden ni crear el PDF.'); } finally { if (mounted) setState(() => _busy = false); } } Future _ensurePdf(PdfReportType reportType) async { final order = _order; if (order == null) return null; final existingPath = order.pdfPath; if (existingPath != null && _pdfService.isCurrentReport(existingPath, type: reportType) && await File(existingPath).exists()) { return existingPath; } final path = await _pdfService.generate(order, type: reportType); final updatedOrder = order.copyWith( updatedAt: DateTime.now(), pdfPath: path, ); await _database.saveWorkOrder(updatedOrder); if (mounted) setState(() => _order = updatedOrder); return path; } Future _openPdf() async { final reportType = await _requestPdfReportType(); if (reportType == null || !mounted) return; try { setState(() => _busy = true); final path = await _ensurePdf(reportType); if (!mounted || path == null) return; setState(() => _busy = false); await Navigator.of(context).push( MaterialPageRoute( builder: (_) => PdfPreviewScreen( pdfPath: path, title: '${_order!.title} - ${reportType.label}', ), ), ); } catch (_) { if (!mounted) return; _showMessage('No fue posible abrir el informe PDF.'); } finally { if (mounted) setState(() => _busy = false); } } Future _sendExistingReport() async { final currentOrder = _order; if (currentOrder == null) return; final reportType = await _requestPdfReportType(); if (reportType == null || !mounted) return; final recipient = await _requestEmail(currentOrder.recipientEmail); if (recipient == null || !mounted) return; try { setState(() => _busy = true); final path = await _ensurePdf(reportType); if (path == null) return; final updatedOrder = _order!.copyWith( updatedAt: DateTime.now(), recipientEmail: recipient, ); await _database.saveWorkOrder(updatedOrder); if (mounted) setState(() => _order = updatedOrder); await _emailService.sendReport( order: updatedOrder, recipient: recipient, pdfPath: path, reportType: reportType, ); if (!mounted) return; _showMessage('El correo quedó listo para enviar.'); } catch (_) { if (!mounted) return; _showMessage( 'No se pudo abrir el correo. Verifica que exista una cuenta configurada.', ); } finally { if (mounted) setState(() => _busy = false); } } Future _requestEmail(String? initialValue) async { return showDialog( context: context, builder: (context) => EmailRecipientDialog(initialValue: initialValue), ); } Future _requestPdfReportType() { return showDialog( context: context, builder: (context) => const PdfReportTypeDialog(), ); } Future _cleanupUnusedImages() async { final currentPaths = _tasks .expand((task) => task.photos) .map((photo) => photo.path) .toSet(); final unusedPaths = { ..._originalImagePaths.difference(currentPaths), ..._newImagePaths.difference(currentPaths), }; await _imageStorage.deleteFiles(unusedPaths); _originalImagePaths = currentPaths; _newImagePaths.clear(); } Future _handleUnsavedExit() async { final action = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Cambios sin guardar'), content: const Text( 'Puedes guardar la orden como borrador para continuar más tarde.', ), actions: [ TextButton( onPressed: () => Navigator.pop(context, 'cancel'), child: const Text('Seguir editando'), ), TextButton( onPressed: () => Navigator.pop(context, 'discard'), child: const Text('Descartar'), ), FilledButton( onPressed: () => Navigator.pop(context, 'save'), child: const Text('Guardar'), ), ], ), ); if (!mounted) return; if (action == 'discard') { await _imageStorage.deleteFiles(_newImagePaths); if (mounted) Navigator.pop(context); } else if (action == 'save') { final saved = await _saveDraft(showConfirmation: false); if (saved != null && mounted) Navigator.pop(context); } } void _showMessage(String message) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(SnackBar(content: Text(message))); } @override Widget build(BuildContext context) { return PopScope( canPop: !_dirty || _readOnly, onPopInvokedWithResult: (didPop, result) { if (!didPop) _handleUnsavedExit(); }, child: Scaffold( appBar: AppBar( title: Text( _readOnly ? 'Detalle de la OT' : widget.order == null ? 'Nueva orden' : 'Editar orden', ), actions: [ if (!_readOnly) IconButton( tooltip: 'Guardar borrador', onPressed: _busy ? null : _saveDraft, icon: const Icon(Icons.save_outlined), ), const SizedBox(width: 6), ], bottom: _busy ? const PreferredSize( preferredSize: Size.fromHeight(3), child: LinearProgressIndicator(minHeight: 3), ) : null, ), body: AbsorbPointer( absorbing: _busy, child: Form( key: _formKey, child: ListView( padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), children: [ if (_readOnly) ...[ _CompletedBanner(order: _order!), const SizedBox(height: 24), ], _OrderDetailsHeader(details: widget.details), const SizedBox(height: 24), if (_readOnly) TextFormField( controller: _titleController, readOnly: true, textCapitalization: TextCapitalization.sentences, textInputAction: TextInputAction.next, decoration: const InputDecoration( hintText: 'Ej. Mantención preventiva bomba N.º 2', ), validator: (value) => (value?.trim().isEmpty ?? true) ? 'Ingresa el título de la orden' : null, ) else Row( children: [ Expanded( child: _titleInputMode == _TitleInputMode.predefined ? DropdownButtonFormField( initialValue: _selectedPredefinedTitle, isExpanded: true, decoration: const InputDecoration( hintText: 'Selecciona una opción', ), items: [ for (final title in _predefinedWorkOrderTitles) DropdownMenuItem( value: title, child: Text(title), ), ], onChanged: _selectPredefinedTitle, validator: (value) => value == null ? 'Selecciona una opción para el título' : null, ) : TextFormField( controller: _titleController, autofocus: true, textCapitalization: TextCapitalization.sentences, textInputAction: TextInputAction.next, decoration: const InputDecoration( hintText: 'Escribe el título de la orden', ), validator: (value) => (value?.trim().isEmpty ?? true) ? 'Ingresa el título de la orden' : null, ), ), const SizedBox(width: 6), IconButton.filledTonal( tooltip: _titleInputMode == _TitleInputMode.predefined ? 'Escribir título de la orden' : 'Usar una opción predefinida', onPressed: _toggleTitleInputMode, icon: Icon( _titleInputMode == _TitleInputMode.predefined ? Icons.edit_outlined : Icons.format_quote_rounded, ), ), ], ), const SizedBox(height: 28), Row( children: [ const Expanded( child: _FieldLabel( title: 'Tareas', subtitle: 'Documenta cada actividad por separado.', ), ), if (!_readOnly) TextButton.icon( onPressed: _addTask, icon: const Icon(Icons.add_rounded), label: const Text('Agregar'), ), ], ), const SizedBox(height: 12), for (var index = 0; index < _tasks.length; index++) ...[ _TaskCard( key: ValueKey(_tasks[index].id), number: index + 1, task: _tasks[index], readOnly: _readOnly, isDictating: _dictatingTaskId == _tasks[index].id, canDelete: !_readOnly, onDelete: () => _removeTask(index), onStartDictation: () => _startDictation(_tasks[index]), onStopDictation: _stopDictation, onAddPhotos: (kind) => _choosePhotos(_tasks[index], kind), onRemovePhoto: (photo) => _removePhoto(_tasks[index], photo), onAddPart: () => _addPart(_tasks[index]), onRemovePart: (part) => _removePart(_tasks[index], part), ), const SizedBox(height: 16), ], if (!_readOnly) OutlinedButton.icon( onPressed: _addTask, icon: const Icon(Icons.add_task_rounded), label: const Text('Agregar otra tarea'), ), const SizedBox(height: 24), const Divider(color: Color(0xFFE2EAE9)), const SizedBox(height: 16), TextFormField( key: const Key('labor-cost-field'), controller: _laborCostController, readOnly: _readOnly, keyboardType: TextInputType.number, textInputAction: TextInputAction.done, inputFormatters: [FilteringTextInputFormatter.digitsOnly], style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w700, ), decoration: const InputDecoration( labelText: 'Costo de mano de obra', helperText: 'Monto total asociado a toda la OT.', prefixText: r'$ ', prefixIcon: Icon(Icons.payments_outlined), ), validator: (value) => _parsePrice(value ?? '') <= 0 ? 'Ingresa un costo de mano de obra válido' : null, ), ], ), ), ), bottomNavigationBar: _BottomActions( readOnly: _readOnly, busy: _busy, onSave: _saveDraft, onFinalize: _finalize, onOpenPdf: _openPdf, onSendEmail: _sendExistingReport, ), ), ); } } class _OrderDetailsHeader extends StatelessWidget { const _OrderDetailsHeader({required this.details}); final WorkOrderDetails details; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: const Color(0xFFE7F1F0), borderRadius: BorderRadius.circular(16), ), child: Column( children: [ _detailRow( icon: Icons.person_outline_rounded, label: 'Cliente', value: details.client, ), ], ), ); } Widget _detailRow({ required IconData icon, required String label, required String value, }) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, size: 21, color: const Color(0xFF0B5C5E)), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label.toUpperCase(), style: const TextStyle( color: Color(0xFF627372), fontSize: 10, fontWeight: FontWeight.w800, letterSpacing: .7, ), ), const SizedBox(height: 3), Text( value.isEmpty ? 'No informado' : value, style: const TextStyle( color: Color(0xFF163334), fontSize: 14, fontWeight: FontWeight.w700, ), ), ], ), ), ], ); } } class _TaskDraft { _TaskDraft({ required this.id, required this.titleController, required this.observationController, required this.beforePhotos, required this.afterPhotos, required this.parts, required VoidCallback onChanged, }) : _onChanged = onChanged { titleController.addListener(_onChanged); observationController.addListener(_onChanged); } factory _TaskDraft.empty(String id, VoidCallback onChanged) { return _TaskDraft( id: id, titleController: TextEditingController(), observationController: TextEditingController(), beforePhotos: [], afterPhotos: [], parts: [], onChanged: onChanged, ); } factory _TaskDraft.fromTask(WorkTask task, VoidCallback onChanged) { return _TaskDraft( id: task.id, titleController: TextEditingController(text: task.title), observationController: TextEditingController(text: task.observation), beforePhotos: List.of(task.beforePhotos), afterPhotos: List.of(task.afterPhotos), parts: task.parts .map((part) => _PartDraft.fromPart(part, onChanged)) .toList(), onChanged: onChanged, ); } final String id; final TextEditingController titleController; final TextEditingController observationController; final List beforePhotos; final List afterPhotos; final List<_PartDraft> parts; final VoidCallback _onChanged; List get photos => [...beforePhotos, ...afterPhotos]; void dispose() { titleController ..removeListener(_onChanged) ..dispose(); observationController ..removeListener(_onChanged) ..dispose(); for (final part in parts) { part.dispose(); } } } class _PartDraft { _PartDraft({ required this.id, required this.nameController, required this.priceController, required VoidCallback onChanged, }) : _onChanged = onChanged { nameController.addListener(_onChanged); priceController.addListener(_onChanged); } factory _PartDraft.empty(String id, VoidCallback onChanged) { return _PartDraft( id: id, nameController: TextEditingController(), priceController: TextEditingController(), onChanged: onChanged, ); } factory _PartDraft.fromPart(TaskPart part, VoidCallback onChanged) { return _PartDraft( id: part.id, nameController: TextEditingController(text: part.name), priceController: TextEditingController(text: part.price.toString()), onChanged: onChanged, ); } final String id; final TextEditingController nameController; final TextEditingController priceController; final VoidCallback _onChanged; int get price => _parsePrice(priceController.text); void dispose() { nameController ..removeListener(_onChanged) ..dispose(); priceController ..removeListener(_onChanged) ..dispose(); } } class _CompletedBanner extends StatelessWidget { const _CompletedBanner({required this.order}); final WorkOrder order; @override Widget build(BuildContext context) { final date = order.completedAt ?? order.updatedAt; return _StatusBanner( icon: Icons.task_alt_rounded, color: const Color(0xFF24755E), background: const Color(0xFFE4F3ED), title: 'Orden finalizada', message: '${DateFormat('dd/MM/yyyy HH:mm').format(date.toLocal())} · ' '${order.tasks.length} tarea(s) · ${order.totalPhotos} foto(s)', ); } } class _StatusBanner extends StatelessWidget { const _StatusBanner({ required this.icon, required this.color, required this.background, required this.title, required this.message, }); final IconData icon; final Color color; final Color background; final String title; final String message; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: background, borderRadius: BorderRadius.circular(16), ), child: Row( children: [ Icon(icon, color: color, size: 27), const SizedBox(width: 13), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: TextStyle(color: color, fontWeight: FontWeight.w800), ), const SizedBox(height: 3), Text( message, style: const TextStyle( color: Color(0xFF4E6260), height: 1.35, fontSize: 12, ), ), ], ), ), ], ), ); } } class _FieldLabel extends StatelessWidget { const _FieldLabel({required this.title, required this.subtitle}); final String title; final String subtitle; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: const TextStyle( color: Color(0xFF163334), fontSize: 17, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 3), Text( subtitle, style: const TextStyle(color: Color(0xFF6A7A79), fontSize: 12), ), ], ); } } class _TaskTitleField extends StatefulWidget { const _TaskTitleField({required this.task, required this.readOnly}); final _TaskDraft task; final bool readOnly; @override State<_TaskTitleField> createState() => _TaskTitleFieldState(); } class _TaskTitleFieldState extends State<_TaskTitleField> { late _TitleInputMode _inputMode; late String _manualTitle; String? _selectedPredefinedTitle; @override void initState() { super.initState(); final initialTitle = widget.task.titleController.text; _selectedPredefinedTitle = _predefinedTaskTitles.contains(initialTitle) ? initialTitle : null; _inputMode = initialTitle.isEmpty || _selectedPredefinedTitle != null ? _TitleInputMode.predefined : _TitleInputMode.manual; _manualTitle = _inputMode == _TitleInputMode.manual ? initialTitle : ''; } void _toggleInputMode() { final nextMode = _inputMode == _TitleInputMode.predefined ? _TitleInputMode.manual : _TitleInputMode.predefined; final nextTitle = nextMode == _TitleInputMode.manual ? _manualTitle : _selectedPredefinedTitle ?? ''; setState(() => _inputMode = nextMode); widget.task.titleController.text = nextTitle; } void _selectPredefinedTitle(String? title) { if (title == null) return; setState(() => _selectedPredefinedTitle = title); widget.task.titleController.text = title; } @override Widget build(BuildContext context) { if (widget.readOnly) { return TextFormField( controller: widget.task.titleController, readOnly: true, decoration: const InputDecoration(labelText: 'Tarea realizada'), ); } return Row( children: [ Expanded( child: _inputMode == _TitleInputMode.predefined ? DropdownButtonFormField( initialValue: _selectedPredefinedTitle, isExpanded: true, decoration: const InputDecoration( hintText: 'Selecciona una opción', ), items: [ for (final title in _predefinedTaskTitles) DropdownMenuItem(value: title, child: Text(title)), ], onChanged: _selectPredefinedTitle, ) : TextFormField( controller: widget.task.titleController, autofocus: true, textCapitalization: TextCapitalization.sentences, onChanged: (value) => _manualTitle = value, decoration: const InputDecoration( hintText: 'Escribe el título de la tarea', ), ), ), const SizedBox(width: 6), IconButton.filledTonal( tooltip: _inputMode == _TitleInputMode.predefined ? 'Escribir título de la tarea' : 'Usar una opción predefinida', onPressed: _toggleInputMode, icon: Icon( _inputMode == _TitleInputMode.predefined ? Icons.edit_outlined : Icons.format_quote_rounded, ), ), ], ); } } class _HoldToDictateButton extends StatelessWidget { const _HoldToDictateButton({ required this.isDictating, required this.onStart, required this.onStop, }); final bool isDictating; final VoidCallback onStart; final VoidCallback onStop; @override Widget build(BuildContext context) { return Semantics( button: true, label: 'Mantén presionado para dictar la observación', child: Listener( behavior: HitTestBehavior.opaque, onPointerDown: (_) => onStart(), onPointerUp: (_) => onStop(), onPointerCancel: (_) => onStop(), child: SizedBox( width: 48, height: 48, child: AnimatedContainer( duration: const Duration(milliseconds: 150), decoration: BoxDecoration( color: isDictating ? const Color(0xFFFFDAD6) : const Color(0xFFD9EEEE), shape: BoxShape.circle, ), alignment: Alignment.center, child: Icon( Icons.mic_rounded, color: isDictating ? const Color(0xFFB3261E) : const Color(0xFF0B5C5E), ), ), ), ), ); } } class _TaskCard extends StatelessWidget { const _TaskCard({ required this.number, required this.task, required this.readOnly, required this.isDictating, required this.canDelete, required this.onDelete, required this.onStartDictation, required this.onStopDictation, required this.onAddPhotos, required this.onRemovePhoto, required this.onAddPart, required this.onRemovePart, super.key, }); final int number; final _TaskDraft task; final bool readOnly; final bool isDictating; final bool canDelete; final VoidCallback onDelete; final VoidCallback onStartDictation; final VoidCallback onStopDictation; final ValueChanged onAddPhotos; final ValueChanged onRemovePhoto; final VoidCallback onAddPart; final ValueChanged<_PartDraft> onRemovePart; @override Widget build(BuildContext context) { return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 32, height: 32, alignment: Alignment.center, decoration: const BoxDecoration( color: Color(0xFF0B5C5E), shape: BoxShape.circle, ), child: Text( '$number', style: const TextStyle( color: Colors.white, fontWeight: FontWeight.w800, ), ), ), const SizedBox(width: 10), const Expanded( child: Text( 'Tarea', style: TextStyle( color: Color(0xFF163334), fontSize: 16, fontWeight: FontWeight.w800, ), ), ), if (canDelete) IconButton( tooltip: 'Quitar tarea', onPressed: onDelete, color: const Color(0xFF8C4A43), icon: const Icon(Icons.delete_outline_rounded), ), ], ), const SizedBox(height: 14), _TaskTitleField(task: task, readOnly: readOnly), const SizedBox(height: 13), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: TextFormField( controller: task.observationController, readOnly: readOnly, textCapitalization: TextCapitalization.sentences, keyboardType: TextInputType.multiline, autocorrect: true, enableSuggestions: true, spellCheckConfiguration: const SpellCheckConfiguration(), minLines: 3, maxLines: 5, decoration: const InputDecoration( labelText: 'Observación', hintText: 'Describe el hallazgo y el trabajo ejecutado', ), ), ), if (!readOnly) ...[ const SizedBox(width: 8), Padding( padding: const EdgeInsets.only(top: 8), child: _HoldToDictateButton( isDictating: isDictating, onStart: onStartDictation, onStop: onStopDictation, ), ), ], ], ), const SizedBox(height: 20), const Divider(color: Color(0xFFE2EAE9)), const SizedBox(height: 12), _PartsSection( parts: task.parts, readOnly: readOnly, onAdd: onAddPart, onRemove: onRemovePart, ), const SizedBox(height: 20), const Divider(color: Color(0xFFE2EAE9)), const SizedBox(height: 12), _PhotoSection( title: 'Antes de la mantención', hint: 'Estado inicial', icon: Icons.history_rounded, photos: task.beforePhotos, readOnly: readOnly, onAdd: () => onAddPhotos(PhotoKind.before), onRemove: onRemovePhoto, ), const SizedBox(height: 20), _PhotoSection( title: 'Después de la mantención', hint: 'Resultado final', icon: Icons.auto_awesome_rounded, photos: task.afterPhotos, readOnly: readOnly, onAdd: () => onAddPhotos(PhotoKind.after), onRemove: onRemovePhoto, ), ], ), ), ); } } class _PartsSection extends StatelessWidget { const _PartsSection({ required this.parts, required this.readOnly, required this.onAdd, required this.onRemove, }); final List<_PartDraft> parts; final bool readOnly; final VoidCallback onAdd; final ValueChanged<_PartDraft> onRemove; int get _subtotal => parts.fold(0, (total, part) => total + part.price); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon( Icons.inventory_2_outlined, color: Color(0xFF0B5C5E), size: 20, ), const SizedBox(width: 8), const Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Repuestos utilizados', style: TextStyle( color: Color(0xFF163334), fontSize: 18, fontWeight: FontWeight.w700, ), ), Text( 'Registra cada repuesto y su precio en pesos.', style: TextStyle(color: Color(0xFF71807F), fontSize: 11), ), ], ), ), if (!readOnly) IconButton.filledTonal( key: const Key('add-part-button'), tooltip: 'Agregar repuesto', onPressed: onAdd, icon: const Icon(Icons.add_rounded, size: 21), ), ], ), if (parts.isEmpty) Container( width: double.infinity, margin: const EdgeInsets.only(top: 10), padding: const EdgeInsets.symmetric(vertical: 18), decoration: BoxDecoration( color: const Color(0xFFF5F8F7), borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFDCE7E5)), ), child: const Text( 'Sin repuestos registrados', textAlign: TextAlign.center, style: TextStyle(color: Color(0xFF7A8988), fontSize: 12), ), ) else ...[ const SizedBox(height: 12), for (var index = 0; index < parts.length; index++) ...[ _PartInputRow( key: ValueKey(parts[index].id), number: index + 1, part: parts[index], readOnly: readOnly, onRemove: () => onRemove(parts[index]), ), if (index < parts.length - 1) const SizedBox(height: 10), ], const SizedBox(height: 12), Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 10), decoration: BoxDecoration( color: const Color(0xFFE7F1F0), borderRadius: BorderRadius.circular(11), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( 'Subtotal de repuestos', style: TextStyle( color: Color(0xFF526665), fontWeight: FontWeight.w700, ), ), AnimatedBuilder( animation: Listenable.merge( parts.map((part) => part.priceController), ), builder: (context, _) => Text( _formatClp(_subtotal), style: const TextStyle( color: Color(0xFF0B5C5E), fontSize: 16, fontWeight: FontWeight.w900, ), ), ), ], ), ), ], ], ); } } class _PartInputRow extends StatelessWidget { const _PartInputRow({ required this.number, required this.part, required this.readOnly, required this.onRemove, super.key, }); final int number; final _PartDraft part; final bool readOnly; final VoidCallback onRemove; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: TextFormField( key: Key('part-name-${part.id}'), controller: part.nameController, readOnly: readOnly, textCapitalization: TextCapitalization.sentences, textInputAction: TextInputAction.next, style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w400, ), decoration: InputDecoration( labelText: 'Repuesto $number', hintText: 'Nombre del repuesto', ), validator: (value) { if (value == null || value.trim().isEmpty) { return 'Ingresa el nombre'; } return null; }, ), ), if (!readOnly) ...[ const SizedBox(width: 4), IconButton( tooltip: 'Quitar repuesto', onPressed: onRemove, color: const Color(0xFF8C4A43), icon: const Icon(Icons.remove_circle_outline_rounded), ), ], ], ), const SizedBox(height: 8), TextFormField( key: Key('part-price-${part.id}'), controller: part.priceController, readOnly: readOnly, keyboardType: TextInputType.number, textInputAction: TextInputAction.done, inputFormatters: [FilteringTextInputFormatter.digitsOnly], style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), decoration: const InputDecoration( labelText: 'Precio', prefixText: r'$ ', ), validator: (value) { if (_parsePrice(value ?? '') <= 0) { return 'Precio inválido'; } return null; }, ), ], ); } } class _PhotoSection extends StatelessWidget { const _PhotoSection({ required this.title, required this.hint, required this.icon, required this.photos, required this.readOnly, required this.onAdd, required this.onRemove, }); final String title; final String hint; final IconData icon; final List photos; final bool readOnly; final VoidCallback onAdd; final ValueChanged onRemove; void _preview(BuildContext context, TaskPhoto photo) { showDialog( context: context, builder: (context) => Dialog( backgroundColor: Colors.black, insetPadding: const EdgeInsets.all(12), child: Stack( children: [ InteractiveViewer( minScale: .8, maxScale: 4, child: Image.file( File(photo.path), width: double.infinity, height: MediaQuery.sizeOf(context).height * .72, fit: BoxFit.contain, errorBuilder: (_, _, _) => const SizedBox( height: 260, child: Center( child: Text( 'Imagen no disponible', style: TextStyle(color: Colors.white), ), ), ), ), ), Positioned( right: 8, top: 8, child: IconButton.filled( onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close_rounded), ), ), ], ), ), ); } @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(icon, color: const Color(0xFF0B5C5E), size: 20), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: const TextStyle( color: Color(0xFF163334), fontWeight: FontWeight.w700, ), ), Text( '$hint · ${photos.length} foto(s)', style: const TextStyle( color: Color(0xFF71807F), fontSize: 11, ), ), ], ), ), if (!readOnly) IconButton.filledTonal( tooltip: 'Agregar fotografías', onPressed: onAdd, icon: const Icon(Icons.add_a_photo_outlined, size: 20), ), ], ), if (photos.isEmpty) Container( width: double.infinity, margin: const EdgeInsets.only(top: 10), padding: const EdgeInsets.symmetric(vertical: 18), decoration: BoxDecoration( color: const Color(0xFFF5F8F7), borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFDCE7E5)), ), child: const Text( 'Sin fotografías', textAlign: TextAlign.center, style: TextStyle(color: Color(0xFF7A8988), fontSize: 12), ), ) else GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: const EdgeInsets.only(top: 10), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, crossAxisSpacing: 8, mainAxisSpacing: 8, ), itemCount: photos.length, itemBuilder: (context, index) { final photo = photos[index]; return InkWell( onTap: () => _preview(context, photo), borderRadius: BorderRadius.circular(11), child: ClipRRect( borderRadius: BorderRadius.circular(11), child: Stack( fit: StackFit.expand, children: [ Image.file( File(photo.path), fit: BoxFit.cover, errorBuilder: (_, _, _) => Container( color: const Color(0xFFE7EEED), child: const Icon(Icons.broken_image_outlined), ), ), if (!readOnly) Positioned( right: 4, top: 4, child: InkWell( onTap: () => onRemove(photo), borderRadius: BorderRadius.circular(20), child: Container( padding: const EdgeInsets.all(4), decoration: const BoxDecoration( color: Color(0xCC172221), shape: BoxShape.circle, ), child: const Icon( Icons.close_rounded, color: Colors.white, size: 16, ), ), ), ), ], ), ), ); }, ), ], ); } } class _SourceIcon extends StatelessWidget { const _SourceIcon({required this.icon}); final IconData icon; @override Widget build(BuildContext context) { return Container( width: 44, height: 44, decoration: BoxDecoration( color: const Color(0xFFE7F1F0), borderRadius: BorderRadius.circular(12), ), child: Icon(icon, color: const Color(0xFF0B5C5E)), ); } } @visibleForTesting class PdfReportTypeDialog extends StatelessWidget { const PdfReportTypeDialog({super.key}); @override Widget build(BuildContext context) { return AlertDialog( title: const Text('Tipo de informe'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('Selecciona quién recibirá este informe.'), const SizedBox(height: 14), for (final type in PdfReportType.values) ...[ Card( margin: EdgeInsets.zero, clipBehavior: Clip.antiAlias, child: ListTile( key: Key('report-type-${type.fileSegment}'), leading: Icon( type == PdfReportType.client ? Icons.person_outline_rounded : Icons.verified_user_outlined, ), title: Text(type.label), subtitle: Text(type.description), trailing: const Icon(Icons.chevron_right_rounded), onTap: () => Navigator.pop(context, type), ), ), if (type != PdfReportType.values.last) const SizedBox(height: 10), ], ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancelar'), ), ], ); } } @visibleForTesting class EmailRecipientDialog extends StatefulWidget { const EmailRecipientDialog({this.initialValue, super.key}); final String? initialValue; @override State createState() => _EmailRecipientDialogState(); } class _EmailRecipientDialogState extends State { final _formKey = GlobalKey(); late final TextEditingController _controller; @override void initState() { super.initState(); _controller = TextEditingController(text: widget.initialValue ?? ''); } @override void dispose() { _controller.dispose(); super.dispose(); } void _submit() { if (_formKey.currentState!.validate()) { Navigator.pop(context, _controller.text.trim()); } } @override Widget build(BuildContext context) { return AlertDialog( title: const Text('Enviar informe'), content: Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'El PDF se adjuntará al correo con el resumen del trabajo.', ), const SizedBox(height: 16), TextFormField( controller: _controller, autofocus: true, keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.done, decoration: const InputDecoration( labelText: 'Correo destinatario', hintText: 'nombre@empresa.cl', prefixIcon: Icon(Icons.alternate_email_rounded), ), validator: (value) { final email = value?.trim() ?? ''; final valid = RegExp( r'^[^@\s]+@[^@\s]+\.[^@\s]+$', ).hasMatch(email); return valid ? null : 'Ingresa un correo válido'; }, onFieldSubmitted: (_) => _submit(), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancelar'), ), FilledButton.icon( onPressed: _submit, icon: const Icon(Icons.mail_outline_rounded), label: const Text('Continuar'), ), ], ); } } class _BottomActions extends StatelessWidget { const _BottomActions({ required this.readOnly, required this.busy, required this.onSave, required this.onFinalize, required this.onOpenPdf, required this.onSendEmail, }); final bool readOnly; final bool busy; final VoidCallback onSave; final VoidCallback onFinalize; final VoidCallback onOpenPdf; final VoidCallback onSendEmail; @override Widget build(BuildContext context) { return Container( padding: EdgeInsets.fromLTRB( 20, 12, 20, 12 + MediaQuery.paddingOf(context).bottom, ), decoration: const BoxDecoration( color: Colors.white, border: Border(top: BorderSide(color: Color(0xFFDCE7E5))), ), child: Row( children: [ Expanded( child: OutlinedButton.icon( onPressed: busy ? null : (readOnly ? onOpenPdf : onSave), icon: Icon( readOnly ? Icons.picture_as_pdf_outlined : Icons.save_outlined, ), label: Text(readOnly ? 'Ver PDF' : 'Guardar'), ), ), const SizedBox(width: 12), Expanded( child: FilledButton.icon( onPressed: busy ? null : (readOnly ? onSendEmail : onFinalize), icon: Icon( readOnly ? Icons.mail_outline_rounded : Icons.task_alt_rounded, ), label: Text(readOnly ? 'Enviar' : 'Finalizar OT'), ), ), ], ), ); } }