12 Commits

Author SHA1 Message Date
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
Nedifinita
bfd05bd249 feat: implement time window algorithm 2025-09-27 01:03:03 +08:00
Nedifinita
8d3366fbf9 refactor 2025-09-27 00:50:12 +08:00
Nedifinita
9b0e9dcacf fix: resolve issue with incorrect deletion of single record 2025-09-27 00:31:51 +08:00
Nedifinita
c3e97332fd feat: add map time filtering function and optimized location processing 2025-09-27 00:14:24 +08:00
Nedifinita
b1d8d5e029 feat: improve record grouping and display 2025-09-26 21:21:37 +08:00
Nedifinita
77501af2f5 fix: update icon 2025-09-26 21:02:14 +08:00
Nedifinita
64401a6ce9 feat: add the function to hide records that are only valid for time. 2025-09-25 22:52:19 +08:00
Nedifinita
72f9dfe17b fix: repair record processing logic 2025-09-25 22:25:51 +08:00
Nedifinita
bf850eed38 refactor: optimize record card rendering logic and animation effects 2025-09-25 22:01:25 +08:00
Nedifinita
56689fc993 feat: optimize record list 2025-09-25 21:45:52 +08:00
56 changed files with 17700 additions and 432 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#000000</color>
</resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 209 KiB

15050
assets/mapbox_map.html 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

@@ -427,7 +427,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@@ -484,7 +484,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";

View File

@@ -1,122 +1 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 872 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

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

View File

@@ -149,7 +149,7 @@ class TrainRecord {
final lbjClassValue = lbjClass.trim();
final trainValue = train.trim();
if (trainValue == "<NUL>") {
if (trainValue == "<NUL>" || trainValue.contains("-----")) {
return "";
}

View File

@@ -1,8 +1,13 @@
import 'dart:math' as math;
import 'dart:isolate';
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:geolocator/geolocator.dart';
import 'package:scrollview_observer/scrollview_observer.dart';
import '../models/merged_record.dart';
import '../services/database_service.dart';
import '../models/train_record.dart';
@@ -31,6 +36,9 @@ class HistoryScreenState extends State<HistoryScreen> {
final Set<String> _selectedRecords = {};
final Map<String, bool> _expandedStates = {};
final ScrollController _scrollController = ScrollController();
final ListObserverController _observerController =
ListObserverController(controller: null)..cacheJumpIndexOffset = false;
late final ChatScrollObserver _chatObserver;
bool _isAtTop = true;
MergeSettings _mergeSettings = MergeSettings();
double _itemHeightCache = 0.0;
@@ -38,6 +46,10 @@ class HistoryScreenState extends State<HistoryScreen> {
final Map<String, double> _mapOptimalZoom = {};
final Map<String, bool> _mapCalculating = {};
LatLng? _currentUserLocation;
bool _isLocationPermissionGranted = false;
Timer? _locationTimer;
int getSelectedCount() => _selectedRecords.length;
Set<String> getSelectedRecordIds() => _selectedRecords;
List<Object> getDisplayItems() => _displayItems;
@@ -53,9 +65,17 @@ class HistoryScreenState extends State<HistoryScreen> {
});
}
Future<void> reloadRecords() async {
await loadRecords(scrollToTop: false);
}
@override
void initState() {
super.initState();
_chatObserver = ChatScrollObserver(_observerController)
..toRebuildScrollViewCallback = () {
setState(() {});
};
_scrollController.addListener(() {
if (_scrollController.position.atEdge) {
if (_scrollController.position.pixels == 0) {
@@ -66,13 +86,18 @@ class HistoryScreenState extends State<HistoryScreen> {
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) loadRecords();
if (mounted) {
loadRecords();
_startLocationUpdates();
}
});
}
@override
void dispose() {
_scrollController.dispose();
_observerController.controller?.dispose();
_locationTimer?.cancel();
super.dispose();
}
@@ -81,7 +106,71 @@ class HistoryScreenState extends State<HistoryScreen> {
final allRecords = await DatabaseService.instance.getAllRecords();
final settingsMap = await DatabaseService.instance.getAllSettings() ?? {};
_mergeSettings = MergeSettings.fromMap(settingsMap);
final items = MergeService.getMixedList(allRecords, _mergeSettings);
List<TrainRecord> filteredRecords = allRecords;
if ((settingsMap['hideTimeOnlyRecords'] ?? 0) == 1) {
int hiddenCount = 0;
int shownCount = 0;
filteredRecords = allRecords.where((record) {
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(record.fullTrainNumber) &&
!record.fullTrainNumber.contains("-----");
final hasDirection = record.direction == 1 || record.direction == 3;
final hasLocoInfo = isFieldMeaningful(record.locoType) ||
isFieldMeaningful(record.loco);
final hasRoute = isFieldMeaningful(record.route);
final hasPosition = isFieldMeaningful(record.position);
final hasSpeed =
isFieldMeaningful(record.speed) && record.speed != "NUL";
final hasPositionInfo = isFieldMeaningful(record.positionInfo);
final hasTrainType =
isFieldMeaningful(record.trainType) && record.trainType != "未知";
final hasLbjClass =
isFieldMeaningful(record.lbjClass) && record.lbjClass != "NA";
final hasTrain = isFieldMeaningful(record.train) &&
!record.train.contains("-----");
final shouldShow = hasTrainNumber ||
hasDirection ||
hasLocoInfo ||
hasRoute ||
hasPosition ||
hasSpeed ||
hasPositionInfo ||
hasTrainType ||
hasLbjClass ||
hasTrain;
if (!shouldShow) {
hiddenCount++;
} else {
shownCount++;
}
return shouldShow;
}).toList();
}
final items = MergeService.getMixedList(filteredRecords, _mergeSettings);
if (mounted) {
final hasDataChanged = _hasDataChanged(items);
@@ -112,6 +201,58 @@ class HistoryScreenState extends State<HistoryScreen> {
final settingsMap = await DatabaseService.instance.getAllSettings() ?? {};
_mergeSettings = MergeSettings.fromMap(settingsMap);
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) {
if (item is TrainRecord) {
return item.uniqueId == newRecord.uniqueId;
@@ -123,44 +264,29 @@ class HistoryScreenState extends State<HistoryScreen> {
if (!isNewRecord) return;
final allRecords = await DatabaseService.instance.getAllRecords();
final items = MergeService.getMixedList(allRecords, _mergeSettings);
if (mounted) {
final previousScrollOffset = _scrollController.hasClients ? _scrollController.offset : 0.0;
final previousItemCount = _displayItems.length;
final allRecords = await DatabaseService.instance.getAllRecords();
final items = MergeService.getMixedList(allRecords, _mergeSettings);
if (!_isAtTop) {
_chatObserver.standby();
}
setState(() {
_displayItems.clear();
_displayItems.addAll(items);
});
final hasDataChanged = _hasDataChanged(items);
if (hasDataChanged) {
setState(() {
_displayItems.clear();
_displayItems.addAll(items);
});
}
if (_scrollController.hasClients) {
if (_isAtTop) {
_scrollController.jumpTo(0.0);
} else {
final newItemCount = items.length;
final itemDifference = newItemCount - previousItemCount;
if (itemDifference > 0 && previousScrollOffset > 0) {
final itemHeight = _getEstimatedItemHeight();
final adjustedOffset = previousScrollOffset + (itemDifference * itemHeight);
_scrollController.jumpTo(adjustedOffset.clamp(0.0, _scrollController.position.maxScrollExtent));
}
}
if (_isAtTop && _scrollController.hasClients) {
_scrollController.jumpTo(0.0);
}
}
} catch (e) {}
}
double _getEstimatedItemHeight() {
if (_itemHeightCache > 0) {
return _itemHeightCache;
}
return 85.0;
}
bool _hasDataChanged(List<Object> newItems) {
if (_displayItems.length != newItems.length) return true;
@@ -194,8 +320,12 @@ class HistoryScreenState extends State<HistoryScreen> {
Text('暂无记录', style: TextStyle(color: Colors.white, fontSize: 18))
]));
}
return ListView.builder(
return ListViewObserver(
controller: _observerController,
child: ListView.builder(
controller: _scrollController,
physics: ChatObserverClampingScrollPhysics(observer: _chatObserver),
shrinkWrap: _chatObserver.isShrinkWrap,
padding: const EdgeInsets.all(16.0),
itemCount: _displayItems.length,
itemBuilder: (context, index) {
@@ -203,10 +333,12 @@ class HistoryScreenState extends State<HistoryScreen> {
if (item is MergedTrainRecord) {
return _buildMergedRecordCard(item);
} else if (item is TrainRecord) {
return _buildRecordCard(item);
return _buildRecordCard(item, key: ValueKey(item.uniqueId));
}
return const SizedBox.shrink();
});
},
),
);
}
Widget _buildMergedRecordCard(MergedTrainRecord mergedRecord) {
@@ -336,23 +468,64 @@ class HistoryScreenState extends State<HistoryScreen> {
);
}
String _formatLocoInfo(TrainRecord record) {
final locoType = record.locoType.trim();
final loco = record.loco.trim();
if (locoType.isNotEmpty && loco.isNotEmpty) {
final shortLoco =
loco.length > 5 ? loco.substring(loco.length - 5) : loco;
return "$locoType-$shortLoco";
} else if (locoType.isNotEmpty) {
return locoType;
} else if (loco.isNotEmpty) {
return loco;
}
return "";
}
String _getDifferingInfo(
TrainRecord record, TrainRecord latest, GroupBy groupBy) {
final train = record.train.trim();
final loco = record.loco.trim();
final locoType = record.locoType.trim();
final latestTrain = latest.train.trim();
final latestLoco = latest.loco.trim();
final latestLocoType = latest.locoType.trim();
switch (groupBy) {
case GroupBy.trainOnly:
return loco != latestLoco && loco.isNotEmpty ? loco : "";
if (loco != latestLoco && loco.isNotEmpty) {
return _formatLocoInfo(record);
}
return "";
case GroupBy.locoOnly:
return train != latestTrain && train.isNotEmpty ? train : "";
case GroupBy.trainOrLoco:
if (train.isNotEmpty && train != latestTrain) return train;
if (loco.isNotEmpty && loco != latestLoco) return loco;
final trainDiff = train.isNotEmpty && train != latestTrain ? train : "";
final locoDiff = loco.isNotEmpty && loco != latestLoco
? _formatLocoInfo(record)
: "";
if (trainDiff.isNotEmpty && locoDiff.isNotEmpty) {
return "$trainDiff $locoDiff";
} else if (trainDiff.isNotEmpty) {
return trainDiff;
} else if (locoDiff.isNotEmpty) {
return locoDiff;
}
return "";
case GroupBy.trainAndLoco:
if (train.isNotEmpty && train != latestTrain) {
final locoInfo = _formatLocoInfo(record);
if (locoInfo.isNotEmpty) {
return "$train $locoInfo";
}
return train;
}
if (loco.isNotEmpty && loco != latestLoco) {
return _formatLocoInfo(record);
}
return "";
}
}
@@ -496,6 +669,7 @@ class HistoryScreenState extends State<HistoryScreen> {
center: bounds.center,
zoom: zoomLevel,
groupKey: groupKey,
currentUserLocation: _currentUserLocation,
))
]);
}
@@ -506,15 +680,61 @@ class HistoryScreenState extends State<HistoryScreen> {
return 10.0;
}
Widget _buildRecordCard(TrainRecord record, {bool isSubCard = false}) {
Future<void> _requestLocationPermission() async {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
return;
}
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.deniedForever) {
return;
}
setState(() {
_isLocationPermissionGranted = true;
});
_getCurrentLocation();
}
Future<void> _getCurrentLocation() async {
try {
Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
forceAndroidLocationManager: true,
);
setState(() {
_currentUserLocation = LatLng(position.latitude, position.longitude);
});
} catch (e) {}
}
void _startLocationUpdates() {
_requestLocationPermission();
_locationTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
if (_isLocationPermissionGranted) {
_getCurrentLocation();
}
});
}
Widget _buildRecordCard(TrainRecord record,
{bool isSubCard = false, Key? key}) {
final isSelected = _selectedRecords.contains(record.uniqueId);
final isExpanded =
!isSubCard && (_expandedStates[record.uniqueId] ?? false);
final GlobalKey itemKey = GlobalKey();
final Widget card = Card(
key: itemKey,
key: key ?? itemKey,
color: isSelected && _isEditMode
? const Color(0xFF2E2E2E)
: const Color(0xFF1E1E1E),
@@ -539,18 +759,6 @@ class HistoryScreenState extends State<HistoryScreen> {
}
widget.onSelectionChanged();
});
} else if (!isSubCard) {
if (isExpanded) {
setState(() {
_expandedStates[record.uniqueId] = false;
_mapOptimalZoom.remove(record.uniqueId);
_mapCalculating.remove(record.uniqueId);
});
} else {
setState(() {
_expandedStates[record.uniqueId] = true;
});
}
}
},
onLongPress: () {
@@ -568,12 +776,13 @@ class HistoryScreenState extends State<HistoryScreen> {
_buildRecordHeader(record),
_buildPositionAndSpeed(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 RenderBox renderBox =
itemKey.currentContext!.findRenderObject() as RenderBox;
final double realHeight = renderBox.size.height;
if (realHeight > 0) {
setState(() {
@@ -582,14 +791,12 @@ class HistoryScreenState extends State<HistoryScreen> {
}
}
});
return card;
}
Widget _buildRecordHeader(TrainRecord record, {bool isMerged = false}) {
final trainType = record.trainType;
final trainDisplay =
record.fullTrainNumber.isEmpty ? "未知列车" : record.fullTrainNumber;
String formattedLocoInfo = "";
if (record.locoType.isNotEmpty && record.loco.isNotEmpty) {
final shortLoco = record.loco.length > 5
@@ -601,6 +808,22 @@ class HistoryScreenState extends State<HistoryScreen> {
} else if (record.loco.isNotEmpty) {
formattedLocoInfo = record.loco;
}
if (record.fullTrainNumber.isEmpty && formattedLocoInfo.isEmpty) {
return Text(
(record.time == "<NUL>" || record.time.isEmpty)
? record.receivedTimestamp.toString().split(".")[0]
: record.time.split("\n")[0],
style: const TextStyle(fontSize: 11, color: Colors.grey),
overflow: TextOverflow.ellipsis);
}
final hasTrainNumber = record.fullTrainNumber.isNotEmpty;
final hasDirection = record.direction == 1 || record.direction == 3;
final hasLocoInfo =
formattedLocoInfo.isNotEmpty && formattedLocoInfo != "<NUL>";
final shouldShowTrainRow = hasTrainNumber || hasDirection || hasLocoInfo;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Flexible(
@@ -616,43 +839,47 @@ class HistoryScreenState extends State<HistoryScreen> {
style: const TextStyle(fontSize: 11, color: Colors.grey),
overflow: TextOverflow.ellipsis))
]),
const SizedBox(height: 2),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Text(trainDisplay,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white),
overflow: TextOverflow.ellipsis)),
const SizedBox(width: 6),
if (record.direction == 1 || record.direction == 3)
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2)),
child: Center(
child: Text(record.direction == 1 ? "" : "",
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.black))))
])),
if (formattedLocoInfo.isNotEmpty && formattedLocoInfo != "<NUL>")
Text(formattedLocoInfo,
style: const TextStyle(fontSize: 14, color: Colors.white70))
]),
const SizedBox(height: 2)
if (shouldShowTrainRow) ...[
const SizedBox(height: 2),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (hasTrainNumber)
Flexible(
child: Text(record.fullTrainNumber,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white),
overflow: TextOverflow.ellipsis)),
if (hasTrainNumber && hasDirection)
const SizedBox(width: 6),
if (hasDirection)
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2)),
child: Center(
child: Text(record.direction == 1 ? "" : "",
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.black))))
])),
if (hasLocoInfo)
Text(formattedLocoInfo,
style: const TextStyle(fontSize: 14, color: Colors.white70))
]),
const SizedBox(height: 2)
]
]);
}
@@ -769,6 +996,7 @@ class HistoryScreenState extends State<HistoryScreen> {
position: position,
zoom: zoomLevel,
recordId: record.uniqueId,
currentUserLocation: _currentUserLocation,
))
]);
}
@@ -909,12 +1137,14 @@ class _DelayedMapWithMarker extends StatefulWidget {
final LatLng position;
final double zoom;
final String recordId;
final LatLng? currentUserLocation;
const _DelayedMapWithMarker({
Key? key,
required this.position,
required this.zoom,
required this.recordId,
this.currentUserLocation,
}) : super(key: key);
@override
@@ -972,6 +1202,44 @@ class _DelayedMapWithMarkerState extends State<_DelayedMapWithMarker> {
@override
Widget build(BuildContext context) {
final markers = <Marker>[
Marker(
point: widget.position,
width: 24,
height: 24,
child: Container(
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white, width: 1.5),
),
child: const Icon(Icons.train, color: Colors.white, size: 12),
),
),
];
if (widget.currentUserLocation != null) {
markers.add(
Marker(
point: widget.currentUserLocation!,
width: 24,
height: 24,
child: Container(
decoration: BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5),
),
child: const Icon(
Icons.my_location,
color: Colors.white,
size: 12,
),
),
),
);
}
if (_isInitializing) {
return FlutterMap(
options: MapOptions(
@@ -984,19 +1252,7 @@ class _DelayedMapWithMarkerState extends State<_DelayedMapWithMarker> {
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'org.noxylva.lbjconsole'),
MarkerLayer(markers: [
Marker(
point: widget.position,
width: 40,
height: 40,
child: Container(
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white, width: 2)),
child:
const Icon(Icons.train, color: Colors.white, size: 20)))
])
MarkerLayer(markers: markers),
],
);
}
@@ -1010,19 +1266,7 @@ class _DelayedMapWithMarkerState extends State<_DelayedMapWithMarker> {
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'org.noxylva.lbjconsole'),
MarkerLayer(markers: [
Marker(
point: widget.position,
width: 40,
height: 40,
child: Container(
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white, width: 2)),
child:
const Icon(Icons.train, color: Colors.white, size: 20)))
])
MarkerLayer(markers: markers),
],
);
}
@@ -1033,6 +1277,7 @@ class _DelayedMultiMarkerMap extends StatefulWidget {
final LatLng center;
final double zoom;
final String groupKey;
final LatLng? currentUserLocation;
const _DelayedMultiMarkerMap({
Key? key,
@@ -1040,6 +1285,7 @@ class _DelayedMultiMarkerMap extends StatefulWidget {
required this.center,
required this.zoom,
required this.groupKey,
this.currentUserLocation,
}) : super(key: key);
@override
@@ -1099,10 +1345,45 @@ class _DelayedMultiMarkerMapState extends State<_DelayedMultiMarkerMap> {
@override
Widget build(BuildContext context) {
final markers = <Marker>[
...widget.positions.map((pos) => Marker(
point: pos,
width: 24,
height: 24,
child: Container(
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.8),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5)),
child: const Icon(Icons.train, color: Colors.white, size: 12)))),
];
if (widget.currentUserLocation != null) {
markers.add(
Marker(
point: widget.currentUserLocation!,
width: 24,
height: 24,
child: Container(
decoration: BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5),
),
child: const Icon(
Icons.my_location,
color: Colors.white,
size: 12,
),
),
),
);
}
return FlutterMap(
options: MapOptions(
onPositionChanged: (position, hasGesture) => _onCameraMove(),
minZoom: 5,
minZoom: 8,
maxZoom: 18,
),
mapController: _mapController,
@@ -1111,20 +1392,7 @@ class _DelayedMultiMarkerMapState extends State<_DelayedMultiMarkerMap> {
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'org.noxylva.lbjconsole',
),
MarkerLayer(
markers: widget.positions
.map((pos) => Marker(
point: pos,
width: 40,
height: 40,
child: Container(
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.8),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2)),
child: const Icon(Icons.train,
color: Colors.white, size: 20))))
.toList()),
MarkerLayer(markers: markers),
],
);
}

View File

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

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math' show sin, cos, sqrt, atan2, pi;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
@@ -21,7 +22,7 @@ class _MapScreenState extends State<MapScreen> {
LatLng? _currentLocation;
LatLng? _lastTrainLocation;
LatLng? _userLocation;
double _currentZoom = 12.0;
double _currentZoom = 14.0;
double _currentRotation = 0.0;
bool _isMapInitialized = false;
@@ -29,14 +30,82 @@ class _MapScreenState extends State<MapScreen> {
bool _isLocationPermissionGranted = false;
Timer? _locationTimer;
String _selectedTimeFilter = 'unlimited';
final Map<String, Duration> _timeFilterOptions = {
'unlimited': Duration.zero,
'1hour': Duration(hours: 1),
'6hours': Duration(hours: 6),
'12hours': Duration(hours: 12),
'24hours': Duration(hours: 24),
'7days': Duration(days: 7),
'30days': Duration(days: 30),
};
@override
void initState() {
super.initState();
_initializeMap();
_loadTrainRecords();
_checkDatabaseSettings();
_loadSettings().then((_) {
_loadTrainRecords().then((_) {
_startLocationUpdates();
if (!_isMapInitialized && (_currentLocation != null || _lastTrainLocation != null || _userLocation != null)) {
_initializeMapPosition();
}
});
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_loadSettings();
_startLocationUpdates();
}
Future<void> _checkDatabaseSettings() async {
try {
final dbInfo = await DatabaseService.instance.getDatabaseInfo();
final settings = await DatabaseService.instance.getAllSettings();
if (settings != null) {
final lat = settings['mapCenterLat'];
final lon = settings['mapCenterLon'];
if (lat != null && lon != null) {
if (lat == 39.9042 && lon == 116.4074) {
} else if (lat == 0.0 && lon == 0.0) {
} else {
final beijingLat = 39.9042;
final beijingLon = 116.4074;
final distance =
_calculateDistance(lat, lon, beijingLat, beijingLon);
if (distance < 50) {}
}
}
}
} catch (e) {}
}
double _calculateDistance(
double lat1, double lon1, double lat2, double lon2) {
const earthRadius = 6371;
final dLat = _degreesToRadians(lat2 - lat1);
final dLon = _degreesToRadians(lon2 - lon1);
final a = sin(dLat / 2) * sin(dLat / 2) +
cos(_degreesToRadians(lat1)) *
cos(_degreesToRadians(lat2)) *
sin(dLon / 2) *
sin(dLon / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
return earthRadius * c;
}
double _degreesToRadians(double degrees) {
return degrees * pi / 180;
}
@override
@@ -80,19 +149,23 @@ class _MapScreenState extends State<MapScreen> {
try {
Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
forceAndroidLocationManager: true,
);
final newLocation = LatLng(position.latitude, position.longitude);
setState(() {
_userLocation = LatLng(position.latitude, position.longitude);
_userLocation = newLocation;
});
} catch (e) {
}
if (!_isMapInitialized) {
_initializeMapPosition();
}
} catch (e) {}
}
void _startLocationUpdates() {
_requestLocationPermission();
_locationTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
if (_isLocationPermissionGranted) {
_getCurrentLocation();
@@ -101,24 +174,22 @@ class _MapScreenState extends State<MapScreen> {
}
Future<void> _forceUpdateLocation() async {
try {
Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.best,
forceAndroidLocationManager: true,
);
final newLocation = LatLng(position.latitude, position.longitude);
setState(() {
_userLocation = newLocation;
});
_mapController.move(newLocation, 15.0);
} catch (e) {
}
} catch (e) {}
}
Future<void> _loadSettings() async {
try {
final settings = await DatabaseService.instance.getAllSettings();
@@ -129,14 +200,19 @@ class _MapScreenState extends State<MapScreen> {
_currentZoom = (settings['mapZoomLevel'] as num?)?.toDouble() ?? 10.0;
_currentRotation =
(settings['mapRotation'] as num?)?.toDouble() ?? 0.0;
_selectedTimeFilter =
settings['mapTimeFilter'] as String? ?? 'unlimited';
final lat = (settings['mapCenterLat'] as num?)?.toDouble();
final lon = (settings['mapCenterLon'] as num?)?.toDouble();
if (lat != null && lon != null) {
if (lat != null && lon != null && lat != 0.0 && lon != 0.0) {
_currentLocation = LatLng(lat, lon);
}
});
if (!_isMapInitialized) {
_initializeMapPosition();
}
}
} catch (e) {}
}
@@ -144,20 +220,32 @@ class _MapScreenState extends State<MapScreen> {
Future<void> _saveSettings() async {
try {
final center = _mapController.camera.center;
await DatabaseService.instance.updateSettings({
final isDefaultLocation =
center.latitude == 39.9042 && center.longitude == 116.4074;
final settings = {
'mapRailwayLayerVisible': _railwayLayerVisible ? 1 : 0,
'mapZoomLevel': _currentZoom,
'mapCenterLat': center.latitude,
'mapCenterLon': center.longitude,
'mapRotation': _currentRotation,
});
'mapTimeFilter': _selectedTimeFilter,
};
if (!isDefaultLocation) {
settings['mapCenterLat'] = center.latitude;
settings['mapCenterLon'] = center.longitude;
}
settings['mapSettingsTimestamp'] = DateTime.now().millisecondsSinceEpoch;
await DatabaseService.instance.updateSettings(settings);
} catch (e) {}
}
Future<void> _loadTrainRecords() async {
setState(() => _isLoading = true);
try {
final records = await DatabaseService.instance.getAllRecords();
final records = await _getFilteredRecords();
setState(() {
_trainRecords.clear();
_trainRecords.addAll(records);
@@ -175,13 +263,28 @@ class _MapScreenState extends State<MapScreen> {
}
}
_initializeMapPosition();
if (!_isMapInitialized) {
_initializeMapPosition();
}
});
} catch (e) {
setState(() => _isLoading = false);
}
}
Future<List<TrainRecord>> _getFilteredRecords() async {
if (_selectedTimeFilter == 'unlimited') {
return await DatabaseService.instance.getAllRecords();
} else {
final duration = _timeFilterOptions[_selectedTimeFilter];
if (duration != null && duration != Duration.zero) {
return await DatabaseService.instance
.getRecordsWithinReceivedTimeRange(duration);
}
return await DatabaseService.instance.getAllRecords();
}
}
void _initializeMapPosition() {
if (_isMapInitialized) return;
@@ -189,21 +292,21 @@ class _MapScreenState extends State<MapScreen> {
if (_currentLocation != null) {
targetLocation = _currentLocation;
} else if (_userLocation != null) {
targetLocation = _userLocation;
} else if (_lastTrainLocation != null) {
targetLocation = _lastTrainLocation;
} else if (_userLocation != null) {
targetLocation = _userLocation;
} else {
_isMapInitialized = true;
return;
targetLocation = const LatLng(39.9042, 116.4074);
}
_centerMap(targetLocation!, zoom: _currentZoom);
_centerMap(targetLocation!, zoom: _currentZoom, rotation: _currentRotation);
_isMapInitialized = true;
}
void _centerMap(LatLng location, {double? zoom}) {
void _centerMap(LatLng location, {double? zoom, double? rotation}) {
_mapController.move(location, zoom ?? _currentZoom);
_mapController.rotate(rotation ?? _currentRotation);
}
LatLng? _parseDmsCoordinate(String? positionInfo) {
@@ -228,9 +331,7 @@ class _MapScreenState extends State<MapScreen> {
return LatLng(lat, lng);
}
}
} catch (e) {
print('解析DMS坐标失败: $e');
}
} catch (e) {}
return null;
}
@@ -294,41 +395,27 @@ class _MapScreenState extends State<MapScreen> {
Marker(
point: position,
width: 80,
height: 60,
height: 16,
child: GestureDetector(
onTap: () => position != null
? _showTrainDetailsDialog(record, position)
: null,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.white, width: 2),
),
child: const Icon(
Icons.train,
color: Colors.white,
size: 18,
),
),
const SizedBox(height: 2),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(2),
color: Colors.black.withOpacity(0.8),
borderRadius: BorderRadius.circular(3),
),
child: Text(
trainDisplay,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontSize: 8,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
@@ -347,7 +434,9 @@ class _MapScreenState extends State<MapScreen> {
}
void _centerToMyLocation() {
_centerMap(_lastTrainLocation ?? const LatLng(39.9042, 116.4074), zoom: 15.0);
if (_userLocation != null) {
_centerMap(_userLocation!, zoom: 15.0, rotation: _currentRotation);
}
}
void _centerToLastTrain() {
@@ -364,11 +453,73 @@ class _MapScreenState extends State<MapScreen> {
}
if (targetPosition != null) {
_centerMap(targetPosition, zoom: 15.0);
_centerMap(targetPosition, zoom: 15.0, rotation: _currentRotation);
}
}
}
void _showTimeFilterDialog() {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('时间筛选'),
content: SizedBox(
width: double.minPositive,
child: Column(
mainAxisSize: MainAxisSize.min,
children: _timeFilterOptions.keys.map((key) {
return RadioListTile<String>(
title: Text(_getTimeFilterLabel(key)),
value: key,
groupValue: _selectedTimeFilter,
onChanged: (String? value) {
if (value != null) {
setState(() {
_selectedTimeFilter = value;
});
_loadTrainRecords();
Navigator.pop(context);
}
},
contentPadding: EdgeInsets.zero,
dense: true,
);
}).toList(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
],
);
},
);
}
String _getTimeFilterLabel(String key) {
switch (key) {
case 'unlimited':
return '全部时间';
case '1hour':
return '最近1小时';
case '6hours':
return '最近6小时';
case '12hours':
return '最近12小时';
case '24hours':
return '最近24小时';
case '7days':
return '最近7天';
case '30days':
return '最近30天';
default:
return '未知';
}
}
void _showTrainDetailsDialog(TrainRecord record, LatLng position) {
showModalBottomSheet(
context: context,
@@ -429,16 +580,26 @@ class _MapScreenState extends State<MapScreen> {
child: Column(
children: [
_buildMaterial3DetailRow(
context, "时间", record.formattedTime),
context, "时间", _getDisplayTime(record)),
_buildMaterial3DetailRow(
context, "日期", record.formattedDate),
context, "日期", _getDisplayDate(record)),
_buildMaterial3DetailRow(
context, "类型", record.trainType),
_buildMaterial3DetailRow(context, "速度",
"${record.speed.replaceAll(' ', '')} km/h"),
_buildMaterial3DetailRow(
context, "速度", "${record.speed.replaceAll(' ', '')} km/h"),
context,
"位置",
record.position.trim().endsWith('.')
? '${record.position.trim().substring(0, record.position.trim().length - 1)}K'
: '${record.position.trim()}K'),
_buildMaterial3DetailRow(
context, "位置", record.position.trim().endsWith('.') ? '${record.position.trim().substring(0, record.position.trim().length - 1)}K' : '${record.position.trim()}K'),
_buildMaterial3DetailRow(context, "路线", record.route.trim().endsWith('.') ? record.route.trim().substring(0, record.route.trim().length - 1) : record.route.trim()),
context,
"路线",
record.route.trim().endsWith('.')
? record.route.trim().substring(
0, record.route.trim().length - 1)
: record.route.trim()),
_buildMaterial3DetailRow(
context, "机车", "${record.locoType}-${record.loco}"),
_buildMaterial3DetailRow(context, "坐标",
@@ -461,7 +622,8 @@ class _MapScreenState extends State<MapScreen> {
child: FilledButton(
onPressed: () {
Navigator.pop(context);
_centerMap(position, zoom: 17.0);
_centerMap(position,
zoom: 17.0, rotation: _currentRotation);
},
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -507,6 +669,25 @@ class _MapScreenState extends State<MapScreen> {
);
}
String _getDisplayTime(TrainRecord record) {
if (record.time == "<NUL>" || record.time.isEmpty) {
final receivedTime = record.receivedTimestamp;
return '${receivedTime.hour.toString().padLeft(2, '0')}:${receivedTime.minute.toString().padLeft(2, '0')}:${receivedTime.second.toString().padLeft(2, '0')}';
} else {
return record.time.split("\n")[0];
}
}
String _getDisplayDate(TrainRecord record) {
if (record.time == "<NUL>" || record.time.isEmpty) {
final receivedTime = record.receivedTimestamp;
return '${receivedTime.year}-${receivedTime.month.toString().padLeft(2, '0')}-${receivedTime.day.toString().padLeft(2, '0')}';
} else {
final now = DateTime.now();
return '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
}
}
Widget _buildMaterial3DetailRow(
BuildContext context, String label, String value) {
return Padding(
@@ -546,46 +727,67 @@ class _MapScreenState extends State<MapScreen> {
markers.add(
Marker(
point: _userLocation!,
width: 40,
height: 40,
width: 24,
height: 24,
child: Container(
decoration: BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white, width: 1),
),
child: const Icon(
Icons.my_location,
color: Colors.white,
size: 20,
size: 12,
),
),
),
);
}
final bool isDefaultLocation = _currentLocation == null &&
_lastTrainLocation == null &&
_userLocation == null;
return Scaffold(
backgroundColor: const Color(0xFF121212),
body: Stack(
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,
options: MapOptions(
initialCenter: _lastTrainLocation ?? const LatLng(39.9042, 116.4074),
initialCenter: _currentLocation ??
_lastTrainLocation ??
_userLocation ??
const LatLng(39.9042, 116.4074),
initialZoom: _currentZoom,
initialRotation: _currentRotation,
minZoom: 4.0,
minZoom: 8.0,
maxZoom: 18.0,
onPositionChanged: (MapCamera camera, bool hasGesture) {
if (hasGesture) {
setState(() {
_currentLocation = camera.center;
_currentZoom = camera.zoom;
_currentRotation = camera.rotation;
});
_saveSettings();
}
setState(() {
_currentLocation = camera.center;
_currentZoom = camera.zoom;
_currentRotation = camera.rotation;
});
_saveSettings();
},
),
children: [
@@ -616,6 +818,13 @@ class _MapScreenState extends State<MapScreen> {
top: 40,
child: Column(
children: [
FloatingActionButton.small(
heroTag: 'timeFilter',
backgroundColor: const Color(0xFF1E1E1E),
onPressed: _showTimeFilterDialog,
child: const Icon(Icons.filter_list, color: Colors.white),
),
const SizedBox(height: 8),
FloatingActionButton.small(
heroTag: 'railwayLayer',
backgroundColor: const Color(0xFF1E1E1E),

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';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
final VoidCallback? onSettingsChanged;
const SettingsScreen({super.key, this.onSettingsChanged});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
@@ -32,8 +34,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _notificationsEnabled = true;
int _recordCount = 0;
bool _mergeRecordsEnabled = false;
bool _hideTimeOnlyRecords = false;
bool _hideUngroupableRecords = false;
GroupBy _groupBy = GroupBy.trainAndLoco;
TimeWindow _timeWindow = TimeWindow.unlimited;
String _mapType = 'map';
@override
void initState() {
@@ -61,8 +66,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
(settingsMap['backgroundServiceEnabled'] ?? 0) == 1;
_notificationsEnabled = (settingsMap['notificationEnabled'] ?? 1) == 1;
_mergeRecordsEnabled = settings.enabled;
_hideTimeOnlyRecords = (settingsMap['hideTimeOnlyRecords'] ?? 0) == 1;
_hideUngroupableRecords = settings.hideUngroupableRecords;
_groupBy = settings.groupBy;
_timeWindow = settings.timeWindow;
_mapType = settingsMap['mapType']?.toString() ?? 'webview';
});
}
}
@@ -82,9 +90,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
'backgroundServiceEnabled': _backgroundServiceEnabled ? 1 : 0,
'notificationEnabled': _notificationsEnabled ? 1 : 0,
'mergeRecordsEnabled': _mergeRecordsEnabled ? 1 : 0,
'hideTimeOnlyRecords': _hideTimeOnlyRecords ? 1 : 0,
'hideUngroupableRecords': _hideUngroupableRecords ? 1 : 0,
'groupBy': _groupBy.name,
'timeWindow': _timeWindow.name,
'mapType': _mapType,
});
widget.onSettingsChanged?.call();
}
@override
@@ -236,6 +248,66 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
],
),
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(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('隐藏只有时间有效的记录', style: AppTheme.bodyLarge),
Text('不显示只有时间信息的记录', style: AppTheme.caption),
],
),
Switch(
value: _hideTimeOnlyRecords,
onChanged: (value) {
setState(() {
_hideTimeOnlyRecords = value;
});
_saveSettings();
},
activeColor: Theme.of(context).colorScheme.primary,
),
],
),
],
),
),
@@ -372,6 +444,29 @@ class _SettingsScreenState extends State<SettingsScreen> {
dropdownColor: AppTheme.secondaryBlack,
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,
),
],
),
],
),
),
@@ -395,7 +490,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [
Row(
children: [
Icon(Icons.storage, color: Theme.of(context).colorScheme.primary),
Icon(Icons.storage,
color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
Text('数据管理', style: AppTheme.titleMedium),
],

View File

@@ -20,9 +20,9 @@ class BackgroundService {
if (_isInitialized) return;
final service = FlutterBackgroundService();
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
if (Platform.isAndroid) {
const AndroidNotificationChannel channel = AndroidNotificationChannel(
_notificationChannelId,
@@ -34,10 +34,12 @@ class BackgroundService {
playSound: false,
);
await flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()?.createNotificationChannel(channel);
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
}
await service.configure(
androidConfiguration: AndroidConfiguration(
onStart: _onStart,
@@ -81,8 +83,9 @@ class BackgroundService {
if (service is AndroidServiceInstance) {
await Future.delayed(const Duration(seconds: 1));
if (await service.isForegroundService()) {
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
final flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
try {
const AndroidNotificationChannel channel = AndroidNotificationChannel(
_notificationChannelId,
@@ -94,8 +97,10 @@ class BackgroundService {
playSound: false,
);
await flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()?.createNotificationChannel(channel);
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
await flutterLocalNotificationsPlugin.show(
_notificationId,
@@ -122,10 +127,7 @@ class BackgroundService {
),
),
);
print('前台服务通知显示成功');
} catch (e) {
print('前台服务通知显示失败: $e');
}
} catch (e) {}
}
}
@@ -136,8 +138,9 @@ class BackgroundService {
final bleService = BLEService();
final isConnected = bleService.isConnected;
final deviceStatus = bleService.deviceStatus;
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
final flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
await flutterLocalNotificationsPlugin.show(
_notificationId,
'LBJ Console',
@@ -163,9 +166,7 @@ class BackgroundService {
),
),
);
} catch (e) {
print('前台服务通知更新失败: $e');
}
} catch (e) {}
}
}
});
@@ -179,7 +180,7 @@ class BackgroundService {
static Future<void> startService() async {
await initialize();
final service = FlutterBackgroundService();
if (Platform.isAndroid) {
final isRunning = await service.isRunning();
if (!isRunning) {
@@ -208,4 +209,4 @@ class BackgroundService {
service.invoke('setAsBackground');
}
}
}
}

View File

@@ -319,6 +319,10 @@ class BLEService {
'${now.millisecondsSinceEpoch}_${Random().nextInt(9999)}';
recordData['receivedTimestamp'] = now.millisecondsSinceEpoch;
if (!recordData.containsKey('timestamp')) {
recordData['timestamp'] = now.millisecondsSinceEpoch;
}
_lastReceivedTime = now;
_lastReceivedTimeController.add(_lastReceivedTime);
@@ -326,9 +330,7 @@ class BLEService {
_dataController.add(trainRecord);
DatabaseService.instance.insertRecord(trainRecord);
}
} catch (e) {
print("$TAG: JSON Decode Error: $e, Data: $jsonData");
}
} catch (e) {}
}
void _updateConnectionState(bool connected, String status) {

View File

@@ -13,7 +13,7 @@ class DatabaseService {
DatabaseService._internal();
static const String _databaseName = 'train_database';
static const _databaseVersion = 1;
static const _databaseVersion = 7;
static const String trainRecordsTable = 'train_records';
static const String appSettingsTable = 'app_settings';
@@ -21,20 +21,76 @@ class DatabaseService {
Database? _database;
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDatabase();
return _database!;
try {
if (_database != null) {
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 {
final directory = await getApplicationDocumentsDirectory();
final path = join(directory.path, _databaseName);
try {
final directory = await getApplicationDocumentsDirectory();
final path = join(directory.path, _databaseName);
return await openDatabase(
path,
version: _databaseVersion,
onCreate: _onCreate,
);
final db = await openDatabase(
path,
version: _databaseVersion,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
return db;
} catch (e, stackTrace) {
rethrow;
}
}
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
if (oldVersion < 2) {
await db.execute(
'ALTER TABLE $appSettingsTable ADD COLUMN hideTimeOnlyRecords INTEGER NOT NULL DEFAULT 0');
}
if (oldVersion < 3) {
await db.execute(
'ALTER TABLE $appSettingsTable ADD COLUMN mapTimeFilter TEXT NOT NULL DEFAULT "unlimited"');
}
if (oldVersion < 4) {
try {
await db.execute(
'ALTER TABLE $appSettingsTable ADD COLUMN mapTimeFilter TEXT NOT NULL DEFAULT "unlimited"');
} 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');
}
}
Future<void> _onCreate(Database db, int version) async {
@@ -73,14 +129,19 @@ class DatabaseService {
mapZoomLevel REAL NOT NULL DEFAULT 10.0,
mapRailwayLayerVisible INTEGER NOT NULL DEFAULT 1,
mapRotation REAL NOT NULL DEFAULT 0.0,
mapType TEXT NOT NULL DEFAULT 'webview',
specifiedDeviceAddress TEXT,
searchOrderList TEXT NOT NULL DEFAULT '',
autoConnectEnabled INTEGER NOT NULL DEFAULT 1,
backgroundServiceEnabled INTEGER NOT NULL DEFAULT 0,
notificationEnabled INTEGER NOT NULL DEFAULT 0,
mergeRecordsEnabled INTEGER NOT NULL DEFAULT 0,
hideTimeOnlyRecords INTEGER NOT NULL DEFAULT 0,
groupBy TEXT NOT NULL DEFAULT 'trainAndLoco',
timeWindow TEXT NOT NULL DEFAULT 'unlimited'
timeWindow TEXT NOT NULL DEFAULT 'unlimited',
mapTimeFilter TEXT NOT NULL DEFAULT 'unlimited',
hideUngroupableRecords INTEGER NOT NULL DEFAULT 0,
mapSettingsTimestamp INTEGER
)
''');
@@ -97,13 +158,18 @@ class DatabaseService {
'mapZoomLevel': 10.0,
'mapRailwayLayerVisible': 1,
'mapRotation': 0.0,
'mapType': 'webview',
'searchOrderList': '',
'autoConnectEnabled': 1,
'backgroundServiceEnabled': 0,
'notificationEnabled': 0,
'mergeRecordsEnabled': 0,
'hideTimeOnlyRecords': 0,
'groupBy': 'trainAndLoco',
'timeWindow': 'unlimited',
'mapTimeFilter': 'unlimited',
'hideUngroupableRecords': 0,
'mapSettingsTimestamp': null,
});
}
@@ -117,14 +183,53 @@ class DatabaseService {
}
Future<List<TrainRecord>> getAllRecords() async {
try {
final db = await database;
final result = await db.query(
trainRecordsTable,
orderBy: 'timestamp DESC',
);
final records =
result.map((json) => TrainRecord.fromDatabaseJson(json)).toList();
return records;
} catch (e, stackTrace) {
rethrow;
}
}
Future<List<TrainRecord>> getRecordsWithinTimeRange(Duration duration) async {
final db = await database;
final cutoffTime = DateTime.now().subtract(duration).millisecondsSinceEpoch;
final result = await db.query(
trainRecordsTable,
where: 'timestamp >= ?',
whereArgs: [cutoffTime],
orderBy: 'timestamp DESC',
);
return result.map((json) => TrainRecord.fromDatabaseJson(json)).toList();
}
Future<List<TrainRecord>> getRecordsWithinReceivedTimeRange(
Duration duration) async {
try {
final db = await database;
final cutoffTime =
DateTime.now().subtract(duration).millisecondsSinceEpoch;
final result = await db.query(
trainRecordsTable,
where: 'receivedTimestamp >= ?',
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 {
final db = await database;
return await db.delete(

View File

@@ -0,0 +1,84 @@
import 'dart:async';
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
class LocationService {
static final LocationService _instance = LocationService._internal();
factory LocationService() => _instance;
LocationService._internal();
static LocationService get instance => _instance;
LatLng? _currentLocation;
Timer? _locationTimer;
bool _isLocationPermissionGranted = false;
final StreamController<LatLng?> _locationStreamController =
StreamController<LatLng?>.broadcast();
Stream<LatLng?> get locationStream => _locationStreamController.stream;
LatLng? get currentLocation => _currentLocation;
bool get isLocationPermissionGranted => _isLocationPermissionGranted;
Future<void> initialize() async {
await _requestLocationPermission();
if (_isLocationPermissionGranted) {
await _getCurrentLocation();
_startLocationUpdates();
}
}
Future<void> _requestLocationPermission() async {
try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
return;
}
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.deniedForever) {
return;
}
_isLocationPermissionGranted = true;
} catch (e) {}
}
Future<void> _getCurrentLocation() async {
if (!_isLocationPermissionGranted) return;
try {
Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
forceAndroidLocationManager: true,
);
_currentLocation = LatLng(position.latitude, position.longitude);
_locationStreamController.add(_currentLocation);
} catch (e) {}
}
void _startLocationUpdates() {
_locationTimer?.cancel();
_locationTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
if (_isLocationPermissionGranted) {
_getCurrentLocation();
}
});
}
Future<void> forceUpdateLocation() async {
if (!_isLocationPermissionGranted) {
await _requestLocationPermission();
}
await _getCurrentLocation();
}
void dispose() {
_locationTimer?.cancel();
_locationStreamController.close();
}
}

View File

@@ -9,7 +9,7 @@ class MapStateService {
MapStateService._internal();
static const String _tableName = 'record_map_states';
final Map<String, MapState> _memoryCache = {};
Future<void> _ensureTableExists() async {
@@ -34,10 +34,10 @@ class MapStateService {
Future<void> saveMapState(String key, MapState state) async {
try {
_memoryCache[key] = state;
final db = await DatabaseService.instance.database;
await _ensureTableExists();
await db.insert(
_tableName,
{
@@ -47,9 +47,7 @@ class MapStateService {
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
} catch (e) {
print('保存地图状态失败: $e');
}
} catch (e) {}
}
Future<MapState?> getMapState(String key) async {
@@ -60,7 +58,7 @@ class MapStateService {
try {
final db = await DatabaseService.instance.database;
await _ensureTableExists();
final result = await db.query(
_tableName,
where: 'key = ?',
@@ -74,16 +72,14 @@ class MapStateService {
_memoryCache[key] = state;
return state;
}
} catch (e) {
print('读取地图状态失败: $e');
}
} catch (e) {}
return null;
}
Future<void> deleteMapState(String key) async {
_memoryCache.remove(key);
try {
final db = await DatabaseService.instance.database;
await db.delete(
@@ -91,23 +87,19 @@ class MapStateService {
where: 'key = ?',
whereArgs: [key],
);
} catch (e) {
print('删除地图状态失败: $e');
}
} catch (e) {}
}
Future<void> clearAllMapStates() async {
_memoryCache.clear();
try {
final db = await DatabaseService.instance.database;
await db.delete(_tableName);
} catch (e) {
print('清空地图状态失败: $e');
}
} catch (e) {}
}
void clearMemoryCache() {
_memoryCache.clear();
}
}
}

View File

@@ -2,10 +2,42 @@ import 'package:lbjconsole/models/train_record.dart';
import 'package:lbjconsole/models/merged_record.dart';
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) {
final train = record.train.trim();
final loco = record.loco.trim();
final hasTrain = train.isNotEmpty && train != "<NUL>";
final hasTrain =
train.isNotEmpty && train != "<NUL>" && !train.contains("-----");
final hasLoco = loco.isNotEmpty && loco != "<NUL>";
switch (groupBy) {
@@ -14,8 +46,13 @@ class MergeService {
case GroupBy.locoOnly:
return hasLoco ? loco : null;
case GroupBy.trainOrLoco:
if (hasTrain) return train;
if (hasLoco) return loco;
if (hasTrain && hasLoco) {
return "train:$train|loco:$loco";
} else if (hasTrain) {
return "train:$train";
} else if (hasLoco) {
return "loco:$loco";
}
return null;
case GroupBy.trainAndLoco:
return (hasTrain && hasLoco) ? "${train}_$loco" : null;
@@ -30,17 +67,19 @@ class MergeService {
return allRecords;
}
final now = DateTime.now();
final validRecords = settings.timeWindow.duration == null
? allRecords
: allRecords
.where((r) =>
now.difference(r.receivedTimestamp) <=
settings.timeWindow.duration!)
.toList();
final filteredRecords = filterUngroupableRecords(
allRecords, settings.groupBy, settings.hideUngroupableRecords);
filteredRecords
.sort((a, b) => b.receivedTimestamp.compareTo(a.receivedTimestamp));
if (settings.groupBy == GroupBy.trainOrLoco) {
return _groupByTrainOrLocoWithTimeWindow(
filteredRecords, settings.timeWindow);
}
final groupedRecords = <String, List<TrainRecord>>{};
for (final record in validRecords) {
for (final record in filteredRecords) {
final key = _generateGroupKey(record, settings.groupBy);
if (key != null) {
groupedRecords.putIfAbsent(key, () => []).add(record);
@@ -49,25 +88,35 @@ class MergeService {
final List<MergedTrainRecord> mergedRecords = [];
final Set<String> mergedRecordIds = {};
final List<TrainRecord> discardedRecords = [];
groupedRecords.forEach((key, group) {
if (group.length >= 2) {
group
.sort((a, b) => b.receivedTimestamp.compareTo(a.receivedTimestamp));
final latestRecord = group.first;
final processedGroup = _applyTimeWindow(group, settings.timeWindow);
if (processedGroup.length >= 2) {
mergedRecords.add(MergedTrainRecord(
groupKey: key,
records: group,
latestRecord: latestRecord,
records: processedGroup,
latestRecord: processedGroup.first,
));
for (final record in group) {
for (final record in processedGroup) {
mergedRecordIds.add(record.uniqueId);
}
}
for (final record in group) {
if (!processedGroup.contains(record)) {
discardedRecords.add(record);
}
}
});
final singleRecords =
allRecords.where((r) => !mergedRecordIds.contains(r.uniqueId)).toList();
final reusedRecords = _reuseDiscardedRecords(
discardedRecords, mergedRecordIds, settings.groupBy);
final singleRecords = filteredRecords
.where((r) => !mergedRecordIds.contains(r.uniqueId))
.toList();
final List<Object> mixedList = [...mergedRecords, ...singleRecords];
mixedList.sort((a, b) {
@@ -82,4 +131,252 @@ class MergeService {
return mixedList;
}
static List<TrainRecord> _applyTimeWindow(
List<TrainRecord> group, TimeWindow timeWindow) {
if (timeWindow.duration == null) {
return group;
}
group.sort((a, b) => a.receivedTimestamp.compareTo(b.receivedTimestamp));
while (group.length > 1) {
final timeSpan = group.last.receivedTimestamp
.difference(group.first.receivedTimestamp);
if (timeSpan <= timeWindow.duration!) {
break;
}
group.removeAt(0);
}
group.sort((a, b) => b.receivedTimestamp.compareTo(a.receivedTimestamp));
return group;
}
static List<TrainRecord> _reuseDiscardedRecords(
List<TrainRecord> discardedRecords,
Set<String> mergedRecordIds,
GroupBy groupBy) {
final reusedRecords = <TrainRecord>[];
for (final record in discardedRecords) {
if (mergedRecordIds.contains(record.uniqueId)) continue;
final key = _generateGroupKey(record, groupBy);
if (key != null) {
reusedRecords.add(record);
}
}
return reusedRecords;
}
static List<Object> _groupByTrainOrLocoWithTimeWindow(
List<TrainRecord> records, TimeWindow timeWindow) {
final List<MergedTrainRecord> mergedRecords = [];
final List<TrainRecord> singleRecords = [];
final Set<String> usedRecordIds = {};
for (int i = 0; i < records.length; i++) {
final record = records[i];
if (usedRecordIds.contains(record.uniqueId)) continue;
final group = <TrainRecord>[record];
for (int j = i + 1; j < records.length; j++) {
final otherRecord = records[j];
if (usedRecordIds.contains(otherRecord.uniqueId)) continue;
final recordTrain = record.train.trim();
final otherTrain = otherRecord.train.trim();
final recordLoco = record.loco.trim();
final otherLoco = otherRecord.loco.trim();
final trainMatch = recordTrain.isNotEmpty &&
recordTrain != "<NUL>" &&
!recordTrain.contains("-----") &&
otherTrain.isNotEmpty &&
otherTrain != "<NUL>" &&
!otherTrain.contains("-----") &&
recordTrain == otherTrain;
final locoMatch = recordLoco.isNotEmpty &&
recordLoco != "<NUL>" &&
otherLoco.isNotEmpty &&
otherLoco != "<NUL>" &&
recordLoco == otherLoco;
final bothTrainEmpty = (recordTrain.isEmpty ||
recordTrain == "<NUL>" ||
recordTrain.contains("----")) &&
(otherTrain.isEmpty ||
otherTrain == "<NUL>" ||
otherTrain.contains("----"));
if (trainMatch || locoMatch || (bothTrainEmpty && locoMatch)) {
group.add(otherRecord);
}
}
final processedGroup = _applyTimeWindow(group, timeWindow);
if (processedGroup.length >= 2) {
for (final record in processedGroup) {
usedRecordIds.add(record.uniqueId);
}
final firstRecord = processedGroup.first;
final train = firstRecord.train.trim();
final loco = firstRecord.loco.trim();
String uniqueGroupKey;
if (train.isNotEmpty &&
train != "<NUL>" &&
!train.contains("-----") &&
loco.isNotEmpty &&
loco != "<NUL>") {
uniqueGroupKey = "train_or_loco:${train}_$loco";
} else if (train.isNotEmpty &&
train != "<NUL>" &&
!train.contains("-----") &&
loco.isEmpty) {
uniqueGroupKey = "train_or_loco:train:$train";
} else if (loco.isNotEmpty && loco != "<NUL>") {
uniqueGroupKey = "train_or_loco:loco:$loco";
} else {
uniqueGroupKey = "train_or_loco:group_${mergedRecords.length}";
}
mergedRecords.add(MergedTrainRecord(
groupKey: uniqueGroupKey,
records: processedGroup,
latestRecord: processedGroup.first,
));
} else {
for (final record in group) {
if (!processedGroup.contains(record)) {
singleRecords.add(record);
usedRecordIds.add(record.uniqueId);
}
}
if (processedGroup.isNotEmpty) {
singleRecords.add(processedGroup.first);
usedRecordIds.add(processedGroup.first.uniqueId);
}
}
}
final List<Object> result = [...mergedRecords, ...singleRecords];
result.sort((a, b) {
final aTime = a is MergedTrainRecord
? a.latestRecord.receivedTimestamp
: (a as TrainRecord).receivedTimestamp;
final bTime = b is MergedTrainRecord
? b.latestRecord.receivedTimestamp
: (b as TrainRecord).receivedTimestamp;
return bTime.compareTo(aTime);
});
return result;
}
static List<Object> _groupByTrainOrLoco(List<TrainRecord> records) {
final List<MergedTrainRecord> mergedRecords = [];
final List<TrainRecord> singleRecords = [];
final Set<String> usedRecordIds = {};
for (int i = 0; i < records.length; i++) {
final record = records[i];
if (usedRecordIds.contains(record.uniqueId)) continue;
final group = <TrainRecord>[record];
for (int j = i + 1; j < records.length; j++) {
final otherRecord = records[j];
if (usedRecordIds.contains(otherRecord.uniqueId)) continue;
final recordTrain = record.train.trim();
final otherTrain = otherRecord.train.trim();
final recordLoco = record.loco.trim();
final otherLoco = otherRecord.loco.trim();
final trainMatch = recordTrain.isNotEmpty &&
recordTrain != "<NUL>" &&
!recordTrain.contains("-----") &&
otherTrain.isNotEmpty &&
otherTrain != "<NUL>" &&
!otherTrain.contains("-----") &&
recordTrain == otherTrain;
final locoMatch = recordLoco.isNotEmpty &&
recordLoco != "<NUL>" &&
otherLoco.isNotEmpty &&
otherLoco != "<NUL>" &&
recordLoco == otherLoco;
final bothTrainEmpty = (recordTrain.isEmpty ||
recordTrain == "<NUL>" ||
recordTrain.contains("----")) &&
(otherTrain.isEmpty ||
otherTrain == "<NUL>" ||
otherTrain.contains("----"));
if (trainMatch || locoMatch || (bothTrainEmpty && locoMatch)) {
group.add(otherRecord);
}
}
if (group.length >= 2) {
for (final record in group) {
usedRecordIds.add(record.uniqueId);
}
final firstRecord = group.first;
final train = firstRecord.train.trim();
final loco = firstRecord.loco.trim();
String uniqueGroupKey;
if (train.isNotEmpty &&
train != "<NUL>" &&
!train.contains("-----") &&
loco.isNotEmpty &&
loco != "<NUL>") {
uniqueGroupKey = "train_or_loco:${train}_$loco";
} else if (train.isNotEmpty &&
train != "<NUL>" &&
!train.contains("-----")) {
uniqueGroupKey = "train_or_loco:train:$train";
} else if (loco.isNotEmpty && loco != "<NUL>") {
uniqueGroupKey = "train_or_loco:loco:$loco";
} else {
uniqueGroupKey = "train_or_loco:group_${mergedRecords.length}";
}
mergedRecords.add(MergedTrainRecord(
groupKey: uniqueGroupKey,
records: group,
latestRecord: group.first,
));
} else {
singleRecords.add(record);
usedRecordIds.add(record.uniqueId);
}
}
final List<Object> result = [...mergedRecords, ...singleRecords];
result.sort((a, b) {
final aTime = a is MergedTrainRecord
? a.latestRecord.receivedTimestamp
: (a as TrainRecord).receivedTimestamp;
final bTime = b is MergedTrainRecord
? b.latestRecord.receivedTimestamp
: (b as TrainRecord).receivedTimestamp;
return bTime.compareTo(aTime);
});
return result;
}
}

View File

@@ -90,20 +90,32 @@ class NotificationService {
String _buildNotificationContent(TrainRecord record) {
final buffer = StringBuffer();
buffer.writeln('车次: ${record.fullTrainNumber}');
buffer.writeln('线路: ${record.route}');
buffer.writeln('方向: ${record.directionText}');
buffer.write(record.fullTrainNumber);
if (_isValidValue(record.route)) {
buffer.write(' ${record.route}');
}
if (_isValidValue(record.directionText)) {
buffer.write(' ${record.directionText}');
}
if (_isValidValue(record.positionInfo)) {
buffer.write(' ${record.positionInfo}');
}
buffer.writeln();
if (_isValidValue(record.locoType) && _isValidValue(record.loco)) {
final shortLoco = record.loco.length > 5
? record.loco.substring(record.loco.length - 5)
: record.loco;
buffer.write('${record.locoType}-$shortLoco');
} else if (_isValidValue(record.locoType)) {
buffer.write(record.locoType);
} else if (_isValidValue(record.loco)) {
buffer.write(record.loco);
}
if (_isValidValue(record.speed)) {
buffer.writeln('速度: ${record.speed} km/h');
buffer.write(' ${record.speed}km/h');
}
if (_isValidValue(record.positionInfo)) {
buffer.writeln('位置: ${record.positionInfo}');
}
buffer.writeln('时间: ${record.formattedTime}');
return buffer.toString().trim();
}

View File

@@ -15,6 +15,7 @@ import share_plus
import shared_preferences_foundation
import sqflite_darwin
import url_launcher_macos
import webview_flutter_wkwebview
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
@@ -27,4 +28,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
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"
source: hosted
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:
dependency: transitive
description:
@@ -358,6 +366,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.1"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.14.4"
flutter_lints:
dependency: "direct dev"
description:
@@ -648,6 +664,30 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
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:
dependency: transitive
description:
@@ -888,6 +928,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
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:
dependency: "direct main"
description:
@@ -920,6 +968,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.28.0"
scrollview_observer:
dependency: "direct main"
description:
name: scrollview_observer
sha256: c2f713509f18f88f637b2084b47a90c91fb1ef066d5d82d2cf3194d8509dc6ab
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.26.2"
share_plus:
dependency: "direct main"
description:
@@ -1245,6 +1301,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
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:
dependency: transitive
description:
@@ -1253,6 +1317,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
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:
dependency: transitive
description:
@@ -1293,6 +1373,38 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
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:
dependency: transitive
description:
@@ -1334,5 +1446,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.8.0-0 <4.0.0"
flutter: ">=3.24.0"
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.35.0"

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.2.0-flutter+20 # versionName: 0.2.0-flutter, versionCode: 3
version: 0.5.1-flutter+51
environment:
sdk: ^3.5.4
@@ -53,6 +53,10 @@ dependencies:
package_info_plus: ^8.1.2
msix: ^3.16.12
flutter_background_service: ^5.1.0
scrollview_observer: ^1.20.0
vector_map_tiles: ^8.0.0
maplibre_gl: ^0.22.0
webview_flutter: ^4.8.0
dev_dependencies:
flutter_test:
@@ -66,6 +70,7 @@ dev_dependencies:
flutter_lints: ^4.0.0
hive_generator: ^2.0.1
build_runner: ^2.4.6
flutter_launcher_icons: ^0.14.1
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
@@ -82,6 +87,7 @@ flutter:
- assets/loco_info.csv
- assets/train_number_info.csv
- assets/loco_type_info.csv
- assets/mapbox_map.html
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
@@ -109,6 +115,13 @@ flutter:
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
flutter_launcher_icons:
android: true
ios: true
image_path: "assets/icon.png"
adaptive_icon_background: "#000000"
adaptive_icon_foreground: "assets/icon.png"
msix_config:
display_name: LBJ Console
publisher_display_name: Noxylva