Files
votianlt/app/lib/jobs_view.dart
T
Sven 5e6044e4b1 fix: Stationsabschluss absichern und Kundenformulare anpassen
Sperrt nachträgliche Aufgabenabschlüsse in App und Backend und zeigt erledigte Aufträge trotz offener optionaler Aufgaben grün an. Ergänzt Regressionstests, die Anredeauswahl und optionale Steuerangaben für Kunden. Enthält App-Version 0.9.25, Backend-Version 0.9.26 und das aktualisierte Frontend-Bundle.
2026-10-06 15:09:51 +02:00

1925 lines
71 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/job_progress.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';
@visibleForTesting
Color? jobCardBackgroundColor(JobProgress progress) {
if (progress.isCompleted) return AppColors.successSoft;
if (progress.hasProgress) return AppColors.warningSoft;
return null;
}
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, Set<int>> _completedStations = 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
? 'Auftrag $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();
final completedStations = <String, Set<int>>{};
for (final job in _appState.assignedJobs) {
completedStations[job.id] = await _databaseService
.loadCompletedStationOrders(job);
}
if (!mounted) return;
setState(() {
_taskStatuses = map;
_completedStations = completedStations;
});
}
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;
}
final progress = JobProgress(
job,
taskStatuses: _taskStatuses,
localCompletedStations: _completedStations[job.id] ?? const {},
);
final totalTasks = job.tasks.length;
final completedTasks = progress.completedTaskCount;
final isJobCompleted = progress.isCompleted;
final cardBg = jobCardBackgroundColor(progress);
// 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,
),
),
);
},
),
],
),
if (isJobCompleted) ...[
const SizedBox(height: 8),
Text(
AppLocalizations.of(context).jobCompleted,
style: const TextStyle(
color: AppColors.success,
fontWeight: FontWeight.w600,
),
),
],
// Progress bar for tasks (optional tasks retain their actual state).
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>(
isJobCompleted ||
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),
// Creation date
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],
),
),
],
),
),
],
),
],
),
),
),
),
),
),
],
),
);
}
void _showJobDetails(Job job) {
Navigator.of(context).pushNamed('/cargo_items', arguments: job);
}
void _toggleTaskCompletion(Job job, int taskIndex) {
final progress = JobProgress(
job,
taskStatuses: _taskStatuses,
localCompletedStations: _completedStations[job.id] ?? const {},
);
if (progress.isStationClosed(job.tasks[taskIndex].stationOrder ?? 0)) {
return;
}
// 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,
completedStationOrders: job.completedStationOrders,
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:
JobProgress(
job,
taskStatuses: _taskStatuses,
localCompletedStations:
_completedStations[job.id] ?? const {},
).isStationClosed(entry.value.stationOrder ?? 0)
? null
: () => _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;
}
}
}