This commit is contained in:
Nedifinita
2025-08-29 13:28:14 +08:00
commit 25f66000cb
148 changed files with 10964 additions and 0 deletions

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

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

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

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

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