Files
votianlt/app/lib/services/database_service.dart
T
Sven 5e6044e4b1 fix: Stationsabschluss absichern und Kundenformulare anpassen
Sperrt nachträgliche Aufgabenabschlüsse in App und Backend und zeigt erledigte Aufträge trotz offener optionaler Aufgaben grün an. Ergänzt Regressionstests, die Anredeauswahl und optionale Steuerangaben für Kunden. Enthält App-Version 0.9.25, Backend-Version 0.9.26 und das aktualisierte Frontend-Bundle.
2026-10-06 15:09:51 +02:00

1732 lines
52 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:votianlt_app/services/developer.dart' as developer;
import '../models/chat_message.dart';
import '../models/job.dart';
import '../models/queued_message.dart';
import '../objectbox.g.dart';
import '../entities/job_entity.dart';
import '../entities/task_status_entity.dart';
import '../entities/user_data_entity.dart';
import '../entities/photo_entity.dart';
import '../entities/queued_message_entity.dart';
import '../entities/chat_message_entity.dart';
class DatabaseService {
static final DatabaseService _instance = DatabaseService._internal();
factory DatabaseService() => _instance;
DatabaseService._internal();
Store? _store;
Completer<void>? _initializingCompleter;
bool get isInitialized => _store != null;
/// Initialize ObjectBox database
Future<void> initialize() async {
if (_store != null) {
return;
}
if (_initializingCompleter != null) {
return _initializingCompleter!.future;
}
final completer = Completer<void>();
_initializingCompleter = completer;
try {
developer.log(
'Initializing ObjectBox database...',
name: 'DatabaseService',
);
// Get database path
final docsDir = await getApplicationDocumentsDirectory();
final path = join(docsDir.path, 'objectbox');
// Open ObjectBox store
_store = await openStore(directory: path);
developer.log(
'ObjectBox database initialized successfully',
name: 'DatabaseService',
);
await _logDatabaseStats();
completer.complete();
} catch (e, stackTrace) {
developer.log(
'Error initializing ObjectBox database: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $stackTrace', name: 'DatabaseService');
if (!completer.isCompleted) {
completer.completeError(e);
}
rethrow;
} finally {
_initializingCompleter = null;
}
}
Future<void> ensureInitialized() async {
if (isInitialized) {
return;
}
await initialize();
}
/// Log database statistics
Future<void> _logDatabaseStats() async {
try {
if (_store == null) return;
final jobCount = _store!.box<JobEntity>().count();
final taskStatusCount = _store!.box<TaskStatusEntity>().count();
final userDataCount = _store!.box<UserDataEntity>().count();
developer.log(
'Database stats - Jobs: $jobCount, Task statuses: $taskStatusCount, User data: $userDataCount',
name: 'DatabaseService',
);
} catch (e) {
developer.log(
'Error getting database stats: $e',
name: 'DatabaseService',
);
}
}
/// Save jobs to database
Future<void> saveJobs(List<Job> jobs) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
developer.log(
'Saving ${jobs.length} jobs to database...',
name: 'DatabaseService',
);
final now = DateTime.now();
final jobBox = _store!.box<JobEntity>();
final taskStatusBox = _store!.box<TaskStatusEntity>();
// Clear existing jobs before inserting new ones. Task statuses are NOT
// cleared wholesale: locally completed tasks may not be confirmed by the
// server yet (offline buffering), so only statuses of tasks that no
// longer exist in any job are removed below.
jobBox.removeAll();
final existingStatuses = {
for (final entity in taskStatusBox.getAll()) entity.taskId: entity,
};
final knownTaskIds = <String>{};
// Save new jobs
for (final job in jobs) {
final normalized = job.normalized();
final jobEntity = JobEntity(
jobId: normalized.id,
jobData: jsonEncode(normalized.toJson()),
createdAt: now,
updatedAt: now,
);
jobBox.put(jobEntity);
// Also persist task completion states from JSON (adopt status on load)
// Only set completed=true entries to avoid overwriting local progress with false
for (final task in normalized.tasks) {
knownTaskIds.add(task.id);
if (task.completed && !existingStatuses.containsKey(task.id)) {
final taskStatusEntity = TaskStatusEntity(
taskId: task.id,
completed: true,
completedAt: now,
createdAt: now,
updatedAt: now,
);
taskStatusBox.put(taskStatusEntity);
}
}
}
// Drop statuses of tasks that no longer belong to any assigned job
for (final entry in existingStatuses.entries) {
if (!knownTaskIds.contains(entry.key)) {
taskStatusBox.remove(entry.value.id);
}
}
developer.log(
'Jobs and task statuses saved successfully',
name: 'DatabaseService',
);
} catch (e, stackTrace) {
developer.log('Error saving jobs: $e', name: 'DatabaseService');
developer.log('Stack trace: $stackTrace', name: 'DatabaseService');
}
}
/// Delete a single job by ID
Future<void> deleteJob(String jobId) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
developer.log(
'Deleting job $jobId from database...',
name: 'DatabaseService',
);
final jobBox = _store!.box<JobEntity>();
final query = jobBox.query(JobEntity_.jobId.equals(jobId)).build();
final entities = query.find();
query.close();
if (entities.isNotEmpty) {
jobBox.remove(entities.first.id);
developer.log(
'Job $jobId deleted successfully',
name: 'DatabaseService',
);
} else {
developer.log(
'Job $jobId not found in database',
name: 'DatabaseService',
);
}
} catch (e, stackTrace) {
developer.log('Error deleting job: $e', name: 'DatabaseService');
developer.log('Stack trace: $stackTrace', name: 'DatabaseService');
}
}
/// Load jobs from database
Future<List<Job>> loadJobs() async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return [];
}
developer.log('Loading jobs from database...', name: 'DatabaseService');
final jobBox = _store!.box<JobEntity>();
final jobEntities = jobBox.getAll();
final List<Job> jobs = [];
for (final entity in jobEntities) {
try {
final jobData = jsonDecode(entity.jobData);
final job = Job.fromJson(Map<String, dynamic>.from(jobData));
jobs.add(job);
} catch (e) {
developer.log('Error parsing job data: $e', name: 'DatabaseService');
}
}
// Sort by created_at DESC
jobs.sort((a, b) => b.createdAt.compareTo(a.createdAt));
developer.log(
'Loaded ${jobs.length} jobs from database',
name: 'DatabaseService',
);
// Log message types for job-related messages in database
if (jobs.isNotEmpty) {
try {
final chatBox = _store!.box<ChatMessageEntity>();
final query =
chatBox
.query(
(ChatMessageEntity_.jobId.notNull() |
ChatMessageEntity_.jobNumber.notNull()),
)
.build();
final messagesWithJobs = query.find();
query.close();
developer.log(
'Found ${messagesWithJobs.length} messages related to jobs in database',
name: 'DatabaseService',
);
// Group by message type and log
final Map<String, int> messageTypeCount = {};
for (final msg in messagesWithJobs) {
final messageType = msg.messageType;
final jobId = msg.jobId;
final jobNumber = msg.jobNumber;
messageTypeCount[messageType] =
(messageTypeCount[messageType] ?? 0) + 1;
developer.log(
'Job-related message: messageType=$messageType, jobId=$jobId, jobNumber=$jobNumber',
name: 'DatabaseService',
);
}
// Summary log
if (messageTypeCount.isNotEmpty) {
developer.log(
'Message type summary for jobs in database: $messageTypeCount',
name: 'DatabaseService',
);
}
} catch (e) {
developer.log(
'Error logging job message types: $e',
name: 'DatabaseService',
);
}
}
return jobs;
} catch (e, stackTrace) {
developer.log('Error loading jobs: $e', name: 'DatabaseService');
developer.log('Stack trace: $stackTrace', name: 'DatabaseService');
return [];
}
}
/// Save task completion status
Future<void> saveTaskStatus(String taskId, bool completed) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
final now = DateTime.now();
final taskStatusBox = _store!.box<TaskStatusEntity>();
// Find existing entity by taskId
final query =
taskStatusBox.query(TaskStatusEntity_.taskId.equals(taskId)).build();
final existing = query.findFirst();
query.close();
if (existing != null) {
existing.completed = completed;
existing.completedAt = completed ? now : null;
existing.updatedAt = now;
taskStatusBox.put(existing);
} else {
final entity = TaskStatusEntity(
taskId: taskId,
completed: completed,
completedAt: completed ? now : null,
createdAt: now,
updatedAt: now,
);
taskStatusBox.put(entity);
}
developer.log(
'Task status saved: $taskId = $completed',
name: 'DatabaseService',
);
} catch (e, stackTrace) {
developer.log('Error saving task status: $e', name: 'DatabaseService');
developer.log('Stack trace: $stackTrace', name: 'DatabaseService');
}
}
/// Load task completion status
Future<bool> loadTaskStatus(String taskId) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return false;
}
final taskStatusBox = _store!.box<TaskStatusEntity>();
final query =
taskStatusBox.query(TaskStatusEntity_.taskId.equals(taskId)).build();
final entity = query.findFirst();
query.close();
if (entity != null) {
return entity.completed;
}
return false;
} catch (e, stackTrace) {
developer.log('Error loading task status: $e', name: 'DatabaseService');
developer.log('Stack trace: $stackTrace', name: 'DatabaseService');
return false;
}
}
/// Merge server confirmations and locally persisted offline confirmations.
Future<Set<int>> loadCompletedStationOrders(Job job) async {
final orders =
job.deliveryStations.isEmpty
? {0}
: job.deliveryStations
.map((station) => station.stationOrder)
.toSet();
final completed = <int>{...job.completedStationOrders};
for (final order in orders) {
if (await loadKeyValue('station_completed:${job.id}:$order') == '1') {
completed.add(order);
}
}
return completed;
}
/// Load all task completion statuses
Future<Map<String, bool>> loadAllTaskStatuses() async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return {};
}
final taskStatusBox = _store!.box<TaskStatusEntity>();
final entities = taskStatusBox.getAll();
final Map<String, bool> statuses = {};
for (final entity in entities) {
statuses[entity.taskId] = entity.completed;
}
developer.log(
'Loaded ${statuses.length} task statuses from database',
name: 'DatabaseService',
);
return statuses;
} catch (e, stackTrace) {
developer.log('Error loading task statuses: $e', name: 'DatabaseService');
developer.log('Stack trace: $stackTrace', name: 'DatabaseService');
return {};
}
}
/// Save user ID
Future<void> saveUserId(String userId) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
await saveKeyValue('userId', userId);
developer.log('User ID saved: $userId', name: 'DatabaseService');
} catch (e, stackTrace) {
developer.log('Error saving user ID: $e', name: 'DatabaseService');
developer.log('Stack trace: $stackTrace', name: 'DatabaseService');
}
}
/// Load user ID
Future<String?> loadUserId() async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return null;
}
final userId = await loadKeyValue('userId');
if (userId != null) {
developer.log('User ID loaded: $userId', name: 'DatabaseService');
return userId;
}
developer.log('No user ID found in database', name: 'DatabaseService');
return null;
} catch (e, stackTrace) {
developer.log('Error loading user ID: $e', name: 'DatabaseService');
developer.log('Stack trace: $stackTrace', name: 'DatabaseService');
return null;
}
}
/// Mark a job as seen (persistently)
Future<void> setJobSeen(String jobId) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
await saveKeyValue('job_seen:$jobId', '1');
} catch (e, stackTrace) {
developer.log('Error setting job seen: $e', name: 'DatabaseService');
developer.log('Stack trace: $stackTrace', name: 'DatabaseService');
}
}
/// Check if a job has been seen
Future<bool> isJobSeen(String jobId) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return false;
}
final value = await loadKeyValue('job_seen:$jobId');
return value != null;
} catch (e, stackTrace) {
developer.log('Error checking job seen: $e', name: 'DatabaseService');
developer.log('Stack trace: $stackTrace', name: 'DatabaseService');
return false;
}
}
/// Load seen flags for a set of job IDs (batch)
Future<Map<String, bool>> loadSeenJobsForIds(Iterable<String> jobIds) async {
final Map<String, bool> map = {};
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return map;
}
if (jobIds.isEmpty) return map;
final userDataBox = _store!.box<UserDataEntity>();
final keys = jobIds.map((id) => 'job_seen:$id').toList();
for (final key in keys) {
final query =
userDataBox.query(UserDataEntity_.key.equals(key)).build();
final entity = query.findFirst();
query.close();
final jobId = key.replaceFirst('job_seen:', '');
map[jobId] = entity != null;
}
} catch (e, stackTrace) {
developer.log('Error loading seen jobs: $e', name: 'DatabaseService');
developer.log('Stack trace: $stackTrace', name: 'DatabaseService');
}
return map;
}
/// Clear all data (for logout)
Future<void> clearAllData() async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
developer.log('Clearing all database data...', name: 'DatabaseService');
_store!.box<JobEntity>().removeAll();
_store!.box<TaskStatusEntity>().removeAll();
_store!.box<UserDataEntity>().removeAll();
developer.log('All database data cleared', name: 'DatabaseService');
} catch (e, stackTrace) {
developer.log(
'Error clearing database data: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $stackTrace', name: 'DatabaseService');
}
}
/// Clear all jobs and related data (task statuses, photos).
/// Preserves user credentials, chat messages, and other user data.
/// Called after reconnection before notifying server.
Future<void> clearAllJobsAndRelatedData() async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
developer.log(
'Clearing all jobs and related data...',
name: 'DatabaseService',
);
final jobBox = _store!.box<JobEntity>();
final taskStatusBox = _store!.box<TaskStatusEntity>();
final photoBox = _store!.box<PhotoEntity>();
final jobCount = jobBox.count();
final taskStatusCount = taskStatusBox.count();
final photoCount = photoBox.count();
jobBox.removeAll();
taskStatusBox.removeAll();
photoBox.removeAll();
// Note: Chat messages are intentionally NOT deleted here
// to preserve chat history across reconnections.
developer.log(
'Cleared $jobCount jobs, $taskStatusCount task statuses, $photoCount photos (chat messages preserved)',
name: 'DatabaseService',
);
} catch (e, stackTrace) {
developer.log(
'Error clearing jobs and related data: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $stackTrace', name: 'DatabaseService');
}
}
/// Upsert a single job and update its related task statuses
Future<void> saveOrUpdateJob(Job job) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
final now = DateTime.now();
final normalized = job.normalized();
final jobBox = _store!.box<JobEntity>();
final taskStatusBox = _store!.box<TaskStatusEntity>();
// Find existing job entity by jobId
final jobQuery =
jobBox.query(JobEntity_.jobId.equals(normalized.id)).build();
final existingJob = jobQuery.findFirst();
jobQuery.close();
if (existingJob != null) {
existingJob.jobData = jsonEncode(normalized.toJson());
existingJob.updatedAt = now;
jobBox.put(existingJob);
} else {
final jobEntity = JobEntity(
jobId: normalized.id,
jobData: jsonEncode(normalized.toJson()),
createdAt: now,
updatedAt: now,
);
jobBox.put(jobEntity);
}
// Update task_status for this job only:
// 1) Remove any existing statuses for the tasks of this job (to avoid stale entries)
final taskIds = normalized.tasks.map((t) => t.id).toList();
if (taskIds.isNotEmpty) {
for (final taskId in taskIds) {
final query =
taskStatusBox
.query(TaskStatusEntity_.taskId.equals(taskId))
.build();
final entities = query.find();
query.close();
for (final entity in entities) {
taskStatusBox.remove(entity.id);
}
}
}
// 2) Insert completed=true entries for tasks coming as completed from JSON
for (final t in normalized.tasks) {
if (t.completed) {
final taskStatusEntity = TaskStatusEntity(
taskId: t.id,
completed: true,
completedAt: now,
createdAt: now,
updatedAt: now,
);
taskStatusBox.put(taskStatusEntity);
}
}
developer.log(
'Upserted job ${normalized.id} and updated ${taskIds.length} task statuses',
name: 'DatabaseService',
);
} catch (e, st) {
developer.log('Error in saveOrUpdateJob: $e', name: 'DatabaseService');
developer.log('Stack trace: $st', name: 'DatabaseService');
}
}
Future<void> deleteJobAndRelatedData(Job job) async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
final trimmedJobId = job.id.trim();
final jobBox = _store!.box<JobEntity>();
final userDataBox = _store!.box<UserDataEntity>();
final taskStatusBox = _store!.box<TaskStatusEntity>();
final photoBox = _store!.box<PhotoEntity>();
if (trimmedJobId.isNotEmpty) {
// Delete job
final jobQuery =
jobBox.query(JobEntity_.jobId.equals(trimmedJobId)).build();
final jobEntities = jobQuery.find();
jobQuery.close();
for (final entity in jobEntities) {
jobBox.remove(entity.id);
}
// Delete job_seen flag
final seenQuery =
userDataBox
.query(UserDataEntity_.key.equals('job_seen:$trimmedJobId'))
.build();
final seenEntities = seenQuery.find();
seenQuery.close();
for (final entity in seenEntities) {
userDataBox.remove(entity.id);
}
}
final taskIds =
job.tasks
.map((task) => task.id.trim())
.where((id) => id.isNotEmpty)
.toList();
if (taskIds.isNotEmpty) {
for (final taskId in taskIds) {
// Delete task status
final taskQuery =
taskStatusBox
.query(TaskStatusEntity_.taskId.equals(taskId))
.build();
final taskEntities = taskQuery.find();
taskQuery.close();
for (final entity in taskEntities) {
taskStatusBox.remove(entity.id);
}
// Delete photos
final photoQuery =
photoBox.query(PhotoEntity_.taskId.equals(taskId)).build();
final photoEntities = photoQuery.find();
photoQuery.close();
for (final entity in photoEntities) {
photoBox.remove(entity.id);
}
// Delete user data related to task (photos, signatures, barcodes)
final allUserData = userDataBox.getAll();
for (final entity in allUserData) {
if (entity.key.contains(':$taskId')) {
userDataBox.remove(entity.id);
}
}
}
}
final trimmedJobNumber =
job.jobNumber.trim().isEmpty ? null : job.jobNumber.trim();
final conversationKeys = <String>[
if (trimmedJobId.isNotEmpty) 'job:${trimmedJobId.toLowerCase()}',
if (trimmedJobNumber != null)
'job_number:${trimmedJobNumber.toLowerCase()}',
];
await deleteChatMessagesForJob(
jobId: trimmedJobId.isNotEmpty ? trimmedJobId : null,
jobNumber: trimmedJobNumber,
conversationKeys: conversationKeys,
);
developer.log(
'Deleted job $trimmedJobId and related local data',
name: 'DatabaseService',
);
} catch (e, st) {
developer.log(
'Error deleting job ${job.id}: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
}
}
/// Save Base64-encoded photos for a task into user_data table (legacy list storage)
Future<void> saveTaskPhotos(String taskId, List<String> base64Photos) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
final key = 'task_photos:$taskId';
final value = jsonEncode(base64Photos);
await saveKeyValue(key, value);
developer.log(
'Saved ${base64Photos.length} photos for task $taskId',
name: 'DatabaseService',
);
} catch (e, st) {
developer.log('Error saving task photos: $e', name: 'DatabaseService');
developer.log('Stack trace: $st', name: 'DatabaseService');
}
}
/// Load Base64-encoded photos for a task from user_data table
Future<List<String>> loadTaskPhotos(String taskId) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return [];
}
final key = 'task_photos:$taskId';
final raw = await loadKeyValue(key);
if (raw == null) return [];
final decoded = jsonDecode(raw);
if (decoded is List) {
return decoded.map((e) => e.toString()).toList();
}
return [];
} catch (e, st) {
developer.log('Error loading task photos: $e', name: 'DatabaseService');
developer.log('Stack trace: $st', name: 'DatabaseService');
return [];
}
}
/// Save photos into the dedicated photos collection (one row per photo)
Future<void> savePhotosForTask(
String taskId,
List<String> base64Photos,
) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
final photoBox = _store!.box<PhotoEntity>();
// Clear existing photos for this task first
final query = photoBox.query(PhotoEntity_.taskId.equals(taskId)).build();
final existingPhotos = query.find();
query.close();
for (final photo in existingPhotos) {
photoBox.remove(photo.id);
}
final now = DateTime.now();
for (int i = 0; i < base64Photos.length; i++) {
final data = base64Photos[i];
final photoEntity = PhotoEntity(
taskId: taskId,
photoIndex: i,
data: data,
createdAt: now,
);
photoBox.put(photoEntity);
}
developer.log(
'Saved ${base64Photos.length} photos into collection for task $taskId',
name: 'DatabaseService',
);
} catch (e, st) {
developer.log(
'Error saving photos for task to collection: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
}
}
/// Save signature SVG for a task into user_data table
Future<void> saveTaskSignature(String taskId, String svg) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
final key = 'task_signature_svg:$taskId';
await saveKeyValue(key, svg);
developer.log(
'Saved signature SVG for task $taskId',
name: 'DatabaseService',
);
} catch (e, st) {
developer.log(
'Error saving task signature SVG: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
}
}
/// Save signature note (Bemerkung) for a task into user_data table
Future<void> saveTaskSignatureNote(String taskId, String note) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
final key = 'task_signature_note:$taskId';
await saveKeyValue(key, note);
} catch (e, st) {
developer.log(
'Error saving task signature note: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
}
}
/// Load signature note (Bemerkung) for a task from user_data table
Future<String?> loadTaskSignatureNote(String taskId) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return null;
}
return await loadKeyValue('task_signature_note:$taskId');
} catch (e, st) {
developer.log(
'Error loading task signature note: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
return null;
}
}
/// Load signature SVG for a task from user_data table
Future<String?> loadTaskSignature(String taskId) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return null;
}
// Try new SVG key first; fallback to legacy PNG key if present
final svgKey = 'task_signature_svg:$taskId';
final legacyKey = 'task_signature:$taskId';
String? result = await loadKeyValue(svgKey);
result ??= await loadKeyValue(legacyKey);
return result;
} catch (e, st) {
developer.log(
'Error loading task signature (SVG): $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
return null;
}
}
/// Save barcodes for a task into user_data table
Future<void> saveTaskBarcodes(String taskId, List<String> barcodes) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
final key = 'task_barcodes:$taskId';
final value = jsonEncode(barcodes);
await saveKeyValue(key, value);
developer.log(
'Saved ${barcodes.length} barcodes for task $taskId',
name: 'DatabaseService',
);
} catch (e, st) {
developer.log('Error saving task barcodes: $e', name: 'DatabaseService');
developer.log('Stack trace: $st', name: 'DatabaseService');
}
}
/// Load barcodes for a task from user_data table
Future<List<String>> loadTaskBarcodes(String taskId) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return [];
}
final key = 'task_barcodes:$taskId';
final raw = await loadKeyValue(key);
if (raw == null) return [];
final decoded = jsonDecode(raw);
if (decoded is List) {
return decoded.map((e) => e.toString()).toList();
}
return [];
} catch (e, st) {
developer.log('Error loading task barcodes: $e', name: 'DatabaseService');
developer.log('Stack trace: $st', name: 'DatabaseService');
return [];
}
}
/// Close database connections
Future<void> close() async {
try {
_store?.close();
_store = null;
developer.log('Database connection closed', name: 'DatabaseService');
} catch (e, stackTrace) {
developer.log('Error closing database: $e', name: 'DatabaseService');
developer.log('Stack trace: $stackTrace', name: 'DatabaseService');
}
}
/// Generic helpers: save and load key-value pairs in user_data table
Future<void> saveKeyValue(String key, String value) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
final now = DateTime.now();
final userDataBox = _store!.box<UserDataEntity>();
// Find existing entity by key
final query = userDataBox.query(UserDataEntity_.key.equals(key)).build();
final existing = query.findFirst();
query.close();
if (existing != null) {
existing.value = value;
existing.updatedAt = now;
userDataBox.put(existing);
} else {
final entity = UserDataEntity(
key: key,
value: value,
createdAt: now,
updatedAt: now,
);
userDataBox.put(entity);
}
developer.log('Saved key "$key"', name: 'DatabaseService');
} catch (e, st) {
developer.log('Error saving key "$key": $e', name: 'DatabaseService');
developer.log('Stack trace: $st', name: 'DatabaseService');
}
}
Future<String?> loadKeyValue(String key) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return null;
}
final userDataBox = _store!.box<UserDataEntity>();
final query = userDataBox.query(UserDataEntity_.key.equals(key)).build();
final entity = query.findFirst();
query.close();
return entity?.value;
} catch (e, st) {
developer.log('Error loading key "$key": $e', name: 'DatabaseService');
developer.log('Stack trace: $st', name: 'DatabaseService');
return null;
}
}
Future<void> deleteKeyValue(String key) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
final userDataBox = _store!.box<UserDataEntity>();
final query = userDataBox.query(UserDataEntity_.key.equals(key)).build();
final entity = query.findFirst();
query.close();
if (entity != null) {
userDataBox.remove(entity.id);
developer.log('Deleted key "$key"', name: 'DatabaseService');
}
} catch (e, st) {
developer.log('Error deleting key "$key": $e', name: 'DatabaseService');
developer.log('Stack trace: $st', name: 'DatabaseService');
}
}
// Credentials persistence ----------------------------------------------------
/// Save login credentials for auto-login on app restart
Future<void> saveCredentials(String email, String password) async {
final normalizedEmail = email.trim();
if (normalizedEmail.isEmpty || password.isEmpty) {
developer.log(
'Skipping credential save because email or password is empty',
name: 'DatabaseService',
);
return;
}
await saveKeyValue('auth_email', normalizedEmail);
await saveKeyValue('auth_password', password);
developer.log(
'Credentials saved for $normalizedEmail',
name: 'DatabaseService',
);
}
/// Load saved login credentials
/// Returns null if no credentials are stored
Future<({String email, String password})?> loadCredentials() async {
final email = await loadKeyValue('auth_email');
final password = await loadKeyValue('auth_password');
final normalizedEmail = email?.trim();
if (normalizedEmail != null &&
normalizedEmail.isNotEmpty &&
password != null &&
password.isNotEmpty) {
developer.log(
'Credentials loaded for $normalizedEmail',
name: 'DatabaseService',
);
return (email: normalizedEmail, password: password);
}
if ((email != null && email.isNotEmpty) ||
(password != null && password.isNotEmpty)) {
developer.log(
'Stored credentials are incomplete or empty - removing them',
name: 'DatabaseService',
);
await deleteCredentials();
}
developer.log('No valid credentials found', name: 'DatabaseService');
return null;
}
/// Delete saved login credentials (on logout)
Future<void> deleteCredentials() async {
await deleteKeyValue('auth_email');
await deleteKeyValue('auth_password');
developer.log('Credentials deleted', name: 'DatabaseService');
}
// Chat messages persistence -------------------------------------------------
Future<void> upsertChatMessage(
ChatMessage message,
String conversationKey,
) async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
final directionString = chatDirectionToString(message.direction);
developer.log(
'[DEBUG_LOG] Upserting message: id=${message.id}, direction=${message.direction} (stored as: $directionString), read=${message.read}',
name: 'DatabaseService',
);
final chatBox = _store!.box<ChatMessageEntity>();
// Find existing entity by messageId
final query =
chatBox
.query(ChatMessageEntity_.messageId.equals(message.id))
.build();
final existing = query.findFirst();
query.close();
if (existing != null) {
existing.conversationKey = conversationKey;
existing.content = message.content;
existing.contentType = chatContentTypeToString(message.contentType);
existing.createdAt = message.createdAt;
existing.origin = directionString;
existing.messageType = chatMessageTypeToString(message.messageType);
existing.jobId = message.jobId;
existing.jobNumber = message.jobNumber;
existing.read = message.read;
existing.pendingSync = message.pendingSync;
chatBox.put(existing);
} else {
final entity = ChatMessageEntity(
messageId: message.id,
conversationKey: conversationKey,
content: message.content,
contentType: chatContentTypeToString(message.contentType),
createdAt: message.createdAt,
origin: directionString,
messageType: chatMessageTypeToString(message.messageType),
jobId: message.jobId,
jobNumber: message.jobNumber,
read: message.read,
pendingSync: message.pendingSync,
);
chatBox.put(entity);
}
} catch (e, st) {
developer.log(
'Error saving chat message ${message.id}: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
}
}
Future<void> migrateConversationKey(String fromKey, String toKey) async {
if (fromKey == toKey) {
return;
}
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
final chatBox = _store!.box<ChatMessageEntity>();
final query =
chatBox
.query(ChatMessageEntity_.conversationKey.equals(fromKey))
.build();
final entities = query.find();
query.close();
for (final entity in entities) {
entity.conversationKey = toKey;
chatBox.put(entity);
}
} catch (e, st) {
developer.log(
'Error migrating conversation key from "$fromKey" to "$toKey": $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
}
}
Future<void> removePendingDuplicates(
String conversationKey,
ChatMessage message,
) async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
final chatBox = _store!.box<ChatMessageEntity>();
final query =
chatBox
.query(
ChatMessageEntity_.conversationKey.equals(conversationKey) &
ChatMessageEntity_.pendingSync.equals(true) &
ChatMessageEntity_.content.equals(message.content) &
ChatMessageEntity_.contentType.equals(
chatContentTypeToString(message.contentType),
) &
ChatMessageEntity_.messageId.notEquals(message.id),
)
.build();
final entities = query.find();
query.close();
for (final entity in entities) {
chatBox.remove(entity.id);
}
} catch (e, st) {
developer.log(
'Error removing pending duplicates: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
}
}
Future<List<ChatMessage>> loadChatMessages({String? conversationKey}) async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return [];
}
final chatBox = _store!.box<ChatMessageEntity>();
List<ChatMessageEntity> entities;
if (conversationKey != null) {
final query =
chatBox
.query(
ChatMessageEntity_.conversationKey.equals(conversationKey),
)
.order(ChatMessageEntity_.createdAt)
.build();
entities = query.find();
query.close();
} else {
entities = chatBox.getAll();
entities.sort((a, b) => a.createdAt.compareTo(b.createdAt));
}
return entities.map(_chatMessageFromEntity).toList();
} catch (e, st) {
developer.log('Error loading chat messages: $e', name: 'DatabaseService');
developer.log('Stack trace: $st', name: 'DatabaseService');
return [];
}
}
Future<Map<String, List<ChatMessage>>> loadAllChatMessagesGrouped() async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return {};
}
final chatBox = _store!.box<ChatMessageEntity>();
final entities = chatBox.getAll();
// Sort by conversation_key and created_at
entities.sort((a, b) {
final keyCompare = a.conversationKey.compareTo(b.conversationKey);
if (keyCompare != 0) return keyCompare;
return a.createdAt.compareTo(b.createdAt);
});
final Map<String, List<ChatMessage>> grouped = {};
for (final entity in entities) {
final key = entity.conversationKey;
final message = _chatMessageFromEntity(entity);
final list = grouped.putIfAbsent(key, () => <ChatMessage>[]);
list.add(message);
}
return grouped;
} catch (e, st) {
developer.log(
'Error loading grouped chat messages: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
return {};
}
}
Future<void> markConversationRead(String conversationKey) async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
final chatBox = _store!.box<ChatMessageEntity>();
final query =
chatBox
.query(ChatMessageEntity_.conversationKey.equals(conversationKey))
.build();
final entities = query.find();
query.close();
for (final entity in entities) {
entity.read = true;
chatBox.put(entity);
}
} catch (e, st) {
developer.log(
'Error marking conversation read: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
}
}
Future<void> deleteChatMessage(String messageId) async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
final chatBox = _store!.box<ChatMessageEntity>();
final query =
chatBox.query(ChatMessageEntity_.messageId.equals(messageId)).build();
final entities = query.find();
query.close();
for (final entity in entities) {
chatBox.remove(entity.id);
}
} catch (e, st) {
developer.log(
'Error deleting chat message "$messageId": $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
}
}
Future<void> deleteChatMessagesForJob({
String? jobId,
String? jobNumber,
Iterable<String>? conversationKeys,
}) async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
final trimmedJobId = jobId?.trim() ?? '';
final trimmedJobNumber = jobNumber?.trim() ?? '';
final keysList =
conversationKeys == null
? <String>[]
: conversationKeys
.map((key) => key.trim())
.where((key) => key.isNotEmpty)
.toSet()
.toList();
if (trimmedJobId.isEmpty &&
trimmedJobNumber.isEmpty &&
keysList.isEmpty) {
developer.log(
'No chat messages matched deletion criteria for jobId=$jobId jobNumber=$jobNumber',
name: 'DatabaseService',
);
return;
}
final chatBox = _store!.box<ChatMessageEntity>();
final entitiesToDelete = <ChatMessageEntity>[];
if (trimmedJobId.isNotEmpty) {
final query =
chatBox
.query(ChatMessageEntity_.jobId.equals(trimmedJobId))
.build();
entitiesToDelete.addAll(query.find());
query.close();
}
if (trimmedJobNumber.isNotEmpty) {
final query =
chatBox
.query(ChatMessageEntity_.jobNumber.equals(trimmedJobNumber))
.build();
entitiesToDelete.addAll(query.find());
query.close();
}
if (keysList.isNotEmpty) {
for (final key in keysList) {
final query =
chatBox
.query(ChatMessageEntity_.conversationKey.equals(key))
.build();
entitiesToDelete.addAll(query.find());
query.close();
}
}
// Remove duplicates by id
final uniqueIds = <int>{};
for (final entity in entitiesToDelete) {
if (uniqueIds.add(entity.id)) {
chatBox.remove(entity.id);
}
}
developer.log(
'Deleted chat messages for jobId=$jobId jobNumber=$jobNumber (conversationKeys=${keysList.length})',
name: 'DatabaseService',
);
} catch (e, st) {
developer.log(
'Error deleting chat messages for jobId=$jobId jobNumber=$jobNumber: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
}
}
Future<int> getTotalUnreadMessageCount() async {
try {
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return 0;
}
final chatBox = _store!.box<ChatMessageEntity>();
final query =
chatBox.query(ChatMessageEntity_.read.equals(false)).build();
final count = query.count();
query.close();
developer.log(
'[DEBUG_LOG] Total unread message count: $count',
name: 'DatabaseService',
);
return count;
} catch (e, st) {
developer.log(
'Error getting total unread message count: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
return 0;
}
}
ChatMessage _chatMessageFromEntity(ChatMessageEntity entity) {
return ChatMessage(
id: entity.messageId,
content: entity.content,
contentType: chatContentTypeFromString(entity.contentType),
createdAt: entity.createdAt,
direction: chatDirectionFromString(entity.origin),
messageType: chatMessageTypeFromString(entity.messageType),
jobId: entity.jobId,
jobNumber: entity.jobNumber,
read: entity.read,
pendingSync: entity.pendingSync,
);
}
// Message Queue Management
/// Save a failed message to the queue
Future<void> queueMessage(QueuedMessage message) async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
final box = _store!.box<QueuedMessageEntity>();
// Find existing entity by messageId
final query =
box.query(QueuedMessageEntity_.messageId.equals(message.id)).build();
final existing = query.findFirst();
query.close();
if (existing != null) {
existing.topic = message.topic;
existing.payload = jsonEncode(message.payload);
existing.createdAt = message.createdAt;
existing.retryCount = message.retryCount;
box.put(existing);
} else {
final entity = QueuedMessageEntity(
messageId: message.id,
topic: message.topic,
payload: jsonEncode(message.payload),
createdAt: message.createdAt,
retryCount: message.retryCount,
);
box.put(entity);
}
developer.log(
'Queued message: ${message.id} for topic: ${message.topic}',
name: 'DatabaseService',
);
} catch (e, st) {
developer.log('Error queuing message: $e', name: 'DatabaseService');
developer.log('Stack trace: $st', name: 'DatabaseService');
}
}
/// Get all queued messages
Future<List<QueuedMessage>> getQueuedMessages() async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return [];
}
final box = _store!.box<QueuedMessageEntity>();
final entities = box.getAll();
// Sort by created_at ASC
entities.sort((a, b) => a.createdAt.compareTo(b.createdAt));
return entities.map((entity) {
return QueuedMessage(
id: entity.messageId,
topic: entity.topic,
payload: jsonDecode(entity.payload),
createdAt: entity.createdAt,
retryCount: entity.retryCount,
);
}).toList();
} catch (e, st) {
developer.log(
'Error getting queued messages: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
return [];
}
}
/// Remove a successfully sent message from the queue
Future<void> removeQueuedMessage(String messageId) async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
final box = _store!.box<QueuedMessageEntity>();
final query =
box.query(QueuedMessageEntity_.messageId.equals(messageId)).build();
final entities = query.find();
query.close();
for (final entity in entities) {
box.remove(entity.id);
}
developer.log(
'Removed queued message: $messageId',
name: 'DatabaseService',
);
} catch (e, st) {
developer.log(
'Error removing queued message: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
}
}
/// Update retry count for a message
Future<void> updateMessageRetryCount(String messageId, int retryCount) async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
final box = _store!.box<QueuedMessageEntity>();
final query =
box.query(QueuedMessageEntity_.messageId.equals(messageId)).build();
final entity = query.findFirst();
query.close();
if (entity != null) {
entity.retryCount = retryCount;
box.put(entity);
}
developer.log(
'Updated retry count for message: $messageId to $retryCount',
name: 'DatabaseService',
);
} catch (e, st) {
developer.log(
'Error updating message retry count: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
}
}
/// Clear all queued messages (for cleanup)
Future<void> clearQueuedMessages() async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return;
}
_store!.box<QueuedMessageEntity>().removeAll();
developer.log('Cleared all queued messages', name: 'DatabaseService');
} catch (e, st) {
developer.log(
'Error clearing queued messages: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
}
}
Future<String?> updateChatMessagePendingSync(
String messageId,
bool pendingSync,
) async {
try {
await ensureInitialized();
if (_store == null) {
developer.log('Database not initialized', name: 'DatabaseService');
return null;
}
final chatBox = _store!.box<ChatMessageEntity>();
final query =
chatBox.query(ChatMessageEntity_.messageId.equals(messageId)).build();
final entity = query.findFirst();
query.close();
if (entity == null) {
return null;
}
entity.pendingSync = pendingSync;
chatBox.put(entity);
return entity.conversationKey;
} catch (e, st) {
developer.log(
'Error updating pendingSync for message $messageId: $e',
name: 'DatabaseService',
);
developer.log('Stack trace: $st', name: 'DatabaseService');
return null;
}
}
// Language preference persistence ----------------------------------------------------
/// Save language preference
Future<void> saveLanguagePreference(String languageCode) async {
await saveKeyValue('language_preference', languageCode);
developer.log(
'Language preference saved: $languageCode',
name: 'DatabaseService',
);
}
/// Load saved language preference
/// Returns null if no preference is stored
Future<String?> loadLanguagePreference() async {
final languageCode = await loadKeyValue('language_preference');
if (languageCode != null) {
developer.log(
'Language preference loaded: $languageCode',
name: 'DatabaseService',
);
return languageCode;
}
developer.log('No language preference found', name: 'DatabaseService');
return null;
}
}