1300 lines
44 KiB
Dart
1300 lines
44 KiB
Dart
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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
String _taskText(TaskInfo taskInfo, String key) {
|
||
// Die Unterschriftenerfassung bleibt unabhängig von der App-Sprache deutsch.
|
||
return AppLocalizations.tr(
|
||
key,
|
||
language: taskInfo.task?.effectiveType == 2 ? AppLanguage.german : null,
|
||
);
|
||
}
|
||
|
||
@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(
|
||
'${_taskText(taskInfo, 'commission_number_short')}: $_commisionNumber',
|
||
style: const TextStyle(fontSize: 18),
|
||
),
|
||
),
|
||
),
|
||
Text(
|
||
'${_taskText(taskInfo, 'task')} ${_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(
|
||
_taskText(taskInfo, 'back'),
|
||
_index > 0 ? () => setState(() => _index--) : null,
|
||
primary: false,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: isLast
|
||
? (widget.readOnly
|
||
? _navButton(_taskText(taskInfo, 'close'), () => _popBack())
|
||
: _navButton(_taskText(taskInfo, 'finish'), canProceed ? () => _onFinish() : null))
|
||
: _navButton(_taskText(taskInfo, 'next'), 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) {
|
||
var summary = (taskInfo.additionalText ?? '').trim();
|
||
if (taskInfo.task?.effectiveType == 2 && summary.isNotEmpty) {
|
||
// Auch früher in einer anderen Sprache gespeicherte Bestätigungen anzeigen.
|
||
summary = _taskText(taskInfo, 'signature_captured');
|
||
}
|
||
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(_taskText(taskInfo, 'completed'), 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);
|
||
// Benutzerdefinierte Typen aus dem Workflow-Editor (>= 1000) verhalten
|
||
// sich wie ihr Basis-Typ, siehe TourTask.effectiveType.
|
||
final type = taskInfo.task?.effectiveType ?? 0;
|
||
|
||
switch (type) {
|
||
case 2:
|
||
return _buildSignatureBody(taskInfo, draft);
|
||
case 3:
|
||
return _buildConfirmButtonBody(draft, 'confirm_arrival'.tr);
|
||
case 4:
|
||
return _buildPhotosBody(taskInfo, draft);
|
||
case 5:
|
||
return Text('barcode_disabled'.tr, style: const TextStyle(color: Colors.grey));
|
||
case 7:
|
||
case 8:
|
||
case 9:
|
||
return _buildTextBody(taskInfo, draft, multiline: type == 8);
|
||
case 11:
|
||
return _buildStatusBody(taskInfo, draft);
|
||
case 12:
|
||
return _buildConfirmBody(draft, taskInfo.task?.topic ?? 'confirm'.tr);
|
||
case 13:
|
||
return _buildFieldsBody(taskInfo, draft, numeric: false);
|
||
case 14:
|
||
return _buildFieldsBody(taskInfo, draft, numeric: true);
|
||
case 15:
|
||
return _buildCheckboxListBody(taskInfo, draft);
|
||
default:
|
||
return Text('task_type_unsupported'.tr, style: const TextStyle(color: Colors.grey));
|
||
}
|
||
}
|
||
|
||
/// Bestätigung per Button (Ankunft melden = 3). 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 12).
|
||
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 13/14): 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 15): 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(_taskText(taskInfo, 'clear')),
|
||
onPressed: () => setState(() => draft.signatureController!.clear()),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// Fotos aufnehmen (Typ 4) – 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.effectiveType) {
|
||
case 13:
|
||
case 14:
|
||
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 15:
|
||
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?.effectiveType ?? 0;
|
||
switch (type) {
|
||
case 2:
|
||
final sig = draft.signatureController;
|
||
return sig != null && sig.points.length > 50;
|
||
case 3:
|
||
case 12:
|
||
return draft.confirmed;
|
||
case 4:
|
||
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 13:
|
||
_ensureFieldControllers(draft, taskInfo.task?.fields?.length ?? 0);
|
||
return _fieldsValid(taskInfo, draft, numeric: false);
|
||
case 14:
|
||
_ensureFieldControllers(draft, taskInfo.task?.fields?.length ?? 0);
|
||
return _fieldsValid(taskInfo, draft, numeric: true);
|
||
case 15:
|
||
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 13/14) 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?.effectiveType ?? 0;
|
||
switch (type) {
|
||
case 2:
|
||
final sig = draft.signatureController;
|
||
return sig != null && sig.points.length > 50;
|
||
case 3:
|
||
case 12:
|
||
return draft.confirmed;
|
||
case 4:
|
||
return draft.photos.isNotEmpty;
|
||
case 7:
|
||
case 8:
|
||
case 9:
|
||
return (draft.text?.text.trim().isNotEmpty ?? false);
|
||
case 11:
|
||
return draft.selectedCode != null;
|
||
case 13:
|
||
case 14:
|
||
return draft.fields.any((c) => c.text.trim().isNotEmpty);
|
||
case 15:
|
||
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?.effectiveType ?? 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 2: // Unterschrift
|
||
final answer = await _createAnswerHeader(taskInfo);
|
||
answer.sign = _convertSignaturePoints(draft.signatureController!.points);
|
||
taskInfo.answer = jsonEncode(answer.toJson());
|
||
taskInfo.additionalText = _taskText(taskInfo, 'signature_captured');
|
||
taskInfo.completed = true;
|
||
_persist(taskInfo);
|
||
break;
|
||
|
||
case 3: // 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 4: // 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 12: // 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 13: // Text-Liste
|
||
case 14: // 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 15: // 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?.effectiveType ?? 0;
|
||
|
||
if (type == 13 || type == 14) {
|
||
_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 3:
|
||
case 12:
|
||
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 13:
|
||
case 14:
|
||
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 15:
|
||
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<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,
|
||
"Workflow",
|
||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: Colors.white),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|