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 {
|
||||
namespace = "org.noxylva.lbjconsole.flutter"
|
||||
compileSdk = 36
|
||||
ndkVersion = "26.1.10909125"
|
||||
ndkVersion = "28.1.13356709"
|
||||
|
||||
compileOptions {
|
||||
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;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
@@ -484,7 +484,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
|
||||
@@ -1,122 +1 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||
|
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 GroupBy groupBy;
|
||||
final TimeWindow timeWindow;
|
||||
|
||||
final bool hideUngroupableRecords;
|
||||
MergeSettings({
|
||||
this.enabled = true,
|
||||
this.groupBy = GroupBy.trainAndLoco,
|
||||
this.timeWindow = TimeWindow.unlimited,
|
||||
this.hideUngroupableRecords = false,
|
||||
});
|
||||
|
||||
factory MergeSettings.fromMap(Map<String, dynamic> map) {
|
||||
@@ -36,6 +37,7 @@ class MergeSettings {
|
||||
(e) => e.name == map['timeWindow'],
|
||||
orElse: () => TimeWindow.unlimited,
|
||||
),
|
||||
hideUngroupableRecords: (map['hideUngroupableRecords'] ?? 0) == 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ class TrainRecord {
|
||||
final lbjClassValue = lbjClass.trim();
|
||||
final trainValue = train.trim();
|
||||
|
||||
if (trainValue == "<NUL>") {
|
||||
if (trainValue == "<NUL>" || trainValue.contains("-----")) {
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:isolate';
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:scrollview_observer/scrollview_observer.dart';
|
||||
import '../models/merged_record.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../models/train_record.dart';
|
||||
@@ -31,6 +36,9 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
final Set<String> _selectedRecords = {};
|
||||
final Map<String, bool> _expandedStates = {};
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final ListObserverController _observerController =
|
||||
ListObserverController(controller: null)..cacheJumpIndexOffset = false;
|
||||
late final ChatScrollObserver _chatObserver;
|
||||
bool _isAtTop = true;
|
||||
MergeSettings _mergeSettings = MergeSettings();
|
||||
double _itemHeightCache = 0.0;
|
||||
@@ -38,6 +46,10 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
final Map<String, double> _mapOptimalZoom = {};
|
||||
final Map<String, bool> _mapCalculating = {};
|
||||
|
||||
LatLng? _currentUserLocation;
|
||||
bool _isLocationPermissionGranted = false;
|
||||
Timer? _locationTimer;
|
||||
|
||||
int getSelectedCount() => _selectedRecords.length;
|
||||
Set<String> getSelectedRecordIds() => _selectedRecords;
|
||||
List<Object> getDisplayItems() => _displayItems;
|
||||
@@ -53,9 +65,17 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> reloadRecords() async {
|
||||
await loadRecords(scrollToTop: false);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chatObserver = ChatScrollObserver(_observerController)
|
||||
..toRebuildScrollViewCallback = () {
|
||||
setState(() {});
|
||||
};
|
||||
_scrollController.addListener(() {
|
||||
if (_scrollController.position.atEdge) {
|
||||
if (_scrollController.position.pixels == 0) {
|
||||
@@ -66,13 +86,18 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
}
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) loadRecords();
|
||||
if (mounted) {
|
||||
loadRecords();
|
||||
_startLocationUpdates();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_observerController.controller?.dispose();
|
||||
_locationTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -81,7 +106,71 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
final allRecords = await DatabaseService.instance.getAllRecords();
|
||||
final settingsMap = await DatabaseService.instance.getAllSettings() ?? {};
|
||||
_mergeSettings = MergeSettings.fromMap(settingsMap);
|
||||
final items = MergeService.getMixedList(allRecords, _mergeSettings);
|
||||
|
||||
List<TrainRecord> filteredRecords = allRecords;
|
||||
if ((settingsMap['hideTimeOnlyRecords'] ?? 0) == 1) {
|
||||
int hiddenCount = 0;
|
||||
int shownCount = 0;
|
||||
|
||||
filteredRecords = allRecords.where((record) {
|
||||
bool isFieldMeaningful(String field) {
|
||||
if (field.isEmpty) return false;
|
||||
String cleaned = field.replaceAll('<NUL>', '').trim();
|
||||
if (cleaned.isEmpty) return false;
|
||||
if (cleaned.runes
|
||||
.every((r) => r == '*'.runes.first || r == ' '.runes.first))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
final hasTrainNumber = isFieldMeaningful(record.fullTrainNumber) &&
|
||||
!record.fullTrainNumber.contains("-----");
|
||||
|
||||
final hasDirection = record.direction == 1 || record.direction == 3;
|
||||
|
||||
final hasLocoInfo = isFieldMeaningful(record.locoType) ||
|
||||
isFieldMeaningful(record.loco);
|
||||
|
||||
final hasRoute = isFieldMeaningful(record.route);
|
||||
|
||||
final hasPosition = isFieldMeaningful(record.position);
|
||||
|
||||
final hasSpeed =
|
||||
isFieldMeaningful(record.speed) && record.speed != "NUL";
|
||||
|
||||
final hasPositionInfo = isFieldMeaningful(record.positionInfo);
|
||||
|
||||
final hasTrainType =
|
||||
isFieldMeaningful(record.trainType) && record.trainType != "未知";
|
||||
|
||||
final hasLbjClass =
|
||||
isFieldMeaningful(record.lbjClass) && record.lbjClass != "NA";
|
||||
|
||||
final hasTrain = isFieldMeaningful(record.train) &&
|
||||
!record.train.contains("-----");
|
||||
|
||||
final shouldShow = hasTrainNumber ||
|
||||
hasDirection ||
|
||||
hasLocoInfo ||
|
||||
hasRoute ||
|
||||
hasPosition ||
|
||||
hasSpeed ||
|
||||
hasPositionInfo ||
|
||||
hasTrainType ||
|
||||
hasLbjClass ||
|
||||
hasTrain;
|
||||
|
||||
if (!shouldShow) {
|
||||
hiddenCount++;
|
||||
} else {
|
||||
shownCount++;
|
||||
}
|
||||
|
||||
return shouldShow;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
final items = MergeService.getMixedList(filteredRecords, _mergeSettings);
|
||||
|
||||
if (mounted) {
|
||||
final hasDataChanged = _hasDataChanged(items);
|
||||
@@ -112,6 +201,58 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
final settingsMap = await DatabaseService.instance.getAllSettings() ?? {};
|
||||
_mergeSettings = MergeSettings.fromMap(settingsMap);
|
||||
|
||||
if ((settingsMap['hideTimeOnlyRecords'] ?? 0) == 1) {
|
||||
bool isFieldMeaningful(String field) {
|
||||
if (field.isEmpty) return false;
|
||||
String cleaned = field.replaceAll('<NUL>', '').trim();
|
||||
if (cleaned.isEmpty) return false;
|
||||
if (cleaned.runes
|
||||
.every((r) => r == '*'.runes.first || r == ' '.runes.first))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
final hasTrainNumber = isFieldMeaningful(newRecord.fullTrainNumber) &&
|
||||
!newRecord.fullTrainNumber.contains("-----");
|
||||
|
||||
final hasDirection =
|
||||
newRecord.direction == 1 || newRecord.direction == 3;
|
||||
|
||||
final hasLocoInfo = isFieldMeaningful(newRecord.locoType) ||
|
||||
isFieldMeaningful(newRecord.loco);
|
||||
|
||||
final hasRoute = isFieldMeaningful(newRecord.route);
|
||||
|
||||
final hasPosition = isFieldMeaningful(newRecord.position);
|
||||
|
||||
final hasSpeed =
|
||||
isFieldMeaningful(newRecord.speed) && newRecord.speed != "NUL";
|
||||
|
||||
final hasPositionInfo = isFieldMeaningful(newRecord.positionInfo);
|
||||
|
||||
final hasTrainType = isFieldMeaningful(newRecord.trainType) &&
|
||||
newRecord.trainType != "未知";
|
||||
|
||||
final hasLbjClass =
|
||||
isFieldMeaningful(newRecord.lbjClass) && newRecord.lbjClass != "NA";
|
||||
|
||||
final hasTrain = isFieldMeaningful(newRecord.train) &&
|
||||
!newRecord.train.contains("-----");
|
||||
|
||||
if (!hasTrainNumber &&
|
||||
!hasDirection &&
|
||||
!hasLocoInfo &&
|
||||
!hasRoute &&
|
||||
!hasPosition &&
|
||||
!hasSpeed &&
|
||||
!hasPositionInfo &&
|
||||
!hasTrainType &&
|
||||
!hasLbjClass &&
|
||||
!hasTrain) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final isNewRecord = !_displayItems.any((item) {
|
||||
if (item is TrainRecord) {
|
||||
return item.uniqueId == newRecord.uniqueId;
|
||||
@@ -123,44 +264,29 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
|
||||
if (!isNewRecord) return;
|
||||
|
||||
final allRecords = await DatabaseService.instance.getAllRecords();
|
||||
final items = MergeService.getMixedList(allRecords, _mergeSettings);
|
||||
|
||||
if (mounted) {
|
||||
final previousScrollOffset = _scrollController.hasClients ? _scrollController.offset : 0.0;
|
||||
final previousItemCount = _displayItems.length;
|
||||
|
||||
final allRecords = await DatabaseService.instance.getAllRecords();
|
||||
final items = MergeService.getMixedList(allRecords, _mergeSettings);
|
||||
if (!_isAtTop) {
|
||||
_chatObserver.standby();
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_displayItems.clear();
|
||||
_displayItems.addAll(items);
|
||||
});
|
||||
final hasDataChanged = _hasDataChanged(items);
|
||||
if (hasDataChanged) {
|
||||
setState(() {
|
||||
_displayItems.clear();
|
||||
_displayItems.addAll(items);
|
||||
});
|
||||
}
|
||||
|
||||
if (_scrollController.hasClients) {
|
||||
if (_isAtTop) {
|
||||
_scrollController.jumpTo(0.0);
|
||||
} else {
|
||||
final newItemCount = items.length;
|
||||
final itemDifference = newItemCount - previousItemCount;
|
||||
|
||||
if (itemDifference > 0 && previousScrollOffset > 0) {
|
||||
final itemHeight = _getEstimatedItemHeight();
|
||||
final adjustedOffset = previousScrollOffset + (itemDifference * itemHeight);
|
||||
|
||||
_scrollController.jumpTo(adjustedOffset.clamp(0.0, _scrollController.position.maxScrollExtent));
|
||||
}
|
||||
}
|
||||
if (_isAtTop && _scrollController.hasClients) {
|
||||
_scrollController.jumpTo(0.0);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
double _getEstimatedItemHeight() {
|
||||
if (_itemHeightCache > 0) {
|
||||
return _itemHeightCache;
|
||||
}
|
||||
return 85.0;
|
||||
}
|
||||
|
||||
bool _hasDataChanged(List<Object> newItems) {
|
||||
if (_displayItems.length != newItems.length) return true;
|
||||
|
||||
@@ -194,8 +320,12 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
Text('暂无记录', style: TextStyle(color: Colors.white, fontSize: 18))
|
||||
]));
|
||||
}
|
||||
return ListView.builder(
|
||||
return ListViewObserver(
|
||||
controller: _observerController,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: ChatObserverClampingScrollPhysics(observer: _chatObserver),
|
||||
shrinkWrap: _chatObserver.isShrinkWrap,
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
itemCount: _displayItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
@@ -203,10 +333,12 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
if (item is MergedTrainRecord) {
|
||||
return _buildMergedRecordCard(item);
|
||||
} else if (item is TrainRecord) {
|
||||
return _buildRecordCard(item);
|
||||
return _buildRecordCard(item, key: ValueKey(item.uniqueId));
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMergedRecordCard(MergedTrainRecord mergedRecord) {
|
||||
@@ -336,23 +468,64 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
String _formatLocoInfo(TrainRecord record) {
|
||||
final locoType = record.locoType.trim();
|
||||
final loco = record.loco.trim();
|
||||
|
||||
if (locoType.isNotEmpty && loco.isNotEmpty) {
|
||||
final shortLoco =
|
||||
loco.length > 5 ? loco.substring(loco.length - 5) : loco;
|
||||
return "$locoType-$shortLoco";
|
||||
} else if (locoType.isNotEmpty) {
|
||||
return locoType;
|
||||
} else if (loco.isNotEmpty) {
|
||||
return loco;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
String _getDifferingInfo(
|
||||
TrainRecord record, TrainRecord latest, GroupBy groupBy) {
|
||||
final train = record.train.trim();
|
||||
final loco = record.loco.trim();
|
||||
final locoType = record.locoType.trim();
|
||||
final latestTrain = latest.train.trim();
|
||||
final latestLoco = latest.loco.trim();
|
||||
final latestLocoType = latest.locoType.trim();
|
||||
|
||||
switch (groupBy) {
|
||||
case GroupBy.trainOnly:
|
||||
return loco != latestLoco && loco.isNotEmpty ? loco : "";
|
||||
if (loco != latestLoco && loco.isNotEmpty) {
|
||||
return _formatLocoInfo(record);
|
||||
}
|
||||
return "";
|
||||
case GroupBy.locoOnly:
|
||||
return train != latestTrain && train.isNotEmpty ? train : "";
|
||||
case GroupBy.trainOrLoco:
|
||||
if (train.isNotEmpty && train != latestTrain) return train;
|
||||
if (loco.isNotEmpty && loco != latestLoco) return loco;
|
||||
final trainDiff = train.isNotEmpty && train != latestTrain ? train : "";
|
||||
final locoDiff = loco.isNotEmpty && loco != latestLoco
|
||||
? _formatLocoInfo(record)
|
||||
: "";
|
||||
|
||||
if (trainDiff.isNotEmpty && locoDiff.isNotEmpty) {
|
||||
return "$trainDiff $locoDiff";
|
||||
} else if (trainDiff.isNotEmpty) {
|
||||
return trainDiff;
|
||||
} else if (locoDiff.isNotEmpty) {
|
||||
return locoDiff;
|
||||
}
|
||||
return "";
|
||||
case GroupBy.trainAndLoco:
|
||||
if (train.isNotEmpty && train != latestTrain) {
|
||||
final locoInfo = _formatLocoInfo(record);
|
||||
if (locoInfo.isNotEmpty) {
|
||||
return "$train $locoInfo";
|
||||
}
|
||||
return train;
|
||||
}
|
||||
if (loco.isNotEmpty && loco != latestLoco) {
|
||||
return _formatLocoInfo(record);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -496,6 +669,7 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
center: bounds.center,
|
||||
zoom: zoomLevel,
|
||||
groupKey: groupKey,
|
||||
currentUserLocation: _currentUserLocation,
|
||||
))
|
||||
]);
|
||||
}
|
||||
@@ -506,15 +680,61 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
return 10.0;
|
||||
}
|
||||
|
||||
Widget _buildRecordCard(TrainRecord record, {bool isSubCard = false}) {
|
||||
Future<void> _requestLocationPermission() async {
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLocationPermissionGranted = true;
|
||||
});
|
||||
|
||||
_getCurrentLocation();
|
||||
}
|
||||
|
||||
Future<void> _getCurrentLocation() async {
|
||||
try {
|
||||
Position position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high,
|
||||
forceAndroidLocationManager: true,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_currentUserLocation = LatLng(position.latitude, position.longitude);
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
void _startLocationUpdates() {
|
||||
_requestLocationPermission();
|
||||
|
||||
_locationTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
|
||||
if (_isLocationPermissionGranted) {
|
||||
_getCurrentLocation();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildRecordCard(TrainRecord record,
|
||||
{bool isSubCard = false, Key? key}) {
|
||||
final isSelected = _selectedRecords.contains(record.uniqueId);
|
||||
final isExpanded =
|
||||
!isSubCard && (_expandedStates[record.uniqueId] ?? false);
|
||||
|
||||
|
||||
final GlobalKey itemKey = GlobalKey();
|
||||
|
||||
|
||||
final Widget card = Card(
|
||||
key: itemKey,
|
||||
key: key ?? itemKey,
|
||||
color: isSelected && _isEditMode
|
||||
? const Color(0xFF2E2E2E)
|
||||
: const Color(0xFF1E1E1E),
|
||||
@@ -539,18 +759,6 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
}
|
||||
widget.onSelectionChanged();
|
||||
});
|
||||
} else if (!isSubCard) {
|
||||
if (isExpanded) {
|
||||
setState(() {
|
||||
_expandedStates[record.uniqueId] = false;
|
||||
_mapOptimalZoom.remove(record.uniqueId);
|
||||
_mapCalculating.remove(record.uniqueId);
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_expandedStates[record.uniqueId] = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongPress: () {
|
||||
@@ -568,12 +776,13 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
_buildRecordHeader(record),
|
||||
_buildPositionAndSpeed(record),
|
||||
_buildLocoInfo(record),
|
||||
if (isExpanded) _buildExpandedContent(record)
|
||||
if (isExpanded) _buildExpandedContent(record),
|
||||
]))));
|
||||
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_itemHeightCache <= 0 && itemKey.currentContext != null) {
|
||||
final RenderBox renderBox = itemKey.currentContext!.findRenderObject() as RenderBox;
|
||||
final RenderBox renderBox =
|
||||
itemKey.currentContext!.findRenderObject() as RenderBox;
|
||||
final double realHeight = renderBox.size.height;
|
||||
if (realHeight > 0) {
|
||||
setState(() {
|
||||
@@ -582,14 +791,12 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
Widget _buildRecordHeader(TrainRecord record, {bool isMerged = false}) {
|
||||
final trainType = record.trainType;
|
||||
final trainDisplay =
|
||||
record.fullTrainNumber.isEmpty ? "未知列车" : record.fullTrainNumber;
|
||||
String formattedLocoInfo = "";
|
||||
if (record.locoType.isNotEmpty && record.loco.isNotEmpty) {
|
||||
final shortLoco = record.loco.length > 5
|
||||
@@ -601,6 +808,22 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
} else if (record.loco.isNotEmpty) {
|
||||
formattedLocoInfo = record.loco;
|
||||
}
|
||||
|
||||
if (record.fullTrainNumber.isEmpty && formattedLocoInfo.isEmpty) {
|
||||
return Text(
|
||||
(record.time == "<NUL>" || record.time.isEmpty)
|
||||
? record.receivedTimestamp.toString().split(".")[0]
|
||||
: record.time.split("\n")[0],
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||
overflow: TextOverflow.ellipsis);
|
||||
}
|
||||
|
||||
final hasTrainNumber = record.fullTrainNumber.isNotEmpty;
|
||||
final hasDirection = record.direction == 1 || record.direction == 3;
|
||||
final hasLocoInfo =
|
||||
formattedLocoInfo.isNotEmpty && formattedLocoInfo != "<NUL>";
|
||||
final shouldShowTrainRow = hasTrainNumber || hasDirection || hasLocoInfo;
|
||||
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||||
Flexible(
|
||||
@@ -616,43 +839,47 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||
overflow: TextOverflow.ellipsis))
|
||||
]),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(trainDisplay,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white),
|
||||
overflow: TextOverflow.ellipsis)),
|
||||
const SizedBox(width: 6),
|
||||
if (record.direction == 1 || record.direction == 3)
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(2)),
|
||||
child: Center(
|
||||
child: Text(record.direction == 1 ? "下" : "上",
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black))))
|
||||
])),
|
||||
if (formattedLocoInfo.isNotEmpty && formattedLocoInfo != "<NUL>")
|
||||
Text(formattedLocoInfo,
|
||||
style: const TextStyle(fontSize: 14, color: Colors.white70))
|
||||
]),
|
||||
const SizedBox(height: 2)
|
||||
if (shouldShowTrainRow) ...[
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (hasTrainNumber)
|
||||
Flexible(
|
||||
child: Text(record.fullTrainNumber,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white),
|
||||
overflow: TextOverflow.ellipsis)),
|
||||
if (hasTrainNumber && hasDirection)
|
||||
const SizedBox(width: 6),
|
||||
if (hasDirection)
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(2)),
|
||||
child: Center(
|
||||
child: Text(record.direction == 1 ? "下" : "上",
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black))))
|
||||
])),
|
||||
if (hasLocoInfo)
|
||||
Text(formattedLocoInfo,
|
||||
style: const TextStyle(fontSize: 14, color: Colors.white70))
|
||||
]),
|
||||
const SizedBox(height: 2)
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -769,6 +996,7 @@ class HistoryScreenState extends State<HistoryScreen> {
|
||||
position: position,
|
||||
zoom: zoomLevel,
|
||||
recordId: record.uniqueId,
|
||||
currentUserLocation: _currentUserLocation,
|
||||
))
|
||||
]);
|
||||
}
|
||||
@@ -909,12 +1137,14 @@ class _DelayedMapWithMarker extends StatefulWidget {
|
||||
final LatLng position;
|
||||
final double zoom;
|
||||
final String recordId;
|
||||
final LatLng? currentUserLocation;
|
||||
|
||||
const _DelayedMapWithMarker({
|
||||
Key? key,
|
||||
required this.position,
|
||||
required this.zoom,
|
||||
required this.recordId,
|
||||
this.currentUserLocation,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -972,6 +1202,44 @@ class _DelayedMapWithMarkerState extends State<_DelayedMapWithMarker> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final markers = <Marker>[
|
||||
Marker(
|
||||
point: widget.position,
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white, width: 1.5),
|
||||
),
|
||||
child: const Icon(Icons.train, color: Colors.white, size: 12),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
if (widget.currentUserLocation != null) {
|
||||
markers.add(
|
||||
Marker(
|
||||
point: widget.currentUserLocation!,
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 1.5),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.my_location,
|
||||
color: Colors.white,
|
||||
size: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_isInitializing) {
|
||||
return FlutterMap(
|
||||
options: MapOptions(
|
||||
@@ -984,19 +1252,7 @@ class _DelayedMapWithMarkerState extends State<_DelayedMapWithMarker> {
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'org.noxylva.lbjconsole'),
|
||||
MarkerLayer(markers: [
|
||||
Marker(
|
||||
point: widget.position,
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white, width: 2)),
|
||||
child:
|
||||
const Icon(Icons.train, color: Colors.white, size: 20)))
|
||||
])
|
||||
MarkerLayer(markers: markers),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1010,19 +1266,7 @@ class _DelayedMapWithMarkerState extends State<_DelayedMapWithMarker> {
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'org.noxylva.lbjconsole'),
|
||||
MarkerLayer(markers: [
|
||||
Marker(
|
||||
point: widget.position,
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white, width: 2)),
|
||||
child:
|
||||
const Icon(Icons.train, color: Colors.white, size: 20)))
|
||||
])
|
||||
MarkerLayer(markers: markers),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1033,6 +1277,7 @@ class _DelayedMultiMarkerMap extends StatefulWidget {
|
||||
final LatLng center;
|
||||
final double zoom;
|
||||
final String groupKey;
|
||||
final LatLng? currentUserLocation;
|
||||
|
||||
const _DelayedMultiMarkerMap({
|
||||
Key? key,
|
||||
@@ -1040,6 +1285,7 @@ class _DelayedMultiMarkerMap extends StatefulWidget {
|
||||
required this.center,
|
||||
required this.zoom,
|
||||
required this.groupKey,
|
||||
this.currentUserLocation,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -1099,10 +1345,45 @@ class _DelayedMultiMarkerMapState extends State<_DelayedMultiMarkerMap> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final markers = <Marker>[
|
||||
...widget.positions.map((pos) => Marker(
|
||||
point: pos,
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.8),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 1.5)),
|
||||
child: const Icon(Icons.train, color: Colors.white, size: 12)))),
|
||||
];
|
||||
|
||||
if (widget.currentUserLocation != null) {
|
||||
markers.add(
|
||||
Marker(
|
||||
point: widget.currentUserLocation!,
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 1.5),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.my_location,
|
||||
color: Colors.white,
|
||||
size: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return FlutterMap(
|
||||
options: MapOptions(
|
||||
onPositionChanged: (position, hasGesture) => _onCameraMove(),
|
||||
minZoom: 5,
|
||||
minZoom: 8,
|
||||
maxZoom: 18,
|
||||
),
|
||||
mapController: _mapController,
|
||||
@@ -1111,20 +1392,7 @@ class _DelayedMultiMarkerMapState extends State<_DelayedMultiMarkerMap> {
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'org.noxylva.lbjconsole',
|
||||
),
|
||||
MarkerLayer(
|
||||
markers: widget.positions
|
||||
.map((pos) => Marker(
|
||||
point: pos,
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.8),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2)),
|
||||
child: const Icon(Icons.train,
|
||||
color: Colors.white, size: 20))))
|
||||
.toList()),
|
||||
MarkerLayer(markers: markers),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:lbjconsole/models/merged_record.dart';
|
||||
import 'package:lbjconsole/models/train_record.dart';
|
||||
import 'package:lbjconsole/screens/history_screen.dart';
|
||||
import 'package:lbjconsole/screens/map_screen.dart';
|
||||
import 'package:lbjconsole/screens/map_webview_screen.dart';
|
||||
import 'package:lbjconsole/screens/settings_screen.dart';
|
||||
import 'package:lbjconsole/services/ble_service.dart';
|
||||
import 'package:lbjconsole/services/database_service.dart';
|
||||
@@ -174,6 +175,7 @@ class MainScreen extends StatefulWidget {
|
||||
|
||||
class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
|
||||
int _currentIndex = 0;
|
||||
String _mapType = 'webview';
|
||||
|
||||
late final BLEService _bleService;
|
||||
final NotificationService _notificationService = NotificationService();
|
||||
@@ -195,8 +197,19 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
|
||||
_initializeServices();
|
||||
_checkAndStartBackgroundService();
|
||||
_setupLastReceivedTimeListener();
|
||||
_loadMapType();
|
||||
}
|
||||
|
||||
Future<void> _loadMapType() async {
|
||||
final settings = await DatabaseService.instance.getAllSettings() ?? {};
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_mapType = settings['mapType']?.toString() ?? 'webview';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> _checkAndStartBackgroundService() async {
|
||||
final settings = await DatabaseService.instance.getAllSettings() ?? {};
|
||||
final backgroundServiceEnabled =
|
||||
@@ -231,6 +244,7 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_bleService.onAppResume();
|
||||
_loadMapType();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,8 +394,12 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
|
||||
onEditModeChanged: _handleHistoryEditModeChanged,
|
||||
onSelectionChanged: _handleSelectionChanged,
|
||||
),
|
||||
const MapScreen(),
|
||||
const SettingsScreen(),
|
||||
_mapType == 'map' ? const MapScreen() : const MapWebViewScreen(),
|
||||
SettingsScreen(
|
||||
onSettingsChanged: () {
|
||||
_loadMapType();
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
@@ -397,7 +415,11 @@ class _MainScreenState extends State<MainScreen> with WidgetsBindingObserver {
|
||||
selectedIndex: _currentIndex,
|
||||
onDestinationSelected: (index) {
|
||||
if (_currentIndex == 2 && index == 0) {
|
||||
_historyScreenKey.currentState?.loadRecords();
|
||||
_historyScreenKey.currentState?.reloadRecords();
|
||||
}
|
||||
// 如果从设置页面切换到地图页面,重新加载地图类型
|
||||
if (_currentIndex == 2 && index == 1) {
|
||||
_loadMapType();
|
||||
}
|
||||
setState(() {
|
||||
if (_isHistoryEditMode) _isHistoryEditMode = false;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' show sin, cos, sqrt, atan2, pi;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
@@ -21,7 +22,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
LatLng? _currentLocation;
|
||||
LatLng? _lastTrainLocation;
|
||||
LatLng? _userLocation;
|
||||
double _currentZoom = 12.0;
|
||||
double _currentZoom = 14.0;
|
||||
double _currentRotation = 0.0;
|
||||
|
||||
bool _isMapInitialized = false;
|
||||
@@ -29,14 +30,82 @@ class _MapScreenState extends State<MapScreen> {
|
||||
bool _isLocationPermissionGranted = false;
|
||||
Timer? _locationTimer;
|
||||
|
||||
String _selectedTimeFilter = 'unlimited';
|
||||
final Map<String, Duration> _timeFilterOptions = {
|
||||
'unlimited': Duration.zero,
|
||||
'1hour': Duration(hours: 1),
|
||||
'6hours': Duration(hours: 6),
|
||||
'12hours': Duration(hours: 12),
|
||||
'24hours': Duration(hours: 24),
|
||||
'7days': Duration(days: 7),
|
||||
'30days': Duration(days: 30),
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeMap();
|
||||
_loadTrainRecords();
|
||||
|
||||
_checkDatabaseSettings();
|
||||
|
||||
_loadSettings().then((_) {
|
||||
_loadTrainRecords().then((_) {
|
||||
_startLocationUpdates();
|
||||
if (!_isMapInitialized && (_currentLocation != null || _lastTrainLocation != null || _userLocation != null)) {
|
||||
_initializeMapPosition();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_loadSettings();
|
||||
_startLocationUpdates();
|
||||
}
|
||||
|
||||
Future<void> _checkDatabaseSettings() async {
|
||||
try {
|
||||
final dbInfo = await DatabaseService.instance.getDatabaseInfo();
|
||||
|
||||
final settings = await DatabaseService.instance.getAllSettings();
|
||||
|
||||
if (settings != null) {
|
||||
final lat = settings['mapCenterLat'];
|
||||
final lon = settings['mapCenterLon'];
|
||||
|
||||
if (lat != null && lon != null) {
|
||||
if (lat == 39.9042 && lon == 116.4074) {
|
||||
} else if (lat == 0.0 && lon == 0.0) {
|
||||
} else {
|
||||
final beijingLat = 39.9042;
|
||||
final beijingLon = 116.4074;
|
||||
final distance =
|
||||
_calculateDistance(lat, lon, beijingLat, beijingLon);
|
||||
|
||||
if (distance < 50) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
double _calculateDistance(
|
||||
double lat1, double lon1, double lat2, double lon2) {
|
||||
const earthRadius = 6371;
|
||||
final dLat = _degreesToRadians(lat2 - lat1);
|
||||
final dLon = _degreesToRadians(lon2 - lon1);
|
||||
final a = sin(dLat / 2) * sin(dLat / 2) +
|
||||
cos(_degreesToRadians(lat1)) *
|
||||
cos(_degreesToRadians(lat2)) *
|
||||
sin(dLon / 2) *
|
||||
sin(dLon / 2);
|
||||
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
|
||||
return earthRadius * c;
|
||||
}
|
||||
|
||||
double _degreesToRadians(double degrees) {
|
||||
return degrees * pi / 180;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -80,19 +149,23 @@ class _MapScreenState extends State<MapScreen> {
|
||||
try {
|
||||
Position position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high,
|
||||
forceAndroidLocationManager: true,
|
||||
);
|
||||
|
||||
final newLocation = LatLng(position.latitude, position.longitude);
|
||||
setState(() {
|
||||
_userLocation = LatLng(position.latitude, position.longitude);
|
||||
_userLocation = newLocation;
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
}
|
||||
if (!_isMapInitialized) {
|
||||
_initializeMapPosition();
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
void _startLocationUpdates() {
|
||||
_requestLocationPermission();
|
||||
|
||||
|
||||
_locationTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
|
||||
if (_isLocationPermissionGranted) {
|
||||
_getCurrentLocation();
|
||||
@@ -101,24 +174,22 @@ class _MapScreenState extends State<MapScreen> {
|
||||
}
|
||||
|
||||
Future<void> _forceUpdateLocation() async {
|
||||
|
||||
try {
|
||||
Position position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.best,
|
||||
forceAndroidLocationManager: true,
|
||||
);
|
||||
|
||||
final newLocation = LatLng(position.latitude, position.longitude);
|
||||
|
||||
|
||||
setState(() {
|
||||
_userLocation = newLocation;
|
||||
});
|
||||
|
||||
_mapController.move(newLocation, 15.0);
|
||||
} catch (e) {
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
try {
|
||||
final settings = await DatabaseService.instance.getAllSettings();
|
||||
@@ -129,14 +200,19 @@ class _MapScreenState extends State<MapScreen> {
|
||||
_currentZoom = (settings['mapZoomLevel'] as num?)?.toDouble() ?? 10.0;
|
||||
_currentRotation =
|
||||
(settings['mapRotation'] as num?)?.toDouble() ?? 0.0;
|
||||
_selectedTimeFilter =
|
||||
settings['mapTimeFilter'] as String? ?? 'unlimited';
|
||||
|
||||
final lat = (settings['mapCenterLat'] as num?)?.toDouble();
|
||||
final lon = (settings['mapCenterLon'] as num?)?.toDouble();
|
||||
|
||||
if (lat != null && lon != null) {
|
||||
if (lat != null && lon != null && lat != 0.0 && lon != 0.0) {
|
||||
_currentLocation = LatLng(lat, lon);
|
||||
}
|
||||
});
|
||||
if (!_isMapInitialized) {
|
||||
_initializeMapPosition();
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
@@ -144,20 +220,32 @@ class _MapScreenState extends State<MapScreen> {
|
||||
Future<void> _saveSettings() async {
|
||||
try {
|
||||
final center = _mapController.camera.center;
|
||||
await DatabaseService.instance.updateSettings({
|
||||
|
||||
final isDefaultLocation =
|
||||
center.latitude == 39.9042 && center.longitude == 116.4074;
|
||||
|
||||
final settings = {
|
||||
'mapRailwayLayerVisible': _railwayLayerVisible ? 1 : 0,
|
||||
'mapZoomLevel': _currentZoom,
|
||||
'mapCenterLat': center.latitude,
|
||||
'mapCenterLon': center.longitude,
|
||||
'mapRotation': _currentRotation,
|
||||
});
|
||||
'mapTimeFilter': _selectedTimeFilter,
|
||||
};
|
||||
|
||||
if (!isDefaultLocation) {
|
||||
settings['mapCenterLat'] = center.latitude;
|
||||
settings['mapCenterLon'] = center.longitude;
|
||||
}
|
||||
|
||||
settings['mapSettingsTimestamp'] = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
await DatabaseService.instance.updateSettings(settings);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
Future<void> _loadTrainRecords() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final records = await DatabaseService.instance.getAllRecords();
|
||||
final records = await _getFilteredRecords();
|
||||
setState(() {
|
||||
_trainRecords.clear();
|
||||
_trainRecords.addAll(records);
|
||||
@@ -175,13 +263,28 @@ class _MapScreenState extends State<MapScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
_initializeMapPosition();
|
||||
if (!_isMapInitialized) {
|
||||
_initializeMapPosition();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<TrainRecord>> _getFilteredRecords() async {
|
||||
if (_selectedTimeFilter == 'unlimited') {
|
||||
return await DatabaseService.instance.getAllRecords();
|
||||
} else {
|
||||
final duration = _timeFilterOptions[_selectedTimeFilter];
|
||||
if (duration != null && duration != Duration.zero) {
|
||||
return await DatabaseService.instance
|
||||
.getRecordsWithinReceivedTimeRange(duration);
|
||||
}
|
||||
return await DatabaseService.instance.getAllRecords();
|
||||
}
|
||||
}
|
||||
|
||||
void _initializeMapPosition() {
|
||||
if (_isMapInitialized) return;
|
||||
|
||||
@@ -189,21 +292,21 @@ class _MapScreenState extends State<MapScreen> {
|
||||
|
||||
if (_currentLocation != null) {
|
||||
targetLocation = _currentLocation;
|
||||
} else if (_userLocation != null) {
|
||||
targetLocation = _userLocation;
|
||||
} else if (_lastTrainLocation != null) {
|
||||
targetLocation = _lastTrainLocation;
|
||||
} else if (_userLocation != null) {
|
||||
targetLocation = _userLocation;
|
||||
} else {
|
||||
_isMapInitialized = true;
|
||||
return;
|
||||
targetLocation = const LatLng(39.9042, 116.4074);
|
||||
}
|
||||
|
||||
_centerMap(targetLocation!, zoom: _currentZoom);
|
||||
_centerMap(targetLocation!, zoom: _currentZoom, rotation: _currentRotation);
|
||||
_isMapInitialized = true;
|
||||
}
|
||||
|
||||
void _centerMap(LatLng location, {double? zoom}) {
|
||||
void _centerMap(LatLng location, {double? zoom, double? rotation}) {
|
||||
_mapController.move(location, zoom ?? _currentZoom);
|
||||
_mapController.rotate(rotation ?? _currentRotation);
|
||||
}
|
||||
|
||||
LatLng? _parseDmsCoordinate(String? positionInfo) {
|
||||
@@ -228,9 +331,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
return LatLng(lat, lng);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('解析DMS坐标失败: $e');
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -294,41 +395,27 @@ class _MapScreenState extends State<MapScreen> {
|
||||
Marker(
|
||||
point: position,
|
||||
width: 80,
|
||||
height: 60,
|
||||
height: 16,
|
||||
child: GestureDetector(
|
||||
onTap: () => position != null
|
||||
? _showTrainDetailsDialog(record, position)
|
||||
: null,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.train,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
color: Colors.black.withOpacity(0.8),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
trainDisplay,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -347,7 +434,9 @@ class _MapScreenState extends State<MapScreen> {
|
||||
}
|
||||
|
||||
void _centerToMyLocation() {
|
||||
_centerMap(_lastTrainLocation ?? const LatLng(39.9042, 116.4074), zoom: 15.0);
|
||||
if (_userLocation != null) {
|
||||
_centerMap(_userLocation!, zoom: 15.0, rotation: _currentRotation);
|
||||
}
|
||||
}
|
||||
|
||||
void _centerToLastTrain() {
|
||||
@@ -364,11 +453,73 @@ class _MapScreenState extends State<MapScreen> {
|
||||
}
|
||||
|
||||
if (targetPosition != null) {
|
||||
_centerMap(targetPosition, zoom: 15.0);
|
||||
_centerMap(targetPosition, zoom: 15.0, rotation: _currentRotation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showTimeFilterDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('时间筛选'),
|
||||
content: SizedBox(
|
||||
width: double.minPositive,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _timeFilterOptions.keys.map((key) {
|
||||
return RadioListTile<String>(
|
||||
title: Text(_getTimeFilterLabel(key)),
|
||||
value: key,
|
||||
groupValue: _selectedTimeFilter,
|
||||
onChanged: (String? value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_selectedTimeFilter = value;
|
||||
});
|
||||
_loadTrainRecords();
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _getTimeFilterLabel(String key) {
|
||||
switch (key) {
|
||||
case 'unlimited':
|
||||
return '全部时间';
|
||||
case '1hour':
|
||||
return '最近1小时';
|
||||
case '6hours':
|
||||
return '最近6小时';
|
||||
case '12hours':
|
||||
return '最近12小时';
|
||||
case '24hours':
|
||||
return '最近24小时';
|
||||
case '7days':
|
||||
return '最近7天';
|
||||
case '30days':
|
||||
return '最近30天';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
}
|
||||
|
||||
void _showTrainDetailsDialog(TrainRecord record, LatLng position) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -429,16 +580,26 @@ class _MapScreenState extends State<MapScreen> {
|
||||
child: Column(
|
||||
children: [
|
||||
_buildMaterial3DetailRow(
|
||||
context, "时间", record.formattedTime),
|
||||
context, "时间", _getDisplayTime(record)),
|
||||
_buildMaterial3DetailRow(
|
||||
context, "日期", record.formattedDate),
|
||||
context, "日期", _getDisplayDate(record)),
|
||||
_buildMaterial3DetailRow(
|
||||
context, "类型", record.trainType),
|
||||
_buildMaterial3DetailRow(context, "速度",
|
||||
"${record.speed.replaceAll(' ', '')} km/h"),
|
||||
_buildMaterial3DetailRow(
|
||||
context, "速度", "${record.speed.replaceAll(' ', '')} km/h"),
|
||||
context,
|
||||
"位置",
|
||||
record.position.trim().endsWith('.')
|
||||
? '${record.position.trim().substring(0, record.position.trim().length - 1)}K'
|
||||
: '${record.position.trim()}K'),
|
||||
_buildMaterial3DetailRow(
|
||||
context, "位置", record.position.trim().endsWith('.') ? '${record.position.trim().substring(0, record.position.trim().length - 1)}K' : '${record.position.trim()}K'),
|
||||
_buildMaterial3DetailRow(context, "路线", record.route.trim().endsWith('.') ? record.route.trim().substring(0, record.route.trim().length - 1) : record.route.trim()),
|
||||
context,
|
||||
"路线",
|
||||
record.route.trim().endsWith('.')
|
||||
? record.route.trim().substring(
|
||||
0, record.route.trim().length - 1)
|
||||
: record.route.trim()),
|
||||
_buildMaterial3DetailRow(
|
||||
context, "机车", "${record.locoType}-${record.loco}"),
|
||||
_buildMaterial3DetailRow(context, "坐标",
|
||||
@@ -461,7 +622,8 @@ class _MapScreenState extends State<MapScreen> {
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_centerMap(position, zoom: 17.0);
|
||||
_centerMap(position,
|
||||
zoom: 17.0, rotation: _currentRotation);
|
||||
},
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -507,6 +669,25 @@ class _MapScreenState extends State<MapScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
String _getDisplayTime(TrainRecord record) {
|
||||
if (record.time == "<NUL>" || record.time.isEmpty) {
|
||||
final receivedTime = record.receivedTimestamp;
|
||||
return '${receivedTime.hour.toString().padLeft(2, '0')}:${receivedTime.minute.toString().padLeft(2, '0')}:${receivedTime.second.toString().padLeft(2, '0')}';
|
||||
} else {
|
||||
return record.time.split("\n")[0];
|
||||
}
|
||||
}
|
||||
|
||||
String _getDisplayDate(TrainRecord record) {
|
||||
if (record.time == "<NUL>" || record.time.isEmpty) {
|
||||
final receivedTime = record.receivedTimestamp;
|
||||
return '${receivedTime.year}-${receivedTime.month.toString().padLeft(2, '0')}-${receivedTime.day.toString().padLeft(2, '0')}';
|
||||
} else {
|
||||
final now = DateTime.now();
|
||||
return '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildMaterial3DetailRow(
|
||||
BuildContext context, String label, String value) {
|
||||
return Padding(
|
||||
@@ -546,46 +727,67 @@ class _MapScreenState extends State<MapScreen> {
|
||||
markers.add(
|
||||
Marker(
|
||||
point: _userLocation!,
|
||||
width: 40,
|
||||
height: 40,
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white, width: 1),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.my_location,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
size: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final bool isDefaultLocation = _currentLocation == null &&
|
||||
_lastTrainLocation == null &&
|
||||
_userLocation == null;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF121212),
|
||||
body: Stack(
|
||||
children: [
|
||||
FlutterMap(
|
||||
if (isDefaultLocation)
|
||||
const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF007ACC)),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'正在加载地图位置...',
|
||||
style: TextStyle(color: Colors.white, fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: _lastTrainLocation ?? const LatLng(39.9042, 116.4074),
|
||||
initialCenter: _currentLocation ??
|
||||
_lastTrainLocation ??
|
||||
_userLocation ??
|
||||
const LatLng(39.9042, 116.4074),
|
||||
initialZoom: _currentZoom,
|
||||
initialRotation: _currentRotation,
|
||||
minZoom: 4.0,
|
||||
minZoom: 8.0,
|
||||
maxZoom: 18.0,
|
||||
|
||||
onPositionChanged: (MapCamera camera, bool hasGesture) {
|
||||
if (hasGesture) {
|
||||
setState(() {
|
||||
_currentLocation = camera.center;
|
||||
_currentZoom = camera.zoom;
|
||||
_currentRotation = camera.rotation;
|
||||
});
|
||||
_saveSettings();
|
||||
}
|
||||
setState(() {
|
||||
_currentLocation = camera.center;
|
||||
_currentZoom = camera.zoom;
|
||||
_currentRotation = camera.rotation;
|
||||
});
|
||||
|
||||
_saveSettings();
|
||||
},
|
||||
),
|
||||
children: [
|
||||
@@ -616,6 +818,13 @@ class _MapScreenState extends State<MapScreen> {
|
||||
top: 40,
|
||||
child: Column(
|
||||
children: [
|
||||
FloatingActionButton.small(
|
||||
heroTag: 'timeFilter',
|
||||
backgroundColor: const Color(0xFF1E1E1E),
|
||||
onPressed: _showTimeFilterDialog,
|
||||
child: const Icon(Icons.filter_list, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FloatingActionButton.small(
|
||||
heroTag: 'railwayLayer',
|
||||
backgroundColor: const Color(0xFF1E1E1E),
|
||||
|
||||
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';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
final VoidCallback? onSettingsChanged;
|
||||
|
||||
const SettingsScreen({super.key, this.onSettingsChanged});
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
@@ -32,8 +34,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _notificationsEnabled = true;
|
||||
int _recordCount = 0;
|
||||
bool _mergeRecordsEnabled = false;
|
||||
bool _hideTimeOnlyRecords = false;
|
||||
bool _hideUngroupableRecords = false;
|
||||
GroupBy _groupBy = GroupBy.trainAndLoco;
|
||||
TimeWindow _timeWindow = TimeWindow.unlimited;
|
||||
String _mapType = 'map';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -61,8 +66,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
(settingsMap['backgroundServiceEnabled'] ?? 0) == 1;
|
||||
_notificationsEnabled = (settingsMap['notificationEnabled'] ?? 1) == 1;
|
||||
_mergeRecordsEnabled = settings.enabled;
|
||||
_hideTimeOnlyRecords = (settingsMap['hideTimeOnlyRecords'] ?? 0) == 1;
|
||||
_hideUngroupableRecords = settings.hideUngroupableRecords;
|
||||
_groupBy = settings.groupBy;
|
||||
_timeWindow = settings.timeWindow;
|
||||
_mapType = settingsMap['mapType']?.toString() ?? 'webview';
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -82,9 +90,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
'backgroundServiceEnabled': _backgroundServiceEnabled ? 1 : 0,
|
||||
'notificationEnabled': _notificationsEnabled ? 1 : 0,
|
||||
'mergeRecordsEnabled': _mergeRecordsEnabled ? 1 : 0,
|
||||
'hideTimeOnlyRecords': _hideTimeOnlyRecords ? 1 : 0,
|
||||
'hideUngroupableRecords': _hideUngroupableRecords ? 1 : 0,
|
||||
'groupBy': _groupBy.name,
|
||||
'timeWindow': _timeWindow.name,
|
||||
'mapType': _mapType,
|
||||
});
|
||||
widget.onSettingsChanged?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -236,6 +248,66 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('地图显示方式', style: AppTheme.bodyLarge),
|
||||
Text('选择地图组件类型', style: AppTheme.caption),
|
||||
],
|
||||
),
|
||||
DropdownButton<String>(
|
||||
value: _mapType,
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: 'webview',
|
||||
child: Text('矢量铁路地图', style: AppTheme.bodyMedium),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'map',
|
||||
child: Text('栅格铁路地图', style: AppTheme.bodyMedium),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_mapType = value;
|
||||
});
|
||||
_saveSettings();
|
||||
}
|
||||
},
|
||||
dropdownColor: AppTheme.secondaryBlack,
|
||||
style: AppTheme.bodyMedium,
|
||||
underline: Container(height: 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('隐藏只有时间有效的记录', style: AppTheme.bodyLarge),
|
||||
Text('不显示只有时间信息的记录', style: AppTheme.caption),
|
||||
],
|
||||
),
|
||||
Switch(
|
||||
value: _hideTimeOnlyRecords,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_hideTimeOnlyRecords = value;
|
||||
});
|
||||
_saveSettings();
|
||||
},
|
||||
activeColor: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -372,6 +444,29 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
dropdownColor: AppTheme.secondaryBlack,
|
||||
style: AppTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('隐藏不可分组记录', style: AppTheme.bodyLarge),
|
||||
Text('不显示无法分组的记录', style: AppTheme.caption),
|
||||
],
|
||||
),
|
||||
Switch(
|
||||
value: _hideUngroupableRecords,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_hideUngroupableRecords = value;
|
||||
});
|
||||
_saveSettings();
|
||||
},
|
||||
activeColor: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -395,7 +490,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.storage, color: Theme.of(context).colorScheme.primary),
|
||||
Icon(Icons.storage,
|
||||
color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Text('数据管理', style: AppTheme.titleMedium),
|
||||
],
|
||||
|
||||
@@ -20,9 +20,9 @@ class BackgroundService {
|
||||
if (_isInitialized) return;
|
||||
|
||||
final service = FlutterBackgroundService();
|
||||
|
||||
|
||||
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
|
||||
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
const AndroidNotificationChannel channel = AndroidNotificationChannel(
|
||||
_notificationChannelId,
|
||||
@@ -34,10 +34,12 @@ class BackgroundService {
|
||||
playSound: false,
|
||||
);
|
||||
|
||||
await flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()?.createNotificationChannel(channel);
|
||||
await flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
|
||||
await service.configure(
|
||||
androidConfiguration: AndroidConfiguration(
|
||||
onStart: _onStart,
|
||||
@@ -81,8 +83,9 @@ class BackgroundService {
|
||||
if (service is AndroidServiceInstance) {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (await service.isForegroundService()) {
|
||||
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
|
||||
|
||||
final flutterLocalNotificationsPlugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
try {
|
||||
const AndroidNotificationChannel channel = AndroidNotificationChannel(
|
||||
_notificationChannelId,
|
||||
@@ -94,8 +97,10 @@ class BackgroundService {
|
||||
playSound: false,
|
||||
);
|
||||
|
||||
await flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()?.createNotificationChannel(channel);
|
||||
await flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.createNotificationChannel(channel);
|
||||
|
||||
await flutterLocalNotificationsPlugin.show(
|
||||
_notificationId,
|
||||
@@ -122,10 +127,7 @@ class BackgroundService {
|
||||
),
|
||||
),
|
||||
);
|
||||
print('前台服务通知显示成功');
|
||||
} catch (e) {
|
||||
print('前台服务通知显示失败: $e');
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,8 +138,9 @@ class BackgroundService {
|
||||
final bleService = BLEService();
|
||||
final isConnected = bleService.isConnected;
|
||||
final deviceStatus = bleService.deviceStatus;
|
||||
|
||||
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
|
||||
|
||||
final flutterLocalNotificationsPlugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
await flutterLocalNotificationsPlugin.show(
|
||||
_notificationId,
|
||||
'LBJ Console',
|
||||
@@ -163,9 +166,7 @@ class BackgroundService {
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
print('前台服务通知更新失败: $e');
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -179,7 +180,7 @@ class BackgroundService {
|
||||
static Future<void> startService() async {
|
||||
await initialize();
|
||||
final service = FlutterBackgroundService();
|
||||
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final isRunning = await service.isRunning();
|
||||
if (!isRunning) {
|
||||
@@ -208,4 +209,4 @@ class BackgroundService {
|
||||
service.invoke('setAsBackground');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +319,10 @@ class BLEService {
|
||||
'${now.millisecondsSinceEpoch}_${Random().nextInt(9999)}';
|
||||
recordData['receivedTimestamp'] = now.millisecondsSinceEpoch;
|
||||
|
||||
if (!recordData.containsKey('timestamp')) {
|
||||
recordData['timestamp'] = now.millisecondsSinceEpoch;
|
||||
}
|
||||
|
||||
_lastReceivedTime = now;
|
||||
_lastReceivedTimeController.add(_lastReceivedTime);
|
||||
|
||||
@@ -326,9 +330,7 @@ class BLEService {
|
||||
_dataController.add(trainRecord);
|
||||
DatabaseService.instance.insertRecord(trainRecord);
|
||||
}
|
||||
} catch (e) {
|
||||
print("$TAG: JSON Decode Error: $e, Data: $jsonData");
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
void _updateConnectionState(bool connected, String status) {
|
||||
|
||||
@@ -13,7 +13,7 @@ class DatabaseService {
|
||||
DatabaseService._internal();
|
||||
|
||||
static const String _databaseName = 'train_database';
|
||||
static const _databaseVersion = 1;
|
||||
static const _databaseVersion = 7;
|
||||
|
||||
static const String trainRecordsTable = 'train_records';
|
||||
static const String appSettingsTable = 'app_settings';
|
||||
@@ -21,20 +21,76 @@ class DatabaseService {
|
||||
Database? _database;
|
||||
|
||||
Future<Database> get database async {
|
||||
if (_database != null) return _database!;
|
||||
_database = await _initDatabase();
|
||||
return _database!;
|
||||
try {
|
||||
if (_database != null) {
|
||||
return _database!;
|
||||
}
|
||||
_database = await _initDatabase();
|
||||
return _database!;
|
||||
} catch (e, stackTrace) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> isDatabaseConnected() async {
|
||||
try {
|
||||
if (_database == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final db = await database;
|
||||
final result = await db.rawQuery('SELECT 1');
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Database> _initDatabase() async {
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final path = join(directory.path, _databaseName);
|
||||
try {
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final path = join(directory.path, _databaseName);
|
||||
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: _databaseVersion,
|
||||
onCreate: _onCreate,
|
||||
);
|
||||
final db = await openDatabase(
|
||||
path,
|
||||
version: _databaseVersion,
|
||||
onCreate: _onCreate,
|
||||
onUpgrade: _onUpgrade,
|
||||
);
|
||||
|
||||
return db;
|
||||
} catch (e, stackTrace) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||
if (oldVersion < 2) {
|
||||
await db.execute(
|
||||
'ALTER TABLE $appSettingsTable ADD COLUMN hideTimeOnlyRecords INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
if (oldVersion < 3) {
|
||||
await db.execute(
|
||||
'ALTER TABLE $appSettingsTable ADD COLUMN mapTimeFilter TEXT NOT NULL DEFAULT "unlimited"');
|
||||
}
|
||||
if (oldVersion < 4) {
|
||||
try {
|
||||
await db.execute(
|
||||
'ALTER TABLE $appSettingsTable ADD COLUMN mapTimeFilter TEXT NOT NULL DEFAULT "unlimited"');
|
||||
} catch (e) {}
|
||||
}
|
||||
if (oldVersion < 5) {
|
||||
await db.execute(
|
||||
'ALTER TABLE $appSettingsTable ADD COLUMN mapType TEXT NOT NULL DEFAULT "webview"');
|
||||
}
|
||||
if (oldVersion < 6) {
|
||||
await db.execute(
|
||||
'ALTER TABLE $appSettingsTable ADD COLUMN hideUngroupableRecords INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
if (oldVersion < 7) {
|
||||
await db.execute(
|
||||
'ALTER TABLE $appSettingsTable ADD COLUMN mapSettingsTimestamp INTEGER');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCreate(Database db, int version) async {
|
||||
@@ -73,14 +129,19 @@ class DatabaseService {
|
||||
mapZoomLevel REAL NOT NULL DEFAULT 10.0,
|
||||
mapRailwayLayerVisible INTEGER NOT NULL DEFAULT 1,
|
||||
mapRotation REAL NOT NULL DEFAULT 0.0,
|
||||
mapType TEXT NOT NULL DEFAULT 'webview',
|
||||
specifiedDeviceAddress TEXT,
|
||||
searchOrderList TEXT NOT NULL DEFAULT '',
|
||||
autoConnectEnabled INTEGER NOT NULL DEFAULT 1,
|
||||
backgroundServiceEnabled INTEGER NOT NULL DEFAULT 0,
|
||||
notificationEnabled INTEGER NOT NULL DEFAULT 0,
|
||||
mergeRecordsEnabled INTEGER NOT NULL DEFAULT 0,
|
||||
hideTimeOnlyRecords INTEGER NOT NULL DEFAULT 0,
|
||||
groupBy TEXT NOT NULL DEFAULT 'trainAndLoco',
|
||||
timeWindow TEXT NOT NULL DEFAULT 'unlimited'
|
||||
timeWindow TEXT NOT NULL DEFAULT 'unlimited',
|
||||
mapTimeFilter TEXT NOT NULL DEFAULT 'unlimited',
|
||||
hideUngroupableRecords INTEGER NOT NULL DEFAULT 0,
|
||||
mapSettingsTimestamp INTEGER
|
||||
)
|
||||
''');
|
||||
|
||||
@@ -97,13 +158,18 @@ class DatabaseService {
|
||||
'mapZoomLevel': 10.0,
|
||||
'mapRailwayLayerVisible': 1,
|
||||
'mapRotation': 0.0,
|
||||
'mapType': 'webview',
|
||||
'searchOrderList': '',
|
||||
'autoConnectEnabled': 1,
|
||||
'backgroundServiceEnabled': 0,
|
||||
'notificationEnabled': 0,
|
||||
'mergeRecordsEnabled': 0,
|
||||
'hideTimeOnlyRecords': 0,
|
||||
'groupBy': 'trainAndLoco',
|
||||
'timeWindow': 'unlimited',
|
||||
'mapTimeFilter': 'unlimited',
|
||||
'hideUngroupableRecords': 0,
|
||||
'mapSettingsTimestamp': null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -117,14 +183,53 @@ class DatabaseService {
|
||||
}
|
||||
|
||||
Future<List<TrainRecord>> getAllRecords() async {
|
||||
try {
|
||||
final db = await database;
|
||||
final result = await db.query(
|
||||
trainRecordsTable,
|
||||
orderBy: 'timestamp DESC',
|
||||
);
|
||||
final records =
|
||||
result.map((json) => TrainRecord.fromDatabaseJson(json)).toList();
|
||||
return records;
|
||||
} catch (e, stackTrace) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<TrainRecord>> getRecordsWithinTimeRange(Duration duration) async {
|
||||
final db = await database;
|
||||
final cutoffTime = DateTime.now().subtract(duration).millisecondsSinceEpoch;
|
||||
final result = await db.query(
|
||||
trainRecordsTable,
|
||||
where: 'timestamp >= ?',
|
||||
whereArgs: [cutoffTime],
|
||||
orderBy: 'timestamp DESC',
|
||||
);
|
||||
return result.map((json) => TrainRecord.fromDatabaseJson(json)).toList();
|
||||
}
|
||||
|
||||
Future<List<TrainRecord>> getRecordsWithinReceivedTimeRange(
|
||||
Duration duration) async {
|
||||
try {
|
||||
final db = await database;
|
||||
final cutoffTime =
|
||||
DateTime.now().subtract(duration).millisecondsSinceEpoch;
|
||||
|
||||
final result = await db.query(
|
||||
trainRecordsTable,
|
||||
where: 'receivedTimestamp >= ?',
|
||||
whereArgs: [cutoffTime],
|
||||
orderBy: 'receivedTimestamp DESC',
|
||||
);
|
||||
final records =
|
||||
result.map((json) => TrainRecord.fromDatabaseJson(json)).toList();
|
||||
return records;
|
||||
} catch (e, stackTrace) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> deleteRecord(String uniqueId) async {
|
||||
final db = await database;
|
||||
return await db.delete(
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ class MapStateService {
|
||||
MapStateService._internal();
|
||||
|
||||
static const String _tableName = 'record_map_states';
|
||||
|
||||
|
||||
final Map<String, MapState> _memoryCache = {};
|
||||
|
||||
Future<void> _ensureTableExists() async {
|
||||
@@ -34,10 +34,10 @@ class MapStateService {
|
||||
Future<void> saveMapState(String key, MapState state) async {
|
||||
try {
|
||||
_memoryCache[key] = state;
|
||||
|
||||
|
||||
final db = await DatabaseService.instance.database;
|
||||
await _ensureTableExists();
|
||||
|
||||
|
||||
await db.insert(
|
||||
_tableName,
|
||||
{
|
||||
@@ -47,9 +47,7 @@ class MapStateService {
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
} catch (e) {
|
||||
print('保存地图状态失败: $e');
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
Future<MapState?> getMapState(String key) async {
|
||||
@@ -60,7 +58,7 @@ class MapStateService {
|
||||
try {
|
||||
final db = await DatabaseService.instance.database;
|
||||
await _ensureTableExists();
|
||||
|
||||
|
||||
final result = await db.query(
|
||||
_tableName,
|
||||
where: 'key = ?',
|
||||
@@ -74,16 +72,14 @@ class MapStateService {
|
||||
_memoryCache[key] = state;
|
||||
return state;
|
||||
}
|
||||
} catch (e) {
|
||||
print('读取地图状态失败: $e');
|
||||
}
|
||||
|
||||
} catch (e) {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> deleteMapState(String key) async {
|
||||
_memoryCache.remove(key);
|
||||
|
||||
|
||||
try {
|
||||
final db = await DatabaseService.instance.database;
|
||||
await db.delete(
|
||||
@@ -91,23 +87,19 @@ class MapStateService {
|
||||
where: 'key = ?',
|
||||
whereArgs: [key],
|
||||
);
|
||||
} catch (e) {
|
||||
print('删除地图状态失败: $e');
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
Future<void> clearAllMapStates() async {
|
||||
_memoryCache.clear();
|
||||
|
||||
|
||||
try {
|
||||
final db = await DatabaseService.instance.database;
|
||||
await db.delete(_tableName);
|
||||
} catch (e) {
|
||||
print('清空地图状态失败: $e');
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
void clearMemoryCache() {
|
||||
_memoryCache.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,42 @@ import 'package:lbjconsole/models/train_record.dart';
|
||||
import 'package:lbjconsole/models/merged_record.dart';
|
||||
|
||||
class MergeService {
|
||||
static bool isNeverGroupableRecord(TrainRecord record, GroupBy groupBy) {
|
||||
final train = record.train.trim();
|
||||
final loco = record.loco.trim();
|
||||
|
||||
final hasValidTrain =
|
||||
train.isNotEmpty && train != "<NUL>" && !train.contains("-----");
|
||||
final hasValidLoco = loco.isNotEmpty && loco != "<NUL>";
|
||||
|
||||
switch (groupBy) {
|
||||
case GroupBy.trainOnly:
|
||||
return !hasValidTrain;
|
||||
|
||||
case GroupBy.locoOnly:
|
||||
return !hasValidLoco;
|
||||
|
||||
case GroupBy.trainAndLoco:
|
||||
return !hasValidTrain || !hasValidLoco;
|
||||
|
||||
case GroupBy.trainOrLoco:
|
||||
return !hasValidTrain && !hasValidLoco;
|
||||
}
|
||||
}
|
||||
|
||||
static List<TrainRecord> filterUngroupableRecords(
|
||||
List<TrainRecord> records, GroupBy groupBy, bool hideUngroupable) {
|
||||
if (!hideUngroupable) return records;
|
||||
return records
|
||||
.where((record) => !isNeverGroupableRecord(record, groupBy))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static String? _generateGroupKey(TrainRecord record, GroupBy groupBy) {
|
||||
final train = record.train.trim();
|
||||
final loco = record.loco.trim();
|
||||
final hasTrain = train.isNotEmpty && train != "<NUL>";
|
||||
final hasTrain =
|
||||
train.isNotEmpty && train != "<NUL>" && !train.contains("-----");
|
||||
final hasLoco = loco.isNotEmpty && loco != "<NUL>";
|
||||
|
||||
switch (groupBy) {
|
||||
@@ -14,8 +46,13 @@ class MergeService {
|
||||
case GroupBy.locoOnly:
|
||||
return hasLoco ? loco : null;
|
||||
case GroupBy.trainOrLoco:
|
||||
if (hasTrain) return train;
|
||||
if (hasLoco) return loco;
|
||||
if (hasTrain && hasLoco) {
|
||||
return "train:$train|loco:$loco";
|
||||
} else if (hasTrain) {
|
||||
return "train:$train";
|
||||
} else if (hasLoco) {
|
||||
return "loco:$loco";
|
||||
}
|
||||
return null;
|
||||
case GroupBy.trainAndLoco:
|
||||
return (hasTrain && hasLoco) ? "${train}_$loco" : null;
|
||||
@@ -30,17 +67,19 @@ class MergeService {
|
||||
return allRecords;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final validRecords = settings.timeWindow.duration == null
|
||||
? allRecords
|
||||
: allRecords
|
||||
.where((r) =>
|
||||
now.difference(r.receivedTimestamp) <=
|
||||
settings.timeWindow.duration!)
|
||||
.toList();
|
||||
final filteredRecords = filterUngroupableRecords(
|
||||
allRecords, settings.groupBy, settings.hideUngroupableRecords);
|
||||
|
||||
filteredRecords
|
||||
.sort((a, b) => b.receivedTimestamp.compareTo(a.receivedTimestamp));
|
||||
|
||||
if (settings.groupBy == GroupBy.trainOrLoco) {
|
||||
return _groupByTrainOrLocoWithTimeWindow(
|
||||
filteredRecords, settings.timeWindow);
|
||||
}
|
||||
|
||||
final groupedRecords = <String, List<TrainRecord>>{};
|
||||
for (final record in validRecords) {
|
||||
for (final record in filteredRecords) {
|
||||
final key = _generateGroupKey(record, settings.groupBy);
|
||||
if (key != null) {
|
||||
groupedRecords.putIfAbsent(key, () => []).add(record);
|
||||
@@ -49,25 +88,35 @@ class MergeService {
|
||||
|
||||
final List<MergedTrainRecord> mergedRecords = [];
|
||||
final Set<String> mergedRecordIds = {};
|
||||
final List<TrainRecord> discardedRecords = [];
|
||||
|
||||
groupedRecords.forEach((key, group) {
|
||||
if (group.length >= 2) {
|
||||
group
|
||||
.sort((a, b) => b.receivedTimestamp.compareTo(a.receivedTimestamp));
|
||||
final latestRecord = group.first;
|
||||
final processedGroup = _applyTimeWindow(group, settings.timeWindow);
|
||||
|
||||
if (processedGroup.length >= 2) {
|
||||
mergedRecords.add(MergedTrainRecord(
|
||||
groupKey: key,
|
||||
records: group,
|
||||
latestRecord: latestRecord,
|
||||
records: processedGroup,
|
||||
latestRecord: processedGroup.first,
|
||||
));
|
||||
for (final record in group) {
|
||||
for (final record in processedGroup) {
|
||||
mergedRecordIds.add(record.uniqueId);
|
||||
}
|
||||
}
|
||||
|
||||
for (final record in group) {
|
||||
if (!processedGroup.contains(record)) {
|
||||
discardedRecords.add(record);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
final singleRecords =
|
||||
allRecords.where((r) => !mergedRecordIds.contains(r.uniqueId)).toList();
|
||||
final reusedRecords = _reuseDiscardedRecords(
|
||||
discardedRecords, mergedRecordIds, settings.groupBy);
|
||||
|
||||
final singleRecords = filteredRecords
|
||||
.where((r) => !mergedRecordIds.contains(r.uniqueId))
|
||||
.toList();
|
||||
|
||||
final List<Object> mixedList = [...mergedRecords, ...singleRecords];
|
||||
mixedList.sort((a, b) {
|
||||
@@ -82,4 +131,252 @@ class MergeService {
|
||||
|
||||
return mixedList;
|
||||
}
|
||||
|
||||
static List<TrainRecord> _applyTimeWindow(
|
||||
List<TrainRecord> group, TimeWindow timeWindow) {
|
||||
if (timeWindow.duration == null) {
|
||||
return group;
|
||||
}
|
||||
|
||||
group.sort((a, b) => a.receivedTimestamp.compareTo(b.receivedTimestamp));
|
||||
|
||||
while (group.length > 1) {
|
||||
final timeSpan = group.last.receivedTimestamp
|
||||
.difference(group.first.receivedTimestamp);
|
||||
|
||||
if (timeSpan <= timeWindow.duration!) {
|
||||
break;
|
||||
}
|
||||
|
||||
group.removeAt(0);
|
||||
}
|
||||
|
||||
group.sort((a, b) => b.receivedTimestamp.compareTo(a.receivedTimestamp));
|
||||
return group;
|
||||
}
|
||||
|
||||
static List<TrainRecord> _reuseDiscardedRecords(
|
||||
List<TrainRecord> discardedRecords,
|
||||
Set<String> mergedRecordIds,
|
||||
GroupBy groupBy) {
|
||||
final reusedRecords = <TrainRecord>[];
|
||||
|
||||
for (final record in discardedRecords) {
|
||||
if (mergedRecordIds.contains(record.uniqueId)) continue;
|
||||
|
||||
final key = _generateGroupKey(record, groupBy);
|
||||
if (key != null) {
|
||||
reusedRecords.add(record);
|
||||
}
|
||||
}
|
||||
|
||||
return reusedRecords;
|
||||
}
|
||||
|
||||
static List<Object> _groupByTrainOrLocoWithTimeWindow(
|
||||
List<TrainRecord> records, TimeWindow timeWindow) {
|
||||
final List<MergedTrainRecord> mergedRecords = [];
|
||||
final List<TrainRecord> singleRecords = [];
|
||||
final Set<String> usedRecordIds = {};
|
||||
|
||||
for (int i = 0; i < records.length; i++) {
|
||||
final record = records[i];
|
||||
if (usedRecordIds.contains(record.uniqueId)) continue;
|
||||
|
||||
final group = <TrainRecord>[record];
|
||||
|
||||
for (int j = i + 1; j < records.length; j++) {
|
||||
final otherRecord = records[j];
|
||||
if (usedRecordIds.contains(otherRecord.uniqueId)) continue;
|
||||
|
||||
final recordTrain = record.train.trim();
|
||||
final otherTrain = otherRecord.train.trim();
|
||||
final recordLoco = record.loco.trim();
|
||||
final otherLoco = otherRecord.loco.trim();
|
||||
|
||||
final trainMatch = recordTrain.isNotEmpty &&
|
||||
recordTrain != "<NUL>" &&
|
||||
!recordTrain.contains("-----") &&
|
||||
otherTrain.isNotEmpty &&
|
||||
otherTrain != "<NUL>" &&
|
||||
!otherTrain.contains("-----") &&
|
||||
recordTrain == otherTrain;
|
||||
|
||||
final locoMatch = recordLoco.isNotEmpty &&
|
||||
recordLoco != "<NUL>" &&
|
||||
otherLoco.isNotEmpty &&
|
||||
otherLoco != "<NUL>" &&
|
||||
recordLoco == otherLoco;
|
||||
|
||||
final bothTrainEmpty = (recordTrain.isEmpty ||
|
||||
recordTrain == "<NUL>" ||
|
||||
recordTrain.contains("----")) &&
|
||||
(otherTrain.isEmpty ||
|
||||
otherTrain == "<NUL>" ||
|
||||
otherTrain.contains("----"));
|
||||
|
||||
if (trainMatch || locoMatch || (bothTrainEmpty && locoMatch)) {
|
||||
group.add(otherRecord);
|
||||
}
|
||||
}
|
||||
|
||||
final processedGroup = _applyTimeWindow(group, timeWindow);
|
||||
|
||||
if (processedGroup.length >= 2) {
|
||||
for (final record in processedGroup) {
|
||||
usedRecordIds.add(record.uniqueId);
|
||||
}
|
||||
|
||||
final firstRecord = processedGroup.first;
|
||||
final train = firstRecord.train.trim();
|
||||
final loco = firstRecord.loco.trim();
|
||||
String uniqueGroupKey;
|
||||
|
||||
if (train.isNotEmpty &&
|
||||
train != "<NUL>" &&
|
||||
!train.contains("-----") &&
|
||||
loco.isNotEmpty &&
|
||||
loco != "<NUL>") {
|
||||
uniqueGroupKey = "train_or_loco:${train}_$loco";
|
||||
} else if (train.isNotEmpty &&
|
||||
train != "<NUL>" &&
|
||||
!train.contains("-----") &&
|
||||
loco.isEmpty) {
|
||||
uniqueGroupKey = "train_or_loco:train:$train";
|
||||
} else if (loco.isNotEmpty && loco != "<NUL>") {
|
||||
uniqueGroupKey = "train_or_loco:loco:$loco";
|
||||
} else {
|
||||
uniqueGroupKey = "train_or_loco:group_${mergedRecords.length}";
|
||||
}
|
||||
|
||||
mergedRecords.add(MergedTrainRecord(
|
||||
groupKey: uniqueGroupKey,
|
||||
records: processedGroup,
|
||||
latestRecord: processedGroup.first,
|
||||
));
|
||||
} else {
|
||||
for (final record in group) {
|
||||
if (!processedGroup.contains(record)) {
|
||||
singleRecords.add(record);
|
||||
usedRecordIds.add(record.uniqueId);
|
||||
}
|
||||
}
|
||||
|
||||
if (processedGroup.isNotEmpty) {
|
||||
singleRecords.add(processedGroup.first);
|
||||
usedRecordIds.add(processedGroup.first.uniqueId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final List<Object> result = [...mergedRecords, ...singleRecords];
|
||||
result.sort((a, b) {
|
||||
final aTime = a is MergedTrainRecord
|
||||
? a.latestRecord.receivedTimestamp
|
||||
: (a as TrainRecord).receivedTimestamp;
|
||||
final bTime = b is MergedTrainRecord
|
||||
? b.latestRecord.receivedTimestamp
|
||||
: (b as TrainRecord).receivedTimestamp;
|
||||
return bTime.compareTo(aTime);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static List<Object> _groupByTrainOrLoco(List<TrainRecord> records) {
|
||||
final List<MergedTrainRecord> mergedRecords = [];
|
||||
final List<TrainRecord> singleRecords = [];
|
||||
final Set<String> usedRecordIds = {};
|
||||
|
||||
for (int i = 0; i < records.length; i++) {
|
||||
final record = records[i];
|
||||
if (usedRecordIds.contains(record.uniqueId)) continue;
|
||||
|
||||
final group = <TrainRecord>[record];
|
||||
|
||||
for (int j = i + 1; j < records.length; j++) {
|
||||
final otherRecord = records[j];
|
||||
if (usedRecordIds.contains(otherRecord.uniqueId)) continue;
|
||||
|
||||
final recordTrain = record.train.trim();
|
||||
final otherTrain = otherRecord.train.trim();
|
||||
final recordLoco = record.loco.trim();
|
||||
final otherLoco = otherRecord.loco.trim();
|
||||
|
||||
final trainMatch = recordTrain.isNotEmpty &&
|
||||
recordTrain != "<NUL>" &&
|
||||
!recordTrain.contains("-----") &&
|
||||
otherTrain.isNotEmpty &&
|
||||
otherTrain != "<NUL>" &&
|
||||
!otherTrain.contains("-----") &&
|
||||
recordTrain == otherTrain;
|
||||
|
||||
final locoMatch = recordLoco.isNotEmpty &&
|
||||
recordLoco != "<NUL>" &&
|
||||
otherLoco.isNotEmpty &&
|
||||
otherLoco != "<NUL>" &&
|
||||
recordLoco == otherLoco;
|
||||
|
||||
final bothTrainEmpty = (recordTrain.isEmpty ||
|
||||
recordTrain == "<NUL>" ||
|
||||
recordTrain.contains("----")) &&
|
||||
(otherTrain.isEmpty ||
|
||||
otherTrain == "<NUL>" ||
|
||||
otherTrain.contains("----"));
|
||||
|
||||
if (trainMatch || locoMatch || (bothTrainEmpty && locoMatch)) {
|
||||
group.add(otherRecord);
|
||||
}
|
||||
}
|
||||
|
||||
if (group.length >= 2) {
|
||||
for (final record in group) {
|
||||
usedRecordIds.add(record.uniqueId);
|
||||
}
|
||||
|
||||
final firstRecord = group.first;
|
||||
final train = firstRecord.train.trim();
|
||||
final loco = firstRecord.loco.trim();
|
||||
String uniqueGroupKey;
|
||||
|
||||
if (train.isNotEmpty &&
|
||||
train != "<NUL>" &&
|
||||
!train.contains("-----") &&
|
||||
loco.isNotEmpty &&
|
||||
loco != "<NUL>") {
|
||||
uniqueGroupKey = "train_or_loco:${train}_$loco";
|
||||
} else if (train.isNotEmpty &&
|
||||
train != "<NUL>" &&
|
||||
!train.contains("-----")) {
|
||||
uniqueGroupKey = "train_or_loco:train:$train";
|
||||
} else if (loco.isNotEmpty && loco != "<NUL>") {
|
||||
uniqueGroupKey = "train_or_loco:loco:$loco";
|
||||
} else {
|
||||
uniqueGroupKey = "train_or_loco:group_${mergedRecords.length}";
|
||||
}
|
||||
|
||||
mergedRecords.add(MergedTrainRecord(
|
||||
groupKey: uniqueGroupKey,
|
||||
records: group,
|
||||
latestRecord: group.first,
|
||||
));
|
||||
} else {
|
||||
singleRecords.add(record);
|
||||
usedRecordIds.add(record.uniqueId);
|
||||
}
|
||||
}
|
||||
|
||||
final List<Object> result = [...mergedRecords, ...singleRecords];
|
||||
result.sort((a, b) {
|
||||
final aTime = a is MergedTrainRecord
|
||||
? a.latestRecord.receivedTimestamp
|
||||
: (a as TrainRecord).receivedTimestamp;
|
||||
final bTime = b is MergedTrainRecord
|
||||
? b.latestRecord.receivedTimestamp
|
||||
: (b as TrainRecord).receivedTimestamp;
|
||||
return bTime.compareTo(aTime);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,20 +90,32 @@ class NotificationService {
|
||||
String _buildNotificationContent(TrainRecord record) {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
buffer.writeln('车次: ${record.fullTrainNumber}');
|
||||
buffer.writeln('线路: ${record.route}');
|
||||
buffer.writeln('方向: ${record.directionText}');
|
||||
buffer.write(record.fullTrainNumber);
|
||||
if (_isValidValue(record.route)) {
|
||||
buffer.write(' ${record.route}');
|
||||
}
|
||||
if (_isValidValue(record.directionText)) {
|
||||
buffer.write(' ${record.directionText}');
|
||||
}
|
||||
if (_isValidValue(record.positionInfo)) {
|
||||
buffer.write(' ${record.positionInfo}');
|
||||
}
|
||||
buffer.writeln();
|
||||
if (_isValidValue(record.locoType) && _isValidValue(record.loco)) {
|
||||
final shortLoco = record.loco.length > 5
|
||||
? record.loco.substring(record.loco.length - 5)
|
||||
: record.loco;
|
||||
buffer.write('${record.locoType}-$shortLoco');
|
||||
} else if (_isValidValue(record.locoType)) {
|
||||
buffer.write(record.locoType);
|
||||
} else if (_isValidValue(record.loco)) {
|
||||
buffer.write(record.loco);
|
||||
}
|
||||
|
||||
if (_isValidValue(record.speed)) {
|
||||
buffer.writeln('速度: ${record.speed} km/h');
|
||||
buffer.write(' ${record.speed}km/h');
|
||||
}
|
||||
|
||||
if (_isValidValue(record.positionInfo)) {
|
||||
buffer.writeln('位置: ${record.positionInfo}');
|
||||
}
|
||||
|
||||
buffer.writeln('时间: ${record.formattedTime}');
|
||||
|
||||
return buffer.toString().trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import share_plus
|
||||
import shared_preferences_foundation
|
||||
import sqflite_darwin
|
||||
import url_launcher_macos
|
||||
import webview_flutter_wkwebview
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
@@ -27,4 +28,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
|
||||
}
|
||||
|
||||
116
pubspec.lock
@@ -233,6 +233,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
executor_lib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: executor_lib
|
||||
sha256: "95ddf2957d9942d9702855b38dd49677f0ee6a8b77d7b16c0e509c7669d17386"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -358,6 +366,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_launcher_icons
|
||||
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.14.4"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -648,6 +664,30 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
maplibre_gl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: maplibre_gl
|
||||
sha256: "5c7b1008396b2a321bada7d986ed60f9423406fbc7bd16f7ce91b385dfa054cd"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.22.0"
|
||||
maplibre_gl_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: maplibre_gl_platform_interface
|
||||
sha256: "08ee0a2d0853ea945a0ab619d52c0c714f43144145cd67478fc6880b52f37509"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.22.0"
|
||||
maplibre_gl_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: maplibre_gl_web
|
||||
sha256: "2b13d4b1955a9a54e38a718f2324e56e4983c080fc6de316f6f4b5458baacb58"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.22.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -888,6 +928,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
protobuf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: protobuf
|
||||
sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -920,6 +968,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
scrollview_observer:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: scrollview_observer
|
||||
sha256: c2f713509f18f88f637b2084b47a90c91fb1ef066d5d82d2cf3194d8509dc6ab
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.26.2"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1245,6 +1301,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.5.1"
|
||||
vector_map_tiles:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: vector_map_tiles
|
||||
sha256: "4dc9243195c1a49c7be82cc1caed0d300242bb94381752af5f6868d9d1404e25"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "8.0.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1253,6 +1317,22 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vector_tile:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_tile
|
||||
sha256: "7ae290246e3a8734422672dbe791d3f7b8ab631734489fc6d405f1cc2080e38c"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
vector_tile_renderer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_tile_renderer
|
||||
sha256: "89746f1108eccbc0b6f33fbbef3fcf394cda3733fc0d5064ea03d53a459b56d3"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "5.2.1"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1293,6 +1373,38 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
webview_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: webview_flutter
|
||||
sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.13.0"
|
||||
webview_flutter_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_android
|
||||
sha256: "3c4eb4fcc252b40c2b5ce7be20d0481428b70f3ff589b0a8b8aaeb64c6bed701"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.10.2"
|
||||
webview_flutter_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_platform_interface
|
||||
sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.14.0"
|
||||
webview_flutter_wkwebview:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_wkwebview
|
||||
sha256: fea63576b3b7e02b2df8b78ba92b48ed66caec2bb041e9a0b1cbd586d5d80bfd
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.23.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1334,5 +1446,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.8.0-0 <4.0.0"
|
||||
flutter: ">=3.24.0"
|
||||
dart: ">=3.9.0 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
|
||||
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
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 0.2.0-flutter+20 # versionName: 0.2.0-flutter, versionCode: 3
|
||||
version: 0.5.1-flutter+51
|
||||
|
||||
environment:
|
||||
sdk: ^3.5.4
|
||||
@@ -53,6 +53,10 @@ dependencies:
|
||||
package_info_plus: ^8.1.2
|
||||
msix: ^3.16.12
|
||||
flutter_background_service: ^5.1.0
|
||||
scrollview_observer: ^1.20.0
|
||||
vector_map_tiles: ^8.0.0
|
||||
maplibre_gl: ^0.22.0
|
||||
webview_flutter: ^4.8.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -66,6 +70,7 @@ dev_dependencies:
|
||||
flutter_lints: ^4.0.0
|
||||
hive_generator: ^2.0.1
|
||||
build_runner: ^2.4.6
|
||||
flutter_launcher_icons: ^0.14.1
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
@@ -82,6 +87,7 @@ flutter:
|
||||
- assets/loco_info.csv
|
||||
- assets/train_number_info.csv
|
||||
- assets/loco_type_info.csv
|
||||
- assets/mapbox_map.html
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
@@ -109,6 +115,13 @@ flutter:
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/to/font-from-package
|
||||
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
ios: true
|
||||
image_path: "assets/icon.png"
|
||||
adaptive_icon_background: "#000000"
|
||||
adaptive_icon_foreground: "assets/icon.png"
|
||||
|
||||
msix_config:
|
||||
display_name: LBJ Console
|
||||
publisher_display_name: Noxylva
|
||||
|
||||