Compare commits
12 Commits
v0.2.0-flu
...
v0.5.1-flu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc2a495984 | ||
|
|
6718ef7129 | ||
|
|
bfd05bd249 | ||
|
|
8d3366fbf9 | ||
|
|
9b0e9dcacf | ||
|
|
c3e97332fd | ||
|
|
b1d8d5e029 | ||
|
|
77501af2f5 | ||
|
|
64401a6ce9 | ||
|
|
72f9dfe17b | ||
|
|
bf850eed38 | ||
|
|
56689fc993 |
@@ -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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 61 KiB |
@@ -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>
|
||||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 12 KiB |
4
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#000000</color>
|
||||||
|
</resources>
|
||||||
BIN
assets/icon.png
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 209 KiB |
15050
assets/mapbox_map.html
Normal file
3
devtools_options.yaml
Normal 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:
|
||||||
@@ -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++";
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 872 B |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 9.3 KiB |
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
final previousScrollOffset = _scrollController.hasClients ? _scrollController.offset : 0.0;
|
|
||||||
final previousItemCount = _displayItems.length;
|
|
||||||
|
|
||||||
final allRecords = await DatabaseService.instance.getAllRecords();
|
final allRecords = await DatabaseService.instance.getAllRecords();
|
||||||
final items = MergeService.getMixedList(allRecords, _mergeSettings);
|
final items = MergeService.getMixedList(allRecords, _mergeSettings);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
if (!_isAtTop) {
|
||||||
|
_chatObserver.standby();
|
||||||
|
}
|
||||||
|
|
||||||
|
final hasDataChanged = _hasDataChanged(items);
|
||||||
|
if (hasDataChanged) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_displayItems.clear();
|
_displayItems.clear();
|
||||||
_displayItems.addAll(items);
|
_displayItems.addAll(items);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (_scrollController.hasClients) {
|
if (_isAtTop && _scrollController.hasClients) {
|
||||||
if (_isAtTop) {
|
|
||||||
_scrollController.jumpTo(0.0);
|
_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,6 +839,7 @@ 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))
|
||||||
]),
|
]),
|
||||||
|
if (shouldShowTrainRow) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
@@ -626,15 +850,17 @@ class HistoryScreenState extends State<HistoryScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
if (hasTrainNumber)
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(trainDisplay,
|
child: Text(record.fullTrainNumber,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Colors.white),
|
color: Colors.white),
|
||||||
overflow: TextOverflow.ellipsis)),
|
overflow: TextOverflow.ellipsis)),
|
||||||
|
if (hasTrainNumber && hasDirection)
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
if (record.direction == 1 || record.direction == 3)
|
if (hasDirection)
|
||||||
Container(
|
Container(
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
@@ -648,11 +874,12 @@ class HistoryScreenState extends State<HistoryScreen> {
|
|||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Colors.black))))
|
color: Colors.black))))
|
||||||
])),
|
])),
|
||||||
if (formattedLocoInfo.isNotEmpty && formattedLocoInfo != "<NUL>")
|
if (hasLocoInfo)
|
||||||
Text(formattedLocoInfo,
|
Text(formattedLocoInfo,
|
||||||
style: const TextStyle(fontSize: 14, color: Colors.white70))
|
style: const TextStyle(fontSize: 14, color: Colors.white70))
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 2)
|
const SizedBox(height: 2)
|
||||||
|
]
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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()),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
|
||||||
_loadSettings();
|
_checkDatabaseSettings();
|
||||||
|
|
||||||
|
_loadSettings().then((_) {
|
||||||
|
_loadTrainRecords().then((_) {
|
||||||
_startLocationUpdates();
|
_startLocationUpdates();
|
||||||
|
if (!_isMapInitialized && (_currentLocation != null || _lastTrainLocation != null || _userLocation != null)) {
|
||||||
|
_initializeMapPosition();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
_loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
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,10 +187,8 @@ 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 {
|
||||||
@@ -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> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!_isMapInitialized) {
|
||||||
_initializeMapPosition();
|
_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),
|
||||||
|
|||||||
1106
lib/screens/map_webview_screen.dart
Normal 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),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
if (_database != null) {
|
||||||
|
return _database!;
|
||||||
|
}
|
||||||
_database = await _initDatabase();
|
_database = await _initDatabase();
|
||||||
return _database!;
|
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 {
|
||||||
|
try {
|
||||||
final directory = await getApplicationDocumentsDirectory();
|
final directory = await getApplicationDocumentsDirectory();
|
||||||
final path = join(directory.path, _databaseName);
|
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 db = await database;
|
||||||
final result = await db.query(
|
final result = await db.query(
|
||||||
trainRecordsTable,
|
trainRecordsTable,
|
||||||
orderBy: 'timestamp DESC',
|
orderBy: 'timestamp DESC',
|
||||||
);
|
);
|
||||||
|
final records =
|
||||||
|
result.map((json) => TrainRecord.fromDatabaseJson(json)).toList();
|
||||||
|
return records;
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<TrainRecord>> getRecordsWithinTimeRange(Duration duration) async {
|
||||||
|
final db = await database;
|
||||||
|
final cutoffTime = DateTime.now().subtract(duration).millisecondsSinceEpoch;
|
||||||
|
final result = await db.query(
|
||||||
|
trainRecordsTable,
|
||||||
|
where: 'timestamp >= ?',
|
||||||
|
whereArgs: [cutoffTime],
|
||||||
|
orderBy: 'timestamp DESC',
|
||||||
|
);
|
||||||
return result.map((json) => TrainRecord.fromDatabaseJson(json)).toList();
|
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(
|
||||||
|
|||||||
84
lib/services/location_service.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
|
|||||||
116
pubspec.lock
@@ -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"
|
||||||
|
|||||||
15
pubspec.yaml
@@ -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
|
||||||
|
|||||||