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:
2026-04-04 10:30:36 +02:00
parent d6132fabe1
commit bba5733783
55 changed files with 2708 additions and 697 deletions

View File

@@ -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;
}