OTMovilGasfiter/lib/screens/work_order_screen.dart
2026-08-02 15:50:25 -04:00

2147 lines
67 KiB
Dart

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<WorkOrderScreen> createState() => _WorkOrderScreenState();
}
class _WorkOrderScreenState extends State<WorkOrderScreen> {
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<FormState>();
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<String> _originalImagePaths;
final Set<String> _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<void> _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<void> _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<void> _restartDictationAfterPause(String taskId) async {
if (_dictationRestarting) return;
_dictationRestarting = true;
try {
await Future<void>.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<void> _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<bool>(
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<void> _choosePhotos(_TaskDraft task, PhotoKind kind) async {
final source = await showModalBottomSheet<ImageSource>(
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 = <XFile>[];
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<WorkOrder?> _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<void> _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<String?> _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<void> _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<void>(
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<void> _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<String?> _requestEmail(String? initialValue) async {
return showDialog<String>(
context: context,
builder: (context) => EmailRecipientDialog(initialValue: initialValue),
);
}
Future<PdfReportType?> _requestPdfReportType() {
return showDialog<PdfReportType>(
context: context,
builder: (context) => const PdfReportTypeDialog(),
);
}
Future<void> _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<void> _handleUnsavedExit() async {
final action = await showDialog<String>(
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<String>(
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<TaskPhoto> beforePhotos;
final List<TaskPhoto> afterPhotos;
final List<_PartDraft> parts;
final VoidCallback _onChanged;
List<TaskPhoto> 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<String>(
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<PhotoKind> onAddPhotos;
final ValueChanged<TaskPhoto> 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<TaskPhoto> photos;
final bool readOnly;
final VoidCallback onAdd;
final ValueChanged<TaskPhoto> onRemove;
void _preview(BuildContext context, TaskPhoto photo) {
showDialog<void>(
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<EmailRecipientDialog> createState() => _EmailRecipientDialogState();
}
class _EmailRecipientDialogState extends State<EmailRecipientDialog> {
final _formKey = GlobalKey<FormState>();
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'),
),
),
],
),
);
}
}