Files
votianlt/app/lib/services/websocket_service.dart

1065 lines
32 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 '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;
}
// ---------------------------------------------------------------------------
// 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')) {
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 Jobs',
body:
newCount == 1
? 'Sie haben einen neuen Job erhalten.'
: 'Sie haben $newCount neue Jobs 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 Job',
body:
jobNumber != null && jobNumber.isNotEmpty
? 'Job $jobNumber wurde Ihnen zugewiesen.'
: 'Sie haben einen neuen Job 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 Job $jobNumber'
: 'Neue Job-Nachricht';
} 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');
}
}
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;
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;
final bufferFlushedPayload = jsonEncode({
'timestamp': DateTime.now().toIso8601String(),
'messageCount': sentCount,
});
_sendWebSocket('/server/buffer_flushed', bufferFlushedPayload);
}
/// Publish a chat message according to the backend contract.
/// Returns the locally constructed message so callers can persist it locally.
/// Messages are buffered if offline and sent automatically when reconnected.
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();
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,
};
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 jsonPayload = jsonEncode(payload);
// sendMessage buffers automatically if not connected/authenticated
sendMessage(topic, jsonPayload);
final now = DateTime.now();
final message = ChatMessage(
id: 'local-${now.microsecondsSinceEpoch}',
content: trimmedContent,
createdAt: now,
direction: ChatDirection.outgoing,
messageType:
normalizedJobId != null && normalizedJobId.isNotEmpty
? ChatMessageType.jobRelated
: ChatMessageType.general,
contentType: contentType,
jobId: normalizedJobId?.isEmpty ?? true ? null : normalizedJobId,
jobNumber:
normalizedJobNumber?.isEmpty ?? true ? null : normalizedJobNumber,
read: false,
pendingSync: true,
);
return message;
} catch (e, st) {
developer.log(
'Error encoding chat message payload: $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');
}
}
/// 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;