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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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