feat: integrate RTL-TCP server support

This commit is contained in:
Nedifinita
2025-11-01 22:39:52 +08:00
parent 356738ac10
commit 5aa19ada14
379 changed files with 109413 additions and 47 deletions

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:async';
import 'dart:developer' as developer;
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:lbjconsole/models/merged_record.dart';
import 'package:lbjconsole/models/train_record.dart';
@@ -13,15 +14,25 @@ import 'package:lbjconsole/services/ble_service.dart';
import 'package:lbjconsole/services/database_service.dart';
import 'package:lbjconsole/services/notification_service.dart';
import 'package:lbjconsole/services/background_service.dart';
import 'package:lbjconsole/services/rtl_tcp_service.dart';
import 'package:lbjconsole/themes/app_theme.dart';
import 'dart:convert';
class _ConnectionStatusWidget extends StatefulWidget {
final BLEService bleService;
final RtlTcpService rtlTcpService;
final DateTime? lastReceivedTime;
final DateTime? rtlTcpLastReceivedTime;
final bool rtlTcpEnabled;
final bool rtlTcpConnected;
const _ConnectionStatusWidget({
required this.bleService,
required this.rtlTcpService,
required this.lastReceivedTime,
required this.rtlTcpLastReceivedTime,
required this.rtlTcpEnabled,
required this.rtlTcpConnected,
});
@override
@@ -58,19 +69,32 @@ class _ConnectionStatusWidgetState extends State<_ConnectionStatusWidget> {
@override
Widget build(BuildContext context) {
final isRtlTcpMode = widget.rtlTcpEnabled;
final rtlTcpConnected = widget.rtlTcpConnected;
final isConnected = isRtlTcpMode ? rtlTcpConnected : _isConnected;
final statusColor = isRtlTcpMode
? (rtlTcpConnected ? Colors.green : Colors.red)
: (_isConnected ? Colors.green : Colors.red);
final statusText = isRtlTcpMode
? (rtlTcpConnected ? '已连接' : '未连接')
: _deviceStatus;
final lastReceivedTime = isRtlTcpMode ? widget.rtlTcpLastReceivedTime : widget.lastReceivedTime;
return Row(
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.lastReceivedTime == null || !_isConnected) ...[
Text(_deviceStatus,
if (lastReceivedTime == null || !isConnected) ...[
Text(statusText,
style: const TextStyle(color: Colors.white70, fontSize: 12)),
],
_LastReceivedTimeWidget(
lastReceivedTime: widget.lastReceivedTime,
isConnected: _isConnected,
lastReceivedTime: lastReceivedTime,
isConnected: isConnected,
),
],
),
@@ -79,7 +103,7 @@ class _ConnectionStatusWidgetState extends State<_ConnectionStatusWidget> {
width: 8,
height: 8,
decoration: BoxDecoration(
color: _isConnected ? Colors.green : Colors.red,
color: statusColor,
shape: BoxShape.circle,
),
),
@@ -179,14 +203,23 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
String _mapType = 'webview';
late final BLEService _bleService;
late final RtlTcpService _rtlTcpService;
final NotificationService _notificationService = NotificationService();
final DatabaseService _databaseService = DatabaseService.instance;
StreamSubscription? _connectionSubscription;
StreamSubscription? _rtlTcpConnectionSubscription;
StreamSubscription? _dataSubscription;
StreamSubscription? _rtlTcpDataSubscription;
StreamSubscription? _lastReceivedTimeSubscription;
StreamSubscription? _rtlTcpLastReceivedTimeSubscription;
StreamSubscription? _settingsSubscription;
DateTime? _lastReceivedTime;
DateTime? _rtlTcpLastReceivedTime;
bool _isHistoryEditMode = false;
bool _rtlTcpEnabled = false;
bool _rtlTcpConnected = false;
bool _isConnected = false;
final GlobalKey<HistoryScreenState> _historyScreenKey =
GlobalKey<HistoryScreenState>();
final GlobalKey<RealtimeScreenState> _realtimeScreenKey =
@@ -197,23 +230,47 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
super.initState();
WidgetsBinding.instance.addObserver(this);
_bleService = BLEService();
_rtlTcpService = RtlTcpService();
_bleService.initialize();
_loadRtlTcpSettings();
_initializeServices();
_checkAndStartBackgroundService();
_setupConnectionListener();
_setupLastReceivedTimeListener();
_setupSettingsListener();
_loadMapType();
}
Future<void> _loadMapType() async {
final settings = await DatabaseService.instance.getAllSettings() ?? {};
final settings = await DatabaseService.instance.getAllSettings();
if (mounted) {
setState(() {
_mapType = settings['mapType']?.toString() ?? 'webview';
_mapType = settings?['mapType']?.toString() ?? 'webview';
});
}
}
void _loadRtlTcpSettings() async {
developer.log('rtl_tcp: load_settings');
final settings = await _databaseService.getAllSettings();
developer.log('rtl_tcp: settings_loaded: enabled=${(settings?['rtlTcpEnabled'] ?? 0) == 1}, host=${settings?['rtlTcpHost']?.toString() ?? '127.0.0.1'}, port=${settings?['rtlTcpPort']?.toString() ?? '14423'}');
if (mounted) {
setState(() {
_rtlTcpEnabled = (settings?['rtlTcpEnabled'] ?? 0) == 1;
_rtlTcpConnected = _rtlTcpService.isConnected;
});
if (_rtlTcpEnabled && !_rtlTcpConnected) {
final host = settings?['rtlTcpHost']?.toString() ?? '127.0.0.1';
final port = settings?['rtlTcpPort']?.toString() ?? '14423';
developer.log('rtl_tcp: auto_connect');
_connectToRtlTcp(host, port);
} else {
developer.log('rtl_tcp: skip_connect: enabled=$_rtlTcpEnabled, connected=$_rtlTcpConnected');
}
}
}
Future<void> _checkAndStartBackgroundService() async {
final settings = await DatabaseService.instance.getAllSettings() ?? {};
final backgroundServiceEnabled =
@@ -233,22 +290,92 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
});
}
});
_rtlTcpLastReceivedTimeSubscription =
_rtlTcpService.lastReceivedTimeStream.listen((time) {
if (mounted) {
if (_rtlTcpEnabled) {
setState(() {
_rtlTcpLastReceivedTime = time;
});
}
}
});
}
void _setupSettingsListener() {
developer.log('rtl_tcp: setup_listener');
_settingsSubscription =
DatabaseService.instance.onSettingsChanged((settings) {
if (mounted && _currentIndex == 1) {
_realtimeScreenKey.currentState?.loadRecords(scrollToTop: false);
developer.log('rtl_tcp: settings_changed: enabled=${(settings?['rtlTcpEnabled'] ?? 0) == 1}, host=${settings?['rtlTcpHost']?.toString() ?? '127.0.0.1'}, port=${settings?['rtlTcpPort']?.toString() ?? '14423'}');
if (mounted) {
final rtlTcpEnabled = (settings?['rtlTcpEnabled'] ?? 0) == 1;
if (rtlTcpEnabled != _rtlTcpEnabled) {
setState(() {
_rtlTcpEnabled = rtlTcpEnabled;
});
if (rtlTcpEnabled) {
final host = settings?['rtlTcpHost']?.toString() ?? '127.0.0.1';
final port = settings?['rtlTcpPort']?.toString() ?? '14423';
_connectToRtlTcp(host, port);
} else {
_rtlTcpConnectionSubscription?.cancel();
_rtlTcpDataSubscription?.cancel();
_rtlTcpLastReceivedTimeSubscription?.cancel();
_rtlTcpService.disconnect();
setState(() {
_rtlTcpConnected = false;
_rtlTcpLastReceivedTime = null;
});
}
}
if (_currentIndex == 1) {
_realtimeScreenKey.currentState?.loadRecords(scrollToTop: false);
}
}
});
}
void _setupConnectionListener() {
_connectionSubscription = _bleService.connectionStream.listen((connected) {
if (mounted) {
setState(() {
_isConnected = connected;
});
}
});
_rtlTcpConnectionSubscription = _rtlTcpService.connectionStream.listen((connected) {
if (mounted) {
if (_rtlTcpEnabled) {
setState(() {
_rtlTcpConnected = connected;
});
}
}
});
}
Future<void> _connectToRtlTcp(String host, String port) async {
developer.log('rtl_tcp: connect: $host:$port');
try {
await _rtlTcpService.connect(host: host, port: port);
developer.log('rtl_tcp: connect_req_sent');
} catch (e) {
developer.log('rtl_tcp: connect_fail: $e');
}
}
@override
void dispose() {
_connectionSubscription?.cancel();
_rtlTcpConnectionSubscription?.cancel();
_dataSubscription?.cancel();
_rtlTcpDataSubscription?.cancel();
_lastReceivedTimeSubscription?.cancel();
_rtlTcpLastReceivedTimeSubscription?.cancel();
_settingsSubscription?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
@@ -274,6 +401,18 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
_realtimeScreenKey.currentState!.addNewRecord(record);
}
});
_rtlTcpDataSubscription = _rtlTcpService.dataStream.listen((record) {
developer.log('rtl_tcp: recv_data: train=${record.train}');
developer.log('rtl_tcp: recv_json: ${jsonEncode(record.toJson())}');
_notificationService.showTrainNotification(record);
if (_historyScreenKey.currentState != null) {
_historyScreenKey.currentState!.addNewRecord(record);
}
if (_realtimeScreenKey.currentState != null) {
_realtimeScreenKey.currentState!.addNewRecord(record);
}
});
}
void _showConnectionDialog() {
@@ -282,7 +421,7 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
context: context,
barrierDismissible: true,
builder: (context) =>
_PixelPerfectBluetoothDialog(bleService: _bleService),
_PixelPerfectBluetoothDialog(bleService: _bleService, rtlTcpEnabled: _rtlTcpEnabled),
).then((_) {
_bleService.setAutoConnectBlocked(false);
if (!_bleService.isManualDisconnect) {
@@ -325,19 +464,26 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
),
centerTitle: false,
actions: [
Row(
children: [
_ConnectionStatusWidget(
bleService: _bleService,
lastReceivedTime: _lastReceivedTime,
),
IconButton(
icon: const Icon(Icons.bluetooth, color: Colors.white),
onPressed: _showConnectionDialog,
),
],
),
],
Row(
children: [
_ConnectionStatusWidget(
bleService: _bleService,
rtlTcpService: _rtlTcpService,
lastReceivedTime: _lastReceivedTime,
rtlTcpLastReceivedTime: _rtlTcpLastReceivedTime,
rtlTcpEnabled: _rtlTcpEnabled,
rtlTcpConnected: _rtlTcpConnected,
),
IconButton(
icon: Icon(
_rtlTcpEnabled ? Icons.wifi : Icons.bluetooth,
color: Colors.white,
),
onPressed: _showConnectionDialog,
),
],
),
],
);
}
@@ -418,6 +564,7 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
SettingsScreen(
onSettingsChanged: () {
_loadMapType();
_loadRtlTcpSettings();
},
),
];
@@ -464,7 +611,8 @@ enum _ScanState { initial, scanning, finished }
class _PixelPerfectBluetoothDialog extends StatefulWidget {
final BLEService bleService;
const _PixelPerfectBluetoothDialog({required this.bleService});
final bool rtlTcpEnabled;
const _PixelPerfectBluetoothDialog({required this.bleService, required this.rtlTcpEnabled});
@override
State<_PixelPerfectBluetoothDialog> createState() =>
_PixelPerfectBluetoothDialogState();
@@ -477,13 +625,28 @@ class _PixelPerfectBluetoothDialogState
StreamSubscription? _connectionSubscription;
StreamSubscription? _lastReceivedTimeSubscription;
DateTime? _lastReceivedTime;
StreamSubscription? _rtlTcpConnectionSubscription;
bool _rtlTcpConnected = false;
@override
void initState() {
super.initState();
_connectionSubscription = widget.bleService.connectionStream.listen((_) {
if (mounted) setState(() {});
});
if (!widget.bleService.isConnected) {
_rtlTcpConnectionSubscription = widget.bleService.rtlTcpService?.connectionStream.listen((connected) {
if (mounted) {
setState(() {
_rtlTcpConnected = connected;
});
}
});
if (widget.rtlTcpEnabled && widget.bleService.rtlTcpService != null) {
_rtlTcpConnected = widget.bleService.rtlTcpService!.isConnected;
}
if (!widget.bleService.isConnected && !widget.rtlTcpEnabled) {
_startScan();
}
}
@@ -491,6 +654,7 @@ class _PixelPerfectBluetoothDialogState
@override
void dispose() {
_connectionSubscription?.cancel();
_rtlTcpConnectionSubscription?.cancel();
_lastReceivedTimeSubscription?.cancel();
super.dispose();
}
@@ -536,13 +700,15 @@ class _PixelPerfectBluetoothDialogState
Widget build(BuildContext context) {
final isConnected = widget.bleService.isConnected;
return AlertDialog(
title: const Text('蓝牙设备'),
title: Text(widget.rtlTcpEnabled ? 'RTL-TCP 模式' : '蓝牙设备'),
content: SizedBox(
width: double.maxFinite,
child: SingleChildScrollView(
child: isConnected
? _buildConnectedView(context, widget.bleService.connectedDevice)
: _buildDisconnectedView(context),
child: widget.rtlTcpEnabled
? _buildRtlTcpView(context)
: (isConnected
? _buildConnectedView(context, widget.bleService.connectedDevice)
: _buildDisconnectedView(context)),
),
),
actions: [
@@ -605,6 +771,31 @@ class _PixelPerfectBluetoothDialogState
]);
}
Widget _buildRtlTcpView(BuildContext context) {
final isConnected = _rtlTcpConnected;
final currentAddress = widget.bleService.rtlTcpService?.currentAddress ?? '未配置';
return Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.wifi, size: 48, color: isConnected ? Colors.green : Colors.red),
const SizedBox(height: 16),
Text(isConnected ? '已连接' : '未连接',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text('$currentAddress',
style: TextStyle(color: isConnected ? Colors.green : Colors.grey)),
const SizedBox(height: 16),
if (_lastReceivedTime != null && isConnected) ...[
_LastReceivedTimeWidget(
lastReceivedTime: _lastReceivedTime,
isConnected: isConnected,
),
],
]);
}
Widget _buildDeviceListView() {
return SizedBox(
height: 200,

View File

@@ -28,6 +28,8 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
late DatabaseService _databaseService;
late TextEditingController _deviceNameController;
late TextEditingController _rtlTcpHostController;
late TextEditingController _rtlTcpPortController;
String _deviceName = '';
bool _backgroundServiceEnabled = false;
@@ -39,19 +41,143 @@ class _SettingsScreenState extends State<SettingsScreen> {
GroupBy _groupBy = GroupBy.trainAndLoco;
TimeWindow _timeWindow = TimeWindow.unlimited;
String _mapType = 'map';
bool _rtlTcpEnabled = false;
String _rtlTcpHost = '127.0.0.1';
String _rtlTcpPort = '14423';
@override
void initState() {
super.initState();
_databaseService = DatabaseService.instance;
_deviceNameController = TextEditingController();
_rtlTcpHostController = TextEditingController();
_rtlTcpPortController = TextEditingController();
_loadSettings();
_loadRecordCount();
}
Widget _buildRtlTcpSettings() {
return Card(
color: AppTheme.tertiaryBlack,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.wifi,
color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
Text('RTL-TCP 接收', style: AppTheme.titleMedium),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('启用RTL-TCP接收', style: AppTheme.bodyLarge),
],
),
Switch(
value: _rtlTcpEnabled,
onChanged: (value) {
setState(() {
_rtlTcpEnabled = value;
});
_saveSettings();
},
activeColor: Theme.of(context).colorScheme.primary,
),
],
),
Visibility(
visible: _rtlTcpEnabled,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
TextField(
decoration: InputDecoration(
labelText: '服务器地址',
hintText: '输入RTL-TCP服务器地址',
labelStyle: const TextStyle(color: Colors.white70),
hintStyle: const TextStyle(color: Colors.white54),
border: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.white54),
borderRadius: BorderRadius.circular(12.0),
),
enabledBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.white54),
borderRadius: BorderRadius.circular(12.0),
),
focusedBorder: OutlineInputBorder(
borderSide:
BorderSide(color: Theme.of(context).colorScheme.primary),
borderRadius: BorderRadius.circular(12.0),
),
),
style: const TextStyle(color: Colors.white),
controller: _rtlTcpHostController,
onChanged: (value) {
setState(() {
_rtlTcpHost = value;
});
_saveSettings();
},
),
const SizedBox(height: 16),
TextField(
decoration: InputDecoration(
labelText: '服务器端口',
hintText: '输入RTL-TCP服务器端口',
labelStyle: const TextStyle(color: Colors.white70),
hintStyle: const TextStyle(color: Colors.white54),
border: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.white54),
borderRadius: BorderRadius.circular(12.0),
),
enabledBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.white54),
borderRadius: BorderRadius.circular(12.0),
),
focusedBorder: OutlineInputBorder(
borderSide:
BorderSide(color: Theme.of(context).colorScheme.primary),
borderRadius: BorderRadius.circular(12.0),
),
),
style: const TextStyle(color: Colors.white),
controller: _rtlTcpPortController,
keyboardType: TextInputType.number,
onChanged: (value) {
setState(() {
_rtlTcpPort = value;
});
_saveSettings();
},
),
],
),
),
],
),
),
);
}
@override
void dispose() {
_deviceNameController.dispose();
_rtlTcpHostController.dispose();
_rtlTcpPortController.dispose();
super.dispose();
}
@@ -71,6 +197,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
_groupBy = settings.groupBy;
_timeWindow = settings.timeWindow;
_mapType = settingsMap['mapType']?.toString() ?? 'webview';
_rtlTcpEnabled = (settingsMap['rtlTcpEnabled'] ?? 0) == 1;
_rtlTcpHost = settingsMap['rtlTcpHost']?.toString() ?? '127.0.0.1';
_rtlTcpPort = settingsMap['rtlTcpPort']?.toString() ?? '14423';
_rtlTcpHostController.text = _rtlTcpHost;
_rtlTcpPortController.text = _rtlTcpPort;
});
}
}
@@ -95,6 +226,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
'groupBy': _groupBy.name,
'timeWindow': _timeWindow.name,
'mapType': _mapType,
'rtlTcpEnabled': _rtlTcpEnabled ? 1 : 0,
'rtlTcpHost': _rtlTcpHost,
'rtlTcpPort': _rtlTcpPort,
});
widget.onSettingsChanged?.call();
}
@@ -110,6 +244,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 20),
_buildAppSettings(),
const SizedBox(height: 20),
_buildRtlTcpSettings(),
const SizedBox(height: 20),
_buildMergeSettings(),
const SizedBox(height: 20),
_buildDataManagement(),
@@ -204,7 +340,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('后台保活服务', style: AppTheme.bodyLarge),
Text('保持应用在后台运行', style: AppTheme.caption),
],
),
Switch(
@@ -232,8 +367,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('LBJ消息通知', style: AppTheme.bodyLarge),
Text('接收LBJ消息通知', style: AppTheme.caption),
Text('通知服务', style: AppTheme.bodyLarge),
],
),
Switch(
@@ -255,8 +389,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('地图显示方式', style: AppTheme.bodyLarge),
Text('选择地图组件类型', style: AppTheme.caption),
Text('地图组件类型', style: AppTheme.bodyLarge),
],
),
DropdownButton<String>(
@@ -293,7 +426,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('隐藏只有时间有效的记录', style: AppTheme.bodyLarge),
Text('不显示只有时间信息的记录', style: AppTheme.caption),
],
),
Switch(
@@ -342,7 +474,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('启用记录合并', style: AppTheme.bodyLarge),
Text('合并相同内容的LBJ记录', style: AppTheme.caption),
],
),
Switch(
@@ -452,7 +583,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('隐藏不可分组记录', style: AppTheme.bodyLarge),
Text('不显示无法分组的记录', style: AppTheme.caption),
],
),
Switch(