From 5533df92b507de963a3870ed58252761cc2ad2f9 Mon Sep 17 00:00:00 2001 From: Nedifinita Date: Sun, 12 Oct 2025 21:42:01 +0800 Subject: [PATCH] feat: add train location tracking functionality --- README.md | 3 +- lib/screens/main_screen.dart | 35 +- lib/screens/realtime_screen.dart | 1310 ++++++++++++++++++++++++++++ lib/services/database_service.dart | 72 +- pubspec.yaml | 2 +- 5 files changed, 1410 insertions(+), 12 deletions(-) create mode 100644 lib/screens/realtime_screen.dart diff --git a/README.md b/README.md index c0329c1..a3d42de 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,16 @@ LBJ Console 是一款应用程序,用于通过 BLE 从 [SX1276_Receive_LBJ](https://github.com/undef-i/SX1276_Receive_LBJ) 设备接收并显示列车预警消息,功能包括: - 接收列车预警消息,支持可选的手机推送通知。 +- 监控指定列车的轨迹,在地图上显示。 - 在地图上显示预警消息的 GPS 信息。 - 基于内置数据文件显示机车配属,机车类型和车次类型。 [android](https://github.com/undef-i/LBJ_Console/tree/android) 分支包含项目早期基于 Android 平台的实现代码,已实现基本功能,现已停止开发。 - ## 数据文件 LBJ Console 依赖以下数据文件,位于 `assets` 目录,用于支持机车配属和车次信息的展示: + - `loco_info.csv`:包含机车配属信息,格式为 `机车型号,机车编号起始值,机车编号结束值,所属铁路局及机务段,备注`。 - `loco_type_info.csv`:包含机车类型编码信息,格式为 `机车类型编码前缀,机车类型`。 - `train_info.csv`:包含车次类型信息,格式为 `正则表达式,车次类型`。 diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 7829bb1..0c6cb67 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -7,6 +7,7 @@ import 'package:lbjconsole/models/train_record.dart'; import 'package:lbjconsole/screens/history_screen.dart'; import 'package:lbjconsole/screens/map_screen.dart'; import 'package:lbjconsole/screens/map_webview_screen.dart'; +import 'package:lbjconsole/screens/realtime_screen.dart'; import 'package:lbjconsole/screens/settings_screen.dart'; import 'package:lbjconsole/services/ble_service.dart'; import 'package:lbjconsole/services/database_service.dart'; @@ -183,10 +184,13 @@ class _MainScreenState extends State with WidgetsBindingObserver { StreamSubscription? _connectionSubscription; StreamSubscription? _dataSubscription; StreamSubscription? _lastReceivedTimeSubscription; + StreamSubscription? _settingsSubscription; DateTime? _lastReceivedTime; bool _isHistoryEditMode = false; final GlobalKey _historyScreenKey = GlobalKey(); + final GlobalKey _realtimeScreenKey = + GlobalKey(); @override void initState() { @@ -197,6 +201,7 @@ class _MainScreenState extends State with WidgetsBindingObserver { _initializeServices(); _checkAndStartBackgroundService(); _setupLastReceivedTimeListener(); + _setupSettingsListener(); _loadMapType(); } @@ -209,7 +214,6 @@ class _MainScreenState extends State with WidgetsBindingObserver { } } - Future _checkAndStartBackgroundService() async { final settings = await DatabaseService.instance.getAllSettings() ?? {}; final backgroundServiceEnabled = @@ -231,11 +235,21 @@ class _MainScreenState extends State with WidgetsBindingObserver { }); } + void _setupSettingsListener() { + _settingsSubscription = + DatabaseService.instance.onSettingsChanged((settings) { + if (mounted && _currentIndex == 1) { + _realtimeScreenKey.currentState?.loadRecords(scrollToTop: false); + } + }); + } + @override void dispose() { _connectionSubscription?.cancel(); _dataSubscription?.cancel(); _lastReceivedTimeSubscription?.cancel(); + _settingsSubscription?.cancel(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -256,6 +270,9 @@ class _MainScreenState extends State with WidgetsBindingObserver { if (_historyScreenKey.currentState != null) { _historyScreenKey.currentState!.addNewRecord(record); } + if (_realtimeScreenKey.currentState != null) { + _realtimeScreenKey.currentState!.addNewRecord(record); + } }); } @@ -302,7 +319,7 @@ class _MainScreenState extends State with WidgetsBindingObserver { backgroundColor: AppTheme.primaryBlack, elevation: 0, title: Text( - ['列车记录', '位置地图', '设置'][_currentIndex], + ['列车记录', '数据监控', '位置地图', '设置'][_currentIndex], style: const TextStyle( color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold), ), @@ -394,6 +411,9 @@ class _MainScreenState extends State with WidgetsBindingObserver { onEditModeChanged: _handleHistoryEditModeChanged, onSelectionChanged: _handleSelectionChanged, ), + RealtimeScreen( + key: _realtimeScreenKey, + ), _mapType == 'map' ? const MapScreen() : const MapWebViewScreen(), SettingsScreen( onSettingsChanged: () { @@ -411,14 +431,16 @@ class _MainScreenState extends State with WidgetsBindingObserver { ), bottomNavigationBar: NavigationBar( backgroundColor: AppTheme.secondaryBlack, - indicatorColor: AppTheme.accentBlue.withOpacity(0.2), + indicatorColor: AppTheme.accentBlue.withValues(alpha: 0.2), selectedIndex: _currentIndex, onDestinationSelected: (index) { - if (_currentIndex == 2 && index == 0) { + if (index == 0) { _historyScreenKey.currentState?.reloadRecords(); } - // 如果从设置页面切换到地图页面,重新加载地图类型 - if (_currentIndex == 2 && index == 1) { + if (index == 1) { + _realtimeScreenKey.currentState?.loadRecords(scrollToTop: false); + } + if (_currentIndex == 3 && index == 2) { _loadMapType(); } setState(() { @@ -429,6 +451,7 @@ class _MainScreenState extends State with WidgetsBindingObserver { destinations: const [ NavigationDestination( icon: Icon(Icons.directions_railway), label: '列车记录'), + NavigationDestination(icon: Icon(Icons.speed), label: '数据监控'), NavigationDestination(icon: Icon(Icons.location_on), label: '位置地图'), NavigationDestination(icon: Icon(Icons.settings), label: '设置'), ], diff --git a/lib/screens/realtime_screen.dart b/lib/screens/realtime_screen.dart new file mode 100644 index 0000000..0c0db2b --- /dev/null +++ b/lib/screens/realtime_screen.dart @@ -0,0 +1,1310 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import '../models/merged_record.dart'; +import '../services/database_service.dart'; +import '../models/train_record.dart'; +import '../services/merge_service.dart'; + +class RealtimeScreen extends StatefulWidget { + const RealtimeScreen({ + super.key, + }); + + @override + RealtimeScreenState createState() => RealtimeScreenState(); +} + +class RealtimeScreenState extends State { + final List _displayItems = []; + bool _isLoading = true; + final ScrollController _scrollController = ScrollController(); + bool _isAtTop = true; + MergeSettings _mergeSettings = MergeSettings(); + StreamSubscription? _recordDeleteSubscription; + StreamSubscription? _settingsSubscription; + + final MapController _mapController = MapController(); + List _selectedGroupRoute = []; + List _mapMarkers = []; + bool _showMap = true; + Set _selectedGroupKeys = {}; + + List getDisplayItems() => _displayItems; + + Future reloadRecords() async { + await loadRecords(scrollToTop: false); + } + + void _updateAllRecordMarkers() { + setState(() { + final allRecordsWithPosition = []; + for (final item in _displayItems) { + if (item is MergedTrainRecord) { + allRecordsWithPosition.addAll(item.records); + } else if (item is TrainRecord) { + allRecordsWithPosition.add(item); + } + } + + _mapMarkers = allRecordsWithPosition + .map((record) { + final position = _parsePositionFromRecord(record); + if (position != null) { + final isInSelectedGroup = _selectedGroupKeys.isNotEmpty && + (_displayItems.any((item) { + if (item is MergedTrainRecord && + _selectedGroupKeys.contains(item.groupKey)) { + return item.records + .any((r) => r.uniqueId == record.uniqueId); + } + return false; + }) || + _selectedGroupKeys.contains("single:${record.uniqueId}")); + + return Marker( + point: position, + width: 10, + height: 10, + child: Container( + decoration: BoxDecoration( + color: isInSelectedGroup ? Colors.black : Colors.grey, + shape: BoxShape.circle, + border: Border.all( + color: isInSelectedGroup + ? Colors.white + : Colors.grey[300]!, + width: 1.5), + ), + ), + ); + } + return null; + }) + .where((marker) => marker != null) + .cast() + .toList(); + }); + } + + List _buildSelectedGroupPolylines() { + final polylineLayers = []; + + for (final groupKey in _selectedGroupKeys.toList()) { + try { + if (groupKey.startsWith('single:')) { + final uniqueId = groupKey.substring(7); + final singleRecord = _displayItems + .whereType() + .firstWhere((record) => record.uniqueId == uniqueId); + + final position = _parsePositionFromRecord(singleRecord); + if (position != null) { + polylineLayers.add( + PolylineLayer( + polylines: [ + Polyline( + points: [position], + strokeWidth: 4.0, + color: Colors.black, + ), + ], + ), + ); + } + } else { + final mergedRecord = _displayItems + .whereType() + .firstWhere((item) => item.groupKey == groupKey); + + final routePoints = mergedRecord.records + .map((record) => _parsePositionFromRecord(record)) + .where((latLng) => latLng != null) + .cast() + .toList() + .reversed + .toList(); + + if (routePoints.isNotEmpty) { + polylineLayers.add( + PolylineLayer( + polylines: [ + Polyline( + points: routePoints, + strokeWidth: 4.0, + color: Colors.black, + ), + ], + ), + ); + } + } + } catch (e) { + _selectedGroupKeys.remove(groupKey); + print('记录不存在,移除选中状态: $groupKey'); + } + } + + return polylineLayers; + } + + List _buildSelectedGroupEndMarkers() { + final markerLayers = []; + + for (final groupKey in _selectedGroupKeys.toList()) { + try { + if (groupKey.startsWith('single:')) { + final uniqueId = groupKey.substring(7); + final singleRecord = _displayItems + .whereType() + .firstWhere((record) => record.uniqueId == uniqueId); + + final position = _parsePositionFromRecord(singleRecord); + if (position != null) { + markerLayers.add( + MarkerLayer( + markers: [ + Marker( + point: position, + width: 60, + height: 20, + child: Container( + color: Colors.black, + alignment: Alignment.center, + child: Text( + _getTrainDisplayName(singleRecord), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ); + } + } else { + final mergedRecord = _displayItems + .whereType() + .firstWhere((item) => item.groupKey == groupKey); + + final routePoints = mergedRecord.records + .map((record) => _parsePositionFromRecord(record)) + .where((latLng) => latLng != null) + .cast() + .toList() + .reversed + .toList(); + + if (routePoints.isNotEmpty) { + markerLayers.add( + MarkerLayer( + markers: [ + Marker( + point: routePoints.last, + width: 60, + height: 20, + child: Container( + color: Colors.black, + alignment: Alignment.center, + child: Text( + _getTrainDisplayName(mergedRecord.latestRecord), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ); + } + } + } catch (e) { + _selectedGroupKeys.remove(groupKey); + print('记录不存在,移除选中状态: $groupKey'); + } + } + + return markerLayers; + } + + void _adjustMapViewToSelectedGroups() { + if (_selectedGroupKeys.isEmpty) { + if (mounted) { + if (mounted) { + _mapController.move(const LatLng(35.8617, 104.1954), 2.0); + } + } + return; + } + + if (!mounted) return; + + final allSelectedPoints = []; + + for (final groupKey in _selectedGroupKeys.toList()) { + try { + if (groupKey.startsWith('single:')) { + final uniqueId = groupKey.substring(7); + final singleRecord = _displayItems + .whereType() + .firstWhere((record) => record.uniqueId == uniqueId); + + final position = _parsePositionFromRecord(singleRecord); + if (position != null) { + allSelectedPoints.add(position); + } + } else { + final mergedRecord = _displayItems + .whereType() + .firstWhere((item) => item.groupKey == groupKey); + + final routePoints = mergedRecord.records + .map((record) => _parsePositionFromRecord(record)) + .where((latLng) => latLng != null) + .cast() + .toList(); + + allSelectedPoints.addAll(routePoints); + } + } catch (e) { + _selectedGroupKeys.remove(groupKey); + print('记录不存在,移除选中状态: $groupKey'); + } + } + + if (allSelectedPoints.isNotEmpty) { + if (mounted) { + if (allSelectedPoints.length > 1) { + final bounds = LatLngBounds.fromPoints(allSelectedPoints); + _mapController.fitCamera( + CameraFit.bounds( + bounds: bounds, + padding: const EdgeInsets.all(50), + maxZoom: 16, + ), + ); + } else if (allSelectedPoints.length == 1) { + _mapController.move(allSelectedPoints.first, 14); + } + } + } + } + + void _onGroupSelected(MergedTrainRecord mergedRecord) { + setState(() { + if (_selectedGroupKeys.contains(mergedRecord.groupKey)) { + _selectedGroupKeys.remove(mergedRecord.groupKey); + } else { + _selectedGroupKeys.add(mergedRecord.groupKey); + } + + _selectedGroupRoute = []; + }); + + _updateAllRecordMarkers(); + + _adjustMapViewToSelectedGroups(); + } + + void _onSingleRecordSelected(TrainRecord record) { + final groupKey = "single:${record.uniqueId}"; + + setState(() { + if (_selectedGroupKeys.contains(groupKey)) { + _selectedGroupKeys.remove(groupKey); + } else { + _selectedGroupKeys.add(groupKey); + } + + _selectedGroupRoute = []; + }); + + _updateAllRecordMarkers(); + + _adjustMapViewToSelectedGroups(); + } + + LatLng? _parsePositionFromRecord(TrainRecord record) { + if (record.positionInfo == null || + record.positionInfo.isEmpty || + record.positionInfo == '') { + return null; + } + + try { + final parts = record.positionInfo.trim().split(RegExp(r'\s+')); + if (parts.length >= 2) { + final lat = _parseDmsCoordinate(parts[0]); + final lng = _parseDmsCoordinate(parts[1]); + if (lat != null && + lng != null && + (lat.abs() > 0.001 || lng.abs() > 0.001)) { + return LatLng(lat, lng); + } + } + } catch (e) { + return null; + } + return null; + } + + double? _parseDmsCoordinate(String dmsStr) { + try { + final degreeIndex = dmsStr.indexOf('°'); + if (degreeIndex == -1) { + return null; + } + final degrees = double.tryParse(dmsStr.substring(0, degreeIndex)); + if (degrees == null) { + return null; + } + final minuteIndex = dmsStr.indexOf('′'); + if (minuteIndex == -1) { + return degrees; + } + final minutes = + double.tryParse(dmsStr.substring(degreeIndex + 1, minuteIndex)); + if (minutes == null) { + return degrees; + } + return degrees + (minutes / 60.0); + } catch (e) { + return null; + } + } + + String _getTrainDisplayName(TrainRecord record) { + if (record.fullTrainNumber.isNotEmpty) { + return record.fullTrainNumber.length > 8 + ? record.fullTrainNumber.substring(0, 8) + : record.fullTrainNumber; + } + if (record.locoType.isNotEmpty && record.loco.isNotEmpty) { + return "${record.locoType}-${record.loco.length > 5 ? record.loco.substring(record.loco.length - 5) : record.loco}"; + } + return "列车"; + } + + void _showRecordDetails(TrainRecord record) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: const Color(0xFF1E1E1E), + title: Text( + _getTrainDisplayName(record), + style: const TextStyle(color: Colors.white), + ), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildDetailRow("时间", record.time), + _buildDetailRow("位置", record.position), + _buildDetailRow("路线", record.route), + _buildDetailRow("速度", record.speed), + _buildDetailRow("坐标", () { + final position = _parsePositionFromRecord(record); + return position != null + ? "${position.latitude.toStringAsFixed(6)}, ${position.longitude.toStringAsFixed(6)}" + : "无数据"; + }()), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('关闭'), + ), + ], + ), + ); + } + + Widget _buildDetailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 60, + child: Text( + "$label: ", + style: const TextStyle( + color: Colors.grey, + fontWeight: FontWeight.bold, + ), + ), + ), + Expanded( + child: Text( + value.isEmpty || value == "" ? "无数据" : value, + style: const TextStyle(color: Colors.white), + ), + ), + ], + ), + ); + } + + @override + void initState() { + super.initState(); + _scrollController.addListener(() { + print( + '滚动监听器触发 - 当前位置: ${_scrollController.position.pixels}, maxScrollExtent: ${_scrollController.position.maxScrollExtent}, isAtTop: $_isAtTop'); + + if (_scrollController.position.atEdge) { + if (_scrollController.position.pixels == + _scrollController.position.maxScrollExtent) { + print('到达底部(反转后的"顶部")- 设置 _isAtTop = true'); + if (!_isAtTop) { + setState(() => _isAtTop = true); + } + } else if (_scrollController.position.pixels == 0) { + print('到达顶部(反转后的"底部")- 设置 _isAtTop = false'); + if (_isAtTop) { + setState(() => _isAtTop = false); + } + } + } else { + if (_isAtTop) { + print('离开底部(反转后的"顶部")- 设置 _isAtTop = false'); + setState(() => _isAtTop = false); + } + } + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + loadRecords(scrollToTop: false).then((_) { + if (_displayItems.isNotEmpty) { + _scheduleInitialScroll(); + } + }); + } + }); + _setupRecordDeleteListener(); + _setupSettingsListener(); + } + + void _scheduleInitialScroll() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _scrollController.hasClients && _displayItems.isNotEmpty) { + try { + final maxScrollExtent = _scrollController.position.maxScrollExtent; + print('初始滚动执行:maxScrollExtent=$maxScrollExtent'); + _scrollController.jumpTo(maxScrollExtent); + print('初始滚动完成:位置=${_scrollController.position.pixels}'); + + if (!_isAtTop) { + setState(() => _isAtTop = true); + } + } catch (e) { + print('初始滚动错误:$e'); + } + } + }); + } + + @override + void dispose() { + _scrollController.dispose(); + _recordDeleteSubscription?.cancel(); + _settingsSubscription?.cancel(); + super.dispose(); + } + + void _setupRecordDeleteListener() { + _recordDeleteSubscription = + DatabaseService.instance.onRecordDeleted((deletedIds) { + if (mounted) { + loadRecords(scrollToTop: false); + } + }); + } + + void _setupSettingsListener() { + _settingsSubscription = + DatabaseService.instance.onSettingsChanged((settings) { + if (mounted) { + loadRecords(scrollToTop: false); + } + }); + } + + Future loadRecords({bool scrollToTop = true}) async { + try { + if (mounted) { + setState(() => _isLoading = true); + } + + final allRecords = await DatabaseService.instance.getAllRecords(); + final settingsMap = await DatabaseService.instance.getAllSettings() ?? {}; + _mergeSettings = MergeSettings.fromMap(settingsMap); + + List filteredRecords = allRecords; + + filteredRecords = allRecords.where((record) { + final position = _parsePositionFromRecord(record); + return position != null; + }).toList(); + + if ((settingsMap['hideTimeOnlyRecords'] ?? 0) == 1) { + filteredRecords = filteredRecords.where((record) { + bool isFieldMeaningful(String field) { + if (field.isEmpty) { + return false; + } + String cleaned = field.replaceAll('', '').trim(); + if (cleaned.isEmpty) { + return false; + } + if (cleaned.runes + .every((r) => r == '*'.runes.first || r == ' '.runes.first)) { + return false; + } + return true; + } + + final hasTrainNumber = isFieldMeaningful(record.fullTrainNumber) && + !record.fullTrainNumber.contains("-----"); + + final hasDirection = record.direction == 1 || record.direction == 3; + + final hasLocoInfo = isFieldMeaningful(record.locoType) || + isFieldMeaningful(record.loco); + + final hasRoute = isFieldMeaningful(record.route); + + final hasPosition = isFieldMeaningful(record.position); + + final hasSpeed = + isFieldMeaningful(record.speed) && record.speed != "NUL"; + + final hasPositionInfo = isFieldMeaningful(record.positionInfo); + + final hasTrainType = + isFieldMeaningful(record.trainType) && record.trainType != "未知"; + + final hasLbjClass = + isFieldMeaningful(record.lbjClass) && record.lbjClass != "NA"; + + final hasTrain = isFieldMeaningful(record.train) && + !record.train.contains("-----"); + + final shouldShow = hasTrainNumber || + hasDirection || + hasLocoInfo || + hasRoute || + hasPosition || + hasSpeed || + hasPositionInfo || + hasTrainType || + hasLbjClass || + hasTrain; + + return shouldShow; + }).toList(); + } + + final items = MergeService.getMixedList(filteredRecords, _mergeSettings); + + if (mounted) { + final hasDataChanged = _hasDataChanged(items); + + if (hasDataChanged) { + final selectedSingleRecords = []; + final selectedMergedGroups = []; + + for (final key in _selectedGroupKeys) { + if (key.startsWith('single:')) { + selectedSingleRecords.add(key); + } else { + selectedMergedGroups.add(key); + } + } + + final inheritedSelections = {}; + + for (final oldSingleKey in selectedSingleRecords) { + final uniqueId = oldSingleKey.substring(7); + + for (final newItem in items) { + if (newItem is MergedTrainRecord) { + final containsOldRecord = newItem.records + .any((record) => record.uniqueId == uniqueId); + if (containsOldRecord) { + inheritedSelections[oldSingleKey] = newItem.groupKey; + break; + } + } + } + } + + setState(() { + _displayItems.clear(); + _displayItems.addAll(items); + _isLoading = false; + + for (final entry in inheritedSelections.entries) { + final oldSingleKey = entry.key; + final newMergedKey = entry.value; + + _selectedGroupKeys.remove(oldSingleKey); + if (!_selectedGroupKeys.contains(newMergedKey)) { + _selectedGroupKeys.add(newMergedKey); + } + } + }); + + _updateAllRecordMarkers(); + + if (scrollToTop && + _isAtTop && + _scrollController.hasClients && + _displayItems.isNotEmpty) { + try { + final maxScrollExtent = + _scrollController.position.maxScrollExtent; + print('loadRecords - 滚动到底部, maxScrollExtent: $maxScrollExtent'); + _scrollController.jumpTo(maxScrollExtent); + print( + 'loadRecords - 滚动完成,新位置: ${_scrollController.position.pixels}'); + } catch (e) { + print('loadRecords - 滚动错误: $e'); + } + } + } else { + if (_isLoading) { + setState(() => _isLoading = false); + } + } + } + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Future addNewRecord(TrainRecord newRecord) async { + print('addNewRecord - 开始添加新记录, 当前_isAtTop=$_isAtTop'); + try { + final position = _parsePositionFromRecord(newRecord); + if (position == null) { + print('addNewRecord - 记录没有位置信息,忽略'); + return; + } + + final settingsMap = await DatabaseService.instance.getAllSettings() ?? {}; + _mergeSettings = MergeSettings.fromMap(settingsMap); + + if ((settingsMap['hideTimeOnlyRecords'] ?? 0) == 1) {} + + final isNewRecord = !_displayItems.any((item) { + if (item is TrainRecord) { + return item.uniqueId == newRecord.uniqueId; + } else if (item is MergedTrainRecord) { + return item.records.any((r) => r.uniqueId == newRecord.uniqueId); + } + return false; + }); + if (!isNewRecord) return; + + if (mounted) { + setState(() { + bool isMerge = false; + Object? mergeResult; + String? oldSingleRecordKey; + + if (_displayItems.isNotEmpty) { + final firstItem = _displayItems.first; + List tempRecords = [newRecord]; + if (firstItem is MergedTrainRecord) { + tempRecords.addAll(firstItem.records); + } else if (firstItem is TrainRecord) { + tempRecords.add(firstItem); + + oldSingleRecordKey = "single:${firstItem.uniqueId}"; + } + final mergeCheckResult = + MergeService.getMixedList(tempRecords, _mergeSettings); + if (mergeCheckResult.length == 1 && + mergeCheckResult.first is MergedTrainRecord) { + isMerge = true; + mergeResult = mergeCheckResult.first; + } + } + + if (isMerge) { + final mergedRecord = mergeResult as MergedTrainRecord; + _displayItems[0] = mergedRecord; + + if (oldSingleRecordKey != null && + _selectedGroupKeys.contains(oldSingleRecordKey)) { + _selectedGroupKeys.remove(oldSingleRecordKey); + _selectedGroupKeys.add(mergedRecord.groupKey); + } + } else { + _displayItems.insert(0, newRecord); + } + }); + + _updateAllRecordMarkers(); + + if (_selectedGroupKeys.isNotEmpty && mounted) { + _adjustMapViewToSelectedGroups(); + } + + print( + 'addNewRecord - 检查滚动条件: _isAtTop=$_isAtTop, hasClients=${_scrollController.hasClients}, 当前位置: ${_scrollController.position.pixels}'); + if (_isAtTop && _scrollController.hasClients) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _scrollController.hasClients) { + final newMaxScrollExtent = + _scrollController.position.maxScrollExtent; + print( + 'addNewRecord - 执行滚动到底部, maxScrollExtent: $newMaxScrollExtent'); + _scrollController.jumpTo(newMaxScrollExtent); + print( + 'addNewRecord - 滚动完成,新位置: ${_scrollController.position.pixels}'); + } + }); + } else { + print('addNewRecord - 不执行滚动,条件不满足'); + } + } + } catch (e) {} + } + + bool _hasDataChanged(List newItems) { + if (_displayItems.length != newItems.length) return true; + + for (int i = 0; i < _displayItems.length; i++) { + final oldItem = _displayItems[i]; + final newItem = newItems[i]; + + if (oldItem.runtimeType != newItem.runtimeType) return true; + + if (oldItem is TrainRecord && newItem is TrainRecord) { + if (oldItem.uniqueId != newItem.uniqueId) return true; + } else if (oldItem is MergedTrainRecord && newItem is MergedTrainRecord) { + if (oldItem.groupKey != newItem.groupKey) return true; + if (oldItem.records.length != newItem.records.length) return true; + } + } + return false; + } + + @override + Widget build(BuildContext context) { + if (_isLoading && _displayItems.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (_displayItems.isEmpty) { + return const Center( + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.history, size: 64, color: Colors.grey), + SizedBox(height: 16), + Text('暂无记录', style: TextStyle(color: Colors.white, fontSize: 18)) + ])); + } + + return Column( + children: [ + if (_showMap) + Expanded( + flex: 1, + child: FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: const LatLng(35.8617, 104.1954), + initialZoom: 2.0, + ), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'org.noxylva.lbjconsole', + ), + MarkerLayer(markers: _mapMarkers), + if (_selectedGroupKeys.isNotEmpty) + ..._buildSelectedGroupPolylines(), + if (_selectedGroupKeys.isNotEmpty) + ..._buildSelectedGroupEndMarkers(), + ], + ), + ), + if (!_showMap) + Padding( + padding: const EdgeInsets.all(16.0), + child: ElevatedButton.icon( + onPressed: () { + setState(() { + _showMap = true; + }); + }, + icon: const Icon(Icons.map, size: 16), + label: const Text('显示地图'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue[800], + foregroundColor: Colors.white, + ), + ), + ), + const SizedBox(height: 8), + Expanded( + flex: _showMap ? 1 : 2, + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16.0), + itemCount: _displayItems.length, + reverse: true, + itemBuilder: (context, index) { + final item = _displayItems[_displayItems.length - 1 - index]; + if (item is MergedTrainRecord) { + return _buildMergedRecordCard(item); + } else if (item is TrainRecord) { + return _buildRecordCard(item, key: ValueKey(item.uniqueId)); + } + return const SizedBox.shrink(); + }, + ), + ), + ], + ); + } + + Widget _buildMergedRecordCard(MergedTrainRecord mergedRecord) { + final isSelected = _selectedGroupKeys.contains(mergedRecord.groupKey); + return GestureDetector( + onTap: () => _onGroupSelected(mergedRecord), + child: Card( + key: ValueKey(mergedRecord.groupKey), + color: const Color(0xFF1E1E1E), + elevation: 1, + margin: const EdgeInsets.only(bottom: 8.0), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + side: BorderSide( + color: isSelected ? Colors.blue : Colors.transparent, + width: 2.0)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildRecordHeader(mergedRecord.latestRecord, + isMerged: true), + _buildPositionAndSpeedWithRouteLogic(mergedRecord), + _buildLocoInfo(mergedRecord.latestRecord), + ]))), + ); + } + + String _formatLocoInfo(TrainRecord record) { + final locoType = record.locoType.trim(); + final loco = record.loco.trim(); + + if (locoType.isNotEmpty && loco.isNotEmpty) { + final shortLoco = + loco.length > 5 ? loco.substring(loco.length - 5) : loco; + return "$locoType-$shortLoco"; + } else if (locoType.isNotEmpty) { + return locoType; + } else if (loco.isNotEmpty) { + return loco; + } + return ""; + } + + String _getDifferingInfo( + TrainRecord record, TrainRecord latest, GroupBy groupBy) { + final train = record.train.trim(); + final loco = record.loco.trim(); + final latestTrain = latest.train.trim(); + final latestLoco = latest.loco.trim(); + + switch (groupBy) { + case GroupBy.trainOnly: + if (loco != latestLoco && loco.isNotEmpty) { + return _formatLocoInfo(record); + } + return ""; + case GroupBy.locoOnly: + return train != latestTrain && train.isNotEmpty ? train : ""; + case GroupBy.trainOrLoco: + final trainDiff = train.isNotEmpty && train != latestTrain ? train : ""; + final locoDiff = loco.isNotEmpty && loco != latestLoco + ? _formatLocoInfo(record) + : ""; + + if (trainDiff.isNotEmpty && locoDiff.isNotEmpty) { + return "$trainDiff $locoDiff"; + } else if (trainDiff.isNotEmpty) { + return trainDiff; + } else if (locoDiff.isNotEmpty) { + return locoDiff; + } + return ""; + case GroupBy.trainAndLoco: + if (train.isNotEmpty && train != latestTrain) { + final locoInfo = _formatLocoInfo(record); + if (locoInfo.isNotEmpty) { + return "$train $locoInfo"; + } + return train; + } + if (loco.isNotEmpty && loco != latestLoco) { + return _formatLocoInfo(record); + } + return ""; + } + } + + String _getLocationInfo(TrainRecord record) { + List parts = []; + if (record.route.isNotEmpty && record.route != "") { + parts.add(record.route); + } + if (record.direction != 0) { + parts.add(record.direction == 1 ? "下" : "上"); + } + if (record.position.isNotEmpty && record.position != "") { + final position = record.position; + final cleanPosition = position.endsWith('.') + ? position.substring(0, position.length - 1) + : position; + parts.add("${cleanPosition}K"); + } + return parts.join(' '); + } + + Widget _buildRecordCard(TrainRecord record, + {bool isSubCard = false, Key? key}) { + final isSelected = _selectedGroupKeys.contains("single:${record.uniqueId}"); + + return GestureDetector( + onTap: () => _onSingleRecordSelected(record), + child: Card( + key: key, + color: const Color(0xFF1E1E1E), + elevation: isSubCard ? 0 : 1, + margin: EdgeInsets.only(bottom: isSubCard ? 4.0 : 8.0), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + side: BorderSide( + color: isSelected ? Colors.blue : Colors.transparent, + width: 2.0)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildRecordHeader(record), + _buildPositionAndSpeed(record), + _buildLocoInfo(record), + ]))), + ); + } + + Widget _buildRecordHeader(TrainRecord record, {bool isMerged = false}) { + final trainType = record.trainType; + String formattedLocoInfo = ""; + if (record.locoType.isNotEmpty && record.loco.isNotEmpty) { + final shortLoco = record.loco.length > 5 + ? record.loco.substring(record.loco.length - 5) + : record.loco; + formattedLocoInfo = "${record.locoType}-$shortLoco"; + } else if (record.locoType.isNotEmpty) { + formattedLocoInfo = record.locoType; + } else if (record.loco.isNotEmpty) { + formattedLocoInfo = record.loco; + } + + if (record.fullTrainNumber.isEmpty && formattedLocoInfo.isEmpty) { + return Text( + (record.time == "" || record.time.isEmpty) + ? record.receivedTimestamp.toString().split(".")[0] + : record.time.split("\n")[0], + style: const TextStyle(fontSize: 11, color: Colors.grey), + overflow: TextOverflow.ellipsis); + } + + final hasTrainNumber = record.fullTrainNumber.isNotEmpty; + final hasDirection = record.direction == 1 || record.direction == 3; + final hasLocoInfo = + formattedLocoInfo.isNotEmpty && formattedLocoInfo != ""; + final shouldShowTrainRow = hasTrainNumber || hasDirection || hasLocoInfo; + + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + Flexible( + child: Text( + (record.time == "" || record.time.isEmpty) + ? record.receivedTimestamp.toString().split(".")[0] + : record.time.split("\n")[0], + style: const TextStyle(fontSize: 11, color: Colors.grey), + overflow: TextOverflow.ellipsis)), + if (trainType.isNotEmpty) + Flexible( + child: Text(trainType, + style: const TextStyle(fontSize: 11, color: Colors.grey), + overflow: TextOverflow.ellipsis)) + ]), + if (shouldShowTrainRow) ...[ + const SizedBox(height: 2), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (hasTrainNumber) + Flexible( + child: Text(record.fullTrainNumber, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white), + overflow: TextOverflow.ellipsis)), + if (hasTrainNumber && hasDirection) + const SizedBox(width: 6), + if (hasDirection) + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(2)), + child: Center( + child: Text(record.direction == 1 ? "下" : "上", + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.black)))) + ])), + if (hasLocoInfo) + Text(formattedLocoInfo, + style: const TextStyle(fontSize: 14, color: Colors.white70)) + ]), + const SizedBox(height: 2) + ] + ]); + } + + Widget _buildLocoInfo(TrainRecord record) { + final locoInfo = record.locoInfo; + if (locoInfo == null || locoInfo.isEmpty) { + return const SizedBox.shrink(); + } + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const SizedBox(height: 4), + Text(locoInfo, + style: const TextStyle(fontSize: 14, color: Colors.white), + maxLines: 1, + overflow: TextOverflow.ellipsis) + ]); + } + + Widget _buildPositionAndSpeed(TrainRecord record) { + final routeStr = record.route.trim(); + final position = record.position.trim(); + final speed = record.speed.trim(); + final isValidRoute = routeStr.isNotEmpty && + !routeStr.runes.every((r) => r == '*'.runes.first); + final isValidPosition = position.isNotEmpty && + !position.runes + .every((r) => r == '-'.runes.first || r == '.'.runes.first) && + position != ""; + final isValidSpeed = speed.isNotEmpty && + !speed.runes + .every((r) => r == '*'.runes.first || r == '-'.runes.first) && + speed != "NUL" && + speed != ""; + if (!isValidRoute && !isValidPosition && !isValidSpeed) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 4.0), + child: + Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + if (isValidRoute || isValidPosition) + Expanded( + child: Row(children: [ + if (isValidRoute) + Flexible( + child: Text(routeStr, + style: + const TextStyle(fontSize: 16, color: Colors.white), + overflow: TextOverflow.ellipsis)), + if (isValidRoute && isValidPosition) const SizedBox(width: 4), + if (isValidPosition) + Flexible( + child: Text( + "${position.trim().endsWith('.') ? position.trim().substring(0, position.trim().length - 1) : position.trim()}K", + style: + const TextStyle(fontSize: 16, color: Colors.white), + overflow: TextOverflow.ellipsis)) + ])), + if (isValidSpeed) + Text("${speed.replaceAll(' ', '')} km/h", + style: const TextStyle(fontSize: 16, color: Colors.white), + textAlign: TextAlign.right) + ])); + } + + Widget _buildPositionAndSpeedWithRouteLogic(MergedTrainRecord mergedRecord) { + if (mergedRecord.records.isEmpty) { + return const SizedBox.shrink(); + } + + final latestRecord = mergedRecord.latestRecord; + + TrainRecord? previousRecord; + if (mergedRecord.records.length > 1) { + final sortedRecords = List.from(mergedRecord.records) + ..sort((a, b) => b.receivedTimestamp.compareTo(a.receivedTimestamp)); + + if (sortedRecords.length > 1) { + previousRecord = sortedRecords[1]; + } + } + + String getValidRoute(TrainRecord record) { + final routeStr = record.route.trim(); + if (routeStr.isNotEmpty && + !routeStr.runes.every((r) => r == '*'.runes.first) && + routeStr != "") { + return routeStr; + } + return ""; + } + + final latestRoute = getValidRoute(latestRecord); + final previousRoute = + previousRecord != null ? getValidRoute(previousRecord) : ""; + + final bool needsSpecialDisplay = previousRecord != null && + latestRoute.isNotEmpty && + previousRoute.isNotEmpty && + latestRoute != previousRoute; + + final position = latestRecord.position.trim(); + final speed = latestRecord.speed.trim(); + + final isValidPosition = position.isNotEmpty && + !position.runes + .every((r) => r == '-'.runes.first || r == '.'.runes.first) && + position != ""; + final isValidSpeed = speed.isNotEmpty && + !speed.runes + .every((r) => r == '*'.runes.first || r == '-'.runes.first) && + speed != "NUL" && + speed != ""; + + if (latestRoute.isEmpty && !isValidPosition && !isValidSpeed) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(top: 4.0), + child: + Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + if (latestRoute.isNotEmpty || isValidPosition) + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (latestRoute.isNotEmpty) ...[ + if (needsSpecialDisplay) ...[ + Flexible( + child: Text(previousRoute, + style: const TextStyle( + fontSize: 16, + color: Colors.white, + decoration: TextDecoration.lineThrough, + decorationColor: Colors.grey, + ), + overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: const Color(0xFF1E1E1E), + title: const Text("路线变化", + style: TextStyle(color: Colors.white)), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("上一条: $previousRoute", + style: const TextStyle(color: Colors.grey)), + const SizedBox(height: 8), + Text("当前: $latestRoute", + style: + const TextStyle(color: Colors.white)), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('关闭'), + ), + ], + ), + ); + }, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.orange.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.orange, width: 1), + ), + child: const Icon( + Icons.question_mark, + size: 16, + color: Colors.orange, + ), + ), + ), + const SizedBox(width: 4), + ] else + Flexible( + child: Text(latestRoute, + style: const TextStyle( + fontSize: 16, color: Colors.white), + overflow: TextOverflow.ellipsis)), + ], + if (latestRoute.isNotEmpty && isValidPosition) + const SizedBox(width: 4), + if (isValidPosition) + Flexible( + child: Text( + "${position.trim().endsWith('.') ? position.trim().substring(0, position.trim().length - 1) : position.trim()}K", + style: const TextStyle( + fontSize: 16, color: Colors.white), + overflow: TextOverflow.ellipsis)) + ], + )), + if (isValidSpeed) + Text("${speed.replaceAll(' ', '')} km/h", + style: const TextStyle(fontSize: 16, color: Colors.white), + textAlign: TextAlign.right) + ])); + } +} diff --git a/lib/services/database_service.dart b/lib/services/database_service.dart index 3d0dd92..0950a7a 100644 --- a/lib/services/database_service.dart +++ b/lib/services/database_service.dart @@ -232,16 +232,28 @@ class DatabaseService { Future deleteRecord(String uniqueId) async { final db = await database; - return await db.delete( + final result = await db.delete( trainRecordsTable, where: 'uniqueId = ?', whereArgs: [uniqueId], ); + + if (result > 0) { + _notifyRecordDeleted([uniqueId]); + } + + return result; } Future deleteAllRecords() async { final db = await database; - return await db.delete(trainRecordsTable); + final result = await db.delete(trainRecordsTable); + + if (result > 0) { + _notifyRecordDeleted([]); + } + + return result; } Future getRecordCount() async { @@ -279,20 +291,31 @@ class DatabaseService { Future updateSettings(Map settings) async { final db = await database; - return await db.update( + final result = await db.update( appSettingsTable, settings, where: 'id = 1', ); + if (result > 0) { + _notifySettingsChanged(settings); + } + return result; } Future setSetting(String key, dynamic value) async { final db = await database; - return await db.update( + 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 { @@ -349,6 +372,42 @@ class DatabaseService { 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 Stream.value(null).listen((_) {}) + ..onData((_) {}) + ..onDone(() { + _settingsListeners.remove(listener); + }); + } + + void _notifySettingsChanged(Map settings) { + for (final listener in _settingsListeners) { + listener(settings); + } } Future close() async { @@ -404,6 +463,11 @@ class DatabaseService { } }); + final currentSettings = await getAllSettings(); + if (currentSettings != null) { + _notifySettingsChanged(currentSettings); + } + return true; } catch (e) { return false; diff --git a/pubspec.yaml b/pubspec.yaml index 9821c14..12e0021 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.5.2-flutter+52 +version: 0.6.0-flutter+60 environment: sdk: ^3.5.4