Preisanzeige in der App entfernen und Auftragsbezeichnungen vereinheitlichen. Stationsabschlüsse und Benachrichtigungen absichern sowie Historie um Aufgabenbeschreibungen und ausführende Nutzer erweitern. Regressionstests ergänzen und App sowie Backend auf Version 0.9.24 anheben.
1892 lines
69 KiB
Dart
1892 lines
69 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
|
|
? '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();
|
|
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),
|
|
// 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) {
|
|
// 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;
|
|
}
|
|
}
|
|
}
|