import 'package:flutter/material.dart'; import 'app_state.dart'; import 'l10n/app_localizations.dart'; import 'services/websocket_service.dart'; import 'services/dart_mq.dart'; import 'services/chat_service.dart'; import 'models/delivery_station.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 'widgets/offline_banner.dart'; import 'package:votianlt_app/services/developer.dart' as developer; import 'dart:async'; import 'services/database_service.dart'; import 'navigation_observer.dart'; import 'routing_view.dart'; class JobsView extends StatefulWidget { const JobsView({super.key}); @override State createState() => _JobsViewState(); } class _JobsViewState extends State with RouteAware { bool _routeActionInProgress = false; void _openRoutingView({ required String address, required bool isDelivery, String? title, }) { Navigator.of(context).push( MaterialPageRoute( builder: (_) => RoutingView( address: address, isDelivery: isDelivery, title: title, ), ), ); } final AppState _appState = AppState(); final StompService _stompService = StompService(); final ChatService _chatService = ChatService(); bool _isLoadingDialogShowing = false; bool _isLoadingJobs = false; DartMQSubscription? _jobsSub; DartMQSubscription? _connectionSub; DartMQSubscription? _jobDeletedSub; DartMQSubscription? _jobCreatedSub; bool _wasConnected = false; bool _isLoggingOut = false; final DatabaseService _databaseService = DatabaseService(); Map _taskStatuses = const {}; Map _jobSeen = const {}; Map _jobSwipeOffsets = const {}; String? _openJobId; static const double _jobSwipeRevealOffset = 50.0; final Set _jobsBeingDeleted = {}; // Listen to AppState jobsUpdated to apply UI refresh exactly once StreamSubscription? _jobsUpdatedSub; bool _isApplyingJobs = false; StreamSubscription? _unreadCountSub; int _unreadMessageCount = 0; @override void initState() { super.initState(); // Always load local task completion statuses to compute progress/card colors _loadLocalTaskStatuses(); // Remember initial connection state _wasConnected = _stompService.isConnected; // Subscribe to route changes WidgetsBinding.instance.addPostFrameCallback((_) { final route = ModalRoute.of(context); if (route != null) { routeObserver.subscribe(this, route); } }); // Listen to connection changes to show banner (handled by widget) and trigger reloads _connectionSub = DartMQ().subscribe(MQTopics.connectionStatus, ( isConnected, ) { // Went online if (isConnected && !_wasConnected) { _showSnack( AppLocalizations.of(context).connectionRestored, backgroundColor: Colors.green, ); if (_appState.isLoggedIn) { _loadJobs(); } } // Went offline if (!isConnected && _wasConnected) { if (mounted && _isLoadingDialogShowing) { _isLoadingDialogShowing = false; Navigator.of(context, rootNavigator: true).pop(); } // Only show offline message when user is logged in and not in logout flow if (_appState.isLoggedIn && !_isLoggingOut) { _showSnack( AppLocalizations.of(context).connectionLost, backgroundColor: Colors.red, ); } } _wasConnected = isConnected; }); // Listen to job deletion events from server _jobDeletedSub = DartMQ().subscribe< Map >(MQTopics.jobDeleted, (data) async { final jobId = data['jobId']?.toString(); final jobNumber = data['jobNumber']?.toString(); if (jobId == null) return; developer.log( 'Job deleted event received: $jobId ($jobNumber)', name: 'JobsView', ); // Delete job from AppState (which also updates DB and notifies listeners) await _appState.deleteJob(jobId); // Show notification to user if (mounted) { final message = jobNumber != null ? 'Job $jobNumber ${AppLocalizations.of(context).jobRemoved}' : AppLocalizations.of(context).jobRemoved; _showSnack(message, backgroundColor: Colors.orange); } }); // Listen to job creation events from server _jobCreatedSub = DartMQ().subscribe< Map >(MQTopics.jobCreated, (data) async { developer.log('Job created event received', name: 'JobsView'); try { // WICHTIG: Der Job wurde bereits vom WebSocketService übersetzt und in die DB gespeichert. // Wir laden die Jobs aus der Datenbank neu, um die übersetzte Version anzuzeigen. developer.log( 'Reloading jobs from database to get translated version...', name: 'JobsView', ); await _loadJobsFromDatabase(); // Extract job number for notification final jobNumber = data['job']?['jobNumber']?.toString() ?? data['jobNumber']?.toString() ?? ''; // Show notification to user if (mounted) { final message = jobNumber.isNotEmpty ? '${AppLocalizations.of(context).newJobReceived}: $jobNumber' : AppLocalizations.of(context).newJobReceived; _showSnack(message, backgroundColor: Colors.green); } } catch (e) { developer.log('Error handling job_created event: $e', name: 'JobsView'); } }); // Listen once-per-cycle for jobs updates from AppState _jobsUpdatedSub = _appState.jobsUpdated.listen((_) async { if (_isApplyingJobs) { return; } _isApplyingJobs = true; try { await _appState.refreshJobsFromDatabase(); await _loadLocalTaskStatuses(); await _loadSeenFlagsForCurrentJobs(); if (mounted && _isLoadingDialogShowing) { _isLoadingDialogShowing = false; Navigator.of(context, rootNavigator: true).pop(); } if (mounted) { setState(() { _syncSwipeStateWithJobs(); }); _showSnack( AppLocalizations.of(context).jobsUpdated, backgroundColor: Colors.green, ); } } finally { _isApplyingJobs = false; } }); // Listen to unread message count changes _unreadCountSub = _chatService.unreadCountStream.listen((count) { developer.log( '[DEBUG_LOG] JobsView received unread count from stream: $count', name: 'JobsView', ); if (mounted) { setState(() { _unreadMessageCount = count; }); developer.log( '[DEBUG_LOG] JobsView updated badge with count: $_unreadMessageCount', name: 'JobsView', ); } }); // Initialize chat service and get initial unread count _chatService.initialize().then((_) { developer.log( '[DEBUG_LOG] ChatService initialized, initial unread count: ${_chatService.unreadCount}', name: 'JobsView', ); if (mounted) { setState(() { _unreadMessageCount = _chatService.unreadCount; }); developer.log( '[DEBUG_LOG] JobsView set initial badge count to: $_unreadMessageCount', name: 'JobsView', ); } }); // Load jobs from database first (for offline/cached jobs) _loadJobsFromDatabase(); _initializeAndLoadJobs(); // Also load seen flags for any jobs already in memory (e.g., from DB) _loadSeenFlagsForCurrentJobs(); } /// Load jobs from database on startup Future _loadJobsFromDatabase() async { try { developer.log( 'Loading jobs from database on startup...', name: 'JobsView', ); await _appState.refreshJobsFromDatabase(); await _loadLocalTaskStatuses(); await _loadSeenFlagsForCurrentJobs(); if (mounted) { setState(() { _syncSwipeStateWithJobs(); }); developer.log( 'Jobs loaded from database: ${_appState.assignedJobs.length}', name: 'JobsView', ); // Debug: Log each job loaded from database for (int i = 0; i < _appState.assignedJobs.length; i++) { final job = _appState.assignedJobs[i]; developer.log( 'DB Job $i: ${job.jobNumber} (${job.id}) - Tasks: ${job.tasks.length}, CargoItems: ${job.cargoItems.length}', name: 'JobsView', ); } } } catch (e, stackTrace) { developer.log('Error loading jobs from database: $e', name: 'JobsView'); developer.log('Stack trace: $stackTrace', name: 'JobsView'); } } /// Initialize connection and load jobs Future _initializeAndLoadJobs() async { // Only proceed if user is logged in if (!_appState.isLoggedIn) { developer.log( 'Skip jobs initialization: user not logged in', name: 'JobsView', ); return; } try { // If not connected and authenticated, initiate connection and wait final isFullyConnected = _stompService.isConnected && _stompService.isAuthenticated; if (!isFullyConnected) { developer.log( 'No authenticated connection at jobs load time - initiating connection', name: 'JobsView', ); // Show loading dialog while waiting for connection if (mounted && !_isLoadingDialogShowing) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && !_isLoadingDialogShowing) { _showLoadingDialog(); } }); } // Initiate WebSocket connection (will auto-login with saved credentials) // connect() is safe to call multiple times - it prevents overlapping attempts _stompService.connect(); // Wait for connection and authentication with timeout await _waitForConnection(); } // Show loading dialog if not already shown and we're fully connected final isNowFullyConnected = _stompService.isConnected && _stompService.isAuthenticated; if (mounted && !_isLoadingDialogShowing && isNowFullyConnected) { WidgetsBinding.instance.addPostFrameCallback((_) { final stillFullyConnected = _stompService.isConnected && _stompService.isAuthenticated; if (mounted && !_isLoadingDialogShowing && stillFullyConnected) { _showLoadingDialog(); } }); } // Load jobs only if connected AND authenticated if (_stompService.isConnected && _stompService.isAuthenticated) { await _loadJobs(); } else { _showSnack( 'Verbindung zum Server konnte nicht hergestellt werden.', backgroundColor: Colors.red, ); } } catch (e) { developer.log('Error during initialization: $e', name: 'JobsView'); } // Reset loading flag after attempt (no dialog to close) if (mounted && _isLoadingDialogShowing) { _isLoadingDialogShowing = false; } } /// Wait for WebSocket connection and authentication to be established Future _waitForConnection() async { // If already connected and authenticated, return immediately if (_stompService.isConnected && _stompService.isAuthenticated) { return; } final completer = Completer(); // Listen for connection status changes final connectionSub = DartMQ().subscribe(MQTopics.connectionStatus, ( isConnected, ) { if (isConnected && !completer.isCompleted) { completer.complete(); } }); try { // Wait with timeout (30 seconds) await completer.future.timeout(const Duration(seconds: 30)); } catch (e) { developer.log('Connection wait timeout or error: $e', name: 'JobsView'); } finally { // Cancel subscription after completer resolves or times out connectionSub.cancel(); } } /// Preload task statuses (no dialog shown) void _showLoadingDialog() { _isLoadingDialogShowing = true; // Preload task statuses to compute card colors _databaseService.loadAllTaskStatuses().then((map) { if (!mounted) return; setState(() { _taskStatuses = map; }); }); // Dialog removed - loading happens silently in background } /// Load jobs from server Future _loadJobs() async { if (!_appState.isLoggedIn) { developer.log('Not logged in - cannot load jobs', name: 'JobsView'); return; } if (_isLoadingJobs) { developer.log( 'Load jobs already in progress - skipping', name: 'JobsView', ); return; } _isLoadingJobs = true; final completer = Completer(); try { developer.log('Loading jobs...', name: 'JobsView'); // Listen for first jobs response only _jobsSub?.cancel(); _jobsSub = DartMQ().subscribe>(MQTopics.jobsResponse, ( jobsData, ) async { if (!mounted) return; final List list = jobsData; developer.log( 'Jobs response received: ${list.length} jobs', name: 'JobsView', ); // WICHTIG: Die Jobs wurden bereits vom WebSocketService übersetzt und in die DB gespeichert. // Wir laden die Jobs aus der Datenbank, um die übersetzten Versionen zu erhalten. developer.log( 'Loading translated jobs from database...', name: 'JobsView', ); await _loadJobsFromDatabase(); // Complete and cancel subscription if (!completer.isCompleted) { completer.complete(); } _jobsSub?.cancel(); _jobsSub = null; _isLoadingJobs = false; }); } catch (e) { developer.log('Error loading jobs: $e', name: 'JobsView'); if (mounted && _isLoadingDialogShowing) { _isLoadingDialogShowing = false; } _isLoadingJobs = false; } } // Helper to show SnackBars safely (not during initState) void _showSnack(String message, {Color? backgroundColor}) { if (!mounted) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; final messenger = ScaffoldMessenger.maybeOf(context); messenger?.showSnackBar( SnackBar( content: Text(message), backgroundColor: backgroundColor, duration: const Duration(seconds: 1), ), ); }); } Future _loadLocalTaskStatuses() async { final map = await _databaseService.loadAllTaskStatuses(); if (!mounted) return; setState(() { _taskStatuses = map; }); } Future _loadSeenFlagsForCurrentJobs() async { final jobs = _appState.assignedJobs; if (jobs.isEmpty) return; final ids = jobs.map((j) => j.id).toSet(); final seenMap = await _databaseService.loadSeenJobsForIds(ids); if (!mounted) return; setState(() { _jobSeen = seenMap; }); } String _two(int n) => n.toString().padLeft(2, '0'); String _formatDateString(String dateStr) { // Convert "YYYY-MM-DD" to "DD.MM.YYYY" if (dateStr.isEmpty) return ''; final parts = dateStr.split('-'); if (parts.length == 3) { return '${parts[2]}.${parts[1]}.${parts[0]}'; } return dateStr; } String _formatTimeString(String timeStr) { // Convert "HH:MM:SS" to "HH:MM" if (timeStr.isEmpty) return ''; final parts = timeStr.split(':'); if (parts.length >= 2) { return '${parts[0]}:${parts[1]}'; } return timeStr; } String _formatDate(DateTime dt) { // Format: dd.MM.yyyy HH:mm return '${_two(dt.day)}.${_two(dt.month)}.${dt.year} ${_two(dt.hour)}:${_two(dt.minute)}'; } String _joinNonEmpty(List parts, {String sep = ' '}) => parts.where((p) => p.trim().isNotEmpty).join(sep); @override void didPopNext() { // Called when returning to this route from another route (e.g., TaskView) // Reload local task statuses so progress and card colors reflect changes _loadLocalTaskStatuses(); } @override void dispose() { routeObserver.unsubscribe(this); _jobsSub?.cancel(); _jobsSub = null; _jobsUpdatedSub?.cancel(); _jobsUpdatedSub = null; _unreadCountSub?.cancel(); _unreadCountSub = null; _connectionSub?.cancel(); _connectionSub = null; _jobDeletedSub?.cancel(); _jobDeletedSub = null; _jobCreatedSub?.cancel(); _jobCreatedSub = null; super.dispose(); } @override Widget build(BuildContext context) { return Stack( children: [ PopScope( canPop: false, child: Scaffold( appBar: AppBar( automaticallyImplyLeading: false, title: Text(AppLocalizations.of(context).availableJobs), backgroundColor: Colors.deepPurple[100], leading: IconButton( icon: const Icon(Icons.logout), onPressed: () { _handleLogout(); }, tooltip: AppLocalizations.of(context).logout, ), actions: [ // Chat Icon with Badge Padding( padding: const EdgeInsets.only(right: 4.0), child: Badge( label: Text('$_unreadMessageCount'), isLabelVisible: _unreadMessageCount > 0, child: IconButton( icon: const Icon(Icons.chat), onPressed: () { Navigator.of(context).pushNamed('/chats'); }, tooltip: AppLocalizations.of(context).openChat, ), ), ), // Settings Icon Padding( padding: const EdgeInsets.only(right: 10.0), child: IconButton( icon: const Icon(Icons.settings), onPressed: () { Navigator.of(context).pushNamed('/settings'); }, tooltip: AppLocalizations.of(context).settings, ), ), ], ), body: Column( children: [ // Offline banner under header OfflineBanner(), Expanded( child: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [Expanded(child: _buildJobsList())], ), ), ), ], ), ), ), ], ); } void _handleLogout() { // Capture a parent context before opening the dialog to ensure we can always navigate on root final parentContext = context; showDialog( context: parentContext, builder: (BuildContext dialogContext) { return AlertDialog( title: Text(AppLocalizations.of(context).logoutConfirm), content: Text(AppLocalizations.of(context).logoutConfirmMessage), actions: [ TextButton( onPressed: () { Navigator.of(dialogContext).pop(); }, child: Text(AppLocalizations.of(context).cancel), ), ElevatedButton( onPressed: () async { _isLoggingOut = true; // suppress connection snackbars // We'll ensure navigation to login in a finally block try { await _appState.clearLogin(); // clear login + DB await _stompService .logout(); // only clear auth state, keep WebSocket connected // Cleanup _jobsSub?.cancel(); _jobsSub = null; // Prevent further job loads until next login _wasConnected = false; } catch (e, stackTrace) { developer.log( 'Error during logout flow: $e', name: 'JobsView', ); developer.log('Stack trace: $stackTrace', name: 'JobsView'); } finally { if (!mounted) { // If the widget is already unmounted, we cannot navigate here safely. // Navigation to login will be handled by higher-level route guards on next build. } else { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { return; } // Use the parentContext to get the root navigator (avoid dialog context pitfalls) final rootNav = Navigator.of( parentContext, rootNavigator: true, ); // Clear any visible SnackBars before navigation ScaffoldMessenger.maybeOf( parentContext, )?.clearSnackBars(); // Close any remaining routes above root (e.g., dialogs) while (rootNav.canPop()) { rootNav.pop(); } // Navigate to login, clearing stack and suppressing connection snack rootNav.pushNamedAndRemoveUntil( '/login', (route) => false, arguments: true, // suppressConnectionSnack ); }); } } }, style: ElevatedButton.styleFrom( backgroundColor: Colors.red, foregroundColor: Colors.white, ), child: Text(AppLocalizations.of(context).logout), ), ], ); }, ); } Widget _buildJobsList() { final jobs = List.from(_appState.assignedJobs)..sort((a, b) { final aSeen = _jobSeen[a.id] ?? false; final bSeen = _jobSeen[b.id] ?? false; if (aSeen != bSeen) { // Unseen first return aSeen ? 1 : -1; } // Then by time (oldest first within each group) return a.createdAt.compareTo(b.createdAt); }); return RefreshIndicator( onRefresh: _refreshJobs, child: jobs.isEmpty ? ListView( children: [ SizedBox( height: MediaQuery.of(context).size.height * 0.6, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.work_outline, size: 64, color: Colors.grey[400], ), const SizedBox(height: 16), Text( AppLocalizations.of(context).noJobsAssigned, style: TextStyle( fontSize: 16, color: Colors.grey[600], ), ), const SizedBox(height: 8), Text( AppLocalizations.of(context).noJobsMessage, style: TextStyle( fontSize: 14, color: Colors.grey[500], ), textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( AppLocalizations.of(context).pullToRefresh, style: TextStyle( fontSize: 12, color: Colors.grey[400], ), textAlign: TextAlign.center, ), const SizedBox(height: 16), ElevatedButton.icon( onPressed: _refreshJobs, icon: const Icon(Icons.refresh), label: Text(AppLocalizations.of(context).refresh), style: ElevatedButton.styleFrom( backgroundColor: Colors.deepPurple[100], foregroundColor: Colors.deepPurple[700], ), ), ], ), ), ), ], ) : ListView.builder( itemCount: jobs.length, itemBuilder: (context, index) { final job = jobs[index]; return _buildJobCard(job); }, ), ); } Future _refreshJobs() async { if (_stompService.isConnected && _stompService.isAuthenticated) { await _loadJobs(); } else { _showSnack( AppLocalizations.of(context).offline, backgroundColor: Colors.red, ); } } void _syncSwipeStateWithJobs() { final jobIds = _appState.assignedJobs.map((job) => job.id).toSet(); final updatedOffsets = {}; _jobSwipeOffsets.forEach((jobId, offset) { if (jobIds.contains(jobId) && offset != 0) { updatedOffsets[jobId] = offset; } }); _jobSwipeOffsets = updatedOffsets; if (_openJobId != null && !jobIds.contains(_openJobId)) { _openJobId = null; } } void _handleJobDragUpdate(Job job, DragUpdateDetails details) { final current = _jobSwipeOffsets[job.id] ?? 0.0; var next = current + details.delta.dx; if (next < -_jobSwipeRevealOffset) { next = -_jobSwipeRevealOffset; } if (next > 0) { next = 0; } if ((next - current).abs() < 0.5) { return; } setState(() { final updated = Map.from(_jobSwipeOffsets); if (next == 0) { updated.remove(job.id); } else { updated[job.id] = next; } _jobSwipeOffsets = updated; }); } void _handleJobDragEnd(Job job, DragEndDetails details) { final current = _jobSwipeOffsets[job.id] ?? 0.0; final velocity = details.primaryVelocity ?? 0.0; final shouldOpen = current <= -(_jobSwipeRevealOffset / 2) || velocity < -300; setState(() { final updated = Map.from(_jobSwipeOffsets); if (shouldOpen) { updated ..clear() ..[job.id] = -_jobSwipeRevealOffset; _jobSwipeOffsets = updated; _openJobId = job.id; } else { updated.remove(job.id); _jobSwipeOffsets = updated; if (_openJobId == job.id) { _openJobId = null; } } }); } void _closeSwipe(String jobId) { final current = _jobSwipeOffsets[jobId] ?? 0.0; if (current == 0 && _openJobId != jobId) { return; } setState(() { final updated = Map.from(_jobSwipeOffsets)..remove(jobId); _jobSwipeOffsets = updated; if (_openJobId == jobId) { _openJobId = null; } }); } Future _deleteJob(Job job) async { final jobId = job.id; if (_jobsBeingDeleted.contains(jobId)) { return; } setState(() { _jobsBeingDeleted.add(jobId); }); _closeSwipe(jobId); try { await _databaseService.deleteJobAndRelatedData(job); _appState.removeJob(jobId); final updatedStatuses = Map.from( _taskStatuses, )..removeWhere((taskId, _) => job.tasks.any((task) => task.id == taskId)); final updatedSeen = Map.from(_jobSeen)..remove(jobId); if (mounted) { setState(() { _taskStatuses = updatedStatuses; _jobSeen = updatedSeen; _syncSwipeStateWithJobs(); }); } await _chatService.deleteJobChats( jobId, jobNumber: job.jobNumber.trim().isEmpty ? null : job.jobNumber, ); if (mounted) { _showSnack( AppLocalizations.of(context).jobDeleted, backgroundColor: Colors.red, ); } } catch (e, st) { developer.log('Error deleting job $jobId: $e', name: 'JobsView'); developer.log('Stack trace: $st', name: 'JobsView'); if (mounted) { _showSnack( AppLocalizations.of(context).jobDeleteError, backgroundColor: Colors.red, ); } } finally { if (mounted) { setState(() { _jobsBeingDeleted.remove(jobId); }); } else { _jobsBeingDeleted.remove(jobId); } } } Widget _buildJobCard(Job job) { Color statusColor; switch (job.statusColor) { case 'green': statusColor = Colors.green; break; case 'blue': statusColor = Colors.blue; break; case 'orange': statusColor = Colors.orange; break; case 'red': statusColor = Colors.red; break; default: statusColor = Colors.grey; } // Determine card background color based on task completion final totalTasks = job.tasks.length; int completedTasks = 0; for (final t in job.tasks) { final isCompleted = _taskStatuses[t.id] ?? t.completed; if (isCompleted) completedTasks++; } // Check if all tasks are completed (job is done) final bool isJobCompleted = totalTasks > 0 && completedTasks == totalTasks; Color? cardBg; if (totalTasks == 0 || completedTasks == 0) { cardBg = null; // unchanged (default) } else if (completedTasks > 0 && completedTasks < totalTasks) { cardBg = Colors.yellow[50]; } else if (completedTasks == totalTasks) { cardBg = Colors.green[50]; } // Build robust display strings with fallbacks final pickupName = _joinNonEmpty([job.pickupFirstName, job.pickupLastName]); final pickupDisplayName = pickupName.isNotEmpty ? pickupName : job.pickupCompany; final pickupAddress = _joinNonEmpty([ _joinNonEmpty([job.pickupStreet, job.pickupHouseNumber]), _joinNonEmpty([job.pickupZip, job.pickupCity]), ], sep: ', '); final deliveryName = _joinNonEmpty([ job.deliveryFirstName, job.deliveryLastName, ]); final firstDeliveryStation = job.deliveryStations.isNotEmpty ? job.deliveryStations.first : null; final hasMultipleDeliveryStations = job.deliveryStations.length > 1; final deliveryDisplayName = hasMultipleDeliveryStations ? (job.deliveryCitiesDisplay.isNotEmpty ? job.deliveryCitiesDisplay : job.deliveryCompany) : (deliveryName.isNotEmpty ? deliveryName : (firstDeliveryStation?.displayName.isNotEmpty == true ? firstDeliveryStation!.displayName : job.deliveryCompany)); final deliveryAddress = hasMultipleDeliveryStations ? '${job.deliveryStations.length} Stationen' : (firstDeliveryStation?.formattedAddress.isNotEmpty == true ? firstDeliveryStation!.formattedAddress : _joinNonEmpty([ _joinNonEmpty([job.deliveryStreet, job.deliveryHouseNumber]), _joinNonEmpty([job.deliveryZip, job.deliveryCity]), ], sep: ', ')); final deliveryRouteAddress = firstDeliveryStation?.formattedAddress.isNotEmpty == true ? firstDeliveryStation!.formattedAddress : deliveryAddress; final swipeOffset = _jobSwipeOffsets[job.id] ?? 0.0; final isDeleting = _jobsBeingDeleted.contains(job.id); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Stack( children: [ Positioned.fill( child: Align( alignment: Alignment.centerRight, child: IgnorePointer( ignoring: swipeOffset == 0, child: AnimatedOpacity( opacity: (swipeOffset.abs() / _jobSwipeRevealOffset).clamp( 0, 1, ), duration: const Duration(milliseconds: 150), child: IconButton( iconSize: 28, padding: const EdgeInsets.all(10), splashRadius: 24, icon: const Icon(Icons.delete, color: Colors.red), tooltip: AppLocalizations.of(context).deleteJob, onPressed: () { if (isDeleting) { return; } _deleteJob(job); }, ), ), ), ), ), GestureDetector( // Only enable swipe gestures for completed jobs onHorizontalDragStart: isJobCompleted ? (_) { final openId = _openJobId; if (openId != null && openId != job.id) { _closeSwipe(openId); } } : null, onHorizontalDragUpdate: isJobCompleted ? (details) { if (_jobsBeingDeleted.contains(job.id)) { return; } _handleJobDragUpdate(job, details); } : null, onHorizontalDragEnd: isJobCompleted ? (details) { if (_jobsBeingDeleted.contains(job.id)) { return; } _handleJobDragEnd(job, details); } : null, onHorizontalDragCancel: isJobCompleted ? () => _closeSwipe(job.id) : null, child: TweenAnimationBuilder( tween: Tween(begin: 0, end: swipeOffset), duration: const Duration(milliseconds: 150), curve: Curves.easeOut, builder: (context, value, child) { return Transform.translate( offset: Offset(value, 0), child: child, ); }, child: Card( margin: EdgeInsets.zero, elevation: 2, color: cardBg, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), clipBehavior: Clip.antiAlias, child: InkWell( onTap: () { final isOpen = (_jobSwipeOffsets[job.id] ?? 0) != 0; if (isOpen) { _closeSwipe(job.id); return; } _showJobDetails(job); }, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( job.jobNumber.isNotEmpty ? job.jobNumber : job.title, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), ), if (job.customerSelection.isNotEmpty) ...[ const SizedBox(height: 2), Text( job.customerSelection, style: TextStyle( fontSize: 14, color: Colors.grey[700], fontWeight: FontWeight.w500, ), ), ], if (job.customerSelection.isEmpty && (job.pickupCity.isNotEmpty || job.deliveryCity.isNotEmpty)) ...[ const SizedBox(height: 2), Text( '${AppLocalizations.of(context).from} ${job.pickupCity.isNotEmpty ? job.pickupCity : '?'} ${AppLocalizations.of(context).to} ${job.deliveryCity.isNotEmpty ? job.deliveryCity : '?'}', style: TextStyle( fontSize: 13, color: Colors.grey[700], ), ), ], if (job.customerSelection.isEmpty && job.pickupCity.isEmpty && job.deliveryCity.isEmpty && job.description.isNotEmpty) ...[ const SizedBox(height: 2), Text( job.description, style: TextStyle( fontSize: 13, color: Colors.grey[700], ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ], ], ), ), Builder( builder: (_) { final seen = _jobSeen[job.id] ?? false; if (!seen) { // Fire and forget DB write; do not block build _databaseService.setJobSeen(job.id).then(( _, ) async { if (mounted) { setState(() { _jobSeen = Map.from( _jobSeen, )..[job.id] = true; }); } }); } if (seen) { return const SizedBox.shrink(); } return Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), decoration: BoxDecoration( color: statusColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), border: Border.all( color: statusColor.withValues(alpha: 0.3), ), ), child: Text( AppLocalizations.of(context).newLabel, style: TextStyle( fontSize: 12, color: statusColor.withValues(alpha: 0.8), fontWeight: FontWeight.w500, ), ), ); }, ), ], ), // Progress bar for tasks if (totalTasks > 0) ...[ const SizedBox(height: 8), Text( AppLocalizations.of(context).tasksToComplete, style: TextStyle( fontSize: 12, color: Colors.grey[500], ), ), const SizedBox(height: 4), Row( children: [ Expanded( child: ClipRRect( borderRadius: BorderRadius.circular(6), child: LinearProgressIndicator( value: totalTasks == 0 ? 0 : completedTasks / totalTasks, minHeight: 8, backgroundColor: Colors.grey[200], valueColor: AlwaysStoppedAnimation( completedTasks >= totalTasks ? Colors.green : (completedTasks > 0 ? Colors.amber : Colors.deepPurpleAccent), ), ), ), ), const SizedBox(width: 8), Text( '$completedTasks/$totalTasks', style: TextStyle( fontSize: 12, color: Colors.grey[700], ), ), ], ), ], const SizedBox(height: 12), // Pickup Information Row( children: [ Icon( Icons.arrow_upward, size: 16, color: Colors.green[600], ), const SizedBox(width: 4), Text( '${AppLocalizations.of(context).pickup}${job.pickupDate.isNotEmpty ? ' ${_formatDateString(job.pickupDate)}${job.pickupTime.isNotEmpty ? ' ${_formatTimeString(job.pickupTime)}' : ''}' : ''}', style: TextStyle( fontSize: 12, color: Colors.grey[700], fontWeight: FontWeight.w500, ), ), ], ), const SizedBox(height: 2), if (pickupDisplayName.isNotEmpty) Text( pickupDisplayName, style: TextStyle( fontSize: 13, color: Colors.grey[800], ), ), if (pickupAddress.isNotEmpty) Row( children: [ Expanded( child: Text( pickupAddress, style: TextStyle( fontSize: 12, color: Colors.grey[600], ), ), ), const SizedBox(width: 8), IconButton( tooltip: AppLocalizations.of(context).routePlan, icon: const Icon( Icons.route, color: Colors.green, ), onPressed: () { if (_routeActionInProgress) return; setState(() => _routeActionInProgress = true); _openRoutingView( address: pickupAddress, isDelivery: false, title: pickupDisplayName.isNotEmpty ? 'Abholung$pickupDisplayName' : 'Abholadresse', ); // Reset after short delay to avoid double-push Future.delayed( const Duration(milliseconds: 600), () { if (mounted) { setState( () => _routeActionInProgress = false, ); } }, ); }, ), ], ), const SizedBox(height: 8), // Delivery Information Row( children: [ Icon( Icons.arrow_downward, size: 16, color: Colors.blue[600], ), const SizedBox(width: 4), Text( '${AppLocalizations.of(context).delivery}${job.deliveryDate.isNotEmpty ? ' ${_formatDateString(job.deliveryDate)}${job.deliveryTime.isNotEmpty ? ' ${_formatTimeString(job.deliveryTime)}' : ''}' : ''}', style: TextStyle( fontSize: 12, color: Colors.grey[700], fontWeight: FontWeight.w500, ), ), ], ), const SizedBox(height: 2), if (deliveryDisplayName.isNotEmpty) Text( deliveryDisplayName, style: TextStyle( fontSize: 13, color: Colors.grey[800], ), ), if (deliveryAddress.isNotEmpty) Row( children: [ Expanded( child: Text( deliveryAddress, style: TextStyle( fontSize: 12, color: Colors.grey[600], ), ), ), const SizedBox(width: 8), IconButton( tooltip: 'Route planen', icon: const Icon( Icons.route, color: Colors.blueAccent, ), onPressed: () { if (_routeActionInProgress) return; setState(() => _routeActionInProgress = true); _openRoutingView( address: deliveryRouteAddress, isDelivery: true, title: hasMultipleDeliveryStations ? 'Erste Zustelladresse' : (deliveryDisplayName.isNotEmpty ? 'Zustellung $deliveryDisplayName' : 'Zustelladresse'), ); // Reset after short delay to avoid double-push Future.delayed( const Duration(milliseconds: 600), () { if (mounted) { setState( () => _routeActionInProgress = false, ); } }, ); }, ), ], ), const SizedBox(height: 8), // Dates and Price Row( children: [ Expanded( child: Row( children: [ Icon( Icons.schedule, size: 16, color: Colors.grey[500], ), const SizedBox(width: 4), Text( '${AppLocalizations.of(context).created}: ${_formatDate(job.createdAt)}', style: TextStyle( fontSize: 12, color: Colors.grey[500], ), ), ], ), ), if (job.price > 0) ...[ Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), decoration: BoxDecoration( color: Colors.green[50], borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.green[200]!), ), child: Text( '${job.price.toStringAsFixed(2)} €', style: TextStyle( fontSize: 12, color: Colors.green[700], fontWeight: FontWeight.w600, ), ), ), ], ], ), ], ), ), ), ), ), ), ], ), ); } void _showJobDetails(Job job) { Navigator.of(context).pushNamed('/cargo_items', arguments: job); } void _toggleTaskCompletion(Job job, int taskIndex) { // Create a new task with toggled completion status final updatedTask = job.tasks[taskIndex].copyWith( completed: !job.tasks[taskIndex].completed, ); // Create a new list with the updated task final updatedTasks = List.from(job.tasks); updatedTasks[taskIndex] = updatedTask; final updatedDeliveryStations = job.deliveryStations .map( (station) => DeliveryStation( stationOrder: station.stationOrder, company: station.company, salutation: station.salutation, firstName: station.firstName, lastName: station.lastName, phone: station.phone, street: station.street, houseNumber: station.houseNumber, addressAddition: station.addressAddition, zip: station.zip, city: station.city, deliveryDate: station.deliveryDate, deliveryTime: station.deliveryTime, tasks: station.tasks .map( (task) => task.id == updatedTask.id ? updatedTask : task, ) .toList(), ), ) .toList(); // Create a new job instance with updated tasks final updatedJob = Job( id: job.id, jobNumber: job.jobNumber, status: job.status, createdAt: job.createdAt, updatedAt: job.updatedAt, createdBy: job.createdBy, customerSelection: job.customerSelection, pickupCompany: job.pickupCompany, pickupSalutation: job.pickupSalutation, pickupFirstName: job.pickupFirstName, pickupLastName: job.pickupLastName, pickupPhone: job.pickupPhone, pickupStreet: job.pickupStreet, pickupHouseNumber: job.pickupHouseNumber, pickupAddressAddition: job.pickupAddressAddition, pickupZip: job.pickupZip, pickupCity: job.pickupCity, deliveryCompany: job.deliveryCompany, deliverySalutation: job.deliverySalutation, deliveryFirstName: job.deliveryFirstName, deliveryLastName: job.deliveryLastName, deliveryPhone: job.deliveryPhone, deliveryStreet: job.deliveryStreet, deliveryHouseNumber: job.deliveryHouseNumber, deliveryAddressAddition: job.deliveryAddressAddition, deliveryZip: job.deliveryZip, deliveryCity: job.deliveryCity, digitalProcessing: job.digitalProcessing, appUser: job.appUser, pickupDate: job.pickupDate, pickupTime: job.pickupTime, deliveryDate: job.deliveryDate, deliveryTime: job.deliveryTime, remark: job.remark, price: job.price, draft: job.draft, cargoItems: job.cargoItems, deliveryStations: updatedDeliveryStations, tasks: updatedTasks, deliveryCitiesDisplay: job.deliveryCitiesDisplay, firstDeliveryCity: job.firstDeliveryCity, lastDeliveryCity: job.lastDeliveryCity, title: job.title, description: job.description, priority: job.priority, dueDate: job.dueDate, assignedTo: job.assignedTo, location: job.location, additionalData: job.additionalData, ); // Update the job in the app state _appState.updateJob(updatedJob); setState(() { // Trigger UI refresh }); // Close and reopen the dialog to reflect changes Navigator.of(context).pop(); _showJobDetailsDialog(updatedJob); } void _showJobDetailsDialog(Job job) { showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: Text(job.title), content: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( '${AppLocalizations.of(context).status}: ${job.statusDisplayText}', style: const TextStyle(fontWeight: FontWeight.w500), ), const SizedBox(height: 8), Text( '${AppLocalizations.of(context).priority}: ${job.priorityDisplayText}', style: const TextStyle(fontWeight: FontWeight.w500), ), const SizedBox(height: 8), Text( '${AppLocalizations.of(context).created}: ${_formatDate(job.createdAt)}', style: const TextStyle(fontWeight: FontWeight.w500), ), if (job.dueDate != null) ...[ const SizedBox(height: 8), Text( '${AppLocalizations.of(context).dueDate}: ${_formatDate(job.dueDate!)}', style: const TextStyle(fontWeight: FontWeight.w500), ), ], if (job.location != null) ...[ const SizedBox(height: 8), Text( '${AppLocalizations.of(context).location}: ${job.location}', style: const TextStyle(fontWeight: FontWeight.w500), ), ], if (job.description.isNotEmpty) ...[ const SizedBox(height: 16), Text( AppLocalizations.of(context).description, style: const TextStyle(fontWeight: FontWeight.w600), ), const SizedBox(height: 8), Text(job.description), ], // CargoItems section if (job.cargoItems.isNotEmpty) ...[ const SizedBox(height: 16), Text( AppLocalizations.of(context).cargo, style: const TextStyle(fontWeight: FontWeight.w600), ), const SizedBox(height: 8), ...job.cargoItems.asMap().entries.map((entry) { final cargoItem = entry.value; return Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.grey[50], borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey[300]!), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( cargoItem.description, style: const TextStyle(fontWeight: FontWeight.w500), ), const SizedBox(height: 4), Text( '${AppLocalizations.of(context).quantity}: ${cargoItem.quantity}', ), Text( '${AppLocalizations.of(context).weight}: ${cargoItem.formattedWeight}', ), Text( '${AppLocalizations.of(context).dimensions}: ${cargoItem.formattedDimensions}', ), ], ), ); }), ], if (job.deliveryStations.isNotEmpty) ...[ const SizedBox(height: 16), Text( '${AppLocalizations.of(context).delivery} (${job.deliveryStations.length})', style: const TextStyle(fontWeight: FontWeight.w600), ), const SizedBox(height: 8), ...job.deliveryStations.map( (station) => Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.grey[50], borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey[300]!), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Station ${station.stationOrder + 1}: ${station.displayName}', style: const TextStyle(fontWeight: FontWeight.w500), ), if (station.formattedAddress.isNotEmpty) ...[ const SizedBox(height: 4), Text(station.formattedAddress), ], if (station.tasks.isNotEmpty) ...[ const SizedBox(height: 4), Text( '${AppLocalizations.of(context).tasks}: ${station.tasks.length}', ), ], ], ), ), ), ], // Tasks section if (job.tasks.isNotEmpty) ...[ const SizedBox(height: 16), Text( AppLocalizations.of(context).tasks, style: const TextStyle(fontWeight: FontWeight.w600), ), const SizedBox(height: 8), ...job.tasks.asMap().entries.map( (entry) => GestureDetector( onTap: () => _toggleTaskCompletion(job, entry.key), child: Container( margin: const EdgeInsets.only(bottom: 4), padding: const EdgeInsets.symmetric( vertical: 8, horizontal: 4, ), decoration: BoxDecoration( color: entry.value.completed ? Colors.green.withValues(alpha: 0.1) : Colors.transparent, borderRadius: BorderRadius.circular(4), border: Border.all( color: entry.value.completed ? Colors.green.withValues(alpha: 0.3) : Colors.transparent, ), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( entry.value.completed ? Icons.check_circle : Icons.radio_button_unchecked, size: 20, color: entry.value.completed ? Colors.green : Colors.grey[600], ), const SizedBox(width: 8), Text( '${entry.key + 1}. ', style: TextStyle( fontWeight: FontWeight.w500, decoration: entry.value.completed ? TextDecoration.lineThrough : null, color: entry.value.completed ? Colors.grey[600] : null, ), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _getTaskDisplayText(entry.value), style: TextStyle( decoration: entry.value.completed ? TextDecoration.lineThrough : null, color: entry.value.completed ? Colors.grey[600] : null, ), ), if (_getTaskStationLabel(job, entry.value) != null) ...[ const SizedBox(height: 2), Text( _getTaskStationLabel(job, entry.value)!, style: TextStyle( fontSize: 12, color: Colors.grey[600], ), ), ], ], ), ), ], ), ), ), ), ], if (job.additionalData != null && job.additionalData!.isNotEmpty) ...[ const SizedBox(height: 16), Text( AppLocalizations.of(context).description, style: const TextStyle(fontWeight: FontWeight.w600), ), const SizedBox(height: 8), ...job.additionalData!.entries.map( (entry) => Padding( padding: const EdgeInsets.only(bottom: 4), child: Text('${entry.key}: ${entry.value}'), ), ), ], ], ), ), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); }, child: Text(AppLocalizations.of(context).close), ), ], ); }, ); } String _getTaskDisplayText(Task task) { final l10n = AppLocalizations.of(context); // Generate display text based on task type switch (task) { case ConfirmationTask(): return task.description!; case PhotoTask(): return '${l10n.photoCapture} (${task.minPhotoCount}-${task.maxPhotoCount} ${l10n.photos})'; case TodoListTask(): return '${l10n.checklist} (${task.todoItems.length})'; case SignatureTask(): return l10n.signatureRequired; case BarcodeTask(): return '${l10n.barcodeScan} (${task.minBarcodeCount}-${task.maxBarcodeCount})'; case CommentTask(): return task.required ? l10n.commentRequired : l10n.comment; default: return l10n.tasks; } } String? _getTaskStationLabel(Job job, Task task) { final stationOrder = task.stationOrder; if (stationOrder == null) { return null; } for (final station in job.deliveryStations) { if (station.stationOrder == stationOrder) { final suffix = station.displayName.isNotEmpty ? station.displayName : station.city; return suffix.isNotEmpty ? 'Station ${stationOrder + 1}: $suffix' : 'Station ${stationOrder + 1}'; } } return 'Station ${stationOrder + 1}'; } }