- Chat: Nachrichten-Status (read/unread), WebSocket-Verbesserungen - App: Login-Optimierung, Job-Übersicht verbessert, neue Übersetzungen - Backend: Dialog-Styling, Invoice-Generator, Job-Verwaltung erweitert - Mehrsprachigkeit: Neue Übersetzungen für DE, EN, ES, ET, FR, LT, LV, PL, RU, TR
1913 lines
70 KiB
Dart
1913 lines
70 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'app_state.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: Colors.green,
|
|
);
|
|
if (_appState.isLoggedIn) {
|
|
_loadJobs();
|
|
}
|
|
}
|
|
|
|
// Went offline
|
|
if (!isConnected && _wasConnected) {
|
|
if (mounted && _isLoadingDialogShowing) {
|
|
_isLoadingDialogShowing = false;
|
|
Navigator.of(context, rootNavigator: true).pop();
|
|
}
|
|
// Only show offline message when user is logged in and not in logout flow
|
|
if (_appState.isLoggedIn && !_isLoggingOut) {
|
|
_showSnack(
|
|
AppLocalizations.of(context).connectionLost,
|
|
backgroundColor: Colors.red,
|
|
);
|
|
}
|
|
}
|
|
|
|
_wasConnected = isConnected;
|
|
});
|
|
|
|
// Listen to job deletion events from server
|
|
_jobDeletedSub = DartMQ().subscribe<
|
|
Map<String, dynamic>
|
|
>(MQTopics.jobDeleted, (data) async {
|
|
final jobId = data['jobId']?.toString();
|
|
final jobNumber = data['jobNumber']?.toString();
|
|
|
|
if (jobId == null) return;
|
|
|
|
developer.log(
|
|
'Job deleted event received: $jobId ($jobNumber)',
|
|
name: 'JobsView',
|
|
);
|
|
|
|
// Delete job from AppState (which also updates DB and notifies listeners)
|
|
await _appState.deleteJob(jobId);
|
|
|
|
// Show notification to user
|
|
if (mounted) {
|
|
final message =
|
|
jobNumber != null
|
|
? 'Job $jobNumber ${AppLocalizations.of(context).jobRemoved}'
|
|
: AppLocalizations.of(context).jobRemoved;
|
|
_showSnack(message, backgroundColor: Colors.orange);
|
|
}
|
|
});
|
|
|
|
// 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: Colors.green);
|
|
}
|
|
} catch (e) {
|
|
developer.log('Error handling job_created event: $e', name: 'JobsView');
|
|
}
|
|
});
|
|
|
|
// Listen once-per-cycle for jobs updates from AppState
|
|
_jobsUpdatedSub = _appState.jobsUpdated.listen((_) async {
|
|
if (_isApplyingJobs) {
|
|
return;
|
|
}
|
|
_isApplyingJobs = true;
|
|
try {
|
|
await _appState.refreshJobsFromDatabase();
|
|
await _loadLocalTaskStatuses();
|
|
await _loadSeenFlagsForCurrentJobs();
|
|
if (mounted && _isLoadingDialogShowing) {
|
|
_isLoadingDialogShowing = false;
|
|
Navigator.of(context, rootNavigator: true).pop();
|
|
}
|
|
if (mounted) {
|
|
setState(() {
|
|
_syncSwipeStateWithJobs();
|
|
});
|
|
_showSnack(
|
|
AppLocalizations.of(context).jobsUpdated,
|
|
backgroundColor: Colors.green,
|
|
);
|
|
}
|
|
} finally {
|
|
_isApplyingJobs = false;
|
|
}
|
|
});
|
|
|
|
// Listen to unread message count changes
|
|
_unreadCountSub = _chatService.unreadCountStream.listen((count) {
|
|
developer.log(
|
|
'[DEBUG_LOG] JobsView received unread count from stream: $count',
|
|
name: 'JobsView',
|
|
);
|
|
if (mounted) {
|
|
setState(() {
|
|
_unreadMessageCount = count;
|
|
});
|
|
developer.log(
|
|
'[DEBUG_LOG] JobsView updated badge with count: $_unreadMessageCount',
|
|
name: 'JobsView',
|
|
);
|
|
}
|
|
});
|
|
|
|
// Initialize chat service and get initial unread count
|
|
_chatService.initialize().then((_) {
|
|
developer.log(
|
|
'[DEBUG_LOG] ChatService initialized, initial unread count: ${_chatService.unreadCount}',
|
|
name: 'JobsView',
|
|
);
|
|
if (mounted) {
|
|
setState(() {
|
|
_unreadMessageCount = _chatService.unreadCount;
|
|
});
|
|
developer.log(
|
|
'[DEBUG_LOG] JobsView set initial badge count to: $_unreadMessageCount',
|
|
name: 'JobsView',
|
|
);
|
|
}
|
|
});
|
|
|
|
// Load jobs from database first (for offline/cached jobs)
|
|
_loadJobsFromDatabase();
|
|
|
|
_initializeAndLoadJobs();
|
|
// Also load seen flags for any jobs already in memory (e.g., from DB)
|
|
_loadSeenFlagsForCurrentJobs();
|
|
}
|
|
|
|
/// Load jobs from database on startup
|
|
Future<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),
|
|
backgroundColor: Colors.deepPurple[100],
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.logout),
|
|
onPressed: () {
|
|
_handleLogout();
|
|
},
|
|
tooltip: AppLocalizations.of(context).logout,
|
|
),
|
|
actions: [
|
|
// Chat Icon with Badge
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 4.0),
|
|
child: Badge(
|
|
label: Text('$_unreadMessageCount'),
|
|
isLabelVisible: _unreadMessageCount > 0,
|
|
child: IconButton(
|
|
icon: const Icon(Icons.chat),
|
|
onPressed: () {
|
|
Navigator.of(context).pushNamed('/chats');
|
|
},
|
|
tooltip: AppLocalizations.of(context).openChat,
|
|
),
|
|
),
|
|
),
|
|
// Settings Icon
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 10.0),
|
|
child: IconButton(
|
|
icon: const Icon(Icons.settings),
|
|
onPressed: () {
|
|
Navigator.of(context).pushNamed('/settings');
|
|
},
|
|
tooltip: AppLocalizations.of(context).settings,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: Column(
|
|
children: [
|
|
// Offline banner under header
|
|
OfflineBanner(),
|
|
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [Expanded(child: _buildJobsList())],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _handleLogout() {
|
|
// Capture a parent context before opening the dialog to ensure we can always navigate on root
|
|
final parentContext = context;
|
|
showDialog(
|
|
context: parentContext,
|
|
builder: (BuildContext dialogContext) {
|
|
return AlertDialog(
|
|
title: Text(AppLocalizations.of(context).logoutConfirm),
|
|
content: Text(AppLocalizations.of(context).logoutConfirmMessage),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(dialogContext).pop();
|
|
},
|
|
child: Text(AppLocalizations.of(context).cancel),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () async {
|
|
_isLoggingOut = true; // suppress connection snackbars
|
|
|
|
// We'll ensure navigation to login in a finally block
|
|
try {
|
|
await _appState.clearLogin(); // clear login + DB
|
|
await _stompService
|
|
.logout(); // only clear auth state, keep WebSocket connected
|
|
|
|
// Cleanup
|
|
_jobsSub?.cancel();
|
|
_jobsSub = null;
|
|
|
|
// Prevent further job loads until next login
|
|
_wasConnected = false;
|
|
} catch (e, stackTrace) {
|
|
developer.log(
|
|
'Error during logout flow: $e',
|
|
name: 'JobsView',
|
|
);
|
|
developer.log('Stack trace: $stackTrace', name: 'JobsView');
|
|
} finally {
|
|
if (!mounted) {
|
|
// If the widget is already unmounted, we cannot navigate here safely.
|
|
// Navigation to login will be handled by higher-level route guards on next build.
|
|
} else {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
// Use the parentContext to get the root navigator (avoid dialog context pitfalls)
|
|
final rootNav = Navigator.of(
|
|
parentContext,
|
|
rootNavigator: true,
|
|
);
|
|
|
|
// Clear any visible SnackBars before navigation
|
|
ScaffoldMessenger.maybeOf(
|
|
parentContext,
|
|
)?.clearSnackBars();
|
|
|
|
// Close any remaining routes above root (e.g., dialogs)
|
|
while (rootNav.canPop()) {
|
|
rootNav.pop();
|
|
}
|
|
|
|
// Navigate to login, clearing stack and suppressing connection snack
|
|
rootNav.pushNamedAndRemoveUntil(
|
|
'/login',
|
|
(route) => false,
|
|
arguments: true, // suppressConnectionSnack
|
|
);
|
|
});
|
|
}
|
|
}
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.red,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
child: Text(AppLocalizations.of(context).logout),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildJobsList() {
|
|
final jobs = List<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: Colors.deepPurple[100],
|
|
foregroundColor: Colors.deepPurple[700],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
)
|
|
: 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: Colors.red,
|
|
);
|
|
}
|
|
}
|
|
|
|
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: Colors.red,
|
|
);
|
|
}
|
|
} catch (e, st) {
|
|
developer.log('Error deleting job $jobId: $e', name: 'JobsView');
|
|
developer.log('Stack trace: $st', name: 'JobsView');
|
|
if (mounted) {
|
|
_showSnack(
|
|
AppLocalizations.of(context).jobDeleteError,
|
|
backgroundColor: Colors.red,
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_jobsBeingDeleted.remove(jobId);
|
|
});
|
|
} else {
|
|
_jobsBeingDeleted.remove(jobId);
|
|
}
|
|
}
|
|
}
|
|
|
|
Widget _buildJobCard(Job job) {
|
|
Color statusColor;
|
|
switch (job.statusColor) {
|
|
case 'green':
|
|
statusColor = Colors.green;
|
|
break;
|
|
case 'blue':
|
|
statusColor = Colors.blue;
|
|
break;
|
|
case 'orange':
|
|
statusColor = Colors.orange;
|
|
break;
|
|
case 'red':
|
|
statusColor = Colors.red;
|
|
break;
|
|
default:
|
|
statusColor = Colors.grey;
|
|
}
|
|
|
|
// Determine card background color based on task completion
|
|
final totalTasks = job.tasks.length;
|
|
int completedTasks = 0;
|
|
for (final t in job.tasks) {
|
|
final isCompleted = _taskStatuses[t.id] ?? t.completed;
|
|
if (isCompleted) completedTasks++;
|
|
}
|
|
|
|
// Check if all tasks are completed (job is done)
|
|
final bool isJobCompleted = totalTasks > 0 && completedTasks == totalTasks;
|
|
|
|
Color? cardBg;
|
|
if (totalTasks == 0 || completedTasks == 0) {
|
|
cardBg = null; // unchanged (default)
|
|
} else if (completedTasks > 0 && completedTasks < totalTasks) {
|
|
cardBg = Colors.yellow[50];
|
|
} else if (completedTasks == totalTasks) {
|
|
cardBg = Colors.green[50];
|
|
}
|
|
// Build robust display strings with fallbacks
|
|
final pickupName = _joinNonEmpty([job.pickupFirstName, job.pickupLastName]);
|
|
final pickupDisplayName =
|
|
pickupName.isNotEmpty ? pickupName : job.pickupCompany;
|
|
final pickupAddress = _joinNonEmpty([
|
|
_joinNonEmpty([job.pickupStreet, job.pickupHouseNumber]),
|
|
_joinNonEmpty([job.pickupZip, job.pickupCity]),
|
|
], sep: ', ');
|
|
|
|
final deliveryName = _joinNonEmpty([
|
|
job.deliveryFirstName,
|
|
job.deliveryLastName,
|
|
]);
|
|
final firstDeliveryStation =
|
|
job.deliveryStations.isNotEmpty ? job.deliveryStations.first : null;
|
|
final hasMultipleDeliveryStations = job.deliveryStations.length > 1;
|
|
final deliveryDisplayName =
|
|
hasMultipleDeliveryStations
|
|
? (job.deliveryCitiesDisplay.isNotEmpty
|
|
? job.deliveryCitiesDisplay
|
|
: job.deliveryCompany)
|
|
: (deliveryName.isNotEmpty
|
|
? deliveryName
|
|
: (firstDeliveryStation?.displayName.isNotEmpty == true
|
|
? firstDeliveryStation!.displayName
|
|
: job.deliveryCompany));
|
|
final deliveryAddress =
|
|
hasMultipleDeliveryStations
|
|
? 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: Colors.red),
|
|
tooltip: AppLocalizations.of(context).deleteJob,
|
|
onPressed: () {
|
|
if (isDeleting) {
|
|
return;
|
|
}
|
|
_deleteJob(job);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
GestureDetector(
|
|
// Only enable swipe gestures for completed jobs
|
|
onHorizontalDragStart:
|
|
isJobCompleted
|
|
? (_) {
|
|
final openId = _openJobId;
|
|
if (openId != null && openId != job.id) {
|
|
_closeSwipe(openId);
|
|
}
|
|
}
|
|
: null,
|
|
onHorizontalDragUpdate:
|
|
isJobCompleted
|
|
? (details) {
|
|
if (_jobsBeingDeleted.contains(job.id)) {
|
|
return;
|
|
}
|
|
_handleJobDragUpdate(job, details);
|
|
}
|
|
: null,
|
|
onHorizontalDragEnd:
|
|
isJobCompleted
|
|
? (details) {
|
|
if (_jobsBeingDeleted.contains(job.id)) {
|
|
return;
|
|
}
|
|
_handleJobDragEnd(job, details);
|
|
}
|
|
: null,
|
|
onHorizontalDragCancel:
|
|
isJobCompleted ? () => _closeSwipe(job.id) : null,
|
|
child: TweenAnimationBuilder<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: Colors.grey[200],
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
completedTasks >= totalTasks
|
|
? Colors.green
|
|
: (completedTasks > 0
|
|
? Colors.amber
|
|
: Colors.deepPurpleAccent),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'$completedTasks/$totalTasks',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[700],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
const SizedBox(height: 12),
|
|
// Pickup Information
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.arrow_upward,
|
|
size: 16,
|
|
color: Colors.green[600],
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'${AppLocalizations.of(context).pickup}${job.pickupDate.isNotEmpty ? ' ${_formatDateString(job.pickupDate)}${job.pickupTime.isNotEmpty ? ' ${_formatTimeString(job.pickupTime)}' : ''}' : ''}',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[700],
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 2),
|
|
if (pickupDisplayName.isNotEmpty)
|
|
Text(
|
|
pickupDisplayName,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: Colors.grey[800],
|
|
),
|
|
),
|
|
if (pickupAddress.isNotEmpty)
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
pickupAddress,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton(
|
|
tooltip: AppLocalizations.of(context).routePlan,
|
|
icon: const Icon(
|
|
Icons.route,
|
|
color: Colors.green,
|
|
),
|
|
onPressed: () {
|
|
if (_routeActionInProgress) return;
|
|
setState(() => _routeActionInProgress = true);
|
|
_openRoutingView(
|
|
address: pickupAddress,
|
|
isDelivery: false,
|
|
title:
|
|
pickupDisplayName.isNotEmpty
|
|
? 'Abholung$pickupDisplayName'
|
|
: 'Abholadresse',
|
|
);
|
|
// Reset after short delay to avoid double-push
|
|
Future.delayed(
|
|
const Duration(milliseconds: 600),
|
|
() {
|
|
if (mounted) {
|
|
setState(
|
|
() => _routeActionInProgress = false,
|
|
);
|
|
}
|
|
},
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
// Delivery Information
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.arrow_downward,
|
|
size: 16,
|
|
color: Colors.blue[600],
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'${AppLocalizations.of(context).delivery}${job.deliveryDate.isNotEmpty ? ' ${_formatDateString(job.deliveryDate)}${job.deliveryTime.isNotEmpty ? ' ${_formatTimeString(job.deliveryTime)}' : ''}' : ''}',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[700],
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 2),
|
|
if (deliveryDisplayName.isNotEmpty)
|
|
Text(
|
|
deliveryDisplayName,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: Colors.grey[800],
|
|
),
|
|
),
|
|
if (deliveryAddress.isNotEmpty)
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
deliveryAddress,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton(
|
|
tooltip: 'Route planen',
|
|
icon: const Icon(
|
|
Icons.route,
|
|
color: Colors.blueAccent,
|
|
),
|
|
onPressed: () {
|
|
if (_routeActionInProgress) return;
|
|
setState(() => _routeActionInProgress = true);
|
|
_openRoutingView(
|
|
address: deliveryRouteAddress,
|
|
isDelivery: true,
|
|
title:
|
|
hasMultipleDeliveryStations
|
|
? 'Erste Zustelladresse'
|
|
: (deliveryDisplayName.isNotEmpty
|
|
? 'Zustellung $deliveryDisplayName'
|
|
: 'Zustelladresse'),
|
|
);
|
|
// Reset after short delay to avoid double-push
|
|
Future.delayed(
|
|
const Duration(milliseconds: 600),
|
|
() {
|
|
if (mounted) {
|
|
setState(
|
|
() => _routeActionInProgress = false,
|
|
);
|
|
}
|
|
},
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
// Dates and Price
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.schedule,
|
|
size: 16,
|
|
color: Colors.grey[500],
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'${AppLocalizations.of(context).created}: ${_formatDate(job.createdAt)}',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[500],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (job.price > 0) ...[
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 6,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.green[50],
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: Colors.green[200]!),
|
|
),
|
|
child: Text(
|
|
'${job.price.toStringAsFixed(2)} €',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.green[700],
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showJobDetails(Job job) {
|
|
Navigator.of(context).pushNamed('/cargo_items', arguments: job);
|
|
}
|
|
|
|
void _toggleTaskCompletion(Job job, int taskIndex) {
|
|
// Create a new task with toggled completion status
|
|
final updatedTask = job.tasks[taskIndex].copyWith(
|
|
completed: !job.tasks[taskIndex].completed,
|
|
);
|
|
|
|
// Create a new list with the updated task
|
|
final updatedTasks = List<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;
|
|
}
|
|
}
|
|
}
|