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

@@ -15,6 +15,8 @@ class ChatService {
static const _jobIdPrefix = 'job:';
static const _jobNumberPrefix = 'job_number:';
static const _generalPrefix = 'general:';
static const _defaultGeneralConversationKey =
'general:allgemeine-nachrichten';
final DatabaseService _databaseService = DatabaseService();
final AppState _appState = AppState();
@@ -103,9 +105,11 @@ class ChatService {
_chats.removeWhere((chat) {
final matchesKey = conversationKeys.contains(chat.id);
final matchesId = trimmedJobId.isNotEmpty &&
final matchesId =
trimmedJobId.isNotEmpty &&
(chat.jobId?.trim().toLowerCase() == lowerJobId);
final matchesNumber = trimmedJobNumber.isNotEmpty &&
final matchesNumber =
trimmedJobNumber.isNotEmpty &&
(chat.jobNumber?.trim().toLowerCase() == lowerJobNumber);
return matchesKey || matchesId || matchesNumber;
});
@@ -129,18 +133,11 @@ class ChatService {
// Messages with GENERAL messageType should always go to the default general chat
if (message.messageType == ChatMessageType.general) {
final localId = _primaryLocalIdentifier();
if (localId != null && localId.isNotEmpty) {
final key = _conversationKeyForParticipants(
localId,
_appState.loggedInEmail!,
);
developer.log(
'[DEBUG_LOG] GENERAL message detected, routing to conversation key: $key (localId=$localId, receiver=${_appState.loggedInEmail})',
name: 'ChatService',
);
return key;
}
developer.log(
'[DEBUG_LOG] GENERAL message detected, routing to conversation key: $_defaultGeneralConversationKey',
name: 'ChatService',
);
return _defaultGeneralConversationKey;
}
// Job-related messages go to job-specific chats
@@ -165,30 +162,11 @@ class ChatService {
return key;
}
// Fallback: create conversation based on userId
final localId = _primaryLocalIdentifier();
if (localId != null && localId.isNotEmpty) {
final key = _conversationKeyForParticipants(
localId,
_appState.loggedInEmail!,
);
developer.log(
'[DEBUG_LOG] Using fallback routing, conversation key: $key',
name: 'ChatService',
);
return key;
}
developer.log(
'[DEBUG_LOG] No local identifier available for fallback routing',
'[DEBUG_LOG] No job context available, routing to default general chat',
name: 'ChatService',
);
return '$_generalPrefix${_appState.loggedInEmail!}';
}
String _conversationKeyForParticipants(String a, String b) {
final participants = <String>[a.toLowerCase(), b.toLowerCase()]..sort();
return '$_generalPrefix${participants.join('|')}';
return _defaultGeneralConversationKey;
}
Future<void> saveIncomingMessage(ChatMessage message) async {
@@ -205,6 +183,20 @@ class ChatService {
await _persistMessage(message);
}
Future<void> markOutgoingMessageSynced(String messageId) async {
if (!_initialized) {
await initialize();
}
final conversationKey = await _databaseService.updateChatMessagePendingSync(
messageId,
false,
);
if (conversationKey != null && conversationKey.isNotEmpty) {
await _refreshConversation(conversationKey);
}
}
Future<void> _persistMessage(ChatMessage message) async {
final conversationKey = conversationKeyForMessage(message);
@@ -239,7 +231,7 @@ class ChatService {
Future<void> _loadChatsFromDatabase() async {
await _databaseService.ensureInitialized();
final grouped = await _databaseService.loadAllChatMessagesGrouped();
final grouped = await _loadNormalizedChatGroups();
_chats.clear();
grouped.forEach((conversationKey, messages) {
final chat = _buildChat(conversationKey, messages);
@@ -254,6 +246,14 @@ class ChatService {
}
Future<void> _refreshConversation(String conversationKey) async {
if (_isLegacyGeneralConversationKey(conversationKey)) {
await _databaseService.migrateConversationKey(
conversationKey,
_defaultGeneralConversationKey,
);
conversationKey = _defaultGeneralConversationKey;
}
final messages = await _databaseService.loadChatMessages(
conversationKey: conversationKey,
);
@@ -317,15 +317,13 @@ class ChatService {
final counterpartNormalized =
counterpart != null &&
counterpart.toLowerCase() == _appState.loggedInEmail!.toLowerCase()
counterpart.toLowerCase() ==
_appState.loggedInEmail!.toLowerCase()
? _appState.loggedInEmail!
: counterpart;
final bool isDefaultGeneral =
!isJobChat &&
conversationKey.startsWith(_generalPrefix) &&
(counterpartNormalized?.toLowerCase() ==
_appState.loggedInEmail!.toLowerCase());
!isJobChat && conversationKey == _defaultGeneralConversationKey;
final title =
isJobChat
@@ -406,23 +404,46 @@ class ChatService {
}
}
Future<Map<String, List<ChatMessage>>> _loadNormalizedChatGroups() async {
var grouped = await _databaseService.loadAllChatMessagesGrouped();
final legacyGeneralKeys =
grouped.keys.where(_isLegacyGeneralConversationKey).toList();
if (legacyGeneralKeys.isEmpty) {
return grouped;
}
for (final key in legacyGeneralKeys) {
await _databaseService.migrateConversationKey(
key,
_defaultGeneralConversationKey,
);
}
grouped = await _databaseService.loadAllChatMessagesGrouped();
return grouped;
}
bool _isLegacyGeneralConversationKey(String conversationKey) {
return conversationKey != _defaultGeneralConversationKey &&
conversationKey.startsWith(_generalPrefix) &&
!conversationKey.startsWith(_jobIdPrefix) &&
!conversationKey.startsWith(_jobNumberPrefix);
}
void _ensureDefaultGeneralChat() {
final localId = _primaryLocalIdentifier();
if (localId == null || localId.isEmpty) {
final receiver = _appState.loggedInEmail;
if (receiver == null || receiver.isEmpty) {
developer.log(
'[DEBUG_LOG] _ensureDefaultGeneralChat: No local identifier available, skipping',
'[DEBUG_LOG] _ensureDefaultGeneralChat: No receiver available, skipping',
name: 'ChatService',
);
return;
}
final conversationKey = _conversationKeyForParticipants(
localId,
_appState.loggedInEmail!,
);
const conversationKey = _defaultGeneralConversationKey;
developer.log(
'[DEBUG_LOG] _ensureDefaultGeneralChat: Creating/ensuring default general chat with key: $conversationKey (localId=$localId, receiver=${_appState.loggedInEmail})',
'[DEBUG_LOG] _ensureDefaultGeneralChat: Creating/ensuring default general chat with key: $conversationKey (receiver=$receiver)',
name: 'ChatService',
);
@@ -431,8 +452,7 @@ class ChatService {
chat.id != conversationKey &&
chat.type == ChatType.general &&
chat.receiver != null &&
chat.receiver!.toLowerCase() ==
_appState.loggedInEmail!.toLowerCase() &&
chat.receiver!.toLowerCase() == receiver.toLowerCase() &&
chat.messages.isEmpty,
);
final index = _chats.indexWhere((chat) => chat.id == conversationKey);
@@ -446,7 +466,7 @@ class ChatService {
Chat(
id: conversationKey,
title: 'Allgemeine Nachrichten',
receiver: _appState.loggedInEmail!,
receiver: receiver,
type: ChatType.general,
jobId: null,
jobNumber: null,
@@ -463,8 +483,7 @@ class ChatService {
final existing = _chats[index];
if (existing.type != ChatType.general ||
existing.receiver == null ||
existing.receiver!.toLowerCase() !=
_appState.loggedInEmail!.toLowerCase() ||
existing.receiver!.toLowerCase() != receiver.toLowerCase() ||
(existing.messages.isEmpty &&
existing.title != 'Allgemeine Nachrichten')) {
developer.log(
@@ -477,7 +496,7 @@ class ChatService {
existing.messages.isEmpty
? 'Allgemeine Nachrichten'
: existing.title,
receiver: _appState.loggedInEmail!,
receiver: receiver,
type: ChatType.general,
jobId: existing.jobId,
jobNumber: existing.jobNumber,
@@ -493,8 +512,4 @@ class ChatService {
}
}
}
String? _primaryLocalIdentifier() {
return _appState.loggedInEmail;
}
}

View File

@@ -38,7 +38,10 @@ class DatabaseService {
final completer = Completer<void>();
_initializingCompleter = completer;
try {
developer.log('Initializing ObjectBox database...', name: 'DatabaseService');
developer.log(
'Initializing ObjectBox database...',
name: 'DatabaseService',
);
// Get database path
final docsDir = await getApplicationDocumentsDirectory();
@@ -75,8 +78,6 @@ class DatabaseService {
await initialize();
}
/// Log database statistics
Future<void> _logDatabaseStats() async {
try {
@@ -164,7 +165,10 @@ class DatabaseService {
return;
}
developer.log('Deleting job $jobId from database...', name: 'DatabaseService');
developer.log(
'Deleting job $jobId from database...',
name: 'DatabaseService',
);
final jobBox = _store!.box<JobEntity>();
final query = jobBox.query(JobEntity_.jobId.equals(jobId)).build();
@@ -173,9 +177,15 @@ class DatabaseService {
if (entities.isNotEmpty) {
jobBox.remove(entities.first.id);
developer.log('Job $jobId deleted successfully', name: 'DatabaseService');
developer.log(
'Job $jobId deleted successfully',
name: 'DatabaseService',
);
} else {
developer.log('Job $jobId not found in database', name: 'DatabaseService');
developer.log(
'Job $jobId not found in database',
name: 'DatabaseService',
);
}
} catch (e, stackTrace) {
developer.log('Error deleting job: $e', name: 'DatabaseService');
@@ -220,9 +230,13 @@ class DatabaseService {
if (jobs.isNotEmpty) {
try {
final chatBox = _store!.box<ChatMessageEntity>();
final query = chatBox.query(
(ChatMessageEntity_.jobId.notNull() | ChatMessageEntity_.jobNumber.notNull())
).build();
final query =
chatBox
.query(
(ChatMessageEntity_.jobId.notNull() |
ChatMessageEntity_.jobNumber.notNull()),
)
.build();
final messagesWithJobs = query.find();
query.close();
@@ -282,7 +296,8 @@ class DatabaseService {
final taskStatusBox = _store!.box<TaskStatusEntity>();
// Find existing entity by taskId
final query = taskStatusBox.query(TaskStatusEntity_.taskId.equals(taskId)).build();
final query =
taskStatusBox.query(TaskStatusEntity_.taskId.equals(taskId)).build();
final existing = query.findFirst();
query.close();
@@ -321,7 +336,8 @@ class DatabaseService {
}
final taskStatusBox = _store!.box<TaskStatusEntity>();
final query = taskStatusBox.query(TaskStatusEntity_.taskId.equals(taskId)).build();
final query =
taskStatusBox.query(TaskStatusEntity_.taskId.equals(taskId)).build();
final entity = query.findFirst();
query.close();
@@ -449,7 +465,8 @@ class DatabaseService {
final keys = jobIds.map((id) => 'job_seen:$id').toList();
for (final key in keys) {
final query = userDataBox.query(UserDataEntity_.key.equals(key)).build();
final query =
userDataBox.query(UserDataEntity_.key.equals(key)).build();
final entity = query.findFirst();
query.close();
@@ -545,7 +562,8 @@ class DatabaseService {
final taskStatusBox = _store!.box<TaskStatusEntity>();
// Find existing job entity by jobId
final jobQuery = jobBox.query(JobEntity_.jobId.equals(normalized.id)).build();
final jobQuery =
jobBox.query(JobEntity_.jobId.equals(normalized.id)).build();
final existingJob = jobQuery.findFirst();
jobQuery.close();
@@ -568,7 +586,10 @@ class DatabaseService {
final taskIds = normalized.tasks.map((t) => t.id).toList();
if (taskIds.isNotEmpty) {
for (final taskId in taskIds) {
final query = taskStatusBox.query(TaskStatusEntity_.taskId.equals(taskId)).build();
final query =
taskStatusBox
.query(TaskStatusEntity_.taskId.equals(taskId))
.build();
final entities = query.find();
query.close();
for (final entity in entities) {
@@ -617,7 +638,8 @@ class DatabaseService {
if (trimmedJobId.isNotEmpty) {
// Delete job
final jobQuery = jobBox.query(JobEntity_.jobId.equals(trimmedJobId)).build();
final jobQuery =
jobBox.query(JobEntity_.jobId.equals(trimmedJobId)).build();
final jobEntities = jobQuery.find();
jobQuery.close();
for (final entity in jobEntities) {
@@ -625,7 +647,10 @@ class DatabaseService {
}
// Delete job_seen flag
final seenQuery = userDataBox.query(UserDataEntity_.key.equals('job_seen:$trimmedJobId')).build();
final seenQuery =
userDataBox
.query(UserDataEntity_.key.equals('job_seen:$trimmedJobId'))
.build();
final seenEntities = seenQuery.find();
seenQuery.close();
for (final entity in seenEntities) {
@@ -633,15 +658,19 @@ class DatabaseService {
}
}
final taskIds = job.tasks
.map((task) => task.id.trim())
.where((id) => id.isNotEmpty)
.toList();
final taskIds =
job.tasks
.map((task) => task.id.trim())
.where((id) => id.isNotEmpty)
.toList();
if (taskIds.isNotEmpty) {
for (final taskId in taskIds) {
// Delete task status
final taskQuery = taskStatusBox.query(TaskStatusEntity_.taskId.equals(taskId)).build();
final taskQuery =
taskStatusBox
.query(TaskStatusEntity_.taskId.equals(taskId))
.build();
final taskEntities = taskQuery.find();
taskQuery.close();
for (final entity in taskEntities) {
@@ -649,7 +678,8 @@ class DatabaseService {
}
// Delete photos
final photoQuery = photoBox.query(PhotoEntity_.taskId.equals(taskId)).build();
final photoQuery =
photoBox.query(PhotoEntity_.taskId.equals(taskId)).build();
final photoEntities = photoQuery.find();
photoQuery.close();
for (final entity in photoEntities) {
@@ -960,9 +990,21 @@ class DatabaseService {
/// Save login credentials for auto-login on app restart
Future<void> saveCredentials(String email, String password) async {
await saveKeyValue('auth_email', email);
final normalizedEmail = email.trim();
if (normalizedEmail.isEmpty || password.isEmpty) {
developer.log(
'Skipping credential save because email or password is empty',
name: 'DatabaseService',
);
return;
}
await saveKeyValue('auth_email', normalizedEmail);
await saveKeyValue('auth_password', password);
developer.log('Credentials saved for $email', name: 'DatabaseService');
developer.log(
'Credentials saved for $normalizedEmail',
name: 'DatabaseService',
);
}
/// Load saved login credentials
@@ -970,11 +1012,29 @@ class DatabaseService {
Future<({String email, String password})?> loadCredentials() async {
final email = await loadKeyValue('auth_email');
final password = await loadKeyValue('auth_password');
if (email != null && password != null) {
developer.log('Credentials loaded for $email', name: 'DatabaseService');
return (email: email, password: password);
final normalizedEmail = email?.trim();
if (normalizedEmail != null &&
normalizedEmail.isNotEmpty &&
password != null &&
password.isNotEmpty) {
developer.log(
'Credentials loaded for $normalizedEmail',
name: 'DatabaseService',
);
return (email: normalizedEmail, password: password);
}
developer.log('No credentials found', name: 'DatabaseService');
if ((email != null && email.isNotEmpty) ||
(password != null && password.isNotEmpty)) {
developer.log(
'Stored credentials are incomplete or empty - removing them',
name: 'DatabaseService',
);
await deleteCredentials();
}
developer.log('No valid credentials found', name: 'DatabaseService');
return null;
}
@@ -1008,7 +1068,10 @@ class DatabaseService {
final chatBox = _store!.box<ChatMessageEntity>();
// Find existing entity by messageId
final query = chatBox.query(ChatMessageEntity_.messageId.equals(message.id)).build();
final query =
chatBox
.query(ChatMessageEntity_.messageId.equals(message.id))
.build();
final existing = query.findFirst();
query.close();
@@ -1060,7 +1123,10 @@ class DatabaseService {
return;
}
final chatBox = _store!.box<ChatMessageEntity>();
final query = chatBox.query(ChatMessageEntity_.conversationKey.equals(fromKey)).build();
final query =
chatBox
.query(ChatMessageEntity_.conversationKey.equals(fromKey))
.build();
final entities = query.find();
query.close();
@@ -1089,13 +1155,18 @@ class DatabaseService {
}
final chatBox = _store!.box<ChatMessageEntity>();
final query = chatBox.query(
ChatMessageEntity_.conversationKey.equals(conversationKey) &
ChatMessageEntity_.pendingSync.equals(true) &
ChatMessageEntity_.content.equals(message.content) &
ChatMessageEntity_.contentType.equals(chatContentTypeToString(message.contentType)) &
ChatMessageEntity_.messageId.notEquals(message.id)
).build();
final query =
chatBox
.query(
ChatMessageEntity_.conversationKey.equals(conversationKey) &
ChatMessageEntity_.pendingSync.equals(true) &
ChatMessageEntity_.content.equals(message.content) &
ChatMessageEntity_.contentType.equals(
chatContentTypeToString(message.contentType),
) &
ChatMessageEntity_.messageId.notEquals(message.id),
)
.build();
final entities = query.find();
query.close();
@@ -1123,9 +1194,13 @@ class DatabaseService {
List<ChatMessageEntity> entities;
if (conversationKey != null) {
final query = chatBox.query(ChatMessageEntity_.conversationKey.equals(conversationKey))
.order(ChatMessageEntity_.createdAt)
.build();
final query =
chatBox
.query(
ChatMessageEntity_.conversationKey.equals(conversationKey),
)
.order(ChatMessageEntity_.createdAt)
.build();
entities = query.find();
query.close();
} else {
@@ -1186,7 +1261,10 @@ class DatabaseService {
}
final chatBox = _store!.box<ChatMessageEntity>();
final query = chatBox.query(ChatMessageEntity_.conversationKey.equals(conversationKey)).build();
final query =
chatBox
.query(ChatMessageEntity_.conversationKey.equals(conversationKey))
.build();
final entities = query.find();
query.close();
@@ -1211,7 +1289,8 @@ class DatabaseService {
}
final chatBox = _store!.box<ChatMessageEntity>();
final query = chatBox.query(ChatMessageEntity_.messageId.equals(messageId)).build();
final query =
chatBox.query(ChatMessageEntity_.messageId.equals(messageId)).build();
final entities = query.find();
query.close();
@@ -1241,15 +1320,18 @@ class DatabaseService {
final trimmedJobId = jobId?.trim() ?? '';
final trimmedJobNumber = jobNumber?.trim() ?? '';
final keysList = conversationKeys == null
? <String>[]
: conversationKeys
.map((key) => key.trim())
.where((key) => key.isNotEmpty)
.toSet()
.toList();
final keysList =
conversationKeys == null
? <String>[]
: conversationKeys
.map((key) => key.trim())
.where((key) => key.isNotEmpty)
.toSet()
.toList();
if (trimmedJobId.isEmpty && trimmedJobNumber.isEmpty && keysList.isEmpty) {
if (trimmedJobId.isEmpty &&
trimmedJobNumber.isEmpty &&
keysList.isEmpty) {
developer.log(
'No chat messages matched deletion criteria for jobId=$jobId jobNumber=$jobNumber',
name: 'DatabaseService',
@@ -1261,20 +1343,29 @@ class DatabaseService {
final entitiesToDelete = <ChatMessageEntity>[];
if (trimmedJobId.isNotEmpty) {
final query = chatBox.query(ChatMessageEntity_.jobId.equals(trimmedJobId)).build();
final query =
chatBox
.query(ChatMessageEntity_.jobId.equals(trimmedJobId))
.build();
entitiesToDelete.addAll(query.find());
query.close();
}
if (trimmedJobNumber.isNotEmpty) {
final query = chatBox.query(ChatMessageEntity_.jobNumber.equals(trimmedJobNumber)).build();
final query =
chatBox
.query(ChatMessageEntity_.jobNumber.equals(trimmedJobNumber))
.build();
entitiesToDelete.addAll(query.find());
query.close();
}
if (keysList.isNotEmpty) {
for (final key in keysList) {
final query = chatBox.query(ChatMessageEntity_.conversationKey.equals(key)).build();
final query =
chatBox
.query(ChatMessageEntity_.conversationKey.equals(key))
.build();
entitiesToDelete.addAll(query.find());
query.close();
}
@@ -1309,7 +1400,8 @@ class DatabaseService {
}
final chatBox = _store!.box<ChatMessageEntity>();
final query = chatBox.query(ChatMessageEntity_.read.equals(false)).build();
final query =
chatBox.query(ChatMessageEntity_.read.equals(false)).build();
final count = query.count();
query.close();
@@ -1349,6 +1441,7 @@ class DatabaseService {
/// Save a failed message to the queue
Future<void> queueMessage(QueuedMessage message) async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
@@ -1357,7 +1450,8 @@ class DatabaseService {
final box = _store!.box<QueuedMessageEntity>();
// Find existing entity by messageId
final query = box.query(QueuedMessageEntity_.messageId.equals(message.id)).build();
final query =
box.query(QueuedMessageEntity_.messageId.equals(message.id)).build();
final existing = query.findFirst();
query.close();
@@ -1391,6 +1485,7 @@ class DatabaseService {
/// Get all queued messages
Future<List<QueuedMessage>> getQueuedMessages() async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return [];
@@ -1424,13 +1519,15 @@ class DatabaseService {
/// Remove a successfully sent message from the queue
Future<void> removeQueuedMessage(String messageId) async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
final box = _store!.box<QueuedMessageEntity>();
final query = box.query(QueuedMessageEntity_.messageId.equals(messageId)).build();
final query =
box.query(QueuedMessageEntity_.messageId.equals(messageId)).build();
final entities = query.find();
query.close();
@@ -1452,18 +1549,17 @@ class DatabaseService {
}
/// Update retry count for a message
Future<void> updateMessageRetryCount(
String messageId,
int retryCount,
) async {
Future<void> updateMessageRetryCount(String messageId, int retryCount) async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
final box = _store!.box<QueuedMessageEntity>();
final query = box.query(QueuedMessageEntity_.messageId.equals(messageId)).build();
final query =
box.query(QueuedMessageEntity_.messageId.equals(messageId)).build();
final entity = query.findFirst();
query.close();
@@ -1488,16 +1584,14 @@ class DatabaseService {
/// Clear all queued messages (for cleanup)
Future<void> clearQueuedMessages() async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
_store!.box<QueuedMessageEntity>().removeAll();
developer.log(
'Cleared all queued messages',
name: 'DatabaseService',
);
developer.log('Cleared all queued messages', name: 'DatabaseService');
} catch (e, st) {
developer.log(
'Error clearing queued messages: $e',
@@ -1507,12 +1601,49 @@ class DatabaseService {
}
}
Future<String?> updateChatMessagePendingSync(
String messageId,
bool pendingSync,
) async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return null;
}
final chatBox = _store!.box<ChatMessageEntity>();
final query =
chatBox.query(ChatMessageEntity_.messageId.equals(messageId)).build();
final entity = query.findFirst();
query.close();
if (entity == null) {
return null;
}
entity.pendingSync = pendingSync;
chatBox.put(entity);
return entity.conversationKey;
} catch (e, st) {
developer.log(
'Error updating pendingSync for message $messageId: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
return null;
}
}
// Language preference persistence ----------------------------------------------------
/// Save language preference
Future<void> saveLanguagePreference(String languageCode) async {
await saveKeyValue('language_preference', languageCode);
developer.log('Language preference saved: $languageCode', name: 'DatabaseService');
developer.log(
'Language preference saved: $languageCode',
name: 'DatabaseService',
);
}
/// Load saved language preference
@@ -1520,7 +1651,10 @@ class DatabaseService {
Future<String?> loadLanguagePreference() async {
final languageCode = await loadKeyValue('language_preference');
if (languageCode != null) {
developer.log('Language preference loaded: $languageCode', name: 'DatabaseService');
developer.log(
'Language preference loaded: $languageCode',
name: 'DatabaseService',
);
return languageCode;
}
developer.log('No language preference found', name: 'DatabaseService');

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