Files
votianlt/app/lib/services/chat_service.dart
Sven Carstensen bba5733783 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
2026-04-04 10:30:36 +02:00

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