import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:geolocator/geolocator.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 = {}; LatLng? _userLocation; bool _isLocationPermissionGranted = false; Timer? _locationTimer; StreamSubscription? _positionStreamSubscription; 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(); if (_userLocation != null) { _mapMarkers.add( Marker( point: _userLocation!, width: 24, height: 24, child: Container( decoration: BoxDecoration( color: Colors.blue, borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.white, width: 1), ), child: const Icon( Icons.my_location, color: Colors.white, size: 12, ), ), ), ); } }); } 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); } } 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: 80, height: 16, child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.black.withOpacity(0.8), borderRadius: BorderRadius.circular(3), ), child: Text( _getTrainDisplayName(singleRecord), style: const TextStyle( color: Colors.white, fontSize: 8, 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: 80, height: 16, child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.black.withOpacity(0.8), borderRadius: BorderRadius.circular(3), ), child: Text( _getTrainDisplayName(mergedRecord.latestRecord), style: const TextStyle( color: Colors.white, fontSize: 8, fontWeight: FontWeight.bold, ), ), ), ], ), ), ], ), ); } } } catch (e) { _selectedGroupKeys.remove(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); } } 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(() { if (_scrollController.position.atEdge) { if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) { if (!_isAtTop) { setState(() => _isAtTop = true); } } else if (_scrollController.position.pixels == 0) { if (_isAtTop) { setState(() => _isAtTop = false); } } } else { if (_isAtTop) { setState(() => _isAtTop = false); } } }); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { loadRecords(scrollToTop: false).then((_) { if (_displayItems.isNotEmpty) { _scheduleInitialScroll(); } }); } }); _setupRecordDeleteListener(); _setupSettingsListener(); _startLocationUpdates(); } void _scheduleInitialScroll() { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _scrollController.hasClients && _displayItems.isNotEmpty) { try { final maxScrollExtent = _scrollController.position.maxScrollExtent; _scrollController.jumpTo(maxScrollExtent); if (!_isAtTop) { setState(() => _isAtTop = true); } } catch (e) {} } }); } @override void dispose() { _scrollController.dispose(); _recordDeleteSubscription?.cancel(); _settingsSubscription?.cancel(); _locationTimer?.cancel(); _positionStreamSubscription?.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 _requestLocationPermission() async { bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { return; } LocationPermission permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); } if (permission == LocationPermission.deniedForever) { return; } setState(() { _isLocationPermissionGranted = true; }); _getCurrentLocation(); _startRealtimeLocationUpdates(); } Future _getCurrentLocation() async { try { Position position = await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high, forceAndroidLocationManager: true, ); final newLocation = LatLng(position.latitude, position.longitude); setState(() { _userLocation = newLocation; }); _updateAllRecordMarkers(); } catch (e) {} } void _startLocationUpdates() { _requestLocationPermission(); } void _startRealtimeLocationUpdates() { _positionStreamSubscription?.cancel(); _positionStreamSubscription = Geolocator.getPositionStream( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, distanceFilter: 1, timeLimit: Duration(seconds: 30), ), ).listen( (Position position) { final newLocation = LatLng(position.latitude, position.longitude); setState(() { _userLocation = newLocation; }); _updateAllRecordMarkers(); }, onError: (error) {}, ); } 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; _scrollController.jumpTo(maxScrollExtent); } catch (e) {} } } else { if (_isLoading) { setState(() => _isLoading = false); } } } } catch (e) { if (mounted) { setState(() => _isLoading = false); } } } Future addNewRecord(TrainRecord newRecord) async { try { final position = _parsePositionFromRecord(newRecord); if (position == null) { 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) { List allRecords = []; Set selectedRecordIds = {}; for (final item in _displayItems) { if (item is MergedTrainRecord) { allRecords.addAll(item.records); if (_selectedGroupKeys.contains(item.groupKey)) { selectedRecordIds.addAll(item.records.map((r) => r.uniqueId)); } } else if (item is TrainRecord) { allRecords.add(item); if (_selectedGroupKeys.contains("single:${item.uniqueId}")) { selectedRecordIds.add(item.uniqueId); } } } allRecords.insert(0, newRecord); final mergedItems = MergeService.getMixedList(allRecords, _mergeSettings); setState(() { _displayItems.clear(); _displayItems.addAll(mergedItems); _selectedGroupKeys.clear(); for (final item in _displayItems) { if (item is MergedTrainRecord) { if (item.records .any((r) => selectedRecordIds.contains(r.uniqueId))) { _selectedGroupKeys.add(item.groupKey); } } else if (item is TrainRecord) { if (selectedRecordIds.contains(item.uniqueId)) { _selectedGroupKeys.add("single:${item.uniqueId}"); } } } }); _updateAllRecordMarkers(); if (_selectedGroupKeys.isNotEmpty && mounted) { _adjustMapViewToSelectedGroups(); } if (_isAtTop && _scrollController.hasClients) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _scrollController.hasClients) { final newMaxScrollExtent = _scrollController.position.maxScrollExtent; _scrollController.jumpTo(newMaxScrollExtent); } }); } else {} } } 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); String displayRoute = latestRoute; bool isDisplayingLatestNormal = true; if (latestRoute.isEmpty || latestRoute.contains('*')) { for (final record in mergedRecord.records) { final route = getValidRoute(record); if (route.isNotEmpty && !route.contains('*')) { displayRoute = route; isDisplayingLatestNormal = (record == latestRecord); break; } } } final bool needsSpecialDisplay = !isDisplayingLatestNormal || (latestRoute.contains('*') && displayRoute != latestRoute); 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 (displayRoute.isNotEmpty) ...[ if (needsSpecialDisplay) ...[ Flexible( child: Text(displayRoute, 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: [ if (!isDisplayingLatestNormal) ...[ Text("显示路线: $displayRoute", style: const TextStyle(color: Colors.white)), const SizedBox(height: 8), ], Text( "最新路线: ${latestRoute.isNotEmpty ? latestRoute : '无效路线'}", style: TextStyle( color: latestRoute.isNotEmpty ? Colors.grey : Colors.red, )), ], ), 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(displayRoute, 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) ])); } }