Fracht mit Maßen und Gewicht anzeigen und Stationsabschlüsse gegen Wiederholung absichern. Backend-Abschluss atomar ausführen und mit Regressionstests abdecken. Optionale Aufgaben beim Kopieren erhalten und Kunden mit Adressen anzeigen. App und Backend auf Version 0.9.23 aktualisieren.
1025 lines
33 KiB
Dart
1025 lines
33 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();
|
|
bool _statusesLoaded = false;
|
|
bool _stationCompleted = false;
|
|
bool _isCompletingStation = false;
|
|
// 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();
|
|
}
|
|
|
|
String get _stationCompletionKey =>
|
|
'station_completed:${widget.job.id}:${widget.stationOrder ?? 0}';
|
|
|
|
/// Load task completion and skipped statuses from database and merge with JSON task states
|
|
Future<void> _loadTaskStatuses() async {
|
|
final statuses = await _databaseService.loadAllTaskStatuses();
|
|
final stationCompleted =
|
|
await _databaseService.loadKeyValue(_stationCompletionKey) == '1';
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_stationCompleted =
|
|
stationCompleted || widget.job.status.toUpperCase() == 'COMPLETED';
|
|
_statusesLoaded = true;
|
|
_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 {
|
|
if (!_statusesLoaded || _stationCompleted || _isCompletingStation) {
|
|
return false;
|
|
}
|
|
// Station kann abgeschlossen werden, wenn alle nicht-optionalen Aufgaben
|
|
// erledigt sind und der Stationsabschluss noch nicht gesendet wurde.
|
|
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 (!_canCompleteStation) return;
|
|
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();
|
|
}
|
|
}
|
|
|
|
Future<void> _sendStationCompleted() async {
|
|
if (!_canCompleteStation) return;
|
|
setState(() => _isCompletingStation = true);
|
|
final stationOrder = widget.stationOrder ?? 0;
|
|
try {
|
|
await StompService().sendStationCompleted(
|
|
jobId: widget.job.id,
|
|
jobNumber: widget.job.jobNumber,
|
|
stationOrder: stationOrder,
|
|
hasIncompleteOptionalTasks: _hasIncompleteOptionalTasks,
|
|
);
|
|
_stationCompleted = true;
|
|
await _databaseService.saveKeyValue(_stationCompletionKey, '1');
|
|
if (!mounted) return;
|
|
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');
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() => _isCompletingStation = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 && _canCompleteStation) {
|
|
_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);
|
|
}
|
|
}
|