refactor: Projektstruktur in app/ und backend/ aufgeteilt
This commit is contained in:
143
app/lib/services/ack_tracker.dart
Normal file
143
app/lib/services/ack_tracker.dart
Normal file
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Represents a message awaiting acknowledgment
|
||||
class PendingMessage {
|
||||
/// Unique message identifier
|
||||
final String messageId;
|
||||
|
||||
/// Target topic
|
||||
final String topic;
|
||||
|
||||
/// The JSON payload to retry sending
|
||||
final String jsonPayload;
|
||||
|
||||
/// When the message was originally sent
|
||||
final DateTime sentAt;
|
||||
|
||||
/// Number of retry attempts so far
|
||||
int retryCount;
|
||||
|
||||
PendingMessage({
|
||||
required this.messageId,
|
||||
required this.topic,
|
||||
required this.jsonPayload,
|
||||
required this.sentAt,
|
||||
this.retryCount = 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// Tracks pending messages awaiting acknowledgment and handles retries.
|
||||
///
|
||||
/// This class is extracted from WebSocketService for testability.
|
||||
/// It manages:
|
||||
/// - Tracking sent messages that require ACK
|
||||
/// - Removing messages when ACK is received
|
||||
/// - Retrying unacknowledged messages
|
||||
/// - Timing out messages after max retries
|
||||
class AckTracker {
|
||||
final Map<String, PendingMessage> _pendingMessages = {};
|
||||
|
||||
/// Maximum number of retry attempts before timeout
|
||||
final int maxRetries;
|
||||
|
||||
/// Callback to retry sending a message.
|
||||
/// Returns true if send was successful.
|
||||
final Future<bool> Function(String topic, String payload)? onRetry;
|
||||
|
||||
/// Callback when a message times out (max retries exceeded)
|
||||
final void Function(String messageId, String topic)? onTimeout;
|
||||
|
||||
AckTracker({
|
||||
this.maxRetries = 4,
|
||||
this.onRetry,
|
||||
this.onTimeout,
|
||||
});
|
||||
|
||||
/// Track a sent message that requires acknowledgment
|
||||
void track(String messageId, String topic, String payload) {
|
||||
_pendingMessages[messageId] = PendingMessage(
|
||||
messageId: messageId,
|
||||
topic: topic,
|
||||
jsonPayload: payload,
|
||||
sentAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove a message from tracking (ACK received)
|
||||
void acknowledge(String messageId) {
|
||||
_pendingMessages.remove(messageId);
|
||||
}
|
||||
|
||||
/// Check if a message is pending acknowledgment
|
||||
bool isPending(String messageId) =>
|
||||
_pendingMessages.containsKey(messageId);
|
||||
|
||||
/// Get the number of pending messages
|
||||
int get pendingCount => _pendingMessages.length;
|
||||
|
||||
/// Get all pending message IDs
|
||||
List<String> get pendingMessageIds =>
|
||||
List.unmodifiable(_pendingMessages.keys);
|
||||
|
||||
/// Get a pending message by ID (for testing)
|
||||
@visibleForTesting
|
||||
PendingMessage? getPendingMessage(String messageId) =>
|
||||
_pendingMessages[messageId];
|
||||
|
||||
/// Process all pending messages and retry if needed.
|
||||
///
|
||||
/// This should be called periodically (e.g., every 5 seconds).
|
||||
/// Messages that exceed maxRetries will be timed out and removed.
|
||||
///
|
||||
/// Set [isConnected] to false to skip retry attempts while disconnected.
|
||||
Future<void> processRetries({bool isConnected = true}) async {
|
||||
if (_pendingMessages.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final messagesToRemove = <String>[];
|
||||
|
||||
for (final entry in _pendingMessages.entries) {
|
||||
final messageId = entry.key;
|
||||
final pending = entry.value;
|
||||
|
||||
if (pending.retryCount >= maxRetries) {
|
||||
// Max retries exceeded - timeout
|
||||
onTimeout?.call(messageId, pending.topic);
|
||||
messagesToRemove.add(messageId);
|
||||
} else if (isConnected) {
|
||||
// Increment retry count and attempt resend
|
||||
pending.retryCount++;
|
||||
|
||||
if (onRetry != null) {
|
||||
await onRetry!(pending.topic, pending.jsonPayload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove timed out messages
|
||||
for (final messageId in messagesToRemove) {
|
||||
_pendingMessages.remove(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all pending messages.
|
||||
///
|
||||
/// Primarily for testing purposes.
|
||||
@visibleForTesting
|
||||
void clearAll() => _pendingMessages.clear();
|
||||
|
||||
/// Clear pending messages for a specific topic pattern.
|
||||
///
|
||||
/// Useful for clearing login messages when auth response is received.
|
||||
void clearForTopic(String topicPattern) {
|
||||
final toRemove = _pendingMessages.entries
|
||||
.where((e) => e.value.topic == topicPattern)
|
||||
.map((e) => e.key)
|
||||
.toList();
|
||||
|
||||
for (final messageId in toRemove) {
|
||||
_pendingMessages.remove(messageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
500
app/lib/services/chat_service.dart
Normal file
500
app/lib/services/chat_service.dart
Normal file
@@ -0,0 +1,500 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
100
app/lib/services/dart_mq.dart
Normal file
100
app/lib/services/dart_mq.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
import 'package:votianlt_app/services/developer.dart' as developer;
|
||||
|
||||
/// A lightweight in-app message bus ("dart_mq") for pub/sub style communication.
|
||||
///
|
||||
// Usage:
|
||||
// final mq = DartMQ();
|
||||
// final sub = mq.subscribe<bool>('connection/status', (isOnline) { /* ... */ });
|
||||
// mq.publish('connection/status', true);
|
||||
// sub.cancel();
|
||||
class DartMQ {
|
||||
DartMQ._internal();
|
||||
static final DartMQ _instance = DartMQ._internal();
|
||||
factory DartMQ() => _instance;
|
||||
|
||||
final Map<String, List<_DartMQSubscriber>> _subscribers = {};
|
||||
|
||||
/// Subscribe to a topic. Returns a cancellable subscription handle.
|
||||
DartMQSubscription subscribe<T>(String topic, void Function(T data) handler) {
|
||||
final sub = _DartMQSubscriber<T>(topic: topic, handler: handler);
|
||||
final list = _subscribers.putIfAbsent(topic, () => <_DartMQSubscriber>[]);
|
||||
list.add(sub);
|
||||
return DartMQSubscription._(this, sub);
|
||||
}
|
||||
|
||||
/// Publish a message to a topic. If no subscribers exist, this is a no-op.
|
||||
void publish<T>(String topic, T data) {
|
||||
final list = _subscribers[topic];
|
||||
if (list == null || list.isEmpty) return;
|
||||
|
||||
// Make a defensive copy to allow cancellation during iteration.
|
||||
final current = List<_DartMQSubscriber>.from(list);
|
||||
for (final s in current) {
|
||||
// Only deliver if types match; otherwise, try dynamic fallback
|
||||
if (s is _DartMQSubscriber<T>) {
|
||||
try {
|
||||
s.handler(data);
|
||||
} catch (e, stackTrace) {
|
||||
developer.log(
|
||||
'Error delivering message to subscriber on topic "$topic": $e',
|
||||
);
|
||||
developer.log('Stack trace: $stackTrace');
|
||||
}
|
||||
} else {
|
||||
// Fallback delivery for handlers expecting dynamic or different T
|
||||
try {
|
||||
final dynamicHandler = s.handler as dynamic;
|
||||
dynamicHandler(data);
|
||||
} catch (e, stackTrace) {
|
||||
developer.log(
|
||||
'Error delivering dynamic message to subscriber on topic "$topic": $e',
|
||||
);
|
||||
developer.log('Stack trace: $stackTrace');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _cancel(_DartMQSubscriber subscriber) {
|
||||
final list = _subscribers[subscriber.topic];
|
||||
if (list == null) return;
|
||||
list.remove(subscriber);
|
||||
if (list.isEmpty) {
|
||||
_subscribers.remove(subscriber.topic);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancellable subscription handle
|
||||
class DartMQSubscription {
|
||||
final DartMQ _mq;
|
||||
final _DartMQSubscriber _subscriber;
|
||||
bool _isCancelled = false;
|
||||
|
||||
DartMQSubscription._(this._mq, this._subscriber);
|
||||
|
||||
void cancel() {
|
||||
if (_isCancelled) return;
|
||||
_isCancelled = true;
|
||||
_mq._cancel(_subscriber);
|
||||
}
|
||||
}
|
||||
|
||||
class _DartMQSubscriber<T> {
|
||||
final String topic;
|
||||
final void Function(T data) handler;
|
||||
_DartMQSubscriber({required this.topic, required this.handler});
|
||||
}
|
||||
|
||||
/// Common topics used in the app
|
||||
class MQTopics {
|
||||
static const connectionStatus = 'connection/status'; // bool
|
||||
static const authResponse = 'auth/response'; // Map<String, dynamic>
|
||||
static const jobsResponse = 'jobs/response'; // List<dynamic>
|
||||
static const taskEvents = 'task/events'; // Map<String, dynamic>
|
||||
static const jobsUpdated = 'app/jobsUpdated'; // void/null
|
||||
static const jobDeleted = 'job/deleted'; // Map<String, dynamic> {jobId, jobNumber, deletedAt}
|
||||
static const jobCreated = 'job/created'; // Map<String, dynamic> - full job data
|
||||
static const chatIncoming = 'chat/incoming'; // ChatMessage
|
||||
static const chatOutgoing = 'chat/outgoing'; // ChatMessage
|
||||
}
|
||||
1529
app/lib/services/database_service.dart
Normal file
1529
app/lib/services/database_service.dart
Normal file
File diff suppressed because it is too large
Load Diff
47
app/lib/services/developer.dart
Normal file
47
app/lib/services/developer.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
// Wrapper around dart:developer.log that also outputs logs in release mode.
|
||||
//
|
||||
// Usage: import this file as `developer` instead of `dart:developer`.
|
||||
// Then call `developer.log(...)` as usual. In debug/profile, it forwards to
|
||||
// dart:developer.log; in release it prints to stdout so logs are visible.
|
||||
export 'dart:developer' hide log;
|
||||
|
||||
import 'dart:async' show Zone;
|
||||
import 'dart:developer' as dev;
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
void log(
|
||||
String message, {
|
||||
DateTime? time,
|
||||
int? sequenceNumber,
|
||||
int level = 0,
|
||||
String name = '',
|
||||
Zone? zone,
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
}) {
|
||||
if (kReleaseMode) {
|
||||
final ts = (time ?? DateTime.now()).toIso8601String();
|
||||
final tag = name.isNotEmpty ? '[$name] ' : '';
|
||||
final seq = sequenceNumber != null ? ' #$sequenceNumber' : '';
|
||||
final lvl = level != 0 ? ' L$level' : '';
|
||||
final err = error != null ? ' | error: $error' : '';
|
||||
final st = stackTrace != null ? ' | stack: $stackTrace' : '';
|
||||
// Keep it a single line to avoid mixing with platform loggers.
|
||||
// Using print to ensure output in release builds.
|
||||
// Example: 2025-09-13T12:47:00.123Z [StompService] Connected ... L800 #42
|
||||
// Note: Some platforms may trim long lines; we still prefer a single print.
|
||||
// ignore: avoid_print
|
||||
print('$ts $tag$message$seq$lvl$err$st');
|
||||
} else {
|
||||
dev.log(
|
||||
message,
|
||||
time: time,
|
||||
sequenceNumber: sequenceNumber,
|
||||
level: level,
|
||||
name: name,
|
||||
zone: zone,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
184
app/lib/services/location_service.dart
Normal file
184
app/lib/services/location_service.dart
Normal file
@@ -0,0 +1,184 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:votianlt_app/services/developer.dart' as developer;
|
||||
import 'websocket_service.dart';
|
||||
|
||||
/// Service for tracking and sending GPS location.
|
||||
/// Sends position every 30 seconds when online.
|
||||
/// Does not buffer location data when offline.
|
||||
class LocationService {
|
||||
static final LocationService _instance = LocationService._internal();
|
||||
|
||||
factory LocationService() => _instance;
|
||||
|
||||
LocationService._internal();
|
||||
|
||||
Timer? _locationTimer;
|
||||
bool _isTracking = false;
|
||||
Position? _lastPosition;
|
||||
|
||||
static const String _topic = '/server/location';
|
||||
static const int _sendIntervalSeconds = 30;
|
||||
|
||||
/// Check if location services are enabled and permission is granted
|
||||
Future<bool> _checkPermissions() async {
|
||||
// Check if location services are enabled
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
developer.log(
|
||||
'Location services are disabled',
|
||||
name: 'LocationService',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check location permission
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
developer.log(
|
||||
'Location permission denied',
|
||||
name: 'LocationService',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
developer.log(
|
||||
'Location permission permanently denied',
|
||||
name: 'LocationService',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Start location tracking and periodic sending
|
||||
Future<void> startTracking() async {
|
||||
if (_isTracking) {
|
||||
developer.log(
|
||||
'Location tracking already active',
|
||||
name: 'LocationService',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final hasPermission = await _checkPermissions();
|
||||
if (!hasPermission) {
|
||||
developer.log(
|
||||
'Cannot start location tracking - permission not granted',
|
||||
name: 'LocationService',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_isTracking = true;
|
||||
developer.log(
|
||||
'Starting location tracking (sending every $_sendIntervalSeconds seconds)',
|
||||
name: 'LocationService',
|
||||
);
|
||||
|
||||
// Get initial position
|
||||
await _updateAndSendPosition();
|
||||
|
||||
// Start periodic timer
|
||||
_locationTimer = Timer.periodic(
|
||||
const Duration(seconds: _sendIntervalSeconds),
|
||||
(_) => _updateAndSendPosition(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Stop location tracking
|
||||
void stopTracking() {
|
||||
if (!_isTracking) return;
|
||||
|
||||
developer.log(
|
||||
'Stopping location tracking',
|
||||
name: 'LocationService',
|
||||
);
|
||||
|
||||
_locationTimer?.cancel();
|
||||
_locationTimer = null;
|
||||
_isTracking = false;
|
||||
}
|
||||
|
||||
/// Get current position and send to server if online
|
||||
Future<void> _updateAndSendPosition() async {
|
||||
try {
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
),
|
||||
);
|
||||
|
||||
_lastPosition = position;
|
||||
|
||||
developer.log(
|
||||
'Position updated: ${position.latitude}, ${position.longitude}',
|
||||
name: 'LocationService',
|
||||
);
|
||||
|
||||
await _sendPosition(position);
|
||||
} catch (e, st) {
|
||||
developer.log(
|
||||
'Error getting position: $e',
|
||||
name: 'LocationService',
|
||||
);
|
||||
developer.log('Stack: $st', name: 'LocationService');
|
||||
}
|
||||
}
|
||||
|
||||
/// Send position to server if online
|
||||
/// Does NOT buffer when offline - location data is time-sensitive
|
||||
Future<void> _sendPosition(Position position) async {
|
||||
final wsService = WebSocketService();
|
||||
|
||||
// Only send if connected and authenticated
|
||||
if (!wsService.isConnected || !wsService.isAuthenticated) {
|
||||
developer.log(
|
||||
'Not sending position - not connected/authenticated',
|
||||
name: 'LocationService',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final payload = {
|
||||
'latitude': position.latitude,
|
||||
'longitude': position.longitude,
|
||||
'accuracy': position.accuracy,
|
||||
'altitude': position.altitude,
|
||||
'speed': position.speed,
|
||||
'heading': position.heading,
|
||||
'timestamp': position.timestamp.toIso8601String(),
|
||||
};
|
||||
|
||||
try {
|
||||
const topic = _topic;
|
||||
final jsonPayload = jsonEncode(payload);
|
||||
|
||||
// Use direct WebSocket send to avoid buffering
|
||||
wsService.sendMessage(topic, jsonPayload);
|
||||
|
||||
developer.log(
|
||||
'Position sent to server: ${position.latitude}, ${position.longitude}',
|
||||
name: 'LocationService',
|
||||
);
|
||||
} catch (e, st) {
|
||||
developer.log(
|
||||
'Error sending position: $e',
|
||||
name: 'LocationService',
|
||||
);
|
||||
developer.log('Stack: $st', name: 'LocationService');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the last known position
|
||||
Position? get lastPosition => _lastPosition;
|
||||
|
||||
/// Check if tracking is active
|
||||
bool get isTracking => _isTracking;
|
||||
}
|
||||
114
app/lib/services/message_handler.dart
Normal file
114
app/lib/services/message_handler.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/message_envelope.dart';
|
||||
|
||||
/// Result of unwrapping a message envelope
|
||||
class UnwrapResult {
|
||||
/// The unwrapped payload
|
||||
final dynamic payload;
|
||||
|
||||
/// The message ID (null if not an envelope)
|
||||
final String? messageId;
|
||||
|
||||
/// Whether this message requires acknowledgment
|
||||
final bool requiresAck;
|
||||
|
||||
UnwrapResult({
|
||||
required this.payload,
|
||||
this.messageId,
|
||||
this.requiresAck = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Handles message envelope unwrapping and deduplication.
|
||||
///
|
||||
/// This class is extracted from WebSocketService for testability.
|
||||
/// It manages:
|
||||
/// - Detecting and unwrapping MessageEnvelope structures
|
||||
/// - Deduplicating messages by messageId
|
||||
/// - Triggering ACK callbacks when required
|
||||
class MessageHandler {
|
||||
final Set<String> _processedMessageIds = {};
|
||||
|
||||
/// Maximum number of message IDs to track for deduplication
|
||||
final int maxProcessedIds;
|
||||
|
||||
/// Callback invoked when an ACK should be sent
|
||||
final void Function(String messageId)? onAckRequired;
|
||||
|
||||
MessageHandler({
|
||||
this.maxProcessedIds = 100,
|
||||
this.onAckRequired,
|
||||
});
|
||||
|
||||
/// Check if data is a valid MessageEnvelope structure.
|
||||
///
|
||||
/// A valid envelope must contain:
|
||||
/// - messageId
|
||||
/// - timestamp
|
||||
/// - topic
|
||||
/// - payload
|
||||
bool isEnvelopeMessage(dynamic data) {
|
||||
if (data is! Map<String, dynamic>) return false;
|
||||
return data.containsKey('messageId') &&
|
||||
data.containsKey('timestamp') &&
|
||||
data.containsKey('topic') &&
|
||||
data.containsKey('payload');
|
||||
}
|
||||
|
||||
/// Unwrap a message envelope and handle deduplication.
|
||||
///
|
||||
/// Returns null if the message was already processed (duplicate).
|
||||
/// For duplicates, still triggers onAckRequired if the original required ACK.
|
||||
///
|
||||
/// Returns [UnwrapResult] with payload and ACK info for new messages.
|
||||
/// If data is not an envelope, returns it as-is with requiresAck=false.
|
||||
UnwrapResult? unwrapEnvelope(dynamic data) {
|
||||
if (!isEnvelopeMessage(data)) {
|
||||
// Not an envelope, return data as-is (no ACK needed)
|
||||
return UnwrapResult(
|
||||
payload: data,
|
||||
messageId: null,
|
||||
requiresAck: false,
|
||||
);
|
||||
}
|
||||
|
||||
final envelope = MessageEnvelope.fromJson(data as Map<String, dynamic>);
|
||||
|
||||
// Check for duplicate
|
||||
if (_processedMessageIds.contains(envelope.messageId)) {
|
||||
// Still send ACK for duplicate messages
|
||||
if (envelope.requiresAck && onAckRequired != null) {
|
||||
onAckRequired!(envelope.messageId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Track this message as processed
|
||||
_processedMessageIds.add(envelope.messageId);
|
||||
|
||||
// Limit set size to prevent memory growth (FIFO eviction)
|
||||
if (_processedMessageIds.length > maxProcessedIds) {
|
||||
_processedMessageIds.remove(_processedMessageIds.first);
|
||||
}
|
||||
|
||||
return UnwrapResult(
|
||||
payload: envelope.payload,
|
||||
messageId: envelope.messageId,
|
||||
requiresAck: envelope.requiresAck,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if a message ID was already processed
|
||||
bool wasProcessed(String messageId) =>
|
||||
_processedMessageIds.contains(messageId);
|
||||
|
||||
/// Get the count of tracked message IDs
|
||||
int get processedCount => _processedMessageIds.length;
|
||||
|
||||
/// Clear all processed message IDs.
|
||||
///
|
||||
/// Primarily for testing purposes.
|
||||
@visibleForTesting
|
||||
void clearProcessedIds() => _processedMessageIds.clear();
|
||||
}
|
||||
123
app/lib/services/notification_service.dart
Normal file
123
app/lib/services/notification_service.dart
Normal file
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:votianlt_app/services/developer.dart' as developer;
|
||||
|
||||
class NotificationService {
|
||||
NotificationService._internal();
|
||||
static final NotificationService _instance = NotificationService._internal();
|
||||
factory NotificationService() => _instance;
|
||||
|
||||
final FlutterLocalNotificationsPlugin _plugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
bool _initialized = false;
|
||||
|
||||
/// The conversation key of the chat currently being viewed by the user.
|
||||
/// When set, incoming chat notifications for this conversation are suppressed.
|
||||
String? activeConversationKey;
|
||||
|
||||
static const String _chatChannelId = 'chat_messages';
|
||||
static const String _chatChannelName = 'Chat-Nachrichten';
|
||||
static const String _chatChannelDescription =
|
||||
'Benachrichtigungen bei neuen Chat-Nachrichten';
|
||||
|
||||
static const String _jobChannelId = 'new_jobs';
|
||||
static const String _jobChannelName = 'Neue Jobs';
|
||||
static const String _jobChannelDescription =
|
||||
'Benachrichtigungen bei neuen Job-Zuweisungen';
|
||||
|
||||
int _nextId = 0;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
|
||||
const androidSettings = AndroidInitializationSettings(
|
||||
'@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const iosSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
);
|
||||
|
||||
const initSettings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iosSettings,
|
||||
);
|
||||
|
||||
await _plugin.initialize(initSettings);
|
||||
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.requestNotificationsPermission();
|
||||
|
||||
_initialized = true;
|
||||
developer.log('NotificationService initialized',
|
||||
name: 'NotificationService');
|
||||
}
|
||||
|
||||
Future<void> showChatNotification({
|
||||
required String title,
|
||||
required String body,
|
||||
required String conversationKey,
|
||||
}) async {
|
||||
if (!_initialized) return;
|
||||
|
||||
if (activeConversationKey == conversationKey) return;
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
_chatChannelId,
|
||||
_chatChannelName,
|
||||
channelDescription: _chatChannelDescription,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
playSound: true,
|
||||
enableVibration: true,
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const details = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _plugin.show(_nextId++, title, body, details,
|
||||
payload: 'chat:$conversationKey');
|
||||
}
|
||||
|
||||
Future<void> showJobNotification({
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
if (!_initialized) return;
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
_jobChannelId,
|
||||
_jobChannelName,
|
||||
channelDescription: _jobChannelDescription,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
playSound: true,
|
||||
enableVibration: true,
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const details = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _plugin.show(_nextId++, title, body, details, payload: 'job');
|
||||
}
|
||||
}
|
||||
535
app/lib/services/translation_service.dart
Normal file
535
app/lib/services/translation_service.dart
Normal file
@@ -0,0 +1,535 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:votianlt_app/config/translation_config.dart';
|
||||
import 'package:votianlt_app/services/dart_mq.dart';
|
||||
import 'package:votianlt_app/services/developer.dart' as developer;
|
||||
import 'package:votianlt_app/app_state.dart';
|
||||
|
||||
/// Service für Übersetzungen – unterstützt LM Studio (lokal) und Moonshot AI (Cloud).
|
||||
///
|
||||
/// Das aktive Backend wird in [TranslationConfig.activeBackend] konfiguriert.
|
||||
/// Verwendet das Singleton-Pattern wie andere Services in der App.
|
||||
/// Übersetzt in die vom Benutzer in der App eingestellte Sprache.
|
||||
class TranslationService {
|
||||
static final TranslationService _instance = TranslationService._internal();
|
||||
factory TranslationService() => _instance;
|
||||
TranslationService._internal();
|
||||
|
||||
static const String _chatCompletionsEndpoint = '/chat/completions';
|
||||
|
||||
// HTTP Client
|
||||
final http.Client _client = http.Client();
|
||||
|
||||
// Verfügbarkeitsstatus
|
||||
bool _isAvailable = false;
|
||||
|
||||
// Aktuell eingestellte Zielsprache (aus der App)
|
||||
String get _targetLanguageCode => AppState().languageCode;
|
||||
|
||||
/// Gibt an ob das Übersetzungsbackend verfügbar ist
|
||||
bool get isAvailable => _isAvailable;
|
||||
|
||||
/// Name des aktiven Backends (für Logs und DartMQ-Nachrichten)
|
||||
String get _backendName => switch (TranslationConfig.activeBackend) {
|
||||
TranslationBackend.lmStudio => 'lm-studio',
|
||||
TranslationBackend.moonshot => 'moonshot-ai',
|
||||
};
|
||||
|
||||
// Verfügbare Sprachen für Übersetzung (alle unterstützten App-Sprachen)
|
||||
static final Map<String, String> supportedLanguages = {
|
||||
'de': 'German',
|
||||
'en': 'English',
|
||||
'es': 'Spanish',
|
||||
'fr': 'French',
|
||||
'pl': 'Polish',
|
||||
'ru': 'Russian',
|
||||
'tr': 'Turkish',
|
||||
'et': 'Estonian',
|
||||
'lv': 'Latvian',
|
||||
'lt': 'Lithuanian',
|
||||
};
|
||||
|
||||
/// Initialisiert den Translation Service
|
||||
Future<void> initialize() async {
|
||||
try {
|
||||
// Auf Sprachänderungen hören
|
||||
_listenToLanguageChanges();
|
||||
|
||||
// Verfügbarkeit prüfen
|
||||
_isAvailable = await _checkAvailability();
|
||||
|
||||
_notifyInitialization();
|
||||
|
||||
developer.log(
|
||||
'TranslationService initialisiert - Backend: $_backendName, Zielsprache: ${supportedLanguages[_targetLanguageCode]}',
|
||||
name: 'TranslationService',
|
||||
);
|
||||
} catch (e) {
|
||||
developer.log('Fehler bei Initialisierung des TranslationService: $e',
|
||||
name: 'TranslationService');
|
||||
_isAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Prüft ob das konfigurierte Backend erreichbar ist
|
||||
Future<bool> _checkAvailability() async {
|
||||
switch (TranslationConfig.activeBackend) {
|
||||
case TranslationBackend.lmStudio:
|
||||
return _checkLmStudioAvailability();
|
||||
case TranslationBackend.moonshot:
|
||||
return _checkMoonshotAvailability();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _checkLmStudioAvailability() async {
|
||||
try {
|
||||
final response = await _client
|
||||
.get(Uri.parse('${TranslationConfig.lmStudioBaseUrl}/v1/models'))
|
||||
.timeout(const Duration(seconds: 5));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
final models = data['data'] as List<dynamic>?;
|
||||
developer.log(
|
||||
'LM Studio verbunden - Verfügbare Modelle: ${models?.length ?? 0}',
|
||||
name: 'TranslationService',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
developer.log('LM Studio nicht erreichbar: $e', name: 'TranslationService');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _checkMoonshotAvailability() async {
|
||||
try {
|
||||
final response = await _client
|
||||
.get(
|
||||
Uri.parse('${TranslationConfig.moonshotBaseUrl}/models'),
|
||||
headers: {'Authorization': 'Bearer ${TranslationConfig.moonshotApiKey}'},
|
||||
)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
developer.log('Moonshot AI verbunden - API erreichbar', name: 'TranslationService');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
developer.log('Moonshot AI nicht erreichbar: $e', name: 'TranslationService');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sendet Initialisierungs-Notification über DartMQ
|
||||
void _notifyInitialization() {
|
||||
DartMQ().publish<Map<String, dynamic>>('translation/service_initialized', {
|
||||
'language': _targetLanguageCode,
|
||||
'backend': _backendName,
|
||||
'isAvailable': isAvailable,
|
||||
'endpoint': _activeEndpoint,
|
||||
});
|
||||
}
|
||||
|
||||
/// Hört auf Sprachänderungen und aktualisiert den Service
|
||||
void _listenToLanguageChanges() {
|
||||
localeNotifier.addListener(() {
|
||||
final newLanguage = AppState().languageCode;
|
||||
developer.log(
|
||||
'Sprache in App geändert zu: ${supportedLanguages[newLanguage] ?? newLanguage}',
|
||||
name: 'TranslationService',
|
||||
);
|
||||
DartMQ().publish<Map<String, dynamic>>('translation/language_changed', {
|
||||
'language': newLanguage,
|
||||
'displayName': supportedLanguages[newLanguage],
|
||||
'backend': _backendName,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Basis-URL des aktiven Backends
|
||||
String get _activeEndpoint => switch (TranslationConfig.activeBackend) {
|
||||
TranslationBackend.lmStudio => TranslationConfig.lmStudioBaseUrl,
|
||||
TranslationBackend.moonshot => TranslationConfig.moonshotBaseUrl,
|
||||
};
|
||||
|
||||
/// Übersetzt einen Text in die vom Benutzer eingestellte Sprache
|
||||
///
|
||||
/// [text] - Der zu übersetzende Text
|
||||
/// [sourceLanguage] - Die Ausgangssprache (optional, wird automatisch erkannt wenn null)
|
||||
///
|
||||
/// Gibt den übersetzten Text zurück oder den Originaltext bei Fehlern
|
||||
Future<String> translate(
|
||||
String text, {
|
||||
String? sourceLanguage,
|
||||
}) async {
|
||||
if (text.isEmpty) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Bei rein numerischem Text oder sehr kurzem Text nicht übersetzen
|
||||
if (_shouldSkipTranslation(text)) {
|
||||
developer.log('Übersetzung übersprungen (kein Text): "$text"',
|
||||
name: 'TranslationService');
|
||||
return text;
|
||||
}
|
||||
|
||||
// Zielsprache aus der App holen
|
||||
final targetCode = _targetLanguageCode;
|
||||
|
||||
// Wenn Quelle gleich Ziel, nicht übersetzen
|
||||
final detectedSource = sourceLanguage ?? await _detectLanguage(text);
|
||||
if (detectedSource == targetCode) {
|
||||
developer.log('Übersetzung übersprungen (Quelle = Ziel): "$text"',
|
||||
name: 'TranslationService');
|
||||
return text;
|
||||
}
|
||||
|
||||
try {
|
||||
final translatedText = await _translate(text, detectedSource, targetCode);
|
||||
|
||||
developer.log(
|
||||
'Übersetzung [${supportedLanguages[detectedSource]} -> ${supportedLanguages[targetCode]}]:\n'
|
||||
' Original: "$text"\n'
|
||||
' Übersetzt: "$translatedText"',
|
||||
name: 'TranslationService',
|
||||
);
|
||||
|
||||
return translatedText;
|
||||
} catch (e) {
|
||||
developer.log(
|
||||
'Fehler bei der Übersetzung: $e\n Original: "$text"',
|
||||
name: 'TranslationService',
|
||||
);
|
||||
return text; // Bei Fehler Original zurückgeben
|
||||
}
|
||||
}
|
||||
|
||||
/// Übersetzt eine Liste von Texten in die vom Benutzer eingestellte Sprache
|
||||
Future<List<String>> translateList(
|
||||
List<String> texts, {
|
||||
String? sourceLanguage,
|
||||
}) async {
|
||||
if (texts.isEmpty) return texts;
|
||||
|
||||
final results = <String>[];
|
||||
final targetCode = _targetLanguageCode;
|
||||
|
||||
developer.log(
|
||||
'Starte Batch-Übersetzung von ${texts.length} Texten nach ${supportedLanguages[targetCode]}',
|
||||
name: 'TranslationService',
|
||||
);
|
||||
|
||||
for (int i = 0; i < texts.length; i++) {
|
||||
final text = texts[i];
|
||||
|
||||
if (text.isEmpty || _shouldSkipTranslation(text)) {
|
||||
results.add(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
final detectedSource = sourceLanguage ?? await _detectLanguage(text);
|
||||
|
||||
if (detectedSource == targetCode) {
|
||||
results.add(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
final translatedText = await _translate(text, detectedSource, targetCode);
|
||||
|
||||
developer.log(
|
||||
'Batch [${i + 1}/${texts.length}] [${supportedLanguages[detectedSource]} -> ${supportedLanguages[targetCode]}]:\n'
|
||||
' Original: "$text"\n'
|
||||
' Übersetzt: "$translatedText"',
|
||||
name: 'TranslationService',
|
||||
);
|
||||
|
||||
results.add(translatedText);
|
||||
} catch (e) {
|
||||
developer.log(
|
||||
'Fehler bei Batch-Übersetzung [${i + 1}/${texts.length}]: $e\n Original: "$text"',
|
||||
name: 'TranslationService',
|
||||
);
|
||||
results.add(text);
|
||||
}
|
||||
}
|
||||
|
||||
developer.log(
|
||||
'Batch-Übersetzung abgeschlossen: ${texts.length} Texte verarbeitet',
|
||||
name: 'TranslationService',
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// Dispatcht die Übersetzung an das konfigurierte Backend
|
||||
Future<String> _translate(String text, String sourceCode, String targetCode) {
|
||||
switch (TranslationConfig.activeBackend) {
|
||||
case TranslationBackend.lmStudio:
|
||||
return _translateWithLmStudio(text, sourceCode, targetCode);
|
||||
case TranslationBackend.moonshot:
|
||||
return _translateWithMoonshot(text, sourceCode, targetCode);
|
||||
}
|
||||
}
|
||||
|
||||
/// Übersetzung mit LM Studio REST API (lokales Modell, kein API-Key)
|
||||
Future<String> _translateWithLmStudio(
|
||||
String text,
|
||||
String sourceCode,
|
||||
String targetCode,
|
||||
) async {
|
||||
final targetName = supportedLanguages[targetCode] ?? targetCode;
|
||||
final sourceName = supportedLanguages[sourceCode] ?? sourceCode;
|
||||
|
||||
final systemPrompt =
|
||||
'You are a professional translator. Translate the user input from $sourceName to $targetName. '
|
||||
'Return ONLY the translation, without any additional text, explanations, or quotes.';
|
||||
|
||||
final requestBody = {
|
||||
'model': TranslationConfig.lmStudioModel,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': systemPrompt},
|
||||
{'role': 'user', 'content': text},
|
||||
],
|
||||
'temperature': 0.1,
|
||||
'max_tokens': 2048,
|
||||
'stream': false,
|
||||
};
|
||||
|
||||
developer.log(
|
||||
'Sende Übersetzungsanfrage an LM Studio: $sourceName -> $targetName (${text.length} Zeichen)',
|
||||
name: 'TranslationService',
|
||||
);
|
||||
|
||||
final response = await _client
|
||||
.post(
|
||||
Uri.parse('${TranslationConfig.lmStudioBaseUrl}$_chatCompletionsEndpoint'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(requestBody),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('LM Studio API Fehler: ${response.statusCode} - ${response.body}');
|
||||
}
|
||||
|
||||
return _extractTranslation(response.body, 'LM Studio');
|
||||
}
|
||||
|
||||
/// Übersetzung mit Moonshot AI Cloud API (Kimi, API-Key erforderlich)
|
||||
Future<String> _translateWithMoonshot(
|
||||
String text,
|
||||
String sourceCode,
|
||||
String targetCode,
|
||||
) async {
|
||||
final targetName = supportedLanguages[targetCode] ?? targetCode;
|
||||
final sourceName = supportedLanguages[sourceCode] ?? sourceCode;
|
||||
|
||||
final systemPrompt =
|
||||
'You are a professional translator. Translate the user input from $sourceName to $targetName. '
|
||||
'Return ONLY the translation, without any additional text, explanations, or quotes.';
|
||||
|
||||
final requestBody = {
|
||||
'model': TranslationConfig.moonshotModel,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': systemPrompt},
|
||||
{'role': 'user', 'content': text},
|
||||
],
|
||||
'temperature': 0.1,
|
||||
'max_tokens': 2048,
|
||||
'stream': false,
|
||||
};
|
||||
|
||||
developer.log(
|
||||
'Sende Übersetzungsanfrage an Moonshot AI: $sourceName -> $targetName (${text.length} Zeichen)',
|
||||
name: 'TranslationService',
|
||||
);
|
||||
|
||||
final response = await _client
|
||||
.post(
|
||||
Uri.parse('${TranslationConfig.moonshotBaseUrl}$_chatCompletionsEndpoint'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ${TranslationConfig.moonshotApiKey}',
|
||||
},
|
||||
body: jsonEncode(requestBody),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Moonshot AI API Fehler: ${response.statusCode} - ${response.body}');
|
||||
}
|
||||
|
||||
return _extractTranslation(response.body, 'Moonshot AI');
|
||||
}
|
||||
|
||||
/// Extrahiert den Übersetzungstext aus der OpenAI-kompatiblen API-Antwort
|
||||
String _extractTranslation(String responseBody, String backendLabel) {
|
||||
final data = jsonDecode(responseBody);
|
||||
final choices = data['choices'] as List<dynamic>?;
|
||||
|
||||
if (choices == null || choices.isEmpty) {
|
||||
throw Exception('Leere Antwort von $backendLabel');
|
||||
}
|
||||
|
||||
final message = choices[0]['message'] as Map<String, dynamic>?;
|
||||
String translated = message?['content']?.toString().trim() ?? '';
|
||||
|
||||
// Anführungszeichen entfernen falls vorhanden
|
||||
if ((translated.startsWith('"') && translated.endsWith('"')) ||
|
||||
(translated.startsWith("'") && translated.endsWith("'"))) {
|
||||
translated = translated.substring(1, translated.length - 1);
|
||||
}
|
||||
|
||||
return translated;
|
||||
}
|
||||
|
||||
/// Hilfsmethode: Erkennt die Sprache eines Textes
|
||||
Future<String> _detectLanguage(String text) async {
|
||||
// Für kurze Texte: Default zu Englisch
|
||||
if (text.length < 10) {
|
||||
return 'en';
|
||||
}
|
||||
|
||||
// Einfache Heuristik basierend auf häufigen Wörtern/Zeichen
|
||||
final lowerText = text.toLowerCase();
|
||||
|
||||
// Deutsche Wörter prüfen
|
||||
final germanWords = ['der', 'die', 'das', 'und', 'ist', 'zu', 'den', 'mit', 'von', 'für'];
|
||||
if (germanWords.any((word) =>
|
||||
lowerText.contains(' $word ') || lowerText.startsWith('$word '))) {
|
||||
return 'de';
|
||||
}
|
||||
|
||||
// Französische Wörter prüfen
|
||||
final frenchWords = ['le', 'la', 'les', 'et', 'est', 'pour', 'dans', 'sur', 'avec', 'une'];
|
||||
if (frenchWords.any((word) =>
|
||||
lowerText.contains(' $word ') || lowerText.startsWith('$word '))) {
|
||||
return 'fr';
|
||||
}
|
||||
|
||||
// Spanische Wörter prüfen
|
||||
final spanishWords = ['el', 'la', 'los', 'las', 'y', 'es', 'para', 'con', 'por', 'del'];
|
||||
if (spanishWords.any((word) =>
|
||||
lowerText.contains(' $word ') || lowerText.startsWith('$word '))) {
|
||||
return 'es';
|
||||
}
|
||||
|
||||
// Polnische Wörter prüfen
|
||||
final polishWords = ['jest', 'i', 'w', 'na', 'do', 'nie', 'się', 'tego', 'tej'];
|
||||
if (polishWords.any((word) =>
|
||||
lowerText.contains(' $word ') || lowerText.startsWith('$word '))) {
|
||||
return 'pl';
|
||||
}
|
||||
|
||||
// Russische/Cyrillische Zeichen prüfen
|
||||
if (RegExp(r'[а-яА-Я]').hasMatch(text)) {
|
||||
return 'ru';
|
||||
}
|
||||
|
||||
// Türkische Zeichen prüfen
|
||||
if (RegExp(r'[çğıöşüÇĞİÖŞÜ]').hasMatch(text)) {
|
||||
return 'tr';
|
||||
}
|
||||
|
||||
// Estnische Zeichen prüfen
|
||||
if (RegExp(r'[äöüõÄÖÜÕ]').hasMatch(text)) {
|
||||
return 'et';
|
||||
}
|
||||
|
||||
// Lettische Zeichen prüfen
|
||||
if (RegExp(r'[āčēģīķļņšūžĀČĒĢĪĶĻŅŠŪŽ]').hasMatch(text)) {
|
||||
return 'lv';
|
||||
}
|
||||
|
||||
// Litauische Zeichen prüfen
|
||||
if (RegExp(r'[ąčęėįšųūžĄČĘĖĮŠŲŪŽ]').hasMatch(text)) {
|
||||
return 'lt';
|
||||
}
|
||||
|
||||
// Arabische Zeichen prüfen
|
||||
if (RegExp(r'[\u0600-\u06FF]').hasMatch(text)) {
|
||||
return 'ar';
|
||||
}
|
||||
|
||||
// Chinesische/Japanische/Koreanische Zeichen prüfen
|
||||
if (RegExp(r'[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff]').hasMatch(text)) {
|
||||
return 'zh';
|
||||
}
|
||||
|
||||
// Default: Englisch
|
||||
return 'en';
|
||||
}
|
||||
|
||||
/// Prüft ob die Übersetzung übersprungen werden sollte
|
||||
bool _shouldSkipTranslation(String text) {
|
||||
// Numerische Werte nicht übersetzen
|
||||
if (RegExp(r'^\d+$').hasMatch(text.trim())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Sehr kurze Codes nicht übersetzen
|
||||
if (text.trim().length <= 2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// E-Mail Adressen nicht übersetzen
|
||||
if (text.contains('@') && text.contains('.')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// URLs nicht übersetzen
|
||||
if (text.startsWith('http://') || text.startsWith('https://')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Prüft ob ein Übersetzungsmodell verfügbar ist
|
||||
Future<bool> isModelAvailable() async {
|
||||
return _checkAvailability();
|
||||
}
|
||||
|
||||
/// Gibt detaillierte Verfügbarkeitsinformationen zurück
|
||||
Future<Map<String, dynamic>> getAvailabilityInfo() async {
|
||||
final isOnline = await _checkAvailability();
|
||||
return {
|
||||
'isAvailable': isOnline,
|
||||
'backend': _backendName,
|
||||
'targetLanguage': _targetLanguageCode,
|
||||
'endpoint': _activeEndpoint,
|
||||
'platform': Platform.operatingSystem,
|
||||
};
|
||||
}
|
||||
|
||||
/// Gibt die aktuell eingestellte Zielsprache zurück
|
||||
String get targetLanguageCode => _targetLanguageCode;
|
||||
|
||||
/// Gibt den Anzeigenamen der aktuellen Sprache zurück
|
||||
String get targetLanguageDisplayName {
|
||||
return supportedLanguages[_targetLanguageCode] ?? _targetLanguageCode;
|
||||
}
|
||||
|
||||
/// Gibt eine Liste aller verfügbaren Sprachen zurück
|
||||
List<MapEntry<String, String>> getAvailableLanguages() {
|
||||
return supportedLanguages.entries.toList();
|
||||
}
|
||||
|
||||
/// Gibt den Anzeigenamen einer Sprache zurück
|
||||
String getLanguageDisplayName(String code) {
|
||||
return supportedLanguages[code] ?? code;
|
||||
}
|
||||
|
||||
/// Schließt den Service und gibt Ressourcen frei
|
||||
Future<void> dispose() async {
|
||||
_client.close();
|
||||
_isAvailable = false;
|
||||
|
||||
developer.log('TranslationService disposed', name: 'TranslationService');
|
||||
}
|
||||
}
|
||||
1064
app/lib/services/websocket_service.dart
Normal file
1064
app/lib/services/websocket_service.dart
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user