init
This commit is contained in:
361
lib/services/ble_service.dart
Normal file
361
lib/services/ble_service.dart
Normal file
@@ -0,0 +1,361 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:lbjconsole/models/train_record.dart';
|
||||
import 'package:lbjconsole/services/database_service.dart';
|
||||
|
||||
class BLEService {
|
||||
static final BLEService _instance = BLEService._internal();
|
||||
factory BLEService() => _instance;
|
||||
BLEService._internal();
|
||||
|
||||
static const String TAG = "LBJ_BT_FLUTTER";
|
||||
static final Guid serviceUuid = Guid("0000ffe0-0000-1000-8000-00805f9b34fb");
|
||||
static final Guid charUuid = Guid("0000ffe1-0000-1000-8000-00805f9b34fb");
|
||||
|
||||
BluetoothDevice? _connectedDevice;
|
||||
BluetoothCharacteristic? _characteristic;
|
||||
StreamSubscription<List<int>>? _valueSubscription;
|
||||
StreamSubscription<BluetoothConnectionState>? _connectionStateSubscription;
|
||||
StreamSubscription<List<ScanResult>>? _scanResultsSubscription;
|
||||
|
||||
final StreamController<String> _statusController =
|
||||
StreamController<String>.broadcast();
|
||||
final StreamController<TrainRecord> _dataController =
|
||||
StreamController<TrainRecord>.broadcast();
|
||||
final StreamController<bool> _connectionController =
|
||||
StreamController<bool>.broadcast();
|
||||
|
||||
Stream<String> get statusStream => _statusController.stream;
|
||||
Stream<TrainRecord> get dataStream => _dataController.stream;
|
||||
Stream<bool> get connectionStream => _connectionController.stream;
|
||||
|
||||
String _deviceStatus = "未连接";
|
||||
String? _lastKnownDeviceAddress;
|
||||
String _targetDeviceName = "LBJReceiver";
|
||||
|
||||
bool _isConnecting = false;
|
||||
bool _isManualDisconnect = false;
|
||||
bool _isAutoConnectBlocked = false;
|
||||
|
||||
Timer? _heartbeatTimer;
|
||||
final StringBuffer _dataBuffer = StringBuffer();
|
||||
|
||||
void initialize() {
|
||||
_loadSettings();
|
||||
FlutterBluePlus.adapterState.listen((state) {
|
||||
if (state == BluetoothAdapterState.on) {
|
||||
ensureConnection();
|
||||
} else {
|
||||
_updateConnectionState(false, "蓝牙已关闭");
|
||||
stopScan();
|
||||
}
|
||||
});
|
||||
_startHeartbeat();
|
||||
}
|
||||
|
||||
void _startHeartbeat() {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = Timer.periodic(const Duration(seconds: 7), (timer) {
|
||||
ensureConnection();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
try {
|
||||
final settings = await DatabaseService.instance.getAllSettings();
|
||||
if (settings != null) {
|
||||
_targetDeviceName = settings['deviceName'] ?? 'LBJReceiver';
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
void ensureConnection() {
|
||||
if (isConnected || _isConnecting) {
|
||||
return;
|
||||
}
|
||||
_tryReconnectDirectly();
|
||||
}
|
||||
|
||||
Future<void> _tryReconnectDirectly() async {
|
||||
if (_lastKnownDeviceAddress == null) {
|
||||
startScan();
|
||||
return;
|
||||
}
|
||||
|
||||
_isConnecting = true;
|
||||
_statusController.add("正在重连...");
|
||||
|
||||
try {
|
||||
final connected = await FlutterBluePlus.connectedSystemDevices;
|
||||
final matchingDevices =
|
||||
connected.where((d) => d.remoteId.str == _lastKnownDeviceAddress);
|
||||
BluetoothDevice? target =
|
||||
matchingDevices.isNotEmpty ? matchingDevices.first : null;
|
||||
|
||||
if (target != null) {
|
||||
await connect(target);
|
||||
} else {
|
||||
startScan();
|
||||
_isConnecting = false;
|
||||
}
|
||||
} catch (e) {
|
||||
startScan();
|
||||
_isConnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startScan({
|
||||
String? targetName,
|
||||
Duration? timeout,
|
||||
Function(List<BluetoothDevice>)? onScanResults,
|
||||
}) async {
|
||||
if (FlutterBluePlus.isScanningNow) {
|
||||
return;
|
||||
}
|
||||
|
||||
_targetDeviceName = targetName ?? _targetDeviceName;
|
||||
_statusController.add("正在扫描...");
|
||||
|
||||
_scanResultsSubscription?.cancel();
|
||||
_scanResultsSubscription = FlutterBluePlus.scanResults.listen((results) {
|
||||
final allFoundDevices = results.map((r) => r.device).toList();
|
||||
|
||||
final filteredDevices = allFoundDevices.where((device) {
|
||||
if (_targetDeviceName.isEmpty) return true;
|
||||
return device.platformName.toLowerCase() ==
|
||||
_targetDeviceName.toLowerCase();
|
||||
}).toList();
|
||||
|
||||
onScanResults?.call(filteredDevices);
|
||||
|
||||
if (isConnected ||
|
||||
_isConnecting ||
|
||||
_isManualDisconnect ||
|
||||
_isAutoConnectBlocked) return;
|
||||
|
||||
for (var device in allFoundDevices) {
|
||||
if (_shouldAutoConnectTo(device)) {
|
||||
stopScan();
|
||||
connect(device);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await FlutterBluePlus.startScan(timeout: timeout);
|
||||
} catch (e) {
|
||||
_statusController.add("扫描失败");
|
||||
}
|
||||
}
|
||||
|
||||
bool _shouldAutoConnectTo(BluetoothDevice device) {
|
||||
final deviceName = device.platformName;
|
||||
final deviceAddress = device.remoteId.str;
|
||||
|
||||
if (_targetDeviceName.isNotEmpty &&
|
||||
deviceName.toLowerCase() == _targetDeviceName.toLowerCase())
|
||||
return true;
|
||||
if (_lastKnownDeviceAddress != null &&
|
||||
_lastKnownDeviceAddress == deviceAddress) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> stopScan() async {
|
||||
await FlutterBluePlus.stopScan();
|
||||
_scanResultsSubscription?.cancel();
|
||||
}
|
||||
|
||||
Future<void> connect(BluetoothDevice device) async {
|
||||
if (isConnected) return;
|
||||
|
||||
_isConnecting = true;
|
||||
_isManualDisconnect = false;
|
||||
_statusController.add("正在连接: ${device.platformName}");
|
||||
|
||||
try {
|
||||
_connectionStateSubscription?.cancel();
|
||||
_connectionStateSubscription = device.connectionState.listen((state) {
|
||||
if (state == BluetoothConnectionState.disconnected) {
|
||||
_onDisconnected();
|
||||
}
|
||||
});
|
||||
|
||||
await device.connect(timeout: const Duration(seconds: 15));
|
||||
await _onConnected(device);
|
||||
} catch (e) {
|
||||
_onDisconnected();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onConnected(BluetoothDevice device) async {
|
||||
_connectedDevice = device;
|
||||
_lastKnownDeviceAddress = device.remoteId.str;
|
||||
await _discoverServicesAndSetupNotifications(device);
|
||||
}
|
||||
|
||||
void _onDisconnected() {
|
||||
final wasConnected = isConnected;
|
||||
_updateConnectionState(false, "连接已断开");
|
||||
_connectionStateSubscription?.cancel();
|
||||
|
||||
if (wasConnected && !_isManualDisconnect) {
|
||||
ensureConnection();
|
||||
}
|
||||
_isConnecting = false;
|
||||
}
|
||||
|
||||
Future<void> _discoverServicesAndSetupNotifications(
|
||||
BluetoothDevice device) async {
|
||||
try {
|
||||
final services = await device.discoverServices();
|
||||
for (var service in services) {
|
||||
if (service.uuid == serviceUuid) {
|
||||
for (var char in service.characteristics) {
|
||||
if (char.uuid == charUuid) {
|
||||
_characteristic = char;
|
||||
await device.requestMtu(512);
|
||||
await char.setNotifyValue(true);
|
||||
_valueSubscription = char.lastValueStream.listen(_onDataReceived);
|
||||
|
||||
_updateConnectionState(true, "已连接");
|
||||
_isConnecting = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await device.disconnect();
|
||||
} catch (e) {
|
||||
await device.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> connectManually(BluetoothDevice device) async {
|
||||
_isManualDisconnect = false;
|
||||
_isAutoConnectBlocked = false;
|
||||
stopScan();
|
||||
await connect(device);
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_isManualDisconnect = true;
|
||||
stopScan();
|
||||
|
||||
await _connectionStateSubscription?.cancel();
|
||||
await _valueSubscription?.cancel();
|
||||
|
||||
if (_connectedDevice != null) {
|
||||
await _connectedDevice!.disconnect();
|
||||
}
|
||||
_onDisconnected();
|
||||
}
|
||||
|
||||
void _onDataReceived(List<int> value) {
|
||||
if (value.isEmpty) return;
|
||||
try {
|
||||
final data = utf8.decode(value);
|
||||
_dataBuffer.write(data);
|
||||
_processDataBuffer();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
void _processDataBuffer() {
|
||||
String bufferContent = _dataBuffer.toString();
|
||||
if (bufferContent.isEmpty) return;
|
||||
|
||||
int firstBrace = bufferContent.indexOf('{');
|
||||
if (firstBrace == -1) {
|
||||
_dataBuffer.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
bufferContent = bufferContent.substring(firstBrace);
|
||||
int braceCount = 0;
|
||||
int lastValidJsonEnd = -1;
|
||||
|
||||
for (int i = 0; i < bufferContent.length; i++) {
|
||||
if (bufferContent[i] == '{') {
|
||||
braceCount++;
|
||||
} else if (bufferContent[i] == '}') {
|
||||
braceCount--;
|
||||
}
|
||||
if (braceCount == 0 && i > 0) {
|
||||
lastValidJsonEnd = i;
|
||||
String jsonToParse = bufferContent.substring(0, lastValidJsonEnd + 1);
|
||||
_parseAndNotify(jsonToParse);
|
||||
bufferContent = bufferContent.substring(lastValidJsonEnd + 1);
|
||||
i = -1;
|
||||
firstBrace = bufferContent.indexOf('{');
|
||||
if (firstBrace != -1) {
|
||||
bufferContent = bufferContent.substring(firstBrace);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_dataBuffer.clear();
|
||||
if (braceCount > 0) {
|
||||
_dataBuffer.write(bufferContent);
|
||||
}
|
||||
}
|
||||
|
||||
void _parseAndNotify(String jsonData) {
|
||||
try {
|
||||
final decodedJson = jsonDecode(jsonData);
|
||||
if (decodedJson is Map<String, dynamic>) {
|
||||
final now = DateTime.now();
|
||||
final recordData = Map<String, dynamic>.from(decodedJson);
|
||||
recordData['uniqueId'] =
|
||||
'${now.millisecondsSinceEpoch}_${Random().nextInt(9999)}';
|
||||
recordData['receivedTimestamp'] = now.millisecondsSinceEpoch;
|
||||
|
||||
final trainRecord = TrainRecord.fromJson(recordData);
|
||||
_dataController.add(trainRecord);
|
||||
DatabaseService.instance.insertRecord(trainRecord);
|
||||
}
|
||||
} catch (e) {
|
||||
print("$TAG: JSON Decode Error: $e, Data: $jsonData");
|
||||
}
|
||||
}
|
||||
|
||||
void _updateConnectionState(bool connected, String status) {
|
||||
if (connected) {
|
||||
_deviceStatus = "已连接";
|
||||
} else {
|
||||
_deviceStatus = status;
|
||||
_connectedDevice = null;
|
||||
_characteristic = null;
|
||||
}
|
||||
_statusController.add(_deviceStatus);
|
||||
_connectionController.add(connected);
|
||||
}
|
||||
|
||||
void onAppResume() {
|
||||
ensureConnection();
|
||||
}
|
||||
|
||||
void setAutoConnectBlocked(bool blocked) {
|
||||
_isAutoConnectBlocked = blocked;
|
||||
}
|
||||
|
||||
bool get isConnected => _connectedDevice != null;
|
||||
String get deviceStatus => _deviceStatus;
|
||||
String? get deviceAddress => _connectedDevice?.remoteId.str;
|
||||
bool get isScanning => FlutterBluePlus.isScanningNow;
|
||||
BluetoothDevice? get connectedDevice => _connectedDevice;
|
||||
bool get isManualDisconnect => _isManualDisconnect;
|
||||
|
||||
void dispose() {
|
||||
_heartbeatTimer?.cancel();
|
||||
disconnect();
|
||||
_statusController.close();
|
||||
_dataController.close();
|
||||
_connectionController.close();
|
||||
}
|
||||
}
|
||||
320
lib/services/database_service.dart
Normal file
320
lib/services/database_service.dart
Normal file
@@ -0,0 +1,320 @@
|
||||
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 'package:lbjconsole/models/train_record.dart';
|
||||
|
||||
class DatabaseService {
|
||||
static final DatabaseService instance = DatabaseService._internal();
|
||||
factory DatabaseService() => instance;
|
||||
DatabaseService._internal();
|
||||
|
||||
static const String _databaseName = 'train_database';
|
||||
static const _databaseVersion = 1;
|
||||
|
||||
static const String trainRecordsTable = 'train_records';
|
||||
static const String appSettingsTable = 'app_settings';
|
||||
|
||||
Database? _database;
|
||||
|
||||
Future<Database> get database async {
|
||||
if (_database != null) return _database!;
|
||||
_database = await _initDatabase();
|
||||
return _database!;
|
||||
}
|
||||
|
||||
Future<Database> _initDatabase() async {
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final path = join(directory.path, _databaseName);
|
||||
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: _databaseVersion,
|
||||
onCreate: _onCreate,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
groupBy TEXT NOT NULL DEFAULT 'trainAndLoco',
|
||||
timeWindow TEXT NOT NULL DEFAULT 'unlimited'
|
||||
)
|
||||
''');
|
||||
|
||||
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,
|
||||
'searchOrderList': '',
|
||||
'autoConnectEnabled': 1,
|
||||
'backgroundServiceEnabled': 0,
|
||||
'notificationEnabled': 0,
|
||||
'mergeRecordsEnabled': 0,
|
||||
'groupBy': 'trainAndLoco',
|
||||
'timeWindow': 'unlimited',
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
final db = await database;
|
||||
final result = await db.query(
|
||||
trainRecordsTable,
|
||||
orderBy: 'timestamp DESC',
|
||||
);
|
||||
return result.map((json) => TrainRecord.fromDatabaseJson(json)).toList();
|
||||
}
|
||||
|
||||
Future<int> deleteRecord(String uniqueId) async {
|
||||
final db = await database;
|
||||
return await db.delete(
|
||||
trainRecordsTable,
|
||||
where: 'uniqueId = ?',
|
||||
whereArgs: [uniqueId],
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> deleteAllRecords() async {
|
||||
final db = await database;
|
||||
return await db.delete(trainRecordsTable);
|
||||
}
|
||||
|
||||
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;
|
||||
return await db.update(
|
||||
appSettingsTable,
|
||||
settings,
|
||||
where: 'id = 1',
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> setSetting(String key, dynamic value) async {
|
||||
final db = await database;
|
||||
return await db.update(
|
||||
appSettingsTable,
|
||||
{key: value},
|
||||
where: 'id = 1',
|
||||
);
|
||||
}
|
||||
|
||||
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],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
lib/services/loco_type_service.dart
Normal file
17
lib/services/loco_type_service.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'package:lbjconsole/util/loco_type_util.dart';
|
||||
|
||||
class LocoTypeService {
|
||||
static final LocoTypeService _instance = LocoTypeService._internal();
|
||||
factory LocoTypeService() => _instance;
|
||||
LocoTypeService._internal();
|
||||
|
||||
bool _isInitialized = false;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
bool get isInitialized => _isInitialized;
|
||||
}
|
||||
85
lib/services/merge_service.dart
Normal file
85
lib/services/merge_service.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'package:lbjconsole/models/train_record.dart';
|
||||
import 'package:lbjconsole/models/merged_record.dart';
|
||||
|
||||
class MergeService {
|
||||
static String? _generateGroupKey(TrainRecord record, GroupBy groupBy) {
|
||||
final train = record.train.trim();
|
||||
final loco = record.loco.trim();
|
||||
final hasTrain = train.isNotEmpty && train != "<NUL>";
|
||||
final hasLoco = loco.isNotEmpty && loco != "<NUL>";
|
||||
|
||||
switch (groupBy) {
|
||||
case GroupBy.trainOnly:
|
||||
return hasTrain ? train : null;
|
||||
case GroupBy.locoOnly:
|
||||
return hasLoco ? loco : null;
|
||||
case GroupBy.trainOrLoco:
|
||||
if (hasTrain) return train;
|
||||
if (hasLoco) return loco;
|
||||
return null;
|
||||
case GroupBy.trainAndLoco:
|
||||
return (hasTrain && hasLoco) ? "${train}_$loco" : null;
|
||||
}
|
||||
}
|
||||
|
||||
static List<Object> getMixedList(
|
||||
List<TrainRecord> allRecords, MergeSettings settings) {
|
||||
if (!settings.enabled) {
|
||||
allRecords
|
||||
.sort((a, b) => b.receivedTimestamp.compareTo(a.receivedTimestamp));
|
||||
return allRecords;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final validRecords = settings.timeWindow.duration == null
|
||||
? allRecords
|
||||
: allRecords
|
||||
.where((r) =>
|
||||
now.difference(r.receivedTimestamp) <=
|
||||
settings.timeWindow.duration!)
|
||||
.toList();
|
||||
|
||||
final groupedRecords = <String, List<TrainRecord>>{};
|
||||
for (final record in validRecords) {
|
||||
final key = _generateGroupKey(record, settings.groupBy);
|
||||
if (key != null) {
|
||||
groupedRecords.putIfAbsent(key, () => []).add(record);
|
||||
}
|
||||
}
|
||||
|
||||
final List<MergedTrainRecord> mergedRecords = [];
|
||||
final Set<String> mergedRecordIds = {};
|
||||
|
||||
groupedRecords.forEach((key, group) {
|
||||
if (group.length >= 2) {
|
||||
group
|
||||
.sort((a, b) => b.receivedTimestamp.compareTo(a.receivedTimestamp));
|
||||
final latestRecord = group.first;
|
||||
mergedRecords.add(MergedTrainRecord(
|
||||
groupKey: key,
|
||||
records: group,
|
||||
latestRecord: latestRecord,
|
||||
));
|
||||
for (final record in group) {
|
||||
mergedRecordIds.add(record.uniqueId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
final singleRecords =
|
||||
allRecords.where((r) => !mergedRecordIds.contains(r.uniqueId)).toList();
|
||||
|
||||
final List<Object> mixedList = [...mergedRecords, ...singleRecords];
|
||||
mixedList.sort((a, b) {
|
||||
final aTime = a is MergedTrainRecord
|
||||
? a.latestRecord.receivedTimestamp
|
||||
: (a as TrainRecord).receivedTimestamp;
|
||||
final bTime = b is MergedTrainRecord
|
||||
? b.latestRecord.receivedTimestamp
|
||||
: (b as TrainRecord).receivedTimestamp;
|
||||
return bTime.compareTo(aTime);
|
||||
});
|
||||
|
||||
return mixedList;
|
||||
}
|
||||
}
|
||||
135
lib/services/notification_service.dart
Normal file
135
lib/services/notification_service.dart
Normal file
@@ -0,0 +1,135 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:lbjconsole/models/train_record.dart';
|
||||
|
||||
class NotificationService {
|
||||
static const String channelId = 'lbj_messages';
|
||||
static const String channelName = 'LBJ Messages';
|
||||
static const String channelDescription = 'Receive LBJ messages';
|
||||
|
||||
final FlutterLocalNotificationsPlugin _notificationsPlugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
int _notificationId = 1000;
|
||||
bool _notificationsEnabled = true;
|
||||
|
||||
final StreamController<bool> _settingsController =
|
||||
StreamController<bool>.broadcast();
|
||||
Stream<bool> get settingsStream => _settingsController.stream;
|
||||
|
||||
Future<void> initialize() async {
|
||||
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
final InitializationSettings initializationSettings =
|
||||
InitializationSettings(
|
||||
android: initializationSettingsAndroid,
|
||||
);
|
||||
|
||||
await _notificationsPlugin.initialize(
|
||||
initializationSettings,
|
||||
onDidReceiveNotificationResponse: (details) {},
|
||||
);
|
||||
|
||||
await _createNotificationChannel();
|
||||
|
||||
_notificationsEnabled = await isNotificationEnabled();
|
||||
_settingsController.add(_notificationsEnabled);
|
||||
}
|
||||
|
||||
Future<void> _createNotificationChannel() async {
|
||||
const AndroidNotificationChannel channel = AndroidNotificationChannel(
|
||||
channelId,
|
||||
channelName,
|
||||
description: channelDescription,
|
||||
importance: Importance.high,
|
||||
enableVibration: true,
|
||||
playSound: true,
|
||||
);
|
||||
|
||||
await _notificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
Future<void> showTrainNotification(TrainRecord record) async {
|
||||
if (!_notificationsEnabled) return;
|
||||
|
||||
if (!_isValidValue(record.train) ||
|
||||
!_isValidValue(record.route) ||
|
||||
!_isValidValue(record.directionText)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final String title = '列车信息更新';
|
||||
final String body = _buildNotificationContent(record);
|
||||
|
||||
final AndroidNotificationDetails androidPlatformChannelSpecifics =
|
||||
AndroidNotificationDetails(
|
||||
channelId,
|
||||
channelName,
|
||||
channelDescription: channelDescription,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
ticker: 'ticker',
|
||||
styleInformation: BigTextStyleInformation(body),
|
||||
);
|
||||
|
||||
final NotificationDetails platformChannelSpecifics =
|
||||
NotificationDetails(android: androidPlatformChannelSpecifics);
|
||||
|
||||
await _notificationsPlugin.show(
|
||||
_notificationId++,
|
||||
title,
|
||||
body,
|
||||
platformChannelSpecifics,
|
||||
payload: 'train_${record.train}',
|
||||
);
|
||||
}
|
||||
|
||||
String _buildNotificationContent(TrainRecord record) {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
buffer.writeln('车次: ${record.fullTrainNumber}');
|
||||
buffer.writeln('线路: ${record.route}');
|
||||
buffer.writeln('方向: ${record.directionText}');
|
||||
|
||||
if (_isValidValue(record.speed)) {
|
||||
buffer.writeln('速度: ${record.speed} km/h');
|
||||
}
|
||||
|
||||
if (_isValidValue(record.positionInfo)) {
|
||||
buffer.writeln('位置: ${record.positionInfo}');
|
||||
}
|
||||
|
||||
buffer.writeln('时间: ${record.formattedTime}');
|
||||
|
||||
return buffer.toString().trim();
|
||||
}
|
||||
|
||||
bool _isValidValue(String? value) {
|
||||
if (value == null || value.isEmpty) return false;
|
||||
final trimmed = value.trim();
|
||||
return trimmed.isNotEmpty &&
|
||||
trimmed != 'NUL' &&
|
||||
trimmed != 'NA' &&
|
||||
trimmed != '*';
|
||||
}
|
||||
|
||||
Future<void> enableNotifications(bool enable) async {
|
||||
_notificationsEnabled = enable;
|
||||
_settingsController.add(_notificationsEnabled);
|
||||
}
|
||||
|
||||
Future<bool> isNotificationEnabled() async {
|
||||
return _notificationsEnabled;
|
||||
}
|
||||
|
||||
Future<void> cancelAllNotifications() async {
|
||||
await _notificationsPlugin.cancelAll();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_settingsController.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user