Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44bc27a366 | ||
|
|
0d6c11e2bd | ||
|
|
92445e681f | ||
|
|
78cc909ec8 | ||
|
|
077e0e4266 | ||
|
|
0bf7033c6c | ||
|
|
0f98b6bcf7 | ||
|
|
8894a73999 | ||
|
|
cd4b58e16b | ||
|
|
39effddfc1 | ||
|
|
c4b06f3b3c | ||
|
|
eb33fa7feb | ||
|
|
65bf7b52c6 | ||
|
|
4278de2a8d | ||
|
|
59e9987d7f | ||
|
|
4e97dcafd7 | ||
|
|
4cad3679a9 | ||
|
|
e6e7831b96 | ||
|
|
39bb8cb440 | ||
|
|
be8dc6bc72 | ||
|
|
cd3128c24b | ||
|
|
e1773370d6 | ||
|
|
c8ab5f7ff8 | ||
|
|
e1d02a8a55 | ||
|
|
aaf414d384 | ||
|
|
3edc8632be | ||
|
|
799410eeb2 | ||
|
|
d64138cea5 | ||
|
|
a1a9a479f9 | ||
|
|
9389ef6e6a |
148
.gitignore
vendored
@@ -13,9 +13,155 @@ captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
local.properties
|
||||
*.ps1
|
||||
.*.bat
|
||||
*.jks
|
||||
*.keystore
|
||||
*.base64
|
||||
docs
|
||||
linux
|
||||
windows
|
||||
flutter/ephemeral/
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
x64/
|
||||
x86/
|
||||
*.[Cc]ache
|
||||
!*.[Cc]ache/
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
*.log
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
*.apk
|
||||
output.json
|
||||
*.iml
|
||||
.idea/
|
||||
misc.xml
|
||||
deploymentTargetDropDown.xml
|
||||
render.experimental.xml
|
||||
*.jks
|
||||
*.keystore
|
||||
google-services.json
|
||||
*.hprof
|
||||
gen-external-apklibs
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.fvm/flutter_sdk
|
||||
.packages
|
||||
.pub-cache/
|
||||
.pub/
|
||||
coverage/
|
||||
lib/generated_plugin_registrant.dart
|
||||
**/android/**/gradle-wrapper.jar
|
||||
**/android/.gradle
|
||||
**/android/captures/
|
||||
**/android/gradlew
|
||||
**/android/gradlew.bat
|
||||
**/android/key.properties
|
||||
**/android/local.properties
|
||||
**/android/**/GeneratedPluginRegistrant.java
|
||||
**/ios/**/*.mode1v3
|
||||
**/ios/**/*.mode2v3
|
||||
**/ios/**/*.moved-aside
|
||||
**/ios/**/*.pbxuser
|
||||
**/ios/**/*.perspectivev3
|
||||
**/ios/**/*sync/
|
||||
**/ios/**/.sconsign.dblite
|
||||
**/ios/**/.tags*
|
||||
**/ios/**/.vagrant/
|
||||
**/ios/**/DerivedData/
|
||||
**/ios/**/Icon?
|
||||
**/ios/**/Pods/
|
||||
**/ios/**/.symlinks/
|
||||
**/ios/**/profile
|
||||
**/ios/**/xcuserdata
|
||||
**/ios/.generated/
|
||||
**/ios/Flutter/.last_build_id
|
||||
**/ios/Flutter/App.framework
|
||||
**/ios/Flutter/Flutter.framework
|
||||
**/ios/Flutter/Flutter.podspec
|
||||
**/ios/Flutter/Generated.xcconfig
|
||||
**/ios/Flutter/app.flx
|
||||
**/ios/Flutter/app.zip
|
||||
**/ios/Flutter/flutter_assets/
|
||||
**/ios/Flutter/flutter_export_environment.sh
|
||||
**/ios/ServiceDefinitions.json
|
||||
**/ios/Runner/GeneratedPluginRegistrant.*
|
||||
!**/ios/**/default.mode1v3
|
||||
!**/ios/**/default.mode2v3
|
||||
!**/ios/**/default.pbxuser
|
||||
!**/ios/**/default.perspectivev3
|
||||
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
|
||||
*.ap_
|
||||
*.aab
|
||||
*.dex
|
||||
*.class
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
.gradle
|
||||
.signing/
|
||||
proguard/
|
||||
/*/build/
|
||||
/*/local.properties
|
||||
/*/out
|
||||
/*/*/build
|
||||
/*/*/production
|
||||
.navigation/
|
||||
*.ipr
|
||||
*~
|
||||
*.swp
|
||||
.externalNativeBuild
|
||||
obj/
|
||||
*.iws
|
||||
/out/
|
||||
.idea/caches/
|
||||
.idea/libraries/
|
||||
.idea/shelf/
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/.name
|
||||
.idea/compiler.xml
|
||||
.idea/copyright/profiles_settings.xml
|
||||
.idea/encodings.xml
|
||||
.idea/misc.xml
|
||||
.idea/modules.xml
|
||||
.idea/scopes/scope_settings.xml
|
||||
.idea/dictionaries
|
||||
.idea/vcs.xml
|
||||
.idea/jsLibraryMappings.xml
|
||||
.idea/datasources.xml
|
||||
.idea/dataSources.ids
|
||||
.idea/sqlDataSources.xml
|
||||
.idea/dynamic.xml
|
||||
.idea/uiDesigner.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/gradle.xml
|
||||
.idea/jarRepositories.xml
|
||||
.idea/navEditor.xml
|
||||
.classpath
|
||||
.project
|
||||
.cproject
|
||||
.settings/
|
||||
.mtj.tmp/
|
||||
*.war
|
||||
*.ear
|
||||
hs_err_pid*
|
||||
.idea_modules/
|
||||
atlassian-ide-plugin.xml
|
||||
.idea/mongoSettings.xml
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
!/gradle/wrapper/gradle-wrapper.jar
|
||||
macos/Flutter/ephemeral/flutter_export_environment.sh
|
||||
macos/Flutter/ephemeral/Flutter-Generated.xcconfig
|
||||
*.py
|
||||
2
.idea/.name
generated
@@ -1 +1 @@
|
||||
LBJ Receiver
|
||||
LBJ_Console
|
||||
6
.idea/deploymentTargetSelector.xml
generated
@@ -2,6 +2,12 @@
|
||||
<project version="4">
|
||||
<component name="deploymentTargetSelector">
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="Unnamed">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="lbjconsole_android">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
|
||||
22
README.md
@@ -1,12 +1,20 @@
|
||||
# LBJ Console
|
||||
|
||||
LBJ Console is an Android app designed to receive and display LBJ messages via BLE from the [SX1276_Receive_LBJ](https://github.com/undef-i/SX1276_Receive_LBJ) device.
|
||||
LBJ Console 是一款应用程序,用于通过 BLE 从 [SX1276_Receive_LBJ](https://github.com/undef-i/SX1276_Receive_LBJ) 设备接收并显示列车预警消息,功能包括:
|
||||
|
||||
## Roadmap
|
||||
- Record filtering (train number, time range)
|
||||
- Record management page optimization
|
||||
- Optional train merge by locomotive/number
|
||||
- 接收列车预警消息,支持可选的手机推送通知。
|
||||
- 在地图上显示预警消息的 GPS 信息。
|
||||
- 基于内置数据文件显示机车配属,机车类型和车次类型。
|
||||
|
||||
# License
|
||||
主分支目前只适配了 Android 。如需在其它平台上面使用,请参考 [flutter](https://github.com/undef-i/LBJ_Console/tree/flutter) 分支自行编译。
|
||||
## 数据文件
|
||||
|
||||
This project is licensed under the GNU General Public License v3.0 (GPLv3). This license ensures that the software remains free and open source, requiring that any modifications or derivative works must also be released under the same license terms.
|
||||
LBJ Console 依赖以下数据文件,位于 `app/src/main/assets/` 目录,用于支持机车配属和车次信息的展示:
|
||||
- `loco_info.csv`:包含机车配属信息,格式为 `机车型号,机车编号起始值,机车编号结束值,所属铁路局及机务段,备注`。
|
||||
- `loco_type_info.csv`:包含机车类型编码信息,格式为 `机车类型编码,机车类型`。
|
||||
- `train_info.csv`:包含车次类型信息,格式为 `正则表达式,车次类型`。
|
||||
|
||||
|
||||
# 许可证
|
||||
|
||||
该项目采用 GNU 通用公共许可证 v3.0(GPLv3)授权。该许可证确保软件保持免费和开源,要求任何修改或衍生作品也必须在相同许可证条款下发布。
|
||||
|
||||
@@ -2,6 +2,7 @@ plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -12,8 +13,8 @@ android {
|
||||
applicationId = "org.noxylva.lbjconsole"
|
||||
minSdk = 29
|
||||
targetSdk = 35
|
||||
versionCode = 3
|
||||
versionName = "0.0.3"
|
||||
versionCode = 15
|
||||
versionName = "0.1.5"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -59,6 +60,7 @@ android {
|
||||
}
|
||||
lint {
|
||||
disable += "NullSafeMutableLiveData"
|
||||
warning += "MissingPermission"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,8 +83,14 @@ dependencies {
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
implementation("org.json:json:20231013")
|
||||
implementation("androidx.compose.material:material-icons-extended:1.5.4")
|
||||
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
|
||||
implementation("org.osmdroid:osmdroid-android:6.1.16")
|
||||
implementation("org.osmdroid:osmdroid-mapsforge:6.1.16")
|
||||
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.androidx.startup.runtime)
|
||||
implementation("com.google.code.gson:gson:2.10.1")
|
||||
}
|
||||
@@ -11,6 +11,13 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
|
||||
|
||||
@@ -22,14 +29,14 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.LBJReceiver"
|
||||
android:theme="@style/Theme.LBJConsole"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.LBJReceiver">
|
||||
android:theme="@style/Theme.LBJConsole">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -37,6 +44,18 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".FilePickerActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.LBJConsole"
|
||||
android:label="数据管理" />
|
||||
|
||||
<service
|
||||
android:name=".BackgroundService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
|
||||
143
app/src/main/assets/loco_type_info.csv
Normal file
@@ -0,0 +1,143 @@
|
||||
001,解放
|
||||
003,前进
|
||||
005,建设
|
||||
006,KD7
|
||||
055,蓝箭控车
|
||||
081,东风21
|
||||
101,东风
|
||||
102,东风2
|
||||
103,东风3
|
||||
104,东风4
|
||||
105,东风4客
|
||||
106,东风4C
|
||||
107,东风5
|
||||
108,东风5宽
|
||||
109,东风6
|
||||
110,东风7
|
||||
111,东风8
|
||||
112,东风9
|
||||
113,东风10
|
||||
114,东方红1
|
||||
115,东方红2
|
||||
116,东方红3
|
||||
117,东方红5
|
||||
118,北京
|
||||
119,北京宽
|
||||
120,ND2
|
||||
121,ND3
|
||||
122,ND4
|
||||
123,ND5
|
||||
124,NY5
|
||||
125,NY6
|
||||
126,NY7
|
||||
127,轻油
|
||||
128,东方红21
|
||||
129,东风7B
|
||||
130,东风5S
|
||||
131,东风7C
|
||||
132,东风7S
|
||||
133,工矿1
|
||||
134,工矿1F
|
||||
135,东风4E
|
||||
136,东风7D
|
||||
137,工矿1A
|
||||
138,东风11
|
||||
139,天安
|
||||
140,东风10F
|
||||
141,东风4D
|
||||
142,东风8B
|
||||
143,东风12
|
||||
144,东风7E
|
||||
145,NYJ1
|
||||
146,NZJ1
|
||||
147,NZJ2
|
||||
148,东风4DJ
|
||||
149,新曙光
|
||||
150,神州
|
||||
151,NJ2
|
||||
152,东风7G
|
||||
153,NDJ3
|
||||
157,FXN3D
|
||||
158,东风11G
|
||||
160,HXN3
|
||||
161,HXN5
|
||||
162,HXN3B
|
||||
163,HXN5B
|
||||
167,FXN3B
|
||||
169,FXN3C
|
||||
170,FXN5C
|
||||
171,FXN3-J
|
||||
201,8G
|
||||
202,8K
|
||||
203,6G
|
||||
204,6K
|
||||
205,韶山1
|
||||
206,韶山3
|
||||
207,韶山4
|
||||
208,韶山5
|
||||
209,韶山6
|
||||
210,韶山3B
|
||||
211,韶山7
|
||||
212,韶山8
|
||||
213,韶山7B
|
||||
214,韶山7C
|
||||
215,韶山6B
|
||||
216,韶山9
|
||||
217,韶山7D
|
||||
218,DJ熊猫
|
||||
219,DJ1
|
||||
220,DJ2
|
||||
221,DJF
|
||||
222,蓝箭动车
|
||||
223,先锋号
|
||||
224,韶山7E
|
||||
225,韶山4G
|
||||
226,韶山3C
|
||||
228,天梭
|
||||
229,DJ4和谐
|
||||
230,KTT
|
||||
231,HXD1
|
||||
232,HXD2
|
||||
233,HXD3
|
||||
234,HXD1B
|
||||
235,HXD2B
|
||||
236,HXD3B
|
||||
237,HXD1C
|
||||
238,HXD2C
|
||||
239,HXD3C
|
||||
240,HXD1D
|
||||
241,HXD2D
|
||||
242,HXD3D
|
||||
243,FXD1B
|
||||
244,FXD2B
|
||||
245,FXD1
|
||||
246,FXD3
|
||||
247,FXD1-J
|
||||
248,FXD3-J
|
||||
249,KZ25TA
|
||||
251,KZ25TB
|
||||
252,HXD1D-J
|
||||
254,FXD1H
|
||||
300,雪域神州
|
||||
301,CRH1
|
||||
302,CRH2
|
||||
303,CRH3
|
||||
305,CRH5
|
||||
306,CRH380A
|
||||
307,CRH380B
|
||||
308,CRH380C
|
||||
309,CRH380D
|
||||
310,CRH6A
|
||||
311,CR400AF
|
||||
312,CR400BF
|
||||
313,CR300AF
|
||||
314,CR300BF
|
||||
315,CRH2E
|
||||
316,CRH6F
|
||||
330,CJ1
|
||||
331,CJ2
|
||||
332,CJ3
|
||||
333,CJ4
|
||||
334,CJ5
|
||||
335,CJ6
|
||||
400,GCD-1000J
|
||||
|
82
app/src/main/assets/train_number_info.csv
Normal file
@@ -0,0 +1,82 @@
|
||||
"^[Gg](4000|[1-3]\d{3}|[1-9]\d{0,2})$","直通图定高速动车组"
|
||||
"^[Gg](400[1-9]|40[1-9]\d|4[1-8]\d{2}|49[0-8]\d|499[0-8])$","直通临客高速动车组"
|
||||
"^[Gg](9000|[6-8]\d{3}|500[1-9]|50[1-9]\d|5[1-9]\d{2})$","管内图定高速动车组"
|
||||
"^[Gg](900[1-9]|90[1-9]\d|9[1-8]\d{2}|99[0-8]\d|999[0-8])$","管内临客高速动车组"
|
||||
"^[Cc]([1-8]\d{3}|9000)$","图定城际动车组"
|
||||
"^[Cc](900[1-9]|90[1-9]\d|9[1-8]\d{2}|99[0-8]\d|999[0-8])$","临客城际动车组"
|
||||
"^[Cc][1-9]\d{2}$","动力集中城际动车组"
|
||||
"^[IDid](4000|[1-3]\d{3}|[1-9]\d{0,2})$","直通图定动车组"
|
||||
"^[IDid](400[1-9]|40[1-9]\d|4[1-8]\d{2}|49[0-8]\d|499[0-8])$","直通临客动车组"
|
||||
"^[IDid](9000|[6-8]\d{3}|500[1-9]|50[1-9]\d|5[1-9]\d{2})$","管内图定动车组"
|
||||
"^[IDid](900[1-9]|90[1-9]\d|9[1-8]\d{2}|99[0-8]\d|999[0-8])$","管内临客动车组"
|
||||
"^[IDid](8([0-8]\d|9[0-8])|7(0[1-9]|[1-9]\d))$","动力集中动车组"
|
||||
"^[IDid](300|[12]\d{2}|[1-9]\d?)$","跨局动力集中动车组"
|
||||
"^[PZpz](4000|[1-3]\d{3}|[1-9]\d{0,2})$","直通图定直达特快旅客列车"
|
||||
"^[PZpz](400[1-9]|40[1-9]\d|4[1-8]\d{2}|49[0-8]\d|499[0-8])$","直通临客直达特快旅客列车"
|
||||
"^[PZpz](9000|[6-8]\d{3}|500[1-9]|50[1-9]\d|5[1-9]\d{2})$","管内图定直达特快旅客列车"
|
||||
"^[PZpz](900[1-9]|90[1-9]\d|9[1-8]\d{2}|99[0-8]\d|999[0-8])$","管内临客直达特快旅客列车"
|
||||
"^[QTqt](3000|[12]\d{3}|[1-9]\d{0,2})$","直通图定特快旅客列车"
|
||||
"^[QTqt](300[1-9]|30[1-9]\d|3[1-8]\d{2}|39[0-8]\d|399[0-8])$","直通临客特快旅客列车"
|
||||
"^[QTqt](4(00[1-9]|0[1-9]\d|[1-8]\d{2}|9[0-8]\d|99[0-8]))$","管内临客特快旅客列车"
|
||||
"^[QTqt]([5-8]\d{3}|9([0-8]\d{2}|9[0-8]\d|99[0-8])|500[1-9]|50[1-9]\d|5[1-9]\d{2})$","管内图定特快旅客列车"
|
||||
"^[WKwk](4000|[1-3]\d{3}|[1-9]\d{0,2})$","直通图定快速旅客列车"
|
||||
"^[WKwk](400[1-9]|40[1-9]\d|4[1-8]\d{2}|49[0-8]\d|499[0-8])$","直通临客快速旅客列车"
|
||||
"^[WKwk](6([0-8]\d{2}|9[0-8]\d|99[0-8])|500[1-9]|50[1-9]\d|5[1-9]\d{2})$","管内临客快速旅客列车"
|
||||
"^[WKwk](8\d{3}|9([0-8]\d{2}|9[0-8]\d|99[0-8])|7(00[1-9]|0[1-9]\d|[1-9]\d{2}))$","管内图定快速旅客列车"
|
||||
"^[Vv1](00[1-9]|0[1-9]\d|[1-9]\d{2})$","跨三局及以上图定普通旅客快车"
|
||||
"^[Bb2](00[1-9]|0[1-9]\d|[1-9]\d{2})$","跨两局图定普通旅客快车"
|
||||
"^3(00[1-9]|0[1-9]\d|[1-9]\d{2})$","跨局临时普通旅客快车"
|
||||
"^[Uu4](00[1-9]|0[1-9]\d|[1-9]\d{2})$","管内图定普通旅客快车"
|
||||
"^[Xx5](000|1[9][9]|200|3[9][9]|400)$","管内图定普通旅客快车"
|
||||
"^6(19[0-8]|1[0-8]\d|0[1-9]\d|00[1-9])$","直通普通旅客慢车"
|
||||
"^(6(20[1-9]|2[1-9]\d|[3-9]\d{2})|7([0-4]\d{2}|5([0-8]\d|9[0-8])))$","管内普通旅客慢车"
|
||||
"^(8([0-8]\d{2}|9[0-8]\d|99[0-8])|7(60[1-9]|6[1-9]\d|[7-9]\d{2}))$","通勤列车"
|
||||
"^[Yy](500|[1-4]\d{2}|[1-9]\d?)$","跨局旅游列车"
|
||||
"^[Yy](50[1-9]|5[1-9]\d|[6-9]\d{2})$","管内旅游列车"
|
||||
"^[Ss][1-9]\d{0,3}$","市郊旅客列车"
|
||||
"^[Ll](6([0-8]\d{2}|9[0-8]\d|99[0-8])|[1-5]\d{3}|[1-9]\d{0,2})$","直通临时旅客列车"
|
||||
"^[Ll]([7-9]\d{3})$","管内临时旅客列车"
|
||||
"^[Xx](19[0-8]|1[0-8]\d|[1-9]\d?)$","特快货物班列"
|
||||
"^[Xx](39[0-8]|3[0-8]\d|2[1-9]\d|20[1-9])$","快速货物班列"
|
||||
"^[Xx]2(40[1-9]|4[1-9]\d|[5-9]\d{2})$","直通货物快运列车"
|
||||
"^[Xx]([4-9]\d{2}|4[1-9]\d|40[1-9])$","管内货物快运列车"
|
||||
"^[Xx]8\d{3}$","中欧中亚集装箱班列"
|
||||
"^[Xx]9([0-4]\d{2}|500)$","中亚集装箱班列"
|
||||
"^[Xx]9(50[1-9]|5[1-9]\d|[6-9]\d{2})$","水铁联运班列"
|
||||
"^[Xx][1-4]\d{4}$","加挂零散快运车辆货物列车"
|
||||
"^1(000[1-9]|00[1-9]\d|0[1-9]\d{2}|[1-9]\d{3})$","技术直达列车"
|
||||
"^2\d{4}$","直通货物列车"
|
||||
"^3\d{4}$","区段摘挂列车"
|
||||
"^4([0-3]\d{3}|4([0-8]\d{2}|9[0-8]\d|99[0-8]))$","摘挂列车"
|
||||
"^4(500[1-9]|50[1-9]\d|5[1-9]\d{2}|[6-9]\d{3})$","小运转列车"
|
||||
"^6\d{4}$","自备列车"
|
||||
"^70\d{3}$","超限货物列车"
|
||||
"^7([1-6]\d{3}|7([0-8]\d{2}|9[0-8]\d|99[0-8]))$","重载货物列车"
|
||||
"^78\d{3}$","保温列车"
|
||||
"^8(0\d{3}|1([0-8]\d{2}|9[0-8]\d|99[0-8]))$","普快货物班列"
|
||||
"^8(200[1-9]|20[1-9]\d|2[1-9]\d{2}|[34]\d{3})$","煤炭直达列车"
|
||||
"^85\d{3}$","石油直达列车"
|
||||
"^86\d{3}$","始发直达列车"
|
||||
"^87\d{3}$","空车直达列车"
|
||||
"^(90\d{3}|91([0-8]\d{2}|9[0-8]\d|99[0-8]))$","军用列车"
|
||||
"^50\d{3}$","客车单机"
|
||||
"^51\d{3}$","货车单机"
|
||||
"^52\d{3}$","小运转单机"
|
||||
"^5(3\d{3}|4([0-8]\d{2}|9[0-8]\d|99[0-8]))$","补机列车"
|
||||
"^55(300|[0-2]\d{2})$","普通客货试运转列车"
|
||||
"^55(500|30[1-9]|3[1-9]\d|4\d{2})$","高速动车组试运转列车"
|
||||
"^55(50[1-9]|5[1-9]\d|[6-9]\d{2})$","普通动车组试运转列车"
|
||||
"^56\d{3}$","轻油动车与轨道车"
|
||||
"^57\d{3}$","路用列车"
|
||||
"^58(10[1-9]|1[1-9]\d|[2-8]\d{2}|9([0-8]\d|9[0-8]))$","救援列车"
|
||||
"^DJ(400|[1-3]\d{2}|[1-9]\d?)$","动车组检测列车300直通"
|
||||
"^DJ([4-9]\d{2}|40[1-9]|4[1-9]\d)$","动车组检测列车300管内"
|
||||
"^DJ1(400|[0-3]\d{2})$","动车组检测列车250直通"
|
||||
"^DJ1(40[1-9]|4[1-9]\d|[5-9]\d{2})$","动车组检测列车250管内"
|
||||
"^DJ[56]\d{3}$","直通动车组确认列车"
|
||||
"^DJ[78]\d{3}$","管内动车组确认列车"
|
||||
"^[Ff][GDCZTKgdcztk]?\d{1,4}$","因故折返旅客列车"
|
||||
"^0[GDCZTKgdcztk]\d{1,4}$","回送图定客车底"
|
||||
"^00(100|[1-9]\d?)$","有火回送动车组车底"
|
||||
"^00(10[1-9]|1[1-9]\d|2([0-8]\d|9[0-8]))$","无火回送动车组车底"
|
||||
"^00(30[1-9]|3[1-9]\d|4([0-8]\d|9[0-8]))$","无火回送普速客车底"
|
||||
|
@@ -19,8 +19,6 @@ import java.util.*
|
||||
class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
companion object {
|
||||
const val TAG = "LBJ_BT"
|
||||
const val SCAN_PERIOD = 10000L
|
||||
|
||||
val SERVICE_UUID = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb")
|
||||
val CHAR_UUID = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
@@ -42,20 +40,69 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
private var targetDeviceName: String? = null
|
||||
private var bluetoothLeScanner: BluetoothLeScanner? = null
|
||||
|
||||
private var continuousScanning = false
|
||||
private var autoReconnect = true
|
||||
private var lastKnownDeviceAddress: String? = null
|
||||
private var connectionAttempts = 0
|
||||
private var isReconnecting = false
|
||||
private var highFrequencyReconnect = true
|
||||
private var reconnectHandler = Handler(Looper.getMainLooper())
|
||||
private var reconnectRunnable: Runnable? = null
|
||||
private var connectionLostCallback: (() -> Unit)? = null
|
||||
private var connectionSuccessCallback: ((String) -> Unit)? = null
|
||||
private var specifiedDeviceAddress: String? = null
|
||||
private var targetDeviceAddress: String? = null
|
||||
private var isDialogOpen = false
|
||||
private var isManualDisconnect = false
|
||||
private var isAutoConnectBlocked = false
|
||||
|
||||
private val leScanCallback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val device = result.device
|
||||
val deviceName = device.name
|
||||
if (targetDeviceName != null) {
|
||||
if (deviceName == null || !deviceName.equals(targetDeviceName, ignoreCase = true)) {
|
||||
return
|
||||
|
||||
val shouldShowDevice = when {
|
||||
targetDeviceName != null -> {
|
||||
deviceName != null && deviceName.equals(targetDeviceName, ignoreCase = true)
|
||||
}
|
||||
else -> {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldShowDevice) {
|
||||
Log.d(TAG, "Showing filtered device: $deviceName")
|
||||
scanCallback?.invoke(device)
|
||||
}
|
||||
|
||||
if (!isConnected && !isReconnecting && !isDialogOpen && !isAutoConnectBlocked) {
|
||||
val deviceAddress = device.address
|
||||
val isSpecifiedDevice = specifiedDeviceAddress == deviceAddress
|
||||
val isTargetDevice = targetDeviceName != null && deviceName != null && deviceName.equals(targetDeviceName, ignoreCase = true)
|
||||
val isKnownDevice = lastKnownDeviceAddress == deviceAddress
|
||||
val isSpecificTargetAddress = targetDeviceAddress == deviceAddress
|
||||
|
||||
if (isSpecificTargetAddress || isSpecifiedDevice || (specifiedDeviceAddress == null && isTargetDevice) || (specifiedDeviceAddress == null && isKnownDevice)) {
|
||||
val priority = when {
|
||||
isSpecificTargetAddress -> "specific target address"
|
||||
isSpecifiedDevice -> "specified device"
|
||||
isTargetDevice -> "target device name"
|
||||
else -> "known device"
|
||||
}
|
||||
Log.i(TAG, "Found device ($priority): $deviceName, auto-connecting")
|
||||
lastKnownDeviceAddress = deviceAddress
|
||||
connectImmediately(deviceAddress)
|
||||
}
|
||||
}
|
||||
scanCallback?.invoke(device)
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
Log.e(TAG, "BLE scan failed code=$errorCode")
|
||||
if (continuousScanning) {
|
||||
handler.post {
|
||||
restartScan()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,12 +154,9 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
return
|
||||
}
|
||||
|
||||
handler.postDelayed({
|
||||
stopScan()
|
||||
}, SCAN_PERIOD)
|
||||
|
||||
isScanning = true
|
||||
Log.d(TAG, "Starting BLE scan target=${targetDeviceName ?: "Any"}")
|
||||
continuousScanning = true
|
||||
Log.d(TAG, "Starting continuous BLE scan target=${targetDeviceName ?: "Any"}")
|
||||
bluetoothLeScanner?.startScan(leScanCallback)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Scan security error: ${e.message}")
|
||||
@@ -127,6 +171,40 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
if (isScanning) {
|
||||
bluetoothLeScanner?.stopScan(leScanCallback)
|
||||
isScanning = false
|
||||
continuousScanning = false
|
||||
Log.d(TAG, "Stopped BLE scan")
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun restartScan() {
|
||||
if (!continuousScanning) return
|
||||
|
||||
try {
|
||||
bluetoothLeScanner?.stopScan(leScanCallback)
|
||||
bluetoothLeScanner?.startScan(leScanCallback)
|
||||
isScanning = true
|
||||
Log.d(TAG, "Restarted BLE scan")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to restart scan: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun connectImmediately(address: String) {
|
||||
if (isReconnecting) return
|
||||
isReconnecting = true
|
||||
|
||||
handler.post {
|
||||
connect(address) { connected ->
|
||||
isReconnecting = false
|
||||
if (connected) {
|
||||
connectionAttempts = 0
|
||||
Log.i(TAG, "Successfully connected to $address")
|
||||
} else {
|
||||
connectionAttempts++
|
||||
Log.w(TAG, "Connection attempt $connectionAttempts failed for $address")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,16 +263,6 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
Log.d(TAG, "Connecting to address=$address")
|
||||
|
||||
|
||||
handler.postDelayed({
|
||||
if (!isConnected && deviceAddress == address) {
|
||||
Log.e(TAG, "Connection timeout reconnecting")
|
||||
|
||||
bluetoothGatt?.close()
|
||||
bluetoothGatt =
|
||||
device.connectGatt(context, false, this, BluetoothDevice.TRANSPORT_LE)
|
||||
}
|
||||
}, 10000)
|
||||
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Connection failed: ${e.message}")
|
||||
@@ -208,10 +276,96 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
return isConnected
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun checkActualConnectionState(): Boolean {
|
||||
bluetoothGatt?.let { gatt ->
|
||||
try {
|
||||
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
val connectedDevices = bluetoothManager.getConnectedDevices(BluetoothProfile.GATT)
|
||||
val isActuallyConnected = connectedDevices.any { it.address == deviceAddress }
|
||||
|
||||
if (isActuallyConnected && !isConnected) {
|
||||
Log.d(TAG, "Found existing GATT connection, updating internal state")
|
||||
isConnected = true
|
||||
return true
|
||||
} else if (!isActuallyConnected && isConnected) {
|
||||
Log.d(TAG, "GATT connection lost, updating internal state")
|
||||
isConnected = false
|
||||
return false
|
||||
}
|
||||
|
||||
return isActuallyConnected
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error checking actual connection state: ${e.message}")
|
||||
return isConnected
|
||||
}
|
||||
}
|
||||
return isConnected
|
||||
}
|
||||
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun disconnect() {
|
||||
bluetoothGatt?.disconnect()
|
||||
Log.d(TAG, "Manual disconnect initiated")
|
||||
isConnected = false
|
||||
isManualDisconnect = true
|
||||
isAutoConnectBlocked = true
|
||||
stopHighFrequencyReconnect()
|
||||
stopScan()
|
||||
|
||||
bluetoothGatt?.let { gatt ->
|
||||
try {
|
||||
gatt.disconnect()
|
||||
Thread.sleep(100)
|
||||
gatt.close()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Disconnect error: ${e.message}")
|
||||
}
|
||||
}
|
||||
bluetoothGatt = null
|
||||
|
||||
dataBuffer.clear()
|
||||
connectionStateCallback = null
|
||||
|
||||
Log.d(TAG, "Manual disconnect - auto connect blocked, deviceAddress preserved: $deviceAddress")
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun connectManually(address: String, onConnectionStateChange: ((Boolean) -> Unit)? = null): Boolean {
|
||||
Log.d(TAG, "Manual connection to device: $address")
|
||||
|
||||
stopScan()
|
||||
stopHighFrequencyReconnect()
|
||||
|
||||
isManualDisconnect = false
|
||||
isAutoConnectBlocked = false
|
||||
autoReconnect = true
|
||||
highFrequencyReconnect = true
|
||||
return connect(address, onConnectionStateChange)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun closeManually() {
|
||||
Log.d(TAG, "Manual close - will restore auto reconnect")
|
||||
|
||||
isConnected = false
|
||||
isManualDisconnect = false
|
||||
isAutoConnectBlocked = false
|
||||
bluetoothGatt?.let { gatt ->
|
||||
try {
|
||||
gatt.disconnect()
|
||||
gatt.close()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Close error: ${e.message}")
|
||||
}
|
||||
}
|
||||
bluetoothGatt = null
|
||||
deviceAddress = null
|
||||
|
||||
autoReconnect = true
|
||||
highFrequencyReconnect = true
|
||||
|
||||
Log.d(TAG, "Auto reconnect mechanism restored and GATT cleaned up")
|
||||
}
|
||||
|
||||
|
||||
@@ -286,30 +440,31 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.e(TAG, "Connection error status=$status")
|
||||
isConnected = false
|
||||
|
||||
isReconnecting = false
|
||||
|
||||
if (status == 133 || status == 8) {
|
||||
Log.e(TAG, "GATT error closing connection")
|
||||
Log.e(TAG, "GATT error, attempting immediate reconnection")
|
||||
try {
|
||||
gatt.close()
|
||||
bluetoothGatt = null
|
||||
|
||||
bluetoothLeScanner = null
|
||||
|
||||
deviceAddress?.let { address ->
|
||||
handler.postDelayed({
|
||||
Log.d(TAG, "Reconnecting to device")
|
||||
val device =
|
||||
BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address)
|
||||
bluetoothGatt = device.connectGatt(
|
||||
context,
|
||||
false,
|
||||
this,
|
||||
BluetoothDevice.TRANSPORT_LE
|
||||
)
|
||||
}, 2000)
|
||||
if (autoReconnect) {
|
||||
Log.d(TAG, "Immediate reconnection to device")
|
||||
handler.post {
|
||||
val device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address)
|
||||
bluetoothGatt = device.connectGatt(
|
||||
context,
|
||||
false,
|
||||
this,
|
||||
BluetoothDevice.TRANSPORT_LE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Reconnect error: ${e.message}")
|
||||
Log.e(TAG, "Immediate reconnect error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,32 +475,42 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
when (newState) {
|
||||
BluetoothProfile.STATE_CONNECTED -> {
|
||||
isConnected = true
|
||||
isReconnecting = false
|
||||
isManualDisconnect = false
|
||||
connectionAttempts = 0
|
||||
Log.i(TAG, "Connected to GATT server")
|
||||
|
||||
handler.post { connectionStateCallback?.invoke(true) }
|
||||
|
||||
deviceAddress?.let { address ->
|
||||
handler.post { connectionSuccessCallback?.invoke(address) }
|
||||
}
|
||||
|
||||
handler.postDelayed({
|
||||
handler.post {
|
||||
try {
|
||||
gatt.discoverServices()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Service discovery failed: ${e.message}")
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
isConnected = false
|
||||
Log.i(TAG, "Disconnected from GATT server")
|
||||
isReconnecting = false
|
||||
Log.i(TAG, "Disconnected from GATT server, manual=$isManualDisconnect")
|
||||
|
||||
handler.post { connectionStateCallback?.invoke(false) }
|
||||
handler.post {
|
||||
connectionStateCallback?.invoke(false)
|
||||
if (!isManualDisconnect) {
|
||||
connectionLostCallback?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!deviceAddress.isNullOrBlank()) {
|
||||
handler.postDelayed({
|
||||
Log.d(TAG, "Reconnecting after disconnect")
|
||||
connect(deviceAddress!!, connectionStateCallback)
|
||||
}, 3000)
|
||||
if (!deviceAddress.isNullOrBlank() && autoReconnect && highFrequencyReconnect && !isManualDisconnect) {
|
||||
startHighFrequencyReconnect(deviceAddress!!)
|
||||
} else if (isManualDisconnect) {
|
||||
Log.d(TAG, "Manual disconnect - no auto reconnect")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -357,17 +522,19 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
private var lastDataTime = 0L
|
||||
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onCharacteristicChanged(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic
|
||||
) {
|
||||
super.onCharacteristicChanged(gatt, characteristic)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val newData = characteristic.value?.let {
|
||||
String(it, StandardCharsets.UTF_8)
|
||||
} ?: return
|
||||
|
||||
Log.d(TAG, "Received data len=${newData.length} preview=${newData.take(50)}")
|
||||
Log.d(TAG, "Received data len=${newData.length} preview=${newData}")
|
||||
|
||||
|
||||
dataBuffer.append(newData)
|
||||
@@ -381,18 +548,17 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
val bufferContent = dataBuffer.toString()
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
|
||||
if (lastDataTime > 0 && currentTime - lastDataTime > 5000) {
|
||||
Log.w(TAG, "Data timeout ${(currentTime - lastDataTime) / 1000}s")
|
||||
|
||||
if (lastDataTime > 0) {
|
||||
val timeDiff = currentTime - lastDataTime
|
||||
if (timeDiff > 10000) {
|
||||
Log.w(TAG, "Long data gap: ${timeDiff / 1000}s")
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "Buffer size=${dataBuffer.length} bytes")
|
||||
|
||||
|
||||
tryExtractJson(bufferContent)
|
||||
|
||||
|
||||
lastDataTime = currentTime
|
||||
}
|
||||
|
||||
@@ -474,7 +640,7 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
private fun processJsonString(jsonStr: String): Boolean {
|
||||
try {
|
||||
val jsonObject = JSONObject(jsonStr)
|
||||
Log.d(TAG, "Parsed JSON len=${jsonStr.length} preview=${jsonStr.take(50)}")
|
||||
Log.d(TAG, "Parsed JSON len=${jsonStr.length} preview=${jsonStr}")
|
||||
|
||||
|
||||
handler.post {
|
||||
@@ -511,9 +677,16 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
)
|
||||
if (descriptor != null) {
|
||||
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
val writeResult = gatt.writeDescriptor(descriptor)
|
||||
Log.d(TAG, "Descriptor write result=$writeResult")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val writeResult = gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
|
||||
Log.d(TAG, "Descriptor write result=$writeResult")
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
@Suppress("DEPRECATION")
|
||||
val writeResult = gatt.writeDescriptor(descriptor)
|
||||
Log.d(TAG, "Descriptor write result=$writeResult")
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Descriptor not found")
|
||||
|
||||
@@ -540,10 +713,134 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
|
||||
|
||||
private fun requestDataAfterDelay() {
|
||||
handler.postDelayed({
|
||||
handler.post {
|
||||
statusCallback?.let { callback ->
|
||||
getStatus(callback)
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoReconnect(enabled: Boolean) {
|
||||
autoReconnect = enabled
|
||||
Log.d(TAG, "Auto reconnect set to: $enabled")
|
||||
}
|
||||
|
||||
fun setHighFrequencyReconnect(enabled: Boolean) {
|
||||
highFrequencyReconnect = enabled
|
||||
if (!enabled) {
|
||||
stopHighFrequencyReconnect()
|
||||
}
|
||||
Log.d(TAG, "High frequency reconnect set to: $enabled")
|
||||
}
|
||||
|
||||
fun setConnectionLostCallback(callback: (() -> Unit)?) {
|
||||
connectionLostCallback = callback
|
||||
}
|
||||
|
||||
fun setConnectionSuccessCallback(callback: ((String) -> Unit)?) {
|
||||
connectionSuccessCallback = callback
|
||||
}
|
||||
|
||||
fun setSpecifiedDeviceAddress(address: String?) {
|
||||
specifiedDeviceAddress = address
|
||||
Log.d(TAG, "Set specified device address: $address")
|
||||
}
|
||||
|
||||
fun getSpecifiedDeviceAddress(): String? = specifiedDeviceAddress
|
||||
|
||||
fun setDialogOpen(isOpen: Boolean) {
|
||||
isDialogOpen = isOpen
|
||||
Log.d(TAG, "Dialog open state set to: $isOpen")
|
||||
}
|
||||
|
||||
fun setAutoConnectBlocked(blocked: Boolean) {
|
||||
isAutoConnectBlocked = blocked
|
||||
Log.d(TAG, "Auto connect blocked set to: $blocked")
|
||||
}
|
||||
|
||||
fun resetManualDisconnectState() {
|
||||
isManualDisconnect = false
|
||||
isAutoConnectBlocked = false
|
||||
Log.d(TAG, "Manual disconnect state reset - auto reconnect enabled")
|
||||
}
|
||||
|
||||
fun setTargetDeviceAddress(address: String?) {
|
||||
targetDeviceAddress = address
|
||||
Log.d(TAG, "Set target device address: $address")
|
||||
}
|
||||
|
||||
fun getTargetDeviceAddress(): String? = targetDeviceAddress
|
||||
|
||||
private fun startHighFrequencyReconnect(address: String) {
|
||||
stopHighFrequencyReconnect()
|
||||
|
||||
Log.d(TAG, "Starting high frequency reconnect for: $address")
|
||||
|
||||
reconnectRunnable = Runnable {
|
||||
if (!isConnected && autoReconnect && highFrequencyReconnect) {
|
||||
Log.d(TAG, "High frequency reconnect attempt ${connectionAttempts + 1} for: $address")
|
||||
connect(address, connectionStateCallback)
|
||||
|
||||
if (!isConnected) {
|
||||
val delay = when {
|
||||
connectionAttempts < 10 -> 100L
|
||||
connectionAttempts < 30 -> 200L
|
||||
connectionAttempts < 60 -> 500L
|
||||
else -> 1000L
|
||||
}
|
||||
|
||||
reconnectHandler.postDelayed(reconnectRunnable!!, delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reconnectHandler.post(reconnectRunnable!!)
|
||||
}
|
||||
|
||||
private fun stopHighFrequencyReconnect() {
|
||||
reconnectRunnable?.let {
|
||||
reconnectHandler.removeCallbacks(it)
|
||||
reconnectRunnable = null
|
||||
Log.d(TAG, "Stopped high frequency reconnect")
|
||||
}
|
||||
}
|
||||
|
||||
fun getConnectionAttempts(): Int = connectionAttempts
|
||||
|
||||
fun getLastKnownDeviceAddress(): String? = lastKnownDeviceAddress
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun disconnectAndCleanup() {
|
||||
isConnected = false
|
||||
autoReconnect = false
|
||||
highFrequencyReconnect = false
|
||||
isManualDisconnect = false
|
||||
isAutoConnectBlocked = false
|
||||
stopHighFrequencyReconnect()
|
||||
stopScan()
|
||||
|
||||
bluetoothGatt?.let { gatt ->
|
||||
try {
|
||||
gatt.disconnect()
|
||||
Thread.sleep(200)
|
||||
gatt.close()
|
||||
Log.d(TAG, "GATT connection cleaned up")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Cleanup error: ${e.message}")
|
||||
}
|
||||
}
|
||||
bluetoothGatt = null
|
||||
bluetoothLeScanner = null
|
||||
deviceAddress = null
|
||||
connectionAttempts = 0
|
||||
|
||||
dataBuffer.clear()
|
||||
connectionStateCallback = null
|
||||
statusCallback = null
|
||||
trainInfoCallback = null
|
||||
connectionLostCallback = null
|
||||
connectionSuccessCallback = null
|
||||
|
||||
Log.d(TAG, "BLE client fully disconnected and cleaned up")
|
||||
}
|
||||
}
|
||||
123
app/src/main/java/org/noxylva/lbjconsole/BackgroundService.kt
Normal file
@@ -0,0 +1,123 @@
|
||||
package org.noxylva.lbjconsole
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
class BackgroundService : Service() {
|
||||
|
||||
companion object {
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val CHANNEL_ID = "background_service_channel"
|
||||
private const val CHANNEL_NAME = "Background Service"
|
||||
|
||||
fun startService(context: Context) {
|
||||
try {
|
||||
val intent = Intent(context, BackgroundService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Service start failed, ignore silently
|
||||
}
|
||||
}
|
||||
|
||||
fun stopService(context: Context) {
|
||||
val intent = Intent(context, BackgroundService::class.java)
|
||||
context.stopService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
acquireWakeLock()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
try {
|
||||
val notification = createNotification()
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
} catch (e: Exception) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
releaseWakeLock()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Keep app running in background"
|
||||
setShowBadge(false)
|
||||
}
|
||||
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotification(): Notification {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("LBJ Console")
|
||||
.setContentText("Running in background")
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun acquireWakeLock() {
|
||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
wakeLock = powerManager.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"LBJConsole::BackgroundWakeLock"
|
||||
)
|
||||
wakeLock?.acquire()
|
||||
}
|
||||
|
||||
private fun releaseWakeLock() {
|
||||
wakeLock?.let {
|
||||
if (it.isHeld) {
|
||||
it.release()
|
||||
}
|
||||
}
|
||||
wakeLock = null
|
||||
}
|
||||
}
|
||||
123
app/src/main/java/org/noxylva/lbjconsole/FilePickerActivity.kt
Normal file
@@ -0,0 +1,123 @@
|
||||
package org.noxylva.lbjconsole
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.noxylva.lbjconsole.util.DatabaseExportImportUtil
|
||||
|
||||
class FilePickerActivity : ComponentActivity() {
|
||||
private val exportFilePicker = registerForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/json")
|
||||
) { uri ->
|
||||
uri?.let { exportDatabase(it) }
|
||||
}
|
||||
|
||||
private val importFilePicker = registerForActivityResult(
|
||||
ActivityResultContracts.OpenDocument()
|
||||
) { uri ->
|
||||
uri?.let { importDatabase(it) }
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val action = intent.getStringExtra("action") ?: "export"
|
||||
|
||||
setContent {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
when (action) {
|
||||
"export" -> {
|
||||
val fileName = "lbj_console_backup_${System.currentTimeMillis()}.json"
|
||||
exportFilePicker.launch(fileName)
|
||||
}
|
||||
"import" -> {
|
||||
importFilePicker.launch(arrayOf("application/json", "text/plain"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportDatabase(uri: Uri) {
|
||||
val coroutineScope = kotlinx.coroutines.MainScope()
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
val databaseUtil = DatabaseExportImportUtil(this@FilePickerActivity)
|
||||
val json = databaseUtil.exportDatabase()
|
||||
|
||||
contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
outputStream.write(json.toByteArray())
|
||||
}
|
||||
|
||||
Toast.makeText(
|
||||
this@FilePickerActivity,
|
||||
"数据导出成功",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(
|
||||
this@FilePickerActivity,
|
||||
"导出失败: ${e.message}",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} finally {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun importDatabase(uri: Uri) {
|
||||
val coroutineScope = kotlinx.coroutines.MainScope()
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
val databaseUtil = DatabaseExportImportUtil(this@FilePickerActivity)
|
||||
val success = databaseUtil.importDatabase(uri)
|
||||
|
||||
if (success) {
|
||||
Toast.makeText(
|
||||
this@FilePickerActivity,
|
||||
"数据导入成功",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} else {
|
||||
Toast.makeText(
|
||||
this@FilePickerActivity,
|
||||
"数据导入失败",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(
|
||||
this@FilePickerActivity,
|
||||
"导入失败: ${e.message}",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} finally {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun createExportIntent(context: android.content.Context): Intent {
|
||||
return Intent(context, FilePickerActivity::class.java).apply {
|
||||
putExtra("action", "export")
|
||||
}
|
||||
}
|
||||
|
||||
fun createImportIntent(context: android.content.Context): Intent {
|
||||
return Intent(context, FilePickerActivity::class.java).apply {
|
||||
putExtra("action", "import")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
223
app/src/main/java/org/noxylva/lbjconsole/NotificationService.kt
Normal file
@@ -0,0 +1,223 @@
|
||||
package org.noxylva.lbjconsole
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.noxylva.lbjconsole.database.AppSettingsRepository
|
||||
import org.noxylva.lbjconsole.database.TrainDatabase
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import org.json.JSONObject
|
||||
import org.noxylva.lbjconsole.model.TrainRecord
|
||||
|
||||
|
||||
class NotificationService(private val context: Context) {
|
||||
companion object {
|
||||
const val TAG = "NotificationService"
|
||||
const val CHANNEL_ID = "lbj_messages"
|
||||
const val CHANNEL_NAME = "LBJ Messages"
|
||||
const val NOTIFICATION_ID_BASE = 2000
|
||||
const val PREFS_NAME = "notification_settings"
|
||||
const val KEY_ENABLED = "notifications_enabled"
|
||||
}
|
||||
|
||||
private val notificationManager = NotificationManagerCompat.from(context)
|
||||
private val appSettingsRepository = AppSettingsRepository(context)
|
||||
private var notificationIdCounter = NOTIFICATION_ID_BASE
|
||||
|
||||
init {
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
description = "Real-time LBJ train message notifications"
|
||||
enableVibration(true)
|
||||
setShowBadge(true)
|
||||
}
|
||||
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.createNotificationChannel(channel)
|
||||
Log.d(TAG, "Notification channel created")
|
||||
}
|
||||
}
|
||||
|
||||
fun isNotificationEnabled(): Boolean {
|
||||
return runBlocking {
|
||||
appSettingsRepository.getSettings().notificationEnabled
|
||||
}
|
||||
}
|
||||
|
||||
fun setNotificationEnabled(enabled: Boolean) {
|
||||
runBlocking {
|
||||
appSettingsRepository.updateNotificationEnabled(enabled)
|
||||
}
|
||||
Log.d(TAG, "Notification enabled set to: $enabled")
|
||||
}
|
||||
|
||||
private fun isValidValue(value: String): Boolean {
|
||||
val trimmed = value.trim()
|
||||
return trimmed.isNotEmpty() &&
|
||||
trimmed != "NUL" &&
|
||||
trimmed != "<NUL>" &&
|
||||
trimmed != "NA" &&
|
||||
trimmed != "<NA>" &&
|
||||
!trimmed.all { it == '*' }
|
||||
}
|
||||
|
||||
fun showTrainNotification(trainRecord: TrainRecord) {
|
||||
if (!isNotificationEnabled()) {
|
||||
Log.d(TAG, "Notifications disabled, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||
)
|
||||
|
||||
val remoteViews = RemoteViews(context.packageName, R.layout.notification_train_record)
|
||||
val trainDisplay = if (isValidValue(trainRecord.lbjClass) && isValidValue(trainRecord.train)) {
|
||||
"${trainRecord.lbjClass.trim()}${trainRecord.train.trim()}"
|
||||
} else if (isValidValue(trainRecord.lbjClass)) {
|
||||
trainRecord.lbjClass.trim()
|
||||
} else if (isValidValue(trainRecord.train)) {
|
||||
trainRecord.train.trim()
|
||||
} else "列车"
|
||||
remoteViews.setTextViewText(R.id.notification_train_number, trainDisplay)
|
||||
|
||||
val directionText = when (trainRecord.direction) {
|
||||
1 -> "下"
|
||||
3 -> "上"
|
||||
else -> ""
|
||||
}
|
||||
if (directionText.isNotEmpty()) {
|
||||
remoteViews.setTextViewText(R.id.notification_direction, directionText)
|
||||
remoteViews.setViewVisibility(R.id.notification_direction, View.VISIBLE)
|
||||
} else {
|
||||
remoteViews.setViewVisibility(R.id.notification_direction, View.GONE)
|
||||
}
|
||||
|
||||
val locoInfo = when {
|
||||
isValidValue(trainRecord.locoType) && isValidValue(trainRecord.loco) -> {
|
||||
val shortLoco = if (trainRecord.loco.length > 5) {
|
||||
trainRecord.loco.takeLast(5)
|
||||
} else {
|
||||
trainRecord.loco
|
||||
}
|
||||
"${trainRecord.locoType}-${shortLoco}"
|
||||
}
|
||||
isValidValue(trainRecord.locoType) -> trainRecord.locoType
|
||||
isValidValue(trainRecord.loco) -> trainRecord.loco
|
||||
else -> ""
|
||||
}
|
||||
if (locoInfo.isNotEmpty()) {
|
||||
remoteViews.setTextViewText(R.id.notification_loco_info, locoInfo)
|
||||
remoteViews.setViewVisibility(R.id.notification_loco_info, View.VISIBLE)
|
||||
} else {
|
||||
remoteViews.setViewVisibility(R.id.notification_loco_info, View.GONE)
|
||||
}
|
||||
|
||||
if (isValidValue(trainRecord.route)) {
|
||||
remoteViews.setTextViewText(R.id.notification_route, trainRecord.route.trim())
|
||||
remoteViews.setViewVisibility(R.id.notification_route, View.VISIBLE)
|
||||
} else {
|
||||
remoteViews.setViewVisibility(R.id.notification_route, View.GONE)
|
||||
}
|
||||
|
||||
if (isValidValue(trainRecord.position)) {
|
||||
remoteViews.setTextViewText(R.id.notification_position, "${trainRecord.position.trim().removeSuffix(".")}K")
|
||||
remoteViews.setViewVisibility(R.id.notification_position, View.VISIBLE)
|
||||
} else {
|
||||
remoteViews.setViewVisibility(R.id.notification_position, View.GONE)
|
||||
}
|
||||
|
||||
if (isValidValue(trainRecord.speed)) {
|
||||
remoteViews.setTextViewText(R.id.notification_speed, "${trainRecord.speed.trim()} km/h")
|
||||
remoteViews.setViewVisibility(R.id.notification_speed, View.VISIBLE)
|
||||
} else {
|
||||
remoteViews.setViewVisibility(R.id.notification_speed, View.GONE)
|
||||
}
|
||||
|
||||
remoteViews.setOnClickPendingIntent(R.id.notification_train_number, pendingIntent)
|
||||
|
||||
val summaryParts = mutableListOf<String>()
|
||||
|
||||
val routeAndDirection = when {
|
||||
isValidValue(trainRecord.route) && directionText.isNotEmpty() -> "${trainRecord.route.trim()}${directionText}行"
|
||||
isValidValue(trainRecord.route) -> trainRecord.route.trim()
|
||||
directionText.isNotEmpty() -> "${directionText}行"
|
||||
else -> null
|
||||
}
|
||||
|
||||
routeAndDirection?.let { summaryParts.add(it) }
|
||||
if (locoInfo.isNotEmpty()) summaryParts.add(locoInfo)
|
||||
|
||||
val summaryText = summaryParts.joinToString(" • ")
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(trainDisplay)
|
||||
.setContentText(summaryText)
|
||||
.setCustomContentView(remoteViews)
|
||||
.setCustomBigContentView(remoteViews)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setWhen(trainRecord.timestamp.time)
|
||||
.build()
|
||||
|
||||
val notificationId = notificationIdCounter++
|
||||
if (notificationIdCounter > NOTIFICATION_ID_BASE + 1000) {
|
||||
notificationIdCounter = NOTIFICATION_ID_BASE
|
||||
}
|
||||
|
||||
notificationManager.notify(notificationId, notification)
|
||||
Log.d(TAG, "Custom notification sent for train: ${trainRecord.train}")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to show notification: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun showTrainNotification(jsonData: JSONObject) {
|
||||
if (!isNotificationEnabled()) {
|
||||
Log.d(TAG, "Notifications disabled, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val trainRecord = TrainRecord(jsonData)
|
||||
showTrainNotification(trainRecord)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to create TrainRecord from JSON: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun hasNotificationPermission(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
notificationManager.areNotificationsEnabled()
|
||||
} else {
|
||||
notificationManager.areNotificationsEnabled()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.noxylva.lbjconsole.database
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface AppSettingsDao {
|
||||
|
||||
@Query("SELECT * FROM app_settings WHERE id = 1")
|
||||
suspend fun getSettings(): AppSettingsEntity?
|
||||
|
||||
@Query("SELECT * FROM app_settings WHERE id = 1")
|
||||
fun getSettingsFlow(): Flow<AppSettingsEntity?>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertSettings(settings: AppSettingsEntity)
|
||||
|
||||
@Update
|
||||
suspend fun updateSettings(settings: AppSettingsEntity)
|
||||
|
||||
@Query("DELETE FROM app_settings")
|
||||
suspend fun deleteAllSettings()
|
||||
|
||||
@Query("UPDATE app_settings SET notificationEnabled = :enabled WHERE id = 1")
|
||||
suspend fun updateNotificationEnabled(enabled: Boolean)
|
||||
|
||||
@Transaction
|
||||
suspend fun saveSettings(settings: AppSettingsEntity) {
|
||||
insertSettings(settings)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.noxylva.lbjconsole.database
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "app_settings")
|
||||
data class AppSettingsEntity(
|
||||
@PrimaryKey val id: Int = 1,
|
||||
val deviceName: String = "LBJReceiver",
|
||||
val currentTab: Int = 0,
|
||||
val historyEditMode: Boolean = false,
|
||||
val historySelectedRecords: String = "",
|
||||
val historyExpandedStates: String = "",
|
||||
val historyScrollPosition: Int = 0,
|
||||
val historyScrollOffset: Int = 0,
|
||||
val settingsScrollPosition: Int = 0,
|
||||
val mapCenterLat: Float? = null,
|
||||
val mapCenterLon: Float? = null,
|
||||
val mapZoomLevel: Float = 10.0f,
|
||||
val mapRailwayLayerVisible: Boolean = true,
|
||||
val specifiedDeviceAddress: String? = null,
|
||||
val searchOrderList: String = "",
|
||||
val autoConnectEnabled: Boolean = true,
|
||||
val backgroundServiceEnabled: Boolean = false,
|
||||
val notificationEnabled: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
package org.noxylva.lbjconsole.database
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
class AppSettingsRepository(private val context: Context) {
|
||||
private val dao = TrainDatabase.getDatabase(context).appSettingsDao()
|
||||
private val sharedPrefs: SharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
|
||||
suspend fun getSettings(): AppSettingsEntity {
|
||||
var settings = dao.getSettings()
|
||||
|
||||
if (settings == null) {
|
||||
settings = migrateFromSharedPreferences()
|
||||
dao.saveSettings(settings)
|
||||
}
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
fun getSettingsFlow(): Flow<AppSettingsEntity?> {
|
||||
return dao.getSettingsFlow()
|
||||
}
|
||||
|
||||
suspend fun saveSettings(settings: AppSettingsEntity) {
|
||||
dao.saveSettings(settings)
|
||||
}
|
||||
|
||||
suspend fun updateDeviceName(deviceName: String) {
|
||||
val current = getSettings()
|
||||
saveSettings(current.copy(deviceName = deviceName))
|
||||
}
|
||||
|
||||
suspend fun updateCurrentTab(tab: Int) {
|
||||
val current = getSettings()
|
||||
saveSettings(current.copy(currentTab = tab))
|
||||
}
|
||||
|
||||
suspend fun updateHistoryEditMode(editMode: Boolean) {
|
||||
val current = getSettings()
|
||||
saveSettings(current.copy(historyEditMode = editMode))
|
||||
}
|
||||
|
||||
suspend fun updateHistorySelectedRecords(selectedRecords: String) {
|
||||
val current = getSettings()
|
||||
saveSettings(current.copy(historySelectedRecords = selectedRecords))
|
||||
}
|
||||
|
||||
suspend fun updateHistoryExpandedStates(expandedStates: String) {
|
||||
val current = getSettings()
|
||||
saveSettings(current.copy(historyExpandedStates = expandedStates))
|
||||
}
|
||||
|
||||
suspend fun updateHistoryScrollPosition(position: Int, offset: Int = 0) {
|
||||
val current = getSettings()
|
||||
saveSettings(current.copy(historyScrollPosition = position, historyScrollOffset = offset))
|
||||
}
|
||||
|
||||
suspend fun updateSettingsScrollPosition(position: Int) {
|
||||
val current = getSettings()
|
||||
saveSettings(current.copy(settingsScrollPosition = position))
|
||||
}
|
||||
|
||||
suspend fun updateMapSettings(centerLat: Float?, centerLon: Float?, zoomLevel: Float, railwayLayerVisible: Boolean) {
|
||||
val current = getSettings()
|
||||
saveSettings(current.copy(
|
||||
mapCenterLat = centerLat,
|
||||
mapCenterLon = centerLon,
|
||||
mapZoomLevel = zoomLevel,
|
||||
mapRailwayLayerVisible = railwayLayerVisible
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun updateSpecifiedDeviceAddress(address: String?) {
|
||||
val current = getSettings()
|
||||
saveSettings(current.copy(specifiedDeviceAddress = address))
|
||||
}
|
||||
|
||||
suspend fun updateSearchOrderList(orderList: String) {
|
||||
val current = getSettings()
|
||||
saveSettings(current.copy(searchOrderList = orderList))
|
||||
}
|
||||
|
||||
suspend fun updateAutoConnectEnabled(enabled: Boolean) {
|
||||
val current = getSettings()
|
||||
saveSettings(current.copy(autoConnectEnabled = enabled))
|
||||
}
|
||||
|
||||
suspend fun updateBackgroundServiceEnabled(enabled: Boolean) {
|
||||
val current = getSettings()
|
||||
saveSettings(current.copy(backgroundServiceEnabled = enabled))
|
||||
}
|
||||
|
||||
suspend fun updateNotificationEnabled(enabled: Boolean) {
|
||||
val current = getSettings()
|
||||
saveSettings(current.copy(notificationEnabled = enabled))
|
||||
}
|
||||
|
||||
private fun migrateFromSharedPreferences(): AppSettingsEntity {
|
||||
return AppSettingsEntity(
|
||||
deviceName = sharedPrefs.getString("device_name", "LBJReceiver") ?: "LBJReceiver",
|
||||
currentTab = sharedPrefs.getInt("current_tab", 0),
|
||||
historyEditMode = sharedPrefs.getBoolean("history_edit_mode", false),
|
||||
historySelectedRecords = sharedPrefs.getString("history_selected_records", "") ?: "",
|
||||
historyExpandedStates = sharedPrefs.getString("history_expanded_states", "") ?: "",
|
||||
historyScrollPosition = sharedPrefs.getInt("history_scroll_position", 0),
|
||||
historyScrollOffset = sharedPrefs.getInt("history_scroll_offset", 0),
|
||||
settingsScrollPosition = sharedPrefs.getInt("settings_scroll_position", 0),
|
||||
mapCenterLat = if (sharedPrefs.contains("map_center_lat")) sharedPrefs.getFloat("map_center_lat", 0f) else null,
|
||||
mapCenterLon = if (sharedPrefs.contains("map_center_lon")) sharedPrefs.getFloat("map_center_lon", 0f) else null,
|
||||
mapZoomLevel = sharedPrefs.getFloat("map_zoom_level", 10.0f),
|
||||
mapRailwayLayerVisible = sharedPrefs.getBoolean("map_railway_layer_visible", true),
|
||||
specifiedDeviceAddress = sharedPrefs.getString("specified_device_address", null),
|
||||
searchOrderList = sharedPrefs.getString("search_order_list", "") ?: "",
|
||||
autoConnectEnabled = sharedPrefs.getBoolean("auto_connect_enabled", true),
|
||||
backgroundServiceEnabled = sharedPrefs.getBoolean("background_service_enabled", false),
|
||||
notificationEnabled = context.getSharedPreferences("notification_settings", Context.MODE_PRIVATE)
|
||||
.getBoolean("notifications_enabled", false)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun clearSharedPreferences() {
|
||||
sharedPrefs.edit().clear().apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package org.noxylva.lbjconsole.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
@Database(
|
||||
entities = [TrainRecordEntity::class, AppSettingsEntity::class],
|
||||
version = 4,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class TrainDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun trainRecordDao(): TrainRecordDao
|
||||
abstract fun appSettingsDao(): AppSettingsDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: TrainDatabase? = null
|
||||
|
||||
private val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("""
|
||||
CREATE TABLE IF NOT EXISTS `app_settings` (
|
||||
`id` INTEGER NOT NULL,
|
||||
`deviceName` TEXT NOT NULL,
|
||||
`currentTab` INTEGER NOT NULL,
|
||||
`historyEditMode` INTEGER NOT NULL,
|
||||
`historySelectedRecords` TEXT NOT NULL,
|
||||
`historyExpandedStates` TEXT NOT NULL,
|
||||
`historyScrollPosition` INTEGER NOT NULL,
|
||||
`historyScrollOffset` INTEGER NOT NULL,
|
||||
`settingsScrollPosition` INTEGER NOT NULL,
|
||||
`mapCenterLat` REAL,
|
||||
`mapCenterLon` REAL,
|
||||
`mapZoomLevel` REAL NOT NULL,
|
||||
`mapRailwayLayerVisible` INTEGER NOT NULL,
|
||||
`specifiedDeviceAddress` TEXT,
|
||||
`searchOrderList` TEXT NOT NULL,
|
||||
`autoConnectEnabled` INTEGER NOT NULL,
|
||||
`backgroundServiceEnabled` INTEGER NOT NULL,
|
||||
PRIMARY KEY(`id`)
|
||||
)
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE app_settings ADD COLUMN notificationEnabled INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Since we can't determine the exact schema change, we'll use fallback migration
|
||||
// This will preserve data where possible while updating the schema
|
||||
|
||||
// Create new table with correct schema
|
||||
database.execSQL("""
|
||||
CREATE TABLE IF NOT EXISTS `app_settings_new` (
|
||||
`id` INTEGER NOT NULL,
|
||||
`deviceName` TEXT NOT NULL DEFAULT 'LBJReceiver',
|
||||
`currentTab` INTEGER NOT NULL DEFAULT 0,
|
||||
`historyEditMode` INTEGER NOT NULL DEFAULT 0,
|
||||
`historySelectedRecords` TEXT NOT NULL DEFAULT '',
|
||||
`historyExpandedStates` TEXT NOT NULL DEFAULT '',
|
||||
`historyScrollPosition` INTEGER NOT NULL DEFAULT 0,
|
||||
`historyScrollOffset` INTEGER NOT NULL DEFAULT 0,
|
||||
`settingsScrollPosition` INTEGER NOT NULL DEFAULT 0,
|
||||
`mapCenterLat` REAL,
|
||||
`mapCenterLon` REAL,
|
||||
`mapZoomLevel` REAL NOT NULL DEFAULT 10.0,
|
||||
`mapRailwayLayerVisible` INTEGER NOT NULL DEFAULT 1,
|
||||
`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,
|
||||
PRIMARY KEY(`id`)
|
||||
)
|
||||
""")
|
||||
|
||||
// Copy data from old table to new table, handling missing columns
|
||||
try {
|
||||
database.execSQL("""
|
||||
INSERT INTO `app_settings_new` (
|
||||
id, deviceName, currentTab, historyEditMode, historySelectedRecords,
|
||||
historyExpandedStates, historyScrollPosition, historyScrollOffset,
|
||||
settingsScrollPosition, mapCenterLat, mapCenterLon, mapZoomLevel,
|
||||
mapRailwayLayerVisible
|
||||
)
|
||||
SELECT
|
||||
COALESCE(id, 1),
|
||||
COALESCE(deviceName, 'LBJReceiver'),
|
||||
COALESCE(currentTab, 0),
|
||||
COALESCE(historyEditMode, 0),
|
||||
COALESCE(historySelectedRecords, ''),
|
||||
COALESCE(historyExpandedStates, ''),
|
||||
COALESCE(historyScrollPosition, 0),
|
||||
COALESCE(historyScrollOffset, 0),
|
||||
COALESCE(settingsScrollPosition, 0),
|
||||
mapCenterLat,
|
||||
mapCenterLon,
|
||||
COALESCE(mapZoomLevel, 10.0),
|
||||
COALESCE(mapRailwayLayerVisible, 1)
|
||||
FROM `app_settings`
|
||||
""")
|
||||
} catch (e: Exception) {
|
||||
// If the old table doesn't exist or has different structure, insert default
|
||||
database.execSQL("""
|
||||
INSERT INTO `app_settings_new` (
|
||||
id, deviceName, currentTab, historyEditMode, historySelectedRecords,
|
||||
historyExpandedStates, historyScrollPosition, historyScrollOffset,
|
||||
settingsScrollPosition, mapZoomLevel, mapRailwayLayerVisible,
|
||||
searchOrderList, autoConnectEnabled, backgroundServiceEnabled,
|
||||
notificationEnabled
|
||||
) VALUES (
|
||||
1, 'LBJReceiver', 0, 0, '', '', 0, 0, 0, 10.0, 1, '', 1, 0, 0
|
||||
)
|
||||
""")
|
||||
}
|
||||
|
||||
// Drop old table and rename new table
|
||||
database.execSQL("DROP TABLE IF EXISTS `app_settings`")
|
||||
database.execSQL("ALTER TABLE `app_settings_new` RENAME TO `app_settings`")
|
||||
}
|
||||
}
|
||||
|
||||
fun getDatabase(context: Context): TrainDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
TrainDatabase::class.java,
|
||||
"train_database"
|
||||
).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4).build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.noxylva.lbjconsole.database
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface TrainRecordDao {
|
||||
|
||||
@Query("SELECT * FROM train_records ORDER BY timestamp DESC")
|
||||
suspend fun getAllRecords(): List<TrainRecordEntity>
|
||||
|
||||
@Query("SELECT * FROM train_records ORDER BY timestamp DESC")
|
||||
fun getAllRecordsFlow(): Flow<List<TrainRecordEntity>>
|
||||
|
||||
@Query("SELECT * FROM train_records WHERE uniqueId = :uniqueId")
|
||||
suspend fun getRecordById(uniqueId: String): TrainRecordEntity?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM train_records")
|
||||
suspend fun getRecordCount(): Int
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertRecord(record: TrainRecordEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertRecords(records: List<TrainRecordEntity>)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteRecord(record: TrainRecordEntity)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteRecords(records: List<TrainRecordEntity>)
|
||||
|
||||
@Query("DELETE FROM train_records")
|
||||
suspend fun deleteAllRecords()
|
||||
|
||||
@Query("DELETE FROM train_records WHERE uniqueId = :uniqueId")
|
||||
suspend fun deleteRecordById(uniqueId: String)
|
||||
|
||||
@Query("DELETE FROM train_records WHERE uniqueId IN (:uniqueIds)")
|
||||
suspend fun deleteRecordsByIds(uniqueIds: List<String>)
|
||||
|
||||
@Query("SELECT * FROM train_records WHERE train LIKE '%' || :train || '%' AND route LIKE '%' || :route || '%' AND (:direction = '全部' OR (:direction = '上行' AND direction = 3) OR (:direction = '下行' AND direction = 1)) ORDER BY timestamp DESC")
|
||||
suspend fun getFilteredRecords(train: String, route: String, direction: String): List<TrainRecordEntity>
|
||||
|
||||
@Query("SELECT * FROM train_records ORDER BY timestamp DESC LIMIT :limit")
|
||||
suspend fun getLatestRecords(limit: Int): List<TrainRecordEntity>
|
||||
|
||||
@Query("SELECT * FROM train_records WHERE timestamp >= :fromTime ORDER BY timestamp DESC")
|
||||
suspend fun getRecordsFromTime(fromTime: Long): List<TrainRecordEntity>
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package org.noxylva.lbjconsole.database
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import org.noxylva.lbjconsole.model.TrainRecord
|
||||
import org.json.JSONObject
|
||||
import java.util.*
|
||||
|
||||
@Entity(tableName = "train_records")
|
||||
data class TrainRecordEntity(
|
||||
@PrimaryKey val uniqueId: String,
|
||||
val timestamp: Long,
|
||||
val receivedTimestamp: Long,
|
||||
val train: String,
|
||||
val direction: Int,
|
||||
val speed: String,
|
||||
val position: String,
|
||||
val time: String,
|
||||
val loco: String,
|
||||
val locoType: String,
|
||||
val lbjClass: String,
|
||||
val route: String,
|
||||
val positionInfo: String,
|
||||
val rssi: Double
|
||||
) {
|
||||
fun toTrainRecord(): TrainRecord {
|
||||
val jsonData = JSONObject().apply {
|
||||
put("uniqueId", uniqueId)
|
||||
put("timestamp", timestamp)
|
||||
put("receivedTimestamp", receivedTimestamp)
|
||||
put("train", train)
|
||||
put("dir", direction)
|
||||
put("speed", speed)
|
||||
put("pos", position)
|
||||
put("time", time)
|
||||
put("loco", loco)
|
||||
put("loco_type", locoType)
|
||||
put("lbj_class", lbjClass)
|
||||
put("route", route)
|
||||
put("position_info", positionInfo)
|
||||
put("rssi", rssi)
|
||||
}
|
||||
return TrainRecord(jsonData)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromTrainRecord(record: TrainRecord): TrainRecordEntity {
|
||||
return TrainRecordEntity(
|
||||
uniqueId = record.uniqueId,
|
||||
timestamp = record.timestamp.time,
|
||||
receivedTimestamp = record.receivedTimestamp.time,
|
||||
train = record.train,
|
||||
direction = record.direction,
|
||||
speed = record.speed,
|
||||
position = record.position,
|
||||
time = record.time,
|
||||
loco = record.loco,
|
||||
locoType = record.locoType,
|
||||
lbjClass = record.lbjClass,
|
||||
route = record.route,
|
||||
positionInfo = record.positionInfo,
|
||||
rssi = record.rssi
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.noxylva.lbjconsole.model
|
||||
|
||||
data class MergeSettings(
|
||||
val enabled: Boolean = true,
|
||||
val groupBy: GroupBy = GroupBy.TRAIN_AND_LOCO,
|
||||
val timeWindow: TimeWindow = TimeWindow.UNLIMITED
|
||||
)
|
||||
|
||||
enum class GroupBy(val displayName: String) {
|
||||
TRAIN_ONLY("车次号"),
|
||||
LOCO_ONLY("机车号"),
|
||||
TRAIN_OR_LOCO("车次号或机车号"),
|
||||
TRAIN_AND_LOCO("车次号与机车号")
|
||||
}
|
||||
|
||||
enum class TimeWindow(val displayName: String, val seconds: Long?) {
|
||||
ONE_HOUR("1小时", 3600),
|
||||
TWO_HOURS("2小时", 7200),
|
||||
SIX_HOURS("6小时", 21600),
|
||||
TWELVE_HOURS("12小时", 43200),
|
||||
ONE_DAY("24小时", 86400),
|
||||
UNLIMITED("不限时间", null)
|
||||
}
|
||||
|
||||
fun generateGroupKey(record: TrainRecord, groupBy: GroupBy): String? {
|
||||
return when (groupBy) {
|
||||
GroupBy.TRAIN_ONLY -> {
|
||||
val train = record.train.trim()
|
||||
if (train.isNotEmpty() && train != "<NUL>") train else null
|
||||
}
|
||||
GroupBy.LOCO_ONLY -> {
|
||||
val loco = record.loco.trim()
|
||||
if (loco.isNotEmpty() && loco != "<NUL>") loco else null
|
||||
}
|
||||
GroupBy.TRAIN_OR_LOCO -> {
|
||||
val train = record.train.trim()
|
||||
val loco = record.loco.trim()
|
||||
when {
|
||||
train.isNotEmpty() && train != "<NUL>" -> train
|
||||
loco.isNotEmpty() && loco != "<NUL>" -> loco
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
GroupBy.TRAIN_AND_LOCO -> {
|
||||
val train = record.train.trim()
|
||||
val loco = record.loco.trim()
|
||||
if (train.isNotEmpty() && train != "<NUL>" &&
|
||||
loco.isNotEmpty() && loco != "<NUL>") {
|
||||
"${train}_${loco}"
|
||||
} else null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.noxylva.lbjconsole.model
|
||||
|
||||
import java.util.*
|
||||
|
||||
data class MergedTrainRecord(
|
||||
val groupKey: String,
|
||||
val records: List<TrainRecord>,
|
||||
val latestRecord: TrainRecord
|
||||
) {
|
||||
val recordCount: Int get() = records.size
|
||||
val timeSpan: Pair<Date, Date> get() =
|
||||
records.minByOrNull { it.timestamp }!!.timestamp to
|
||||
records.maxByOrNull { it.timestamp }!!.timestamp
|
||||
|
||||
fun getAllCoordinates() = records.mapNotNull { it.getCoordinates() }
|
||||
|
||||
fun getUniqueRoutes() = records.map { it.route }.filter { it.isNotEmpty() && it != "<NUL>" }.toSet()
|
||||
|
||||
fun getUniquePositions() = records.map { it.position }.filter { it.isNotEmpty() && it != "<NUL>" }.toSet()
|
||||
}
|
||||
@@ -1,16 +1,32 @@
|
||||
package org.noxylva.lbjconsole.model
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
import java.util.*
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.noxylva.lbjconsole.util.LocationUtils
|
||||
import org.noxylva.lbjconsole.util.LocationUtil
|
||||
import org.noxylva.lbjconsole.util.LocoTypeUtil
|
||||
|
||||
class TrainRecord(jsonData: JSONObject? = null) {
|
||||
companion object {
|
||||
const val TAG = "TrainRecord"
|
||||
private var nextId = 0L
|
||||
private var LocoTypeUtil: LocoTypeUtil? = null
|
||||
|
||||
@Synchronized
|
||||
private fun generateUniqueId(): String {
|
||||
return "${System.currentTimeMillis()}_${++nextId}"
|
||||
}
|
||||
|
||||
fun initializeLocoTypeUtil(context: Context) {
|
||||
if (LocoTypeUtil == null) {
|
||||
LocoTypeUtil = LocoTypeUtil(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val uniqueId: String
|
||||
var timestamp: Date = Date()
|
||||
var receivedTimestamp: Date = Date()
|
||||
var train: String = ""
|
||||
@@ -29,10 +45,15 @@ class TrainRecord(jsonData: JSONObject? = null) {
|
||||
private var _coordinates: GeoPoint? = null
|
||||
|
||||
init {
|
||||
uniqueId = if (jsonData?.has("uniqueId") == true) {
|
||||
jsonData.getString("uniqueId")
|
||||
} else {
|
||||
generateUniqueId()
|
||||
}
|
||||
|
||||
jsonData?.let {
|
||||
try {
|
||||
if (jsonData.has("timestamp")) {
|
||||
|
||||
timestamp = Date(jsonData.getLong("timestamp"))
|
||||
}
|
||||
|
||||
@@ -63,20 +84,25 @@ class TrainRecord(jsonData: JSONObject? = null) {
|
||||
position = jsonData.optString("pos", "")
|
||||
time = jsonData.optString("time", "")
|
||||
loco = jsonData.optString("loco", "")
|
||||
locoType = jsonData.optString("loco_type", "")
|
||||
|
||||
locoType = if (loco.isNotEmpty()) {
|
||||
val prefix = if (loco.length >= 3) loco.take(3) else loco
|
||||
LocoTypeUtil?.getLocoTypeByCode(prefix) ?: ""
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
lbjClass = jsonData.optString("lbj_class", "")
|
||||
route = jsonData.optString("route", "")
|
||||
positionInfo = jsonData.optString("position_info", "")
|
||||
rssi = jsonData.optDouble("rssi", 0.0)
|
||||
|
||||
|
||||
_coordinates = null
|
||||
|
||||
Log.d(TAG, "Successfully parsed: train=$train, dir=$direction, speed=$speed")
|
||||
Log.d(TAG, "Successfully parsed: train=$train, dir=$direction, speed=$speed, lbjClass='$lbjClass', locoType='$locoType'")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "JSON parse error: ${e.message}", e)
|
||||
|
||||
|
||||
try { train = jsonData.optString("train", "") } catch (e: Exception) { }
|
||||
try { direction = jsonData.optInt("dir", 0) } catch (e: Exception) { }
|
||||
try { speed = jsonData.optString("speed", "") } catch (e: Exception) { }
|
||||
@@ -95,7 +121,7 @@ class TrainRecord(jsonData: JSONObject? = null) {
|
||||
}
|
||||
|
||||
|
||||
_coordinates = LocationUtils.parsePositionInfo(positionInfo)
|
||||
_coordinates = LocationUtil.parsePositionInfo(positionInfo)
|
||||
return _coordinates
|
||||
}
|
||||
private fun isValidValue(value: String): Boolean {
|
||||
@@ -122,7 +148,7 @@ class TrainRecord(jsonData: JSONObject? = null) {
|
||||
lbjClass.trim()
|
||||
} else if (isValidValue(train)) {
|
||||
train.trim()
|
||||
} else ""
|
||||
} else null
|
||||
|
||||
val map = mutableMapOf<String, String>()
|
||||
|
||||
@@ -131,14 +157,17 @@ class TrainRecord(jsonData: JSONObject? = null) {
|
||||
map["receivedTimestamp"] = dateFormat.format(receivedTimestamp)
|
||||
|
||||
|
||||
if (trainDisplay.isNotEmpty()) map["train"] = trainDisplay
|
||||
trainDisplay?.takeIf { it.isNotEmpty() }?.let { map["train"] = it }
|
||||
|
||||
if (directionText != "未知") map["direction"] = directionText
|
||||
if (isValidValue(speed)) map["speed"] = "速度: ${speed.trim()} km/h"
|
||||
if (isValidValue(position)) map["position"] = "位置: ${position.trim()} km"
|
||||
if (isValidValue(speed)) map["speed"] = "${speed.trim()} km/h"
|
||||
if (isValidValue(position)) {
|
||||
map["position"] = "${position.trim().removeSuffix(".")} K"
|
||||
}
|
||||
val timeToDisplay = if (showDetailedTime) {
|
||||
val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault())
|
||||
if (isValidValue(time)) {
|
||||
"列车时间: $time\n接收时间: ${dateFormat.format(receivedTimestamp)}"
|
||||
"$time\n${dateFormat.format(receivedTimestamp)}"
|
||||
} else {
|
||||
dateFormat.format(receivedTimestamp)
|
||||
}
|
||||
@@ -152,13 +181,13 @@ class TrainRecord(jsonData: JSONObject? = null) {
|
||||
}
|
||||
}
|
||||
map["time"] = timeToDisplay
|
||||
if (isValidValue(loco)) map["loco"] = "机车号: ${loco.trim()}"
|
||||
if (isValidValue(locoType)) map["loco_type"] = "型号: ${locoType.trim()}"
|
||||
if (isValidValue(route)) map["route"] = "线路: ${route.trim()}"
|
||||
if (isValidValue(loco)) map["loco"] = "${loco.trim()}"
|
||||
if (isValidValue(locoType)) map["loco_type"] = "${locoType.trim()}"
|
||||
if (isValidValue(route)) map["route"] = "${route.trim()}"
|
||||
if (isValidValue(positionInfo) && !positionInfo.trim().matches(Regex(".*(<NUL>|\\s)*.*"))) {
|
||||
map["position_info"] = "位置信息: ${positionInfo.trim()}"
|
||||
map["position_info"] = "${positionInfo.trim()}"
|
||||
}
|
||||
if (rssi != 0.0) map["rssi"] = "信号强度: $rssi dBm"
|
||||
if (rssi != 0.0) map["rssi"] = "$rssi dBm"
|
||||
|
||||
return map
|
||||
}
|
||||
@@ -166,6 +195,7 @@ class TrainRecord(jsonData: JSONObject? = null) {
|
||||
|
||||
fun toJSON(): JSONObject {
|
||||
val json = JSONObject()
|
||||
json.put("uniqueId", uniqueId)
|
||||
json.put("timestamp", timestamp.time)
|
||||
json.put("receivedTimestamp", receivedTimestamp.time)
|
||||
json.put("train", train)
|
||||
@@ -181,4 +211,14 @@ class TrainRecord(jsonData: JSONObject? = null) {
|
||||
json.put("rssi", rssi)
|
||||
return json
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is TrainRecord) return false
|
||||
return uniqueId == other.uniqueId
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return uniqueId.hashCode()
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,11 @@ import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.*
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.noxylva.lbjconsole.database.TrainDatabase
|
||||
import org.noxylva.lbjconsole.database.TrainRecordEntity
|
||||
import java.io.File
|
||||
import java.io.FileWriter
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -19,15 +22,50 @@ class TrainRecordManager(private val context: Context) {
|
||||
const val MAX_RECORDS = 1000
|
||||
private const val PREFS_NAME = "train_records"
|
||||
private const val KEY_RECORDS = "records"
|
||||
private const val KEY_MERGE_SETTINGS = "merge_settings"
|
||||
}
|
||||
|
||||
|
||||
private val trainRecords = CopyOnWriteArrayList<TrainRecord>()
|
||||
private val recordCount = AtomicInteger(0)
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val database = TrainDatabase.getDatabase(context)
|
||||
private val trainRecordDao = database.trainRecordDao()
|
||||
private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
var mergeSettings = MergeSettings()
|
||||
private set
|
||||
|
||||
init {
|
||||
loadRecords()
|
||||
ioScope.launch {
|
||||
migrateFromSharedPreferences()
|
||||
loadRecords()
|
||||
loadMergeSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun migrateFromSharedPreferences() {
|
||||
try {
|
||||
val jsonStr = prefs.getString(KEY_RECORDS, null)
|
||||
if (jsonStr != null && jsonStr != "[]") {
|
||||
val jsonArray = JSONArray(jsonStr)
|
||||
val records = mutableListOf<TrainRecordEntity>()
|
||||
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
val jsonObject = jsonArray.getJSONObject(i)
|
||||
val trainRecord = TrainRecord(jsonObject)
|
||||
records.add(TrainRecordEntity.fromTrainRecord(trainRecord))
|
||||
}
|
||||
|
||||
if (records.isNotEmpty()) {
|
||||
trainRecordDao.insertRecords(records)
|
||||
prefs.edit().remove(KEY_RECORDS).apply()
|
||||
Log.d(TAG, "Migrated ${records.size} records from SharedPreferences to Room database")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to migrate records: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,11 +81,16 @@ class TrainRecordManager(private val context: Context) {
|
||||
|
||||
|
||||
while (trainRecords.size > MAX_RECORDS) {
|
||||
trainRecords.removeAt(trainRecords.size - 1)
|
||||
val removedRecord = trainRecords.removeAt(trainRecords.size - 1)
|
||||
ioScope.launch {
|
||||
trainRecordDao.deleteRecordById(removedRecord.uniqueId)
|
||||
}
|
||||
}
|
||||
|
||||
recordCount.incrementAndGet()
|
||||
saveRecords()
|
||||
ioScope.launch {
|
||||
trainRecordDao.insertRecord(TrainRecordEntity.fromTrainRecord(record))
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
@@ -67,6 +110,16 @@ class TrainRecordManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getFilteredRecordsFromDatabase(): List<TrainRecord> {
|
||||
return try {
|
||||
val entities = trainRecordDao.getFilteredRecords(filterTrain, filterRoute, filterDirection)
|
||||
entities.map { it.toTrainRecord() }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to get filtered records from database: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun matchFilter(record: TrainRecord): Boolean {
|
||||
|
||||
@@ -109,63 +162,84 @@ class TrainRecordManager(private val context: Context) {
|
||||
}
|
||||
|
||||
|
||||
suspend fun refreshRecordsFromDatabase() {
|
||||
try {
|
||||
val entities = trainRecordDao.getAllRecords()
|
||||
trainRecords.clear()
|
||||
entities.forEach { entity ->
|
||||
trainRecords.add(entity.toTrainRecord())
|
||||
}
|
||||
recordCount.set(trainRecords.size)
|
||||
Log.d(TAG, "Refreshed ${trainRecords.size} records from database")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to refresh records from database: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun clearRecords() {
|
||||
trainRecords.clear()
|
||||
recordCount.set(0)
|
||||
saveRecords()
|
||||
ioScope.launch {
|
||||
trainRecordDao.deleteAllRecords()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteRecord(record: TrainRecord): Boolean {
|
||||
val result = trainRecords.remove(record)
|
||||
if (result) {
|
||||
recordCount.decrementAndGet()
|
||||
saveRecords()
|
||||
ioScope.launch {
|
||||
trainRecordDao.deleteRecordById(record.uniqueId)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun deleteRecords(records: List<TrainRecord>): Int {
|
||||
var deletedCount = 0
|
||||
val idsToDelete = mutableListOf<String>()
|
||||
|
||||
records.forEach { record ->
|
||||
if (trainRecords.remove(record)) {
|
||||
deletedCount++
|
||||
idsToDelete.add(record.uniqueId)
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount > 0) {
|
||||
recordCount.addAndGet(-deletedCount)
|
||||
saveRecords()
|
||||
ioScope.launch {
|
||||
trainRecordDao.deleteRecordsByIds(idsToDelete)
|
||||
}
|
||||
}
|
||||
return deletedCount
|
||||
}
|
||||
|
||||
private fun saveRecords() {
|
||||
try {
|
||||
val jsonArray = JSONArray()
|
||||
for (record in trainRecords) {
|
||||
jsonArray.put(record.toJSON())
|
||||
ioScope.launch {
|
||||
try {
|
||||
val entities = trainRecords.map { TrainRecordEntity.fromTrainRecord(it) }
|
||||
trainRecordDao.insertRecords(entities)
|
||||
Log.d(TAG, "Saved ${trainRecords.size} records to database")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save records: ${e.message}")
|
||||
}
|
||||
prefs.edit().putString(KEY_RECORDS, jsonArray.toString()).apply()
|
||||
Log.d(TAG, "Saved ${trainRecords.size} records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save records: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun loadRecords() {
|
||||
private suspend fun loadRecords() {
|
||||
try {
|
||||
val jsonStr = prefs.getString(KEY_RECORDS, "[]")
|
||||
val jsonArray = JSONArray(jsonStr)
|
||||
val entities = trainRecordDao.getAllRecords()
|
||||
trainRecords.clear()
|
||||
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
val jsonObject = jsonArray.getJSONObject(i)
|
||||
trainRecords.add(TrainRecord(jsonObject))
|
||||
entities.forEach { entity ->
|
||||
trainRecords.add(entity.toTrainRecord())
|
||||
}
|
||||
|
||||
recordCount.set(trainRecords.size)
|
||||
Log.d(TAG, "Loaded ${trainRecords.size} records")
|
||||
Log.d(TAG, "Loaded ${trainRecords.size} records from database")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load records: ${e.message}")
|
||||
}
|
||||
@@ -177,4 +251,213 @@ class TrainRecordManager(private val context: Context) {
|
||||
fun getRecordCount(): Int {
|
||||
return recordCount.get()
|
||||
}
|
||||
|
||||
fun updateMergeSettings(newSettings: MergeSettings) {
|
||||
mergeSettings = newSettings
|
||||
saveMergeSettings()
|
||||
}
|
||||
|
||||
|
||||
fun getMergedRecords(): List<MergedTrainRecord> {
|
||||
if (!mergeSettings.enabled) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val records = getFilteredRecords()
|
||||
return processRecordsForMerging(records, mergeSettings)
|
||||
}
|
||||
|
||||
fun getMixedRecords(): List<Any> {
|
||||
if (!mergeSettings.enabled) {
|
||||
return getFilteredRecords()
|
||||
}
|
||||
|
||||
val allRecords = getFilteredRecords()
|
||||
val mergedRecords = processRecordsForMerging(allRecords, mergeSettings)
|
||||
|
||||
val mergedRecordIds = mergedRecords.flatMap { merged ->
|
||||
merged.records.map { it.uniqueId }
|
||||
}.toSet()
|
||||
|
||||
val singleRecords = allRecords.filter { record ->
|
||||
!mergedRecordIds.contains(record.uniqueId)
|
||||
}
|
||||
|
||||
val mixedList = mutableListOf<Any>()
|
||||
mixedList.addAll(mergedRecords)
|
||||
mixedList.addAll(singleRecords)
|
||||
|
||||
return mixedList.sortedByDescending { item ->
|
||||
when (item) {
|
||||
is MergedTrainRecord -> item.latestRecord.timestamp
|
||||
is TrainRecord -> item.timestamp
|
||||
else -> Date(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processRecordsForMerging(records: List<TrainRecord>, settings: MergeSettings): List<MergedTrainRecord> {
|
||||
val validRecords = settings.timeWindow.seconds?.let { windowSeconds ->
|
||||
val currentTime = Date()
|
||||
records.filter { record ->
|
||||
(currentTime.time - record.timestamp.time) / 1000 <= windowSeconds
|
||||
}
|
||||
} ?: records
|
||||
|
||||
return when (settings.groupBy) {
|
||||
GroupBy.TRAIN_OR_LOCO -> processTrainOrLocoMerging(validRecords)
|
||||
else -> {
|
||||
val groupedRecords = mutableMapOf<String, MutableList<TrainRecord>>()
|
||||
validRecords.forEach { record ->
|
||||
val groupKey = generateGroupKey(record, settings.groupBy)
|
||||
if (groupKey != null) {
|
||||
groupedRecords.getOrPut(groupKey) { mutableListOf() }.add(record)
|
||||
}
|
||||
}
|
||||
|
||||
groupedRecords.mapNotNull { (groupKey, groupRecords) ->
|
||||
if (groupRecords.size >= 2) {
|
||||
val latestRecord = if (groupRecords.size > 1) {
|
||||
groupRecords.maxByOrNull { it.timestamp } ?: groupRecords.last()
|
||||
} else {
|
||||
groupRecords.last()
|
||||
}
|
||||
MergedTrainRecord(
|
||||
groupKey = groupKey,
|
||||
records = groupRecords.toList(),
|
||||
latestRecord = latestRecord
|
||||
)
|
||||
} else null
|
||||
}.sortedByDescending { it.latestRecord.timestamp }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processTrainOrLocoMerging(records: List<TrainRecord>): List<MergedTrainRecord> {
|
||||
val trainGroups = mutableMapOf<String, MutableList<TrainRecord>>()
|
||||
val locoGroups = mutableMapOf<String, MutableList<TrainRecord>>()
|
||||
val mergedGroups = mutableSetOf<MutableList<TrainRecord>>()
|
||||
|
||||
records.forEach { record ->
|
||||
val train = record.train.trim()
|
||||
val loco = record.loco.trim()
|
||||
|
||||
if ((train.isEmpty() || train == "<NUL>") && (loco.isEmpty() || loco == "<NUL>")) {
|
||||
return@forEach
|
||||
}
|
||||
|
||||
var targetGroup: MutableList<TrainRecord>? = null
|
||||
|
||||
if (train.isNotEmpty() && train != "<NUL>") {
|
||||
targetGroup = trainGroups[train]
|
||||
}
|
||||
|
||||
if (targetGroup == null && loco.isNotEmpty() && loco != "<NUL>") {
|
||||
targetGroup = locoGroups[loco]
|
||||
}
|
||||
|
||||
if (targetGroup != null) {
|
||||
targetGroup.add(record)
|
||||
if (train.isNotEmpty() && train != "<NUL>") {
|
||||
trainGroups[train] = targetGroup
|
||||
}
|
||||
if (loco.isNotEmpty() && loco != "<NUL>") {
|
||||
locoGroups[loco] = targetGroup
|
||||
}
|
||||
} else {
|
||||
val newGroup = mutableListOf(record)
|
||||
mergedGroups.add(newGroup)
|
||||
|
||||
if (train.isNotEmpty() && train != "<NUL>") {
|
||||
trainGroups[train] = newGroup
|
||||
}
|
||||
if (loco.isNotEmpty() && loco != "<NUL>") {
|
||||
locoGroups[loco] = newGroup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mergedGroups.mapNotNull { groupRecords ->
|
||||
if (groupRecords.size >= 2) {
|
||||
val latestRecord = groupRecords.maxByOrNull { it.timestamp } ?: groupRecords.lastOrNull() ?: return@mapNotNull null
|
||||
val groupKey = "${latestRecord.train}_OR_${latestRecord.loco}"
|
||||
MergedTrainRecord(
|
||||
groupKey = groupKey,
|
||||
records = groupRecords.toList(),
|
||||
latestRecord = latestRecord
|
||||
)
|
||||
} else null
|
||||
}.sortedByDescending { it.latestRecord.timestamp }
|
||||
}
|
||||
|
||||
private fun saveMergeSettings() {
|
||||
ioScope.launch {
|
||||
try {
|
||||
val json = JSONObject().apply {
|
||||
put("enabled", mergeSettings.enabled)
|
||||
put("groupBy", mergeSettings.groupBy.name)
|
||||
put("timeWindow", mergeSettings.timeWindow.name)
|
||||
}
|
||||
prefs.edit().putString(KEY_MERGE_SETTINGS, json.toString()).apply()
|
||||
Log.d(TAG, "Saved merge settings")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save merge settings: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadMergeSettings() {
|
||||
try {
|
||||
val jsonStr = prefs.getString(KEY_MERGE_SETTINGS, null)
|
||||
if (jsonStr != null) {
|
||||
val json = JSONObject(jsonStr)
|
||||
mergeSettings = MergeSettings(
|
||||
enabled = json.getBoolean("enabled"),
|
||||
groupBy = GroupBy.valueOf(json.getString("groupBy")),
|
||||
timeWindow = TimeWindow.valueOf(json.getString("timeWindow"))
|
||||
)
|
||||
}
|
||||
Log.d(TAG, "Loaded merge settings: $mergeSettings")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load merge settings: ${e.message}")
|
||||
mergeSettings = MergeSettings()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exportRecordsToJson(): JSONArray {
|
||||
val jsonArray = JSONArray()
|
||||
try {
|
||||
val entities = trainRecordDao.getAllRecords()
|
||||
entities.forEach { entity ->
|
||||
val record = entity.toTrainRecord()
|
||||
jsonArray.put(record.toJSON())
|
||||
}
|
||||
Log.d(TAG, "Exported ${entities.size} records to JSON")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to export records to JSON: ${e.message}")
|
||||
}
|
||||
return jsonArray
|
||||
}
|
||||
|
||||
suspend fun importRecordsFromJson(jsonArray: JSONArray): Int {
|
||||
var importedCount = 0
|
||||
try {
|
||||
val records = mutableListOf<TrainRecordEntity>()
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
val jsonObject = jsonArray.getJSONObject(i)
|
||||
val trainRecord = TrainRecord(jsonObject)
|
||||
records.add(TrainRecordEntity.fromTrainRecord(trainRecord))
|
||||
}
|
||||
|
||||
if (records.isNotEmpty()) {
|
||||
trainRecordDao.insertRecords(records)
|
||||
importedCount = records.size
|
||||
refreshRecordsFromDatabase()
|
||||
Log.d(TAG, "Imported $importedCount records from JSON")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to import records from JSON: ${e.message}")
|
||||
}
|
||||
return importedCount
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
package org.noxylva.lbjconsole.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
import org.noxylva.lbjconsole.model.TrainRecord
|
||||
|
||||
@Composable
|
||||
fun TrainDetailDialog(
|
||||
trainRecord: TrainRecord,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val recordMap = trainRecord.toMap()
|
||||
val coordinates = remember { trainRecord.getCoordinates() }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = true
|
||||
)
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
|
||||
Text(
|
||||
text = "列车详情",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
DetailItem("列车号", recordMap["train"] ?: "--")
|
||||
DetailItem("方向", recordMap["direction"] ?: "未知")
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
|
||||
DetailItem("接收时间", recordMap["timestamp"] ?: "--")
|
||||
DetailItem("列车时间", recordMap["time"] ?: "--")
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
|
||||
DetailItem("速度", recordMap["speed"] ?: "--")
|
||||
DetailItem("位置", recordMap["position"] ?: "--")
|
||||
DetailItem("位置信息", recordMap["position_info"] ?: "--")
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
|
||||
DetailItem("机车号", recordMap["loco"] ?: "--")
|
||||
DetailItem("机车类型", recordMap["loco_type"] ?: "--")
|
||||
DetailItem("列车类型", recordMap["lbj_class"] ?: "--")
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
|
||||
DetailItem("路线", recordMap["route"] ?: "--")
|
||||
DetailItem("信号强度", recordMap["rssi"] ?: "--")
|
||||
|
||||
if (coordinates != null) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
DetailItem(
|
||||
label = "经纬度",
|
||||
value = "纬度: ${coordinates.latitude}, 经度: ${coordinates.longitude}"
|
||||
)
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.padding(vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
MapView(context).apply {
|
||||
setTileSource(TileSourceFactory.MAPNIK)
|
||||
setMultiTouchControls(true)
|
||||
controller.setZoom(15.0)
|
||||
controller.setCenter(coordinates)
|
||||
|
||||
|
||||
val marker = Marker(this)
|
||||
marker.position = coordinates
|
||||
marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
marker.title = recordMap["train"] ?: "列车"
|
||||
overlays.add(marker)
|
||||
}
|
||||
},
|
||||
update = { mapView ->
|
||||
mapView.controller.setCenter(coordinates)
|
||||
mapView.invalidate()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
) {
|
||||
Text("关闭")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailItem(
|
||||
label: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
package org.noxylva.lbjconsole.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import org.noxylva.lbjconsole.model.TrainRecord
|
||||
|
||||
@Composable
|
||||
fun TrainInfoCard(
|
||||
trainRecord: TrainRecord,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val recordMap = trainRecord.toMap()
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp, horizontal = 6.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = recordMap["train"]?.toString() ?: "",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
val directionStr = recordMap["direction"]?.toString() ?: ""
|
||||
val directionColor = when(directionStr) {
|
||||
"上行" -> MaterialTheme.colorScheme.primary
|
||||
"下行" -> MaterialTheme.colorScheme.secondary
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = directionColor.copy(alpha = 0.1f),
|
||||
modifier = Modifier.padding(horizontal = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = directionStr,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
fontSize = 12.sp,
|
||||
color = directionColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = run {
|
||||
val trainTime = trainRecord.time.trim()
|
||||
if (trainTime.isNotEmpty() && trainTime != "NUL" && trainTime != "<NUL>" && trainTime != "NA" && trainTime != "<NA>") {
|
||||
trainTime
|
||||
} else {
|
||||
val receivedTime = recordMap["receivedTimestamp"]?.toString() ?: ""
|
||||
if (receivedTime.contains(" ")) {
|
||||
receivedTime.split(" ")[1]
|
||||
} else {
|
||||
java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault()).format(trainRecord.receivedTimestamp)
|
||||
}
|
||||
}
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "速度: ${recordMap["speed"] ?: ""}",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "位置: ${recordMap["position"] ?: ""}",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
HorizontalDivider(thickness = 0.5.dp)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
CompactInfoItem(label = "机车号", value = recordMap["loco"]?.toString() ?: "")
|
||||
CompactInfoItem(label = "线路", value = recordMap["route"]?.toString() ?: "")
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
CompactInfoItem(label = "类型", value = recordMap["lbj_class"]?.toString() ?: "")
|
||||
CompactInfoItem(label = "信号", value = recordMap["rssi"]?.toString() ?: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactInfoItem(
|
||||
label: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "$label: ",
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Text(
|
||||
text = value,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
package org.noxylva.lbjconsole.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.noxylva.lbjconsole.model.TrainRecord
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@Composable
|
||||
fun TrainRecordsList(
|
||||
records: List<TrainRecord>,
|
||||
onRecordClick: (TrainRecord) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (records.isEmpty()) {
|
||||
Text(
|
||||
text = "暂无历史记录",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 4.dp, horizontal = 8.dp)
|
||||
) {
|
||||
items(records) { record ->
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp)
|
||||
.clickable { onRecordClick(record) },
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = record.train,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 15.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
|
||||
val directionText = when (record.direction) {
|
||||
1 -> "下行"
|
||||
3 -> "上行"
|
||||
else -> "未知"
|
||||
}
|
||||
|
||||
val directionColor = when(record.direction) {
|
||||
1 -> MaterialTheme.colorScheme.secondary
|
||||
3 -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = directionColor.copy(alpha = 0.1f),
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Text(
|
||||
text = directionText,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
|
||||
fontSize = 11.sp,
|
||||
color = directionColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
|
||||
Text(
|
||||
text = "位置: ${record.position} km",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
Text(
|
||||
text = "${record.speed} km/h",
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
|
||||
val timeStr = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(record.timestamp)
|
||||
Text(
|
||||
text = timeStr,
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TrainRecordsListWithToolbar(
|
||||
records: List<TrainRecord>,
|
||||
onRecordClick: (TrainRecord) -> Unit,
|
||||
onFilterClick: () -> Unit,
|
||||
onClearClick: () -> Unit,
|
||||
onDeleteRecords: (List<TrainRecord>) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var selectedRecords by remember { mutableStateOf<MutableSet<TrainRecord>>(mutableSetOf()) }
|
||||
var selectionMode by remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 3.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = if (selectionMode) "已选择 ${selectedRecords.size} 条" else "历史记录 (${records.size})",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (selectionMode) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (selectedRecords.isNotEmpty()) {
|
||||
onDeleteRecords(selectedRecords.toList())
|
||||
}
|
||||
selectionMode = false
|
||||
selectedRecords = mutableSetOf()
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("删除")
|
||||
}
|
||||
TextButton(onClick = {
|
||||
selectionMode = false
|
||||
selectedRecords = mutableSetOf()
|
||||
}) {
|
||||
Text("取消")
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = onFilterClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.FilterList,
|
||||
contentDescription = "筛选"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(vertical = 4.dp, horizontal = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
items(records.chunked(2)) { rowRecords ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
rowRecords.forEach { record ->
|
||||
val isSelected = selectedRecords.contains(record)
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable {
|
||||
if (selectionMode) {
|
||||
if (isSelected) {
|
||||
selectedRecords.remove(record)
|
||||
} else {
|
||||
selectedRecords.add(record)
|
||||
}
|
||||
if (selectedRecords.isEmpty()) {
|
||||
selectionMode = false
|
||||
}
|
||||
} else {
|
||||
onRecordClick(record)
|
||||
}
|
||||
}
|
||||
.padding(vertical = 2.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (selectionMode) {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { checked ->
|
||||
if (checked) {
|
||||
selectedRecords.add(record)
|
||||
} else {
|
||||
selectedRecords.remove(record)
|
||||
}
|
||||
if (selectedRecords.isEmpty()) {
|
||||
selectionMode = false
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = record.train,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 15.sp,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
if (!selectionMode) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
selectionMode = true
|
||||
selectedRecords = mutableSetOf(record)
|
||||
},
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Clear,
|
||||
contentDescription = "删除",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (record.speed.isNotEmpty() || record.position.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
if (record.speed.isNotEmpty()) {
|
||||
Text(
|
||||
text = "${record.speed} km/h",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (record.position.isNotEmpty()) {
|
||||
Text(
|
||||
text = "${record.position} km",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val timeStr = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(record.timestamp)
|
||||
Text(
|
||||
text = timeStr,
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,12 @@ import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.MyLocation
|
||||
@@ -16,11 +21,16 @@ import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -99,6 +109,48 @@ fun MapScreen(
|
||||
|
||||
var railwayLayerVisibleState by remember(railwayLayerVisible) { mutableStateOf(railwayLayerVisible) }
|
||||
|
||||
fun updateMarkers() {
|
||||
val mapView = mapViewRef.value ?: return
|
||||
|
||||
mapView.overlays.removeAll { it is Marker }
|
||||
|
||||
validRecords.forEach { record ->
|
||||
record.getCoordinates()?.let { point ->
|
||||
val marker = Marker(mapView).apply {
|
||||
position = point
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
|
||||
val recordMap = record.toMap()
|
||||
title = recordMap["train"]?.toString() ?: "列车"
|
||||
val latStr = String.format("%.4f", point.latitude)
|
||||
val lonStr = String.format("%.4f", point.longitude)
|
||||
val coordStr = "${latStr}°N, ${lonStr}°E"
|
||||
snippet = coordStr
|
||||
|
||||
setInfoWindowAnchor(Marker.ANCHOR_CENTER, 0f)
|
||||
|
||||
setOnMarkerClickListener { clickedMarker, _ ->
|
||||
selectedRecord = record
|
||||
dialogPosition = point
|
||||
showDetailDialog = true
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
mapView.overlays.add(marker)
|
||||
marker.showInfoWindow()
|
||||
}
|
||||
}
|
||||
|
||||
mapView.invalidate()
|
||||
}
|
||||
|
||||
LaunchedEffect(records) {
|
||||
if (isMapInitialized) {
|
||||
updateMarkers()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
@@ -136,49 +188,6 @@ fun MapScreen(
|
||||
}
|
||||
|
||||
|
||||
fun updateMarkers() {
|
||||
val mapView = mapViewRef.value ?: return
|
||||
|
||||
|
||||
mapView.overlays.removeAll { it is Marker }
|
||||
|
||||
|
||||
validRecords.forEach { record ->
|
||||
record.getCoordinates()?.let { point ->
|
||||
val marker = Marker(mapView).apply {
|
||||
position = point
|
||||
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
|
||||
val recordMap = record.toMap()
|
||||
title = recordMap["train"]?.toString() ?: "列车"
|
||||
|
||||
val latStr = String.format("%.4f", point.latitude)
|
||||
val lonStr = String.format("%.4f", point.longitude)
|
||||
val coordStr = "${latStr}°N, ${lonStr}°E"
|
||||
snippet = coordStr
|
||||
|
||||
setInfoWindowAnchor(Marker.ANCHOR_CENTER, 0f)
|
||||
|
||||
setOnMarkerClickListener { clickedMarker, _ ->
|
||||
selectedRecord = record
|
||||
dialogPosition = point
|
||||
showDetailDialog = true
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
mapView.overlays.add(marker)
|
||||
marker.showInfoWindow()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
mapView.invalidate()
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
fun updateRailwayLayerVisibility(visible: Boolean) {
|
||||
@@ -306,7 +315,7 @@ fun MapScreen(
|
||||
|
||||
val locationProvider = GpsMyLocationProvider(ctx).apply {
|
||||
locationUpdateMinDistance = 10f
|
||||
locationUpdateMinTime = 1000
|
||||
locationUpdateMinTime = 5000
|
||||
}
|
||||
|
||||
|
||||
@@ -574,8 +583,8 @@ fun Context.getCompactMarkerDrawable(color: Int): Drawable {
|
||||
|
||||
|
||||
private fun Int.directionText(): String = when (this) {
|
||||
1 -> "↓"
|
||||
3 -> "↑"
|
||||
1 -> "下行"
|
||||
3 -> "上行"
|
||||
else -> "?"
|
||||
}
|
||||
|
||||
@@ -585,50 +594,143 @@ private fun TrainMarkerDialog(
|
||||
position: GeoPoint?,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val recordMap = record.toMap()
|
||||
|
||||
val displayItems = recordMap.filterKeys {
|
||||
it !in setOf("train", "direction", "time")
|
||||
}.toList()
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
|
||||
val recordMap = record.toMap()
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = recordMap["train"]?.toString() ?: "列车", style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
text = recordMap["train"]?.toString() ?: "列车",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
recordMap["direction"]?.let { direction ->
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = direction,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
text = (direction as? Int)?.directionText() ?: direction.toString(),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp
|
||||
),
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
displayItems.forEach { (key, value) ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
val title = when (key) {
|
||||
"speed" -> "速度"
|
||||
"position" -> "位置"
|
||||
"time" -> "时间"
|
||||
"loco" -> "机车号"
|
||||
"loco_type" -> "机车型号"
|
||||
"route" -> "线路"
|
||||
"rssi" -> "信号强度"
|
||||
"timestamp" -> "时间"
|
||||
"receivedTimestamp" -> "接收时间"
|
||||
else -> key
|
||||
}
|
||||
|
||||
record.toMap().forEach { (key, value) ->
|
||||
if (key != "train" && key != "direction") {
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(vertical = 2.dp)
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = value.toString(),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
position?.let {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "坐标: ${String.format("%.6f", it.latitude)}, ${String.format("%.6f", it.longitude)}",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "坐标信息",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = "${String.format("%.6f", it.latitude)}, ${String.format("%.6f", it.longitude)}",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("确定")
|
||||
Text("关闭")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoSection(title: String, items: List<Pair<String, String>>) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
items.forEach { (key, value) ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = value.toString(),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoSectionSimple(title: String, items: List<Pair<String, String>>) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
items.forEach { (key, value) ->
|
||||
Text(
|
||||
text = value.toString(),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
package org.noxylva.lbjconsole.ui.screens
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import org.noxylva.lbjconsole.model.TrainRecord
|
||||
import org.noxylva.lbjconsole.ui.components.TrainDetailDialog
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@Composable
|
||||
fun MonitorScreen(
|
||||
latestRecord: TrainRecord?,
|
||||
recentRecords: List<TrainRecord>,
|
||||
lastUpdateTime: Date?,
|
||||
temporaryStatusMessage: String? = null,
|
||||
onRecordClick: (TrainRecord) -> Unit,
|
||||
onClearLog: () -> Unit
|
||||
) {
|
||||
var showDetailDialog by remember { mutableStateOf(false) }
|
||||
var selectedRecord by remember { mutableStateOf<TrainRecord?>(null) }
|
||||
|
||||
|
||||
val timeSinceLastUpdate = remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(key1 = lastUpdateTime) {
|
||||
if (lastUpdateTime != null) {
|
||||
while (true) {
|
||||
val now = Date()
|
||||
val diffInSec = (now.time - lastUpdateTime.time) / 1000
|
||||
timeSinceLastUpdate.value = when {
|
||||
diffInSec < 60 -> "${diffInSec}秒前"
|
||||
diffInSec < 3600 -> "${diffInSec / 60}分钟前"
|
||||
else -> "${diffInSec / 3600}小时前"
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Card(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(20.dp)
|
||||
) {
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = timeSinceLastUpdate.value ?: "暂无数据",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) {
|
||||
if (latestRecord != null) {
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
.clickable {
|
||||
selectedRecord = latestRecord
|
||||
showDetailDialog = true
|
||||
onRecordClick(latestRecord)
|
||||
}
|
||||
) {
|
||||
|
||||
val recordMap = latestRecord.toMap()
|
||||
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = recordMap["train"]?.toString() ?: "",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Text(
|
||||
text = recordMap["direction"]?.toString() ?: "",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
color = when(recordMap["direction"]?.toString()) {
|
||||
"上行" -> MaterialTheme.colorScheme.primary
|
||||
"下行" -> MaterialTheme.colorScheme.secondary
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
|
||||
if (recordMap.containsKey("time")) {
|
||||
recordMap["time"]?.split("\n")?.forEach { timeLine ->
|
||||
Text(
|
||||
text = timeLine,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(thickness = 0.5.dp)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
recordMap["speed"]?.let { speed ->
|
||||
Text(
|
||||
text = speed,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
recordMap["position"]?.let { position ->
|
||||
Text(
|
||||
text = position,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
recordMap.forEach { (key, value) ->
|
||||
when (key) {
|
||||
"timestamp", "train", "direction", "time", "speed", "position", "position_info" -> {}
|
||||
else -> {
|
||||
Text(
|
||||
text = value,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (recordMap.containsKey("position_info")) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = recordMap["position_info"] ?: "",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
"暂无列车信息",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
|
||||
if (lastUpdateTime != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
"上次接收数据: ${SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(lastUpdateTime)}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (showDetailDialog && selectedRecord != null) {
|
||||
TrainDetailDialog(
|
||||
trainRecord = selectedRecord!!,
|
||||
onDismiss = { showDetailDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoItem(
|
||||
label: String,
|
||||
value: String,
|
||||
fontSize: TextUnit = 14.sp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "$label: ",
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = fontSize,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Text(
|
||||
text = value,
|
||||
fontSize = fontSize,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,32 @@
|
||||
package org.noxylva.lbjconsole.ui.screens
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.noxylva.lbjconsole.model.MergeSettings
|
||||
import org.noxylva.lbjconsole.model.GroupBy
|
||||
import org.noxylva.lbjconsole.model.TimeWindow
|
||||
import org.noxylva.lbjconsole.database.AppSettingsRepository
|
||||
import org.noxylva.lbjconsole.BackgroundService
|
||||
import org.noxylva.lbjconsole.NotificationService
|
||||
import org.noxylva.lbjconsole.FilePickerActivity
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -15,44 +34,472 @@ fun SettingsScreen(
|
||||
deviceName: String,
|
||||
onDeviceNameChange: (String) -> Unit,
|
||||
onApplySettings: () -> Unit,
|
||||
appVersion: String = "Unknown"
|
||||
appVersion: String = "Unknown",
|
||||
mergeSettings: MergeSettings,
|
||||
onMergeSettingsChange: (MergeSettings) -> Unit,
|
||||
scrollPosition: Int = 0,
|
||||
onScrollPositionChange: (Int) -> Unit = {},
|
||||
specifiedDeviceAddress: String? = null,
|
||||
searchOrderList: List<String> = emptyList(),
|
||||
onSpecifiedDeviceSelected: (String?) -> Unit = {},
|
||||
autoConnectEnabled: Boolean = true,
|
||||
onAutoConnectEnabledChange: (Boolean) -> Unit = {}
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val scrollState = rememberScrollState(initial = scrollPosition)
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
onScrollPositionChange(scrollState.value)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
.verticalScroll(scrollState)
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = deviceName,
|
||||
onValueChange = onDeviceNameChange,
|
||||
label = { Text("蓝牙设备名称") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onApplySettings,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text("应用设置")
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bluetooth,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
"蓝牙设备",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = deviceName,
|
||||
onValueChange = onDeviceNameChange,
|
||||
label = { Text("设备名称") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.DeviceHub,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
|
||||
if (searchOrderList.isNotEmpty()) {
|
||||
var deviceAddressExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = deviceAddressExpanded,
|
||||
onExpandedChange = { deviceAddressExpanded = !deviceAddressExpanded }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = specifiedDeviceAddress ?: "无",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("指定设备地址") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.LocationOn,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = deviceAddressExpanded)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = deviceAddressExpanded,
|
||||
onDismissRequest = { deviceAddressExpanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("无") },
|
||||
onClick = {
|
||||
onSpecifiedDeviceSelected(null)
|
||||
deviceAddressExpanded = false
|
||||
}
|
||||
)
|
||||
searchOrderList.forEach { address ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(address)
|
||||
if (address == specifiedDeviceAddress) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "已指定",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
onSpecifiedDeviceSelected(address)
|
||||
deviceAddressExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
"应用设置",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val notificationService = remember(context) { NotificationService(context) }
|
||||
|
||||
var backgroundServiceEnabled by remember { mutableStateOf<Boolean?>(null) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(context) {
|
||||
val repository = AppSettingsRepository(context)
|
||||
backgroundServiceEnabled = repository.getSettings().backgroundServiceEnabled
|
||||
}
|
||||
|
||||
var notificationEnabled by remember(context, notificationService) {
|
||||
mutableStateOf(notificationService.isNotificationEnabled())
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
"后台保活服务",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
"保持应用在后台运行",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (backgroundServiceEnabled == null) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
} else {
|
||||
Switch(
|
||||
checked = backgroundServiceEnabled!!,
|
||||
onCheckedChange = { enabled ->
|
||||
backgroundServiceEnabled = enabled
|
||||
coroutineScope.launch {
|
||||
val repository = AppSettingsRepository(context)
|
||||
repository.updateBackgroundServiceEnabled(enabled)
|
||||
if (enabled) {
|
||||
BackgroundService.startService(context)
|
||||
} else {
|
||||
BackgroundService.stopService(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
"LBJ消息通知",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
"实时接收列车LBJ消息通知",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = notificationEnabled,
|
||||
onCheckedChange = { enabled ->
|
||||
notificationEnabled = enabled
|
||||
notificationService.setNotificationEnabled(enabled)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
"自动连接",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
"自动连接蓝牙设备",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = autoConnectEnabled,
|
||||
onCheckedChange = onAutoConnectEnabledChange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MergeType,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
"记录合并",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"启用记录合并",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Switch(
|
||||
checked = mergeSettings.enabled,
|
||||
onCheckedChange = { enabled ->
|
||||
onMergeSettingsChange(mergeSettings.copy(enabled = enabled))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (mergeSettings.enabled) {
|
||||
var groupByExpanded by remember { mutableStateOf(false) }
|
||||
var timeWindowExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = groupByExpanded,
|
||||
onExpandedChange = { groupByExpanded = !groupByExpanded }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = mergeSettings.groupBy.displayName,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("分组方式") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Group,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = groupByExpanded)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = groupByExpanded,
|
||||
onDismissRequest = { groupByExpanded = false }
|
||||
) {
|
||||
GroupBy.values().forEach { groupBy ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(groupBy.displayName) },
|
||||
onClick = {
|
||||
onMergeSettingsChange(mergeSettings.copy(groupBy = groupBy))
|
||||
groupByExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = timeWindowExpanded,
|
||||
onExpandedChange = { timeWindowExpanded = !timeWindowExpanded }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = mergeSettings.timeWindow.displayName,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("时间窗口") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Schedule,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = timeWindowExpanded)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = timeWindowExpanded,
|
||||
onDismissRequest = { timeWindowExpanded = false }
|
||||
) {
|
||||
TimeWindow.values().forEach { timeWindow ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(timeWindow.displayName) },
|
||||
onClick = {
|
||||
onMergeSettingsChange(mergeSettings.copy(timeWindow = timeWindow))
|
||||
timeWindowExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Storage,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
"数据管理",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
val intent = FilePickerActivity.createExportIntent(context)
|
||||
context.startActivity(intent)
|
||||
},
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 4.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Upload, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("导出")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val intent = FilePickerActivity.createImportIntent(context)
|
||||
context.startActivity(intent)
|
||||
},
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 4.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Download, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("导入")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "LBJ Console v$appVersion by undef-i",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.clickable {
|
||||
uriHandler.openUri("https://github.com/undef-i")
|
||||
}
|
||||
)
|
||||
text = "LBJ Console v$appVersion by undef-i",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable {
|
||||
uriHandler.openUri("https://github.com/undef-i/LBJ_Console")
|
||||
}
|
||||
.padding(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ private val LightColorScheme = lightColorScheme(
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun LBJReceiverTheme(
|
||||
fun LBJConsoleTheme(
|
||||
darkTheme: Boolean = true,
|
||||
|
||||
dynamicColor: Boolean = true,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package org.noxylva.lbjconsole.util
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.noxylva.lbjconsole.database.AppSettingsEntity
|
||||
import org.noxylva.lbjconsole.database.TrainDatabase
|
||||
import org.noxylva.lbjconsole.database.TrainRecordEntity
|
||||
import java.io.*
|
||||
import java.util.*
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class DatabaseExportImportUtil(private val context: Context) {
|
||||
private val gson = Gson()
|
||||
private val database = TrainDatabase.getDatabase(context)
|
||||
|
||||
data class SimpleRecordBackup(
|
||||
val records: List<TrainRecordEntity>
|
||||
)
|
||||
|
||||
suspend fun exportDatabase(): String = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val trainRecords = database.trainRecordDao().getAllRecords()
|
||||
|
||||
val backup = SimpleRecordBackup(
|
||||
records = trainRecords
|
||||
)
|
||||
|
||||
val json = gson.toJson(backup)
|
||||
json
|
||||
} catch (e: Exception) {
|
||||
Log.e("DatabaseExport", "导出失败", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun importDatabase(uri: Uri): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
val json = inputStream.bufferedReader().use { it.readText() }
|
||||
val backup: SimpleRecordBackup = gson.fromJson(json, object : TypeToken<SimpleRecordBackup>() {}.type)
|
||||
|
||||
database.trainRecordDao().deleteAllRecords()
|
||||
|
||||
if (backup.records.isNotEmpty()) {
|
||||
database.trainRecordDao().insertRecords(backup.records)
|
||||
}
|
||||
|
||||
true
|
||||
} ?: false
|
||||
} catch (e: Exception) {
|
||||
Log.e("DatabaseImport", "导入失败", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getExportFileUri(): Uri {
|
||||
val filePath = exportDatabase()
|
||||
return Uri.parse("file://$filePath")
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import android.util.Log
|
||||
import org.osmdroid.util.GeoPoint
|
||||
|
||||
|
||||
object LocationUtils {
|
||||
private const val TAG = "LocationUtils"
|
||||
object LocationUtil {
|
||||
private const val TAG = "LocationUtil"
|
||||
|
||||
|
||||
fun parsePositionInfo(positionInfo: String): GeoPoint? {
|
||||
@@ -52,7 +52,7 @@ object LocationUtils {
|
||||
|
||||
val minuteEndIndex = dmsString.indexOf('′')
|
||||
if (minuteEndIndex == -1) {
|
||||
return degrees
|
||||
return null
|
||||
}
|
||||
|
||||
val minutes = dmsString.substring(degreeIndex + 1, minuteEndIndex).toDouble()
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.noxylva.lbjconsole.util
|
||||
|
||||
import android.content.Context
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
|
||||
class LocoTypeUtil(private val context: Context) {
|
||||
private val locoTypeMap = mutableMapOf<String, String>()
|
||||
|
||||
init {
|
||||
loadLocoTypeMapping()
|
||||
}
|
||||
|
||||
private fun loadLocoTypeMapping() {
|
||||
try {
|
||||
context.assets.open("loco_type_info.csv").use { inputStream ->
|
||||
BufferedReader(InputStreamReader(inputStream)).use { reader ->
|
||||
reader.lines().forEach { line ->
|
||||
val parts = line.split(",")
|
||||
if (parts.size >= 2) {
|
||||
val code = parts[0].trim()
|
||||
val type = parts[1].trim()
|
||||
locoTypeMap[code] = type
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
fun getLocoTypeByCode(code: String): String? {
|
||||
return locoTypeMap[code]
|
||||
}
|
||||
|
||||
fun getLocoTypeByLocoNumber(locoNumber: String): String? {
|
||||
if (locoNumber.length < 3) return null
|
||||
val prefix = locoNumber.take(3)
|
||||
return getLocoTypeByCode(prefix)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package org.noxylva.lbjconsole.util
|
||||
|
||||
import android.content.Context
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class TrainTypeUtil(private val context: Context) {
|
||||
private val trainTypePatterns = mutableListOf<Pair<Pattern, String>>()
|
||||
|
||||
init {
|
||||
loadTrainTypePatterns()
|
||||
}
|
||||
|
||||
private fun loadTrainTypePatterns() {
|
||||
try {
|
||||
val inputStream = context.assets.open("train_number_info.csv")
|
||||
val reader = BufferedReader(InputStreamReader(inputStream))
|
||||
|
||||
reader.useLines { lines ->
|
||||
lines.forEach { line ->
|
||||
if (line.isNotBlank()) {
|
||||
val firstQuoteEnd = line.indexOf('"', 1)
|
||||
if (firstQuoteEnd > 0 && firstQuoteEnd < line.length - 1) {
|
||||
val regex = line.substring(1, firstQuoteEnd)
|
||||
val remainingPart = line.substring(firstQuoteEnd + 1).trim()
|
||||
if (remainingPart.startsWith(",\"") && remainingPart.endsWith("\"")) {
|
||||
val type = remainingPart.substring(2, remainingPart.length - 1)
|
||||
try {
|
||||
val pattern = Pattern.compile(regex)
|
||||
trainTypePatterns.add(Pair(pattern, type))
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
fun getTrainType(locoType: String, train: String): String? {
|
||||
if (train.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val actualTrain = if (locoType == "NA") {
|
||||
train
|
||||
} else {
|
||||
locoType + train
|
||||
}
|
||||
|
||||
for ((pattern, type) in trainTypePatterns) {
|
||||
if (pattern.matcher(actualTrain).matches()) {
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
0
app/src/main/res/drawable/ic_launcher_background.xml
Normal file → Executable file
0
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file → Executable file
10
app/src/main/res/drawable/ic_notification.xml
Executable file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="@android:color/white">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09C6.04,10.33 6,10.66 6,11v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z"/>
|
||||
</vector>
|
||||
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,6m-4,0a4,4 0,1 1,8 0a4,4 0,1 1,-8 0" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,10L12,10c-2.2,0 -4,1.8 -4,4v6h8v-6C16,11.8 14.2,10 12,10z" />
|
||||
</vector>
|
||||
99
app/src/main/res/layout/notification_train_record.xml
Executable file
@@ -0,0 +1,99 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="4dp"
|
||||
android:background="@android:color/transparent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/notification_train_number"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="G1234"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="?android:attr/textColorPrimary"
|
||||
android:layout_marginEnd="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/notification_direction"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:text="下"
|
||||
android:textSize="10sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@android:color/white"
|
||||
android:background="@android:color/black"
|
||||
android:gravity="center"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/notification_loco_info"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="CRH380D-1234"
|
||||
android:textSize="12sp"
|
||||
android:textColor="?android:attr/textColorPrimary" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/notification_route"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="京沪高铁"
|
||||
android:textSize="14sp"
|
||||
android:textColor="?android:attr/textColorPrimary"
|
||||
android:layout_marginEnd="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/notification_position"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="1234K"
|
||||
android:textSize="14sp"
|
||||
android:textColor="?android:attr/textColorPrimary" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/notification_speed"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="300 km/h"
|
||||
android:textSize="14sp"
|
||||
android:textColor="?android:attr/textColorPrimary" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
0
app/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal file → Executable file
0
app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
Normal file → Executable file
0
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file → Executable file
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
0
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file → Executable file
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
0
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file → Executable file
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 982 B |
0
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file → Executable file
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
0
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file → Executable file
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
0
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file → Executable file
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
0
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file → Executable file
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
0
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file → Executable file
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
0
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file → Executable file
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
0
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file → Executable file
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 7.6 KiB |
@@ -1,590 +0,0 @@
|
||||
6G,51,90,西安铁路局 宝鸡电力机务段,,
|
||||
6K,1,85,中国铁路郑州局集团有限公司 洛阳机务段,,
|
||||
8G,1,1,中国铁路太原局集团有限公司 太原北机务段、侯马机务段、石家庄电力机务段,,
|
||||
8G,2,2,中国铁道博物馆,,
|
||||
8G,3,75,中国铁路太原局集团有限公司 太原北机务段、侯马机务段、石家庄电力机务段,,
|
||||
8G,76,76,中国铁路太原局集团有限公司 太原机务段北场,,
|
||||
8G,77,96,中国铁路太原局集团有限公司 太原北机务段、侯马机务段、石家庄电力机务段,,
|
||||
8G,97,97,中国铁路太原局集团有限公司 榆次机务折返段,,
|
||||
8G,98,100,中国铁路太原局集团有限公司 太原北机务段、侯马机务段、石家庄电力机务段,,
|
||||
8K,1,1,中国铁路北京局集团有限公司 丰台机务段,,
|
||||
8K,2,7,*北京铁路局 丰台西段、丰台段;太原铁路局 大同西段、湖东段,,
|
||||
8K,8,8,中国铁道博物馆,*科技号,
|
||||
8K,9,17,*北京铁路局 丰台西段、丰台段;太原铁路局 大同西段、湖东段,,
|
||||
8K,18,18,*北京铁路局 丰台机务段,,
|
||||
8K,19,23,*北京铁路局 丰台西段、丰台段;太原铁路局 大同西段、湖东段,,
|
||||
8K,24,24,中国铁路太原局集团有限公司 湖东机务段 大同西运用车间,,
|
||||
8K,25,64,*北京铁路局 丰台西段、丰台段;太原铁路局 大同西段、湖东段,,
|
||||
8K,65,65,天津铁道职业技术学院,,
|
||||
8K,66,71,*北京铁路局 丰台西段、丰台段;太原铁路局 大同西段、湖东段,,
|
||||
8K,72,72,中国铁路北京局集团有限公司 丰台机务段,,
|
||||
8K,73,90,*北京铁路局 丰台西段、丰台段;太原铁路局 大同西段、湖东段,,
|
||||
8K,91,91,中国铁路太原局集团有限公司 太原机务段北场 机车展场,,
|
||||
8K,92,100,*北京铁路局 丰台西段、丰台段;太原铁路局 大同西段、湖东段,,
|
||||
CR400AF,21,21,中国铁路北京局集团有限公司 朝阳动车运用所,CR400AF-G,
|
||||
CR400AF,207,208,中国铁路北京局集团有限公司 北京西动车运用所,,
|
||||
CR400AF,1001,1002,中国铁路广州局集团有限公司 深圳动车运用所,CR400AF-A,
|
||||
CR400AF,1003,1003,中国铁路广州局集团有限公司 广州南动车运用所,CR400AF-A,
|
||||
CR400AF,1004,1004,中国铁路广州局集团有限公司 深圳动车运用所,CR400AF-A,
|
||||
CR400AF,1005,1005,中国铁路广州局集团有限公司 广州南动车运用所,CR400AF-A,
|
||||
CR400AF,1006,1006,中国铁路广州局集团有限公司 广州南动车运用所,,
|
||||
CR400AF,1007,1009,中国铁路广州局集团有限公司 潮州动车运用所,,
|
||||
CR400AF,1010,1010,中国铁路广州局集团有限公司 广州南动车运用所,,
|
||||
CR400AF,1011,1014,中国铁路广州局集团有限公司 潮州动车运用所,,
|
||||
CR400AF,1015,1020,中国铁路广州局集团有限公司 广州南动车运用所,,
|
||||
CR400AF,1021,1021,中国铁路广州局集团有限公司 潮州动车运用所,,
|
||||
CR400AF,1022,1025,中国铁路广州局集团有限公司 广州南动车运用所,,
|
||||
CR400AF,1026,1027,中国铁路广州局集团有限公司 广州南动车运用所,CR400AF-A,
|
||||
CR400AF,1028,1029,中国铁路广州局集团有限公司 深圳动车运用所,CR400AF-A,
|
||||
CR400AF,1030,1030,中国铁路广州局集团有限公司 广州南动车运用所,CR400AF-A,
|
||||
CR400AF,1031,1031,中国铁路广州局集团有限公司 深圳动车运用所,CR400AF-A,
|
||||
CR400AF,1032,1032,中国铁路广州局集团有限公司 广州南动车运用所,CR400AF-A,
|
||||
CR400AF,1033,1033,中国铁路广州局集团有限公司 深圳动车运用所,CR400AF-A,
|
||||
CR400AF,1034,1038,中国铁路广州局集团有限公司 广州南动车运用所,CR400AF-A,
|
||||
CR400AF,1039,1039,中国铁路广州局集团有限公司 潮州动车运用所,,
|
||||
CR400AF,1040,1040,中国铁路广州局集团有限公司 广州南动车运用所,,
|
||||
CR400AF,2002,2002,中国铁路北京局集团有限公司 北京西动车运用所,,
|
||||
CR400AF,2004,2004,中国铁路北京局集团有限公司 北京西动车运用所,,
|
||||
CR400AF,2005,2005,中国铁路成都局集团有限公司 重庆西动车运用所,,
|
||||
CR400AF,2006,2007,中国铁路北京局集团有限公司 北京西动车运用所,,
|
||||
CR400AF,2008,2008,中国铁路北京局集团有限公司 雄安动车运用所,,
|
||||
CR400AF,2009,2010,中国铁路北京局集团有限公司 北京西动车运用所,,
|
||||
CR400AF,2011,2011,中国铁路广州局集团有限公司 长沙动车运用所,,
|
||||
CR400AF,2012,2012,中国铁路北京局集团有限公司 北京西动车运用所,,
|
||||
CR400AF,2013,2013,中国铁路成都局集团有限公司 重庆西动车运用所,,
|
||||
CR400AF,2014,2016,中国铁路北京局集团有限公司 北京西动车运用所,,
|
||||
CR400AF,2017,2017,中国铁路广州局集团有限公司 长沙动车运用所,,
|
||||
CR400AF,2023,2023,中国铁路北京局集团有限公司 北京西动车运用所,,
|
||||
CR400AF,2024,2024,中国铁路广州局集团有限公司 长沙动车运用所,,
|
||||
CR400AF,2025,2025,中国铁路成都局集团有限公司 重庆西动车运用所,,
|
||||
CR400AF,2026,2028,中国铁路广州局集团有限公司 长沙动车运用所,,
|
||||
CR400AF,2030,2030,中国铁路北京局集团有限公司 雄安动车运用所,,
|
||||
CR400AF,2031,2032,中国铁路成都局集团有限公司 重庆西动车运用所,,
|
||||
CR400AF,2033,2033,中国铁路北京局集团有限公司 雄安动车运用所,,
|
||||
CR400AF,2034,2034,中国铁路北京局集团有限公司 北京西动车运用所,,
|
||||
CR400AF,2035,2038,中国铁路广州局集团有限公司 长沙动车运用所,,
|
||||
CR400AF,2040,2046,中国铁路广州局集团有限公司 长沙动车运用所,,
|
||||
CR400AF,2047,2048,中国铁路北京局集团有限公司 北京西动车运用所,,
|
||||
CR400AF,2049,2049,中国铁路成都局集团有限公司 重庆西动车运用所,,
|
||||
CR400AF,2051,2051,中国铁路广州局集团有限公司 长沙动车运用所,,
|
||||
CR400AF,2053,2055,中国铁路广州局集团有限公司 长沙动车运用所,,
|
||||
CR400AF,2057,2057,中国铁路广州局集团有限公司 长沙动车运用所,,
|
||||
CR400AF,2058,2058,中国铁路北京局集团有限公司 雄安动车运用所,,
|
||||
CR400AF,2060,2062,中国铁路广州局集团有限公司 长沙动车运用所,,
|
||||
CR400AF,2064,2064,中国铁路广州局集团有限公司 长沙动车运用所,,
|
||||
CR400AF,2065,2066,中国铁路广州局集团有限公司 深圳动车运用所,CR400AF-A,
|
||||
CR400AF,2067,2068,中国铁路广州局集团有限公司 长沙动车运用所,CR400AF-A,
|
||||
CR400AF,2069,2069,中国铁路广州局集团有限公司 深圳动车运用所,CR400AF-A,
|
||||
CR400AF,2070,2070,中国铁路广州局集团有限公司 长沙动车运用所,CR400AF-A,
|
||||
CR400AF,2071,2071,中国铁路广州局集团有限公司 深圳动车运用所,CR400AF-A,
|
||||
CR400AF,2072,2072,中国铁路广州局集团有限公司 长沙动车运用所,CR400AF-A,
|
||||
CR400AF,2073,2073,中国铁路广州局集团有限公司 深圳动车运用所,CR400AF-A,
|
||||
CR400AF,2074,2076,中国铁路广州局集团有限公司 长沙动车运用所,CR400AF-A,
|
||||
CR400AF,2077,2079,中国铁路广州局集团有限公司 深圳动车运用所,CR400AF-A,
|
||||
CR400AF,2080,2084,中国铁路广州局集团有限公司 长沙动车运用所,CR400AF-A,
|
||||
CR400AF,2085,2085,中国铁路济南局集团有限公司 济南东动车运用所,,
|
||||
CR400AF,2086,2086,中国铁路济南局集团有限公司 青岛动车运用所,,
|
||||
CR400AF,2087,2087,中国铁路济南局集团有限公司 济南东动车运用所,,
|
||||
CR400AF,2088,2090,中国铁路济南局集团有限公司 青岛动车运用所,,
|
||||
CR400AF,2091,2094,中国铁路济南局集团有限公司 济南东动车运用所,,
|
||||
CR400AF,2095,2097,中国铁路广州局集团有限公司 长沙动车运用所,CR400AF-A,
|
||||
CR400AF,2098,2098,中国铁路广州局集团有限公司 深圳动车运用所,CR400AF-A,
|
||||
CR400AF,2099,2100,中国铁路广州局集团有限公司 长沙动车运用所,CR400AF-A,
|
||||
CR400AF,2102,2102,中国铁路济南局集团有限公司 济南东动车运用所,CR400AF-A,
|
||||
CR400AF,2103,2104,中国铁路广州局集团有限公司 长沙动车运用所,CR400AF-A,
|
||||
CR400AF,2105,2105,中国铁路广州局集团有限公司 深圳动车运用所,CR400AF-A,
|
||||
CR400AF,2106,2106,中国铁路广州局集团有限公司 长沙动车运用所,CR400AF-A,
|
||||
CR400AF,2107,2115,中国铁路济南局集团有限公司 济南东动车运用所,CR400AF-A,
|
||||
CR400AF,2116,2123,中国铁路北京局集团有限公司 北京南动车运用所,CR400AF-B,
|
||||
CR400AF,2124,2124,中国铁路武汉局集团有限公司 武汉动车运用所,,
|
||||
CR400AF,2125,2125,中国铁路武汉局集团有限公司 武汉动车运用所,,
|
||||
CR400AF,2126,2127,中国铁路武汉局集团有限公司 汉口动车运用所,,
|
||||
CR400AF,2128,2128,中国铁路武汉局集团有限公司 武汉动车运用所,,
|
||||
CR400AF,2130,2131,中国铁路广州局集团有限公司 长沙动车运用所,,
|
||||
CR400AF,2133,2133,中国铁路广州局集团有限公司 长沙动车运用所,,
|
||||
CR400AF,2134,2134,中国铁路济南局集团有限公司 青岛动车运用所,,
|
||||
CR400AF,2135,2135,中国铁路成都局集团有限公司 重庆西动车运用所,,
|
||||
CR400AF,2136,2138,中国铁路济南局集团有限公司 青岛动车运用所,,
|
||||
CR400AF,2139,2139,中国铁路济南局集团有限公司 济南东动车运用所,,
|
||||
CR400AF,2140,2140,中国铁路成都局集团有限公司 重庆西动车运用所,,
|
||||
CR400AF,2141,2141,中国铁路济南局集团有限公司 济南东动车运用所,,
|
||||
CR400AF,2142,2144,中国铁路北京局集团有限公司 北京西动车运用所,,
|
||||
CR400AF,2145,2146,中国铁路北京局集团有限公司 雄安动车运用所,,
|
||||
CR400AF,2148,2150,中国铁路武汉局集团有限公司 汉口动车运用所,,
|
||||
CR400AF,2151,2151,中国铁路武汉局集团有限公司 武汉动车运用所,,
|
||||
CR400AF,2152,2153,中国铁路武汉局集团有限公司 汉口动车运用所,,
|
||||
CR400AF,2154,2156,中国铁路武汉局集团有限公司 武汉动车运用所,,
|
||||
CR400AF,2159,2159,中国铁路武汉局集团有限公司 武汉动车运用所,,
|
||||
CR400AF,2160,2161,中国铁路武汉局集团有限公司 汉口动车运用所,,
|
||||
CR400AF,2162,2163,中国铁路济南局集团有限公司 济南东动车运用所,,
|
||||
CR400AF,2164,2164,中国铁路武汉局集团有限公司 武汉动车运用所,,
|
||||
CR400AF,2171,2172,中国铁路武汉局集团有限公司 武汉动车运用所,,
|
||||
CR400AF,2173,2173,中国铁路武汉局集团有限公司 汉口动车运用所,,
|
||||
CR400AF,2174,2177,中国铁路武汉局集团有限公司 武汉动车运用所,,
|
||||
CR400AF,2178,2178,中国铁路北京局集团有限公司 雄安动车运用所,,
|
||||
CR400AF,2179,2179,中国铁路北京局集团有限公司 北京西动车运用所,,
|
||||
CR400AF,2180,2180,中国铁路北京局集团有限公司 雄安动车运用所,,
|
||||
CR400AF,2181,2182,中国铁路北京局集团有限公司 北京西动车运用所,,
|
||||
CR400AF,2183,2187,中国铁路北京局集团有限公司 北京南动车运用所,,
|
||||
CR400AF,2190,2192,中国铁路武汉局集团有限公司 武汉动车运用所,CR400AF-A,
|
||||
CR400AF,2193,2193,中国铁路济南局集团有限公司 济南东动车运用所,CR400AF-A,
|
||||
CR400AF,2194,2195,中国铁路广州局集团有限公司 深圳动车运用所,CR400AF-A,
|
||||
CR400AF,2196,2200,中国铁路武汉局集团有限公司 武汉动车运用所,CR400AF-A,
|
||||
CR400AF,2201,2205,中国铁路济南局集团有限公司 济南东动车运用所,CR400AF-A,
|
||||
CR400AF,2206,2210,中国铁路北京局集团有限公司 北京南动车运用所,CR400AF-B,
|
||||
CR400AF,2211,2212,中国铁路济南局集团有限公司 济南东动车运用所,CR400AF-A,
|
||||
CR400AF,2213,2213,中国铁路北京局集团有限公司 北京西动车运用所,,
|
||||
CR400AF,2215,2217,中国铁路北京局集团有限公司 朝阳动车运用所,CR400AF-G,
|
||||
CR400AF,2222,2225,中国铁路上海局集团有限公司 上海南动车运用所,,
|
||||
CR400AF,2226,2226,中国铁路广州局集团有限公司 广州南动车运用所,,
|
||||
CR400AF,2227,2227,中国铁路广州局集团有限公司 长沙动车运用所,,
|
||||
CR400AF,2228,2228,中国铁路广州局集团有限公司 广州南动车运用所,,
|
||||
CR400AF,2229,2229,中国铁路广州局集团有限公司 长沙动车运用所,,
|
||||
CR400AF,2230,2231,中国铁路济南局集团有限公司 济南东动车运用所,,
|
||||
CR400AF,2232,2235,中国铁路上海局集团有限公司 上海南动车运用所,,
|
||||
CR400AF,2236,2236,中国铁路成都局集团有限公司 重庆西动车运用所,,
|
||||
CR400AF,2237,2243,中国铁路上海局集团有限公司 上海南动车运用所,,
|
||||
CR400AF,2244,2248,中国铁路成都局集团有限公司 重庆西动车运用所,,
|
||||
CR400AF,2254,2256,中国铁路成都局集团有限公司 重庆西动车运用所,,
|
||||
DJ1,1,1,中国铁道科学研究院 环形铁道,,
|
||||
DJ1,2,2,株洲西门子牵引设备有限公司,,
|
||||
DJ1,3,3,西安铁路局 宝鸡机务段 秦岭附加队 ,,
|
||||
DJ2,1,1,中国铁路郑州局集团有限公司 郑州机务段京武快车队,奥星,
|
||||
DJ2,2,3,中国铁路郑州局集团有限公司 郑州机务段,奥星,
|
||||
HXD1D,1,15,中国铁路武汉局集团有限公司 武昌南机务段,,
|
||||
HXD1D,16,16,中国铁路上海局集团有限公司 杭州机务段,,
|
||||
HXD1D,17,17,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD1D,18,18,中国铁路南昌局集团有限公司 鹰潭机务段,,
|
||||
HXD1D,19,19,中国铁路上海局集团有限公司 杭州机务段,,
|
||||
HXD1D,20,20,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD1D,21,21,中国铁路上海局集团有限公司 杭州机务段,,
|
||||
HXD1D,22,24,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD1D,25,25,中国铁路南昌局集团有限公司 鹰潭机务段,,
|
||||
HXD1D,26,26,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD1D,27,27,中国铁路上海局集团有限公司 杭州机务段,,
|
||||
HXD1D,28,28,中国铁路南昌局集团有限公司 鹰潭机务段,,
|
||||
HXD1D,29,34,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD1D,35,35,中国铁路上海局集团有限公司 杭州机务段,,
|
||||
HXD1D,36,38,中国铁路兰州局集团有限公司 兰州西机务段,,
|
||||
HXD1D,39,39,中国铁路济南局集团有限公司 济南机务段,,
|
||||
HXD1D,40,50,中国铁路兰州局集团有限公司 兰州西机务段,,
|
||||
HXD1D,51,75,中国铁路乌鲁木齐局集团有限公司 乌鲁木齐机务段,,
|
||||
HXD1D,76,105,中国铁路兰州局集团有限公司 兰州西机务段,,
|
||||
HXD1D,106,137,中国铁路上海局集团有限公司 上海机务段,,
|
||||
HXD1D,138,168,中国铁路上海局集团有限公司 杭州机务段,,
|
||||
HXD1D,169,175,中国铁路上海局集团有限公司 上海机务段,,
|
||||
HXD1D,176,185,中国铁路武汉局集团有限公司 武昌南机务段,,
|
||||
HXD1D,186,187,中国铁路南昌局集团有限公司 鹰潭机务段,,
|
||||
HXD1D,188,188,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD1D,189,190,中国铁路南昌局集团有限公司 鹰潭机务段,,
|
||||
HXD1D,191,232,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD1D,233,233,中国铁路南昌局集团有限公司 鹰潭机务段,,
|
||||
HXD1D,234,237,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD1D,238,257,中国铁路广州局集团有限公司 广州机务段,,
|
||||
HXD1D,258,270,中国铁路武汉局集团有限公司 武昌南机务段,,
|
||||
HXD1D,271,275,中国铁路乌鲁木齐局集团有限公司 乌鲁木齐机务段,,
|
||||
HXD1D,276,279,中国铁路上海局集团有限公司 上海机务段,,
|
||||
HXD1D,280,289,中国铁路上海局集团有限公司 徐州机务段,,
|
||||
HXD1D,290,291,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD1D,292,293,中国铁路南昌局集团有限公司 鹰潭机务段,,
|
||||
HXD1D,294,295,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD1D,296,300,中国铁路广州局集团有限公司 广州机务段,,
|
||||
HXD1D,301,310,中国铁路武汉局集团有限公司 武昌南机务段,,
|
||||
HXD1D,311,320,中国铁路乌鲁木齐局集团有限公司 乌鲁木齐机务段,,
|
||||
HXD1D,321,340,中国铁路青藏集团有限公司 西宁机务段,,
|
||||
HXD1D,341,362,中国铁路乌鲁木齐局集团有限公司 乌鲁木齐机务段,,
|
||||
HXD1D,363,382,中国铁路广州局集团有限公司 广州机务段,,
|
||||
HXD1D,383,392,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD1D,393,405,中国铁路上海局集团有限公司 上海机务段,,
|
||||
HXD1D,406,415,中国铁路兰州局集团有限公司 兰州西机务段,,
|
||||
HXD1D,416,430,中国铁路广州局集团有限公司 长沙机务段,,
|
||||
HXD1D,431,440,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD1D,441,445,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD1D,446,450,中国铁路乌鲁木齐局集团有限公司 乌鲁木齐机务段,,
|
||||
HXD1D,451,460,中国铁路武汉局集团有限公司 武昌南机务段,,
|
||||
HXD1D,461,470,中国铁路广州局集团有限公司 广州机务段,,
|
||||
HXD1D,471,478,中国铁路兰州局集团有限公司 兰州西机务段,,
|
||||
HXD1D,479,483,中国铁路上海局集团有限公司 上海机务段,,
|
||||
HXD1D,484,488,中国铁路上海局集团有限公司 杭州机务段,,
|
||||
HXD1D,489,490,中国铁路上海局集团有限公司 杭州机务段,,
|
||||
HXD1D,491,510,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
HXD1D,511,512,中国铁路上海局集团有限公司 徐州机务段,,
|
||||
HXD1D,513,515,中国铁路上海局集团有限公司 上海机务段,,
|
||||
HXD1D,516,520,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD1D,521,534,中国铁路广州局集团有限公司 广州机务段,,
|
||||
HXD1D,522,522,广州铁路职业技术学院,,
|
||||
HXD1D,535,544,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD1D,545,551,中国铁路上海局集团有限公司 上海机务段,,
|
||||
HXD1D,552,554,中国铁路上海局集团有限公司 杭州机务段,,
|
||||
HXD1D,555,559,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
HXD1D,560,564,中国铁路乌鲁木齐局集团有限公司 乌鲁木齐机务段,,
|
||||
HXD1D,565,570,中国铁路广州局集团有限公司 广州机务段,,
|
||||
HXD1D,571,585,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD1D,586,595,中国铁路上海局集团有限公司 杭州机务段,,
|
||||
HXD1D,596,613,中国铁路兰州局集团有限公司 兰州西机务段,,
|
||||
HXD1D,614,623,中国铁路广州局集团有限公司 广州机务段,,
|
||||
HXD1D,624,633,中国铁路上海局集团有限公司 上海机务段,,
|
||||
HXD1D,634,636,中国铁路乌鲁木齐局集团有限公司 乌鲁木齐机务段,,
|
||||
HXD1D,637,644,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
HXD1D,645,660,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
HXD1D,661,668,中国铁路武汉局集团有限公司 武昌南机务段,,
|
||||
HXD1D,669,673,中国铁路乌鲁木齐局集团有限公司 乌鲁木齐机务段,,
|
||||
HXD1D,674,678,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
HXD1D,679,682,中国铁路上海局集团有限公司 上海机务段,,
|
||||
HXD1D,683,683,中国铁路上海局集团有限公司 徐州机务段,,
|
||||
HXD1D,684,684,中国铁路上海局集团有限公司 徐州机务段,,
|
||||
HXD1D,685,689,中国铁路青藏集团有限公司 格尔木机务段,,
|
||||
HXD1D,1898,1898,中国铁路上海局集团有限公司 上海机务段,周恩来号,
|
||||
HXD1D-J,1,3,中国铁路青藏集团有限公司 拉萨动车运用所,,
|
||||
HXD1D-J,1001,1009,中国铁路昆明局集团有限公司 昆明动车运用所,,
|
||||
HXD1D-J,1010,1013,中国铁路青藏集团有限公司 格尔木机务段,,
|
||||
HXD1D-J,1014,1019,中国铁路成都局集团有限公司 成都动车运用所,,
|
||||
HXD1D-J,1020,1027,中国铁路昆明局集团有限公司 昆明动车运用所,,
|
||||
HXD3C,1,9,中国铁路沈阳局集团有限公司 沈阳机务段,,
|
||||
HXD3C,10,10,中国铁路沈阳局集团有限公司 沈阳机务段,,
|
||||
HXD3C,11,15,中国铁路济南局集团有限公司 济南机务段,,
|
||||
HXD3C,16,20,中国铁路武汉局集团有限公司 江岸机务段(襄阳机务段支配),,
|
||||
HXD3C,21,25,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD3C,26,30,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
HXD3C,31,35,中国铁路上海局集团有限公司 宁东机务段(上海机务段支配),,
|
||||
HXD3C,36,41,中国铁路武汉局集团有限公司 江岸机务段(武南机务段支配),,
|
||||
HXD3C,42,45,中国铁路武汉局集团有限公司 江岸机务段(襄阳机务段支配),,
|
||||
HXD3C,46,55,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD3C,56,60,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
HXD3C,61,61,中国铁路沈阳局集团有限公司 沈阳机务段,,
|
||||
HXD3C,62,62,中国铁路济南局集团有限公司 济南机务段,,
|
||||
HXD3C,63,63,中国铁路沈阳局集团有限公司 沈阳机务段,,
|
||||
HXD3C,64,70,中国铁路济南局集团有限公司 济南机务段,,
|
||||
HXD3C,71,85,中国铁路武汉局集团有限公司 江岸机务段,,
|
||||
HXD3C,86,95,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
HXD3C,96,100,中国铁路济南局集团有限公司 济南机务段,,
|
||||
HXD3C,101,110,中国铁路武汉局集团有限公司 江岸机务段,,
|
||||
HXD3C,111,120,中国铁路沈阳局集团有限公司 沈阳机务段,,
|
||||
HXD3C,121,125,中国铁路上海局集团有限公司 宁东机务段(上海机务段支配),,
|
||||
HXD3C,126,130,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD3C,131,135,中国铁路济南局集团有限公司 济南机务段,,
|
||||
HXD3C,136,140,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
HXD3C,141,165,中国铁路武汉局集团有限公司 江岸机务段,,
|
||||
HXD3C,166,180,中国铁路成都局集团有限公司 重庆机务段,,
|
||||
HXD3C,181,182,中国铁路济南局集团有限公司 济南机务段,,
|
||||
HXD3C,183,190,中国铁路沈阳局集团有限公司 沈阳机务段,,
|
||||
HXD3C,191,195,中国铁路济南局集团有限公司 济南机务段,,
|
||||
HXD3C,198,200,中国铁路上海局集团有限公司 宁东机务段,,
|
||||
HXD3C,201,220,中国铁路武汉局集团有限公司 江岸机务段,,
|
||||
HXD3C,221,225,中国铁路上海局集团有限公司 宁东机务段,,
|
||||
HXD3C,226,229,中国铁路沈阳局集团有限公司 沈阳机务段,,
|
||||
HXD3C,238,238,中国铁路广州局集团有限公司 株洲机务段,,
|
||||
HXD3C,271,300,中国铁路上海局集团有限公司 宁东机务段,,
|
||||
HXD3C,446,446,中国铁路广州局集团有限公司 广州机务段,,
|
||||
HXD3C,805,809,中国铁路广州局集团有限公司 ,,
|
||||
HXD3C,810,819,中国铁路南宁局集团有限公司,,
|
||||
HXD3C,820,829,中国铁路武汉局集团有限公司,,
|
||||
HXD3C,896,925,中国铁路沈阳局集团有限公司,,
|
||||
HXD3C,926,930,中国铁路南宁局集团有限公司,,
|
||||
HXD3C,931,945,中国铁路北京局集团有限公司,,
|
||||
HXD3C,946,955,中国铁路济南局集团有限公司,,
|
||||
HXD3C,956,965,中国铁路郑州局集团有限公司,,
|
||||
HXD3C,966,974,中国铁路济南局集团有限公司,,
|
||||
HXD3D,1,10,中国铁路沈阳局集团有限公司 沈阳机务段,,
|
||||
HXD3D,11,25,西安铁路局集团有限公司 西安机务段,,
|
||||
HXD3D,26,34,中国铁路兰州局集团有限公司 兰州西机务段,,
|
||||
HXD3D,35,35,中国铁路兰州局集团有限公司 迎水桥机务段,雷锋号,
|
||||
HXD3D,36,38,中国铁路兰州局集团有限公司 兰州西机务段,,
|
||||
HXD3D,39,39,中国铁路济南局集团有限公司 济南机务段,共青团号,
|
||||
HXD3D,40,40,中国铁路兰州局集团有限公司 兰州西机务段,,
|
||||
HXD3D,41,50,西安铁路局集团有限公司 西安机务段,,
|
||||
HXD3D,51,70,中国铁路北京局集团有限公司 北京机务段,,
|
||||
HXD3D,71,90,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD3D,91,115,中国铁路兰州局集团有限公司 兰州西机务段,,
|
||||
HXD3D,116,135,中国铁路昆明局集团有限公司 昆明机务段,,
|
||||
HXD3D,136,145,中国铁路北京局集团有限公司 北京机务段,,
|
||||
HXD3D,146,150,呼和浩特铁路局集团有限公司 集宁机务段,,
|
||||
HXD3D,151,155,中国铁路沈阳局集团有限公司 沈阳机务段,,
|
||||
HXD3D,156,160,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD3D,161,165,中国铁路北京局集团有限公司 北京机务段,,
|
||||
HXD3D,166,170,西安铁路局集团有限公司 西安机务段,,
|
||||
HXD3D,171,180,中国铁路兰州局集团有限公司 兰州西机务段,,
|
||||
HXD3D,181,190,中国铁路济南局集团有限公司 济南机务段,,
|
||||
HXD3D,191,245,中国铁路沈阳局集团有限公司 沈阳机务段,,
|
||||
HXD3D,246,255,中国铁路北京局集团有限公司 北京机务段,,
|
||||
HXD3D,256,265,呼和浩特铁路局集团有限公司 集宁机务段,,
|
||||
HXD3D,266,290,中国铁路兰州局集团有限公司 兰州西机务段,,
|
||||
HXD3D,291,300,中国铁路沈阳局集团有限公司 沈阳机务段,,
|
||||
HXD3D,301,310,中国铁路济南局集团有限公司 济南机务段,,
|
||||
HXD3D,310,315,中国铁路昆明局集团有限公司 昆明机务段,,
|
||||
HXD3D,316,320,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD3D,321,322,呼和浩特铁路局集团有限公司 集宁机务段,,
|
||||
HXD3D,323,325,中国铁路北京局集团有限公司 北京机务段,,
|
||||
HXD3D,326,333,西安铁路局集团有限公司 西安机务段,,
|
||||
HXD3D,334,340,西安铁路局集团有限公司 安康机务段,,
|
||||
HXD3D,341,345,中国铁路沈阳局集团有限公司 沈阳机务段,,
|
||||
HXD3D,346,346,中国铁路成都局集团有限公司 重庆机务段,,
|
||||
HXD3D,351,351,中国铁路成都局集团有限公司 重庆机务段,,
|
||||
HXD3D,356,365,西安铁路局集团有限公司 安康机务段,,
|
||||
HXD3D,366,369,中国铁路北京局集团有限公司 北京机务段,,
|
||||
HXD3D,370,382,呼和浩特铁路局集团有限公司 集宁机务段,,
|
||||
HXD3D,383,392,中国铁路昆明局集团有限公司 昆明机务段,,
|
||||
HXD3D,393,397,中国铁路兰州局集团有限公司 兰州西机务段,,
|
||||
HXD3D,398,402,西安铁路局集团有限公司 西安机务段,,
|
||||
HXD3D,403,417,西安铁路局集团有限公司 西安机务段,,
|
||||
HXD3D,418,419,中国铁路哈尔滨局集团有限公司 牡丹江机务段,,
|
||||
HXD3D,420,424,中国铁路北京局集团有限公司 北京机务段,,
|
||||
HXD3D,425,429,呼和浩特铁路局集团有限公司 集宁机务段,,
|
||||
HXD3D,430,433,中国铁路哈尔滨局集团有限公司 牡丹江机务段,,
|
||||
HXD3D,434,443,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD3D,444,449,中国铁路济南局集团有限公司 济南机务段,,
|
||||
HXD3D,450,464,西安铁路局集团有限公司 西安机务段,,
|
||||
HXD3D,465,468,中国铁路济南局集团有限公司 济南机务段,,
|
||||
HXD3D,469,473,中国铁路济南局集团有限公司 济南机务段,,
|
||||
HXD3D,474,479,中国铁路昆明局集团有限公司 昆明机务段,,
|
||||
HXD3D,480,484,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD3D,485,489,西安铁路局集团有限公司 西安机务段,,
|
||||
HXD3D,490,499,中国铁路北京局集团有限公司 北京机务段,,
|
||||
HXD3D,500,503,中国铁路哈尔滨局集团有限公司 牡丹江机务段,,
|
||||
HXD3D,504,514,中国铁路沈阳局集团有限公司 沈阳机务段,,
|
||||
HXD3D,515,515,中国铁路成都局集团有限公司 重庆机务段,,
|
||||
HXD3D,516,518,中国铁路沈阳局集团有限公司 沈阳机务段,,
|
||||
HXD3D,519,528,西安铁路局集团有限公司 西安机务段,,
|
||||
HXD3D,529,538,中国铁路北京局集团有限公司 北京机务段,,
|
||||
HXD3D,539,541,呼和浩特铁路局集团有限公司 集宁机务段,,
|
||||
HXD3D,542,553,西安铁路局集团有限公司 西安机务段,,
|
||||
HXD3D,554,563,中国铁路济南局集团有限公司 济南机务段,,
|
||||
HXD3D,564,568,中国铁路昆明局集团有限公司 昆明机务段,,
|
||||
HXD3D,569,573,中国铁路成都局集团有限公司 重庆机务段,,
|
||||
HXD3D,574,583,中国铁路济南局集团有限公司 济南机务段,,
|
||||
HXD3D,584,584,中国铁路沈阳局集团有限公司 沈阳机务段,,
|
||||
HXD3D,585,609,中国铁路哈尔滨局集团有限公司 三棵树机务段,,
|
||||
HXD3D,610,611,中国铁路北京局集团有限公司 北京机务段,,
|
||||
HXD3D,612,621,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD3D,622,626,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
HXD3D,627,629,中国铁路哈尔滨局集团有限公司 三棵树机务段,,
|
||||
HXD3D,630,630,中国铁路哈尔滨局集团有限公司 哈尔滨机务段,,
|
||||
HXD3D,631,631,西安铁路局集团有限公司 西安机务段,第五代“钢人铁马号”,
|
||||
HXD3D,632,653,中国铁路沈阳局集团有限公司 沈阳机务段,,
|
||||
HXD3D,654,673,中国铁路沈阳局集团有限公司 沈阳机务段,,
|
||||
HXD3D,674,681,中国铁路沈阳局集团有限公司 沈阳机务段,,
|
||||
HXD3D,682,688,中国铁路兰州局集团有限公司 兰州西机务段,,
|
||||
HXD3D,1886,1886,中国铁路哈尔滨局集团有限公司 哈尔滨机务段,第五代“朱德号”,
|
||||
HXD3D,1893,1893,中国铁路北京局集团有限公司 丰台机务段,第六代“毛泽东号”,
|
||||
HXD3D,1921,1921,中国铁路沈阳局集团有限公司 沈阳机务段,共产党员号,
|
||||
HXD3D,7001,7002,广西沿海铁路股份有限公司 南宁南机务运用段,,
|
||||
HXD3D,7003,7003,吉林铁道职业技术学院,,
|
||||
HXD3D,8001,8025,中国铁路沈阳局集团有限公司 沈阳机务段,,大同
|
||||
HXD3D,8026,8028,中国铁路太原局集团有限公司 太原南机务段,,大同
|
||||
东方红2,1,50,,,资阳
|
||||
东风,1201,1830,,,大连、成都
|
||||
东风,2001,2094,,,戚墅堰
|
||||
东风11,1,459,,,戚墅堰
|
||||
东风12,8001,8001,吉林铁道职业技术学院,,
|
||||
东风2,3201,3348,,,戚墅堰
|
||||
东风21,1,5,中国铁路昆明局集团有限公司 昆明机务段,,
|
||||
东风21,6,6,中国铁路昆明局集团有限公司 昆明机务段,状元号,
|
||||
东风21,7,7,中国铁路昆明局集团有限公司 昆明机务段,亲年号,
|
||||
东风21,8,8,中国铁路昆明局集团有限公司 昆明机务段,建水古城,
|
||||
东风21,9,100,中国铁路昆明局集团有限公司 昆明机务段,,
|
||||
东风21,101,101,中国铁路昆明局集团有限公司 昆明机务段,异龙号,
|
||||
东风21,102,102,中国铁路昆明局集团有限公司 昆明机务段,,
|
||||
东风21,1001,1002,云南钢铁厂,,
|
||||
东风2Z,3251,3251,*齐齐哈尔铁路局 加格达奇机务段,,
|
||||
东风3,3243,3243,中车共享城机车公园,,
|
||||
东风4,3247,3247,中车成都轨道交通产业园,,
|
||||
东风4B,1001,1999,,,大连
|
||||
东风4B,1963,1963,*北京铁路局 丰台机务段,,
|
||||
东风4B,2101,2685,,,大连
|
||||
东风4B,2104,2104,*上海铁路局 蚌埠机务段,,
|
||||
东风4B,2376,2376,*南昌铁路局 鹰潭机务段,,
|
||||
东风4B,3101,3999,,,资阳
|
||||
东风4B,3214,3214,*浙江金温铁道开发有限公司 温州机务段,,
|
||||
东风4B,3249,3249,*西安铁路局 西安机务段,,
|
||||
东风4B,3390,3390,*成都铁路局 重庆机务段,,
|
||||
东风4B,3593,3593,*中国铁路广州局集团有限公司 株洲机务段,,
|
||||
东风4B,6001,6587,,,大同
|
||||
东风4B,6530,6530,*南宁铁路局 南宁机务段,,
|
||||
东风4B,7001,7363,,,大连
|
||||
东风4B,7364,7365,,,四方
|
||||
东风4B,7366,7796,,,大连
|
||||
东风4B,7701,7732,,,戚墅堰改
|
||||
东风4B,9001,9702,,,资阳
|
||||
东风4B,9167,9167,*南昌铁路局 向塘机务段,,
|
||||
东风4B,9531,9531,*新长铁路公司,,
|
||||
东风4C,1,10,,,大同
|
||||
东风4C,11,11,中国铁路北京局集团有限公司 丰台段,青年文明号,
|
||||
东风4C,12,40,,,大同
|
||||
东风4C,2001,2006,,,四方
|
||||
东风4C,4001,4465,,,大连
|
||||
东风4C,4466,4466,四方机车车辆厂,,四方
|
||||
东风4C,5001,5273,,,资阳
|
||||
东风4C,5274,5275,三茂铁路公司 三水机务段,东风4CK,
|
||||
东风4C,5276,5335,,,资阳
|
||||
东风4D,7001,7021,中国铁路南宁局集团有限公司,,
|
||||
东风5,1,1,中国铁路北京局集团有限公司 北京车辆段,,
|
||||
东风5,1974,1975,中国铁路兰州局集团有限公司 兰州西机务段,,唐山
|
||||
东风5,1976,2082,,,唐山
|
||||
东风5,2083,2083,中国石油兰州石化公司,,唐山
|
||||
东风5,3279,3279,云南铁路博物馆,,
|
||||
东风6,1,2,*沈阳铁路局 大连机务段,,
|
||||
东风6,3,3,沈阳铁路陈列馆,,
|
||||
东风6,4,4,*沈阳铁路局 大连机务段,,
|
||||
东风7,174,174,中国铁路太原局集团有限公司 太原机务段北场,,
|
||||
东风7B,3006,3006,中国铁道博物馆,,
|
||||
东风7B,3015,3015,王坪村铁路公园,,
|
||||
东风7B,6001,6072,*北京铁路局 邯郸机务段;郑州铁路局 新乡机务段,调车,
|
||||
东风7D,1,1,中国铁道博物馆,,
|
||||
东风7D,3001,3001,中国铁道博物馆,,
|
||||
东风7E,1,1,中国铁路郑州局集团有限公司 新乡机务段,,
|
||||
东风7E,2,2,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
东风7G,9001,9004,呼和浩特铁路局 集宁机务段 赛汗塔拉分段,,
|
||||
东风8,1,1,中国铁道博物馆,,
|
||||
东风9,1,2,中国铁路广州局集团有限公司广州机务段,,
|
||||
韶山1,8,8,中国铁道博物馆,,
|
||||
韶山1,156,156,郑州世纪欢乐园,,
|
||||
韶山1,160,160,北京铁路电气化学校,,
|
||||
韶山1,227,227,中国铁路兰州局集团有限公司 兰州西机务段,,
|
||||
韶山1,254,254,中国铁路北京局集团有限公司 丰台机务段 储备厂,,
|
||||
韶山1,307,307,中国铁路太原局集团有限公司 榆次机务折返段,,
|
||||
韶山1,309,309,中国铁路太原局集团有限公司 太原机务段北场,,
|
||||
韶山1,321,321,武汉铁路职业技术学院,,
|
||||
韶山1,681,681,中国铁道博物馆,,
|
||||
韶山1,695,695,沈阳铁路陈列馆,,
|
||||
韶山1,762,762,中国铁路广州局集团有限公司 娄底运用车间储备厂,,
|
||||
韶山1,818,818,西南交通大学 机车博物园,,
|
||||
韶山1,821,821,韶关机务实训基地,,
|
||||
韶山1,826,826,韶关机务实训基地,,
|
||||
韶山3,454,454,中国铁路成都局集团有限公司 贵阳机务段,先锋号,
|
||||
韶山3,524,524,中国铁路武汉局集团有限公司 江岸机务段,青年号,
|
||||
韶山3,4160,4160,广西沿海铁路公司 南宁南机务运用段,共青团号,
|
||||
韶山3,4178,4178,广西沿海铁路公司 南宁南机务运用段,共青团号,
|
||||
韶山3,4235,4235,中国铁路成都局集团有限公司 重庆机务段,青年文明号,
|
||||
韶山3,4258,4258,中国铁路成都局集团有限公司 重庆机务段,党员先锋号,
|
||||
韶山3,5080,5080,广州铁路博物馆,,
|
||||
韶山3,6005,6005,湖南交通工程学院,,
|
||||
韶山3,8050,8050,武汉四美塘铁路遗址公园,,
|
||||
韶山3B,16,16,西安铁路局 安康机务段,青年文明号,
|
||||
韶山3B,5001,5001,中国铁路成都局集团有限公司 贵阳机务段,*先锋力神,
|
||||
韶山3B,5035,5035,中国铁路兰州局集团有限公司 迎水桥机务段,雷锋号 (曾),
|
||||
韶山3B,5038,5038,中国铁路兰州局集团有限公司 迎水桥机务段,青年文明号,
|
||||
韶山3B,5151,5151,中国铁路成都局集团有限公司 西昌机务段,扶贫先锋号,
|
||||
韶山3B,5162,5162,中国铁路昆明局集团有限公司 昆明机务段,五四青年号,
|
||||
韶山3B,5235,5235,中国铁路成都局集团有限公司 西昌机务段,*共青团号,
|
||||
韶山3C,1,1,中国铁路成都局集团有限公司 贵阳机务段,,
|
||||
韶山4,6,6,中国铁道博物馆,,
|
||||
韶山4,10,10,中国铁路成都局集团有限公司 西昌机务段,,
|
||||
韶山4,50,50,中国铁路郑州局集团有限公司 新乡机务段,先锋号,
|
||||
韶山4,63,63,中国铁路太原局集团有限公司 太原机务段,,
|
||||
韶山4,204,204,中国铁路郑州局集团有限公司 新乡机务段,先锋号,
|
||||
韶山4,448,448,中国铁路沈阳局集团有限公司 苏家屯机务段,先锋号,
|
||||
韶山4,574,574,中铁三局集团,先锋号,
|
||||
韶山4,743,743,中国铁路哈尔滨局集团有限公司 哈尔滨机务段,青年文明号,
|
||||
韶山4,855,855,西安铁路局 新丰镇机务段,,
|
||||
韶山4,911,911,中铁三局集团,青年文明号,
|
||||
韶山4,2006,2006,吉林铁道职业技术学院,,
|
||||
韶山4B,19,19,神朔铁路公司 神木北机务段,青年号,
|
||||
韶山4B,89,89,神朔铁路公司 神木北机务段,青年文明号,
|
||||
韶山4B,90,90,神朔铁路公司 神木北机务段,青年文明号,
|
||||
韶山4B,257,257,包神铁路公司 东胜机务段,党员先锋号,
|
||||
韶山4G,159,1177,,,株洲
|
||||
韶山4G,168,168,中国铁道博物馆,,
|
||||
韶山4G,171,171,中国铁路哈尔滨局集团有限公司 牡丹江机务段,,
|
||||
韶山4G,179,179,中国铁路太原局集团有限公司 湖东机务段,,
|
||||
韶山4G,466,466,石家庄铁道大学,,
|
||||
韶山4G,1089,1089,*呼和浩特铁路局 包头西机务段,,
|
||||
韶山4G,1886,1886,中国铁路哈尔滨局集团有限公司 哈尔滨机务段,*朱德号,株洲
|
||||
韶山4G,3001,3002,,,资阳
|
||||
韶山4G,6001,6001,中国铁道博物馆,,
|
||||
韶山4G,6001,6001,中国铁道博物馆,,大同
|
||||
韶山4G,7001,7110,,,大连
|
||||
韶山4G,7121,7243,,,大连
|
||||
韶山5,1,1,中国铁道博物馆,,
|
||||
韶山5,2,2,郑州世纪欢乐园 ,,
|
||||
韶山6,1,1,郑州铁路司机学校,,
|
||||
韶山6,2,2,中国铁道博物馆,,
|
||||
韶山6B,1011,1011,西安铁路局 西安机务段,*青年文明号,
|
||||
韶山6B,1026,1026,韶关机务实训基地,,
|
||||
韶山6B,1088,1088,中国铁路武汉局集团有限公司 襄阳机务段,*民兵号,
|
||||
韶山6B,1111,1111,中国铁路武汉局集团有限公司 襄阳机务段,*先锋号,
|
||||
韶山6B,6001,6001,韶关机务实训基地,,
|
||||
韶山6B,6002,6002,广州铁路博物馆,,
|
||||
韶山7,1,79,中国铁路南宁局集团有限公司 柳州机务段,,
|
||||
韶山7,76,76,中国铁路南宁局集团有限公司 南宁机务段,*五四红旗号,
|
||||
韶山7,80,84,中国铁路南宁局集团有限公司 柳州机务段,,
|
||||
韶山7,85,111,中国铁路南宁局集团有限公司 柳州机务段,,
|
||||
韶山7,8112,8113,山西孝柳铁路有限责任公司,,
|
||||
韶山7B,1,1,*南宁铁路局集团有限公司 南宁机务段,,
|
||||
韶山7B,2,2,中国铁路南宁局集团有限公司 柳州机务段,,
|
||||
韶山7D,1,58,西安铁路局集团有限公司 西安机务段,,
|
||||
韶山7D,631,631,西安铁路局集团有限公司 西安机务段,*钢人铁马号,
|
||||
韶山7E,1,140,,,大同
|
||||
韶山7E,6001,6002,中国铁路昆明局集团有限公司,,大同
|
||||
韶山7E,7001,7004,,,大连
|
||||
韶山8,1,1,中国铁路广州局集团有限公司 广州机务段,,
|
||||
韶山8,2,2,中国铁路广州局集团有限公司 广州机务段,,
|
||||
韶山8,3,4,中国铁路上海局集团有限公司 上海机务段,,
|
||||
韶山8,5,5,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
韶山8,9,9,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
韶山8,11,11,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
韶山8,12,12,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
韶山8,15,16,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
韶山8,17,17,中国铁路上海局集团有限公司 上海机务段,,
|
||||
韶山8,20,20,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
韶山8,24,25,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
韶山8,27,27,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
韶山8,29,32,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
韶山8,33,35,中国铁路上海局集团有限公司 上海机务段,,
|
||||
韶山8,36,36,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
韶山8,38,38,中国铁路上海局集团有限公司 上海机务段,,
|
||||
韶山8,39,39,中国铁路上海局集团有限公司 上海机务段,国祥号,
|
||||
韶山8,40,40,中国铁路上海局集团有限公司 上海机务段,,
|
||||
韶山8,41,41,中国铁路北京局集团有限公司 北京机务段,,
|
||||
韶山8,43,43,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
韶山8,44,44,中国铁路北京局集团有限公司 邯郸机务段,,
|
||||
韶山8,45,45,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
韶山8,48,48,中国铁路北京局集团有限公司 邯郸机务段,,
|
||||
韶山8,49,49,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
韶山8,50,50,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
韶山8,51,51,中国铁路北京局集团有限公司 邯郸机务段,,
|
||||
韶山8,52,52,中国铁路上海局集团有限公司 上海机务段,,
|
||||
韶山8,55,55,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
韶山8,56,57,中国铁路北京局集团有限公司 邯郸机务段,,
|
||||
韶山8,64,64,中国铁路广州局集团有限公司 广州机务段,,
|
||||
韶山8,72,72,中国铁路北京局集团有限公司 邯郸机务段,,
|
||||
韶山8,73,73,中国铁路北京局集团有限公司 北京机务段,,
|
||||
韶山8,74,74,中国铁路北京局集团有限公司 邯郸机务段,,
|
||||
韶山8,81,81,中国铁路北京局集团有限公司 北京机务段,,
|
||||
韶山8,83,84,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
韶山8,85,85,中国铁路北京局集团有限公司 北京机务段,,
|
||||
韶山8,88,103,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
韶山8,104,104,中国铁路北京局集团有限公司 邯郸机务段,,
|
||||
韶山8,109,111,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
韶山8,114,116,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
韶山8,118,119,中国铁路北京局集团有限公司 北京机务段,,
|
||||
韶山8,121,126,中国铁路北京局集团有限公司 北京机务段,,
|
||||
韶山8,127,128,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
韶山8,130,130,中国铁路南昌局集团有限公司 南昌机务段,,
|
||||
韶山8,131,131,中国铁路广州局集团有限公司 长沙机务段,,
|
||||
韶山8,132,132,中国铁路广州局集团有限公司 长沙机务段,,
|
||||
韶山8,133,133,中国铁路广州局集团有限公司 长沙机务段,,
|
||||
韶山8,134,134,中国铁路广州局集团有限公司 长沙机务段,,
|
||||
韶山8,136,136,中国铁路广州局集团有限公司 长沙机务段,,
|
||||
韶山8,141,141,中国铁路广州局集团有限公司 广州机务段,,
|
||||
韶山8,144,144,中国铁路广州局集团有限公司 长沙机务段,,
|
||||
韶山8,148,148,中国铁路广州局集团有限公司 广州机务段,,
|
||||
韶山8,156,156,中国铁路广州局集团有限公司 广州机务段,,
|
||||
韶山8,163,163,中国铁路广州局集团有限公司 广州机务段,,
|
||||
韶山8,166,166,中国铁路广州局集团有限公司 广州机务段,新世纪金龙号,
|
||||
韶山8,171,171,中国铁路上海局集团有限公司 上海机务段,,
|
||||
韶山8,172,172,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
韶山8,173,173,中国铁路广州局集团有限公司 广州机务段,,
|
||||
韶山8,181,181,中国铁路广州局集团有限公司 广州机务段,,
|
||||
韶山8,186,186,中国铁路广州局集团有限公司 广州机务段,,
|
||||
韶山8,191,191,中国铁路广州局集团有限公司 广州机务段,,
|
||||
韶山8,192,192,中国铁路广州局集团有限公司 广州机务段,,
|
||||
韶山8,197,197,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
韶山8,200,204,中国铁路上海局集团有限公司 上海机务段,,
|
||||
韶山8,205,205,中国铁路广州局集团有限公司 长沙机务段,,
|
||||
韶山8,214,214,中国铁路郑州局集团有限公司 郑州机务段,,
|
||||
韶山9,1,3,中国铁路沈阳局集团有限公司 沈阳机务段;中国铁路上海局集团有限公司 上海机务段,,
|
||||
韶山9,5,29,中国铁路沈阳局集团有限公司 沈阳机务段;中国铁路上海局集团有限公司 上海机务段,,
|
||||
韶山9,30,30,中国铁路沈阳局集团有限公司 通辽机务段,,
|
||||
韶山9,31,37,中国铁路沈阳局集团有限公司 沈阳机务段;中国铁路上海局集团有限公司 上海机务段,,
|
||||
韶山9,38,38,中国铁路沈阳局集团有限公司 通辽机务段,,
|
||||
韶山9,39,43,中国铁路沈阳局集团有限公司 沈阳机务段;中国铁路上海局集团有限公司 上海机务段,,
|
||||
|
0
app/src/main/res/values/colors.xml
Normal file → Executable file
0
app/src/main/res/values/strings.xml
Normal file → Executable file
2
app/src/main/res/values/themes.xml
Normal file → Executable file
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.LBJReceiver" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
<style name="Theme.LBJConsole" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
0
app/src/main/res/xml/backup_rules.xml
Normal file → Executable file
0
app/src/main/res/xml/data_extraction_rules.xml
Normal file → Executable file
0
app/src/main/res/xml/file_paths.xml
Normal file → Executable file
@@ -8,6 +8,8 @@ espressoCore = "3.6.1"
|
||||
lifecycleRuntimeKtx = "2.9.0"
|
||||
activityCompose = "1.10.1"
|
||||
composeBom = "2024.04.01"
|
||||
room = "2.6.1"
|
||||
startup = "1.1.1"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -24,9 +26,14 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
|
||||
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
androidx-startup-runtime = { group = "androidx.startup", name = "startup-runtime", version.ref = "startup" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version = "2.0.0-1.0.21" }
|
||||
|
||||
|
||||
@@ -19,6 +19,6 @@ dependencyResolutionManagement {
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "LBJ Receiver"
|
||||
rootProject.name = "LBJ_Console"
|
||||
include(":app")
|
||||
|
||||