Files
stadtbote-app/lib/content_views/task_list_page.dart
T
SvenandClaude Fable 5 f58f3f0c59 Pflichtfeld-Kennzeichen (required) aus dem Workflow-Editor auswerten
- TourField/TourEntry um optionales required-Flag erweitert (Altdaten ohne
  Angabe verhalten sich unverändert)
- Weiter-Button frei, wenn alle Pflichtfelder erfüllt sind oder der Task
  optional ist; Listen ohne Pflichtfelder (inkl. leerer Listen) sind frei
- Pflichtfelder und Pflicht-Checkboxen mit rotem * markiert; Titel-* nur
  noch, wenn der Task tatsächlich zwingende Eingaben verlangt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:03:51 +02:00

1286 lines
43 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<List<TourTask>?> _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<String, dynamic>)['tasks'] as List<dynamic>;
return list.map((e) => TourTask.fromJson(e as Map<String, dynamic>)).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.
///
/// <p>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<TaskListPage> createState() => _TaskListPageState();
@override
Future<Widget> getHeaderWidget(BuildContext context) async {
await Future.delayed(const Duration(microseconds: 0));
return _HeaderWidget();
}
}
class _TaskListPageState extends State<TaskListPage> with ProducerMixin {
bool _initCompleted = false;
late List<TaskInfo> _taskList;
late String _commisionNumber;
int _index = 0;
/// Erfassungs-Zustand je Aufgabe (Schlüssel: s_id).
final Map<int, _TaskDraft> _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<List<TaskInfo>> _fetchTaskList() async {
List<TaskInfo> 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<TaskInfo>();
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 ?? '') + (_isTaskOptional(taskInfo) ? "" : " *"),
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<int>(
groupValue: draft.selectedCode,
onChanged: (value) => setState(() => draft.selectedCode = value),
child: Column(
children: options
.map((o) => RadioListTile<int>(
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.rich(
TextSpan(
text: fields[i].title!,
children: [
if (fields[i].required == true)
const TextSpan(text: ' *', style: TextStyle(color: Colors.red)),
],
),
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.rich(
TextSpan(
text: e.text,
children: [
if (e.required == true)
const TextSpan(text: ' *', style: TextStyle(color: Colors.red)),
],
),
),
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<void> _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);
}
/// Ob die Aufgabe insgesamt optional ist (steuert das "*" im Titel):
/// entweder per {@code opt}-Flag oder – bei Listen-Tasks – weil kein
/// einziger Eintrag als Pflichtfeld gekennzeichnet ist. Altdaten ohne
/// Pflicht-Kennzeichen gelten wie bisher als verpflichtend.
bool _isTaskOptional(TaskInfo taskInfo) {
final task = taskInfo.task;
if (task == null) return true;
if (task.opt ?? false) return true;
switch (task.type) {
case 12:
case 13:
final fields = task.fields ?? [];
if (fields.isEmpty) return true;
if (fields.every((f) => f.required == null)) return false;
return fields.every((f) => f.required != true);
case 14:
final entries = task.entries ?? [];
if (entries.isEmpty) return true;
if (entries.every((e) => e.required == null)) return false;
return entries.every((e) => e.required != true);
default:
return false;
}
}
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 _fieldsValid(taskInfo, draft, numeric: false);
case 13:
_ensureFieldControllers(draft, taskInfo.task?.fields?.length ?? 0);
return _fieldsValid(taskInfo, draft, numeric: true);
case 14:
final entries = taskInfo.task?.entries ?? [];
// Ohne definierte Einträge gibt es keine Pflichtfelder -> freigeben.
if (entries.isEmpty) return true;
// Altdaten ohne Pflicht-Kennzeichen: wie bisher mindestens ein Häkchen.
if (entries.every((e) => e.required == null)) {
return draft.checked.isNotEmpty;
}
return entries
.where((e) => e.required == true)
.every((e) => draft.checked.contains(e.code));
default:
return true;
}
}
/// Prüft die Eingabefelder einer Text-/Zahlen-Liste (Typ 12/13) gegen ihre
/// Pflicht-Kennzeichen. Altdaten ohne Kennzeichen behalten das bisherige
/// Verhalten (alle Felder müssen gefüllt bzw. numerisch sein). Sonst müssen
/// nur Pflichtfelder gefüllt (bei Zahlenlisten numerisch) sein; optionale
/// Felder dürfen leer bleiben, müssen aber – falls ausgefüllt – bei
/// Zahlenlisten ebenfalls numerisch sein.
bool _fieldsValid(TaskInfo taskInfo, _TaskDraft draft, {required bool numeric}) {
final fields = taskInfo.task?.fields ?? [];
// Ohne definierte Felder gibt es keine Pflichtfelder -> freigeben.
if (fields.isEmpty) return true;
final legacy = fields.every((f) => f.required == null);
for (int i = 0; i < fields.length && i < draft.fields.length; i++) {
final text = draft.fields[i].text.trim();
final mandatory = legacy || fields[i].required == true;
if (mandatory) {
if (numeric ? !_isNumeric(text) : text.isEmpty) return false;
} else if (numeric && text.isNotEmpty && !_isNumeric(text)) {
return false;
}
}
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<void> _onNext() async {
final taskInfo = _taskList[_index];
if (!_isPageReadOnly(taskInfo)) {
await _commitCurrent(taskInfo);
}
if (mounted) {
setState(() {
if (_index < _taskList.length - 1) _index++;
});
}
}
Future<void> _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<void> _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 = <FinFieldAnswer>[];
final summary = <String>[];
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<TaskInfo>();
box.put(taskInfo);
}
void _storePhotos(List<Uint8List> photos) {
final box = MyApp.objectBoxStore.box<Picture>();
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<String, dynamic>);
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<Point> 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<FinTourTask> _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<Job>();
jobBox.put(widget.job);
_sendImages();
await _checkJobFinished();
}
_sendImages() {
Future.delayed(const Duration(seconds: 1), () async {
final box = MyApp.objectBoxStore.box<Picture>();
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<Picture>();
box.remove(picture.id);
}
}
});
}
Future<void> _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<Job>();
box.put(widget.job);
}
}
}
/// Erfassungs-Zustand einer Aufgabe während der Bearbeitung.
class _TaskDraft {
TextEditingController? text;
TextEditingController? remark;
int? selectedCode;
List<TextEditingController> fields = [];
Set<int> checked = {};
bool confirmed = false;
SignatureController? signatureController;
List<Uint8List> 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),
),
),
),
],
),
);
}
}