diff --git a/lib/content_views/task_list_page.dart b/lib/content_views/task_list_page.dart index cb5124a..67b6d17 100644 --- a/lib/content_views/task_list_page.dart +++ b/lib/content_views/task_list_page.dart @@ -3,7 +3,9 @@ import 'dart:convert'; import 'package:dart_mq/dart_mq.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:image/image.dart' as image_lib; import 'package:intl/intl.dart'; +import 'package:signature/signature.dart'; import 'package:stadtbote/content_views/utils/content_view_widget.dart'; import 'package:stadtbote/messages/pop_content_view_message.dart'; import 'package:stadtbote/model/job.dart'; @@ -14,21 +16,22 @@ import 'package:stadtbote/network_messages/accept_job_message.dart'; import 'package:stadtbote/network_messages/fin_task_message.dart'; import 'package:stadtbote/network_messages/fin_tour_message.dart'; import 'package:stadtbote/network_messages/pic_message.dart'; -import 'package:stadtbote/popups/tasks_popups/arrived_popup.dart'; -import 'package:stadtbote/popups/tasks_popups/commision_number_popup.dart'; -import 'package:stadtbote/popups/tasks_popups/pickup_delivery_status_popup.dart'; -import 'package:stadtbote/popups/tasks_popups/receipt_giver_popup.dart'; -import 'package:stadtbote/popups/tasks_popups/signature_popup.dart'; -import 'package:stadtbote/popups/tasks_popups/station_notice_popup.dart'; -import 'package:stadtbote/popups/tasks_popups/take_photos_popup.dart'; import 'package:stadtbote/tools/constants.dart'; import 'package:stadtbote/tools/messages/publish_payload_message.dart'; import '../main.dart'; import '../model/task_info.dart'; +import '../model/tour_task.dart'; import '../tools/app_localizations.dart'; import '../tools/util.dart'; +/// Seiten-Wizard zur Bearbeitung der Aufgaben einer Tour. Pro Aufgabe wird +/// genau eine Seite angezeigt; mit „Zurück"/„Weiter" navigiert der Bearbeiter +/// zwischen den Aufgaben, die letzte Seite schließt die Tour ab. +/// +///

Eine bereits erledigte und nicht erneut bearbeitbare Aufgabe wird +/// schreibgeschützt dargestellt. Ist die Eingabe einer Aufgabe nicht optional, +/// bleibt „Weiter" deaktiviert, bis die Daten gültig erfasst sind. class TaskListPage extends ContentViewWidget { const TaskListPage({super.key, required this.job, required this.tour, this.readOnly = false}); @@ -50,12 +53,17 @@ class TaskListPage extends ContentViewWidget { } class _TaskListPageState extends State with ProducerMixin { - late bool _initCompleted = false; + bool _initCompleted = false; late List _taskList; late String _commisionNumber; + int _index = 0; + + /// Erfassungs-Zustand je Aufgabe (Schlüssel: s_id). + final Map _drafts = {}; + @override void initState() { Future.delayed(const Duration(seconds: 0), () async { @@ -63,6 +71,12 @@ class _TaskListPageState extends State with ProducerMixin { _commisionNumber = widget.tour.com ?? ""; + // Auf der ersten noch nicht erledigten Aufgabe starten. + if (!widget.readOnly) { + final first = _taskList.indexWhere((t) => !t.completed); + _index = first >= 0 ? first : (_taskList.isEmpty ? 0 : _taskList.length - 1); + } + _initCompleted = true; if (mounted) { @@ -74,133 +88,17 @@ class _TaskListPageState extends State with ProducerMixin { } @override - Widget build(BuildContext context) { - if (!_initCompleted) { - return Util.getWaitWidget(context); + void dispose() { + for (final draft in _drafts.values) { + draft.dispose(); } - - var steps = _initSteps(); - - return Padding( - padding: const EdgeInsets.all(8.0), - child: SingleChildScrollView( - scrollDirection: Axis.vertical, - padding: const EdgeInsets.all(0), - child: AbsorbPointer( - absorbing: widget.readOnly, - child: Column( - children: [ - Visibility( - visible: _commisionNumber.isNotEmpty, - child: Row( - children: [ - Text('${'commission_number_short'.tr}: $_commisionNumber', style: const TextStyle(fontSize: 20), textAlign: TextAlign.left), - ], - ), - ), - const SizedBox(height: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: steps, - ), - ], - ), - ), - ), - ); - } - - List _initSteps() { - List steps = []; - for (int i=0; i<_taskList.length; i++) { - var taskInfo = _taskList[i]; - - var textColor = isTaskActive(i) ? Colors.black : const Color(0x33000000); - - var optional = (taskInfo.task?.opt ?? false) == true; - steps.add( - ListTile( - minVerticalPadding: 0, - title: Container( - padding: const EdgeInsets.all(0), - height: 60, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Stack( - children: [ - Positioned( - left: 16.5, - top: i == 0 ? 40 : 40, - child: Container( - height: i == widget.tour.tasks!.length - 1 ? 0 : 20, - width: 6, - color: isTaskActive(i) ? Constants.colorPrimary : Constants.colorPrimaryOpaque, - ), - ), - Column( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isTaskActive(i) ? Constants.colorPrimary : Constants.colorPrimaryOpaque, - ), - child: const Icon(Icons.check, size: 30.0, color: Colors.white), - ), - ], - ), - ], - ), - const SizedBox(width: 20), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Text( - (taskInfo.task?.topic ?? '') + (optional ? "" : " *"), - textAlign: TextAlign.left, - style: TextStyle( - color: textColor, - fontSize: 15.0, - ), - ), - const SizedBox(height: 5), - SizedBox( - width: MediaQuery.of(context).size.width * 0.7, - child: Text( - taskInfo.additionalText ?? '', - textAlign: TextAlign.left, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: textColor, - fontSize: 12.0, - ), - ), - ), - ], - ), - ], - ), - ), - onTap: () { - if (isTaskActive(i)) { - _processTask(taskInfo); - } - }, - ), - ); - } - - return steps; + super.dispose(); } Future> _fetchTaskList() async { List taskList = []; - for(var task in widget.tour.tasks!) { + for (var task in widget.tour.tasks!) { final box = MyApp.objectBoxStore.box(); final query = box.query(TaskInfo_.sId.equals(task.sId)).build(); var taskInfo = query.findFirst(); @@ -220,277 +118,785 @@ class _TaskListPageState extends State with ProducerMixin { return taskList; } - void _processTask(TaskInfo taskInfo) { - switch(taskInfo.task?.type ?? 0) { + // --------------------------------------------------------------------------- + // Aufbau der Seite + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + if (!_initCompleted) { + return Util.getWaitWidget(context); + } + + if (_taskList.isEmpty) { + return const Center(child: Text("—")); + } + + final taskInfo = _taskList[_index]; + final readOnly = _isPageReadOnly(taskInfo); + final isLast = _index == _taskList.length - 1; + final canProceed = _canProceed(taskInfo); + + return Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Visibility( + visible: _commisionNumber.isNotEmpty, + child: Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Text( + '${'commission_number_short'.tr}: $_commisionNumber', + style: const TextStyle(fontSize: 18), + ), + ), + ), + Text( + '${'task'.tr} ${_index + 1} / ${_taskList.length}', + style: const TextStyle(fontSize: 13, color: Colors.grey), + ), + const SizedBox(height: 4), + LinearProgressIndicator( + value: (_index + 1) / _taskList.length, + color: Constants.colorPrimary, + backgroundColor: Constants.colorPrimaryOpaque, + ), + const SizedBox(height: 12), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + (taskInfo.task?.topic ?? '') + ((taskInfo.task?.opt ?? false) ? "" : " *"), + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + ), + if (taskInfo.completed) + const Icon(Icons.check_circle, color: Constants.colorPrimary), + ], + ), + if ((taskInfo.task?.txt ?? '').isNotEmpty) ...[ + const SizedBox(height: 8), + Text(taskInfo.task!.txt!, style: const TextStyle(fontSize: 15, color: Colors.black87)), + ], + const SizedBox(height: 16), + readOnly ? _buildReadOnlyBody(taskInfo) : _buildEditableBody(taskInfo), + ], + ), + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _navButton( + 'back'.tr, + _index > 0 ? () => setState(() => _index--) : null, + primary: false, + ), + ), + const SizedBox(width: 12), + Expanded( + child: isLast + ? (widget.readOnly + ? _navButton('close'.tr, () => _popBack()) + : _navButton('finish'.tr, canProceed ? () => _onFinish() : null)) + : _navButton('next'.tr, canProceed ? () => _onNext() : null), + ), + ], + ), + ], + ), + ); + } + + Widget _navButton(String text, VoidCallback? onPressed, {bool primary = true}) { + final enabled = onPressed != null; + return SizedBox( + height: 52, + child: MaterialButton( + color: primary ? Constants.colorPrimary : const Color.fromARGB(255, 220, 220, 220), + disabledColor: const Color.fromARGB(255, 230, 230, 230), + elevation: 3, + disabledElevation: 0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15))), + onPressed: onPressed, + child: Text( + text, + style: TextStyle( + fontSize: 16, + color: enabled ? (primary ? Colors.white : Colors.black) : Colors.grey, + ), + ), + ), + ); + } + + // --------------------------------------------------------------------------- + // Read-only-Darstellung einer erledigten Aufgabe + // --------------------------------------------------------------------------- + + Widget _buildReadOnlyBody(TaskInfo taskInfo) { + final summary = (taskInfo.additionalText ?? '').trim(); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('completed'.tr, style: const TextStyle(fontWeight: FontWeight.bold)), + if (summary.isNotEmpty) ...[ + const SizedBox(height: 6), + Text(summary, style: const TextStyle(fontSize: 15)), + ], + ], + ), + ); + } + + // --------------------------------------------------------------------------- + // Erfassungs-Widgets je Aufgabentyp + // --------------------------------------------------------------------------- + + Widget _buildEditableBody(TaskInfo taskInfo) { + final draft = _draftFor(taskInfo); + final type = taskInfo.task?.type ?? 0; + + switch (type) { + case 1: + return _buildConfirmBody(draft, 'confirm_arrival'.tr); case 2: - _processSignature(taskInfo); - break; - + return _buildSignatureBody(taskInfo, draft); case 3: - _processTaskArrivedOrFinished(taskInfo); - break; - - case 4: - _processTakePhotos(taskInfo); - break; - + return _buildPhotosBody(taskInfo, draft); case 5: - // 26.02.2025 Nach Abstimmung mit Gunnar entfernt - //_processScanBarcodes(taskInfo); - break; - + return Text('barcode_disabled'.tr, style: const TextStyle(color: Colors.grey)); + case 6: + return _buildConfirmBody(draft, taskInfo.task?.topic ?? 'confirm'.tr); case 7: - _processReceiptGiver(taskInfo); - break; - case 8: - _processStationNotice(taskInfo); - break; - case 9: - _processCommissionNumber(taskInfo); - break; - + return _buildTextBody(taskInfo, draft, multiline: type == 8); case 11: - _processPickupDeliveryStatusTask(taskInfo); - break; + return _buildStatusBody(taskInfo, draft); + case 12: + return _buildFieldsBody(taskInfo, draft, numeric: false); + case 13: + return _buildFieldsBody(taskInfo, draft, numeric: true); + case 14: + return _buildCheckboxListBody(taskInfo, draft); + default: + return Text('task_type_unsupported'.tr, style: const TextStyle(color: Colors.grey)); } } - _processTaskArrivedOrFinished(TaskInfo taskInfo) { - Util.logInfo('Task ${taskInfo.task?.sId ?? 0} called'); - - if (!mounted) return; - final currentContext = context; - showTaskArrivedPopup(currentContext, taskInfo, () async { - DateFormat dateFormat = DateFormat("dd.MM.yyyy HH:mm"); - - var answer = await _createAnswerHeader(taskInfo); - - taskInfo.answer = jsonEncode(answer.toJson()); - taskInfo.additionalText = dateFormat.format(DateTime.now()); - taskInfo.completed = true; - - _sendArrivedMessage(taskInfo); - - if (mounted) { - setState(() {}); - } - - _finishTaskProcessing(taskInfo); - }); + /// Bestätigungs-Aufgabe (Ankunft melden = 1, Checkbox = 6). + Widget _buildConfirmBody(_TaskDraft draft, String label) { + return CheckboxListTile( + value: draft.confirmed, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + activeColor: Constants.colorPrimary, + title: Text(label), + onChanged: (value) => setState(() => draft.confirmed = value ?? false), + ); } - _processCommissionNumber(TaskInfo taskInfo) { - Util.logInfo('Task ${taskInfo.task?.sId ?? 0} called'); - - if (!mounted) return; - final currentContext = context; - showComissionNumberPopup(currentContext, taskInfo, (comissionNumber) async { - var answer = await _createAnswerHeader(taskInfo); - answer.val = comissionNumber; - - taskInfo.answer = jsonEncode(answer.toJson()); - taskInfo.additionalText = comissionNumber; - taskInfo.completed = true; - - _commisionNumber = comissionNumber; - - if (mounted) { - setState(() {}); - } - - _finishTaskProcessing(taskInfo); - }); + /// Texterfassung (Quittungsgeber = 7, Bemerkung = 8, Kommissionsnummer = 9). + Widget _buildTextBody(TaskInfo taskInfo, _TaskDraft draft, {required bool multiline}) { + final maxLen = taskInfo.task?.maxLen ?? 0; + draft.text ??= _listenedController(); + return Util.getDefaultTextField( + taskInfo.task?.topic ?? '', + controller: draft.text, + textInputType: multiline ? TextInputType.multiline : TextInputType.text, + maxLines: multiline ? 6 : 1, + maxLength: maxLen > 0 ? maxLen : null, + formatter: multiline ? null : [FilteringTextInputFormatter.singleLineFormatter], + ); } - /*_processScanBarcodes(TaskInfo taskInfo) { - Util.logInfo('Task ${taskInfo.task?.sId ?? 0} called'); + /// Statusauswahl (Typ 11) auf Basis von choices bzw. entries. + Widget _buildStatusBody(TaskInfo taskInfo, _TaskDraft draft) { + final options = _statusOptions(taskInfo.task); + if (options.isEmpty) { + return Text('no_options'.tr, style: const TextStyle(color: Colors.grey)); + } + final selected = options.where((o) => o.code == draft.selectedCode); + final requiresRemark = selected.isNotEmpty && selected.first.requiresRemark; + draft.remark ??= _listenedController(); - showScanBarcodesPopup(context, taskInfo, (List barcodes) async { - var answer = await _createAnswerHeader(taskInfo); - answer.codes = []; - for(var barcode in barcodes) { - answer.codes!.add(barcode); - } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...options.map((o) => RadioListTile( + value: o.code, + groupValue: draft.selectedCode, + contentPadding: EdgeInsets.zero, + activeColor: Constants.colorPrimary, + title: Text(o.label), + onChanged: (value) => setState(() => draft.selectedCode = value), + )), + if (requiresRemark) ...[ + const SizedBox(height: 8), + Util.getDefaultTextField( + 'remark'.tr, + controller: draft.remark, + formatter: [FilteringTextInputFormatter.singleLineFormatter], + ), + ], + ], + ); + } - taskInfo.answer = jsonEncode(answer.toJson()); - taskInfo.additionalText = 'barcodes_scanned'.tr; - taskInfo.completed = true; + /// Text-/Zahlen-Liste (Typ 12/13): je Feld ein beschriftetes Eingabefeld. + Widget _buildFieldsBody(TaskInfo taskInfo, _TaskDraft draft, {required bool numeric}) { + final fields = taskInfo.task?.fields ?? []; + if (fields.isEmpty) { + return Text('no_fields'.tr, style: const TextStyle(color: Colors.grey)); + } + _ensureFieldControllers(draft, fields.length); - setState(() {}); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < fields.length; i++) ...[ + if ((fields[i].title ?? '').isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 4, top: 4), + child: Text(fields[i].title!, style: const TextStyle(fontWeight: FontWeight.w600)), + ), + Util.getDefaultTextField( + fields[i].placeholder ?? '', + controller: draft.fields[i], + textInputType: numeric ? const TextInputType.numberWithOptions(decimal: true, signed: true) : TextInputType.text, + formatter: numeric + ? [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,\-]'))] + : [FilteringTextInputFormatter.singleLineFormatter], + ), + const SizedBox(height: 8), + ], + ], + ); + } - _finishTaskProcessing(taskInfo); - }); - }*/ + /// Checkbox-Liste (Typ 14): Mehrfachauswahl aus den entries. + Widget _buildCheckboxListBody(TaskInfo taskInfo, _TaskDraft draft) { + final entries = taskInfo.task?.entries ?? []; + if (entries.isEmpty) { + return Text('no_options'.tr, style: const TextStyle(color: Colors.grey)); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: entries + .map((e) => CheckboxListTile( + value: draft.checked.contains(e.code), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + activeColor: Constants.colorPrimary, + title: Text(e.text), + onChanged: (value) => setState(() { + if (value ?? false) { + draft.checked.add(e.code); + } else { + draft.checked.remove(e.code); + } + }), + )) + .toList(), + ); + } - _processTakePhotos(TaskInfo taskInfo) { - Util.logInfo('Task ${taskInfo.task?.sId ?? 0} called'); + /// Unterschrift (Typ 2) – Inline-Signature-Pad. + Widget _buildSignatureBody(TaskInfo taskInfo, _TaskDraft draft) { + draft.signatureController ??= SignatureController( + penStrokeWidth: 3, + penColor: Colors.black, + onDrawEnd: () { + if (mounted) setState(() {}); + }, + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(10), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Signature( + controller: draft.signatureController!, + height: 240, + backgroundColor: Colors.lightBlue.shade50, + ), + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + icon: const Icon(Icons.clear), + label: Text('clear'.tr), + onPressed: () => setState(() => draft.signatureController!.clear()), + ), + ), + ], + ); + } - if (!mounted) return; - final currentContext = context; - showTakePhotosPopup(currentContext, taskInfo, (List? photos) async { - if (photos != null) { - final box = MyApp.objectBoxStore.box(); + /// Fotos aufnehmen (Typ 3) – Inline-Aufnahme und Vorschau. + Widget _buildPhotosBody(TaskInfo taskInfo, _TaskDraft draft) { + final max = taskInfo.task?.max ?? 999; + final min = taskInfo.task?.min ?? 0; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${'photos'.tr}: ${draft.photos.length}${min > 0 ? ' (min. $min)' : ''}', + style: const TextStyle(color: Colors.grey)), + const SizedBox(height: 8), + if (draft.photos.isNotEmpty) + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (int i = 0; i < draft.photos.length; i++) + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.memory(draft.photos[i], width: 90, height: 90, fit: BoxFit.cover), + ), + Positioned( + right: 0, + top: 0, + child: GestureDetector( + onTap: () => setState(() => draft.photos.removeAt(i)), + child: const CircleAvatar( + radius: 12, + backgroundColor: Colors.black54, + child: Icon(Icons.close, size: 16, color: Colors.white), + ), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 12), + if (draft.photos.length < max) + getDefaultButton('add'.tr, () => _capturePhoto(draft), buttonHeight: 48), + ], + ); + } - // Delete existing pictures for this tour - final query = box.query(Picture_.tourId.equals(widget.tour.id)).build(); - final existingPictures = query.find(); - query.close(); - box.removeMany(existingPictures.map((e) => e.id).toList()); - - // Add new pictures - for (int i = 0; i < photos.length; i++) { - var pictureData = photos[i]; - - var picture = Picture(); - picture.number = i; - picture.tourId = widget.tour.id; - picture.data = base64Encode(pictureData); - box.put(picture); + Future _capturePhoto(_TaskDraft draft) async { + try { + var photo = await Util.capturePhoto(); + if (photo != null) { + var fullsizeImageData = await photo.readAsBytes(); + image_lib.Image? image = image_lib.decodeImage(fullsizeImageData); + if (image != null) { + var thumbnail = image_lib.copyResize(image, width: 800); + var resizedImageData = image_lib.encodeJpg(thumbnail, quality: 75); + photo.delete(); + draft.photos.add(resizedImageData); + if (mounted) setState(() {}); } + } + } catch (ex) { + Util.logException(ex.toString()); + } + } + // --------------------------------------------------------------------------- + // Validierung („Weiter" freigeben) + // --------------------------------------------------------------------------- + + bool _isPageReadOnly(TaskInfo taskInfo) { + if (widget.readOnly) return true; + return taskInfo.completed && !(taskInfo.task?.reenter ?? false); + } + + bool _canProceed(TaskInfo taskInfo) { + if (_isPageReadOnly(taskInfo)) return true; + if (taskInfo.task?.opt ?? false) return true; + return _isValid(taskInfo); + } + + bool _isValid(TaskInfo taskInfo) { + final draft = _draftFor(taskInfo); + final type = taskInfo.task?.type ?? 0; + switch (type) { + case 1: + case 6: + return draft.confirmed; + case 2: + final sig = draft.signatureController; + return sig != null && sig.points.length > 50; + case 3: + final min = (taskInfo.task?.min ?? 0); + final max = (taskInfo.task?.max ?? 999); + final required = min > 0 ? min : 1; + return draft.photos.length >= required && draft.photos.length <= max; + case 5: + return true; + case 7: + case 8: + case 9: + return (draft.text?.text.trim().isNotEmpty ?? false); + case 11: + if (draft.selectedCode == null) return false; + final options = _statusOptions(taskInfo.task); + final selected = options.where((o) => o.code == draft.selectedCode); + if (selected.isNotEmpty && selected.first.requiresRemark) { + return (draft.remark?.text.trim().isNotEmpty ?? false); + } + return true; + case 12: + _ensureFieldControllers(draft, taskInfo.task?.fields?.length ?? 0); + return draft.fields.isNotEmpty && draft.fields.every((c) => c.text.trim().isNotEmpty); + case 13: + _ensureFieldControllers(draft, taskInfo.task?.fields?.length ?? 0); + return draft.fields.isNotEmpty && draft.fields.every((c) => _isNumeric(c.text)); + case 14: + return draft.checked.isNotEmpty; + default: + return true; + } + } + + /// Ob der Bearbeiter überhaupt Daten erfasst hat (für optionale Aufgaben: + /// entscheidet, ob eine Antwort geschrieben oder die Aufgabe übersprungen wird). + bool _hasData(TaskInfo taskInfo) { + final draft = _draftFor(taskInfo); + final type = taskInfo.task?.type ?? 0; + switch (type) { + case 1: + case 6: + return draft.confirmed; + case 2: + final sig = draft.signatureController; + return sig != null && sig.points.length > 50; + case 3: + return draft.photos.isNotEmpty; + case 7: + case 8: + case 9: + return (draft.text?.text.trim().isNotEmpty ?? false); + case 11: + return draft.selectedCode != null; + case 12: + case 13: + return draft.fields.any((c) => c.text.trim().isNotEmpty); + case 14: + return draft.checked.isNotEmpty; + default: + return false; + } + } + + // --------------------------------------------------------------------------- + // Navigation + // --------------------------------------------------------------------------- + + Future _onNext() async { + final taskInfo = _taskList[_index]; + if (!_isPageReadOnly(taskInfo)) { + await _commitCurrent(taskInfo); + } + if (mounted) { + setState(() { + if (_index < _taskList.length - 1) _index++; + }); + } + } + + Future _onFinish() async { + final taskInfo = _taskList[_index]; + if (!_isPageReadOnly(taskInfo)) { + await _commitCurrent(taskInfo); + } + await _sendFinTourMessage(); + _popBack(); + } + + void _popBack() { + sendMessage( + routingKey: Constants.messageTopicPopContentView, + payload: PopContentViewMessage(count: 1), + ); + } + + // --------------------------------------------------------------------------- + // Antworten erfassen und speichern + // --------------------------------------------------------------------------- + + Future _commitCurrent(TaskInfo taskInfo) async { + final draft = _draftFor(taskInfo); + final type = taskInfo.task?.type ?? 0; + + // Optionale Aufgabe ohne Eingabe: als erledigt markieren, keine Antwort. + if (!_hasData(taskInfo)) { + taskInfo.completed = true; + taskInfo.answer = null; + taskInfo.additionalText = null; + _persist(taskInfo); + return; + } + + switch (type) { + case 1: // Ankunft melden + final answer = await _createAnswerHeader(taskInfo); + taskInfo.answer = jsonEncode(answer.toJson()); + taskInfo.additionalText = DateFormat("dd.MM.yyyy HH:mm").format(DateTime.now()); + taskInfo.completed = true; + _persist(taskInfo); + _sendArrivedMessage(taskInfo); + break; + + case 2: // Unterschrift + final answer = await _createAnswerHeader(taskInfo); + answer.sign = _convertSignaturePoints(draft.signatureController!.points); + taskInfo.answer = jsonEncode(answer.toJson()); + taskInfo.additionalText = 'signature_captured'.tr; + taskInfo.completed = true; + _persist(taskInfo); + break; + + case 3: // Fotos + _storePhotos(draft.photos); + taskInfo.answer = null; // Bilder werden separat als pic-Nachrichten gesendet. taskInfo.additionalText = 'photos_taken'.tr; taskInfo.completed = true; + _persist(taskInfo); + break; - if (mounted) { - setState(() {}); - } - - _finishTaskProcessing(taskInfo); - } - }); - } - - void _processStationNotice(TaskInfo taskInfo) { - Util.logInfo('Task ${taskInfo.task?.sId ?? 0} called'); - - if (!mounted) return; - final currentContext = context; - showStationNoticePopup(currentContext, taskInfo, (notice) async { - var answer = await _createAnswerHeader(taskInfo); - answer.val = notice; - - taskInfo.answer = jsonEncode(answer.toJson()); - taskInfo.additionalText = notice; - taskInfo.completed = true; - - _finishTaskProcessing(taskInfo); - - if (mounted) { - setState(() {}); - } - }); - } - - void _processReceiptGiver(TaskInfo taskInfo) { - Util.logInfo('Task ${taskInfo.task?.sId ?? 0} called'); - - if (!mounted) return; - final currentContext = context; - showReceiptGiverPopup(currentContext, taskInfo, (receiptGiver, selectedTourEntry) async { - var answer = await _createAnswerHeader(taskInfo); - answer.code = selectedTourEntry.code; - answer.val = receiptGiver; - - taskInfo.answer = jsonEncode(answer.toJson()); - taskInfo.additionalText = receiptGiver; - taskInfo.completed = true; - - if (mounted) { - setState(() {}); - } - - _finishTaskProcessing(taskInfo); - }); - } - - void _processPickupDeliveryStatusTask(TaskInfo taskInfo) { - Util.logInfo('Task ${taskInfo.task?.sId ?? 0} called'); - - Future.delayed(const Duration(seconds: 1), () async { - if (!context.mounted) return; - showPickupDeliveryStatusPopup(MyApp.appNavigatorKey.currentContext!, taskInfo, (tourEntry, remark) async { - var answer = await _createAnswerHeader(taskInfo); - answer.code = tourEntry!.code; - answer.val = remark; - + case 6: // Checkbox / Bestätigung + final answer = await _createAnswerHeader(taskInfo); + answer.val = "1"; taskInfo.answer = jsonEncode(answer.toJson()); - taskInfo.additionalText = tourEntry.text; + taskInfo.additionalText = "✓"; taskInfo.completed = true; + _persist(taskInfo); + break; - _finishTaskProcessing(taskInfo); + case 7: // Quittungsgeber + case 8: // Bemerkung + case 9: // Kommissionsnummer + final value = draft.text?.text.trim() ?? ''; + final answer = await _createAnswerHeader(taskInfo); + answer.val = value; + taskInfo.answer = jsonEncode(answer.toJson()); + taskInfo.additionalText = value; + taskInfo.completed = true; + if (type == 9) _commisionNumber = value; + _persist(taskInfo); + break; - if (mounted) { - setState(() {}); + case 11: // Statusauswahl + final answer = await _createAnswerHeader(taskInfo); + answer.code = draft.selectedCode; + final remark = draft.remark?.text.trim() ?? ''; + answer.val = remark.isNotEmpty ? remark : null; + taskInfo.answer = jsonEncode(answer.toJson()); + taskInfo.additionalText = _statusLabel(taskInfo.task, draft.selectedCode); + taskInfo.completed = true; + _persist(taskInfo); + break; + + case 12: // Text-Liste + case 13: // Zahlen-Liste + final answer = await _createAnswerHeader(taskInfo); + final fields = taskInfo.task?.fields ?? []; + final answers = []; + final summary = []; + for (int i = 0; i < fields.length; i++) { + final fa = FinFieldAnswer(); + fa.title = fields[i].title; + fa.val = i < draft.fields.length ? draft.fields[i].text.trim() : ''; + answers.add(fa); + summary.add('${fa.title ?? ''}: ${fa.val ?? ''}'); } - }); - }); + answer.fields = answers; + taskInfo.answer = jsonEncode(answer.toJson()); + taskInfo.additionalText = summary.join(', '); + taskInfo.completed = true; + _persist(taskInfo); + break; + + case 14: // Checkbox-Liste + final answer = await _createAnswerHeader(taskInfo); + final checked = draft.checked.toList()..sort(); + answer.checked = checked; + final entries = taskInfo.task?.entries ?? []; + final labels = entries.where((e) => draft.checked.contains(e.code)).map((e) => e.text).toList(); + taskInfo.answer = jsonEncode(answer.toJson()); + taskInfo.additionalText = labels.join(', '); + taskInfo.completed = true; + _persist(taskInfo); + break; + + default: + taskInfo.completed = true; + _persist(taskInfo); + } } - void _processSignature(TaskInfo taskInfo) { - Util.logInfo('Task ${taskInfo.task?.sId ?? 0} called'); - - MyApp.suppressAcceptDialog = true; - - SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft]); - - Future.delayed(const Duration(seconds: 1), () async { - if (!context.mounted) return; - showSignaturePopup(MyApp.appNavigatorKey.currentContext!, taskInfo, (String? signature) async { - MyApp.suppressAcceptDialog = false; - - SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); - - if (signature != null) { - var answer = await _createAnswerHeader(taskInfo); - answer.sign = signature; - - taskInfo.answer = jsonEncode(answer.toJson()); - taskInfo.additionalText = 'signature_captured'.tr; - taskInfo.completed = true; - - _sendFinTourMessage(); - - _finishTaskProcessing(taskInfo); - - sendMessage( - routingKey: Constants.messageTopicPopContentView, - payload: PopContentViewMessage(count: 2), - ); - } - }); - }); - } - - _finishTaskProcessing(TaskInfo taskInfo) async { - Util.logInfo('Task ${taskInfo.task?.sId ?? 0} finished'); - + void _persist(TaskInfo taskInfo) { final box = MyApp.objectBoxStore.box(); box.put(taskInfo); } - bool isTaskActive(int index) { - if (!widget.readOnly) { - var currentTaskInfo = _taskList[index]; + void _storePhotos(List photos) { + final box = MyApp.objectBoxStore.box(); - if (currentTaskInfo.completed && !(currentTaskInfo.task?.reenter ?? false)) return false; + final query = box.query(Picture_.tourId.equals(widget.tour.id)).build(); + final existingPictures = query.find(); + query.close(); + box.removeMany(existingPictures.map((e) => e.id).toList()); - for (var taskInfo in _taskList) - { - if (taskInfo == currentTaskInfo) break; + for (int i = 0; i < photos.length; i++) { + var picture = Picture(); + picture.number = i; + picture.tourId = widget.tour.id; + picture.data = base64Encode(photos[i]); + box.put(picture); + } + } - if (!(taskInfo.task?.opt ?? false) && !taskInfo.completed) return false; - } + // --------------------------------------------------------------------------- + // Draft-Verwaltung + // --------------------------------------------------------------------------- - if (currentTaskInfo.task?.reenter ?? false) return true; + _TaskDraft _draftFor(TaskInfo taskInfo) { + return _drafts.putIfAbsent(taskInfo.sId, () => _buildDraft(taskInfo)); + } - return true; + /// Erstellt den Erfassungs-Zustand und füllt ihn bei bereits erledigten, + /// erneut bearbeitbaren Aufgaben aus der gespeicherten Antwort vor. + _TaskDraft _buildDraft(TaskInfo taskInfo) { + final draft = _TaskDraft(); + final type = taskInfo.task?.type ?? 0; + + if (type == 12 || type == 13) { + _ensureFieldControllers(draft, taskInfo.task?.fields?.length ?? 0); } - return false; + if (!taskInfo.completed || taskInfo.answer == null) { + return draft; + } + + // Vorbefüllung aus bestehender Antwort (z. B. bei erneut bearbeitbar). + try { + final answer = FinTourTask.fromJson(jsonDecode(taskInfo.answer!) as Map); + switch (type) { + case 1: + case 6: + draft.confirmed = true; + break; + case 7: + case 8: + case 9: + draft.text = _listenedController(answer.val ?? ''); + break; + case 11: + draft.selectedCode = answer.code; + draft.remark = _listenedController(answer.val ?? ''); + break; + case 12: + case 13: + final values = answer.fields ?? []; + for (int i = 0; i < draft.fields.length && i < values.length; i++) { + draft.fields[i].text = values[i].val ?? ''; + } + break; + case 14: + draft.checked = (answer.checked ?? []).toSet(); + break; + } + } catch (ex) { + Util.logException(ex.toString()); + } + + return draft; } + void _ensureFieldControllers(_TaskDraft draft, int count) { + while (draft.fields.length < count) { + draft.fields.add(_listenedController()); + } + } + + /// Erstellt einen Controller, der bei jeder Eingabe die Seite neu bewertet + /// (z. B. um „Weiter" frei­zuschalten). + TextEditingController _listenedController([String text = '']) { + final controller = TextEditingController(text: text); + controller.addListener(() { + if (mounted) setState(() {}); + }); + return controller; + } + + // --------------------------------------------------------------------------- + // Hilfsfunktionen + // --------------------------------------------------------------------------- + + List<_StatusOption> _statusOptions(TourTask? task) { + if (task == null) return []; + if (task.choices != null && task.choices!.isNotEmpty) { + return task.choices! + .map((c) => _StatusOption(c.ret, c.txt, c.requiresRemark)) + .toList(); + } + if (task.entries != null && task.entries!.isNotEmpty) { + return task.entries! + .map((e) => _StatusOption(e.code, e.text, e.queryRemark)) + .toList(); + } + return []; + } + + String _statusLabel(TourTask? task, int? code) { + final options = _statusOptions(task); + final match = options.where((o) => o.code == code); + return match.isNotEmpty ? match.first.label : ''; + } + + bool _isNumeric(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) return false; + return double.tryParse(trimmed.replaceAll(',', '.')) != null; + } + + String _convertSignaturePoints(List points) { + if (points.isEmpty) return ""; + String result = ""; + for (var point in points) { + if (point.type != PointType.tap) { + result += "${point.offset.dx.toInt()},${point.offset.dy.toInt()};"; + } else { + result += "A"; + } + } + return result.replaceAll("AA", "A"); + } + + // --------------------------------------------------------------------------- + // Netzwerk-Nachrichten (unverändert übernommen) + // --------------------------------------------------------------------------- + _sendArrivedMessage(TaskInfo taskInfo) async { var message = FinTask(); message.trId = widget.tour.id; @@ -549,7 +955,7 @@ class _TaskListPageState extends State with ProducerMixin { } finTourMessage.tasks = []; - for(var taskInfo in _taskList) { + for (var taskInfo in _taskList) { if (taskInfo.answer != null) { var finTourTask = FinTourTask.fromJson(jsonDecode(taskInfo.answer!)); @@ -625,6 +1031,36 @@ class _TaskListPageState extends State with ProducerMixin { } } +/// Erfassungs-Zustand einer Aufgabe während der Bearbeitung. +class _TaskDraft { + TextEditingController? text; + TextEditingController? remark; + int? selectedCode; + List fields = []; + Set checked = {}; + bool confirmed = false; + SignatureController? signatureController; + List photos = []; + + void dispose() { + text?.dispose(); + remark?.dispose(); + for (final c in fields) { + c.dispose(); + } + signatureController?.dispose(); + } +} + +/// Eine auswählbare Option eines Status-Tasks (Typ 11). +class _StatusOption { + final int code; + final String label; + final bool requiresRemark; + + _StatusOption(this.code, this.label, this.requiresRemark); +} + class _HeaderWidget extends StatefulWidget { @override State<_HeaderWidget> createState() => _HeaderWidgetState(); @@ -674,31 +1110,6 @@ class _HeaderWidgetState extends State<_HeaderWidget> with ProducerMixin { ), ), ), - Visibility( - visible: false, - child: Container( - width: 60, - height: 60, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.red, - ), - child: FittedBox( - child: GestureDetector( - onTap: () async { - sendMessage( - routingKey: Constants.messageTopicPopContentView, - payload: PopContentViewMessage(count: 1), - ); - }, - child: const CircleAvatar( - backgroundColor: Constants.colorPrimary, - radius: 10, - child: Padding(padding: EdgeInsets.all(1.0), child: Icon(Icons.info_outline, size: 20.0, color: Colors.white))), - ), - ), - ), - ), ], ), SizedBox( @@ -716,4 +1127,4 @@ class _HeaderWidgetState extends State<_HeaderWidget> with ProducerMixin { ), ); } -} \ No newline at end of file +} diff --git a/lib/tools/app_localizations.dart b/lib/tools/app_localizations.dart index 95dda4b..b1e7573 100644 --- a/lib/tools/app_localizations.dart +++ b/lib/tools/app_localizations.dart @@ -151,6 +151,17 @@ class AppLocalizations { 'signature_captured': 'Unterschrift erfasst', 'barcodes_scanned': 'Barcodes gescannt', + // Aufgaben-Wizard + 'task': 'Aufgabe', + 'finish': 'Abschließen', + 'completed': 'Erledigt', + 'clear': 'Leeren', + 'confirm_arrival': 'Ankunft am Einsatzort bestätigen', + 'barcode_disabled': 'Barcode-Scan ist deaktiviert.', + 'task_type_unsupported': 'Dieser Aufgabentyp wird nicht unterstützt.', + 'no_options': 'Keine Auswahloptionen vorhanden.', + 'no_fields': 'Keine Eingabefelder definiert.', + // Popup Dialoge 'apply': 'Übernehmen', 'add': 'Hinzufügen',