570 lines
16 KiB
Dart
570 lines
16 KiB
Dart
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<Database> get database async {
|
|
try {
|
|
if (_database != null) {
|
|
return _database!;
|
|
}
|
|
_database = await _initDatabase();
|
|
return _database!;
|
|
} catch (e) {
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<bool> isDatabaseConnected() async {
|
|
try {
|
|
if (_database == null) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<Database> _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<void> _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<Map<String, dynamic>> 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<void> _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<int> insertRecord(TrainRecord record) async {
|
|
final db = await database;
|
|
return await db.insert(
|
|
trainRecordsTable,
|
|
record.toDatabaseJson(),
|
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
);
|
|
}
|
|
|
|
Future<List<TrainRecord>> 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<List<TrainRecord>> 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<List<TrainRecord>> 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<int> 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<int> deleteAllRecords() async {
|
|
final db = await database;
|
|
final result = await db.delete(trainRecordsTable);
|
|
|
|
if (result > 0) {
|
|
_notifyRecordDeleted([]);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
Future<int> getRecordCount() async {
|
|
final db = await database;
|
|
final result = await db.rawQuery('SELECT COUNT(*) FROM $trainRecordsTable');
|
|
return Sqflite.firstIntValue(result) ?? 0;
|
|
}
|
|
|
|
Future<TrainRecord?> 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<Map<String, dynamic>?> 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<int> updateSettings(Map<String, dynamic> 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<int> 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<List<String>> 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<int> updateSearchOrderList(List<String> orderList) async {
|
|
return await setSetting('searchOrderList', orderList.join(','));
|
|
}
|
|
|
|
Future<Map<String, dynamic>> 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<String?> 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<void> deleteRecords(List<String> uniqueIds) async {
|
|
final db = await database;
|
|
for (String id in uniqueIds) {
|
|
await db.delete(
|
|
'train_records',
|
|
where: 'uniqueId = ?',
|
|
whereArgs: [id],
|
|
);
|
|
}
|
|
_notifyRecordDeleted(uniqueIds);
|
|
}
|
|
|
|
final List<Function(List<String>)> _recordDeleteListeners = [];
|
|
|
|
final List<Function(Map<String, dynamic>)> _settingsListeners = [];
|
|
|
|
StreamSubscription<void> onRecordDeleted(Function(List<String>) listener) {
|
|
_recordDeleteListeners.add(listener);
|
|
return Stream.value(null).listen((_) {})
|
|
..onData((_) {})
|
|
..onDone(() {
|
|
_recordDeleteListeners.remove(listener);
|
|
});
|
|
}
|
|
|
|
void _notifyRecordDeleted(List<String> deletedIds) {
|
|
for (final listener in _recordDeleteListeners) {
|
|
listener(deletedIds);
|
|
}
|
|
}
|
|
|
|
StreamSubscription<void> onSettingsChanged(
|
|
Function(Map<String, dynamic>) listener) {
|
|
_settingsListeners.add(listener);
|
|
return _SettingsListenerSubscription(() {
|
|
_settingsListeners.remove(listener);
|
|
});
|
|
}
|
|
|
|
void _notifySettingsChanged(Map<String, dynamic> settings) {
|
|
for (final listener in _settingsListeners) {
|
|
listener(settings);
|
|
}
|
|
}
|
|
|
|
Future<void> close() async {
|
|
if (_database != null) {
|
|
await _database!.close();
|
|
_database = null;
|
|
}
|
|
}
|
|
|
|
Future<String?> 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<bool> 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<Map<String, dynamic>>.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<bool> 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<void> {
|
|
final void Function() _onCancel;
|
|
bool _isCanceled = false;
|
|
|
|
_SettingsListenerSubscription(this._onCancel);
|
|
|
|
@override
|
|
Future<void> 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<void>? resumeSignal]) {}
|
|
|
|
@override
|
|
void resume() {}
|
|
|
|
@override
|
|
bool get isPaused => false;
|
|
|
|
@override
|
|
Future<E> asFuture<E>([E? futureValue]) => Future.value(futureValue);
|
|
} |