- Menüpunkt "Kunden" in "Adressbuch" umbenannt und App-Label "Verfügbare Jobs" zu "Auftragsliste" geändert (alle 10 Sprachen) - Fortlaufende Kundennummer (usrId) ab 10000 über neuen SequenceGeneratorService und Counter-Dokument in misc-Collection - Abholung/Lieferstation-Dialog: Änderungen an verknüpften Stammdaten aktualisieren den bestehenden Adressbuch-Eintrag statt einen neuen zu erzeugen; Checkbox-Label wechselt zu "Adresse im Adressbuch aktualisieren" - Geänderte Adressen ohne Checkbox werden als interner Customer (internal=true) gesichert und im Adressbuch ausgeblendet - E-Mail in AddCustomer und in Stations-Dialogen kein Pflichtfeld mehr; "(Login)" aus profile.email entfernt - Manuelles Beenden eines Auftrags öffnet neue Seite JobManualCompleteView statt eines Dialogs
999 lines
32 KiB
Dart
999 lines
32 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'package:votianlt_app/services/developer.dart' as developer;
|
|
import 'package:image/image.dart' as img;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'app_theme.dart';
|
|
import 'l10n/app_localizations.dart';
|
|
import 'l10n/localization_helpers.dart';
|
|
import 'models/job.dart';
|
|
import 'models/task.dart';
|
|
import 'models/tasks/confirmation_task.dart';
|
|
import 'models/tasks/photo_task.dart';
|
|
import 'models/tasks/todolist_task.dart';
|
|
import 'models/tasks/signature_task.dart';
|
|
import 'models/tasks/barcode_task.dart';
|
|
import 'models/tasks/comment_task.dart';
|
|
import 'services/database_service.dart';
|
|
import 'widgets/offline_banner.dart';
|
|
import 'services/websocket_service.dart';
|
|
import 'Tasks/photo_capture_screen.dart';
|
|
import 'Tasks/barcode_capture_screen.dart';
|
|
import 'Tasks/signature_capture_screen.dart';
|
|
|
|
class TaskView extends StatefulWidget {
|
|
final Job job;
|
|
final int? stationOrder;
|
|
final String? stationTitle;
|
|
|
|
const TaskView({
|
|
super.key,
|
|
required this.job,
|
|
this.stationOrder,
|
|
this.stationTitle,
|
|
});
|
|
|
|
@override
|
|
State<TaskView> createState() => _TaskViewState();
|
|
}
|
|
|
|
class _TaskViewState extends State<TaskView> {
|
|
final Set<String> _completedTasks = {};
|
|
final DatabaseService _databaseService = DatabaseService();
|
|
// Store SVG representations of signatures per task for later use
|
|
final Map<String, String> _signatureSvgByTask = {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadTaskStatuses();
|
|
}
|
|
|
|
List<Task> get _visibleTasks {
|
|
final stationOrder = widget.stationOrder;
|
|
if (stationOrder == null) {
|
|
return widget.job.tasks;
|
|
}
|
|
return widget.job.tasks
|
|
.where((task) => task.stationOrder == stationOrder)
|
|
.toList();
|
|
}
|
|
|
|
/// Load task completion and skipped statuses from database and merge with JSON task states
|
|
Future<void> _loadTaskStatuses() async {
|
|
final statuses = await _databaseService.loadAllTaskStatuses();
|
|
setState(() {
|
|
_completedTasks.clear();
|
|
// 1) Add all completed from DB
|
|
for (final entry in statuses.entries) {
|
|
if (entry.value) {
|
|
_completedTasks.add(entry.key);
|
|
}
|
|
}
|
|
// 2) Merge: also mark tasks completed if the job JSON already had them completed
|
|
for (final t in widget.job.tasks) {
|
|
if (t.completed) {
|
|
_completedTasks.add(t.id);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(
|
|
widget.stationTitle?.isNotEmpty == true
|
|
? '${AppLocalizations.of(context).tasks} - ${widget.stationTitle}'
|
|
: '${AppLocalizations.of(context).tasks} - ${widget.job.jobNumber}',
|
|
),
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
},
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.chat),
|
|
onPressed: () {
|
|
Navigator.of(context).pushNamed('/chats');
|
|
},
|
|
tooltip: AppLocalizations.of(context).openChat,
|
|
),
|
|
],
|
|
),
|
|
body: Column(
|
|
children: [
|
|
OfflineBanner(),
|
|
if (_getRemark().isNotEmpty)
|
|
Container(
|
|
width: double.infinity,
|
|
margin: const EdgeInsets.all(5),
|
|
constraints: const BoxConstraints(maxHeight: 150),
|
|
padding: const EdgeInsets.all(12.0),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surfaceMuted,
|
|
border: Border.all(color: AppColors.border, width: 1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: SingleChildScrollView(
|
|
child: Text(
|
|
_getRemark(),
|
|
style: const TextStyle(fontSize: 14, color: AppColors.text),
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(child: _buildTasksStepper()),
|
|
if (_visibleTasks.isNotEmpty) ...[
|
|
const SizedBox(height: 12),
|
|
_buildCompleteStationButton(),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
bool get _canCompleteStation {
|
|
// Station kann abgeschlossen werden, wenn alle nicht-optionalen Aufgaben
|
|
// erledigt sind. Sind nur optionale Aufgaben vorhanden, ist der Button
|
|
// immer aktiv.
|
|
for (final t in _visibleTasks) {
|
|
if (!t.optional && !_completedTasks.contains(t.id)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool get _hasIncompleteOptionalTasks {
|
|
for (final t in _visibleTasks) {
|
|
if (t.optional && !_completedTasks.contains(t.id)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
Widget _buildCompleteStationButton() {
|
|
final bool enabled = _canCompleteStation;
|
|
return SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton.icon(
|
|
onPressed: enabled ? _onCompleteStationPressed : null,
|
|
icon: const Icon(Icons.flag),
|
|
label: const Text('Station abschließen'),
|
|
style: ElevatedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _onCompleteStationPressed() {
|
|
if (_hasIncompleteOptionalTasks) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (BuildContext ctx) {
|
|
return AlertDialog(
|
|
title: const Text('Offene optionale Aufgaben'),
|
|
content: const Text(
|
|
'Es gibt nicht erledigte optionale Aufgaben. Möchten Sie die Station trotzdem abschließen?',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
child: Text(AppLocalizations.of(context).cancel),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.of(ctx).pop();
|
|
_sendStationCompleted();
|
|
},
|
|
child: const Text('Trotzdem abschließen'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
} else {
|
|
_sendStationCompleted();
|
|
}
|
|
}
|
|
|
|
void _sendStationCompleted() {
|
|
final stationOrder = widget.stationOrder ?? 0;
|
|
try {
|
|
StompService().sendStationCompleted(
|
|
jobId: widget.job.id,
|
|
jobNumber: widget.job.jobNumber,
|
|
stationOrder: stationOrder,
|
|
hasIncompleteOptionalTasks: _hasIncompleteOptionalTasks,
|
|
);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Station abgeschlossen')),
|
|
);
|
|
Navigator.of(context).pop();
|
|
} catch (e) {
|
|
developer.log('Error sending station completion: $e', name: 'TaskView');
|
|
}
|
|
}
|
|
|
|
Widget _buildTasksStepper() {
|
|
if (_visibleTasks.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(
|
|
Icons.task_outlined,
|
|
size: 64,
|
|
color: AppColors.textMuted,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
AppLocalizations.of(context).noTasks,
|
|
style: const TextStyle(fontSize: 16, color: AppColors.textMuted),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
AppLocalizations.of(context).noTasksMessage,
|
|
style: const TextStyle(fontSize: 14, color: AppColors.textMuted),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return ListView.builder(
|
|
itemCount: _visibleTasks.length,
|
|
itemBuilder: (context, index) {
|
|
final task = _visibleTasks[index];
|
|
final isCompleted = _completedTasks.contains(task.id);
|
|
final canBeCompletedNow =
|
|
!isCompleted && _arePreviousTasksCompleted(index);
|
|
|
|
// Hintergrundfarbe je nach Status:
|
|
// abgeschlossen → hellgrün, bearbeitbar → weiß, gesperrt → hellgrau
|
|
// (Optionale Aufgaben werden durch einen Chip markiert, nicht per Farbe.)
|
|
final Color cardColor =
|
|
isCompleted
|
|
? AppColors.successSoft
|
|
: canBeCompletedNow
|
|
? AppColors.surface
|
|
: AppColors.surfaceMuted;
|
|
final Color borderColor =
|
|
isCompleted
|
|
? AppColors.success.withValues(alpha: 0.35)
|
|
: canBeCompletedNow
|
|
? AppColors.border
|
|
: AppColors.border.withValues(alpha: 0.7);
|
|
final Color circleColor =
|
|
isCompleted
|
|
? AppColors.success
|
|
: canBeCompletedNow
|
|
? AppColors.primary
|
|
: AppColors.textMuted;
|
|
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
elevation: isCompleted || canBeCompletedNow ? 2 : 0,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
side: BorderSide(color: borderColor, width: 1),
|
|
),
|
|
child: InkWell(
|
|
onTap:
|
|
canBeCompletedNow
|
|
? () => _showTaskCompletionDialog(task, index)
|
|
: null,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
color: cardColor,
|
|
),
|
|
child: Stack(
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
// Task number circle
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: circleColor,
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
'${index + 1}',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
// Task content
|
|
Expanded(
|
|
child: Padding(
|
|
padding: EdgeInsets.only(
|
|
right: task.optional ? 72 : 0,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildTaskDisplayText(task, isCompleted, index),
|
|
if (_getTaskStationLabel(task) != null) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
_getTaskStationLabel(task)!,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[600],
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (isCompleted) ...[
|
|
const SizedBox(width: 8),
|
|
const Icon(
|
|
Icons.check_circle,
|
|
color: AppColors.success,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
if (task.optional)
|
|
Positioned.fill(
|
|
child: Align(
|
|
alignment: Alignment.centerRight,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.warningSoft,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: AppColors.warning.withValues(alpha: 0.5),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: const Text(
|
|
'Optional',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.warning,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _showTaskCompletionDialog(Task task, int taskIndex) {
|
|
switch (task) {
|
|
case ConfirmationTask():
|
|
_showConfirmationDialog(task, taskIndex);
|
|
break;
|
|
case PhotoTask():
|
|
_showPhotoDialog(task);
|
|
break;
|
|
case TodoListTask():
|
|
_showTodoListDialog(task);
|
|
break;
|
|
case SignatureTask():
|
|
_showSignatureDialog(task);
|
|
break;
|
|
case BarcodeTask():
|
|
_showBarcodeDialog(task);
|
|
break;
|
|
case CommentTask():
|
|
_showCommentDialog(task);
|
|
break;
|
|
default:
|
|
_showGenericDialog(task);
|
|
break;
|
|
}
|
|
}
|
|
|
|
void _showConfirmationDialog(ConfirmationTask task, int taskIndex) {
|
|
final description =
|
|
task.description?.isNotEmpty == true
|
|
? task.description!
|
|
: AppLocalizations.of(context).confirmationDescription;
|
|
final buttonText =
|
|
task.buttonText.isNotEmpty
|
|
? task.buttonText
|
|
: AppLocalizations.of(context).confirm;
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text(AppLocalizations.of(context).confirmationRequired),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [Text(description)],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: Text(AppLocalizations.of(context).cancel),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
_completeTask(task.id, taskType: 'CONFIRMATION');
|
|
},
|
|
child: Text(buttonText),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
// Compress photos and Base64-encode while keeping total payload under a cap
|
|
Future<List<String>> _compressAndEncodePhotos(
|
|
List<Uint8List> photos, {
|
|
int maxDim = 1280,
|
|
int jpegQuality = 70,
|
|
int maxTotalBase64Bytes = 450 * 1024,
|
|
}) async {
|
|
final List<String> encoded = [];
|
|
int total = 0;
|
|
for (final bytes in photos) {
|
|
try {
|
|
final img.Image? decoded = img.decodeImage(bytes);
|
|
if (decoded == null) {
|
|
continue;
|
|
}
|
|
// Resize if needed keeping aspect ratio
|
|
final int w = decoded.width;
|
|
final int h = decoded.height;
|
|
img.Image resized = decoded;
|
|
final int longest = w > h ? w : h;
|
|
if (longest > maxDim) {
|
|
if (w >= h) {
|
|
resized = img.copyResize(decoded, width: maxDim);
|
|
} else {
|
|
resized = img.copyResize(decoded, height: maxDim);
|
|
}
|
|
}
|
|
final List<int> jpg = img.encodeJpg(resized, quality: jpegQuality);
|
|
final String b64 = base64Encode(jpg);
|
|
// Respect total payload cap
|
|
if (total + b64.length > maxTotalBase64Bytes) {
|
|
break;
|
|
}
|
|
encoded.add(b64);
|
|
total += b64.length;
|
|
} catch (e, st) {
|
|
developer.log('Photo compress/encode error: $e', name: 'TaskView');
|
|
developer.log('Stack: $st', name: 'TaskView');
|
|
}
|
|
}
|
|
return encoded;
|
|
}
|
|
|
|
void _showPhotoDialog(PhotoTask task) {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder:
|
|
(context) => PhotoCaptureScreen(
|
|
task: task,
|
|
onPhotosCompleted: (List<Uint8List> photoData) async {
|
|
// Compress + encode photos for network send (limit payload)
|
|
final List<String> base64List = await _compressAndEncodePhotos(
|
|
photoData,
|
|
);
|
|
final bool truncated = base64List.length < photoData.length;
|
|
|
|
// Try to persist full-quality (encoded) photos to DB for offline/backup
|
|
try {
|
|
// Persist the compressed versions to keep DB size reasonable as well
|
|
await _databaseService.saveTaskPhotos(task.id, base64List);
|
|
} catch (e, stackTrace) {
|
|
developer.log(
|
|
'Error saving task photos: $e',
|
|
name: 'TaskView',
|
|
);
|
|
developer.log('Stack trace: $stackTrace', name: 'TaskView');
|
|
}
|
|
|
|
// Always complete the task regardless of persistence success/failure
|
|
_completeTask(
|
|
task.id,
|
|
taskType: 'PHOTO',
|
|
extraData: {
|
|
'photos': base64List,
|
|
'count': photoData.length,
|
|
if (truncated) 'truncated': true,
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showTodoListDialog(TodoListTask task) {
|
|
final items = task.todoItems;
|
|
final List<bool> checkedItems = List.filled(items.length, false);
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (BuildContext context) {
|
|
return StatefulBuilder(
|
|
builder: (context, setState) {
|
|
return AlertDialog(
|
|
title: Text(AppLocalizations.of(context).checklist),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(AppLocalizations.of(context).checklistDescription),
|
|
const SizedBox(height: 16),
|
|
...items.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final item = entry.value;
|
|
return CheckboxListTile(
|
|
title: Text(item),
|
|
value: checkedItems[index],
|
|
onChanged: (bool? value) {
|
|
setState(() {
|
|
checkedItems[index] = value ?? false;
|
|
});
|
|
},
|
|
dense: true,
|
|
contentPadding: EdgeInsets.zero,
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: Text(AppLocalizations.of(context).abort),
|
|
),
|
|
ElevatedButton(
|
|
onPressed:
|
|
checkedItems.every((checked) => checked)
|
|
? () {
|
|
Navigator.of(context).pop();
|
|
_completeTask(
|
|
task.id,
|
|
taskType: 'TODOLIST',
|
|
extraData: {
|
|
'items': task.todoItems,
|
|
'checkedStates': checkedItems,
|
|
},
|
|
);
|
|
}
|
|
: null,
|
|
child: Text(AppLocalizations.of(context).finish),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _showSignatureDialog(SignatureTask task) {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder:
|
|
(context) => SignatureCaptureScreen(
|
|
task: task,
|
|
onSignatureCompleted: (String svg, String note) async {
|
|
try {
|
|
// Persist SVG only (no PNG)
|
|
await _databaseService.saveTaskSignature(task.id, svg);
|
|
await _databaseService.saveTaskSignatureNote(task.id, note);
|
|
} catch (e, stackTrace) {
|
|
developer.log(
|
|
'Error saving task signature: $e',
|
|
name: 'TaskView',
|
|
);
|
|
developer.log('Stack trace: $stackTrace', name: 'TaskView');
|
|
}
|
|
// Store SVG for later use in this TaskView session
|
|
setState(() {
|
|
_signatureSvgByTask[task.id] = svg;
|
|
});
|
|
// Read back once (for analyzer to see it used) and optional debug
|
|
debugPrint(
|
|
'Signature SVG stored for task ${task.id}: length=${_signatureSvgByTask[task.id]?.length ?? 0}',
|
|
);
|
|
|
|
_completeTask(
|
|
task.id,
|
|
taskType: 'SIGNATURE',
|
|
extraData: {
|
|
'signatureSvg': svg,
|
|
'svgLength': svg.length,
|
|
'hasSignature': true,
|
|
'signatureNote': note,
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showBarcodeDialog(BarcodeTask task) {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder:
|
|
(context) => BarcodeCaptureScreen(
|
|
task: task,
|
|
onBarcodesCompleted: (List<String> barcodes) async {
|
|
try {
|
|
// Save barcodes to database for later use
|
|
await _databaseService.saveTaskBarcodes(task.id, barcodes);
|
|
} catch (e, stackTrace) {
|
|
developer.log(
|
|
'Error saving task barcodes: $e',
|
|
name: 'TaskView',
|
|
);
|
|
developer.log('Stack trace: $stackTrace', name: 'TaskView');
|
|
}
|
|
_completeTask(
|
|
task.id,
|
|
taskType: 'BARCODE',
|
|
extraData: {'barcodes': barcodes, 'count': barcodes.length},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showGenericDialog(Task task) {
|
|
final TextEditingController noteController = TextEditingController();
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text(AppLocalizations.of(context).completeTask),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(AppLocalizations.of(context).completeTaskConfirm),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: noteController,
|
|
decoration: InputDecoration(
|
|
labelText: AppLocalizations.of(context).completeTaskNote,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
maxLines: 3,
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: Text(AppLocalizations.of(context).abort),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
_completeTask(task.id, taskType: 'GENERIC');
|
|
},
|
|
child: Text(AppLocalizations.of(context).complete),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _completeTask(
|
|
String taskId, {
|
|
String? taskType,
|
|
Map<String, dynamic>? extraData,
|
|
}) {
|
|
final bool hadOpenMandatoryBefore = _hasOpenMandatoryTasks;
|
|
setState(() {
|
|
_completedTasks.add(taskId);
|
|
});
|
|
// Save to database
|
|
_databaseService.saveTaskStatus(taskId, true);
|
|
|
|
// Notify server via STOMP about task completion (best-effort)
|
|
try {
|
|
StompService().sendTaskCompleted(
|
|
taskId: taskId,
|
|
taskType: taskType,
|
|
extraData: extraData,
|
|
);
|
|
} catch (e) {
|
|
developer.log('Error sending task completion: $e', name: 'TaskView');
|
|
}
|
|
|
|
// Wenn die letzte nicht-optionale Aufgabe gerade erledigt wurde,
|
|
// den Benutzer fragen, ob er die Station jetzt abschließen möchte.
|
|
if (hadOpenMandatoryBefore && !_hasOpenMandatoryTasks) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
_showLastMandatoryCompletedDialog();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
bool get _hasOpenMandatoryTasks {
|
|
for (final t in _visibleTasks) {
|
|
if (!t.optional && !_completedTasks.contains(t.id)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void _showLastMandatoryCompletedDialog() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (BuildContext ctx) {
|
|
return AlertDialog(
|
|
title: const Text('Alle Pflichtaufgaben erledigt'),
|
|
content: const Text(
|
|
'Alle nicht optionalen Aufgaben dieser Station sind erledigt. '
|
|
'Möchten Sie die Station jetzt abschließen?',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
child: const Text('Später'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.of(ctx).pop();
|
|
_onCompleteStationPressed();
|
|
},
|
|
child: const Text('Station abschließen'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
bool _arePreviousTasksCompleted(int index) {
|
|
if (index <= 0) return true;
|
|
for (int i = 0; i < index; i++) {
|
|
final t = _visibleTasks[i];
|
|
if (!t.optional && !_completedTasks.contains(t.id)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void _showCommentDialog(CommentTask task) {
|
|
final TextEditingController commentController = TextEditingController();
|
|
commentController.text = task.commentText;
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text(AppLocalizations.of(context).enterComment),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(AppLocalizations.of(context).commentDescription),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: commentController,
|
|
decoration: InputDecoration(
|
|
labelText:
|
|
task.required
|
|
? AppLocalizations.of(context).commentRequired
|
|
: AppLocalizations.of(context).comment,
|
|
border: const OutlineInputBorder(),
|
|
hintText: '...',
|
|
),
|
|
maxLines: 4,
|
|
minLines: 2,
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: Text(AppLocalizations.of(context).abort),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
final comment = commentController.text.trim();
|
|
if (task.required && comment.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
AppLocalizations.of(context).commentRequired,
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
Navigator.of(context).pop();
|
|
_completeTask(
|
|
task.id,
|
|
taskType: 'COMMENT',
|
|
extraData: {
|
|
'commentText': comment,
|
|
'required': task.required,
|
|
},
|
|
);
|
|
},
|
|
child: Text(AppLocalizations.of(context).save),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildTaskDisplayText(Task task, bool isCompleted, int taskIndex) {
|
|
final titleStyle = TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.grey[800],
|
|
decoration: isCompleted ? TextDecoration.lineThrough : null,
|
|
);
|
|
final subtitleStyle = TextStyle(
|
|
fontSize: 13,
|
|
color: Colors.grey[600],
|
|
decoration: isCompleted ? TextDecoration.lineThrough : null,
|
|
);
|
|
|
|
final displayName =
|
|
task.displayName != null
|
|
? localizeKnownText(context, task.displayName!)
|
|
: null;
|
|
final description =
|
|
task.description != null
|
|
? localizeKnownText(context, task.description!)
|
|
: null;
|
|
final String? signatureNote =
|
|
(task is SignatureTask && task.note != null && task.note!.trim().isNotEmpty)
|
|
? task.note!.trim()
|
|
: null;
|
|
|
|
if (displayName?.isNotEmpty == true) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(displayName!, style: titleStyle),
|
|
if (description?.isNotEmpty == true) ...[
|
|
const SizedBox(height: 2),
|
|
Text(description!, style: subtitleStyle),
|
|
],
|
|
if (signatureNote != null) ...[
|
|
const SizedBox(height: 2),
|
|
Text(signatureNote, style: subtitleStyle),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
if (description?.isNotEmpty == true) {
|
|
if (signatureNote != null) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(description!, style: titleStyle),
|
|
const SizedBox(height: 2),
|
|
Text(signatureNote, style: subtitleStyle),
|
|
],
|
|
);
|
|
}
|
|
return Text(description!, style: titleStyle);
|
|
}
|
|
|
|
if (signatureNote != null) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(_getStandardTaskDisplayText(task), style: titleStyle),
|
|
const SizedBox(height: 2),
|
|
Text(signatureNote, style: subtitleStyle),
|
|
],
|
|
);
|
|
}
|
|
|
|
// Fall back to standard text based on task type
|
|
return Text(_getStandardTaskDisplayText(task), style: titleStyle);
|
|
}
|
|
|
|
String _getStandardTaskDisplayText(Task task) {
|
|
// Generate display text based on task type
|
|
switch (task) {
|
|
case PhotoTask():
|
|
return '${AppLocalizations.of(context).takePhotos} (${task.minPhotoCount}-${task.maxPhotoCount} ${AppLocalizations.of(context).photosCount})';
|
|
|
|
case TodoListTask():
|
|
return '${AppLocalizations.of(context).checklist} (${task.todoItems.length} ${AppLocalizations.of(context).checklistPoints})';
|
|
|
|
case SignatureTask():
|
|
return AppLocalizations.of(context).signatureRequiredText;
|
|
|
|
case BarcodeTask():
|
|
return '${AppLocalizations.of(context).scanBarcodes} (${task.minBarcodeCount}-${task.maxBarcodeCount} ${AppLocalizations.of(context).barcodeCount})';
|
|
|
|
case CommentTask():
|
|
return task.required
|
|
? AppLocalizations.of(context).commentRequired
|
|
: AppLocalizations.of(context).commentOptional;
|
|
|
|
default:
|
|
return AppLocalizations.of(context).genericTask;
|
|
}
|
|
}
|
|
|
|
String _getRemark() => widget.job.remark;
|
|
|
|
String? _getTaskStationLabel(Task task) {
|
|
if (widget.stationOrder != null) {
|
|
return null;
|
|
}
|
|
final stationOrder = task.stationOrder;
|
|
if (stationOrder == null) {
|
|
return null;
|
|
}
|
|
|
|
for (final station in widget.job.deliveryStations) {
|
|
if (station.stationOrder == stationOrder) {
|
|
final suffix =
|
|
station.displayName.isNotEmpty ? station.displayName : station.city;
|
|
return localizedStationLabel(context, stationOrder + 1, suffix: suffix);
|
|
}
|
|
}
|
|
|
|
return AppLocalizations.of(context).stationNumber(stationOrder + 1);
|
|
}
|
|
}
|