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 { 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

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; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; 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_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";
@@ -484,7 +484,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; 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_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; 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":"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"}}
"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"
}
}

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 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

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

View File

@@ -1,8 +1,13 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:isolate'; import 'dart:isolate';
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:scrollview_observer/scrollview_observer.dart';
import '../models/merged_record.dart'; import '../models/merged_record.dart';
import '../services/database_service.dart'; import '../services/database_service.dart';
import '../models/train_record.dart'; import '../models/train_record.dart';
@@ -31,6 +36,9 @@ class HistoryScreenState extends State<HistoryScreen> {
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();
final ListObserverController _observerController =
ListObserverController(controller: null)..cacheJumpIndexOffset = false;
late final ChatScrollObserver _chatObserver;
bool _isAtTop = true; bool _isAtTop = true;
MergeSettings _mergeSettings = MergeSettings(); MergeSettings _mergeSettings = MergeSettings();
double _itemHeightCache = 0.0; double _itemHeightCache = 0.0;
@@ -38,6 +46,10 @@ class HistoryScreenState extends State<HistoryScreen> {
final Map<String, double> _mapOptimalZoom = {}; final Map<String, double> _mapOptimalZoom = {};
final Map<String, bool> _mapCalculating = {}; final Map<String, bool> _mapCalculating = {};
LatLng? _currentUserLocation;
bool _isLocationPermissionGranted = false;
Timer? _locationTimer;
int getSelectedCount() => _selectedRecords.length; int getSelectedCount() => _selectedRecords.length;
Set<String> getSelectedRecordIds() => _selectedRecords; Set<String> getSelectedRecordIds() => _selectedRecords;
List<Object> getDisplayItems() => _displayItems; List<Object> getDisplayItems() => _displayItems;
@@ -53,9 +65,17 @@ class HistoryScreenState extends State<HistoryScreen> {
}); });
} }
Future<void> reloadRecords() async {
await loadRecords(scrollToTop: false);
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_chatObserver = ChatScrollObserver(_observerController)
..toRebuildScrollViewCallback = () {
setState(() {});
};
_scrollController.addListener(() { _scrollController.addListener(() {
if (_scrollController.position.atEdge) { if (_scrollController.position.atEdge) {
if (_scrollController.position.pixels == 0) { if (_scrollController.position.pixels == 0) {
@@ -66,13 +86,18 @@ class HistoryScreenState extends State<HistoryScreen> {
} }
}); });
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) loadRecords(); if (mounted) {
loadRecords();
_startLocationUpdates();
}
}); });
} }
@override @override
void dispose() { void dispose() {
_scrollController.dispose(); _scrollController.dispose();
_observerController.controller?.dispose();
_locationTimer?.cancel();
super.dispose(); super.dispose();
} }
@@ -81,7 +106,71 @@ class HistoryScreenState extends State<HistoryScreen> {
final allRecords = await DatabaseService.instance.getAllRecords(); final allRecords = await DatabaseService.instance.getAllRecords();
final settingsMap = await DatabaseService.instance.getAllSettings() ?? {}; final settingsMap = await DatabaseService.instance.getAllSettings() ?? {};
_mergeSettings = MergeSettings.fromMap(settingsMap); _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) { if (mounted) {
final hasDataChanged = _hasDataChanged(items); final hasDataChanged = _hasDataChanged(items);
@@ -112,6 +201,58 @@ 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) {
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) {
return item.uniqueId == newRecord.uniqueId; return item.uniqueId == newRecord.uniqueId;
@@ -123,44 +264,29 @@ class HistoryScreenState extends State<HistoryScreen> {
if (!isNewRecord) return; if (!isNewRecord) return;
final allRecords = await DatabaseService.instance.getAllRecords();
final items = MergeService.getMixedList(allRecords, _mergeSettings);
if (mounted) { if (mounted) {
final previousScrollOffset = _scrollController.hasClients ? _scrollController.offset : 0.0; if (!_isAtTop) {
final previousItemCount = _displayItems.length; _chatObserver.standby();
}
final allRecords = await DatabaseService.instance.getAllRecords(); final hasDataChanged = _hasDataChanged(items);
final items = MergeService.getMixedList(allRecords, _mergeSettings); if (hasDataChanged) {
setState(() {
_displayItems.clear();
_displayItems.addAll(items);
});
}
setState(() { if (_isAtTop && _scrollController.hasClients) {
_displayItems.clear(); _scrollController.jumpTo(0.0);
_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));
}
}
} }
} }
} catch (e) {} } catch (e) {}
} }
double _getEstimatedItemHeight() {
if (_itemHeightCache > 0) {
return _itemHeightCache;
}
return 85.0;
}
bool _hasDataChanged(List<Object> newItems) { bool _hasDataChanged(List<Object> newItems) {
if (_displayItems.length != newItems.length) return true; if (_displayItems.length != newItems.length) return true;
@@ -194,8 +320,12 @@ class HistoryScreenState extends State<HistoryScreen> {
Text('暂无记录', style: TextStyle(color: Colors.white, fontSize: 18)) Text('暂无记录', style: TextStyle(color: Colors.white, fontSize: 18))
])); ]));
} }
return ListView.builder( return ListViewObserver(
controller: _observerController,
child: ListView.builder(
controller: _scrollController, controller: _scrollController,
physics: ChatObserverClampingScrollPhysics(observer: _chatObserver),
shrinkWrap: _chatObserver.isShrinkWrap,
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
itemCount: _displayItems.length, itemCount: _displayItems.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -203,10 +333,12 @@ class HistoryScreenState extends State<HistoryScreen> {
if (item is MergedTrainRecord) { if (item is MergedTrainRecord) {
return _buildMergedRecordCard(item); return _buildMergedRecordCard(item);
} else if (item is TrainRecord) { } else if (item is TrainRecord) {
return _buildRecordCard(item); return _buildRecordCard(item, key: ValueKey(item.uniqueId));
} }
return const SizedBox.shrink(); return const SizedBox.shrink();
}); },
),
);
} }
Widget _buildMergedRecordCard(MergedTrainRecord mergedRecord) { 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( String _getDifferingInfo(
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:
return loco != latestLoco && loco.isNotEmpty ? loco : ""; if (loco != latestLoco && loco.isNotEmpty) {
return _formatLocoInfo(record);
}
return "";
case GroupBy.locoOnly: case GroupBy.locoOnly:
return train != latestTrain && train.isNotEmpty ? train : ""; return train != latestTrain && train.isNotEmpty ? train : "";
case GroupBy.trainOrLoco: case GroupBy.trainOrLoco:
if (train.isNotEmpty && train != latestTrain) return train; final trainDiff = train.isNotEmpty && train != latestTrain ? train : "";
if (loco.isNotEmpty && loco != latestLoco) return loco; 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 ""; return "";
case GroupBy.trainAndLoco: 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 ""; return "";
} }
} }
@@ -496,6 +669,7 @@ class HistoryScreenState extends State<HistoryScreen> {
center: bounds.center, center: bounds.center,
zoom: zoomLevel, zoom: zoomLevel,
groupKey: groupKey, groupKey: groupKey,
currentUserLocation: _currentUserLocation,
)) ))
]); ]);
} }
@@ -506,7 +680,53 @@ class HistoryScreenState extends State<HistoryScreen> {
return 10.0; 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 isSelected = _selectedRecords.contains(record.uniqueId);
final isExpanded = final isExpanded =
!isSubCard && (_expandedStates[record.uniqueId] ?? false); !isSubCard && (_expandedStates[record.uniqueId] ?? false);
@@ -514,7 +734,7 @@ class HistoryScreenState extends State<HistoryScreen> {
final GlobalKey itemKey = GlobalKey(); final GlobalKey itemKey = GlobalKey();
final Widget card = Card( final Widget card = Card(
key: itemKey, key: key ?? itemKey,
color: isSelected && _isEditMode color: isSelected && _isEditMode
? const Color(0xFF2E2E2E) ? const Color(0xFF2E2E2E)
: const Color(0xFF1E1E1E), : const Color(0xFF1E1E1E),
@@ -539,18 +759,6 @@ class HistoryScreenState extends State<HistoryScreen> {
} }
widget.onSelectionChanged(); 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: () { onLongPress: () {
@@ -568,12 +776,13 @@ class HistoryScreenState extends State<HistoryScreen> {
_buildRecordHeader(record), _buildRecordHeader(record),
_buildPositionAndSpeed(record), _buildPositionAndSpeed(record),
_buildLocoInfo(record), _buildLocoInfo(record),
if (isExpanded) _buildExpandedContent(record) if (isExpanded) _buildExpandedContent(record),
])))); ]))));
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (_itemHeightCache <= 0 && itemKey.currentContext != null) { 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; final double realHeight = renderBox.size.height;
if (realHeight > 0) { if (realHeight > 0) {
setState(() { setState(() {
@@ -588,8 +797,6 @@ class HistoryScreenState extends State<HistoryScreen> {
Widget _buildRecordHeader(TrainRecord record, {bool isMerged = false}) { Widget _buildRecordHeader(TrainRecord record, {bool isMerged = false}) {
final trainType = record.trainType; final trainType = record.trainType;
final trainDisplay =
record.fullTrainNumber.isEmpty ? "未知列车" : record.fullTrainNumber;
String formattedLocoInfo = ""; String formattedLocoInfo = "";
if (record.locoType.isNotEmpty && record.loco.isNotEmpty) { if (record.locoType.isNotEmpty && record.loco.isNotEmpty) {
final shortLoco = record.loco.length > 5 final shortLoco = record.loco.length > 5
@@ -601,6 +808,22 @@ class HistoryScreenState extends State<HistoryScreen> {
} else if (record.loco.isNotEmpty) { } else if (record.loco.isNotEmpty) {
formattedLocoInfo = record.loco; 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: [ return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Flexible( Flexible(
@@ -616,43 +839,47 @@ class HistoryScreenState extends State<HistoryScreen> {
style: const TextStyle(fontSize: 11, color: Colors.grey), style: const TextStyle(fontSize: 11, color: Colors.grey),
overflow: TextOverflow.ellipsis)) overflow: TextOverflow.ellipsis))
]), ]),
const SizedBox(height: 2), if (shouldShowTrainRow) ...[
Row( const SizedBox(height: 2),
mainAxisAlignment: MainAxisAlignment.spaceBetween, Row(
crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ crossAxisAlignment: CrossAxisAlignment.center,
Flexible( children: [
child: Row( Flexible(
mainAxisSize: MainAxisSize.min, child: Row(
crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min,
children: [ crossAxisAlignment: CrossAxisAlignment.center,
Flexible( children: [
child: Text(trainDisplay, if (hasTrainNumber)
style: const TextStyle( Flexible(
fontSize: 20, child: Text(record.fullTrainNumber,
fontWeight: FontWeight.bold, style: const TextStyle(
color: Colors.white), fontSize: 20,
overflow: TextOverflow.ellipsis)), fontWeight: FontWeight.bold,
const SizedBox(width: 6), color: Colors.white),
if (record.direction == 1 || record.direction == 3) overflow: TextOverflow.ellipsis)),
Container( if (hasTrainNumber && hasDirection)
width: 20, const SizedBox(width: 6),
height: 20, if (hasDirection)
decoration: BoxDecoration( Container(
color: Colors.white, width: 20,
borderRadius: BorderRadius.circular(2)), height: 20,
child: Center( decoration: BoxDecoration(
child: Text(record.direction == 1 ? "" : "", color: Colors.white,
style: const TextStyle( borderRadius: BorderRadius.circular(2)),
fontSize: 12, child: Center(
fontWeight: FontWeight.bold, child: Text(record.direction == 1 ? "" : "",
color: Colors.black)))) style: const TextStyle(
])), fontSize: 12,
if (formattedLocoInfo.isNotEmpty && formattedLocoInfo != "<NUL>") fontWeight: FontWeight.bold,
Text(formattedLocoInfo, color: Colors.black))))
style: const TextStyle(fontSize: 14, color: Colors.white70)) ])),
]), if (hasLocoInfo)
const SizedBox(height: 2) 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, position: position,
zoom: zoomLevel, zoom: zoomLevel,
recordId: record.uniqueId, recordId: record.uniqueId,
currentUserLocation: _currentUserLocation,
)) ))
]); ]);
} }
@@ -909,12 +1137,14 @@ class _DelayedMapWithMarker extends StatefulWidget {
final LatLng position; final LatLng position;
final double zoom; final double zoom;
final String recordId; final String recordId;
final LatLng? currentUserLocation;
const _DelayedMapWithMarker({ const _DelayedMapWithMarker({
Key? key, Key? key,
required this.position, required this.position,
required this.zoom, required this.zoom,
required this.recordId, required this.recordId,
this.currentUserLocation,
}) : super(key: key); }) : super(key: key);
@override @override
@@ -972,6 +1202,44 @@ class _DelayedMapWithMarkerState extends State<_DelayedMapWithMarker> {
@override @override
Widget build(BuildContext context) { 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) { if (_isInitializing) {
return FlutterMap( return FlutterMap(
options: MapOptions( options: MapOptions(
@@ -984,19 +1252,7 @@ class _DelayedMapWithMarkerState extends State<_DelayedMapWithMarker> {
TileLayer( TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'org.noxylva.lbjconsole'), userAgentPackageName: 'org.noxylva.lbjconsole'),
MarkerLayer(markers: [ MarkerLayer(markers: 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)))
])
], ],
); );
} }
@@ -1010,19 +1266,7 @@ class _DelayedMapWithMarkerState extends State<_DelayedMapWithMarker> {
TileLayer( TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'org.noxylva.lbjconsole'), userAgentPackageName: 'org.noxylva.lbjconsole'),
MarkerLayer(markers: [ MarkerLayer(markers: 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)))
])
], ],
); );
} }
@@ -1033,6 +1277,7 @@ class _DelayedMultiMarkerMap extends StatefulWidget {
final LatLng center; final LatLng center;
final double zoom; final double zoom;
final String groupKey; final String groupKey;
final LatLng? currentUserLocation;
const _DelayedMultiMarkerMap({ const _DelayedMultiMarkerMap({
Key? key, Key? key,
@@ -1040,6 +1285,7 @@ class _DelayedMultiMarkerMap extends StatefulWidget {
required this.center, required this.center,
required this.zoom, required this.zoom,
required this.groupKey, required this.groupKey,
this.currentUserLocation,
}) : super(key: key); }) : super(key: key);
@override @override
@@ -1099,10 +1345,45 @@ class _DelayedMultiMarkerMapState extends State<_DelayedMultiMarkerMap> {
@override @override
Widget build(BuildContext context) { 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( 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,
@@ -1111,20 +1392,7 @@ class _DelayedMultiMarkerMapState extends State<_DelayedMultiMarkerMap> {
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'org.noxylva.lbjconsole', userAgentPackageName: 'org.noxylva.lbjconsole',
), ),
MarkerLayer( MarkerLayer(markers: markers),
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()),
], ],
); );
} }

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(
@@ -397,7 +415,11 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
selectedIndex: _currentIndex, selectedIndex: _currentIndex,
onDestinationSelected: (index) { onDestinationSelected: (index) {
if (_currentIndex == 2 && index == 0) { if (_currentIndex == 2 && index == 0) {
_historyScreenKey.currentState?.loadRecords(); _historyScreenKey.currentState?.reloadRecords();
}
// 如果从设置页面切换到地图页面,重新加载地图类型
if (_currentIndex == 2 && index == 1) {
_loadMapType();
} }
setState(() { setState(() {
if (_isHistoryEditMode) _isHistoryEditMode = false; if (_isHistoryEditMode) _isHistoryEditMode = false;

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' show sin, cos, sqrt, atan2, pi;
import 'package:flutter/material.dart'; import 'package:flutter/material.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';
@@ -21,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;
@@ -29,14 +30,82 @@ class _MapScreenState extends State<MapScreen> {
bool _isLocationPermissionGranted = false; bool _isLocationPermissionGranted = false;
Timer? _locationTimer; 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 @override
void initState() { void initState() {
super.initState(); super.initState();
_initializeMap(); _initializeMap();
_loadTrainRecords();
_checkDatabaseSettings();
_loadSettings().then((_) {
_loadTrainRecords().then((_) {
_startLocationUpdates();
if (!_isMapInitialized && (_currentLocation != null || _lastTrainLocation != null || _userLocation != null)) {
_initializeMapPosition();
}
});
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_loadSettings(); _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 @override
@@ -80,14 +149,18 @@ class _MapScreenState extends State<MapScreen> {
try { try {
Position position = await Geolocator.getCurrentPosition( Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high, desiredAccuracy: LocationAccuracy.high,
forceAndroidLocationManager: true,
); );
final newLocation = LatLng(position.latitude, position.longitude);
setState(() { setState(() {
_userLocation = LatLng(position.latitude, position.longitude); _userLocation = newLocation;
}); });
} catch (e) { if (!_isMapInitialized) {
} _initializeMapPosition();
}
} catch (e) {}
} }
void _startLocationUpdates() { void _startLocationUpdates() {
@@ -101,10 +174,10 @@ class _MapScreenState extends State<MapScreen> {
} }
Future<void> _forceUpdateLocation() async { Future<void> _forceUpdateLocation() async {
try { try {
Position position = await Geolocator.getCurrentPosition( Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.best, desiredAccuracy: LocationAccuracy.best,
forceAndroidLocationManager: true,
); );
final newLocation = LatLng(position.latitude, position.longitude); final newLocation = LatLng(position.latitude, position.longitude);
@@ -114,11 +187,9 @@ class _MapScreenState extends State<MapScreen> {
}); });
_mapController.move(newLocation, 15.0); _mapController.move(newLocation, 15.0);
} catch (e) { } catch (e) {}
}
} }
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
try { try {
final settings = await DatabaseService.instance.getAllSettings(); final settings = await DatabaseService.instance.getAllSettings();
@@ -129,14 +200,19 @@ class _MapScreenState extends State<MapScreen> {
_currentZoom = (settings['mapZoomLevel'] as num?)?.toDouble() ?? 10.0; _currentZoom = (settings['mapZoomLevel'] as num?)?.toDouble() ?? 10.0;
_currentRotation = _currentRotation =
(settings['mapRotation'] as num?)?.toDouble() ?? 0.0; (settings['mapRotation'] as num?)?.toDouble() ?? 0.0;
_selectedTimeFilter =
settings['mapTimeFilter'] as String? ?? 'unlimited';
final lat = (settings['mapCenterLat'] as num?)?.toDouble(); final lat = (settings['mapCenterLat'] as num?)?.toDouble();
final lon = (settings['mapCenterLon'] 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); _currentLocation = LatLng(lat, lon);
} }
}); });
if (!_isMapInitialized) {
_initializeMapPosition();
}
} }
} catch (e) {} } catch (e) {}
} }
@@ -144,20 +220,32 @@ class _MapScreenState extends State<MapScreen> {
Future<void> _saveSettings() async { Future<void> _saveSettings() async {
try { try {
final center = _mapController.camera.center; 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, 'mapRailwayLayerVisible': _railwayLayerVisible ? 1 : 0,
'mapZoomLevel': _currentZoom, 'mapZoomLevel': _currentZoom,
'mapCenterLat': center.latitude,
'mapCenterLon': center.longitude,
'mapRotation': _currentRotation, '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) {} } catch (e) {}
} }
Future<void> _loadTrainRecords() async { Future<void> _loadTrainRecords() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
final records = await DatabaseService.instance.getAllRecords(); final records = await _getFilteredRecords();
setState(() { setState(() {
_trainRecords.clear(); _trainRecords.clear();
_trainRecords.addAll(records); _trainRecords.addAll(records);
@@ -175,13 +263,28 @@ class _MapScreenState extends State<MapScreen> {
} }
} }
_initializeMapPosition(); if (!_isMapInitialized) {
_initializeMapPosition();
}
}); });
} catch (e) { } catch (e) {
setState(() => _isLoading = false); 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() { void _initializeMapPosition() {
if (_isMapInitialized) return; if (_isMapInitialized) return;
@@ -189,21 +292,21 @@ class _MapScreenState extends State<MapScreen> {
if (_currentLocation != null) { if (_currentLocation != null) {
targetLocation = _currentLocation; targetLocation = _currentLocation;
} else if (_userLocation != null) {
targetLocation = _userLocation;
} else if (_lastTrainLocation != null) { } else if (_lastTrainLocation != null) {
targetLocation = _lastTrainLocation; targetLocation = _lastTrainLocation;
} else if (_userLocation != null) {
targetLocation = _userLocation;
} else { } else {
_isMapInitialized = true; targetLocation = const LatLng(39.9042, 116.4074);
return;
} }
_centerMap(targetLocation!, zoom: _currentZoom); _centerMap(targetLocation!, zoom: _currentZoom, rotation: _currentRotation);
_isMapInitialized = true; _isMapInitialized = true;
} }
void _centerMap(LatLng location, {double? zoom}) { void _centerMap(LatLng location, {double? zoom, double? rotation}) {
_mapController.move(location, zoom ?? _currentZoom); _mapController.move(location, zoom ?? _currentZoom);
_mapController.rotate(rotation ?? _currentRotation);
} }
LatLng? _parseDmsCoordinate(String? positionInfo) { LatLng? _parseDmsCoordinate(String? positionInfo) {
@@ -228,9 +331,7 @@ class _MapScreenState extends State<MapScreen> {
return LatLng(lat, lng); return LatLng(lat, lng);
} }
} }
} catch (e) { } catch (e) {}
print('解析DMS坐标失败: $e');
}
return null; return null;
} }
@@ -294,41 +395,27 @@ class _MapScreenState extends State<MapScreen> {
Marker( Marker(
point: position, point: position,
width: 80, width: 80,
height: 60, height: 16,
child: GestureDetector( child: GestureDetector(
onTap: () => position != null onTap: () => position != null
? _showTrainDetailsDialog(record, position) ? _showTrainDetailsDialog(record, position)
: null, : null,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [ 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( Container(
padding: padding:
const EdgeInsets.symmetric(horizontal: 4, vertical: 1), const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7), color: Colors.black.withOpacity(0.8),
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(3),
), ),
child: Text( child: Text(
trainDisplay, trainDisplay,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 10, fontSize: 8,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -347,7 +434,9 @@ class _MapScreenState extends State<MapScreen> {
} }
void _centerToMyLocation() { 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() { void _centerToLastTrain() {
@@ -364,11 +453,73 @@ class _MapScreenState extends State<MapScreen> {
} }
if (targetPosition != null) { 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) { void _showTrainDetailsDialog(TrainRecord record, LatLng position) {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@@ -429,16 +580,26 @@ class _MapScreenState extends State<MapScreen> {
child: Column( child: Column(
children: [ children: [
_buildMaterial3DetailRow( _buildMaterial3DetailRow(
context, "时间", record.formattedTime), context, "时间", _getDisplayTime(record)),
_buildMaterial3DetailRow( _buildMaterial3DetailRow(
context, "日期", record.formattedDate), context, "日期", _getDisplayDate(record)),
_buildMaterial3DetailRow( _buildMaterial3DetailRow(
context, "类型", record.trainType), context, "类型", record.trainType),
_buildMaterial3DetailRow(context, "速度",
"${record.speed.replaceAll(' ', '')} km/h"),
_buildMaterial3DetailRow( _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( _buildMaterial3DetailRow(
context, "位置", record.position.trim().endsWith('.') ? '${record.position.trim().substring(0, record.position.trim().length - 1)}K' : '${record.position.trim()}K'), context,
_buildMaterial3DetailRow(context, "路线", record.route.trim().endsWith('.') ? record.route.trim().substring(0, record.route.trim().length - 1) : record.route.trim()), "路线",
record.route.trim().endsWith('.')
? record.route.trim().substring(
0, record.route.trim().length - 1)
: record.route.trim()),
_buildMaterial3DetailRow( _buildMaterial3DetailRow(
context, "机车", "${record.locoType}-${record.loco}"), context, "机车", "${record.locoType}-${record.loco}"),
_buildMaterial3DetailRow(context, "坐标", _buildMaterial3DetailRow(context, "坐标",
@@ -461,7 +622,8 @@ class _MapScreenState extends State<MapScreen> {
child: FilledButton( child: FilledButton(
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
_centerMap(position, zoom: 17.0); _centerMap(position,
zoom: 17.0, rotation: _currentRotation);
}, },
child: const Row( child: const Row(
mainAxisAlignment: MainAxisAlignment.center, 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( Widget _buildMaterial3DetailRow(
BuildContext context, String label, String value) { BuildContext context, String label, String value) {
return Padding( return Padding(
@@ -546,46 +727,67 @@ class _MapScreenState extends State<MapScreen> {
markers.add( markers.add(
Marker( Marker(
point: _userLocation!, point: _userLocation!,
width: 40, width: 24,
height: 40, height: 24,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blue, color: Colors.blue,
shape: BoxShape.circle, borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white, width: 2), border: Border.all(color: Colors.white, width: 1),
), ),
child: const Icon( child: const Icon(
Icons.my_location, Icons.my_location,
color: Colors.white, color: Colors.white,
size: 20, size: 12,
), ),
), ),
), ),
); );
} }
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: _lastTrainLocation ?? const LatLng(39.9042, 116.4074), initialCenter: _currentLocation ??
_lastTrainLocation ??
_userLocation ??
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) {
if (hasGesture) { setState(() {
setState(() { _currentLocation = camera.center;
_currentLocation = camera.center; _currentZoom = camera.zoom;
_currentZoom = camera.zoom; _currentRotation = camera.rotation;
_currentRotation = camera.rotation; });
});
_saveSettings(); _saveSettings();
}
}, },
), ),
children: [ children: [
@@ -616,6 +818,13 @@ class _MapScreenState extends State<MapScreen> {
top: 40, top: 40,
child: Column( child: Column(
children: [ 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( FloatingActionButton.small(
heroTag: 'railwayLayer', heroTag: 'railwayLayer',
backgroundColor: const Color(0xFF1E1E1E), 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'; 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();
@@ -32,8 +34,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _notificationsEnabled = true; bool _notificationsEnabled = true;
int _recordCount = 0; int _recordCount = 0;
bool _mergeRecordsEnabled = false; bool _mergeRecordsEnabled = 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() {
@@ -61,8 +66,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
(settingsMap['backgroundServiceEnabled'] ?? 0) == 1; (settingsMap['backgroundServiceEnabled'] ?? 0) == 1;
_notificationsEnabled = (settingsMap['notificationEnabled'] ?? 1) == 1; _notificationsEnabled = (settingsMap['notificationEnabled'] ?? 1) == 1;
_mergeRecordsEnabled = settings.enabled; _mergeRecordsEnabled = settings.enabled;
_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';
}); });
} }
} }
@@ -82,9 +90,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
'backgroundServiceEnabled': _backgroundServiceEnabled ? 1 : 0, 'backgroundServiceEnabled': _backgroundServiceEnabled ? 1 : 0,
'notificationEnabled': _notificationsEnabled ? 1 : 0, 'notificationEnabled': _notificationsEnabled ? 1 : 0,
'mergeRecordsEnabled': _mergeRecordsEnabled ? 1 : 0, 'mergeRecordsEnabled': _mergeRecordsEnabled ? 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
@@ -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, 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,
),
],
),
], ],
), ),
), ),
@@ -395,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

@@ -34,8 +34,10 @@ class BackgroundService {
playSound: false, playSound: false,
); );
await flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation< await flutterLocalNotificationsPlugin
AndroidFlutterLocalNotificationsPlugin>()?.createNotificationChannel(channel); .resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
} }
await service.configure( await service.configure(
@@ -81,7 +83,8 @@ class BackgroundService {
if (service is AndroidServiceInstance) { if (service is AndroidServiceInstance) {
await Future.delayed(const Duration(seconds: 1)); await Future.delayed(const Duration(seconds: 1));
if (await service.isForegroundService()) { if (await service.isForegroundService()) {
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); final flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
try { try {
const AndroidNotificationChannel channel = AndroidNotificationChannel( const AndroidNotificationChannel channel = AndroidNotificationChannel(
@@ -94,8 +97,10 @@ class BackgroundService {
playSound: false, playSound: false,
); );
await flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation< await flutterLocalNotificationsPlugin
AndroidFlutterLocalNotificationsPlugin>()?.createNotificationChannel(channel); .resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
await flutterLocalNotificationsPlugin.show( await flutterLocalNotificationsPlugin.show(
_notificationId, _notificationId,
@@ -122,10 +127,7 @@ class BackgroundService {
), ),
), ),
); );
print('前台服务通知显示成功'); } catch (e) {}
} catch (e) {
print('前台服务通知显示失败: $e');
}
} }
} }
@@ -137,7 +139,8 @@ class BackgroundService {
final isConnected = bleService.isConnected; final isConnected = bleService.isConnected;
final deviceStatus = bleService.deviceStatus; final deviceStatus = bleService.deviceStatus;
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); final flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
await flutterLocalNotificationsPlugin.show( await flutterLocalNotificationsPlugin.show(
_notificationId, _notificationId,
'LBJ Console', 'LBJ Console',
@@ -163,9 +166,7 @@ class BackgroundService {
), ),
), ),
); );
} catch (e) { } catch (e) {}
print('前台服务通知更新失败: $e');
}
} }
} }
}); });

View File

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

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 = 1; 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,20 +21,76 @@ 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,
);
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 { Future<void> _onCreate(Database db, int version) async {
@@ -73,14 +129,19 @@ 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,
backgroundServiceEnabled INTEGER NOT NULL DEFAULT 0, backgroundServiceEnabled INTEGER NOT NULL DEFAULT 0,
notificationEnabled INTEGER NOT NULL DEFAULT 0, notificationEnabled INTEGER NOT NULL DEFAULT 0,
mergeRecordsEnabled INTEGER NOT NULL DEFAULT 0, mergeRecordsEnabled 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',
hideUngroupableRecords INTEGER NOT NULL DEFAULT 0,
mapSettingsTimestamp INTEGER
) )
'''); ''');
@@ -97,13 +158,18 @@ 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,
'notificationEnabled': 0, 'notificationEnabled': 0,
'mergeRecordsEnabled': 0, 'mergeRecordsEnabled': 0,
'hideTimeOnlyRecords': 0,
'groupBy': 'trainAndLoco', 'groupBy': 'trainAndLoco',
'timeWindow': 'unlimited', 'timeWindow': 'unlimited',
'mapTimeFilter': 'unlimited',
'hideUngroupableRecords': 0,
'mapSettingsTimestamp': null,
}); });
} }
@@ -117,14 +183,53 @@ class DatabaseService {
} }
Future<List<TrainRecord>> getAllRecords() async { 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 db = await database;
final cutoffTime = DateTime.now().subtract(duration).millisecondsSinceEpoch;
final result = await db.query( final result = await db.query(
trainRecordsTable, trainRecordsTable,
where: 'timestamp >= ?',
whereArgs: [cutoffTime],
orderBy: 'timestamp DESC', orderBy: 'timestamp DESC',
); );
return result.map((json) => TrainRecord.fromDatabaseJson(json)).toList(); 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 { Future<int> deleteRecord(String uniqueId) async {
final db = await database; final db = await database;
return await db.delete( 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

@@ -47,9 +47,7 @@ class MapStateService {
}, },
conflictAlgorithm: ConflictAlgorithm.replace, conflictAlgorithm: ConflictAlgorithm.replace,
); );
} catch (e) { } catch (e) {}
print('保存地图状态失败: $e');
}
} }
Future<MapState?> getMapState(String key) async { Future<MapState?> getMapState(String key) async {
@@ -74,9 +72,7 @@ class MapStateService {
_memoryCache[key] = state; _memoryCache[key] = state;
return state; return state;
} }
} catch (e) { } catch (e) {}
print('读取地图状态失败: $e');
}
return null; return null;
} }
@@ -91,9 +87,7 @@ class MapStateService {
where: 'key = ?', where: 'key = ?',
whereArgs: [key], whereArgs: [key],
); );
} catch (e) { } catch (e) {}
print('删除地图状态失败: $e');
}
} }
Future<void> clearAllMapStates() async { Future<void> clearAllMapStates() async {
@@ -102,9 +96,7 @@ class MapStateService {
try { try {
final db = await DatabaseService.instance.database; final db = await DatabaseService.instance.database;
await db.delete(_tableName); await db.delete(_tableName);
} catch (e) { } catch (e) {}
print('清空地图状态失败: $e');
}
} }
void clearMemoryCache() { void clearMemoryCache() {

View File

@@ -2,10 +2,42 @@ 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();
final hasTrain = train.isNotEmpty && train != "<NUL>"; final hasTrain =
train.isNotEmpty && train != "<NUL>" && !train.contains("-----");
final hasLoco = loco.isNotEmpty && loco != "<NUL>"; final hasLoco = loco.isNotEmpty && loco != "<NUL>";
switch (groupBy) { switch (groupBy) {
@@ -14,8 +46,13 @@ class MergeService {
case GroupBy.locoOnly: case GroupBy.locoOnly:
return hasLoco ? loco : null; return hasLoco ? loco : null;
case GroupBy.trainOrLoco: case GroupBy.trainOrLoco:
if (hasTrain) return train; if (hasTrain && hasLoco) {
if (hasLoco) return loco; return "train:$train|loco:$loco";
} else if (hasTrain) {
return "train:$train";
} else if (hasLoco) {
return "loco:$loco";
}
return null; return null;
case GroupBy.trainAndLoco: case GroupBy.trainAndLoco:
return (hasTrain && hasLoco) ? "${train}_$loco" : null; return (hasTrain && hasLoco) ? "${train}_$loco" : null;
@@ -30,17 +67,19 @@ class MergeService {
return allRecords; return allRecords;
} }
final now = DateTime.now(); final filteredRecords = filterUngroupableRecords(
final validRecords = settings.timeWindow.duration == null allRecords, settings.groupBy, settings.hideUngroupableRecords);
? allRecords
: allRecords filteredRecords
.where((r) => .sort((a, b) => b.receivedTimestamp.compareTo(a.receivedTimestamp));
now.difference(r.receivedTimestamp) <=
settings.timeWindow.duration!) if (settings.groupBy == GroupBy.trainOrLoco) {
.toList(); return _groupByTrainOrLocoWithTimeWindow(
filteredRecords, settings.timeWindow);
}
final groupedRecords = <String, List<TrainRecord>>{}; final groupedRecords = <String, List<TrainRecord>>{};
for (final record in validRecords) { 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);
@@ -49,25 +88,35 @@ class MergeService {
final List<MergedTrainRecord> mergedRecords = []; final List<MergedTrainRecord> mergedRecords = [];
final Set<String> mergedRecordIds = {}; final Set<String> mergedRecordIds = {};
final List<TrainRecord> discardedRecords = [];
groupedRecords.forEach((key, group) { groupedRecords.forEach((key, group) {
if (group.length >= 2) { final processedGroup = _applyTimeWindow(group, settings.timeWindow);
group
.sort((a, b) => b.receivedTimestamp.compareTo(a.receivedTimestamp)); if (processedGroup.length >= 2) {
final latestRecord = group.first;
mergedRecords.add(MergedTrainRecord( mergedRecords.add(MergedTrainRecord(
groupKey: key, groupKey: key,
records: group, records: processedGroup,
latestRecord: latestRecord, latestRecord: processedGroup.first,
)); ));
for (final record in group) { for (final record in processedGroup) {
mergedRecordIds.add(record.uniqueId); mergedRecordIds.add(record.uniqueId);
} }
} }
for (final record in group) {
if (!processedGroup.contains(record)) {
discardedRecords.add(record);
}
}
}); });
final singleRecords = final reusedRecords = _reuseDiscardedRecords(
allRecords.where((r) => !mergedRecordIds.contains(r.uniqueId)).toList(); discardedRecords, mergedRecordIds, settings.groupBy);
final singleRecords = filteredRecords
.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) {
@@ -82,4 +131,252 @@ class MergeService {
return mixedList; 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) { String _buildNotificationContent(TrainRecord record) {
final buffer = StringBuffer(); final buffer = StringBuffer();
buffer.writeln('车次: ${record.fullTrainNumber}'); buffer.write(record.fullTrainNumber);
buffer.writeln('线路: ${record.route}'); if (_isValidValue(record.route)) {
buffer.writeln('方向: ${record.directionText}'); 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)) { 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(); return buffer.toString().trim();
} }

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:
@@ -358,6 +366,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "3.0.1" 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: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -648,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:
@@ -888,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:
@@ -920,6 +968,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.28.0" 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: share_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1245,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:
@@ -1253,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:
@@ -1293,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:
@@ -1334,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

@@ -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.2.0-flutter+20 # versionName: 0.2.0-flutter, versionCode: 3 version: 0.5.1-flutter+51
environment: environment:
sdk: ^3.5.4 sdk: ^3.5.4
@@ -53,6 +53,10 @@ dependencies:
package_info_plus: ^8.1.2 package_info_plus: ^8.1.2
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
vector_map_tiles: ^8.0.0
maplibre_gl: ^0.22.0
webview_flutter: ^4.8.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -66,6 +70,7 @@ dev_dependencies:
flutter_lints: ^4.0.0 flutter_lints: ^4.0.0
hive_generator: ^2.0.1 hive_generator: ^2.0.1
build_runner: ^2.4.6 build_runner: ^2.4.6
flutter_launcher_icons: ^0.14.1
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec
@@ -82,6 +87,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
@@ -109,6 +115,13 @@ flutter:
# For details regarding fonts from package dependencies, # For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package # 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: msix_config:
display_name: LBJ Console display_name: LBJ Console
publisher_display_name: Noxylva publisher_display_name: Noxylva