6 Commits

Author SHA1 Message Date
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
Nedifinita
cc2a495984 feat: improve map initialization process and communication 2025-09-29 20:17:05 +08:00
Nedifinita
6718ef7129 feat: add vector railway map 2025-09-29 18:44:15 +08:00
15 changed files with 16745 additions and 265 deletions

1
.gitattributes vendored Normal file
View File

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

View File

@@ -26,7 +26,7 @@ if (flutterVersionName == null) {
android { android {
namespace = "org.noxylva.lbjconsole.flutter" namespace = "org.noxylva.lbjconsole.flutter"
compileSdk = 36 compileSdk = 36
ndkVersion = "26.1.10909125" ndkVersion = "28.1.13356709"
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11

15050
assets/mapbox_map.html vendored Normal file

File diff suppressed because it is too large Load Diff

3
devtools_options.yaml Normal file
View File

@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@@ -18,11 +18,12 @@ class MergeSettings {
final bool enabled; final bool enabled;
final GroupBy groupBy; final GroupBy groupBy;
final TimeWindow timeWindow; final TimeWindow timeWindow;
final bool hideUngroupableRecords;
MergeSettings({ MergeSettings({
this.enabled = true, this.enabled = true,
this.groupBy = GroupBy.trainAndLoco, this.groupBy = GroupBy.trainAndLoco,
this.timeWindow = TimeWindow.unlimited, this.timeWindow = TimeWindow.unlimited,
this.hideUngroupableRecords = false,
}); });
factory MergeSettings.fromMap(Map<String, dynamic> map) { factory MergeSettings.fromMap(Map<String, dynamic> map) {
@@ -36,6 +37,7 @@ class MergeSettings {
(e) => e.name == map['timeWindow'], (e) => e.name == map['timeWindow'],
orElse: () => TimeWindow.unlimited, orElse: () => TimeWindow.unlimited,
), ),
hideUngroupableRecords: (map['hideUngroupableRecords'] ?? 0) == 1,
); );
} }
} }

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,132 @@ 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(() {
_displayItems.clear(); bool isMerge = false;
_displayItems.addAll(items); Object? mergeResult;
if (_displayItems.isNotEmpty) {
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;
}
}
if (isMerge) {
_displayItems[0] = mergeResult!;
} else {
_displayItems.insert(0, newRecord);
}
}); });
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 +358,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 +400,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 +444,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 +545,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 +585,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 +608,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 +668,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 +683,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) {}
} }
@@ -731,10 +726,8 @@ class HistoryScreenState extends State<HistoryScreen> {
final isExpanded = final isExpanded =
!isSubCard && (_expandedStates[record.uniqueId] ?? false); !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),
@@ -762,7 +755,9 @@ class HistoryScreenState extends State<HistoryScreen> {
} }
}, },
onLongPress: () { onLongPress: () {
if (!_isEditMode) setEditMode(true); if (!_isEditMode) {
setEditMode(true);
}
setState(() { setState(() {
_selectedRecords.add(record.uniqueId); _selectedRecords.add(record.uniqueId);
widget.onSelectionChanged(); widget.onSelectionChanged();
@@ -778,21 +773,6 @@ class HistoryScreenState extends State<HistoryScreen> {
_buildLocoInfo(record), _buildLocoInfo(record),
if (isExpanded) _buildExpandedContent(record), if (isExpanded) _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}) {
@@ -885,7 +865,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 +892,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 +985,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 +1008,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 +1134,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 +1169,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 +1278,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 +1316,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 +1354,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)))),
@@ -1383,7 +1385,7 @@ class _DelayedMultiMarkerMapState extends State<_DelayedMultiMarkerMap> {
return FlutterMap( return FlutterMap(
options: MapOptions( options: MapOptions(
onPositionChanged: (position, hasGesture) => _onCameraMove(), onPositionChanged: (position, hasGesture) => _onCameraMove(),
minZoom: 5, minZoom: 8,
maxZoom: 18, maxZoom: 18,
), ),
mapController: _mapController, mapController: _mapController,

View File

@@ -6,6 +6,7 @@ import 'package:lbjconsole/models/merged_record.dart';
import 'package:lbjconsole/models/train_record.dart'; 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/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';
@@ -174,6 +175,7 @@ class MainScreen extends StatefulWidget {
class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver { class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
int _currentIndex = 0; int _currentIndex = 0;
String _mapType = 'webview';
late final BLEService _bleService; late final BLEService _bleService;
final NotificationService _notificationService = NotificationService(); final NotificationService _notificationService = NotificationService();
@@ -195,8 +197,19 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
_initializeServices(); _initializeServices();
_checkAndStartBackgroundService(); _checkAndStartBackgroundService();
_setupLastReceivedTimeListener(); _setupLastReceivedTimeListener();
_loadMapType();
} }
Future<void> _loadMapType() async {
final settings = await DatabaseService.instance.getAllSettings() ?? {};
if (mounted) {
setState(() {
_mapType = settings['mapType']?.toString() ?? 'webview';
});
}
}
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,6 +244,7 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
_bleService.onAppResume(); _bleService.onAppResume();
_loadMapType();
} }
} }
@@ -380,8 +394,12 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
onEditModeChanged: _handleHistoryEditModeChanged, onEditModeChanged: _handleHistoryEditModeChanged,
onSelectionChanged: _handleSelectionChanged, onSelectionChanged: _handleSelectionChanged,
), ),
const MapScreen(), _mapType == 'map' ? const MapScreen() : const MapWebViewScreen(),
const SettingsScreen(), SettingsScreen(
onSettingsChanged: () {
_loadMapType();
},
),
]; ];
return Scaffold( return Scaffold(
@@ -399,6 +417,10 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
if (_currentIndex == 2 && index == 0) { if (_currentIndex == 2 && index == 0) {
_historyScreenKey.currentState?.reloadRecords(); _historyScreenKey.currentState?.reloadRecords();
} }
// 如果从设置页面切换到地图页面,重新加载地图类型
if (_currentIndex == 2 && index == 1) {
_loadMapType();
}
setState(() { setState(() {
if (_isHistoryEditMode) _isHistoryEditMode = false; if (_isHistoryEditMode) _isHistoryEditMode = false;
_currentIndex = index; _currentIndex = index;

View File

@@ -22,7 +22,7 @@ class _MapScreenState extends State<MapScreen> {
LatLng? _currentLocation; LatLng? _currentLocation;
LatLng? _lastTrainLocation; LatLng? _lastTrainLocation;
LatLng? _userLocation; LatLng? _userLocation;
double _currentZoom = 12.0; double _currentZoom = 14.0;
double _currentRotation = 0.0; double _currentRotation = 0.0;
bool _isMapInitialized = false; bool _isMapInitialized = false;
@@ -51,10 +51,19 @@ class _MapScreenState extends State<MapScreen> {
_loadSettings().then((_) { _loadSettings().then((_) {
_loadTrainRecords().then((_) { _loadTrainRecords().then((_) {
_startLocationUpdates(); _startLocationUpdates();
if (!_isMapInitialized && (_currentLocation != null || _lastTrainLocation != null || _userLocation != null)) {
_initializeMapPosition();
}
}); });
}); });
} }
@override
void didChangeDependencies() {
super.didChangeDependencies();
_loadSettings();
}
Future<void> _checkDatabaseSettings() async { Future<void> _checkDatabaseSettings() async {
try { try {
final dbInfo = await DatabaseService.instance.getDatabaseInfo(); final dbInfo = await DatabaseService.instance.getDatabaseInfo();
@@ -227,6 +236,8 @@ class _MapScreenState extends State<MapScreen> {
settings['mapCenterLon'] = center.longitude; settings['mapCenterLon'] = center.longitude;
} }
settings['mapSettingsTimestamp'] = DateTime.now().millisecondsSinceEpoch;
await DatabaseService.instance.updateSettings(settings); await DatabaseService.instance.updateSettings(settings);
} catch (e) {} } catch (e) {}
} }
@@ -734,11 +745,31 @@ class _MapScreenState extends State<MapScreen> {
); );
} }
final bool isDefaultLocation = _currentLocation == null &&
_lastTrainLocation == null &&
_userLocation == null;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFF121212), backgroundColor: const Color(0xFF121212),
body: Stack( body: Stack(
children: [ children: [
FlutterMap( if (isDefaultLocation)
const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF007ACC)),
),
SizedBox(height: 16),
Text(
'正在加载地图位置...',
style: TextStyle(color: Colors.white, fontSize: 16),
),
],
),
)
else FlutterMap(
mapController: _mapController, mapController: _mapController,
options: MapOptions( options: MapOptions(
initialCenter: _currentLocation ?? initialCenter: _currentLocation ??
@@ -747,7 +778,7 @@ class _MapScreenState extends State<MapScreen> {
const LatLng(39.9042, 116.4074), const LatLng(39.9042, 116.4074),
initialZoom: _currentZoom, initialZoom: _currentZoom,
initialRotation: _currentRotation, initialRotation: _currentRotation,
minZoom: 4.0, minZoom: 8.0,
maxZoom: 18.0, maxZoom: 18.0,
onPositionChanged: (MapCamera camera, bool hasGesture) { onPositionChanged: (MapCamera camera, bool hasGesture) {
setState(() { setState(() {

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,9 @@ import 'package:share_plus/share_plus.dart';
import 'package:cross_file/cross_file.dart'; import 'package:cross_file/cross_file.dart';
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key}); final VoidCallback? onSettingsChanged;
const SettingsScreen({super.key, this.onSettingsChanged});
@override @override
State<SettingsScreen> createState() => _SettingsScreenState(); State<SettingsScreen> createState() => _SettingsScreenState();
@@ -33,8 +35,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
int _recordCount = 0; int _recordCount = 0;
bool _mergeRecordsEnabled = false; bool _mergeRecordsEnabled = false;
bool _hideTimeOnlyRecords = false; bool _hideTimeOnlyRecords = false;
bool _hideUngroupableRecords = false;
GroupBy _groupBy = GroupBy.trainAndLoco; GroupBy _groupBy = GroupBy.trainAndLoco;
TimeWindow _timeWindow = TimeWindow.unlimited; TimeWindow _timeWindow = TimeWindow.unlimited;
String _mapType = 'map';
@override @override
void initState() { void initState() {
@@ -63,8 +67,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
_notificationsEnabled = (settingsMap['notificationEnabled'] ?? 1) == 1; _notificationsEnabled = (settingsMap['notificationEnabled'] ?? 1) == 1;
_mergeRecordsEnabled = settings.enabled; _mergeRecordsEnabled = settings.enabled;
_hideTimeOnlyRecords = (settingsMap['hideTimeOnlyRecords'] ?? 0) == 1; _hideTimeOnlyRecords = (settingsMap['hideTimeOnlyRecords'] ?? 0) == 1;
_hideUngroupableRecords = settings.hideUngroupableRecords;
_groupBy = settings.groupBy; _groupBy = settings.groupBy;
_timeWindow = settings.timeWindow; _timeWindow = settings.timeWindow;
_mapType = settingsMap['mapType']?.toString() ?? 'webview';
}); });
} }
} }
@@ -85,9 +91,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
'notificationEnabled': _notificationsEnabled ? 1 : 0, 'notificationEnabled': _notificationsEnabled ? 1 : 0,
'mergeRecordsEnabled': _mergeRecordsEnabled ? 1 : 0, 'mergeRecordsEnabled': _mergeRecordsEnabled ? 1 : 0,
'hideTimeOnlyRecords': _hideTimeOnlyRecords ? 1 : 0, 'hideTimeOnlyRecords': _hideTimeOnlyRecords ? 1 : 0,
'hideUngroupableRecords': _hideUngroupableRecords ? 1 : 0,
'groupBy': _groupBy.name, 'groupBy': _groupBy.name,
'timeWindow': _timeWindow.name, 'timeWindow': _timeWindow.name,
'mapType': _mapType,
}); });
widget.onSettingsChanged?.call();
} }
@override @override
@@ -240,6 +249,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('地图显示方式', style: AppTheme.bodyLarge),
Text('选择地图组件类型', style: AppTheme.caption),
],
),
DropdownButton<String>(
value: _mapType,
items: [
DropdownMenuItem(
value: 'webview',
child: Text('矢量铁路地图', style: AppTheme.bodyMedium),
),
DropdownMenuItem(
value: 'map',
child: Text('栅格铁路地图', style: AppTheme.bodyMedium),
),
],
onChanged: (value) {
if (value != null) {
setState(() {
_mapType = value;
});
_saveSettings();
}
},
dropdownColor: AppTheme.secondaryBlack,
style: AppTheme.bodyMedium,
underline: Container(height: 0),
),
],
),
const SizedBox(height: 16),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -398,6 +444,29 @@ class _SettingsScreenState extends State<SettingsScreen> {
dropdownColor: AppTheme.secondaryBlack, dropdownColor: AppTheme.secondaryBlack,
style: AppTheme.bodyMedium, style: AppTheme.bodyMedium,
), ),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('隐藏不可分组记录', style: AppTheme.bodyLarge),
Text('不显示无法分组的记录', style: AppTheme.caption),
],
),
Switch(
value: _hideUngroupableRecords,
onChanged: (value) {
setState(() {
_hideUngroupableRecords = value;
});
_saveSettings();
},
activeColor: Theme.of(context).colorScheme.primary,
),
],
),
], ],
), ),
), ),
@@ -421,7 +490,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [ children: [
Row( Row(
children: [ children: [
Icon(Icons.storage, color: Theme.of(context).colorScheme.primary), Icon(Icons.storage,
color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12), const SizedBox(width: 12),
Text('数据管理', style: AppTheme.titleMedium), Text('数据管理', style: AppTheme.titleMedium),
], ],

View File

@@ -13,7 +13,7 @@ class DatabaseService {
DatabaseService._internal(); DatabaseService._internal();
static const String _databaseName = 'train_database'; static const String _databaseName = 'train_database';
static const _databaseVersion = 4; static const _databaseVersion = 7;
static const String trainRecordsTable = 'train_records'; static const String trainRecordsTable = 'train_records';
static const String appSettingsTable = 'app_settings'; static const String appSettingsTable = 'app_settings';
@@ -21,21 +21,47 @@ class DatabaseService {
Database? _database; Database? _database;
Future<Database> get database async { Future<Database> get database async {
if (_database != null) return _database!; try {
_database = await _initDatabase(); if (_database != null) {
return _database!; return _database!;
}
_database = await _initDatabase();
return _database!;
} catch (e, stackTrace) {
rethrow;
}
}
Future<bool> isDatabaseConnected() async {
try {
if (_database == null) {
return false;
}
final db = await database;
final result = await db.rawQuery('SELECT 1');
return true;
} catch (e) {
return false;
}
} }
Future<Database> _initDatabase() async { Future<Database> _initDatabase() async {
final directory = await getApplicationDocumentsDirectory(); try {
final path = join(directory.path, _databaseName); final directory = await getApplicationDocumentsDirectory();
final path = join(directory.path, _databaseName);
return await openDatabase( final db = await openDatabase(
path, path,
version: _databaseVersion, version: _databaseVersion,
onCreate: _onCreate, onCreate: _onCreate,
onUpgrade: _onUpgrade, onUpgrade: _onUpgrade,
); );
return db;
} catch (e, stackTrace) {
rethrow;
}
} }
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async { Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
@@ -51,8 +77,19 @@ class DatabaseService {
try { try {
await db.execute( await db.execute(
'ALTER TABLE $appSettingsTable ADD COLUMN mapTimeFilter TEXT NOT NULL DEFAULT "unlimited"'); 'ALTER TABLE $appSettingsTable ADD COLUMN mapTimeFilter TEXT NOT NULL DEFAULT "unlimited"');
} catch (e) { } catch (e) {}
} }
if (oldVersion < 5) {
await db.execute(
'ALTER TABLE $appSettingsTable ADD COLUMN mapType TEXT NOT NULL DEFAULT "webview"');
}
if (oldVersion < 6) {
await db.execute(
'ALTER TABLE $appSettingsTable ADD COLUMN hideUngroupableRecords INTEGER NOT NULL DEFAULT 0');
}
if (oldVersion < 7) {
await db.execute(
'ALTER TABLE $appSettingsTable ADD COLUMN mapSettingsTimestamp INTEGER');
} }
} }
@@ -92,6 +129,7 @@ class DatabaseService {
mapZoomLevel REAL NOT NULL DEFAULT 10.0, mapZoomLevel REAL NOT NULL DEFAULT 10.0,
mapRailwayLayerVisible INTEGER NOT NULL DEFAULT 1, mapRailwayLayerVisible INTEGER NOT NULL DEFAULT 1,
mapRotation REAL NOT NULL DEFAULT 0.0, mapRotation REAL NOT NULL DEFAULT 0.0,
mapType TEXT NOT NULL DEFAULT 'webview',
specifiedDeviceAddress TEXT, specifiedDeviceAddress TEXT,
searchOrderList TEXT NOT NULL DEFAULT '', searchOrderList TEXT NOT NULL DEFAULT '',
autoConnectEnabled INTEGER NOT NULL DEFAULT 1, autoConnectEnabled INTEGER NOT NULL DEFAULT 1,
@@ -101,7 +139,9 @@ class DatabaseService {
hideTimeOnlyRecords INTEGER NOT NULL DEFAULT 0, hideTimeOnlyRecords INTEGER NOT NULL DEFAULT 0,
groupBy TEXT NOT NULL DEFAULT 'trainAndLoco', groupBy TEXT NOT NULL DEFAULT 'trainAndLoco',
timeWindow TEXT NOT NULL DEFAULT 'unlimited', timeWindow TEXT NOT NULL DEFAULT 'unlimited',
mapTimeFilter TEXT NOT NULL DEFAULT 'unlimited' mapTimeFilter TEXT NOT NULL DEFAULT 'unlimited',
hideUngroupableRecords INTEGER NOT NULL DEFAULT 0,
mapSettingsTimestamp INTEGER
) )
'''); ''');
@@ -118,6 +158,7 @@ class DatabaseService {
'mapZoomLevel': 10.0, 'mapZoomLevel': 10.0,
'mapRailwayLayerVisible': 1, 'mapRailwayLayerVisible': 1,
'mapRotation': 0.0, 'mapRotation': 0.0,
'mapType': 'webview',
'searchOrderList': '', 'searchOrderList': '',
'autoConnectEnabled': 1, 'autoConnectEnabled': 1,
'backgroundServiceEnabled': 0, 'backgroundServiceEnabled': 0,
@@ -127,6 +168,8 @@ class DatabaseService {
'groupBy': 'trainAndLoco', 'groupBy': 'trainAndLoco',
'timeWindow': 'unlimited', 'timeWindow': 'unlimited',
'mapTimeFilter': 'unlimited', 'mapTimeFilter': 'unlimited',
'hideUngroupableRecords': 0,
'mapSettingsTimestamp': null,
}); });
} }
@@ -140,12 +183,18 @@ class DatabaseService {
} }
Future<List<TrainRecord>> getAllRecords() async { Future<List<TrainRecord>> getAllRecords() async {
final db = await database; try {
final result = await db.query( final db = await database;
trainRecordsTable, final result = await db.query(
orderBy: 'timestamp DESC', trainRecordsTable,
); orderBy: 'timestamp DESC',
return result.map((json) => TrainRecord.fromDatabaseJson(json)).toList(); );
final records =
result.map((json) => TrainRecord.fromDatabaseJson(json)).toList();
return records;
} catch (e, stackTrace) {
rethrow;
}
} }
Future<List<TrainRecord>> getRecordsWithinTimeRange(Duration duration) async { Future<List<TrainRecord>> getRecordsWithinTimeRange(Duration duration) async {
@@ -162,15 +211,23 @@ class DatabaseService {
Future<List<TrainRecord>> getRecordsWithinReceivedTimeRange( Future<List<TrainRecord>> getRecordsWithinReceivedTimeRange(
Duration duration) async { Duration duration) async {
final db = await database; try {
final cutoffTime = DateTime.now().subtract(duration).millisecondsSinceEpoch; final db = await database;
final result = await db.query( final cutoffTime =
trainRecordsTable, DateTime.now().subtract(duration).millisecondsSinceEpoch;
where: 'receivedTimestamp >= ?',
whereArgs: [cutoffTime], final result = await db.query(
orderBy: 'receivedTimestamp DESC', trainRecordsTable,
); where: 'receivedTimestamp >= ?',
return result.map((json) => TrainRecord.fromDatabaseJson(json)).toList(); whereArgs: [cutoffTime],
orderBy: 'receivedTimestamp DESC',
);
final records =
result.map((json) => TrainRecord.fromDatabaseJson(json)).toList();
return records;
} catch (e, stackTrace) {
rethrow;
}
} }
Future<int> deleteRecord(String uniqueId) async { Future<int> deleteRecord(String uniqueId) async {

View File

@@ -2,6 +2,37 @@ import 'package:lbjconsole/models/train_record.dart';
import 'package:lbjconsole/models/merged_record.dart'; import 'package:lbjconsole/models/merged_record.dart';
class MergeService { class MergeService {
static bool isNeverGroupableRecord(TrainRecord record, GroupBy groupBy) {
final train = record.train.trim();
final loco = record.loco.trim();
final hasValidTrain =
train.isNotEmpty && train != "<NUL>" && !train.contains("-----");
final hasValidLoco = loco.isNotEmpty && loco != "<NUL>";
switch (groupBy) {
case GroupBy.trainOnly:
return !hasValidTrain;
case GroupBy.locoOnly:
return !hasValidLoco;
case GroupBy.trainAndLoco:
return !hasValidTrain || !hasValidLoco;
case GroupBy.trainOrLoco:
return !hasValidTrain && !hasValidLoco;
}
}
static List<TrainRecord> filterUngroupableRecords(
List<TrainRecord> records, GroupBy groupBy, bool hideUngroupable) {
if (!hideUngroupable) return records;
return records
.where((record) => !isNeverGroupableRecord(record, groupBy))
.toList();
}
static String? _generateGroupKey(TrainRecord record, GroupBy groupBy) { static String? _generateGroupKey(TrainRecord record, GroupBy groupBy) {
final train = record.train.trim(); final train = record.train.trim();
final loco = record.loco.trim(); final loco = record.loco.trim();
@@ -36,15 +67,19 @@ class MergeService {
return allRecords; return allRecords;
} }
allRecords final filteredRecords = filterUngroupableRecords(
allRecords, settings.groupBy, settings.hideUngroupableRecords);
filteredRecords
.sort((a, b) => b.receivedTimestamp.compareTo(a.receivedTimestamp)); .sort((a, b) => b.receivedTimestamp.compareTo(a.receivedTimestamp));
if (settings.groupBy == GroupBy.trainOrLoco) { if (settings.groupBy == GroupBy.trainOrLoco) {
return _groupByTrainOrLocoWithTimeWindow(allRecords, settings.timeWindow); return _groupByTrainOrLocoWithTimeWindow(
filteredRecords, settings.timeWindow);
} }
final groupedRecords = <String, List<TrainRecord>>{}; final groupedRecords = <String, List<TrainRecord>>{};
for (final record in allRecords) { for (final record in filteredRecords) {
final key = _generateGroupKey(record, settings.groupBy); final key = _generateGroupKey(record, settings.groupBy);
if (key != null) { if (key != null) {
groupedRecords.putIfAbsent(key, () => []).add(record); groupedRecords.putIfAbsent(key, () => []).add(record);
@@ -79,8 +114,9 @@ class MergeService {
final reusedRecords = _reuseDiscardedRecords( final reusedRecords = _reuseDiscardedRecords(
discardedRecords, mergedRecordIds, settings.groupBy); discardedRecords, mergedRecordIds, settings.groupBy);
final singleRecords = final singleRecords = filteredRecords
allRecords.where((r) => !mergedRecordIds.contains(r.uniqueId)).toList(); .where((r) => !mergedRecordIds.contains(r.uniqueId))
.toList();
final List<Object> mixedList = [...mergedRecords, ...singleRecords]; final List<Object> mixedList = [...mergedRecords, ...singleRecords];
mixedList.sort((a, b) { mixedList.sort((a, b) {
@@ -219,7 +255,6 @@ class MergeService {
latestRecord: processedGroup.first, latestRecord: processedGroup.first,
)); ));
} else { } else {
// 处理被丢弃的记录
for (final record in group) { for (final record in group) {
if (!processedGroup.contains(record)) { if (!processedGroup.contains(record)) {
singleRecords.add(record); singleRecords.add(record);

View File

@@ -15,6 +15,7 @@ import share_plus
import shared_preferences_foundation import shared_preferences_foundation
import sqflite_darwin import sqflite_darwin
import url_launcher_macos import url_launcher_macos
import webview_flutter_wkwebview
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
@@ -27,4 +28,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
} }

View File

@@ -233,6 +233,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.7.11" version: "0.7.11"
executor_lib:
dependency: transitive
description:
name: executor_lib
sha256: "95ddf2957d9942d9702855b38dd49677f0ee6a8b77d7b16c0e509c7669d17386"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.2"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -656,6 +664,30 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.3.0" version: "1.3.0"
maplibre_gl:
dependency: "direct main"
description:
name: maplibre_gl
sha256: "5c7b1008396b2a321bada7d986ed60f9423406fbc7bd16f7ce91b385dfa054cd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.22.0"
maplibre_gl_platform_interface:
dependency: transitive
description:
name: maplibre_gl_platform_interface
sha256: "08ee0a2d0853ea945a0ab619d52c0c714f43144145cd67478fc6880b52f37509"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.22.0"
maplibre_gl_web:
dependency: transitive
description:
name: maplibre_gl_web
sha256: "2b13d4b1955a9a54e38a718f2324e56e4983c080fc6de316f6f4b5458baacb58"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.22.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -896,6 +928,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
protobuf:
dependency: transitive
description:
name: protobuf
sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.0"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1261,6 +1301,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "4.5.1" version: "4.5.1"
vector_map_tiles:
dependency: "direct main"
description:
name: vector_map_tiles
sha256: "4dc9243195c1a49c7be82cc1caed0d300242bb94381752af5f6868d9d1404e25"
url: "https://pub.flutter-io.cn"
source: hosted
version: "8.0.0"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -1269,6 +1317,22 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
vector_tile:
dependency: transitive
description:
name: vector_tile
sha256: "7ae290246e3a8734422672dbe791d3f7b8ab631734489fc6d405f1cc2080e38c"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.1"
vector_tile_renderer:
dependency: transitive
description:
name: vector_tile_renderer
sha256: "89746f1108eccbc0b6f33fbbef3fcf394cda3733fc0d5064ea03d53a459b56d3"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.2.1"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
@@ -1309,6 +1373,38 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "3.0.3" version: "3.0.3"
webview_flutter:
dependency: "direct main"
description:
name: webview_flutter
sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.13.0"
webview_flutter_android:
dependency: transitive
description:
name: webview_flutter_android
sha256: "3c4eb4fcc252b40c2b5ce7be20d0481428b70f3ff589b0a8b8aaeb64c6bed701"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.10.2"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.14.0"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: fea63576b3b7e02b2df8b78ba92b48ed66caec2bb041e9a0b1cbd586d5d80bfd
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.23.1"
win32: win32:
dependency: transitive dependency: transitive
description: description:
@@ -1350,5 +1446,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.8.0-0 <4.0.0" dart: ">=3.9.0 <4.0.0"
flutter: ">=3.24.0" flutter: ">=3.35.0"

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.4.0-flutter+40 version: 0.5.2-flutter+52
environment: environment:
sdk: ^3.5.4 sdk: ^3.5.4
@@ -54,6 +54,9 @@ dependencies:
msix: ^3.16.12 msix: ^3.16.12
flutter_background_service: ^5.1.0 flutter_background_service: ^5.1.0
scrollview_observer: ^1.20.0 scrollview_observer: ^1.20.0
vector_map_tiles: ^8.0.0
maplibre_gl: ^0.22.0
webview_flutter: ^4.8.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -73,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.
@@ -84,6 +86,7 @@ flutter:
- assets/loco_info.csv - assets/loco_info.csv
- assets/train_number_info.csv - assets/train_number_info.csv
- assets/loco_type_info.csv - assets/loco_type_info.csv
- assets/mapbox_map.html
# An image asset can refer to one or more resolution-specific "variants", see # An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images # https://flutter.dev/to/resolution-aware-images