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" freizuschalten).
+ 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',