501 lines
15 KiB
Dart
501 lines
15 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:';
|
|
|
|
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) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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',
|
|
name: 'ChatService',
|
|
);
|
|
return '$_generalPrefix${_appState.loggedInEmail!}';
|
|
}
|
|
|
|
String _conversationKeyForParticipants(String a, String b) {
|
|
final participants = <String>[a.toLowerCase(), b.toLowerCase()]..sort();
|
|
return '$_generalPrefix${participants.join('|')}';
|
|
}
|
|
|
|
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> _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 _databaseService.loadAllChatMessagesGrouped();
|
|
_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 {
|
|
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.startsWith(_generalPrefix) &&
|
|
(counterpartNormalized?.toLowerCase() ==
|
|
_appState.loggedInEmail!.toLowerCase());
|
|
|
|
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 'Job $jobNumber';
|
|
}
|
|
if (jobId != null && jobId.length >= 6) {
|
|
return 'Job ${jobId.substring(0, 6).toUpperCase()}';
|
|
}
|
|
return 'Job-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');
|
|
}
|
|
}
|
|
|
|
void _ensureDefaultGeneralChat() {
|
|
final localId = _primaryLocalIdentifier();
|
|
if (localId == null || localId.isEmpty) {
|
|
developer.log(
|
|
'[DEBUG_LOG] _ensureDefaultGeneralChat: No local identifier available, skipping',
|
|
name: 'ChatService',
|
|
);
|
|
return;
|
|
}
|
|
|
|
final conversationKey = _conversationKeyForParticipants(
|
|
localId,
|
|
_appState.loggedInEmail!,
|
|
);
|
|
|
|
developer.log(
|
|
'[DEBUG_LOG] _ensureDefaultGeneralChat: Creating/ensuring default general chat with key: $conversationKey (localId=$localId, receiver=${_appState.loggedInEmail})',
|
|
name: 'ChatService',
|
|
);
|
|
|
|
_chats.removeWhere(
|
|
(chat) =>
|
|
chat.id != conversationKey &&
|
|
chat.type == ChatType.general &&
|
|
chat.receiver != null &&
|
|
chat.receiver!.toLowerCase() ==
|
|
_appState.loggedInEmail!.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: _appState.loggedInEmail!,
|
|
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() !=
|
|
_appState.loggedInEmail!.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: _appState.loggedInEmail!,
|
|
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',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
String? _primaryLocalIdentifier() {
|
|
return _appState.loggedInEmail;
|
|
}
|
|
}
|