Preisanzeige in der App entfernen und Auftragsbezeichnungen vereinheitlichen. Stationsabschlüsse und Benachrichtigungen absichern sowie Historie um Aufgabenbeschreibungen und ausführende Nutzer erweitern. Regressionstests ergänzen und App sowie Backend auf Version 0.9.24 anheben.
516 lines
16 KiB
Dart
516 lines
16 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:votianlt_app/services/developer.dart' as developer;
|
|
|
|
import '../app_state.dart';
|
|
import '../models/chat.dart';
|
|
import '../models/chat_message.dart';
|
|
import 'database_service.dart';
|
|
|
|
class ChatService {
|
|
ChatService._internal();
|
|
static final ChatService _instance = ChatService._internal();
|
|
factory ChatService() => _instance;
|
|
|
|
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();
|
|
|
|
final List<Chat> _chats = <Chat>[];
|
|
final StreamController<List<Chat>> _chatsController =
|
|
StreamController<List<Chat>>.broadcast();
|
|
final StreamController<int> _unreadCountController =
|
|
StreamController<int>.broadcast();
|
|
|
|
bool _initialized = false;
|
|
Completer<void>? _initCompleter;
|
|
int _unreadCount = 0;
|
|
|
|
Stream<List<Chat>> get chatsStream => _chatsController.stream;
|
|
List<Chat> get currentChats => List<Chat>.unmodifiable(_chats);
|
|
Stream<int> get unreadCountStream => _unreadCountController.stream;
|
|
int get unreadCount => _unreadCount;
|
|
|
|
Future<void> initialize() async {
|
|
if (_initialized) {
|
|
await _loadChatsFromDatabase();
|
|
developer.log(
|
|
'ChatService already initialized, refreshed chats/unread count: $_unreadCount',
|
|
name: 'ChatService',
|
|
);
|
|
return;
|
|
}
|
|
if (_initCompleter != null) {
|
|
return _initCompleter!.future;
|
|
}
|
|
|
|
_initCompleter = Completer<void>();
|
|
developer.log('Initializing ChatService...', name: 'ChatService');
|
|
|
|
await _loadChatsFromDatabase();
|
|
|
|
_initialized = true;
|
|
_initCompleter!.complete();
|
|
developer.log(
|
|
'ChatService initialized with unread count: $_unreadCount',
|
|
name: 'ChatService',
|
|
);
|
|
}
|
|
|
|
Future<void> dispose() async {
|
|
await _chatsController.close();
|
|
await _unreadCountController.close();
|
|
_initialized = false;
|
|
_initCompleter = null;
|
|
}
|
|
|
|
Future<List<ChatMessage>> loadMessagesForChat(String conversationKey) async {
|
|
await initialize();
|
|
return _databaseService.loadChatMessages(conversationKey: conversationKey);
|
|
}
|
|
|
|
Future<void> markConversationRead(String conversationKey) async {
|
|
await initialize();
|
|
await _databaseService.markConversationRead(conversationKey);
|
|
await _refreshConversation(conversationKey);
|
|
// Note: _refreshConversation already calls _updateUnreadCount,
|
|
// so we don't need to call it again here
|
|
}
|
|
|
|
Future<void> deleteJobChats(String jobId, {String? jobNumber}) async {
|
|
if (!_initialized) {
|
|
await initialize();
|
|
}
|
|
|
|
final trimmedJobId = jobId.trim();
|
|
final lowerJobId = trimmedJobId.toLowerCase();
|
|
final trimmedJobNumber = jobNumber?.trim() ?? '';
|
|
final lowerJobNumber = trimmedJobNumber.toLowerCase();
|
|
|
|
final conversationKeys = <String>[
|
|
if (trimmedJobId.isNotEmpty) '$_jobIdPrefix$lowerJobId',
|
|
if (trimmedJobNumber.isNotEmpty) '$_jobNumberPrefix$lowerJobNumber',
|
|
];
|
|
|
|
await _databaseService.deleteChatMessagesForJob(
|
|
jobId: trimmedJobId.isNotEmpty ? trimmedJobId : null,
|
|
jobNumber: trimmedJobNumber.isNotEmpty ? trimmedJobNumber : null,
|
|
conversationKeys: conversationKeys,
|
|
);
|
|
|
|
_chats.removeWhere((chat) {
|
|
final matchesKey = conversationKeys.contains(chat.id);
|
|
final matchesId =
|
|
trimmedJobId.isNotEmpty &&
|
|
(chat.jobId?.trim().toLowerCase() == lowerJobId);
|
|
final matchesNumber =
|
|
trimmedJobNumber.isNotEmpty &&
|
|
(chat.jobNumber?.trim().toLowerCase() == lowerJobNumber);
|
|
return matchesKey || matchesId || matchesNumber;
|
|
});
|
|
|
|
_ensureDefaultGeneralChat();
|
|
_sortChats();
|
|
_emitChats();
|
|
await _updateUnreadCount();
|
|
|
|
developer.log(
|
|
'Removed chat conversations for jobId=$jobId jobNumber=$jobNumber',
|
|
name: 'ChatService',
|
|
);
|
|
}
|
|
|
|
String conversationKeyForMessage(ChatMessage message) {
|
|
developer.log(
|
|
'[DEBUG_LOG] conversationKeyForMessage called for message ${message.id}, messageType=${message.messageType}, direction=${message.direction}',
|
|
name: 'ChatService',
|
|
);
|
|
|
|
// Messages with GENERAL messageType should always go to the default general chat
|
|
if (message.messageType == ChatMessageType.general) {
|
|
developer.log(
|
|
'[DEBUG_LOG] GENERAL message detected, routing to conversation key: $_defaultGeneralConversationKey',
|
|
name: 'ChatService',
|
|
);
|
|
return _defaultGeneralConversationKey;
|
|
}
|
|
|
|
// Job-related messages go to job-specific chats
|
|
final jobId = message.jobId?.trim();
|
|
if (jobId != null && jobId.isNotEmpty) {
|
|
final normalizedJobId = jobId.toLowerCase();
|
|
final key = '$_jobIdPrefix$normalizedJobId';
|
|
developer.log(
|
|
'[DEBUG_LOG] Job-related message (by jobId), routing to conversation key: $key',
|
|
name: 'ChatService',
|
|
);
|
|
return key;
|
|
}
|
|
final jobNumber = message.jobNumber?.trim();
|
|
if (jobNumber != null && jobNumber.isNotEmpty) {
|
|
final normalizedJobNumber = jobNumber.toLowerCase();
|
|
final key = '$_jobNumberPrefix$normalizedJobNumber';
|
|
developer.log(
|
|
'[DEBUG_LOG] Job-related message (by jobNumber), routing to conversation key: $key',
|
|
name: 'ChatService',
|
|
);
|
|
return key;
|
|
}
|
|
|
|
developer.log(
|
|
'[DEBUG_LOG] No job context available, routing to default general chat',
|
|
name: 'ChatService',
|
|
);
|
|
return _defaultGeneralConversationKey;
|
|
}
|
|
|
|
Future<void> saveIncomingMessage(ChatMessage message) async {
|
|
if (!_initialized) {
|
|
await initialize();
|
|
}
|
|
await _persistMessage(message.copyWith(pendingSync: false));
|
|
}
|
|
|
|
Future<void> saveOutgoingMessage(ChatMessage message) async {
|
|
if (!_initialized) {
|
|
await initialize();
|
|
}
|
|
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);
|
|
|
|
final jobId = message.jobId?.trim();
|
|
if (jobId != null && jobId.isNotEmpty) {
|
|
final legacyKey = '$_jobIdPrefix$jobId';
|
|
if (legacyKey != conversationKey) {
|
|
await _databaseService.migrateConversationKey(
|
|
legacyKey,
|
|
conversationKey,
|
|
);
|
|
}
|
|
} else {
|
|
final jobNumber = message.jobNumber?.trim();
|
|
if (jobNumber != null && jobNumber.isNotEmpty) {
|
|
final legacyKey = '$_jobNumberPrefix$jobNumber';
|
|
if (legacyKey != conversationKey) {
|
|
await _databaseService.migrateConversationKey(
|
|
legacyKey,
|
|
conversationKey,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
await _databaseService.upsertChatMessage(message, conversationKey);
|
|
if (!message.pendingSync) {
|
|
await _databaseService.removePendingDuplicates(conversationKey, message);
|
|
}
|
|
await _refreshConversation(conversationKey);
|
|
}
|
|
|
|
Future<void> _loadChatsFromDatabase() async {
|
|
await _databaseService.ensureInitialized();
|
|
final grouped = await _loadNormalizedChatGroups();
|
|
_chats.clear();
|
|
grouped.forEach((conversationKey, messages) {
|
|
final chat = _buildChat(conversationKey, messages);
|
|
if (chat != null) {
|
|
_chats.add(chat);
|
|
}
|
|
});
|
|
_ensureDefaultGeneralChat();
|
|
_sortChats();
|
|
_emitChats();
|
|
await _updateUnreadCount();
|
|
}
|
|
|
|
Future<void> _refreshConversation(String conversationKey) async {
|
|
if (_isLegacyGeneralConversationKey(conversationKey)) {
|
|
await _databaseService.migrateConversationKey(
|
|
conversationKey,
|
|
_defaultGeneralConversationKey,
|
|
);
|
|
conversationKey = _defaultGeneralConversationKey;
|
|
}
|
|
|
|
final messages = await _databaseService.loadChatMessages(
|
|
conversationKey: conversationKey,
|
|
);
|
|
final index = _chats.indexWhere((chat) => chat.id == conversationKey);
|
|
|
|
if (messages.isEmpty) {
|
|
if (index != -1) {
|
|
_chats.removeAt(index);
|
|
}
|
|
_ensureDefaultGeneralChat();
|
|
_sortChats();
|
|
_emitChats();
|
|
await _updateUnreadCount();
|
|
return;
|
|
}
|
|
|
|
final chat = _buildChat(conversationKey, messages);
|
|
if (chat == null) {
|
|
return;
|
|
}
|
|
|
|
if (index == -1) {
|
|
_chats.add(chat);
|
|
} else {
|
|
_chats[index] = chat;
|
|
}
|
|
_ensureDefaultGeneralChat();
|
|
_sortChats();
|
|
_emitChats();
|
|
await _updateUnreadCount();
|
|
}
|
|
|
|
Chat? _buildChat(String conversationKey, List<ChatMessage> messages) {
|
|
if (messages.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
messages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
|
final lastMessage = messages.last;
|
|
|
|
final jobId = messages
|
|
.map((m) => m.jobId)
|
|
.firstWhere(
|
|
(value) => value != null && value.isNotEmpty,
|
|
orElse: () => null,
|
|
);
|
|
final jobNumber = messages
|
|
.map((m) => m.jobNumber)
|
|
.firstWhere(
|
|
(value) => value != null && value.isNotEmpty,
|
|
orElse: () => null,
|
|
);
|
|
final counterpart = _determineCounterpart(conversationKey, messages);
|
|
|
|
final isJobChat =
|
|
conversationKey.startsWith(_jobIdPrefix) ||
|
|
conversationKey.startsWith(_jobNumberPrefix) ||
|
|
messages.any((m) => m.messageType == ChatMessageType.jobRelated);
|
|
|
|
final chatType = isJobChat ? ChatType.jobSpecific : ChatType.general;
|
|
|
|
final counterpartNormalized =
|
|
counterpart != null &&
|
|
counterpart.toLowerCase() ==
|
|
_appState.loggedInEmail!.toLowerCase()
|
|
? _appState.loggedInEmail!
|
|
: counterpart;
|
|
|
|
final bool isDefaultGeneral =
|
|
!isJobChat && conversationKey == _defaultGeneralConversationKey;
|
|
|
|
final title =
|
|
isJobChat
|
|
? _buildJobTitle(jobNumber, jobId)
|
|
: (isDefaultGeneral
|
|
? 'Allgemeine Nachrichten'
|
|
: (counterpart ?? 'Allgemeiner Chat'));
|
|
|
|
return Chat(
|
|
id: conversationKey,
|
|
title: title,
|
|
receiver: counterpartNormalized,
|
|
type: chatType,
|
|
jobId: jobId,
|
|
jobNumber: jobNumber,
|
|
messages: List<ChatMessage>.unmodifiable(messages),
|
|
lastMessageTime: lastMessage.createdAt,
|
|
lastMessagePreview:
|
|
lastMessage.contentType == ChatContentType.image
|
|
? '[Bild]'
|
|
: lastMessage.content,
|
|
);
|
|
}
|
|
|
|
String _buildJobTitle(String? jobNumber, String? jobId) {
|
|
if (jobNumber != null && jobNumber.isNotEmpty) {
|
|
return 'Auftrag $jobNumber';
|
|
}
|
|
if (jobId != null && jobId.length >= 6) {
|
|
return 'Auftrag ${jobId.substring(0, 6).toUpperCase()}';
|
|
}
|
|
return 'Auftrags-Chat';
|
|
}
|
|
|
|
String? _determineCounterpart(
|
|
String conversationKey,
|
|
List<ChatMessage> messages,
|
|
) {
|
|
// Receiver is always the userId for general chats
|
|
return _appState.loggedInEmail;
|
|
}
|
|
|
|
void _sortChats() {
|
|
_chats.sort((a, b) => b.lastMessageTime.compareTo(a.lastMessageTime));
|
|
}
|
|
|
|
void _emitChats() {
|
|
if (_chatsController.isClosed) {
|
|
return;
|
|
}
|
|
_chatsController.add(List<Chat>.unmodifiable(_chats));
|
|
}
|
|
|
|
Future<void> _updateUnreadCount() async {
|
|
try {
|
|
await _databaseService.ensureInitialized();
|
|
final count = await _databaseService.getTotalUnreadMessageCount();
|
|
developer.log(
|
|
'[DEBUG_LOG] Unread count from database: $count',
|
|
name: 'ChatService',
|
|
);
|
|
_unreadCount = count;
|
|
if (!_unreadCountController.isClosed) {
|
|
_unreadCountController.add(count);
|
|
developer.log(
|
|
'[DEBUG_LOG] Emitted unread count to stream: $count',
|
|
name: 'ChatService',
|
|
);
|
|
} else {
|
|
developer.log(
|
|
'[DEBUG_LOG] Unread count controller is closed, cannot emit',
|
|
name: 'ChatService',
|
|
);
|
|
}
|
|
} catch (e, st) {
|
|
developer.log('Error updating unread count: $e', name: 'ChatService');
|
|
developer.log('Stack trace: $st', name: '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 receiver = _appState.loggedInEmail;
|
|
if (receiver == null || receiver.isEmpty) {
|
|
developer.log(
|
|
'[DEBUG_LOG] _ensureDefaultGeneralChat: No receiver available, skipping',
|
|
name: 'ChatService',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const conversationKey = _defaultGeneralConversationKey;
|
|
|
|
developer.log(
|
|
'[DEBUG_LOG] _ensureDefaultGeneralChat: Creating/ensuring default general chat with key: $conversationKey (receiver=$receiver)',
|
|
name: 'ChatService',
|
|
);
|
|
|
|
_chats.removeWhere(
|
|
(chat) =>
|
|
chat.id != conversationKey &&
|
|
chat.type == ChatType.general &&
|
|
chat.receiver != null &&
|
|
chat.receiver!.toLowerCase() == receiver.toLowerCase() &&
|
|
chat.messages.isEmpty,
|
|
);
|
|
final index = _chats.indexWhere((chat) => chat.id == conversationKey);
|
|
|
|
if (index == -1) {
|
|
developer.log(
|
|
'[DEBUG_LOG] _ensureDefaultGeneralChat: Chat not found, creating new "Allgemeine Nachrichten" chat',
|
|
name: 'ChatService',
|
|
);
|
|
_chats.add(
|
|
Chat(
|
|
id: conversationKey,
|
|
title: 'Allgemeine Nachrichten',
|
|
receiver: receiver,
|
|
type: ChatType.general,
|
|
jobId: null,
|
|
jobNumber: null,
|
|
messages: const [],
|
|
lastMessageTime: DateTime.fromMillisecondsSinceEpoch(0),
|
|
lastMessagePreview: 'Noch keine Nachrichten',
|
|
),
|
|
);
|
|
} else {
|
|
developer.log(
|
|
'[DEBUG_LOG] _ensureDefaultGeneralChat: Chat already exists at index $index, verifying/updating it',
|
|
name: 'ChatService',
|
|
);
|
|
final existing = _chats[index];
|
|
if (existing.type != ChatType.general ||
|
|
existing.receiver == null ||
|
|
existing.receiver!.toLowerCase() != receiver.toLowerCase() ||
|
|
(existing.messages.isEmpty &&
|
|
existing.title != 'Allgemeine Nachrichten')) {
|
|
developer.log(
|
|
'[DEBUG_LOG] _ensureDefaultGeneralChat: Updating existing chat to ensure correct settings',
|
|
name: 'ChatService',
|
|
);
|
|
_chats[index] = Chat(
|
|
id: existing.id,
|
|
title:
|
|
existing.messages.isEmpty
|
|
? 'Allgemeine Nachrichten'
|
|
: existing.title,
|
|
receiver: receiver,
|
|
type: ChatType.general,
|
|
jobId: existing.jobId,
|
|
jobNumber: existing.jobNumber,
|
|
messages: existing.messages,
|
|
lastMessageTime: existing.lastMessageTime,
|
|
lastMessagePreview: existing.lastMessagePreview,
|
|
);
|
|
} else {
|
|
developer.log(
|
|
'[DEBUG_LOG] _ensureDefaultGeneralChat: Existing chat is already correctly configured (${existing.messages.length} messages)',
|
|
name: 'ChatService',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|