2147 lines
67 KiB
Dart
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'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|