6 Commits

Author SHA1 Message Date
Nedifinita
6073ea615e fix: solve the problem that errors may occur during merging 2025-10-14 23:55:42 +08:00
Nedifinita
5533df92b5 feat: add train location tracking functionality 2025-10-12 21:42:01 +08:00
Nedifinita
24c6abd4f3 Merge branch 'flutter' of https://github.com/undef-i/LBJ_Console into flutter 2025-10-08 22:35:47 +08:00
Nedifinita
5b3960f7d6 fix: correct the scrolling status error when adding new records to the merged record group 2025-10-08 22:35:43 +08:00
undef-i
33e790957e Add .gitattributes file 2025-09-30 01:11:08 +08:00
undef-i
88e3636c3f Create .gitattributes 2025-09-30 01:10:34 +08:00
7 changed files with 1628 additions and 231 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
assets/* linguist-vendored

View File

@@ -3,15 +3,16 @@
LBJ Console 是一款应用程序,用于通过 BLE 从 [SX1276_Receive_LBJ](https://github.com/undef-i/SX1276_Receive_LBJ) 设备接收并显示列车预警消息,功能包括: LBJ Console 是一款应用程序,用于通过 BLE 从 [SX1276_Receive_LBJ](https://github.com/undef-i/SX1276_Receive_LBJ) 设备接收并显示列车预警消息,功能包括:
- 接收列车预警消息,支持可选的手机推送通知。 - 接收列车预警消息,支持可选的手机推送通知。
- 监控指定列车的轨迹,在地图上显示。
- 在地图上显示预警消息的 GPS 信息。 - 在地图上显示预警消息的 GPS 信息。
- 基于内置数据文件显示机车配属,机车类型和车次类型。 - 基于内置数据文件显示机车配属,机车类型和车次类型。
[android](https://github.com/undef-i/LBJ_Console/tree/android) 分支包含项目早期基于 Android 平台的实现代码,已实现基本功能,现已停止开发。 [android](https://github.com/undef-i/LBJ_Console/tree/android) 分支包含项目早期基于 Android 平台的实现代码,已实现基本功能,现已停止开发。
## 数据文件 ## 数据文件
LBJ Console 依赖以下数据文件,位于 `assets` 目录,用于支持机车配属和车次信息的展示: LBJ Console 依赖以下数据文件,位于 `assets` 目录,用于支持机车配属和车次信息的展示:
- `loco_info.csv`:包含机车配属信息,格式为 `机车型号,机车编号起始值,机车编号结束值,所属铁路局及机务段,备注` - `loco_info.csv`:包含机车配属信息,格式为 `机车型号,机车编号起始值,机车编号结束值,所属铁路局及机务段,备注`
- `loco_type_info.csv`:包含机车类型编码信息,格式为 `机车类型编码前缀,机车类型` - `loco_type_info.csv`:包含机车类型编码信息,格式为 `机车类型编码前缀,机车类型`
- `train_info.csv`:包含车次类型信息,格式为 `正则表达式,车次类型` - `train_info.csv`:包含车次类型信息,格式为 `正则表达式,车次类型`

View File

@@ -1,9 +1,7 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:isolate'; import 'dart:isolate';
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
@@ -33,6 +31,10 @@ class HistoryScreenState extends State<HistoryScreen> {
final List<Object> _displayItems = []; final List<Object> _displayItems = [];
bool _isLoading = true; bool _isLoading = true;
bool _isEditMode = false; bool _isEditMode = false;
int? _anchorIndex;
double? _anchorOffset;
double? _oldCardHeight;
double? _oldScrollOffset;
final Set<String> _selectedRecords = {}; final Set<String> _selectedRecords = {};
final Map<String, bool> _expandedStates = {}; final Map<String, bool> _expandedStates = {};
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
@@ -41,7 +43,6 @@ class HistoryScreenState extends State<HistoryScreen> {
late final ChatScrollObserver _chatObserver; late final ChatScrollObserver _chatObserver;
bool _isAtTop = true; bool _isAtTop = true;
MergeSettings _mergeSettings = MergeSettings(); MergeSettings _mergeSettings = MergeSettings();
double _itemHeightCache = 0.0;
final Map<String, double> _mapOptimalZoom = {}; final Map<String, double> _mapOptimalZoom = {};
final Map<String, bool> _mapCalculating = {}; final Map<String, bool> _mapCalculating = {};
@@ -74,15 +75,21 @@ class HistoryScreenState extends State<HistoryScreen> {
super.initState(); super.initState();
_chatObserver = ChatScrollObserver(_observerController) _chatObserver = ChatScrollObserver(_observerController)
..toRebuildScrollViewCallback = () { ..toRebuildScrollViewCallback = () {
setState(() {}); if (mounted) {
setState(() {});
}
}; };
_scrollController.addListener(() { _scrollController.addListener(() {
if (_scrollController.position.atEdge) { if (_scrollController.position.atEdge) {
if (_scrollController.position.pixels == 0) { if (_scrollController.position.pixels == 0) {
if (!_isAtTop) setState(() => _isAtTop = true); if (!_isAtTop) {
setState(() => _isAtTop = true);
}
} }
} else { } else {
if (_isAtTop) setState(() => _isAtTop = false); if (_isAtTop) {
setState(() => _isAtTop = false);
}
} }
}); });
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -109,17 +116,19 @@ class HistoryScreenState extends State<HistoryScreen> {
List<TrainRecord> filteredRecords = allRecords; List<TrainRecord> filteredRecords = allRecords;
if ((settingsMap['hideTimeOnlyRecords'] ?? 0) == 1) { if ((settingsMap['hideTimeOnlyRecords'] ?? 0) == 1) {
int hiddenCount = 0;
int shownCount = 0;
filteredRecords = allRecords.where((record) { filteredRecords = allRecords.where((record) {
bool isFieldMeaningful(String field) { bool isFieldMeaningful(String field) {
if (field.isEmpty) return false; if (field.isEmpty) {
String cleaned = field.replaceAll('<NUL>', '').trim();
if (cleaned.isEmpty) return false;
if (cleaned.runes
.every((r) => r == '*'.runes.first || r == ' '.runes.first))
return false; return false;
}
String cleaned = field.replaceAll('<NUL>', '').trim();
if (cleaned.isEmpty) {
return false;
}
if (cleaned.runes
.every((r) => r == '*'.runes.first || r == ' '.runes.first)) {
return false;
}
return true; return true;
} }
@@ -160,12 +169,6 @@ class HistoryScreenState extends State<HistoryScreen> {
hasLbjClass || hasLbjClass ||
hasTrain; hasTrain;
if (!shouldShow) {
hiddenCount++;
} else {
shownCount++;
}
return shouldShow; return shouldShow;
}).toList(); }).toList();
} }
@@ -192,7 +195,9 @@ class HistoryScreenState extends State<HistoryScreen> {
} }
} }
} catch (e) { } catch (e) {
if (mounted) setState(() => _isLoading = false); if (mounted) {
setState(() => _isLoading = false);
}
} }
} }
@@ -201,57 +206,7 @@ class HistoryScreenState extends State<HistoryScreen> {
final settingsMap = await DatabaseService.instance.getAllSettings() ?? {}; final settingsMap = await DatabaseService.instance.getAllSettings() ?? {};
_mergeSettings = MergeSettings.fromMap(settingsMap); _mergeSettings = MergeSettings.fromMap(settingsMap);
if ((settingsMap['hideTimeOnlyRecords'] ?? 0) == 1) { if ((settingsMap['hideTimeOnlyRecords'] ?? 0) == 1) {}
bool isFieldMeaningful(String field) {
if (field.isEmpty) return false;
String cleaned = field.replaceAll('<NUL>', '').trim();
if (cleaned.isEmpty) return false;
if (cleaned.runes
.every((r) => r == '*'.runes.first || r == ' '.runes.first))
return false;
return true;
}
final hasTrainNumber = isFieldMeaningful(newRecord.fullTrainNumber) &&
!newRecord.fullTrainNumber.contains("-----");
final hasDirection =
newRecord.direction == 1 || newRecord.direction == 3;
final hasLocoInfo = isFieldMeaningful(newRecord.locoType) ||
isFieldMeaningful(newRecord.loco);
final hasRoute = isFieldMeaningful(newRecord.route);
final hasPosition = isFieldMeaningful(newRecord.position);
final hasSpeed =
isFieldMeaningful(newRecord.speed) && newRecord.speed != "NUL";
final hasPositionInfo = isFieldMeaningful(newRecord.positionInfo);
final hasTrainType = isFieldMeaningful(newRecord.trainType) &&
newRecord.trainType != "未知";
final hasLbjClass =
isFieldMeaningful(newRecord.lbjClass) && newRecord.lbjClass != "NA";
final hasTrain = isFieldMeaningful(newRecord.train) &&
!newRecord.train.contains("-----");
if (!hasTrainNumber &&
!hasDirection &&
!hasLocoInfo &&
!hasRoute &&
!hasPosition &&
!hasSpeed &&
!hasPositionInfo &&
!hasTrainType &&
!hasLbjClass &&
!hasTrain) {
return;
}
}
final isNewRecord = !_displayItems.any((item) { final isNewRecord = !_displayItems.any((item) {
if (item is TrainRecord) { if (item is TrainRecord) {
@@ -261,32 +216,147 @@ class HistoryScreenState extends State<HistoryScreen> {
} }
return false; return false;
}); });
if (!isNewRecord) return; if (!isNewRecord) return;
final allRecords = await DatabaseService.instance.getAllRecords();
final items = MergeService.getMixedList(allRecords, _mergeSettings);
if (mounted) { if (mounted) {
if (!_isAtTop) { if (_isAtTop) {
_chatObserver.standby();
}
final hasDataChanged = _hasDataChanged(items);
if (hasDataChanged) {
setState(() { setState(() {
List<TrainRecord> allRecords = [];
Set<String> selectedRecordIds = {};
for (final item in _displayItems) {
if (item is MergedTrainRecord) {
allRecords.addAll(item.records);
if (_selectedRecords.contains(item.records.first.uniqueId)) {
selectedRecordIds.addAll(item.records.map((r) => r.uniqueId));
}
} else if (item is TrainRecord) {
allRecords.add(item);
if (_selectedRecords.contains(item.uniqueId)) {
selectedRecordIds.add(item.uniqueId);
}
}
}
allRecords.insert(0, newRecord);
final mergedItems =
MergeService.getMixedList(allRecords, _mergeSettings);
_displayItems.clear(); _displayItems.clear();
_displayItems.addAll(items); _displayItems.addAll(mergedItems);
_selectedRecords.clear();
for (final item in _displayItems) {
if (item is MergedTrainRecord) {
if (item.records
.any((r) => selectedRecordIds.contains(r.uniqueId))) {
_selectedRecords.addAll(item.records.map((r) => r.uniqueId));
}
} else if (item is TrainRecord) {
if (selectedRecordIds.contains(item.uniqueId)) {
_selectedRecords.add(item.uniqueId);
}
}
}
}); });
if (_scrollController.hasClients) {
_scrollController.jumpTo(0.0);
}
return;
} }
if (_isAtTop && _scrollController.hasClients) { final anchorModel = _observerController.observeFirstItem();
_scrollController.jumpTo(0.0); if (anchorModel == null) {
return;
} }
_anchorIndex = anchorModel.index;
if (_anchorIndex! > 0) {
_anchorOffset = anchorModel.layoutOffset;
} else {
_oldCardHeight = anchorModel.size.height;
_oldScrollOffset = _scrollController.offset;
}
bool isMerge = false;
Object? mergeResult;
final firstItem = _displayItems.first;
List<TrainRecord> tempRecords = [newRecord];
if (firstItem is MergedTrainRecord) {
tempRecords.addAll(firstItem.records);
} else if (firstItem is TrainRecord) {
tempRecords.add(firstItem);
}
final mergeCheckResult =
MergeService.getMixedList(tempRecords, _mergeSettings);
if (mergeCheckResult.length == 1 &&
mergeCheckResult.first is MergedTrainRecord) {
isMerge = true;
mergeResult = mergeCheckResult.first;
}
setState(() {
if (isMerge) {
_displayItems[0] = mergeResult!;
} else {
_displayItems.insert(0, newRecord);
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _anchorIndex == null) return;
if (_anchorIndex! > 0) {
final newAnchorIndex = isMerge ? _anchorIndex! : _anchorIndex! + 1;
final newAnchorModel =
_observerController.observeItem(index: newAnchorIndex);
if (newAnchorModel != null && _anchorOffset != null) {
final newOffset = newAnchorModel.layoutOffset;
final delta = newOffset - _anchorOffset!;
if (delta.abs() > 0.1) {
_scrollController.jumpTo(_scrollController.offset + delta);
}
}
} else {
final newAnchorModel = _observerController.observeItem(index: 0);
if (newAnchorModel != null &&
_oldCardHeight != null &&
_oldScrollOffset != null) {
final newHeight = newAnchorModel.size.height;
final heightDelta = newHeight - _oldCardHeight!;
if (heightDelta.abs() > 0.1) {
_scrollController.jumpTo(_oldScrollOffset! + heightDelta);
}
}
}
_anchorIndex = null;
_anchorOffset = null;
_oldCardHeight = null;
_oldScrollOffset = null;
});
} }
} catch (e) {} } catch (e) {}
} }
String _getGroupKeyForRecord(TrainRecord record, MergeSettings settings) {
switch (settings.groupBy) {
case GroupBy.trainOnly:
return record.train.trim();
case GroupBy.locoOnly:
return record.loco.trim();
case GroupBy.trainAndLoco:
return '${record.train.trim()}-${record.loco.trim()}';
case GroupBy.trainOrLoco:
final train = record.train.trim();
if (train.isNotEmpty) return train;
final loco = record.loco.trim();
if (loco.isNotEmpty) return loco;
return '';
}
}
bool _hasDataChanged(List<Object> newItems) { bool _hasDataChanged(List<Object> newItems) {
if (_displayItems.length != newItems.length) return true; if (_displayItems.length != newItems.length) return true;
@@ -303,7 +373,6 @@ class HistoryScreenState extends State<HistoryScreen> {
if (oldItem.records.length != newItem.records.length) return true; if (oldItem.records.length != newItem.records.length) return true;
} }
} }
return false; return false;
} }
@@ -346,6 +415,7 @@ class HistoryScreenState extends State<HistoryScreen> {
mergedRecord.records.any((r) => _selectedRecords.contains(r.uniqueId)); mergedRecord.records.any((r) => _selectedRecords.contains(r.uniqueId));
final isExpanded = _expandedStates[mergedRecord.groupKey] ?? false; final isExpanded = _expandedStates[mergedRecord.groupKey] ?? false;
return Card( return Card(
key: ValueKey(mergedRecord.groupKey),
color: isSelected && _isEditMode color: isSelected && _isEditMode
? const Color(0xFF2E2E2E) ? const Color(0xFF2E2E2E)
: const Color(0xFF1E1E1E), : const Color(0xFF1E1E1E),
@@ -389,7 +459,9 @@ class HistoryScreenState extends State<HistoryScreen> {
} }
}, },
onLongPress: () { onLongPress: () {
if (!_isEditMode) setEditMode(true); if (!_isEditMode) {
setEditMode(true);
}
setState(() { setState(() {
final allIdsInGroup = final allIdsInGroup =
mergedRecord.records.map((r) => r.uniqueId).toSet(); mergedRecord.records.map((r) => r.uniqueId).toSet();
@@ -488,10 +560,8 @@ class HistoryScreenState extends State<HistoryScreen> {
TrainRecord record, TrainRecord latest, GroupBy groupBy) { TrainRecord record, TrainRecord latest, GroupBy groupBy) {
final train = record.train.trim(); final train = record.train.trim();
final loco = record.loco.trim(); final loco = record.loco.trim();
final locoType = record.locoType.trim();
final latestTrain = latest.train.trim(); final latestTrain = latest.train.trim();
final latestLoco = latest.loco.trim(); final latestLoco = latest.loco.trim();
final latestLocoType = latest.locoType.trim();
switch (groupBy) { switch (groupBy) {
case GroupBy.trainOnly: case GroupBy.trainOnly:
@@ -530,77 +600,14 @@ class HistoryScreenState extends State<HistoryScreen> {
} }
} }
double _calculateOptimalZoom(List<LatLng> positions,
{double containerWidth = 400, double containerHeight = 220}) {
if (positions.length == 1) return 17.0;
double minLat = positions[0].latitude;
double maxLat = positions[0].latitude;
double minLng = positions[0].longitude;
double maxLng = positions[0].longitude;
for (final pos in positions) {
minLat = math.min(minLat, pos.latitude);
maxLat = math.max(maxLat, pos.latitude);
minLng = math.min(minLng, pos.longitude);
maxLng = math.max(maxLng, pos.longitude);
}
double latToY(double lat) {
final latRad = lat * math.pi / 180.0;
return math.log(math.tan(latRad) + 1.0 / math.cos(latRad));
}
double lngToX(double lng) {
return lng * math.pi / 180.0;
}
final minX = lngToX(minLng);
final maxX = lngToX(maxLng);
final minY = latToY(minLat);
final maxY = latToY(maxLat);
const worldSize = 2.0 * math.pi;
final widthWorld = (maxX - minX) / worldSize;
final heightWorld = (maxY - minY) / worldSize;
const paddingRatio = 0.8;
final widthZoom =
math.log((containerWidth * paddingRatio) / (widthWorld * 256.0)) /
math.log(2.0);
final heightZoom =
math.log((containerHeight * paddingRatio) / (heightWorld * 256.0)) /
math.log(2.0);
final optimalZoom = math.min(widthZoom, heightZoom);
return math.max(1.0, math.min(20.0, optimalZoom));
}
double _calculateDistance(LatLng pos1, LatLng pos2) {
const earthRadius = 6371000;
final lat1 = pos1.latitude * math.pi / 180;
final lat2 = pos2.latitude * math.pi / 180;
final deltaLat = (pos2.latitude - pos1.latitude) * math.pi / 180;
final deltaLng = (pos2.longitude - pos1.longitude) * math.pi / 180;
final a = math.sin(deltaLat / 2) * math.sin(deltaLat / 2) +
math.cos(lat1) *
math.cos(lat2) *
math.sin(deltaLng / 2) *
math.sin(deltaLng / 2);
final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
return earthRadius * c;
}
String _getLocationInfo(TrainRecord record) { String _getLocationInfo(TrainRecord record) {
List<String> parts = []; List<String> parts = [];
if (record.route.isNotEmpty && record.route != "<NUL>") if (record.route.isNotEmpty && record.route != "<NUL>") {
parts.add(record.route); parts.add(record.route);
if (record.direction != 0) parts.add(record.direction == 1 ? "" : ""); }
if (record.direction != 0) {
parts.add(record.direction == 1 ? "" : "");
}
if (record.position.isNotEmpty && record.position != "<NUL>") { if (record.position.isNotEmpty && record.position != "<NUL>") {
final position = record.position; final position = record.position;
final cleanPosition = position.endsWith('.') final cleanPosition = position.endsWith('.')
@@ -616,7 +623,9 @@ class HistoryScreenState extends State<HistoryScreen> {
.map((record) => _parsePosition(record.positionInfo)) .map((record) => _parsePosition(record.positionInfo))
.whereType<LatLng>() .whereType<LatLng>()
.toList(); .toList();
if (positions.isEmpty) return const SizedBox.shrink(); if (positions.isEmpty) {
return const SizedBox.shrink();
}
final mapId = records.map((r) => r.uniqueId).join('_'); final mapId = records.map((r) => r.uniqueId).join('_');
final bounds = LatLngBounds.fromPoints(positions); final bounds = LatLngBounds.fromPoints(positions);
@@ -674,12 +683,6 @@ class HistoryScreenState extends State<HistoryScreen> {
]); ]);
} }
double _getDefaultZoom(List<LatLng> positions) {
if (positions.length == 1) return 15.0;
if (positions.length < 10) return 12.0;
return 10.0;
}
Future<void> _requestLocationPermission() async { Future<void> _requestLocationPermission() async {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) { if (!serviceEnabled) {
@@ -695,23 +698,30 @@ class HistoryScreenState extends State<HistoryScreen> {
return; return;
} }
setState(() { if (mounted) {
_isLocationPermissionGranted = true; setState(() {
}); _isLocationPermissionGranted = true;
});
}
_getCurrentLocation(); _getCurrentLocation();
} }
Future<void> _getCurrentLocation() async { Future<void> _getCurrentLocation() async {
try { try {
final locationSettings = AndroidSettings(
accuracy: LocationAccuracy.high,
forceLocationManager: true,
);
Position position = await Geolocator.getCurrentPosition( Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high, locationSettings: locationSettings,
forceAndroidLocationManager: true,
); );
setState(() { if (mounted) {
_currentUserLocation = LatLng(position.latitude, position.longitude); setState(() {
}); _currentUserLocation = LatLng(position.latitude, position.longitude);
});
}
} catch (e) {} } catch (e) {}
} }
@@ -728,13 +738,9 @@ class HistoryScreenState extends State<HistoryScreen> {
Widget _buildRecordCard(TrainRecord record, Widget _buildRecordCard(TrainRecord record,
{bool isSubCard = false, Key? key}) { {bool isSubCard = false, Key? key}) {
final isSelected = _selectedRecords.contains(record.uniqueId); final isSelected = _selectedRecords.contains(record.uniqueId);
final isExpanded =
!isSubCard && (_expandedStates[record.uniqueId] ?? false);
final GlobalKey itemKey = GlobalKey(); return Card(
key: key,
final Widget card = Card(
key: key ?? itemKey,
color: isSelected && _isEditMode color: isSelected && _isEditMode
? const Color(0xFF2E2E2E) ? const Color(0xFF2E2E2E)
: const Color(0xFF1E1E1E), : const Color(0xFF1E1E1E),
@@ -759,10 +765,17 @@ class HistoryScreenState extends State<HistoryScreen> {
} }
widget.onSelectionChanged(); widget.onSelectionChanged();
}); });
} else {
setState(() {
_expandedStates[record.uniqueId] =
!(_expandedStates[record.uniqueId] ?? false);
});
} }
}, },
onLongPress: () { onLongPress: () {
if (!_isEditMode) setEditMode(true); if (!_isEditMode) {
setEditMode(true);
}
setState(() { setState(() {
_selectedRecords.add(record.uniqueId); _selectedRecords.add(record.uniqueId);
widget.onSelectionChanged(); widget.onSelectionChanged();
@@ -776,23 +789,9 @@ class HistoryScreenState extends State<HistoryScreen> {
_buildRecordHeader(record), _buildRecordHeader(record),
_buildPositionAndSpeed(record), _buildPositionAndSpeed(record),
_buildLocoInfo(record), _buildLocoInfo(record),
if (isExpanded) _buildExpandedContent(record), if (_expandedStates[record.uniqueId] ?? false)
_buildExpandedContent(record),
])))); ]))));
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_itemHeightCache <= 0 && itemKey.currentContext != null) {
final RenderBox renderBox =
itemKey.currentContext!.findRenderObject() as RenderBox;
final double realHeight = renderBox.size.height;
if (realHeight > 0) {
setState(() {
_itemHeightCache = realHeight;
});
}
}
});
return card;
} }
Widget _buildRecordHeader(TrainRecord record, {bool isMerged = false}) { Widget _buildRecordHeader(TrainRecord record, {bool isMerged = false}) {
@@ -823,6 +822,7 @@ class HistoryScreenState extends State<HistoryScreen> {
final hasLocoInfo = final hasLocoInfo =
formattedLocoInfo.isNotEmpty && formattedLocoInfo != "<NUL>"; formattedLocoInfo.isNotEmpty && formattedLocoInfo != "<NUL>";
final shouldShowTrainRow = hasTrainNumber || hasDirection || hasLocoInfo; final shouldShowTrainRow = hasTrainNumber || hasDirection || hasLocoInfo;
final hasPosition = _parsePosition(record.positionInfo) != null;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
@@ -876,7 +876,8 @@ class HistoryScreenState extends State<HistoryScreen> {
])), ])),
if (hasLocoInfo) if (hasLocoInfo)
Text(formattedLocoInfo, Text(formattedLocoInfo,
style: const TextStyle(fontSize: 14, color: Colors.white70)) style:
const TextStyle(fontSize: 14, color: Colors.white70)),
]), ]),
const SizedBox(height: 2) const SizedBox(height: 2)
] ]
@@ -885,7 +886,9 @@ class HistoryScreenState extends State<HistoryScreen> {
Widget _buildLocoInfo(TrainRecord record) { Widget _buildLocoInfo(TrainRecord record) {
final locoInfo = record.locoInfo; final locoInfo = record.locoInfo;
if (locoInfo == null || locoInfo.isEmpty) return const SizedBox.shrink(); if (locoInfo == null || locoInfo.isEmpty) {
return const SizedBox.shrink();
}
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const SizedBox(height: 4), const SizedBox(height: 4),
Text(locoInfo, Text(locoInfo,
@@ -910,8 +913,9 @@ class HistoryScreenState extends State<HistoryScreen> {
.every((r) => r == '*'.runes.first || r == '-'.runes.first) && .every((r) => r == '*'.runes.first || r == '-'.runes.first) &&
speed != "NUL" && speed != "NUL" &&
speed != "<NUL>"; speed != "<NUL>";
if (!isValidRoute && !isValidPosition && !isValidSpeed) if (!isValidRoute && !isValidPosition && !isValidSpeed) {
return const SizedBox.shrink(); return const SizedBox.shrink();
}
return Padding( return Padding(
padding: const EdgeInsets.only(top: 4.0), padding: const EdgeInsets.only(top: 4.0),
child: child:
@@ -1002,8 +1006,11 @@ class HistoryScreenState extends State<HistoryScreen> {
} }
LatLng? _parsePosition(String? positionInfo) { LatLng? _parsePosition(String? positionInfo) {
if (positionInfo == null || positionInfo.isEmpty || positionInfo == '<NUL>') if (positionInfo == null ||
positionInfo.isEmpty ||
positionInfo == '<NUL>') {
return null; return null;
}
try { try {
final parts = positionInfo.trim().split(RegExp(r'\s+')); final parts = positionInfo.trim().split(RegExp(r'\s+'));
if (parts.length >= 2) { if (parts.length >= 2) {
@@ -1022,14 +1029,22 @@ class HistoryScreenState extends State<HistoryScreen> {
double? _parseDmsCoordinate(String dmsStr) { double? _parseDmsCoordinate(String dmsStr) {
try { try {
final degreeIndex = dmsStr.indexOf('°'); final degreeIndex = dmsStr.indexOf('°');
if (degreeIndex == -1) return null; if (degreeIndex == -1) {
return null;
}
final degrees = double.tryParse(dmsStr.substring(0, degreeIndex)); final degrees = double.tryParse(dmsStr.substring(0, degreeIndex));
if (degrees == null) return null; if (degrees == null) {
return null;
}
final minuteIndex = dmsStr.indexOf(''); final minuteIndex = dmsStr.indexOf('');
if (minuteIndex == -1) return degrees; if (minuteIndex == -1) {
return degrees;
}
final minutes = final minutes =
double.tryParse(dmsStr.substring(degreeIndex + 1, minuteIndex)); double.tryParse(dmsStr.substring(degreeIndex + 1, minuteIndex));
if (minutes == null) return degrees; if (minutes == null) {
return degrees;
}
return degrees + (minutes / 60.0); return degrees + (minutes / 60.0);
} catch (e) { } catch (e) {
return null; return null;
@@ -1140,12 +1155,12 @@ class _DelayedMapWithMarker extends StatefulWidget {
final LatLng? currentUserLocation; final LatLng? currentUserLocation;
const _DelayedMapWithMarker({ const _DelayedMapWithMarker({
Key? key, super.key,
required this.position, required this.position,
required this.zoom, required this.zoom,
required this.recordId, required this.recordId,
this.currentUserLocation, this.currentUserLocation,
}) : super(key: key); });
@override @override
State<_DelayedMapWithMarker> createState() => _DelayedMapWithMarkerState(); State<_DelayedMapWithMarker> createState() => _DelayedMapWithMarkerState();
@@ -1175,13 +1190,17 @@ class _DelayedMapWithMarkerState extends State<_DelayedMapWithMarker> {
_mapController.rotate(savedState.bearing); _mapController.rotate(savedState.bearing);
} }
} }
setState(() { if (mounted) {
_isInitializing = false; setState(() {
}); _isInitializing = false;
});
}
} }
void _onCameraMove() { void _onCameraMove() {
if (_isInitializing) return; if (_isInitializing) {
return;
}
final camera = _mapController.camera; final camera = _mapController.camera;
final state = MapState( final state = MapState(
@@ -1280,13 +1299,13 @@ class _DelayedMultiMarkerMap extends StatefulWidget {
final LatLng? currentUserLocation; final LatLng? currentUserLocation;
const _DelayedMultiMarkerMap({ const _DelayedMultiMarkerMap({
Key? key, super.key,
required this.positions, required this.positions,
required this.center, required this.center,
required this.zoom, required this.zoom,
required this.groupKey, required this.groupKey,
this.currentUserLocation, this.currentUserLocation,
}) : super(key: key); });
@override @override
State<_DelayedMultiMarkerMap> createState() => _DelayedMultiMarkerMapState(); State<_DelayedMultiMarkerMap> createState() => _DelayedMultiMarkerMapState();
@@ -1318,13 +1337,17 @@ class _DelayedMultiMarkerMapState extends State<_DelayedMultiMarkerMap> {
} else if (mounted) { } else if (mounted) {
_mapController.move(widget.center, widget.zoom); _mapController.move(widget.center, widget.zoom);
} }
setState(() { if (mounted) {
_isInitializing = false; setState(() {
}); _isInitializing = false;
});
}
} }
void _onCameraMove() { void _onCameraMove() {
if (_isInitializing) return; if (_isInitializing) {
return;
}
final camera = _mapController.camera; final camera = _mapController.camera;
final state = MapState( final state = MapState(
@@ -1352,7 +1375,7 @@ class _DelayedMultiMarkerMapState extends State<_DelayedMultiMarkerMap> {
height: 24, height: 24,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red.withOpacity(0.8), color: Colors.red.withAlpha((255 * 0.8).round()),
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5)), border: Border.all(color: Colors.white, width: 1.5)),
child: const Icon(Icons.train, color: Colors.white, size: 12)))), child: const Icon(Icons.train, color: Colors.white, size: 12)))),

View File

@@ -7,6 +7,7 @@ import 'package:lbjconsole/models/train_record.dart';
import 'package:lbjconsole/screens/history_screen.dart'; import 'package:lbjconsole/screens/history_screen.dart';
import 'package:lbjconsole/screens/map_screen.dart'; import 'package:lbjconsole/screens/map_screen.dart';
import 'package:lbjconsole/screens/map_webview_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/screens/settings_screen.dart';
import 'package:lbjconsole/services/ble_service.dart'; import 'package:lbjconsole/services/ble_service.dart';
import 'package:lbjconsole/services/database_service.dart'; import 'package:lbjconsole/services/database_service.dart';
@@ -183,10 +184,13 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
StreamSubscription? _connectionSubscription; StreamSubscription? _connectionSubscription;
StreamSubscription? _dataSubscription; StreamSubscription? _dataSubscription;
StreamSubscription? _lastReceivedTimeSubscription; StreamSubscription? _lastReceivedTimeSubscription;
StreamSubscription? _settingsSubscription;
DateTime? _lastReceivedTime; DateTime? _lastReceivedTime;
bool _isHistoryEditMode = false; bool _isHistoryEditMode = false;
final GlobalKey<HistoryScreenState> _historyScreenKey = final GlobalKey<HistoryScreenState> _historyScreenKey =
GlobalKey<HistoryScreenState>(); GlobalKey<HistoryScreenState>();
final GlobalKey<RealtimeScreenState> _realtimeScreenKey =
GlobalKey<RealtimeScreenState>();
@override @override
void initState() { void initState() {
@@ -197,6 +201,7 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
_initializeServices(); _initializeServices();
_checkAndStartBackgroundService(); _checkAndStartBackgroundService();
_setupLastReceivedTimeListener(); _setupLastReceivedTimeListener();
_setupSettingsListener();
_loadMapType(); _loadMapType();
} }
@@ -209,7 +214,6 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
} }
} }
Future<void> _checkAndStartBackgroundService() async { Future<void> _checkAndStartBackgroundService() async {
final settings = await DatabaseService.instance.getAllSettings() ?? {}; final settings = await DatabaseService.instance.getAllSettings() ?? {};
final backgroundServiceEnabled = final backgroundServiceEnabled =
@@ -231,11 +235,21 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
}); });
} }
void _setupSettingsListener() {
_settingsSubscription =
DatabaseService.instance.onSettingsChanged((settings) {
if (mounted && _currentIndex == 1) {
_realtimeScreenKey.currentState?.loadRecords(scrollToTop: false);
}
});
}
@override @override
void dispose() { void dispose() {
_connectionSubscription?.cancel(); _connectionSubscription?.cancel();
_dataSubscription?.cancel(); _dataSubscription?.cancel();
_lastReceivedTimeSubscription?.cancel(); _lastReceivedTimeSubscription?.cancel();
_settingsSubscription?.cancel();
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();
} }
@@ -256,6 +270,9 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
if (_historyScreenKey.currentState != null) { if (_historyScreenKey.currentState != null) {
_historyScreenKey.currentState!.addNewRecord(record); _historyScreenKey.currentState!.addNewRecord(record);
} }
if (_realtimeScreenKey.currentState != null) {
_realtimeScreenKey.currentState!.addNewRecord(record);
}
}); });
} }
@@ -302,7 +319,7 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
backgroundColor: AppTheme.primaryBlack, backgroundColor: AppTheme.primaryBlack,
elevation: 0, elevation: 0,
title: Text( title: Text(
['列车记录', '位置地图', '设置'][_currentIndex], ['列车记录', '数据监控', '位置地图', '设置'][_currentIndex],
style: const TextStyle( style: const TextStyle(
color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold), color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
), ),
@@ -394,6 +411,9 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
onEditModeChanged: _handleHistoryEditModeChanged, onEditModeChanged: _handleHistoryEditModeChanged,
onSelectionChanged: _handleSelectionChanged, onSelectionChanged: _handleSelectionChanged,
), ),
RealtimeScreen(
key: _realtimeScreenKey,
),
_mapType == 'map' ? const MapScreen() : const MapWebViewScreen(), _mapType == 'map' ? const MapScreen() : const MapWebViewScreen(),
SettingsScreen( SettingsScreen(
onSettingsChanged: () { onSettingsChanged: () {
@@ -411,14 +431,16 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
), ),
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(
backgroundColor: AppTheme.secondaryBlack, backgroundColor: AppTheme.secondaryBlack,
indicatorColor: AppTheme.accentBlue.withOpacity(0.2), indicatorColor: AppTheme.accentBlue.withValues(alpha: 0.2),
selectedIndex: _currentIndex, selectedIndex: _currentIndex,
onDestinationSelected: (index) { onDestinationSelected: (index) {
if (_currentIndex == 2 && index == 0) { if (index == 0) {
_historyScreenKey.currentState?.reloadRecords(); _historyScreenKey.currentState?.reloadRecords();
} }
// 如果从设置页面切换到地图页面,重新加载地图类型 if (index == 1) {
if (_currentIndex == 2 && index == 1) { _realtimeScreenKey.currentState?.loadRecords(scrollToTop: false);
}
if (_currentIndex == 3 && index == 2) {
_loadMapType(); _loadMapType();
} }
setState(() { setState(() {
@@ -429,6 +451,7 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
destinations: const [ destinations: const [
NavigationDestination( NavigationDestination(
icon: Icon(Icons.directions_railway), label: '列车记录'), icon: Icon(Icons.directions_railway), label: '列车记录'),
NavigationDestination(icon: Icon(Icons.speed), label: '数据监控'),
NavigationDestination(icon: Icon(Icons.location_on), label: '位置地图'), NavigationDestination(icon: Icon(Icons.location_on), label: '位置地图'),
NavigationDestination(icon: Icon(Icons.settings), label: '设置'), NavigationDestination(icon: Icon(Icons.settings), label: '设置'),
], ],

File diff suppressed because it is too large Load Diff

View File

@@ -232,16 +232,28 @@ class DatabaseService {
Future<int> deleteRecord(String uniqueId) async { Future<int> deleteRecord(String uniqueId) async {
final db = await database; final db = await database;
return await db.delete( final result = await db.delete(
trainRecordsTable, trainRecordsTable,
where: 'uniqueId = ?', where: 'uniqueId = ?',
whereArgs: [uniqueId], whereArgs: [uniqueId],
); );
if (result > 0) {
_notifyRecordDeleted([uniqueId]);
}
return result;
} }
Future<int> deleteAllRecords() async { Future<int> deleteAllRecords() async {
final db = await database; final db = await database;
return await db.delete(trainRecordsTable); final result = await db.delete(trainRecordsTable);
if (result > 0) {
_notifyRecordDeleted([]);
}
return result;
} }
Future<int> getRecordCount() async { Future<int> getRecordCount() async {
@@ -279,20 +291,31 @@ class DatabaseService {
Future<int> updateSettings(Map<String, dynamic> settings) async { Future<int> updateSettings(Map<String, dynamic> settings) async {
final db = await database; final db = await database;
return await db.update( final result = await db.update(
appSettingsTable, appSettingsTable,
settings, settings,
where: 'id = 1', where: 'id = 1',
); );
if (result > 0) {
_notifySettingsChanged(settings);
}
return result;
} }
Future<int> setSetting(String key, dynamic value) async { Future<int> setSetting(String key, dynamic value) async {
final db = await database; final db = await database;
return await db.update( final result = await db.update(
appSettingsTable, appSettingsTable,
{key: value}, {key: value},
where: 'id = 1', where: 'id = 1',
); );
if (result > 0) {
final currentSettings = await getAllSettings();
if (currentSettings != null) {
_notifySettingsChanged(currentSettings);
}
}
return result;
} }
Future<List<String>> getSearchOrderList() async { Future<List<String>> getSearchOrderList() async {
@@ -349,6 +372,42 @@ class DatabaseService {
whereArgs: [id], whereArgs: [id],
); );
} }
_notifyRecordDeleted(uniqueIds);
}
final List<Function(List<String>)> _recordDeleteListeners = [];
final List<Function(Map<String, dynamic>)> _settingsListeners = [];
StreamSubscription<void> onRecordDeleted(Function(List<String>) listener) {
_recordDeleteListeners.add(listener);
return Stream.value(null).listen((_) {})
..onData((_) {})
..onDone(() {
_recordDeleteListeners.remove(listener);
});
}
void _notifyRecordDeleted(List<String> deletedIds) {
for (final listener in _recordDeleteListeners) {
listener(deletedIds);
}
}
StreamSubscription<void> onSettingsChanged(
Function(Map<String, dynamic>) listener) {
_settingsListeners.add(listener);
return Stream.value(null).listen((_) {})
..onData((_) {})
..onDone(() {
_settingsListeners.remove(listener);
});
}
void _notifySettingsChanged(Map<String, dynamic> settings) {
for (final listener in _settingsListeners) {
listener(settings);
}
} }
Future<void> close() async { Future<void> close() async {
@@ -404,6 +463,11 @@ class DatabaseService {
} }
}); });
final currentSettings = await getAllSettings();
if (currentSettings != null) {
_notifySettingsChanged(currentSettings);
}
return true; return true;
} catch (e) { } catch (e) {
return false; return false;

View File

@@ -2,7 +2,7 @@ name: lbjconsole
description: "LBJ Console" description: "LBJ Console"
# The following line prevents the package from being accidentally published to # The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages. # pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev publish_to: "none" # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application. # The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43 # A version number is three numbers separated by dots, like 1.2.43
@@ -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 # 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 # 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. # of the product and file versions while build-number is used as the build suffix.
version: 0.5.1-flutter+51 version: 0.6.1-flutter+61
environment: environment:
sdk: ^3.5.4 sdk: ^3.5.4
@@ -76,7 +76,6 @@ dev_dependencies:
# The following section is specific to Flutter packages. # The following section is specific to Flutter packages.
flutter: flutter:
# The following line ensures that the Material Icons font is # The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in # included with your application, so that you can use the icons in
# the material Icons class. # the material Icons class.