Files
votianlt/app/lib/jobs_view.dart
Sven Carstensen 6e8bedd9b4 feat: Drag-and-Drop-Reihenfolge, Station-Abschluss-Flow und UI-Verbesserungen
Lieferstationen-Dialog (Backend/Vaadin):
- Aufgaben per Drag & Drop neu anordnen, inkl. Drag-Handle, komprimierter
  Kachelansicht während des Drags und horizontaler Einfügelinie als Drop-Target
- Drop-Indikator wird unterdrückt, wenn der Drop keine Positionsänderung bewirken
  würde, und nach dem Abschluss clientseitig zuverlässig aufgeräumt
- Drag-Handle, Aufgabentyp-Label und Close-Button auf einheitlicher Position
  ausgerichtet; Abstände in der Kachel komprimiert

Station-Abschluss-Flow (Flutter-App + Backend):
- Neuer Button "Station abschließen" unter den Aufgaben; deaktiviert, solange
  Pflichtaufgaben offen sind, ansonsten aktiv (auch wenn nur optionale Aufgaben
  existieren)
- Hinweisdialog nach Erledigung der letzten Pflichtaufgabe sowie Warnung bei
  offenen optionalen Aufgaben vor dem Senden
- Neue station_completed-Nachricht (jobId, jobNumber, stationOrder,
  completedAt, hasIncompleteOptionalTasks) wird an den Server gesendet
- Backend: Auftrag wird nicht mehr automatisch beim Erledigen der letzten
  Pflichtaufgabe abgeschlossen, sondern erst beim Empfang der
  station_completed-Nachricht (neuer Handler in MessageController und
  MessagingConfig)

Aufgabenliste in der App:
- Farbcodierung optionaler Aufgaben entfernt; stattdessen vertikal zentrierter
  "Optional"-Chip am rechten Kartenrand

Weitere UI-Überarbeitungen über Login, Jobs, Chats, Settings, Aufgaben-Capture-
Screens, Offline-Banner und zugehörige Widgets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:26:30 +02:00

1913 lines
70 KiB
Dart

import 'package:flutter/material.dart';
import 'app_state.dart';
import 'app_theme.dart';
import 'l10n/app_localizations.dart';
import 'l10n/localization_helpers.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<JobsView> createState() => _JobsViewState();
}
class _JobsViewState extends State<JobsView> 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<String, bool> _taskStatuses = const {};
Map<String, bool> _jobSeen = const {};
Map<String, double> _jobSwipeOffsets = const {};
String? _openJobId;
static const double _jobSwipeRevealOffset = 50.0;
final Set<String> _jobsBeingDeleted = <String>{};
// Listen to AppState jobsUpdated to apply UI refresh exactly once
StreamSubscription<void>? _jobsUpdatedSub;
bool _isApplyingJobs = false;
StreamSubscription<int>? _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<bool>(MQTopics.connectionStatus, (
isConnected,
) {
// Went online
if (isConnected && !_wasConnected) {
_showSnack(
AppLocalizations.of(context).connectionRestored,
backgroundColor: AppColors.success,
);
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: AppColors.danger,
);
}
}
_wasConnected = isConnected;
});
// Listen to job deletion events from server
_jobDeletedSub = DartMQ().subscribe<
Map<String, dynamic>
>(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: AppColors.warning);
}
});
// Listen to job creation events from server
_jobCreatedSub = DartMQ().subscribe<
Map<String, dynamic>
>(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: AppColors.success);
}
} 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: AppColors.success,
);
}
} 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<void> _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<void> _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<void> _waitForConnection() async {
// If already connected and authenticated, return immediately
if (_stompService.isConnected && _stompService.isAuthenticated) {
return;
}
final completer = Completer<void>();
// Listen for connection status changes
final connectionSub = DartMQ().subscribe<bool>(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<void> _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<void>();
try {
developer.log('Loading jobs...', name: 'JobsView');
// Listen for first jobs response only
_jobsSub?.cancel();
_jobsSub = DartMQ().subscribe<List<dynamic>>(MQTopics.jobsResponse, (
jobsData,
) async {
if (!mounted) return;
final List<dynamic> 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<void> _loadLocalTaskStatuses() async {
final map = await _databaseService.loadAllTaskStatuses();
if (!mounted) return;
setState(() {
_taskStatuses = map;
});
}
Future<void> _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<String> 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),
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: AppColors.danger,
foregroundColor: Colors.white,
),
child: Text(AppLocalizations.of(context).logout),
),
],
);
},
);
}
Widget _buildJobsList() {
final jobs = List<Job>.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: AppColors.primarySoft,
foregroundColor: AppColors.primaryStrong,
),
),
],
),
),
),
],
)
: ListView.builder(
itemCount: jobs.length,
itemBuilder: (context, index) {
final job = jobs[index];
return _buildJobCard(job);
},
),
);
}
Future<void> _refreshJobs() async {
if (_stompService.isConnected && _stompService.isAuthenticated) {
await _loadJobs();
} else {
_showSnack(
AppLocalizations.of(context).offline,
backgroundColor: AppColors.danger,
);
}
}
void _syncSwipeStateWithJobs() {
final jobIds = _appState.assignedJobs.map((job) => job.id).toSet();
final updatedOffsets = <String, double>{};
_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<String, double>.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<String, double>.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<String, double>.from(_jobSwipeOffsets)..remove(jobId);
_jobSwipeOffsets = updated;
if (_openJobId == jobId) {
_openJobId = null;
}
});
}
Future<void> _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<String, bool>.from(
_taskStatuses,
)..removeWhere((taskId, _) => job.tasks.any((task) => task.id == taskId));
final updatedSeen = Map<String, bool>.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: AppColors.danger,
);
}
} 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: AppColors.danger,
);
}
} finally {
if (mounted) {
setState(() {
_jobsBeingDeleted.remove(jobId);
});
} else {
_jobsBeingDeleted.remove(jobId);
}
}
}
Widget _buildJobCard(Job job) {
Color statusColor;
switch (job.statusColor) {
case 'green':
statusColor = AppColors.success;
break;
case 'blue':
statusColor = AppColors.primary;
break;
case 'orange':
statusColor = AppColors.warning;
break;
case 'red':
statusColor = AppColors.danger;
break;
default:
statusColor = AppColors.textMuted;
}
// 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 = AppColors.warningSoft;
} else if (completedTasks == totalTasks) {
cardBg = AppColors.successSoft;
}
// 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
? AppLocalizations.of(
context,
).deliveryStationsCount(job.deliveryStations.length)
: (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: AppColors.danger),
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<double>(
tween: Tween<double>(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
: localizeKnownText(context, 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<String, bool>.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: AppColors.border,
valueColor: AlwaysStoppedAnimation<Color>(
completedTasks >= totalTasks
? AppColors.success
: (completedTasks > 0
? AppColors.warning
: AppColors.primary),
),
),
),
),
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: AppColors.primary,
),
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: AppColors.primary,
),
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<Task>.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(localizeKnownText(context, job.title)),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${AppLocalizations.of(context).status}: ${_localizedStatusText(job.status)}',
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Text(
'${AppLocalizations.of(context).priority}: ${_localizedPriorityText(job.priority)}',
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(localizeKnownText(context, 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,
).deliveryStationsCount(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(
localizedStationLabel(
context,
station.stationOrder + 1,
suffix: 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 localizedStationLabel(context, stationOrder + 1, suffix: suffix);
}
}
return AppLocalizations.of(context).stationNumber(stationOrder + 1);
}
String _localizedStatusText(String status) {
final l10n = AppLocalizations.of(context);
switch (status.toLowerCase()) {
case 'created':
return l10n.statusCreated;
case 'pending':
return l10n.statusPending;
case 'assigned':
return l10n.statusAssigned;
case 'in_progress':
case 'started':
return l10n.statusInProgress;
case 'completed':
case 'done':
return l10n.statusCompleted;
case 'cancelled':
return l10n.statusCancelled;
case 'failed':
return l10n.statusFailed;
default:
return localizeKnownText(context, status);
}
}
String _localizedPriorityText(String priority) {
final l10n = AppLocalizations.of(context);
switch (priority.toLowerCase()) {
case 'low':
return l10n.priorityLow;
case 'high':
return l10n.priorityHigh;
case 'urgent':
return l10n.priorityUrgent;
case 'normal':
default:
return l10n.priorityMedium;
}
}
}