Files
votianlt/app/lib/services/websocket_service.dart
T
Sven b642f42954 feat: Auftragsdetails und Stationsabschluss für Version 0.9.24
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.
2026-10-01 11:31:59 +02:00

1238 lines
37 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:votianlt_app/services/developer.dart' as developer;
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_socket_channel/status.dart' as ws_status;
import 'dart:math';
import 'database_service.dart';
import 'chat_service.dart';
import 'notification_service.dart';
import 'location_service.dart';
import '../app_state.dart';
import '../models/chat_message.dart';
import '../models/job.dart';
import '../models/queued_message.dart';
import 'dart_mq.dart';
class WebSocketService {
static final WebSocketService _instance = WebSocketService._internal();
factory WebSocketService() => _instance;
WebSocketService._internal();
WebSocketChannel? _wsChannel;
StreamSubscription? _wsSubscription;
bool _isConnected = false;
bool _isConnecting = false;
// Authentication state
bool _isAuthenticated = false;
String? _authToken;
// Keep last known values for UI initialization (Behavior-like)
Map<String, dynamic>? _lastAuthResponse;
// Completer to await a graceful WebSocket disconnect
Completer<void>? _disconnectCompleter;
// Unique persistent App ID for client identification
String? _appId;
// Automatic reconnection timer
Timer? _reconnectTimer;
// In-memory message buffer for messages sent while disconnected
final List<_BufferedMessage> _messageBuffer = [];
// Database service
final DatabaseService _databaseService = DatabaseService();
// Validator for optional jobId field on chat messages
final RegExp _jobIdRegExp = RegExp(r'^[0-9a-fA-F]{24}$');
/// Ensure a unique persistent App ID exists.
/// Generates a UUID v4 if not already stored.
Future<void> _ensureAppId() async {
if (_appId != null) return;
try {
final existing = await _databaseService.loadKeyValue('appId');
if (existing != null && existing.isNotEmpty) {
_appId = existing;
developer.log(
'Loaded existing appId: $_appId',
name: 'WebSocketService',
);
return;
}
} catch (_) {}
// Generate a UUID v4 and persist
_appId = _generateUuid();
developer.log('Generated new appId: $_appId', name: 'WebSocketService');
try {
await _databaseService.saveKeyValue('appId', _appId!);
} catch (_) {}
}
/// Get the unique persistent App ID (for external access if needed)
String? get appId => _appId;
/// Generate a UUID v4
String _generateUuid() {
final rand = Random();
final buf = StringBuffer();
for (int i = 0; i < 36; i++) {
if (i == 8 || i == 13 || i == 18 || i == 23) {
buf.write('-');
} else if (i == 14) {
buf.write('4');
} else if (i == 19) {
buf.write(['8', '9', 'a', 'b'][rand.nextInt(4)]);
} else {
buf.write(rand.nextInt(16).toRadixString(16));
}
}
return buf.toString();
}
// ---------------------------------------------------------------------------
// WebSocket Connection
// ---------------------------------------------------------------------------
/// Build the WebSocket URL
/// Im Release-Modus: votianlt.de (Produktionsserver)
/// Im Debug-Modus: localhost (Android Emulator: 10.0.2.2)
String _buildWebSocketUrl() {
// Release-Modus: Verbindung zum Produktionsserver
if (kReleaseMode) {
return 'wss://votianlt.de/ws/messaging';
}
return 'ws://192.168.180.10:8082/ws/messaging';
}
/// Handle a connected WebSocket (common setup for connect and reconnect)
void _onWebSocketConnected() {
developer.log('WebSocket connected', name: 'WebSocketService');
// Update internal connection state
_isConnected = true;
_isConnecting = false;
// Note: Don't publish connectionStatus=true yet - wait for successful auth
// Re-run the same setup as initial connection (auto-login)
_setupAfterConnect();
}
/// Setup auto-login after connection (initial or reconnect)
Future<void> _setupAfterConnect() async {
try {
// Ensure we have an appId
await _ensureAppId();
// Auto-login with saved credentials if user was previously logged in
final credentials = await _databaseService.loadCredentials();
if (credentials != null) {
developer.log(
'Auto-login with saved credentials for ${credentials.email}',
name: 'WebSocketService',
);
await login(credentials.email, credentials.password);
}
} catch (e, st) {
developer.log(
'Error in _setupAfterConnect: $e',
name: 'WebSocketService',
);
developer.log('Stack: $st', name: 'WebSocketService');
}
}
/// Handle WebSocket disconnection
void _handleWebSocketDisconnect() {
developer.log('WebSocket disconnected', name: 'WebSocketService');
_isConnected = false;
_isAuthenticated = false;
Future.microtask(() {
DartMQ().publish<bool>(MQTopics.connectionStatus, false);
});
// Clean up WebSocket resources
_wsSubscription?.cancel();
_wsSubscription = null;
_wsChannel = null;
try {
_disconnectCompleter?.complete();
} catch (_) {}
_disconnectCompleter = null;
// Start automatic reconnection attempts
_startReconnectTimer();
}
void _startReconnectTimer() {
_stopReconnectTimer();
_reconnectTimer = Timer.periodic(const Duration(seconds: 15), (timer) {
if (_isConnected || _isConnecting) {
_stopReconnectTimer();
return;
}
developer.log(
'Attempting automatic reconnection...',
name: 'WebSocketService',
);
connect();
});
}
void _stopReconnectTimer() {
_reconnectTimer?.cancel();
_reconnectTimer = null;
}
/// Force a clean reconnect after the app resumes from standby.
/// Keeps buffered outbound messages intact and relies on saved credentials
/// for the subsequent auto-login inside [connect].
Future<void> reconnectForAppResume() async {
final credentials = await _databaseService.loadCredentials();
if (credentials == null) {
developer.log(
'Skipping reconnect after resume - no saved credentials',
name: 'WebSocketService',
);
return;
}
if (_isConnecting) {
developer.log(
'Skipping reconnect after resume - connection attempt already running',
name: 'WebSocketService',
);
return;
}
developer.log(
'Restarting WebSocket connection after app resume',
name: 'WebSocketService',
);
_stopReconnectTimer();
final existingSubscription = _wsSubscription;
final existingChannel = _wsChannel;
_wsSubscription = null;
_wsChannel = null;
_disconnectCompleter = null;
_isConnected = false;
_isConnecting = false;
_isAuthenticated = false;
_authToken = null;
_lastAuthResponse = null;
Future.microtask(() {
DartMQ().publish<bool>(MQTopics.connectionStatus, false);
});
try {
await existingSubscription?.cancel();
} catch (e, st) {
developer.log(
'Error cancelling old WebSocket subscription on resume: $e',
name: 'WebSocketService',
);
developer.log('Stack: $st', name: 'WebSocketService');
}
try {
await existingChannel?.sink.close(ws_status.goingAway);
} catch (e, st) {
developer.log(
'Error closing old WebSocket channel on resume: $e',
name: 'WebSocketService',
);
developer.log('Stack: $st', name: 'WebSocketService');
}
await connect();
}
// ---------------------------------------------------------------------------
// WebSocket Send / Receive
// ---------------------------------------------------------------------------
/// Send a message over WebSocket in wire format: {"topic": ..., "payload": ...}
bool _sendWebSocket(String topic, String jsonPayload) {
if (!_isConnected || _wsChannel == null) {
developer.log(
'Cannot send, not connected. Topic=$topic',
name: 'WebSocketService',
);
return false;
}
try {
final parsed = jsonDecode(jsonPayload);
final wireMessage = jsonEncode({'topic': topic, 'payload': parsed});
developer.log('>> SEND: $wireMessage', name: 'WebSocketService');
_wsChannel!.sink.add(wireMessage);
return true;
} catch (e, st) {
developer.log(
'Error sending WebSocket message: $e',
name: 'WebSocketService',
);
developer.log('Stack: $st', name: 'WebSocketService');
return false;
}
}
/// Handle incoming WebSocket message
void _onWebSocketMessage(dynamic rawData) async {
if (rawData is! String) {
developer.log(
'Received non-text WebSocket message, ignoring',
name: 'WebSocketService',
);
return;
}
try {
final wireMessage = jsonDecode(rawData) as Map<String, dynamic>;
final topic = wireMessage['topic'] as String?;
final payload = wireMessage['payload'];
if (topic == null) {
developer.log(
'WebSocket message missing topic field',
name: 'WebSocketService',
);
return;
}
developer.log('<< RECEIVED: $rawData', name: 'WebSocketService');
await _handleMessage(topic, payload);
} catch (e, st) {
developer.log(
'Error parsing WebSocket message: $e',
name: 'WebSocketService',
);
developer.log('Stack: $st', name: 'WebSocketService');
}
}
// ---------------------------------------------------------------------------
// Message Handlers
// ---------------------------------------------------------------------------
Future<void> _handleMessage(String topic, dynamic data) async {
developer.log(
'_handleMessage called with topic: $topic',
name: 'WebSocketService',
);
if (topic.startsWith('/client/')) {
await _handleClientMessage(topic, data);
} else {
developer.log(
'Topic does not start with /client/, ignoring',
name: 'WebSocketService',
);
}
}
Future<void> _handleClientMessage(String topic, dynamic data) async {
developer.log(
'Handling client message: topic=$topic, dataType=${data.runtimeType}',
name: 'WebSocketService',
);
if (topic.endsWith('/auth')) {
await _handleAuthMessage(topic, data);
} else if (topic.endsWith('/jobs')) {
_handleJobsMessage(data);
} else if (topic.endsWith('/job_deleted')) {
_handleJobDeletedMessage(data);
} else if (topic.endsWith('/job_created')) {
_handleJobCreatedMessage(data);
} else if (topic.endsWith('/message_ack')) {
await _handleChatMessageAck(data);
} else if (topic.endsWith('/message')) {
await _handleChatMessage(topic, data);
} else {
_handleOtherClientMessage(topic, data);
}
}
Future<void> _handleAuthMessage(
String topic,
Map<String, dynamic> data,
) async {
_lastAuthResponse = data;
DartMQ().publish<Map<String, dynamic>>(MQTopics.authResponse, data);
if (data['success'] == true) {
await _handleSuccessfulAuth();
} else {
_handleFailedAuth();
}
}
Future<void> _handleSuccessfulAuth() async {
_isAuthenticated = true;
developer.log('Auth successful', name: 'WebSocketService');
// Publish connection status to UI - fully connected and authenticated (async to avoid build-phase issues)
Future.microtask(() {
DartMQ().publish<bool>(MQTopics.connectionStatus, true);
});
// Flush any messages that were buffered while disconnected.
// This also clears local jobs and notifies the server.
await _flushMessageBuffer();
// Start location tracking only if enabled in auth response
final locationTrackingEnabled =
_lastAuthResponse?['locationTrackingEnabled'] == true;
developer.log(
'Location tracking enabled: $locationTrackingEnabled',
name: 'WebSocketService',
);
if (locationTrackingEnabled) {
LocationService().startTracking();
developer.log('Location tracking started', name: 'WebSocketService');
} else {
developer.log(
'Location tracking disabled by server',
name: 'WebSocketService',
);
}
}
void _handleFailedAuth() {
_isAuthenticated = false;
_authToken = null;
}
/// Übersetzung deaktiviert - Texte werden im Original angezeigt
Future<Map<String, dynamic>> _translateJobData(
Map<String, dynamic> jobData,
) async {
// Keine Übersetzung - Daten werden so wie vom Server empfangen verwendet
return jobData;
}
void _handleJobsMessage(List<dynamic> data) async {
final jobs = data;
// Log empfangene Jobs JSON
developer.log(
'<< JOBS RECEIVED: ${jsonEncode(data)}',
name: 'WebSocketService',
);
if (jobs.isNotEmpty) {
final currentJobCount = AppState().assignedJobs.length;
if (currentJobCount > 0 && jobs.length > currentJobCount) {
final newCount = jobs.length - currentJobCount;
NotificationService().showJobNotification(
title: 'Neue Aufträge',
body:
newCount == 1
? 'Sie haben einen neuen Auftrag erhalten.'
: 'Sie haben $newCount neue Aufträge erhalten.',
);
}
// Parse and persist jobs to database immediately
try {
final List<Job> parsedJobs = [];
for (final jobData in jobs) {
try {
Map<String, dynamic> actualJobData;
if (jobData is Map<String, dynamic> && jobData.containsKey('job')) {
actualJobData = Map<String, dynamic>.from(
jobData['job'] as Map<String, dynamic>,
);
if (jobData.containsKey('tasks') && jobData['tasks'] is List) {
actualJobData['tasks'] = jobData['tasks'];
}
if (jobData.containsKey('cargoItems') &&
jobData['cargoItems'] is List) {
actualJobData['cargoItems'] = jobData['cargoItems'];
}
} else {
actualJobData = jobData as Map<String, dynamic>;
}
// Übersetze Textfelder vor dem Speichern
actualJobData = await _translateJobData(actualJobData);
final job = Job.fromJson(actualJobData);
parsedJobs.add(job);
} catch (e, stackTrace) {
developer.log('Error parsing job: $e', name: 'WebSocketService');
developer.log('Stack trace: $stackTrace', name: 'WebSocketService');
}
}
// Save all parsed jobs to database
if (parsedJobs.isNotEmpty) {
await _databaseService.saveJobs(parsedJobs);
developer.log(
'Saved ${parsedJobs.length} jobs to database',
name: 'WebSocketService',
);
}
} catch (e, stackTrace) {
developer.log(
'Error saving jobs to database: $e',
name: 'WebSocketService',
);
developer.log('Stack trace: $stackTrace', name: 'WebSocketService');
}
DartMQ().publish<List<dynamic>>(MQTopics.jobsResponse, jobs);
} else {
// Clear all jobs from database when empty list received
await _databaseService.clearAllJobsAndRelatedData();
developer.log(
'Cleared all jobs from database (empty list received)',
name: 'WebSocketService',
);
// Still publish empty list to complete any waiting operations
DartMQ().publish<List<dynamic>>(MQTopics.jobsResponse, []);
}
}
void _handleJobDeletedMessage(Map<String, dynamic> data) async {
final jobId = data['jobId']?.toString();
final jobNumber = data['jobNumber']?.toString();
if (jobId == null || jobId.isEmpty) {
developer.log(
'Received job_deleted message without jobId',
name: 'WebSocketService',
);
return;
}
developer.log(
'<< JOB DELETED: jobId=$jobId, jobNumber=$jobNumber',
name: 'WebSocketService',
);
// Delete job from database immediately
try {
await _databaseService.deleteJob(jobId);
developer.log(
'Deleted job $jobId from database',
name: 'WebSocketService',
);
} catch (e, stackTrace) {
developer.log(
'Error deleting job $jobId from database: $e',
name: 'WebSocketService',
);
developer.log('Stack trace: $stackTrace', name: 'WebSocketService');
}
// Publish event via DartMQ for UI to handle
DartMQ().publish<Map<String, dynamic>>(MQTopics.jobDeleted, data);
}
void _handleJobCreatedMessage(Map<String, dynamic> data) async {
final jobId = data['job']?['id']?.toString() ?? data['id']?.toString();
final jobNumber =
data['job']?['jobNumber']?.toString() ?? data['jobNumber']?.toString();
// Log empfangenes Job JSON
developer.log(
'<< JOB CREATED JSON: ${jsonEncode(data)}',
name: 'WebSocketService',
);
developer.log(
'<< JOB CREATED: jobId=$jobId, jobNumber=$jobNumber',
name: 'WebSocketService',
);
// Parse and persist job to database immediately
try {
Map<String, dynamic> actualJobData;
if (data.containsKey('job')) {
actualJobData = Map<String, dynamic>.from(
data['job'] as Map<String, dynamic>,
);
if (data.containsKey('tasks') && data['tasks'] is List) {
actualJobData['tasks'] = data['tasks'];
}
if (data.containsKey('cargoItems') && data['cargoItems'] is List) {
actualJobData['cargoItems'] = data['cargoItems'];
}
} else {
actualJobData = data;
}
// Übersetze Textfelder vor dem Speichern
actualJobData = await _translateJobData(actualJobData);
final job = Job.fromJson(actualJobData);
await _databaseService.saveOrUpdateJob(job);
developer.log(
'Saved new job ${job.id} to database',
name: 'WebSocketService',
);
} catch (e, stackTrace) {
developer.log(
'Error saving new job to database: $e',
name: 'WebSocketService',
);
developer.log('Stack trace: $stackTrace', name: 'WebSocketService');
}
// Show notification with sound for new job
NotificationService().showJobNotification(
title: 'Neuer Auftrag',
body:
jobNumber != null && jobNumber.isNotEmpty
? 'Auftrag $jobNumber wurde Ihnen zugewiesen.'
: 'Sie haben einen neuen Auftrag erhalten.',
);
// Publish event via DartMQ for UI to handle
DartMQ().publish<Map<String, dynamic>>(MQTopics.jobCreated, data);
}
Future<void> _handleChatMessage(
String topic,
Map<String, dynamic> data,
) async {
const requiredFields = [
'messageId',
'content',
'origin',
'messageType',
'createdAt',
];
final missing = <String>[];
for (final field in requiredFields) {
final value = data[field];
if (value == null || (value is String && value.trim().isEmpty)) {
missing.add(field);
}
}
if (missing.isNotEmpty) {
return;
}
try {
final message = ChatMessage.fromJson(data);
await ChatService().saveIncomingMessage(message);
final conversationKey = ChatService().conversationKeyForMessage(message);
String notificationTitle;
if (message.messageType == ChatMessageType.jobRelated) {
final jobNumber = message.jobNumber ?? '';
notificationTitle =
jobNumber.isNotEmpty
? 'Nachricht zu Auftrag $jobNumber'
: 'Neue Auftragsnachricht';
} else {
notificationTitle = 'Neue Nachricht';
}
String notificationBody;
if (message.contentType == ChatContentType.image) {
notificationBody = '[Bild]';
} else {
final content = message.content;
notificationBody =
content.length > 100 ? '${content.substring(0, 100)}...' : content;
}
NotificationService().showChatNotification(
title: notificationTitle,
body: notificationBody,
conversationKey: conversationKey,
);
} catch (e, st) {
developer.log('Error parsing chat message: $e', name: 'WebSocketService');
developer.log('Stack: $st', name: 'WebSocketService');
}
}
Future<void> _handleChatMessageAck(Map<String, dynamic> data) async {
final clientMessageId = data['clientMessageId']?.toString().trim() ?? '';
if (clientMessageId.isEmpty) {
developer.log(
'Received message ACK without clientMessageId',
name: 'WebSocketService',
);
return;
}
await _databaseService.removeQueuedMessage(clientMessageId);
await ChatService().markOutgoingMessageSynced(clientMessageId);
}
void _handleOtherClientMessage(String topic, Map<String, dynamic> data) {
final type = data['type'];
if (topic.contains('/tasks/') || type == 'task') {
DartMQ().publish<Map<String, dynamic>>(MQTopics.taskEvents, data);
} else {
developer.log(
'Unhandled client message type: $type on topic: $topic',
name: 'WebSocketService',
);
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
bool get isConnected => _isConnected;
bool get isConnecting => _isConnecting;
bool get isAuthenticated => _isAuthenticated;
String? get authToken => _authToken;
/// Connect to the WebSocket server
Future<void> connect() async {
// Prevent overlapping connection attempts
if (_isConnected) {
developer.log(
'Already connected to WebSocket server',
name: 'WebSocketService',
);
return;
}
if (_isConnecting) {
developer.log(
'Connection attempt already in progress - skipping',
name: 'WebSocketService',
);
return;
}
final wsUrl = _buildWebSocketUrl();
developer.log(
'Connecting to WebSocket server: $wsUrl',
name: 'WebSocketService',
);
_isConnecting = true;
// Publish "not connected" status to UI while connecting (async to avoid build-phase issues)
Future.microtask(() {
DartMQ().publish<bool>(MQTopics.connectionStatus, false);
});
try {
// Ensure stable appId
await _ensureAppId();
final channel = WebSocketChannel.connect(Uri.parse(wsUrl));
// Wait for the connection to be established
await channel.ready;
_wsChannel = channel;
// Listen for incoming messages
_wsSubscription = channel.stream.listen(
_onWebSocketMessage,
onError: (error) {
developer.log('WebSocket error: $error', name: 'WebSocketService');
_handleWebSocketDisconnect();
},
onDone: () {
developer.log(
'WebSocket connection closed',
name: 'WebSocketService',
);
_handleWebSocketDisconnect();
},
cancelOnError: false,
);
// Stop any reconnection attempts since we're now connected
_stopReconnectTimer();
developer.log(
'Connected to WebSocket server at $wsUrl',
name: 'WebSocketService',
);
// Run common post-connect setup (sets _isConnected, starts timers, auto-login)
_onWebSocketConnected();
} catch (e) {
_isConnecting = false;
developer.log(
'Error connecting to WebSocket server: $e',
name: 'WebSocketService',
);
// Start reconnection attempts on connection failure
_startReconnectTimer();
}
}
/// Send a message to the server. Buffers if not connected/authenticated
/// or if sending fails due to an exception.
void sendMessage(String topic, String message) {
if (_isConnected && _isAuthenticated && _wsChannel != null) {
final success = _sendWebSocket(topic, message);
if (!success) {
// Message could not be sent due to exception - buffer for retry
developer.log(
'>> BUFFERED (send failed): topic=$topic',
name: 'WebSocketService',
);
_messageBuffer.add(_BufferedMessage(topic, message));
}
} else {
developer.log(
'>> BUFFERED (not ready): topic=$topic',
name: 'WebSocketService',
);
_messageBuffer.add(_BufferedMessage(topic, message));
}
}
/// Flush all buffered messages after successful authentication.
/// Messages that fail to send are re-buffered for the next flush cycle.
/// Clears all local jobs and related data, then notifies the server.
Future<void> _flushMessageBuffer() async {
final initialBufferSize = _messageBuffer.length;
final sentQueuedChatCount = await _flushQueuedChatMessages();
if (initialBufferSize > 0) {
developer.log(
'Flushing ${_messageBuffer.length} buffered messages',
name: 'WebSocketService',
);
final messages = List<_BufferedMessage>.from(_messageBuffer);
_messageBuffer.clear();
final failedMessages = <_BufferedMessage>[];
for (final msg in messages) {
final success = _sendWebSocket(msg.topic, msg.jsonPayload);
if (!success) {
// Re-buffer failed messages for retry on next connection
failedMessages.add(msg);
}
}
// Add failed messages back to buffer for retry
if (failedMessages.isNotEmpty) {
developer.log(
'${failedMessages.length} messages failed to send, re-buffering for retry',
name: 'WebSocketService',
);
_messageBuffer.addAll(failedMessages);
}
}
// Clear all local jobs and related data before notifying server.
// The server will send current jobs automatically.
developer.log(
'Clearing local jobs and related data before buffer_flushed notification',
name: 'WebSocketService',
);
await _databaseService.clearAllJobsAndRelatedData();
// Notify server that buffer flush is complete
final sentCount =
(initialBufferSize - _messageBuffer.length) + sentQueuedChatCount;
final bufferFlushedPayload = jsonEncode({
'timestamp': DateTime.now().toIso8601String(),
'messageCount': sentCount,
});
_sendWebSocket('/server/buffer_flushed', bufferFlushedPayload);
}
Future<int> _flushQueuedChatMessages() async {
final queuedMessages = await _databaseService.getQueuedMessages();
if (queuedMessages.isEmpty) {
return 0;
}
developer.log(
'Flushing ${queuedMessages.length} queued chat messages',
name: 'WebSocketService',
);
var sentCount = 0;
for (final message in queuedMessages) {
final success = await _trySendQueuedChatMessage(
message,
incrementRetryOnFailure: true,
);
if (success) {
sentCount++;
}
}
return sentCount;
}
Future<bool> _trySendQueuedChatMessage(
QueuedMessage message, {
bool incrementRetryOnFailure = false,
}) async {
if (!_isConnected || !_isAuthenticated || _wsChannel == null) {
return false;
}
final success = _sendWebSocket(message.topic, jsonEncode(message.payload));
if (!success && incrementRetryOnFailure) {
await _databaseService.updateMessageRetryCount(
message.id,
message.retryCount + 1,
);
}
return success;
}
/// Publish a chat message according to the backend contract.
/// The message is stored locally and remains queued until the server confirms it.
Future<ChatMessage?> sendChatMessage({
required String sender,
required String receiver,
required String content,
ChatContentType contentType = ChatContentType.text,
String? jobId,
String? jobNumber,
}) async {
final trimmedSender = sender.trim();
final trimmedReceiver = receiver.trim();
final trimmedContent = content.trim();
final normalizedJobId = jobId?.trim();
final normalizedJobNumber = jobNumber?.trim();
final hasJobContext =
(normalizedJobId?.isNotEmpty ?? false) ||
(normalizedJobNumber?.isNotEmpty ?? false);
if (trimmedSender.isEmpty ||
trimmedReceiver.isEmpty ||
trimmedContent.isEmpty) {
developer.log(
'Cannot send chat message - missing required fields',
name: 'WebSocketService',
);
return null;
}
if (normalizedJobId != null &&
normalizedJobId.isNotEmpty &&
!_jobIdRegExp.hasMatch(normalizedJobId)) {
developer.log(
'Cannot send chat message - jobId has invalid format: $normalizedJobId',
name: 'WebSocketService',
);
return null;
}
final payload = <String, dynamic>{
'sender': trimmedSender,
'receiver': trimmedReceiver,
'content': trimmedContent,
};
final now = DateTime.now();
final clientMessageId = 'local-${now.microsecondsSinceEpoch}';
payload['messageId'] = clientMessageId;
if (normalizedJobId != null && normalizedJobId.isNotEmpty) {
payload['jobId'] = normalizedJobId;
}
if (normalizedJobNumber != null && normalizedJobNumber.isNotEmpty) {
payload['jobNumber'] = normalizedJobNumber;
}
payload['contentType'] = chatContentTypeToString(contentType);
const topic = '/server/message';
try {
final message = ChatMessage(
id: clientMessageId,
content: trimmedContent,
createdAt: now,
direction: ChatDirection.outgoing,
messageType:
hasJobContext
? ChatMessageType.jobRelated
: ChatMessageType.general,
contentType: contentType,
jobId: normalizedJobId?.isEmpty ?? true ? null : normalizedJobId,
jobNumber:
normalizedJobNumber?.isEmpty ?? true ? null : normalizedJobNumber,
read: false,
pendingSync: true,
);
final queuedMessage = QueuedMessage(
id: clientMessageId,
topic: topic,
payload: payload,
createdAt: now,
);
await _databaseService.queueMessage(queuedMessage);
await ChatService().saveOutgoingMessage(message);
final sentImmediately = await _trySendQueuedChatMessage(queuedMessage);
if (!sentImmediately) {
developer.log(
'Chat message $clientMessageId queued for retry after reconnect',
name: 'WebSocketService',
);
}
return message;
} catch (e, st) {
developer.log('Error sending chat message: $e', name: 'WebSocketService');
developer.log('Stack: $st', name: 'WebSocketService');
return null;
}
}
/// Subscribe to a topic (no-op for WebSocket - all messages arrive on one connection)
void subscribe(String topic, [Function? callback]) {
// WebSocket does not use topic subscriptions.
// All messages are routed by the server to this connection after authentication.
}
/// Send login request
Future<void> login(String email, String password) async {
final loginStartTime = DateTime.now();
final sessionId = loginStartTime.millisecondsSinceEpoch.toString();
developer.log('=== LOGIN METHOD CALLED ===', name: 'WebSocketService');
developer.log('Email: $email', name: 'WebSocketService');
developer.log('isConnected: $_isConnected', name: 'WebSocketService');
developer.log(
'wsChannel: ${_wsChannel != null ? "exists" : "null"}',
name: 'WebSocketService',
);
await _ensureAppId();
developer.log('AppId: $_appId', name: 'WebSocketService');
if (!_isConnected || _wsChannel == null) {
developer.log(
'LOGIN ABORTED: Not connected to server',
name: 'WebSocketService',
);
_lastAuthResponse = {
'success': false,
'message': 'Nicht mit Server verbunden',
'sessionId': sessionId,
'timestamp': loginStartTime.toIso8601String(),
};
DartMQ().publish<Map<String, dynamic>>(
MQTopics.authResponse,
_lastAuthResponse!,
);
return;
}
final loginData = {'email': email, 'password': password};
try {
const topic = '/server/login';
final jsonPayload = jsonEncode(loginData);
developer.log(
'Sending login message to $topic',
name: 'WebSocketService',
);
// Send login directly (not via sendMessage which buffers until authenticated)
_sendWebSocket(topic, jsonPayload);
developer.log(
'Login message sent successfully',
name: 'WebSocketService',
);
} catch (e, st) {
developer.log('Error sending login: $e', name: 'WebSocketService');
developer.log('Stack: $st', name: 'WebSocketService');
_lastAuthResponse = {
'success': false,
'message': 'Fehler beim Senden der Anmeldedaten',
'error': e.toString(),
'sessionId': sessionId,
'timestamp': DateTime.now().toIso8601String(),
};
DartMQ().publish<Map<String, dynamic>>(
MQTopics.authResponse,
_lastAuthResponse!,
);
}
}
/// Retry login with saved credentials (called from UI after auth timeout)
Future<void> retryLoginWithSavedCredentials() async {
if (!_isConnected || _wsChannel == null) {
developer.log(
'Cannot retry login: not connected to server',
name: 'WebSocketService',
);
return;
}
final credentials = await _databaseService.loadCredentials();
if (credentials != null) {
developer.log(
'Retrying login with saved credentials for ${credentials.email}',
name: 'WebSocketService',
);
await login(credentials.email, credentials.password);
} else {
developer.log(
'Cannot retry login: no saved credentials found',
name: 'WebSocketService',
);
}
}
/// Logout user
Future<void> logout() async {
_isAuthenticated = false;
_authToken = null;
// Delete saved credentials to prevent auto-login
await _databaseService.deleteCredentials();
// Stop location tracking
LocationService().stopTracking();
}
/// Disconnect from WebSocket server
Future<void> disconnect() async {
// Stop location tracking
LocationService().stopTracking();
if (_wsChannel != null) {
final completer = Completer<void>();
_disconnectCompleter = completer;
try {
await _wsChannel!.sink.close(ws_status.normalClosure);
} catch (e, st) {
developer.log('Error during disconnect: $e', name: 'WebSocketService');
developer.log('Stack: $st', name: 'WebSocketService');
if (!completer.isCompleted) {
completer.complete();
}
}
try {
await completer.future.timeout(const Duration(seconds: 3));
} catch (_) {}
_wsSubscription?.cancel();
_wsSubscription = null;
_wsChannel = null;
_disconnectCompleter = null;
}
_isConnected = false;
_isAuthenticated = false;
_authToken = null;
_stopReconnectTimer();
_lastAuthResponse = null;
_messageBuffer.clear();
Future.microtask(() {
DartMQ().publish<bool>(MQTopics.connectionStatus, false);
});
}
/// Reset service state without disposing (useful for logout)
Future<void> reset() async {
await disconnect();
}
/// Send task completion event to server.
/// Messages are buffered if offline and sent automatically when reconnected.
Future<void> sendTaskCompleted({
required String taskId,
String? taskType,
Map<String, dynamic>? extraData,
}) async {
final String normalizedType = (taskType ?? 'UNKNOWN').toUpperCase();
const String destination = '/server/task_completed';
final payload = <String, dynamic>{
'taskId': taskId,
'taskType': normalizedType,
};
if (extraData != null && extraData.isNotEmpty) {
payload['extraData'] = extraData;
}
try {
final jsonPayload = jsonEncode(payload);
// sendMessage buffers automatically if not connected/authenticated
sendMessage(destination, jsonPayload);
} catch (e, st) {
developer.log(
'Error sending task completion: $e',
name: 'WebSocketService',
);
developer.log('Stack: $st', name: 'WebSocketService');
}
}
/// Send station completion event to server.
/// Messages are buffered if offline and sent automatically when reconnected.
Future<void> sendStationCompleted({
required String jobId,
required String jobNumber,
required int stationOrder,
bool hasIncompleteOptionalTasks = false,
}) async {
const String destination = '/server/station_completed';
final payload = <String, dynamic>{
'jobId': jobId,
'jobNumber': jobNumber,
'stationOrder': stationOrder,
'completedAt': DateTime.now().toUtc().toIso8601String(),
'hasIncompleteOptionalTasks': hasIncompleteOptionalTasks,
};
try {
final jsonPayload = jsonEncode(payload);
sendMessage(destination, jsonPayload);
} catch (e, st) {
developer.log(
'Error sending station completion: $e',
name: 'WebSocketService',
);
developer.log('Stack: $st', name: 'WebSocketService');
rethrow;
}
}
/// Dispose resources
void dispose() {
_stopReconnectTimer();
disconnect();
}
}
class _BufferedMessage {
final String topic;
final String jsonPayload;
_BufferedMessage(this.topic, this.jsonPayload);
}
// Backward compatibility typedef
typedef StompService = WebSocketService;