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 createState() => _TaskViewState(); } class _TaskViewState extends State { final Set _completedTasks = {}; final DatabaseService _databaseService = DatabaseService(); // Store SVG representations of signatures per task for later use final Map _signatureSvgByTask = {}; @override void initState() { super.initState(); _loadTaskStatuses(); } List 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 _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> _compressAndEncodePhotos( List photos, { int maxDim = 1280, int jpegQuality = 70, int maxTotalBase64Bytes = 450 * 1024, }) async { final List 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 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 photoData) async { // Compress + encode photos for network send (limit payload) final List 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 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) async { try { // Persist SVG only (no PNG) await _databaseService.saveTaskSignature(task.id, svg); } 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, }, ); }, ), ), ); } void _showBarcodeDialog(BarcodeTask task) { Navigator.of(context).push( MaterialPageRoute( builder: (context) => BarcodeCaptureScreen( task: task, onBarcodesCompleted: (List 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? 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; 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 (description?.isNotEmpty == true) { return Text(description!, style: titleStyle); } // 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); } }