import 'dart:convert'; import 'package:dart_mq/dart_mq.dart'; import 'package:flutter/material.dart'; // TEMP (nur zum Testen, s. _tempTaskJsonUrl): wieder entfernen. import 'package:http/http.dart' as http; 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'; import 'package:stadtbote/model/job_tour.dart'; import 'package:stadtbote/model/pictures.dart'; import 'package:stadtbote/objectbox.g.dart'; 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/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'; // ============================================================================= // TEMPORÄR — NUR ZUM TESTEN, WIEDER ENTFERNEN // // Lädt das Aufgaben-JSON beim Öffnen der Seite von einer Webseite statt die // Aufgaben der übergebenen Tour zu verwenden. Die URL muss entweder eine // JSON-Liste von Aufgaben liefern oder ein Objekt mit einer "tasks"-Liste. // // Zum Entfernen: diesen Block, den http-Import oben und die mit // "TEMP" markierte Zeile in _fetchTaskList() löschen. Alternativ die URL auf // null setzen, dann greift wieder das normale Verhalten (auch bei Ladefehler). // ============================================================================= // ignore: unnecessary_nullable_for_final_variable_declarations const String? _tempTaskJsonUrl = "https://appcreation.de/download/task.json"; Future?> _tempLoadTasksFromWeb() async { final url = _tempTaskJsonUrl; if (url == null) { return null; } try { final response = await http.get(Uri.parse(url)); if (response.statusCode != 200) { debugPrint("TEMP: Aufgaben-JSON laden fehlgeschlagen, HTTP ${response.statusCode}"); return null; } final decoded = jsonDecode(utf8.decode(response.bodyBytes)); final list = decoded is List ? decoded : (decoded as Map)['tasks'] as List; return list.map((e) => TourTask.fromJson(e as Map)).toList(); } catch (e) { debugPrint("TEMP: Aufgaben-JSON laden fehlgeschlagen: $e"); return null; } } // ========================================================== ENDE TEMPORÄR ==== /// 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}); final Job job; final JobTour tour; final bool readOnly; @override State createState() => _TaskListPageState(); @override Future getHeaderWidget(BuildContext context) async { await Future.delayed(const Duration(microseconds: 0)); return _HeaderWidget(); } } class _TaskListPageState extends State with ProducerMixin { 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 { _taskList = await _fetchTaskList(); _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) { setState(() {}); } }); super.initState(); } @override void dispose() { for (final draft in _drafts.values) { draft.dispose(); } super.dispose(); } Future> _fetchTaskList() async { List taskList = []; // TEMP (nur zum Testen): Aufgaben von Webseite statt aus der Tour laden. final tasks = await _tempLoadTasksFromWeb() ?? widget.tour.tasks!; for (var task in tasks) { final box = MyApp.objectBoxStore.box(); final query = box.query(TaskInfo_.sId.equals(task.sId)).build(); var taskInfo = query.findFirst(); query.close(); if (taskInfo == null) { taskInfo = TaskInfo(); taskInfo.sId = task.sId; taskInfo.answer = null; taskInfo.additionalText = null; taskInfo.completed = false; } taskInfo.task = task; taskList.add(taskInfo); } return taskList; } // --------------------------------------------------------------------------- // 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 _buildConfirmButtonBody(draft, 'confirm_arrival'.tr); case 2: return _buildSignatureBody(taskInfo, draft); case 3: return _buildPhotosBody(taskInfo, draft); case 5: return Text('barcode_disabled'.tr, style: const TextStyle(color: Colors.grey)); case 6: return _buildConfirmBody(draft, taskInfo.task?.topic ?? 'confirm'.tr); case 7: case 8: case 9: return _buildTextBody(taskInfo, draft, multiline: type == 8); case 11: 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)); } } /// Bestätigung per Button (Ankunft melden = 1). Antippen bestätigt bzw. /// nimmt die Bestätigung wieder zurück; der Zustand wird über ein /// Häkchen-Symbol im Button angezeigt. Widget _buildConfirmButtonBody(_TaskDraft draft, String label) { final confirmed = draft.confirmed; return SizedBox( height: 52, child: MaterialButton( color: Constants.colorPrimary, elevation: 3, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15))), onPressed: () => setState(() => draft.confirmed = !confirmed), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (confirmed) ...[ const Icon(Icons.check_circle, color: Colors.white), const SizedBox(width: 8), ], Flexible( child: Text( label, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, color: Colors.white), ), ), ], ), ), ); } /// Bestätigungs-Aufgabe als Checkbox (Typ 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), ); } /// 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], ); } /// 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(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ RadioGroup( groupValue: draft.selectedCode, onChanged: (value) => setState(() => draft.selectedCode = value), child: Column( children: options .map((o) => RadioListTile( value: o.code, contentPadding: EdgeInsets.zero, activeColor: Constants.colorPrimary, title: Text(o.label), )) .toList(), ), ), if (requiresRemark) ...[ const SizedBox(height: 8), Util.getDefaultTextField( 'remark'.tr, controller: draft.remark, formatter: [FilteringTextInputFormatter.singleLineFormatter], ), ], ], ); } /// 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); 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), ], ], ); } /// 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(), ); } /// 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()), ), ), ], ); } /// 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), ], ); } 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; case 6: // Checkbox / Bestätigung final answer = await _createAnswerHeader(taskInfo); answer.val = "1"; taskInfo.answer = jsonEncode(answer.toJson()); taskInfo.additionalText = "✓"; taskInfo.completed = true; _persist(taskInfo); break; 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; 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 _persist(TaskInfo taskInfo) { final box = MyApp.objectBoxStore.box(); box.put(taskInfo); } void _storePhotos(List photos) { final box = MyApp.objectBoxStore.box(); 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 (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); } } // --------------------------------------------------------------------------- // Draft-Verwaltung // --------------------------------------------------------------------------- _TaskDraft _draftFor(TaskInfo taskInfo) { return _drafts.putIfAbsent(taskInfo.sId, () => _buildDraft(taskInfo)); } /// 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); } 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; message.task = Task(); message.task.sId = taskInfo.task?.sId ?? 0; message.task.time = Util.nowUnixSecondsUtc(); var position = await MyApp.getCurrentPosition(); if (position != null) { message.task.pos = FinTaskPos(); message.task.pos!.time = Util.toUnixSecondsUtc(position.timestamp); message.task.pos!.lat = position.latitude; message.task.pos!.long = position.longitude; } else { message.task.pos = null; } sendMessage( routingKey: Constants.messageTopicPublishPayload, payload: PublishPayloadMessage(topic: "fin_task", message: message.toJson(), acknowledgeRequired: true), ); } Future _createAnswerHeader(TaskInfo taskInfo) async { var position = await MyApp.getCurrentPosition(); var finTourTask = FinTourTask(); finTourTask.sId = taskInfo.task?.sId ?? 0; finTourTask.time = Util.nowUnixSecondsUtc(); if (position != null) { finTourTask.pos = FinTaskPos(); finTourTask.pos!.time = Util.toUnixSecondsUtc(position.timestamp); finTourTask.pos!.lat = position.latitude; finTourTask.pos!.long = position.longitude; finTourTask.pos!.acc = position.accuracy.toInt(); } else { finTourTask.pos = null; } return finTourTask; } _sendFinTourMessage() async { var finTourMessage = FinTour(); finTourMessage.trId = widget.tour.id; finTourMessage.com = _commisionNumber; var position = await MyApp.getCurrentPosition(); if (position != null) { finTourMessage.track = Track(); finTourMessage.track!.time = Util.nowUnixSecondsUtc(); finTourMessage.track!.pos = FinTaskPos(); finTourMessage.track!.pos!.time = Util.toUnixSecondsUtc(position.timestamp); finTourMessage.track!.pos!.lat = position.latitude; finTourMessage.track!.pos!.long = position.longitude; } finTourMessage.tasks = []; for (var taskInfo in _taskList) { if (taskInfo.answer != null) { var finTourTask = FinTourTask.fromJson(jsonDecode(taskInfo.answer!)); finTourMessage.tasks.add(finTourTask); } } sendMessage( routingKey: Constants.messageTopicPublishPayload, payload: PublishPayloadMessage(topic: "fin_tour", message: finTourMessage.toJson(), acknowledgeRequired: true), ); widget.tour.state = Constants.stateFinishedNotConfirmed; final updatedTours = widget.job.tours; final tourIdx = updatedTours.indexWhere((t) => t.id == widget.tour.id); if (tourIdx >= 0) { updatedTours[tourIdx] = widget.tour; widget.job.tours = updatedTours; } final jobBox = MyApp.objectBoxStore.box(); jobBox.put(widget.job); _sendImages(); await _checkJobFinished(); } _sendImages() { Future.delayed(const Duration(seconds: 1), () async { final box = MyApp.objectBoxStore.box(); final query = box.query(Picture_.tourId.equals(widget.tour.id)).build(); var pictures = query.find(); query.close(); if (pictures.isNotEmpty) { for (var picture in pictures) { var message = PicData(); message.trId = picture.tourId; message.num = picture.number; message.time = Util.nowUnixSecondsUtc(); message.data = picture.data; sendMessage( routingKey: Constants.messageTopicPublishPayload, payload: PublishPayloadMessage(topic: "pic", message: message.toJson(), acknowledgeRequired: true), ); final box = MyApp.objectBoxStore.box(); box.remove(picture.id); } } }); } Future _checkJobFinished() async { var jobFinished = true; for (var tour in widget.job.tours) { if (tour.state == 0) { jobFinished = false; break; } } if (jobFinished) { widget.job.state = 2; final box = MyApp.objectBoxStore.box(); box.put(widget.job); } } } /// 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(); } class _HeaderWidgetState extends State<_HeaderWidget> with ProducerMixin { @override void initState() { super.initState(); } @override Widget build(BuildContext context) { var cellWidth = (MediaQuery.of(context).size.width - 60 - 60 - 25) / 2; return Padding( padding: const EdgeInsets.all(8), child: Stack( alignment: Alignment.center, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Visibility( visible: true, child: Container( width: 60, height: 60, decoration: const BoxDecoration( shape: BoxShape.circle, color: Colors.white, ), child: FittedBox( child: GestureDetector( onTap: () async { sendMessage( routingKey: Constants.messageTopicPopContentView, payload: PopContentViewMessage(count: 1), ); }, child: const CircleAvatar( radius: 10, backgroundColor: Constants.colorPrimary, child: Padding(padding: EdgeInsets.all(1.0), child: Icon(Icons.arrow_back, size: 20.0, color: Colors.white)), ), ), ), ), ), ], ), SizedBox( height: 100, width: cellWidth * 2, child: const Center( child: Text( textAlign: TextAlign.center, "Liste der Aufgaben", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: Colors.white), ), ), ), ], ), ); } }