1065 lines
32 KiB
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;
|