import 'dart:async'; import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'dart:io'; import 'dart:convert'; import 'dart:developer' as developer; import 'package:lbjconsole/models/train_record.dart'; enum InputSource { bluetooth, rtlTcp, audioInput } class DatabaseService { static final DatabaseService instance = DatabaseService._internal(); factory DatabaseService() => instance; DatabaseService._internal(); static const String _databaseName = 'train_database'; static const _databaseVersion = 9; static const String trainRecordsTable = 'train_records'; static const String appSettingsTable = 'app_settings'; Database? _database; Future get database async { try { if (_database != null) { return _database!; } _database = await _initDatabase(); return _database!; } catch (e) { rethrow; } } Future isDatabaseConnected() async { try { if (_database == null) { return false; } return true; } catch (e) { return false; } } Future _initDatabase() async { try { final directory = await getApplicationDocumentsDirectory(); final path = join(directory.path, _databaseName); final db = await openDatabase( path, version: _databaseVersion, onCreate: _onCreate, onUpgrade: _onUpgrade, ); return db; } catch (e) { rethrow; } } Future _onUpgrade(Database db, int oldVersion, int newVersion) async { developer.log('Database upgrading from $oldVersion to $newVersion', name: 'Database'); if (oldVersion < 2) { await db.execute( 'ALTER TABLE $appSettingsTable ADD COLUMN hideTimeOnlyRecords INTEGER NOT NULL DEFAULT 0'); } if (oldVersion < 3) { await db.execute( 'ALTER TABLE $appSettingsTable ADD COLUMN mapTimeFilter TEXT NOT NULL DEFAULT "unlimited"'); } if (oldVersion < 4) { try { await db.execute( 'ALTER TABLE $appSettingsTable ADD COLUMN mapTimeFilter TEXT NOT NULL DEFAULT "unlimited"'); } catch (e) {} } if (oldVersion < 5) { await db.execute( 'ALTER TABLE $appSettingsTable ADD COLUMN mapType TEXT NOT NULL DEFAULT "webview"'); } if (oldVersion < 6) { await db.execute( 'ALTER TABLE $appSettingsTable ADD COLUMN hideUngroupableRecords INTEGER NOT NULL DEFAULT 0'); } if (oldVersion < 7) { await db.execute( 'ALTER TABLE $appSettingsTable ADD COLUMN mapSettingsTimestamp INTEGER'); } if (oldVersion < 8) { await db.execute( 'ALTER TABLE $appSettingsTable ADD COLUMN rtlTcpEnabled INTEGER NOT NULL DEFAULT 0'); await db.execute( 'ALTER TABLE $appSettingsTable ADD COLUMN rtlTcpHost TEXT NOT NULL DEFAULT "127.0.0.1"'); await db.execute( 'ALTER TABLE $appSettingsTable ADD COLUMN rtlTcpPort TEXT NOT NULL DEFAULT "14423"'); } if (oldVersion < 9) { await db.execute( 'ALTER TABLE $appSettingsTable ADD COLUMN inputSource TEXT NOT NULL DEFAULT "bluetooth"'); try { final List> results = await db.query(appSettingsTable, columns: ['rtlTcpEnabled'], where: 'id = 1'); if (results.isNotEmpty) { final int rtlTcpEnabled = results.first['rtlTcpEnabled'] as int? ?? 0; if (rtlTcpEnabled == 1) { await db.update( appSettingsTable, {'inputSource': 'rtlTcp'}, where: 'id = 1' ); developer.log('Migrated V8 settings: inputSource set to rtlTcp', name: 'Database'); } } } catch (e) { developer.log('Migration V8->V9 data update failed: $e', name: 'Database'); } } } Future _onCreate(Database db, int version) async { await db.execute(''' CREATE TABLE IF NOT EXISTS $trainRecordsTable ( uniqueId TEXT PRIMARY KEY, timestamp INTEGER NOT NULL, receivedTimestamp INTEGER NOT NULL, train TEXT NOT NULL, direction INTEGER NOT NULL, speed TEXT NOT NULL, position TEXT NOT NULL, time TEXT NOT NULL, loco TEXT NOT NULL, locoType TEXT NOT NULL, lbjClass TEXT NOT NULL, route TEXT NOT NULL, positionInfo TEXT NOT NULL, rssi REAL NOT NULL ) '''); await db.execute(''' CREATE TABLE IF NOT EXISTS $appSettingsTable ( id INTEGER PRIMARY KEY, deviceName TEXT NOT NULL DEFAULT 'LBJReceiver', currentTab INTEGER NOT NULL DEFAULT 0, historyEditMode INTEGER NOT NULL DEFAULT 0, historySelectedRecords TEXT NOT NULL DEFAULT '', historyExpandedStates TEXT NOT NULL DEFAULT '', historyScrollPosition INTEGER NOT NULL DEFAULT 0, historyScrollOffset INTEGER NOT NULL DEFAULT 0, settingsScrollPosition INTEGER NOT NULL DEFAULT 0, mapCenterLat REAL, mapCenterLon REAL, mapZoomLevel REAL NOT NULL DEFAULT 10.0, mapRailwayLayerVisible INTEGER NOT NULL DEFAULT 1, mapRotation REAL NOT NULL DEFAULT 0.0, mapType TEXT NOT NULL DEFAULT 'webview', specifiedDeviceAddress TEXT, searchOrderList TEXT NOT NULL DEFAULT '', autoConnectEnabled INTEGER NOT NULL DEFAULT 1, backgroundServiceEnabled INTEGER NOT NULL DEFAULT 0, notificationEnabled INTEGER NOT NULL DEFAULT 0, mergeRecordsEnabled INTEGER NOT NULL DEFAULT 0, hideTimeOnlyRecords INTEGER NOT NULL DEFAULT 0, groupBy TEXT NOT NULL DEFAULT 'trainAndLoco', timeWindow TEXT NOT NULL DEFAULT 'unlimited', mapTimeFilter TEXT NOT NULL DEFAULT 'unlimited', hideUngroupableRecords INTEGER NOT NULL DEFAULT 0, mapSettingsTimestamp INTEGER, rtlTcpEnabled INTEGER NOT NULL DEFAULT 0, rtlTcpHost TEXT NOT NULL DEFAULT '127.0.0.1', rtlTcpPort TEXT NOT NULL DEFAULT '14423', inputSource TEXT NOT NULL DEFAULT 'bluetooth' ) '''); await db.insert(appSettingsTable, { 'id': 1, 'deviceName': 'LBJReceiver', 'currentTab': 0, 'historyEditMode': 0, 'historySelectedRecords': '', 'historyExpandedStates': '', 'historyScrollPosition': 0, 'historyScrollOffset': 0, 'settingsScrollPosition': 0, 'mapZoomLevel': 10.0, 'mapRailwayLayerVisible': 1, 'mapRotation': 0.0, 'mapType': 'webview', 'searchOrderList': '', 'autoConnectEnabled': 1, 'backgroundServiceEnabled': 0, 'notificationEnabled': 0, 'mergeRecordsEnabled': 0, 'hideTimeOnlyRecords': 0, 'groupBy': 'trainAndLoco', 'timeWindow': 'unlimited', 'mapTimeFilter': 'unlimited', 'hideUngroupableRecords': 0, 'mapSettingsTimestamp': null, 'rtlTcpEnabled': 0, 'rtlTcpHost': '127.0.0.1', 'rtlTcpPort': '14423', 'inputSource': 'bluetooth', }); } Future insertRecord(TrainRecord record) async { final db = await database; return await db.insert( trainRecordsTable, record.toDatabaseJson(), conflictAlgorithm: ConflictAlgorithm.replace, ); } Future> getAllRecords() async { try { final db = await database; final result = await db.query( trainRecordsTable, orderBy: 'timestamp DESC', ); final records = result.map((json) => TrainRecord.fromDatabaseJson(json)).toList(); return records; } catch (e) { rethrow; } } Future> getRecordsWithinTimeRange(Duration duration) async { final db = await database; final cutoffTime = DateTime.now().subtract(duration).millisecondsSinceEpoch; final result = await db.query( trainRecordsTable, where: 'timestamp >= ?', whereArgs: [cutoffTime], orderBy: 'timestamp DESC', ); return result.map((json) => TrainRecord.fromDatabaseJson(json)).toList(); } Future> getRecordsWithinReceivedTimeRange( Duration duration) async { try { final db = await database; final cutoffTime = DateTime.now().subtract(duration).millisecondsSinceEpoch; final result = await db.query( trainRecordsTable, where: 'receivedTimestamp >= ?', whereArgs: [cutoffTime], orderBy: 'receivedTimestamp DESC', ); final records = result.map((json) => TrainRecord.fromDatabaseJson(json)).toList(); return records; } catch (e) { rethrow; } } Future deleteRecord(String uniqueId) async { final db = await database; final result = await db.delete( trainRecordsTable, where: 'uniqueId = ?', whereArgs: [uniqueId], ); if (result > 0) { _notifyRecordDeleted([uniqueId]); } return result; } Future deleteAllRecords() async { final db = await database; final result = await db.delete(trainRecordsTable); if (result > 0) { _notifyRecordDeleted([]); } return result; } Future getRecordCount() async { final db = await database; final result = await db.rawQuery('SELECT COUNT(*) FROM $trainRecordsTable'); return Sqflite.firstIntValue(result) ?? 0; } Future getLatestRecord() async { final db = await database; final result = await db.query( trainRecordsTable, orderBy: 'timestamp DESC', limit: 1, ); if (result.isNotEmpty) { return TrainRecord.fromDatabaseJson(result.first); } return null; } Future?> getAllSettings() async { final db = await database; try { final result = await db.query( appSettingsTable, where: 'id = 1', ); if (result.isEmpty) return null; return result.first; } catch (e) { return null; } } Future updateSettings(Map settings) async { final db = await database; final result = await db.update( appSettingsTable, settings, where: 'id = 1', ); if (result > 0) { final currentSettings = await getAllSettings(); if (currentSettings != null) { _notifySettingsChanged(currentSettings); } } return result; } Future setSetting(String key, dynamic value) async { final db = await database; final result = await db.update( appSettingsTable, {key: value}, where: 'id = 1', ); if (result > 0) { final currentSettings = await getAllSettings(); if (currentSettings != null) { _notifySettingsChanged(currentSettings); } } return result; } Future> getSearchOrderList() async { final settings = await getAllSettings(); if (settings != null && settings['searchOrderList'] != null) { final listString = settings['searchOrderList'] as String; if (listString.isNotEmpty) { return listString.split(','); } } return []; } Future updateSearchOrderList(List orderList) async { return await setSetting('searchOrderList', orderList.join(',')); } Future> getDatabaseInfo() async { final db = await database; final count = await getRecordCount(); final settings = await getAllSettings(); return { 'databaseVersion': _databaseVersion, 'trainRecordCount': count, 'appSettings': settings, 'path': db.path, }; } Future backupDatabase() async { try { final db = await database; final directory = await getApplicationDocumentsDirectory(); final originalPath = db.path; final backupDirectory = Directory(join(directory.path, 'backups')); if (!await backupDirectory.exists()) { await backupDirectory.create(recursive: true); } final backupPath = join(backupDirectory.path, 'train_database_backup_${DateTime.now().millisecondsSinceEpoch}.db'); await File(originalPath).copy(backupPath); return backupPath; } catch (e) { return null; } } Future deleteRecords(List uniqueIds) async { final db = await database; for (String id in uniqueIds) { await db.delete( 'train_records', where: 'uniqueId = ?', whereArgs: [id], ); } _notifyRecordDeleted(uniqueIds); } final List)> _recordDeleteListeners = []; final List)> _settingsListeners = []; StreamSubscription onRecordDeleted(Function(List) listener) { _recordDeleteListeners.add(listener); return Stream.value(null).listen((_) {}) ..onData((_) {}) ..onDone(() { _recordDeleteListeners.remove(listener); }); } void _notifyRecordDeleted(List deletedIds) { for (final listener in _recordDeleteListeners) { listener(deletedIds); } } StreamSubscription onSettingsChanged( Function(Map) listener) { _settingsListeners.add(listener); return _SettingsListenerSubscription(() { _settingsListeners.remove(listener); }); } void _notifySettingsChanged(Map settings) { for (final listener in _settingsListeners) { listener(settings); } } Future close() async { if (_database != null) { await _database!.close(); _database = null; } } Future exportDataAsJson({String? customPath}) async { try { final records = await getAllRecords(); final exportData = { 'records': records.map((r) => r.toDatabaseJson()).toList(), }; final jsonString = jsonEncode(exportData); String filePath; if (customPath != null) { filePath = customPath; } else { final tempDir = Directory.systemTemp; final fileName = 'LBJ_Console_${DateTime.now().year}${DateTime.now().month.toString().padLeft(2, '0')}${DateTime.now().day.toString().padLeft(2, '0')}.json'; filePath = join(tempDir.path, fileName); } await File(filePath).writeAsString(jsonString); return filePath; } catch (e) { return null; } } Future importDataFromJson(String filePath) async { try { final jsonString = await File(filePath).readAsString(); final importData = jsonDecode(jsonString); final db = await database; await db.transaction((txn) async { await txn.delete(trainRecordsTable); if (importData['records'] != null) { final records = List>.from(importData['records']); for (final record in records) { await txn.insert(trainRecordsTable, record); } } }); final currentSettings = await getAllSettings(); if (currentSettings != null) { _notifySettingsChanged(currentSettings); } return true; } catch (e) { return false; } } Future deleteExportFile(String filePath) async { try { final file = File(filePath); if (await file.exists()) { await file.delete(); return true; } return false; } catch (e) { return false; } } } class _SettingsListenerSubscription implements StreamSubscription { final void Function() _onCancel; bool _isCanceled = false; _SettingsListenerSubscription(this._onCancel); @override Future cancel() async { if (!_isCanceled) { _isCanceled = true; _onCancel(); } } @override void onData(void Function(void data)? handleData) {} @override void onDone(void Function()? handleDone) {} @override void onError(Function? handleError) {} @override void pause([Future? resumeSignal]) {} @override void resume() {} @override bool get isPaused => false; @override Future asFuture([E? futureValue]) => Future.value(futureValue); }