refactor: Projektstruktur in app/ und backend/ aufgeteilt

This commit is contained in:
2026-03-24 15:06:44 +01:00
parent 5f5d5995c5
commit 2673ef658d
449 changed files with 28551 additions and 167 deletions

View 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);
}
}
}

View 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;
}
}

View 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
}

File diff suppressed because it is too large Load Diff

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

View 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;
}

View 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();
}

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

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

File diff suppressed because it is too large Load Diff