Files
votianlt/app/lib/services/chat_service.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;
}
}