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';
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';
/// 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 = [];
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();
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 _buildConfirmBody(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ä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),
);
}
/// 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: [
...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],
),
],
],
);
}
/// 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" 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;
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),
),
),
),
],
),
);
}
}