feat: erweiterte Chat-Funktionalität, UI-Verbesserungen und Lokalisierungsupdates
- 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
This commit is contained in:
@@ -13,6 +13,7 @@ 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 {
|
||||
@@ -193,6 +194,73 @@ class WebSocketService {
|
||||
_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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -290,6 +358,8 @@ class WebSocketService {
|
||||
_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 {
|
||||
@@ -598,6 +668,20 @@ class 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') {
|
||||
@@ -731,6 +815,7 @@ class WebSocketService {
|
||||
/// 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(
|
||||
@@ -766,7 +851,8 @@ class WebSocketService {
|
||||
await _databaseService.clearAllJobsAndRelatedData();
|
||||
|
||||
// Notify server that buffer flush is complete
|
||||
final sentCount = initialBufferSize - _messageBuffer.length;
|
||||
final sentCount =
|
||||
(initialBufferSize - _messageBuffer.length) + sentQueuedChatCount;
|
||||
final bufferFlushedPayload = jsonEncode({
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
'messageCount': sentCount,
|
||||
@@ -774,9 +860,51 @@ class WebSocketService {
|
||||
_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.
|
||||
/// Returns the locally constructed message so callers can persist it locally.
|
||||
/// Messages are buffered if offline and sent automatically when reconnected.
|
||||
/// The message is stored locally and remains queued until the server confirms it.
|
||||
Future<ChatMessage?> sendChatMessage({
|
||||
required String sender,
|
||||
required String receiver,
|
||||
@@ -790,6 +918,9 @@ class WebSocketService {
|
||||
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 ||
|
||||
@@ -816,6 +947,9 @@ class WebSocketService {
|
||||
'receiver': trimmedReceiver,
|
||||
'content': trimmedContent,
|
||||
};
|
||||
final now = DateTime.now();
|
||||
final clientMessageId = 'local-${now.microsecondsSinceEpoch}';
|
||||
payload['messageId'] = clientMessageId;
|
||||
|
||||
if (normalizedJobId != null && normalizedJobId.isNotEmpty) {
|
||||
payload['jobId'] = normalizedJobId;
|
||||
@@ -828,18 +962,13 @@ class WebSocketService {
|
||||
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}',
|
||||
id: clientMessageId,
|
||||
content: trimmedContent,
|
||||
createdAt: now,
|
||||
direction: ChatDirection.outgoing,
|
||||
messageType:
|
||||
normalizedJobId != null && normalizedJobId.isNotEmpty
|
||||
hasJobContext
|
||||
? ChatMessageType.jobRelated
|
||||
: ChatMessageType.general,
|
||||
contentType: contentType,
|
||||
@@ -849,13 +978,26 @@ class WebSocketService {
|
||||
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 encoding chat message payload: $e',
|
||||
name: 'WebSocketService',
|
||||
);
|
||||
developer.log('Error sending chat message: $e', name: 'WebSocketService');
|
||||
developer.log('Stack: $st', name: 'WebSocketService');
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user