16 Commits

Author SHA1 Message Date
Nedifinita
0bf7033c6c feat: update locomotive type support and optimized position display format 2025-08-19 19:11:52 +08:00
Nedifinita
0f98b6bcf7 refactor: optimize train record merging logic 2025-08-19 18:06:02 +08:00
Nedifinita
8894a73999 fix: optimize the display logic of history item spacing 2025-08-19 17:43:21 +08:00
Nedifinita
cd4b58e16b fix: correct the default value handling issue when the train display is empty 2025-08-19 17:03:09 +08:00
Nedifinita
39effddfc1 feat: add LocoTypeUtil 2025-08-19 16:35:47 +08:00
undef-i
c4b06f3b3c fix: typo 2025-08-17 14:24:54 +08:00
Nedifinita
eb33fa7feb chore: update project name and .gitignore file 2025-08-08 19:20:26 +08:00
undef-i
65bf7b52c6 fix: correct the error in train_number_info.csv 2025-08-05 20:43:16 +08:00
undef-i
4278de2a8d fix: correct incorrect rendering and status of map marker points 2025-08-03 18:40:14 +08:00
Nedifinita
59e9987d7f feat: add train type recognition and restructure settings storage 2025-08-01 20:07:35 +08:00
Nedifinita
4e97dcafd7 fix: hide duplicate info when train and loco both match 2025-08-01 17:51:24 +08:00
Nedifinita
4cad3679a9 fix: improve TRAIN_OR_LOCO merge display and settings scroll 2025-08-01 17:44:26 +08:00
Nedifinita
e6e7831b96 refactor: migrate data storage from SharedPreferences to Room database 2025-08-01 17:36:04 +08:00
Nedifinita
39bb8cb440 fix: optimize the logic for saving scroll position 2025-08-01 17:35:34 +08:00
Nedifinita
be8dc6bc72 feat: add custom train information notification layout 2025-07-26 17:31:09 +08:00
Nedifinita
cd3128c24b feat: add option for automatically connecting to Bluetooth devices 2025-07-26 17:08:08 +08:00
57 changed files with 1932 additions and 951 deletions

4
.gitignore vendored
View File

@@ -13,10 +13,12 @@ captures
.externalNativeBuild .externalNativeBuild
.cxx .cxx
local.properties local.properties
local.properties
*.ps1 *.ps1
.*.bat .*.bat
*.jks *.jks
*.keystore *.keystore
*.base64 *.base64
docs docs
linux
windows
android_original

2
.idea/.name generated
View File

@@ -1 +1 @@
LBJ Receiver LBJ_Console

View File

@@ -1,8 +1,20 @@
# LBJ Console # 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 是一款 Android 应用程序,用于通过 BLE 从 [SX1276_Receive_LBJ](https://github.com/undef-i/SX1276_Receive_LBJ) 设备接收并显示列车预警消息,功能包括:
- 接收列车预警消息,支持可选的手机推送通知。
- 在地图上显示预警消息的 GPS 信息。
- 基于内置数据文件显示机车配属,机车类型和车次类型。
# License ## 数据文件
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.0GPLv3授权。该许可证确保软件保持免费和开源要求任何修改或衍生作品也必须在相同许可证条款下发布。

View File

@@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
} }
android { android {
@@ -12,8 +13,8 @@ android {
applicationId = "org.noxylva.lbjconsole" applicationId = "org.noxylva.lbjconsole"
minSdk = 29 minSdk = 29
targetSdk = 35 targetSdk = 35
versionCode = 7 versionCode = 13
versionName = "0.0.7" versionName = "0.1.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -59,6 +60,7 @@ android {
} }
lint { lint {
disable += "NullSafeMutableLiveData" disable += "NullSafeMutableLiveData"
warning += "MissingPermission"
} }
} }
@@ -85,4 +87,9 @@ dependencies {
implementation("org.osmdroid:osmdroid-android:6.1.16") implementation("org.osmdroid:osmdroid-android:6.1.16")
implementation("org.osmdroid:osmdroid-mapsforge: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)
} }

View File

@@ -14,6 +14,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/> <uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>

View File

@@ -0,0 +1,142 @@
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
1 001 解放
2 003 前进
3 005 建设
4 006 KD7
5 055 蓝箭控车
6 081 东风21
7 101 东风
8 102 东风2
9 103 东风3
10 104 东风4
11 105 东风4客
12 106 东风4C
13 107 东风5
14 108 东风5宽
15 109 东风6
16 110 东风7
17 111 东风8
18 112 东风9
19 113 东风10
20 114 东方红1
21 115 东方红2
22 116 东方红3
23 117 东方红5
24 118 北京
25 119 北京宽
26 120 ND2
27 121 ND3
28 122 ND4
29 123 ND5
30 124 NY5
31 125 NY6
32 126 NY7
33 127 轻油
34 128 东方红21
35 129 东风7B
36 130 东风5S
37 131 东风7C
38 132 东风7S
39 133 工矿1
40 134 工矿1F
41 135 东风4E
42 136 东风7D
43 137 工矿1A
44 138 东风11
45 139 天安
46 140 东风10F
47 141 东风4D
48 142 东风8B
49 143 东风12
50 144 东风7E
51 145 NYJ1
52 146 NZJ1
53 147 NZJ2
54 148 东风4DJ
55 149 新曙光
56 150 神州
57 151 NJ2
58 152 东风7G
59 153 NDJ3
60 157 FXN3D
61 158 东风11G
62 160 HXN3
63 161 HXN5
64 162 HXN3B
65 163 HXN5B
66 167 FXN3B
67 169 FXN3C
68 170 FXN5C
69 171 FXN3-J
70 201 8G
71 202 8K
72 203 6G
73 204 6K
74 205 韶山1
75 206 韶山3
76 207 韶山4
77 208 韶山5
78 209 韶山6
79 210 韶山3B
80 211 韶山7
81 212 韶山8
82 213 韶山7B
83 214 韶山7C
84 215 韶山6B
85 216 韶山9
86 217 韶山7D
87 218 DJ熊猫
88 219 DJ1
89 220 DJ2
90 221 DJF
91 222 蓝箭动车
92 223 先锋号
93 224 韶山7E
94 225 韶山4G
95 226 韶山3C
96 228 天梭
97 229 DJ4和谐
98 230 KTT
99 231 HXD1
100 232 HXD2
101 233 HXD3
102 234 HXD1B
103 235 HXD2B
104 236 HXD3B
105 237 HXD1C
106 238 HXD2C
107 239 HXD3C
108 240 HXD1D
109 241 HXD2D
110 242 HXD3D
111 243 FXD1B
112 244 FXD2B
113 245 FXD1
114 246 FXD3
115 247 FXD1-J
116 248 FXD3-J
117 249 KZ25TA
118 251 KZ25TB
119 252 HXD1D-J
120 254 FXD1H
121 300 雪域神州
122 301 CRH1
123 302 CRH2
124 303 CRH3
125 305 CRH5
126 306 CRH380A
127 307 CRH380B
128 308 CRH380C
129 309 CRH380D
130 310 CRH6A
131 311 CR400AF
132 312 CR400BF
133 313 CR300AF
134 314 CR300BF
135 315 CRH2E
136 316 CRH6F
137 330 CJ1
138 331 CJ2
139 332 CJ3
140 333 CJ4
141 334 CJ5
142 335 CJ6

View 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]))$","无火回送普速客车底"
1 ^[Gg](4000|[1-3]\d{3}|[1-9]\d{0,2})$ 直通图定高速动车组
2 ^[Gg](400[1-9]|40[1-9]\d|4[1-8]\d{2}|49[0-8]\d|499[0-8])$ 直通临客高速动车组
3 ^[Gg](9000|[6-8]\d{3}|500[1-9]|50[1-9]\d|5[1-9]\d{2})$ 管内图定高速动车组
4 ^[Gg](900[1-9]|90[1-9]\d|9[1-8]\d{2}|99[0-8]\d|999[0-8])$ 管内临客高速动车组
5 ^[Cc]([1-8]\d{3}|9000)$ 图定城际动车组
6 ^[Cc](900[1-9]|90[1-9]\d|9[1-8]\d{2}|99[0-8]\d|999[0-8])$ 临客城际动车组
7 ^[Cc][1-9]\d{2}$ 动力集中城际动车组
8 ^[IDid](4000|[1-3]\d{3}|[1-9]\d{0,2})$ 直通图定动车组
9 ^[IDid](400[1-9]|40[1-9]\d|4[1-8]\d{2}|49[0-8]\d|499[0-8])$ 直通临客动车组
10 ^[IDid](9000|[6-8]\d{3}|500[1-9]|50[1-9]\d|5[1-9]\d{2})$ 管内图定动车组
11 ^[IDid](900[1-9]|90[1-9]\d|9[1-8]\d{2}|99[0-8]\d|999[0-8])$ 管内临客动车组
12 ^[IDid](8([0-8]\d|9[0-8])|7(0[1-9]|[1-9]\d))$ 动力集中动车组
13 ^[IDid](300|[12]\d{2}|[1-9]\d?)$ 跨局动力集中动车组
14 ^[PZpz](4000|[1-3]\d{3}|[1-9]\d{0,2})$ 直通图定直达特快旅客列车
15 ^[PZpz](400[1-9]|40[1-9]\d|4[1-8]\d{2}|49[0-8]\d|499[0-8])$ 直通临客直达特快旅客列车
16 ^[PZpz](9000|[6-8]\d{3}|500[1-9]|50[1-9]\d|5[1-9]\d{2})$ 管内图定直达特快旅客列车
17 ^[PZpz](900[1-9]|90[1-9]\d|9[1-8]\d{2}|99[0-8]\d|999[0-8])$ 管内临客直达特快旅客列车
18 ^[QTqt](3000|[12]\d{3}|[1-9]\d{0,2})$ 直通图定特快旅客列车
19 ^[QTqt](300[1-9]|30[1-9]\d|3[1-8]\d{2}|39[0-8]\d|399[0-8])$ 直通临客特快旅客列车
20 ^[QTqt](4(00[1-9]|0[1-9]\d|[1-8]\d{2}|9[0-8]\d|99[0-8]))$ 管内临客特快旅客列车
21 ^[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})$ 管内图定特快旅客列车
22 ^[WKwk](4000|[1-3]\d{3}|[1-9]\d{0,2})$ 直通图定快速旅客列车
23 ^[WKwk](400[1-9]|40[1-9]\d|4[1-8]\d{2}|49[0-8]\d|499[0-8])$ 直通临客快速旅客列车
24 ^[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})$ 管内临客快速旅客列车
25 ^[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}))$ 管内图定快速旅客列车
26 ^[Vv1](00[1-9]|0[1-9]\d|[1-9]\d{2})$ 跨三局及以上图定普通旅客快车
27 ^[Bb2](00[1-9]|0[1-9]\d|[1-9]\d{2})$ 跨两局图定普通旅客快车
28 ^3(00[1-9]|0[1-9]\d|[1-9]\d{2})$ 跨局临时普通旅客快车
29 ^[Uu4](00[1-9]|0[1-9]\d|[1-9]\d{2})$ 管内图定普通旅客快车
30 ^[Xx5](000|1[9][9]|200|3[9][9]|400)$ 管内图定普通旅客快车
31 ^6(19[0-8]|1[0-8]\d|0[1-9]\d|00[1-9])$ 直通普通旅客慢车
32 ^(6(20[1-9]|2[1-9]\d|[3-9]\d{2})|7([0-4]\d{2}|5([0-8]\d|9[0-8])))$ 管内普通旅客慢车
33 ^(8([0-8]\d{2}|9[0-8]\d|99[0-8])|7(60[1-9]|6[1-9]\d|[7-9]\d{2}))$ 通勤列车
34 ^[Yy](500|[1-4]\d{2}|[1-9]\d?)$ 跨局旅游列车
35 ^[Yy](50[1-9]|5[1-9]\d|[6-9]\d{2})$ 管内旅游列车
36 ^[Ss][1-9]\d{0,3}$ 市郊旅客列车
37 ^[Ll](6([0-8]\d{2}|9[0-8]\d|99[0-8])|[1-5]\d{3}|[1-9]\d{0,2})$ 直通临时旅客列车
38 ^[Ll]([7-9]\d{3})$ 管内临时旅客列车
39 ^[Xx](19[0-8]|1[0-8]\d|[1-9]\d?)$ 特快货物班列
40 ^[Xx](39[0-8]|3[0-8]\d|2[1-9]\d|20[1-9])$ 快速货物班列
41 ^[Xx]2(40[1-9]|4[1-9]\d|[5-9]\d{2})$ 直通货物快运列车
42 ^[Xx]([4-9]\d{2}|4[1-9]\d|40[1-9])$ 管内货物快运列车
43 ^[Xx]8\d{3}$ 中欧中亚集装箱班列
44 ^[Xx]9([0-4]\d{2}|500)$ 中亚集装箱班列
45 ^[Xx]9(50[1-9]|5[1-9]\d|[6-9]\d{2})$ 水铁联运班列
46 ^[Xx][1-4]\d{4}$ 加挂零散快运车辆货物列车
47 ^1(000[1-9]|00[1-9]\d|0[1-9]\d{2}|[1-9]\d{3})$ 技术直达列车
48 ^2\d{4}$ 直通货物列车
49 ^3\d{4}$ 区段摘挂列车
50 ^4([0-3]\d{3}|4([0-8]\d{2}|9[0-8]\d|99[0-8]))$ 摘挂列车
51 ^4(500[1-9]|50[1-9]\d|5[1-9]\d{2}|[6-9]\d{3})$ 小运转列车
52 ^6\d{4}$ 自备列车
53 ^70\d{3}$ 超限货物列车
54 ^7([1-6]\d{3}|7([0-8]\d{2}|9[0-8]\d|99[0-8]))$ 重载货物列车
55 ^78\d{3}$ 保温列车
56 ^8(0\d{3}|1([0-8]\d{2}|9[0-8]\d|99[0-8]))$ 普快货物班列
57 ^8(200[1-9]|20[1-9]\d|2[1-9]\d{2}|[34]\d{3})$ 煤炭直达列车
58 ^85\d{3}$ 石油直达列车
59 ^86\d{3}$ 始发直达列车
60 ^87\d{3}$ 空车直达列车
61 ^(90\d{3}|91([0-8]\d{2}|9[0-8]\d|99[0-8]))$ 军用列车
62 ^50\d{3}$ 客车单机
63 ^51\d{3}$ 货车单机
64 ^52\d{3}$ 小运转单机
65 ^5(3\d{3}|4([0-8]\d{2}|9[0-8]\d|99[0-8]))$ 补机列车
66 ^55(300|[0-2]\d{2})$ 普通客货试运转列车
67 ^55(500|30[1-9]|3[1-9]\d|4\d{2})$ 高速动车组试运转列车
68 ^55(50[1-9]|5[1-9]\d|[6-9]\d{2})$ 普通动车组试运转列车
69 ^56\d{3}$ 轻油动车与轨道车
70 ^57\d{3}$ 路用列车
71 ^58(10[1-9]|1[1-9]\d|[2-8]\d{2}|9([0-8]\d|9[0-8]))$ 救援列车
72 ^DJ(400|[1-3]\d{2}|[1-9]\d?)$ 动车组检测列车300直通
73 ^DJ([4-9]\d{2}|40[1-9]|4[1-9]\d)$ 动车组检测列车300管内
74 ^DJ1(400|[0-3]\d{2})$ 动车组检测列车250直通
75 ^DJ1(40[1-9]|4[1-9]\d|[5-9]\d{2})$ 动车组检测列车250管内
76 ^DJ[56]\d{3}$ 直通动车组确认列车
77 ^DJ[78]\d{3}$ 管内动车组确认列车
78 ^[Ff][GDCZTKgdcztk]?\d{1,4}$ 因故折返旅客列车
79 ^0[GDCZTKgdcztk]\d{1,4}$ 回送图定客车底
80 ^00(100|[1-9]\d?)$ 有火回送动车组车底
81 ^00(10[1-9]|1[1-9]\d|2([0-8]\d|9[0-8]))$ 无火回送动车组车底
82 ^00(30[1-9]|3[1-9]\d|4([0-8]\d|9[0-8]))$ 无火回送普速客车底

View File

@@ -276,6 +276,33 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
return isConnected 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") @SuppressLint("MissingPermission")
fun disconnect() { fun disconnect() {
@@ -507,7 +534,7 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
String(it, StandardCharsets.UTF_8) String(it, StandardCharsets.UTF_8)
} ?: return } ?: 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) dataBuffer.append(newData)
@@ -613,7 +640,7 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
private fun processJsonString(jsonStr: String): Boolean { private fun processJsonString(jsonStr: String): Boolean {
try { try {
val jsonObject = JSONObject(jsonStr) 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 { handler.post {

View File

@@ -52,6 +52,7 @@ import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import org.json.JSONObject import org.json.JSONObject
import org.osmdroid.config.Configuration import org.osmdroid.config.Configuration
import org.noxylva.lbjconsole.model.TrainRecord import org.noxylva.lbjconsole.model.TrainRecord
@@ -61,9 +62,12 @@ import org.noxylva.lbjconsole.ui.screens.HistoryScreen
import org.noxylva.lbjconsole.ui.screens.MapScreen import org.noxylva.lbjconsole.ui.screens.MapScreen
import org.noxylva.lbjconsole.ui.screens.SettingsScreen import org.noxylva.lbjconsole.ui.screens.SettingsScreen
import org.noxylva.lbjconsole.ui.screens.CardMapView
import org.noxylva.lbjconsole.ui.theme.LBJConsoleTheme import org.noxylva.lbjconsole.ui.theme.LBJConsoleTheme
import org.noxylva.lbjconsole.util.LocoInfoUtil import org.noxylva.lbjconsole.util.LocoInfoUtil
import org.noxylva.lbjconsole.util.TrainTypeUtil
import org.noxylva.lbjconsole.database.AppSettingsRepository
import java.util.* import java.util.*
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanCallback
@@ -74,7 +78,9 @@ class MainActivity : ComponentActivity() {
private val bleClient by lazy { BLEClient(this) } private val bleClient by lazy { BLEClient(this) }
private val trainRecordManager by lazy { TrainRecordManager(this) } private val trainRecordManager by lazy { TrainRecordManager(this) }
private val locoInfoUtil by lazy { LocoInfoUtil(this) } private val locoInfoUtil by lazy { LocoInfoUtil(this) }
private val trainTypeUtil by lazy { TrainTypeUtil(this) }
private val notificationService by lazy { NotificationService(this) } private val notificationService by lazy { NotificationService(this) }
private val appSettingsRepository by lazy { AppSettingsRepository(this) }
private var deviceStatus by mutableStateOf("未连接") private var deviceStatus by mutableStateOf("未连接")
@@ -103,23 +109,20 @@ class MainActivity : ComponentActivity() {
private var historyExpandedStates by mutableStateOf<Map<String, Boolean>>(emptyMap()) private var historyExpandedStates by mutableStateOf<Map<String, Boolean>>(emptyMap())
private var historyScrollPosition by mutableStateOf(0) private var historyScrollPosition by mutableStateOf(0)
private var historyScrollOffset by mutableStateOf(0) private var historyScrollOffset by mutableStateOf(0)
private var historyCardMapStates by mutableStateOf<Map<String, CardMapView>>(emptyMap())
private var settingsScrollPosition by mutableStateOf(0)
private var mapCenterPosition by mutableStateOf<Pair<Double, Double>?>(null) private var mapCenterPosition by mutableStateOf<Pair<Double, Double>?>(null)
private var mapZoomLevel by mutableStateOf(10.0) private var mapZoomLevel by mutableStateOf(10.0)
private var mapRailwayLayerVisible by mutableStateOf(true) private var mapRailwayLayerVisible by mutableStateOf(true)
private var settingsScrollPosition by mutableStateOf(0)
private var mergeSettings by mutableStateOf(MergeSettings()) private var mergeSettings by mutableStateOf(MergeSettings())
private var targetDeviceName = "LBJReceiver" private var targetDeviceName = "LBJReceiver"
private var specifiedDeviceAddress by mutableStateOf<String?>(null) private var specifiedDeviceAddress by mutableStateOf<String?>(null)
private var searchOrderList by mutableStateOf(listOf<String>()) private var searchOrderList by mutableStateOf(listOf<String>())
private var showDisconnectButton by mutableStateOf(false) private var showDisconnectButton by mutableStateOf(false)
private var autoConnectEnabled by mutableStateOf(true)
private val settingsPrefs by lazy { getSharedPreferences("app_settings", Context.MODE_PRIVATE) }
private fun getAppVersion(): String { private fun getAppVersion(): String {
return try { return try {
@@ -180,10 +183,10 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
TrainRecord.initializeLocoTypeUtil(this)
loadSettings() loadSettings()
val permissions = mutableListOf<String>() val permissions = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@@ -193,94 +196,24 @@ class MainActivity : ComponentActivity() {
Manifest.permission.BLUETOOTH_ADVERTISE Manifest.permission.BLUETOOTH_ADVERTISE
)) ))
} else { } else {
permissions.addAll(arrayOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_ADMIN
))
}
permissions.addAll(arrayOf( permissions.addAll(arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION Manifest.permission.ACCESS_COARSE_LOCATION
)) ))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
} }
if (permissions.isNotEmpty()) {
requestPermissions.launch(permissions.toTypedArray()) requestPermissions.launch(permissions.toTypedArray())
} else {
startAutoScanAndConnect()
}
Configuration.getInstance().userAgentValue = packageName
bleClient.setTrainInfoCallback { jsonData -> bleClient.setTrainInfoCallback { jsonData ->
handleTrainInfo(jsonData) handleTrainInfo(jsonData)
} }
bleClient.setHighFrequencyReconnect(true)
bleClient.setConnectionLostCallback {
runOnUiThread {
deviceStatus = "连接丢失,正在重连..."
showDisconnectButton = false
if (showConnectionDialog) {
foundDevices = emptyList()
startScan()
}
}
}
bleClient.setConnectionSuccessCallback { address ->
runOnUiThread {
deviceAddress = address
deviceStatus = "已连接"
showDisconnectButton = true
Log.d(TAG, "Connection success callback: address=$address")
}
}
lifecycleScope.launch {
try {
locoInfoUtil.loadLocoData()
Log.d(TAG, "Loaded locomotive data")
} catch (e: Exception) {
Log.e(TAG, "Load locomotive data failed", e)
}
}
try {
val osmCacheDir = File(cacheDir, "osm").apply { mkdirs() }
val tileCache = File(osmCacheDir, "tiles").apply { mkdirs() }
Configuration.getInstance().apply {
userAgentValue = packageName
load(this@MainActivity, getSharedPreferences("osmdroid", Context.MODE_PRIVATE))
osmdroidBasePath = osmCacheDir
osmdroidTileCache = tileCache
expirationOverrideDuration = 86400000L * 7
tileDownloadThreads = 4
tileFileSystemThreads = 4
setUserAgentValue("LBJConsole/1.0")
}
Log.d(TAG, "OSM cache configured")
} catch (e: Exception) {
Log.e(TAG, "OSM cache config failed", e)
}
saveSettings()
if (SettingsActivity.isBackgroundServiceEnabled(this)) {
BackgroundService.startService(this)
}
enableEdgeToEdge()
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = false
}
setContent { setContent {
LBJConsoleTheme { LBJConsoleTheme {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -292,6 +225,9 @@ class MainActivity : ComponentActivity() {
isScanning = isScanning, isScanning = isScanning,
currentTab = currentTab, currentTab = currentTab,
onTabChange = { tab -> onTabChange = { tab ->
if (currentTab == 2 && tab != 2) {
saveSettings()
}
currentTab = tab currentTab = tab
saveSettings() saveSettings()
}, },
@@ -313,7 +249,12 @@ class MainActivity : ComponentActivity() {
saveSettings() saveSettings()
Log.d(TAG, "Set specified device address: $address") Log.d(TAG, "Set specified device address: $address")
}, },
autoConnectEnabled = autoConnectEnabled,
onAutoConnectEnabledChange = { enabled ->
autoConnectEnabled = enabled
saveSettings()
Log.d(TAG, "Auto connect enabled: $enabled")
},
latestRecord = latestRecord, latestRecord = latestRecord,
recentRecords = recentRecords, recentRecords = recentRecords,
@@ -324,10 +265,11 @@ class MainActivity : ComponentActivity() {
}, },
onClearMonitorLog = { onClearMonitorLog = {
recentRecords.clear() recentRecords.clear()
latestRecord = null
lastUpdateTime = null
temporaryStatusMessage = null temporaryStatusMessage = null
}, },
allRecords = trainRecordManager.getMixedRecords(), allRecords = trainRecordManager.getMixedRecords(),
mergedRecords = trainRecordManager.getMergedRecords(), mergedRecords = trainRecordManager.getMergedRecords(),
recordCount = trainRecordManager.getRecordCount(), recordCount = trainRecordManager.getRecordCount(),
@@ -347,12 +289,14 @@ class MainActivity : ComponentActivity() {
historyEditMode = historyEditMode, historyEditMode = historyEditMode,
historySelectedRecords = historySelectedRecords, historySelectedRecords = historySelectedRecords,
historyExpandedStates = historyExpandedStates, historyExpandedStates = historyExpandedStates,
historyMapViewStates = historyCardMapStates,
historyScrollPosition = historyScrollPosition, historyScrollPosition = historyScrollPosition,
historyScrollOffset = historyScrollOffset, historyScrollOffset = historyScrollOffset,
onHistoryStateChange = { editMode, selectedRecords, expandedStates, scrollPosition, scrollOffset -> onHistoryStateChange = { editMode, selectedRecords, expandedStates, mapStates, scrollPosition, scrollOffset ->
historyEditMode = editMode historyEditMode = editMode
historySelectedRecords = selectedRecords historySelectedRecords = selectedRecords
historyExpandedStates = expandedStates historyExpandedStates = expandedStates
historyCardMapStates = mapStates
historyScrollPosition = scrollPosition historyScrollPosition = scrollPosition
historyScrollOffset = scrollOffset historyScrollOffset = scrollOffset
saveSettings() saveSettings()
@@ -361,6 +305,7 @@ class MainActivity : ComponentActivity() {
settingsScrollPosition = settingsScrollPosition, settingsScrollPosition = settingsScrollPosition,
onSettingsScrollPositionChange = { position -> onSettingsScrollPositionChange = { position ->
android.util.Log.d(TAG, "Settings scroll position changed: $position")
settingsScrollPosition = position settingsScrollPosition = position
saveSettings() saveSettings()
}, },
@@ -411,12 +356,15 @@ class MainActivity : ComponentActivity() {
deviceName = settingsDeviceName, deviceName = settingsDeviceName,
onDeviceNameChange = { newName -> settingsDeviceName = newName }, onDeviceNameChange = { newName -> settingsDeviceName = newName },
onApplySettings = { onApplySettings = {
saveSettings() if (targetDeviceName != settingsDeviceName) {
targetDeviceName = settingsDeviceName targetDeviceName = settingsDeviceName
Log.d(TAG, "Applied settings deviceName=${settingsDeviceName}") Log.d(TAG, "Applied settings deviceName=${settingsDeviceName}")
saveSettings()
}
}, },
appVersion = getAppVersion(), appVersion = getAppVersion(),
locoInfoUtil = locoInfoUtil, locoInfoUtil = locoInfoUtil,
trainTypeUtil = trainTypeUtil,
onOpenSettings = { onOpenSettings = {
val intent = Intent(this@MainActivity, SettingsActivity::class.java) val intent = Intent(this@MainActivity, SettingsActivity::class.java)
startActivity(intent) startActivity(intent)
@@ -473,7 +421,6 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
} }
} }
@@ -556,7 +503,7 @@ class MainActivity : ComponentActivity() {
private fun handleTrainInfo(jsonData: JSONObject) { private fun handleTrainInfo(jsonData: JSONObject) {
Log.d(TAG, "Received train data=${jsonData.toString().take(50)}...") Log.d(TAG, "Received train data=${jsonData.toString()}...")
runOnUiThread { runOnUiThread {
try { try {
@@ -615,6 +562,11 @@ class MainActivity : ComponentActivity() {
private fun startAutoScanAndConnect() { private fun startAutoScanAndConnect() {
if (!autoConnectEnabled) {
Log.d(TAG, "Auto connect disabled, skipping auto scan")
return
}
Log.d(TAG, "Starting auto scan and connect") Log.d(TAG, "Starting auto scan and connect")
if (!hasBluetoothPermissions()) { if (!hasBluetoothPermissions()) {
@@ -726,82 +678,99 @@ class MainActivity : ComponentActivity() {
private fun loadSettings() { private fun loadSettings() {
settingsDeviceName = settingsPrefs.getString("device_name", "LBJReceiver") ?: "LBJReceiver" lifecycleScope.launch {
targetDeviceName = settingsDeviceName try {
val settings = appSettingsRepository.getSettings()
settingsDeviceName = settings.deviceName
targetDeviceName = settings.deviceName
currentTab = settings.currentTab
historyEditMode = settings.historyEditMode
currentTab = settingsPrefs.getInt("current_tab", 0) historySelectedRecords = if (settings.historySelectedRecords.isEmpty()) {
historyEditMode = settingsPrefs.getBoolean("history_edit_mode", false)
val selectedRecordsStr = settingsPrefs.getString("history_selected_records", "")
historySelectedRecords = if (selectedRecordsStr.isNullOrEmpty()) {
emptySet() emptySet()
} else { } else {
selectedRecordsStr.split(",").toSet() settings.historySelectedRecords.split(",").toSet()
} }
val expandedStatesStr = settingsPrefs.getString("history_expanded_states", "") historyExpandedStates = if (settings.historyExpandedStates.isEmpty()) {
historyExpandedStates = if (expandedStatesStr.isNullOrEmpty()) {
emptyMap() emptyMap()
} else { } else {
expandedStatesStr.split(";").mapNotNull { pair -> settings.historyExpandedStates.split(";").mapNotNull { pair ->
val parts = pair.split(":") val parts = pair.split(":")
if (parts.size == 2) parts[0] to (parts[1] == "true") else null if (parts.size == 2) parts[0] to (parts[1] == "true") else null
}.toMap() }.toMap()
} }
historyScrollPosition = settingsPrefs.getInt("history_scroll_position", 0) historyScrollPosition = settings.historyScrollPosition
historyScrollOffset = settingsPrefs.getInt("history_scroll_offset", 0) historyScrollOffset = settings.historyScrollOffset
settingsScrollPosition = settingsPrefs.getInt("settings_scroll_position", 0) settingsScrollPosition = settings.settingsScrollPosition
android.util.Log.d(TAG, "Loaded settings scroll position: $settingsScrollPosition")
val centerLat = settingsPrefs.getFloat("map_center_lat", Float.NaN) mapCenterPosition = if (settings.mapCenterLat != null && settings.mapCenterLon != null) {
val centerLon = settingsPrefs.getFloat("map_center_lon", Float.NaN) settings.mapCenterLat.toDouble() to settings.mapCenterLon.toDouble()
mapCenterPosition = if (!centerLat.isNaN() && !centerLon.isNaN()) {
centerLat.toDouble() to centerLon.toDouble()
} else null } else null
mapZoomLevel = settingsPrefs.getFloat("map_zoom_level", 10.0f).toDouble() mapZoomLevel = settings.mapZoomLevel.toDouble()
mapRailwayLayerVisible = settingsPrefs.getBoolean("map_railway_visible", true) mapRailwayLayerVisible = settings.mapRailwayLayerVisible
mergeSettings = trainRecordManager.mergeSettings mergeSettings = trainRecordManager.mergeSettings
specifiedDeviceAddress = settingsPrefs.getString("specified_device_address", null) specifiedDeviceAddress = settings.specifiedDeviceAddress
val searchOrderStr = settingsPrefs.getString("search_order_list", "") searchOrderList = if (settings.searchOrderList.isEmpty()) {
searchOrderList = if (searchOrderStr.isNullOrEmpty()) {
emptyList() emptyList()
} else { } else {
searchOrderStr.split(",").filter { it.isNotBlank() } settings.searchOrderList.split(",").filter { it.isNotBlank() }
} }
autoConnectEnabled = settings.autoConnectEnabled
bleClient.setSpecifiedDeviceAddress(specifiedDeviceAddress) bleClient.setSpecifiedDeviceAddress(specifiedDeviceAddress)
Log.d(TAG, "Loaded settings deviceName=${settingsDeviceName} tab=${currentTab} specifiedDevice=${specifiedDeviceAddress} searchOrder=${searchOrderList.size}") Log.d(TAG, "Loaded settings from Room: deviceName=${settingsDeviceName} tab=${currentTab} specifiedDevice=${specifiedDeviceAddress} searchOrder=${searchOrderList.size} autoConnect=${autoConnectEnabled}")
} catch (e: Exception) {
Log.e(TAG, "Error loading settings from Room", e)
}
}
} }
private fun saveSettings() { private fun saveSettings() {
val editor = settingsPrefs.edit() lifecycleScope.launch(Dispatchers.IO) {
.putString("device_name", settingsDeviceName) try {
.putInt("current_tab", currentTab) val currentSettings = appSettingsRepository.getSettings()
.putBoolean("history_edit_mode", historyEditMode) val updatedSettings = currentSettings.copy(
.putString("history_selected_records", historySelectedRecords.joinToString(",")) deviceName = settingsDeviceName,
.putString("history_expanded_states", historyExpandedStates.map { "${it.key}:${it.value}" }.joinToString(";")) currentTab = currentTab,
.putInt("history_scroll_position", historyScrollPosition) historyEditMode = historyEditMode,
.putInt("history_scroll_offset", historyScrollOffset) historySelectedRecords = historySelectedRecords.joinToString(","),
.putInt("settings_scroll_position", settingsScrollPosition) historyExpandedStates = historyExpandedStates.map { "${it.key}:${it.value}" }.joinToString(";"),
.putFloat("map_zoom_level", mapZoomLevel.toFloat()) historyScrollPosition = historyScrollPosition,
.putBoolean("map_railway_visible", mapRailwayLayerVisible) historyScrollOffset = historyScrollOffset,
.putString("specified_device_address", specifiedDeviceAddress) settingsScrollPosition = settingsScrollPosition,
.putString("search_order_list", searchOrderList.joinToString(",")) mapCenterLat = mapCenterPosition?.first?.toFloat(),
mapCenterLon = mapCenterPosition?.second?.toFloat(),
mapZoomLevel = mapZoomLevel.toFloat(),
mapRailwayLayerVisible = mapRailwayLayerVisible,
specifiedDeviceAddress = specifiedDeviceAddress,
searchOrderList = searchOrderList.joinToString(","),
autoConnectEnabled = autoConnectEnabled
)
mapCenterPosition?.let { (lat, lon) -> appSettingsRepository.saveSettings(updatedSettings)
editor.putFloat("map_center_lat", lat.toFloat()) Log.d(TAG, "Saved settings to Room: deviceName=${settingsDeviceName} tab=${currentTab} settingsScrollPosition=${settingsScrollPosition} mapCenter=${mapCenterPosition} zoom=${mapZoomLevel}")
editor.putFloat("map_center_lon", lon.toFloat()) } catch (e: Exception) {
Log.e(TAG, "Error saving settings to Room", e)
}
}
} }
editor.apply() override fun onNewIntent(intent: Intent) {
Log.d(TAG, "Saved settings deviceName=${settingsDeviceName} tab=${currentTab} mapCenter=${mapCenterPosition} zoom=${mapZoomLevel}") super.onNewIntent(intent)
Log.d(TAG, "onNewIntent called")
currentTab = 0
forceUiRefresh()
} }
override fun onResume() { override fun onResume() {
@@ -810,12 +779,20 @@ class MainActivity : ComponentActivity() {
bleClient.setHighFrequencyReconnect(true) bleClient.setHighFrequencyReconnect(true)
if (hasBluetoothPermissions() && !bleClient.isConnected()) { if (hasBluetoothPermissions()) {
Log.d(TAG, "App resumed and not connected, starting auto scan") val actuallyConnected = bleClient.checkActualConnectionState()
startAutoScanAndConnect()
} else if (bleClient.isConnected()) { if (actuallyConnected) {
showDisconnectButton = true showDisconnectButton = true
deviceStatus = "已连接" deviceStatus = "已连接"
Log.d(TAG, "App resumed - connection verified")
} else if (autoConnectEnabled) {
Log.d(TAG, "App resumed and not connected, starting auto scan")
startAutoScanAndConnect()
} else {
deviceStatus = "未连接"
showDisconnectButton = false
}
} }
} }
@@ -847,6 +824,8 @@ fun MainContent(
specifiedDeviceAddress: String?, specifiedDeviceAddress: String?,
searchOrderList: List<String>, searchOrderList: List<String>,
onSpecifiedDeviceSelected: (String?) -> Unit, onSpecifiedDeviceSelected: (String?) -> Unit,
autoConnectEnabled: Boolean,
onAutoConnectEnabledChange: (Boolean) -> Unit,
latestRecord: TrainRecord?, latestRecord: TrainRecord?,
@@ -879,14 +858,16 @@ fun MainContent(
locoInfoUtil: LocoInfoUtil, locoInfoUtil: LocoInfoUtil,
trainTypeUtil: TrainTypeUtil,
historyEditMode: Boolean, historyEditMode: Boolean,
historySelectedRecords: Set<String>, historySelectedRecords: Set<String>,
historyExpandedStates: Map<String, Boolean>, historyExpandedStates: Map<String, Boolean>,
historyMapViewStates: Map<String, CardMapView>,
historyScrollPosition: Int, historyScrollPosition: Int,
historyScrollOffset: Int, historyScrollOffset: Int,
onHistoryStateChange: (Boolean, Set<String>, Map<String, Boolean>, Int, Int) -> Unit, onHistoryStateChange: (Boolean, Set<String>, Map<String, Boolean>, Map<String, CardMapView>, Int, Int) -> Unit,
settingsScrollPosition: Int, settingsScrollPosition: Int,
@@ -992,7 +973,7 @@ fun MainContent(
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = { IconButton(onClick = {
onHistoryStateChange(false, emptySet(), historyExpandedStates, historyScrollPosition, historyScrollOffset) onHistoryStateChange(false, emptySet(), historyExpandedStates, historyMapViewStates, historyScrollPosition, historyScrollOffset)
}) { }) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
@@ -1038,7 +1019,7 @@ fun MainContent(
} }
onDeleteRecords(recordsToDelete.toList()) onDeleteRecords(recordsToDelete.toList())
onHistoryStateChange(false, emptySet(), historyExpandedStates, historyScrollPosition, historyScrollOffset) onHistoryStateChange(false, emptySet(), historyExpandedStates, historyMapViewStates, historyScrollPosition, historyScrollOffset)
} }
} }
) { ) {
@@ -1094,6 +1075,7 @@ fun MainContent(
lastUpdateTime = lastUpdateTime, lastUpdateTime = lastUpdateTime,
temporaryStatusMessage = temporaryStatusMessage, temporaryStatusMessage = temporaryStatusMessage,
locoInfoUtil = locoInfoUtil, locoInfoUtil = locoInfoUtil,
trainTypeUtil = trainTypeUtil,
mergeSettings = mergeSettings, mergeSettings = mergeSettings,
onClearRecords = onClearRecords, onClearRecords = onClearRecords,
onRecordClick = onRecordClick, onRecordClick = onRecordClick,
@@ -1102,6 +1084,7 @@ fun MainContent(
editMode = historyEditMode, editMode = historyEditMode,
selectedRecords = historySelectedRecords, selectedRecords = historySelectedRecords,
expandedStates = historyExpandedStates, expandedStates = historyExpandedStates,
mapViewStates = historyMapViewStates,
scrollPosition = historyScrollPosition, scrollPosition = historyScrollPosition,
scrollOffset = historyScrollOffset, scrollOffset = historyScrollOffset,
onStateChange = onHistoryStateChange onStateChange = onHistoryStateChange
@@ -1118,7 +1101,9 @@ fun MainContent(
onScrollPositionChange = onSettingsScrollPositionChange, onScrollPositionChange = onSettingsScrollPositionChange,
specifiedDeviceAddress = specifiedDeviceAddress, specifiedDeviceAddress = specifiedDeviceAddress,
searchOrderList = searchOrderList, searchOrderList = searchOrderList,
onSpecifiedDeviceSelected = onSpecifiedDeviceSelected onSpecifiedDeviceSelected = onSpecifiedDeviceSelected,
autoConnectEnabled = autoConnectEnabled,
onAutoConnectEnabledChange = onAutoConnectEnabledChange
) )
3 -> MapScreen( 3 -> MapScreen(
records = if (allRecords.isNotEmpty()) { records = if (allRecords.isNotEmpty()) {

View File

@@ -5,14 +5,19 @@ import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import kotlinx.coroutines.runBlocking
import org.noxylva.lbjconsole.database.AppSettingsRepository
import org.noxylva.lbjconsole.database.TrainDatabase
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import android.view.View
import android.widget.RemoteViews
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import org.json.JSONObject import org.json.JSONObject
import org.noxylva.lbjconsole.model.TrainRecord import org.noxylva.lbjconsole.model.TrainRecord
class NotificationService(private val context: Context) { class NotificationService(private val context: Context) {
companion object { companion object {
const val TAG = "NotificationService" const val TAG = "NotificationService"
@@ -24,7 +29,7 @@ class NotificationService(private val context: Context) {
} }
private val notificationManager = NotificationManagerCompat.from(context) private val notificationManager = NotificationManagerCompat.from(context)
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val appSettingsRepository = AppSettingsRepository(context)
private var notificationIdCounter = NOTIFICATION_ID_BASE private var notificationIdCounter = NOTIFICATION_ID_BASE
init { init {
@@ -50,11 +55,15 @@ class NotificationService(private val context: Context) {
} }
fun isNotificationEnabled(): Boolean { fun isNotificationEnabled(): Boolean {
return prefs.getBoolean(KEY_ENABLED, false) return runBlocking {
appSettingsRepository.getSettings().notificationEnabled
}
} }
fun setNotificationEnabled(enabled: Boolean) { fun setNotificationEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_ENABLED, enabled).apply() runBlocking {
appSettingsRepository.updateNotificationEnabled(enabled)
}
Log.d(TAG, "Notification enabled set to: $enabled") Log.d(TAG, "Notification enabled set to: $enabled")
} }
@@ -86,12 +95,7 @@ class NotificationService(private val context: Context) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
val directionText = when (trainRecord.direction) { val remoteViews = RemoteViews(context.packageName, R.layout.notification_train_record)
1 -> "下行"
3 -> "上行"
else -> "未知"
}
val trainDisplay = if (isValidValue(trainRecord.lbjClass) && isValidValue(trainRecord.train)) { val trainDisplay = if (isValidValue(trainRecord.lbjClass) && isValidValue(trainRecord.train)) {
"${trainRecord.lbjClass.trim()}${trainRecord.train.trim()}" "${trainRecord.lbjClass.trim()}${trainRecord.train.trim()}"
} else if (isValidValue(trainRecord.lbjClass)) { } else if (isValidValue(trainRecord.lbjClass)) {
@@ -99,26 +103,83 @@ class NotificationService(private val context: Context) {
} else if (isValidValue(trainRecord.train)) { } else if (isValidValue(trainRecord.train)) {
trainRecord.train.trim() trainRecord.train.trim()
} else "列车" } 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)
}
val title = trainDisplay
val content = buildString {
append(directionText)
if (isValidValue(trainRecord.route)) { if (isValidValue(trainRecord.route)) {
append("\n线路: ${trainRecord.route.trim()}") remoteViews.setTextViewText(R.id.notification_route, trainRecord.route.trim())
} remoteViews.setViewVisibility(R.id.notification_route, View.VISIBLE)
if (isValidValue(trainRecord.speed)) { } else {
append("\n速度: ${trainRecord.speed.trim()} km/h") remoteViews.setViewVisibility(R.id.notification_route, View.GONE)
} }
if (isValidValue(trainRecord.position)) { if (isValidValue(trainRecord.position)) {
append("\n位置: ${trainRecord.position.trim()} km") 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) val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title) .setContentTitle(trainDisplay)
.setContentText(content) .setContentText(summaryText)
.setStyle(NotificationCompat.BigTextStyle().bigText(content)) .setCustomContentView(remoteViews)
.setCustomBigContentView(remoteViews)
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.setAutoCancel(true) .setAutoCancel(true)
@@ -131,7 +192,7 @@ class NotificationService(private val context: Context) {
} }
notificationManager.notify(notificationId, notification) notificationManager.notify(notificationId, notification)
Log.d(TAG, "Notification sent for train: ${trainRecord.train}") Log.d(TAG, "Custom notification sent for train: ${trainRecord.train}")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to show notification: ${e.message}", e) Log.e(TAG, "Failed to show notification: ${e.message}", e)

View File

@@ -1,30 +1,29 @@
package org.noxylva.lbjconsole package org.noxylva.lbjconsole
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.widget.Switch import android.widget.Switch
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.noxylva.lbjconsole.database.AppSettingsRepository
class SettingsActivity : AppCompatActivity() { class SettingsActivity : AppCompatActivity() {
companion object { companion object {
private const val PREFS_NAME = "lbj_console_settings" suspend fun isBackgroundServiceEnabled(context: Context): Boolean {
private const val KEY_BACKGROUND_SERVICE = "background_service_enabled" val repository = AppSettingsRepository(context)
return repository.getSettings().backgroundServiceEnabled
fun isBackgroundServiceEnabled(context: Context): Boolean {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(KEY_BACKGROUND_SERVICE, false)
} }
fun setBackgroundServiceEnabled(context: Context, enabled: Boolean) { suspend fun setBackgroundServiceEnabled(context: Context, enabled: Boolean) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val repository = AppSettingsRepository(context)
prefs.edit().putBoolean(KEY_BACKGROUND_SERVICE, enabled).apply() repository.updateBackgroundServiceEnabled(enabled)
} }
} }
private lateinit var backgroundServiceSwitch: Switch private lateinit var backgroundServiceSwitch: Switch
private lateinit var prefs: SharedPreferences private lateinit var appSettingsRepository: AppSettingsRepository
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -33,7 +32,7 @@ class SettingsActivity : AppCompatActivity() {
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.title = "Settings" supportActionBar?.title = "Settings"
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) appSettingsRepository = AppSettingsRepository(this)
initViews() initViews()
setupListeners() setupListeners()
@@ -41,12 +40,16 @@ class SettingsActivity : AppCompatActivity() {
private fun initViews() { private fun initViews() {
backgroundServiceSwitch = findViewById(R.id.switch_background_service) backgroundServiceSwitch = findViewById(R.id.switch_background_service)
backgroundServiceSwitch.isChecked = isBackgroundServiceEnabled(this) lifecycleScope.launch {
backgroundServiceSwitch.isChecked = isBackgroundServiceEnabled(this@SettingsActivity)
}
} }
private fun setupListeners() { private fun setupListeners() {
backgroundServiceSwitch.setOnCheckedChangeListener { _, isChecked -> backgroundServiceSwitch.setOnCheckedChangeListener { _, isChecked ->
setBackgroundServiceEnabled(this, isChecked) lifecycleScope.launch {
setBackgroundServiceEnabled(this@SettingsActivity, isChecked)
}
if (isChecked) { if (isChecked) {
BackgroundService.startService(this) BackgroundService.startService(this)

View File

@@ -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)
}
}

View File

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

View File

@@ -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()
}
}

View File

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

View File

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

View File

@@ -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
)
}
}
}

View File

@@ -7,9 +7,10 @@ data class MergeSettings(
) )
enum class GroupBy(val displayName: String) { enum class GroupBy(val displayName: String) {
TRAIN_AND_LOCO("车次号+机车号"), TRAIN_ONLY("车次号"),
TRAIN_ONLY("仅车次"), LOCO_ONLY("机车"),
LOCO_ONLY("机车号") TRAIN_OR_LOCO("车次号或机车号"),
TRAIN_AND_LOCO("车次号与机车号")
} }
enum class TimeWindow(val displayName: String, val seconds: Long?) { enum class TimeWindow(val displayName: String, val seconds: Long?) {
@@ -23,14 +24,6 @@ enum class TimeWindow(val displayName: String, val seconds: Long?) {
fun generateGroupKey(record: TrainRecord, groupBy: GroupBy): String? { fun generateGroupKey(record: TrainRecord, groupBy: GroupBy): String? {
return when (groupBy) { return when (groupBy) {
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
}
GroupBy.TRAIN_ONLY -> { GroupBy.TRAIN_ONLY -> {
val train = record.train.trim() val train = record.train.trim()
if (train.isNotEmpty() && train != "<NUL>") train else null if (train.isNotEmpty() && train != "<NUL>") train else null
@@ -39,5 +32,22 @@ fun generateGroupKey(record: TrainRecord, groupBy: GroupBy): String? {
val loco = record.loco.trim() val loco = record.loco.trim()
if (loco.isNotEmpty() && loco != "<NUL>") loco else null 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
}
} }
} }

View File

@@ -1,20 +1,29 @@
package org.noxylva.lbjconsole.model package org.noxylva.lbjconsole.model
import android.content.Context
import android.util.Log import android.util.Log
import org.json.JSONObject import org.json.JSONObject
import java.util.* import java.util.*
import org.osmdroid.util.GeoPoint 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) { class TrainRecord(jsonData: JSONObject? = null) {
companion object { companion object {
const val TAG = "TrainRecord" const val TAG = "TrainRecord"
private var nextId = 0L private var nextId = 0L
private var LocoTypeUtil: LocoTypeUtil? = null
@Synchronized @Synchronized
private fun generateUniqueId(): String { private fun generateUniqueId(): String {
return "${System.currentTimeMillis()}_${++nextId}" return "${System.currentTimeMillis()}_${++nextId}"
} }
fun initializeLocoTypeUtil(context: Context) {
if (LocoTypeUtil == null) {
LocoTypeUtil = LocoTypeUtil(context)
}
}
} }
val uniqueId: String val uniqueId: String
@@ -75,20 +84,25 @@ class TrainRecord(jsonData: JSONObject? = null) {
position = jsonData.optString("pos", "") position = jsonData.optString("pos", "")
time = jsonData.optString("time", "") time = jsonData.optString("time", "")
loco = jsonData.optString("loco", "") 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", "") lbjClass = jsonData.optString("lbj_class", "")
route = jsonData.optString("route", "") route = jsonData.optString("route", "")
positionInfo = jsonData.optString("position_info", "") positionInfo = jsonData.optString("position_info", "")
rssi = jsonData.optDouble("rssi", 0.0) rssi = jsonData.optDouble("rssi", 0.0)
_coordinates = null _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) { } catch (e: Exception) {
Log.e(TAG, "JSON parse error: ${e.message}", e) Log.e(TAG, "JSON parse error: ${e.message}", e)
try { train = jsonData.optString("train", "") } catch (e: Exception) { } try { train = jsonData.optString("train", "") } catch (e: Exception) { }
try { direction = jsonData.optInt("dir", 0) } catch (e: Exception) { } try { direction = jsonData.optInt("dir", 0) } catch (e: Exception) { }
try { speed = jsonData.optString("speed", "") } catch (e: Exception) { } try { speed = jsonData.optString("speed", "") } catch (e: Exception) { }
@@ -107,7 +121,7 @@ class TrainRecord(jsonData: JSONObject? = null) {
} }
_coordinates = LocationUtils.parsePositionInfo(positionInfo) _coordinates = LocationUtil.parsePositionInfo(positionInfo)
return _coordinates return _coordinates
} }
private fun isValidValue(value: String): Boolean { private fun isValidValue(value: String): Boolean {
@@ -134,7 +148,7 @@ class TrainRecord(jsonData: JSONObject? = null) {
lbjClass.trim() lbjClass.trim()
} else if (isValidValue(train)) { } else if (isValidValue(train)) {
train.trim() train.trim()
} else "" } else null
val map = mutableMapOf<String, String>() val map = mutableMapOf<String, String>()
@@ -143,14 +157,17 @@ class TrainRecord(jsonData: JSONObject? = null) {
map["receivedTimestamp"] = dateFormat.format(receivedTimestamp) 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 (directionText != "未知") map["direction"] = directionText
if (isValidValue(speed)) map["speed"] = "速度: ${speed.trim()} km/h" if (isValidValue(speed)) map["speed"] = "${speed.trim()} km/h"
if (isValidValue(position)) map["position"] = "位置: ${position.trim()} km" if (isValidValue(position)) {
map["position"] = "${position.trim().removeSuffix(".")} K"
}
val timeToDisplay = if (showDetailedTime) { val timeToDisplay = if (showDetailedTime) {
val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault()) val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault())
if (isValidValue(time)) { if (isValidValue(time)) {
"列车时间: $time\n接收时间: ${dateFormat.format(receivedTimestamp)}" "$time\n${dateFormat.format(receivedTimestamp)}"
} else { } else {
dateFormat.format(receivedTimestamp) dateFormat.format(receivedTimestamp)
} }
@@ -164,13 +181,13 @@ class TrainRecord(jsonData: JSONObject? = null) {
} }
} }
map["time"] = timeToDisplay map["time"] = timeToDisplay
if (isValidValue(loco)) map["loco"] = "机车号: ${loco.trim()}" if (isValidValue(loco)) map["loco"] = "${loco.trim()}"
if (isValidValue(locoType)) map["loco_type"] = "型号: ${locoType.trim()}" if (isValidValue(locoType)) map["loco_type"] = "${locoType.trim()}"
if (isValidValue(route)) map["route"] = "线路: ${route.trim()}" if (isValidValue(route)) map["route"] = "${route.trim()}"
if (isValidValue(positionInfo) && !positionInfo.trim().matches(Regex(".*(<NUL>|\\s)*.*"))) { 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 return map
} }

View File

@@ -7,6 +7,8 @@ import android.util.Log
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.noxylva.lbjconsole.database.TrainDatabase
import org.noxylva.lbjconsole.database.TrainRecordEntity
import java.io.File import java.io.File
import java.io.FileWriter import java.io.FileWriter
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -27,6 +29,8 @@ class TrainRecordManager(private val context: Context) {
private val trainRecords = CopyOnWriteArrayList<TrainRecord>() private val trainRecords = CopyOnWriteArrayList<TrainRecord>()
private val recordCount = AtomicInteger(0) private val recordCount = AtomicInteger(0)
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) 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()) private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
var mergeSettings = MergeSettings() var mergeSettings = MergeSettings()
@@ -34,11 +38,36 @@ class TrainRecordManager(private val context: Context) {
init { init {
ioScope.launch { ioScope.launch {
migrateFromSharedPreferences()
loadRecords() loadRecords()
loadMergeSettings() 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}")
}
}
private var filterTrain: String = "" private var filterTrain: String = ""
private var filterRoute: String = "" private var filterRoute: String = ""
@@ -52,11 +81,16 @@ class TrainRecordManager(private val context: Context) {
while (trainRecords.size > MAX_RECORDS) { 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() recordCount.incrementAndGet()
saveRecords() ioScope.launch {
trainRecordDao.insertRecord(TrainRecordEntity.fromTrainRecord(record))
}
return record return record
} }
@@ -76,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 { private fun matchFilter(record: TrainRecord): Boolean {
@@ -118,32 +162,56 @@ 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() { fun clearRecords() {
trainRecords.clear() trainRecords.clear()
recordCount.set(0) recordCount.set(0)
saveRecords() ioScope.launch {
trainRecordDao.deleteAllRecords()
}
} }
fun deleteRecord(record: TrainRecord): Boolean { fun deleteRecord(record: TrainRecord): Boolean {
val result = trainRecords.remove(record) val result = trainRecords.remove(record)
if (result) { if (result) {
recordCount.decrementAndGet() recordCount.decrementAndGet()
saveRecords() ioScope.launch {
trainRecordDao.deleteRecordById(record.uniqueId)
}
} }
return result return result
} }
fun deleteRecords(records: List<TrainRecord>): Int { fun deleteRecords(records: List<TrainRecord>): Int {
var deletedCount = 0 var deletedCount = 0
val idsToDelete = mutableListOf<String>()
records.forEach { record -> records.forEach { record ->
if (trainRecords.remove(record)) { if (trainRecords.remove(record)) {
deletedCount++ deletedCount++
idsToDelete.add(record.uniqueId)
} }
} }
if (deletedCount > 0) { if (deletedCount > 0) {
recordCount.addAndGet(-deletedCount) recordCount.addAndGet(-deletedCount)
saveRecords() ioScope.launch {
trainRecordDao.deleteRecordsByIds(idsToDelete)
}
} }
return deletedCount return deletedCount
} }
@@ -151,12 +219,9 @@ class TrainRecordManager(private val context: Context) {
private fun saveRecords() { private fun saveRecords() {
ioScope.launch { ioScope.launch {
try { try {
val jsonArray = JSONArray() val entities = trainRecords.map { TrainRecordEntity.fromTrainRecord(it) }
for (record in trainRecords) { trainRecordDao.insertRecords(entities)
jsonArray.put(record.toJSON()) Log.d(TAG, "Saved ${trainRecords.size} records to database")
}
prefs.edit().putString(KEY_RECORDS, jsonArray.toString()).apply()
Log.d(TAG, "Saved ${trainRecords.size} records")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to save records: ${e.message}") Log.e(TAG, "Failed to save records: ${e.message}")
} }
@@ -164,19 +229,17 @@ class TrainRecordManager(private val context: Context) {
} }
private fun loadRecords() { private suspend fun loadRecords() {
try { try {
val jsonStr = prefs.getString(KEY_RECORDS, "[]") val entities = trainRecordDao.getAllRecords()
val jsonArray = JSONArray(jsonStr)
trainRecords.clear() trainRecords.clear()
for (i in 0 until jsonArray.length()) { entities.forEach { entity ->
val jsonObject = jsonArray.getJSONObject(i) trainRecords.add(entity.toTrainRecord())
trainRecords.add(TrainRecord(jsonObject))
} }
recordCount.set(trainRecords.size) recordCount.set(trainRecords.size)
Log.d(TAG, "Loaded ${trainRecords.size} records") Log.d(TAG, "Loaded ${trainRecords.size} records from database")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to load records: ${e.message}") Log.e(TAG, "Failed to load records: ${e.message}")
} }
@@ -234,29 +297,93 @@ class TrainRecordManager(private val context: Context) {
} }
private fun processRecordsForMerging(records: List<TrainRecord>, settings: MergeSettings): List<MergedTrainRecord> { private fun processRecordsForMerging(records: List<TrainRecord>, settings: MergeSettings): List<MergedTrainRecord> {
val groupedRecords = mutableMapOf<String, MutableList<TrainRecord>>() val validRecords = settings.timeWindow.seconds?.let { windowSeconds ->
val currentTime = Date() val currentTime = Date()
records.filter { record ->
(currentTime.time - record.timestamp.time) / 1000 <= windowSeconds
}
} ?: records
records.forEach { record -> 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) val groupKey = generateGroupKey(record, settings.groupBy)
if (groupKey != null) { if (groupKey != null) {
val withinTimeWindow = settings.timeWindow.seconds?.let { windowSeconds ->
(currentTime.time - record.timestamp.time) / 1000 <= windowSeconds
} ?: true
if (withinTimeWindow) {
groupedRecords.getOrPut(groupKey) { mutableListOf() }.add(record) groupedRecords.getOrPut(groupKey) { mutableListOf() }.add(record)
} }
} }
}
return groupedRecords.mapNotNull { (groupKey, groupRecords) -> groupedRecords.mapNotNull { (groupKey, groupRecords) ->
if (groupRecords.size >= 2) { if (groupRecords.size >= 2) {
val sortedRecords = groupRecords.sortedBy { it.timestamp } val latestRecord = if (groupRecords.size > 1) {
val latestRecord = sortedRecords.maxByOrNull { it.timestamp }!! groupRecords.maxByOrNull { it.timestamp } ?: groupRecords.last()
} else {
groupRecords.last()
}
MergedTrainRecord( MergedTrainRecord(
groupKey = groupKey, groupKey = groupKey,
records = sortedRecords, 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 latestRecord = latestRecord
) )
} else null } else null
@@ -296,4 +423,41 @@ class TrainRecordManager(private val context: Context) {
mergeSettings = MergeSettings() 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
}
} }

View File

@@ -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
)
}
}

View File

@@ -1,5 +1,6 @@
package org.noxylva.lbjconsole.ui.screens package org.noxylva.lbjconsole.ui.screens
import android.util.Log
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -41,8 +42,15 @@ import org.noxylva.lbjconsole.model.MergedTrainRecord
import org.noxylva.lbjconsole.model.MergeSettings import org.noxylva.lbjconsole.model.MergeSettings
import org.noxylva.lbjconsole.model.GroupBy import org.noxylva.lbjconsole.model.GroupBy
import org.noxylva.lbjconsole.util.LocoInfoUtil import org.noxylva.lbjconsole.util.LocoInfoUtil
import org.noxylva.lbjconsole.util.TrainTypeUtil
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import androidx.compose.ui.platform.LocalContext
data class CardMapView(val center: GeoPoint, val zoom: Double)
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@@ -53,6 +61,7 @@ fun TrainRecordItem(
expandedStatesMap: MutableMap<String, Boolean>, expandedStatesMap: MutableMap<String, Boolean>,
latestRecord: TrainRecord?, latestRecord: TrainRecord?,
locoInfoUtil: LocoInfoUtil?, locoInfoUtil: LocoInfoUtil?,
trainTypeUtil: TrainTypeUtil?,
onRecordClick: (TrainRecord) -> Unit, onRecordClick: (TrainRecord) -> Unit,
onToggleSelection: (TrainRecord) -> Unit, onToggleSelection: (TrainRecord) -> Unit,
onLongClick: (TrainRecord) -> Unit, onLongClick: (TrainRecord) -> Unit,
@@ -61,6 +70,8 @@ fun TrainRecordItem(
val recordId = record.uniqueId val recordId = record.uniqueId
val isExpanded = expandedStatesMap[recordId] == true val isExpanded = expandedStatesMap[recordId] == true
val cardColor = when { val cardColor = when {
isSelected -> MaterialTheme.colorScheme.primaryContainer isSelected -> MaterialTheme.colorScheme.primaryContainer
else -> MaterialTheme.colorScheme.surface else -> MaterialTheme.colorScheme.surface
@@ -129,32 +140,53 @@ fun TrainRecordItem(
} }
} }
val trainType = if (record.train?.trim().isNullOrEmpty()) {
null
} else {
val lbjClassValue = record.lbjClass?.trim() ?: "NA"
trainTypeUtil?.getTrainType(lbjClassValue, record.train!!.trim())
}
if (!trainType.isNullOrEmpty()) {
Text( Text(
text = "${record.rssi} dBm", text = trainType,
fontSize = 10.sp, fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
}
Spacer(modifier = Modifier.height(2.dp)) val hasTrainDisplay = recordMap["train"]?.toString()?.isNotEmpty() ?: false
val hasRouteOrPosition = record.route.trim().isNotEmpty() && !record.route.trim().all { it == '*' } ||
record.position.trim().isNotEmpty() && !record.position.trim().all { it == '-' || it == '.' } && record.position.trim() != "<NUL>"
val hasSpeed = record.speed.trim().isNotEmpty() &&
!record.speed.trim().all { it == '*' || it == '-' } &&
record.speed.trim() != "NUL" && record.speed.trim() != "<NUL>"
val hasLocoInfo = locoInfoUtil != null && record.locoType.isNotEmpty() && record.loco.isNotEmpty() &&
locoInfoUtil.getLocoInfoDisplay(record.locoType, record.loco) != null
val shouldShowOnlyTime = !hasTrainDisplay && !hasRouteOrPosition && !hasSpeed && !hasLocoInfo
Spacer(modifier = Modifier.height(if (shouldShowOnlyTime) 0.dp else 2.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
val trainDisplay = recordMap["train"]?.toString() ?: "未知列车" val trainDisplay = recordMap["train"]?.toString() ?: ""
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp) horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
if (trainDisplay.isNotEmpty()) {
Text( Text(
text = trainDisplay, text = trainDisplay,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 20.sp, fontSize = 20.sp,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
}
val directionText = when (record.direction) { val directionText = when (record.direction) {
1 -> "" 1 -> ""
@@ -208,7 +240,7 @@ fun TrainRecordItem(
} }
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(if (shouldShowOnlyTime) 0.dp else 2.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -239,7 +271,7 @@ fun TrainRecordItem(
if (isValidPosition) { if (isValidPosition) {
Text( Text(
text = "${position}K", text = "${position.trim().removeSuffix(".")}K",
fontSize = 16.sp, fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.alignByBaseline() modifier = Modifier.alignByBaseline()
@@ -268,7 +300,7 @@ fun TrainRecordItem(
record.loco record.loco
) )
if (locoInfoText != null) { if (locoInfoText != null) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(if (shouldShowOnlyTime) 0.dp else 2.dp))
Text( Text(
text = locoInfoText, text = locoInfoText,
fontSize = 14.sp, fontSize = 14.sp,
@@ -276,7 +308,8 @@ fun TrainRecordItem(
) )
} }
} }
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(if (shouldShowOnlyTime) 0.dp else 2.dp))
AnimatedVisibility( AnimatedVisibility(
visible = isExpanded, visible = isExpanded,
enter = expandVertically(animationSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow)) + fadeIn(animationSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow)), enter = expandVertically(animationSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow)) + fadeIn(animationSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow)),
@@ -315,6 +348,7 @@ fun TrainRecordItem(
controller.setZoom(10.0) controller.setZoom(10.0)
controller.setCenter(coordinates) controller.setCenter(coordinates)
this.isTilesScaledToDpi = true this.isTilesScaledToDpi = true
tilesScaleFactor = context.resources.displayMetrics.density * 0.2f
this.setUseDataConnection(true) this.setUseDataConnection(true)
try { try {
@@ -392,9 +426,12 @@ fun MergedTrainRecordItem(
mergedRecord: MergedTrainRecord, mergedRecord: MergedTrainRecord,
expandedStatesMap: MutableMap<String, Boolean>, expandedStatesMap: MutableMap<String, Boolean>,
locoInfoUtil: LocoInfoUtil?, locoInfoUtil: LocoInfoUtil?,
trainTypeUtil: TrainTypeUtil?,
mergeSettings: MergeSettings? = null, mergeSettings: MergeSettings? = null,
isInEditMode: Boolean = false, isInEditMode: Boolean = false,
selectedRecords: List<TrainRecord> = emptyList(), selectedRecords: List<TrainRecord> = emptyList(),
mapViewState: CardMapView?,
onMapViewStateChange: (CardMapView) -> Unit,
onToggleSelection: (TrainRecord) -> Unit = {}, onToggleSelection: (TrainRecord) -> Unit = {},
onLongClick: (TrainRecord) -> Unit = {}, onLongClick: (TrainRecord) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
@@ -483,12 +520,20 @@ fun MergedTrainRecordItem(
} }
} }
val trainType = if (latestRecord.train?.trim().isNullOrEmpty()) {
null
} else {
val lbjClassValue = latestRecord.lbjClass?.trim() ?: "NA"
trainTypeUtil?.getTrainType(lbjClassValue, latestRecord.train!!.trim())
}
if (!trainType.isNullOrEmpty()) {
Text( Text(
text = "${latestRecord.rssi} dBm", text = trainType,
fontSize = 10.sp, fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
}
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
@@ -497,18 +542,20 @@ fun MergedTrainRecordItem(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
val trainDisplay = recordMap["train"]?.toString() ?: "未知列车" val trainDisplay = recordMap["train"]?.toString() ?: ""
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp) horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
if (trainDisplay.isNotEmpty()) {
Text( Text(
text = trainDisplay, text = trainDisplay,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 20.sp, fontSize = 20.sp,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
}
val directionText = when (latestRecord.direction) { val directionText = when (latestRecord.direction) {
1 -> "" 1 -> ""
@@ -562,7 +609,7 @@ fun MergedTrainRecordItem(
} }
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(2.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -593,7 +640,7 @@ fun MergedTrainRecordItem(
if (isValidPosition) { if (isValidPosition) {
Text( Text(
text = "${position}K", text = "${position.trim().removeSuffix(".")}K",
fontSize = 16.sp, fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.alignByBaseline() modifier = Modifier.alignByBaseline()
@@ -622,7 +669,7 @@ fun MergedTrainRecordItem(
latestRecord.loco latestRecord.loco
) )
if (locoInfoText != null) { if (locoInfoText != null) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(2.dp))
Text( Text(
text = locoInfoText, text = locoInfoText,
fontSize = 14.sp, fontSize = 14.sp,
@@ -630,18 +677,20 @@ fun MergedTrainRecordItem(
) )
} }
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(2.dp))
AnimatedVisibility( AnimatedVisibility(
visible = isExpanded, visible = isExpanded,
enter = expandVertically(animationSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow)) + fadeIn(animationSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow)), enter = expandVertically(animationSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow)) + fadeIn(animationSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow)),
exit = shrinkVertically(animationSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow)) + fadeOut(animationSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow)) exit = shrinkVertically(animationSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow)) + fadeOut(animationSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow))
) { ) {
Column { Column {
val coordinates = remember { latestRecord.getCoordinates() } val allValidCoordinates = remember {
mergedRecord.records
.mapNotNull { it.getCoordinates() }
.filter { it.latitude != 0.0 || it.longitude != 0.0 }
}
if (allValidCoordinates.isNotEmpty()) {
if (coordinates != null) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -667,16 +716,31 @@ fun MergedTrainRecordItem(
v.parent?.requestDisallowInterceptTouchEvent(true) v.parent?.requestDisallowInterceptTouchEvent(true)
false false
} }
controller.setZoom(10.0)
controller.setCenter(coordinates)
this.isTilesScaledToDpi = true this.isTilesScaledToDpi = true
tilesScaleFactor = context.resources.displayMetrics.density * 0.2f
this.setUseDataConnection(true) this.setUseDataConnection(true)
addMapListener(object : org.osmdroid.events.MapListener {
override fun onScroll(event: org.osmdroid.events.ScrollEvent?): Boolean {
val center = mapCenter
val zoom = zoomLevelDouble
onMapViewStateChange(CardMapView(center as GeoPoint, zoom))
return true
}
override fun onZoom(event: org.osmdroid.events.ZoomEvent?): Boolean {
val center = mapCenter
val zoom = zoomLevelDouble
onMapViewStateChange(CardMapView(center as GeoPoint, zoom))
return true
}
})
try { try {
val railwayTileSource = XYTileSource( val railwayTileSource = XYTileSource(
"OpenRailwayMap", 8, 16, 256, ".png", "OpenRailwayMap", 8, 16, 256, ".png",
arrayOf( arrayOf(
"https://a.tiles.openrailwayMap.org/standard/", "https://a.tiles.openrailwaymap.org/standard/",
"https://b.tiles.openrailwaymap.org/standard/", "https://b.tiles.openrailwaymap.org/standard/",
"https://c.tiles.openrailwaymap.org/standard/" "https://c.tiles.openrailwaymap.org/standard/"
), ),
@@ -708,6 +772,10 @@ fun MergedTrainRecordItem(
e.printStackTrace() e.printStackTrace()
} }
mergedRecord.records.forEach { record ->
record.getCoordinates()?.let { coordinates ->
if (coordinates.latitude != 0.0 || coordinates.longitude != 0.0) {
val recordMap = record.toMap()
val marker = Marker(this) val marker = Marker(this)
marker.position = coordinates marker.position = coordinates
@@ -719,8 +787,46 @@ fun MergedTrainRecordItem(
marker.setInfoWindowAnchor(Marker.ANCHOR_CENTER, 0f) marker.setInfoWindowAnchor(Marker.ANCHOR_CENTER, 0f)
overlays.add(marker) overlays.add(marker)
if (record == latestRecord) {
marker.showInfoWindow() marker.showInfoWindow()
} }
}
}
}
if (mapViewState != null) {
controller.setZoom(mapViewState.zoom)
controller.setCenter(mapViewState.center)
} else if (allValidCoordinates.size > 1) {
val boundingBox = BoundingBox.fromGeoPoints(allValidCoordinates.filter { it.latitude != 0.0 || it.longitude != 0.0 })
val layoutListener = object : android.view.View.OnLayoutChangeListener {
override fun onLayoutChange(v: android.view.View?, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) {
if (width > 0 && height > 0) {
val zoomLevel = org.osmdroid.views.MapView.getTileSystem().getBoundingBoxZoom(boundingBox, width, height)
val latSpan = boundingBox.latitudeSpan
val adjustedCenter = org.osmdroid.util.GeoPoint(
boundingBox.center.latitude + latSpan * 0.25,
boundingBox.center.longitude
)
val newZoom = zoomLevel - 1.0
controller.setZoom(newZoom)
controller.setCenter(adjustedCenter)
onMapViewStateChange(CardMapView(adjustedCenter, newZoom))
removeOnLayoutChangeListener(this)
}
}
}
addOnLayoutChangeListener(layoutListener)
} else if (allValidCoordinates.isNotEmpty()) {
val center = allValidCoordinates.first()
val zoom = 10.0
controller.setZoom(zoom)
controller.setCenter(center)
onMapViewStateChange(CardMapView(center, zoom))
}
}
}, },
update = { mapView -> mapView.invalidate() } update = { mapView -> mapView.invalidate() }
) )
@@ -770,6 +876,30 @@ fun MergedTrainRecordItem(
"${recordItem.locoType}-${recordItem.loco}" "${recordItem.locoType}-${recordItem.loco}"
} else null } else null
} }
GroupBy.TRAIN_OR_LOCO -> {
val latestTrain = mergedRecord.latestRecord.train.trim()
val latestLoco = mergedRecord.latestRecord.loco.trim()
val recordTrain = recordItem.train.trim()
val recordLoco = recordItem.loco.trim()
when {
latestTrain.isNotEmpty() && latestTrain != "<NUL>" &&
recordTrain.isNotEmpty() && recordTrain != "<NUL>" &&
latestTrain == recordTrain && latestLoco != recordLoco -> {
if (recordLoco.isNotEmpty() && recordLoco != "<NUL>") {
"${recordItem.locoType}-${recordLoco}"
} else null
}
latestLoco.isNotEmpty() && latestLoco != "<NUL>" &&
recordLoco.isNotEmpty() && recordLoco != "<NUL>" &&
latestLoco == recordLoco && latestTrain != recordTrain -> {
if (recordTrain.isNotEmpty() && recordTrain != "<NUL>") {
recordTrain
} else null
}
else -> null
}
}
else -> null else -> null
} }
@@ -793,12 +923,12 @@ fun MergedTrainRecordItem(
} }
if (recordItem.position.isNotEmpty() && recordItem.position != "<NUL>") { if (recordItem.position.isNotEmpty() && recordItem.position != "<NUL>") {
if (isNotEmpty()) append(" ") if (isNotEmpty()) append(" ")
append("${recordItem.position}K") append("${recordItem.position.trim().removeSuffix(".")}K")
} }
} }
Text( Text(
text = locationText.ifEmpty { "位置未知" }, text = locationText.ifEmpty { "" },
fontSize = 11.sp, fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -848,6 +978,7 @@ fun HistoryScreen(
lastUpdateTime: Date?, lastUpdateTime: Date?,
temporaryStatusMessage: String? = null, temporaryStatusMessage: String? = null,
locoInfoUtil: LocoInfoUtil? = null, locoInfoUtil: LocoInfoUtil? = null,
trainTypeUtil: TrainTypeUtil? = null,
mergeSettings: MergeSettings? = null, mergeSettings: MergeSettings? = null,
onClearRecords: () -> Unit = {}, onClearRecords: () -> Unit = {},
onRecordClick: (TrainRecord) -> Unit = {}, onRecordClick: (TrainRecord) -> Unit = {},
@@ -856,12 +987,14 @@ fun HistoryScreen(
editMode: Boolean = false, editMode: Boolean = false,
selectedRecords: Set<String> = emptySet(), selectedRecords: Set<String> = emptySet(),
expandedStates: Map<String, Boolean> = emptyMap(), expandedStates: Map<String, Boolean> = emptyMap(),
mapViewStates: Map<String, CardMapView> = emptyMap(),
scrollPosition: Int = 0, scrollPosition: Int = 0,
scrollOffset: Int = 0, scrollOffset: Int = 0,
onStateChange: (Boolean, Set<String>, Map<String, Boolean>, Int, Int) -> Unit = { _, _, _, _, _ -> } onStateChange: (Boolean, Set<String>, Map<String, Boolean>, Map<String, CardMapView>, Int, Int) -> Unit = { _, _, _, _, _, _ -> }
) { ) {
val refreshKey = latestRecord?.timestamp?.time ?: 0 val refreshKey = latestRecord?.timestamp?.time ?: 0
var wasAtTopBeforeUpdate by remember { mutableStateOf(false) }
var isInEditMode by remember(editMode) { mutableStateOf(editMode) } var isInEditMode by remember(editMode) { mutableStateOf(editMode) }
val selectedRecordsList = remember(selectedRecords) { val selectedRecordsList = remember(selectedRecords) {
@@ -887,6 +1020,9 @@ fun HistoryScreen(
val expandedStatesMap = remember(expandedStates) { val expandedStatesMap = remember(expandedStates) {
mutableStateMapOf<String, Boolean>().apply { putAll(expandedStates) } mutableStateMapOf<String, Boolean>().apply { putAll(expandedStates) }
} }
val mapViewStatesMap = remember(mapViewStates) {
mutableStateMapOf<String, CardMapView>().apply { putAll(mapViewStates) }
}
val listState = rememberLazyListState( val listState = rememberLazyListState(
initialFirstVisibleItemIndex = scrollPosition, initialFirstVisibleItemIndex = scrollPosition,
@@ -917,28 +1053,44 @@ fun HistoryScreen(
LaunchedEffect(isInEditMode, selectedRecordsList.size) { LaunchedEffect(isInEditMode, selectedRecordsList.size) {
val selectedIds = selectedRecordsList.map { it.uniqueId }.toSet() val selectedIds = selectedRecordsList.map { it.uniqueId }.toSet()
onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), mapViewStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
} }
LaunchedEffect(expandedStatesMap.toMap()) { LaunchedEffect(expandedStatesMap.toMap()) {
if (!isInEditMode) { if (!isInEditMode) {
val selectedIds = selectedRecordsList.map { it.uniqueId }.toSet() val selectedIds = selectedRecordsList.map { it.uniqueId }.toSet()
delay(50) delay(50)
onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), mapViewStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
} }
} }
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) { LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
if (!isInEditMode) { if (!isInEditMode) {
val selectedIds = selectedRecordsList.map { it.uniqueId }.toSet() val selectedIds = selectedRecordsList.map { it.uniqueId }.toSet()
onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), mapViewStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
} }
} }
LaunchedEffect(selectedRecordsList.size) { LaunchedEffect(selectedRecordsList.size) {
if (selectedRecordsList.isEmpty() && isInEditMode) { if (selectedRecordsList.isEmpty() && isInEditMode) {
isInEditMode = false isInEditMode = false
onStateChange(false, emptySet(), expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) onStateChange(false, emptySet(), expandedStatesMap.toMap(), mapViewStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
}
}
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
if (!isInEditMode && filteredRecords.isNotEmpty()) {
wasAtTopBeforeUpdate = listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset <= 100
}
}
LaunchedEffect(refreshKey) {
if (refreshKey > 0 && !isInEditMode && filteredRecords.isNotEmpty() && wasAtTopBeforeUpdate) {
try {
listState.animateScrollToItem(0, 0)
} catch (e: Exception) {
listState.scrollToItem(0, 0)
}
} }
} }
@@ -1000,6 +1152,7 @@ fun HistoryScreen(
expandedStatesMap = expandedStatesMap, expandedStatesMap = expandedStatesMap,
latestRecord = latestRecord, latestRecord = latestRecord,
locoInfoUtil = locoInfoUtil, locoInfoUtil = locoInfoUtil,
trainTypeUtil = trainTypeUtil,
onRecordClick = onRecordClick, onRecordClick = onRecordClick,
onToggleSelection = { record -> onToggleSelection = { record ->
if (selectedRecordsList.contains(record)) { if (selectedRecordsList.contains(record)) {
@@ -1023,9 +1176,14 @@ fun HistoryScreen(
mergedRecord = item, mergedRecord = item,
expandedStatesMap = expandedStatesMap, expandedStatesMap = expandedStatesMap,
locoInfoUtil = locoInfoUtil, locoInfoUtil = locoInfoUtil,
trainTypeUtil = trainTypeUtil,
mergeSettings = mergeSettings, mergeSettings = mergeSettings,
isInEditMode = isInEditMode, isInEditMode = isInEditMode,
selectedRecords = selectedRecordsList, selectedRecords = selectedRecordsList,
mapViewState = mapViewStatesMap[item.groupKey],
onMapViewStateChange = { newState ->
mapViewStatesMap[item.groupKey] = newState
},
onToggleSelection = { record -> onToggleSelection = { record ->
if (selectedRecordsList.contains(record)) { if (selectedRecordsList.contains(record)) {
selectedRecordsList.remove(record) selectedRecordsList.remove(record)

View File

@@ -7,7 +7,12 @@ import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter import android.graphics.PorterDuffColorFilter
import android.util.Log import android.util.Log
import android.view.ViewGroup import android.view.ViewGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* 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.Icons
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.MyLocation import androidx.compose.material.icons.filled.MyLocation
@@ -16,11 +21,16 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView 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.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -112,7 +122,6 @@ fun MapScreen(
val recordMap = record.toMap() val recordMap = record.toMap()
title = recordMap["train"]?.toString() ?: "列车" title = recordMap["train"]?.toString() ?: "列车"
val latStr = String.format("%.4f", point.latitude) val latStr = String.format("%.4f", point.latitude)
val lonStr = String.format("%.4f", point.longitude) val lonStr = String.format("%.4f", point.longitude)
val coordStr = "${latStr}°N, ${lonStr}°E" val coordStr = "${latStr}°N, ${lonStr}°E"
@@ -574,8 +583,8 @@ fun Context.getCompactMarkerDrawable(color: Int): Drawable {
private fun Int.directionText(): String = when (this) { private fun Int.directionText(): String = when (this) {
1 -> "" 1 -> "下行"
3 -> "" 3 -> "上行"
else -> "?" else -> "?"
} }
@@ -585,50 +594,143 @@ private fun TrainMarkerDialog(
position: GeoPoint?, position: GeoPoint?,
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
val recordMap = record.toMap()
val displayItems = recordMap.filterKeys {
it !in setOf("train", "direction", "time")
}.toList()
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { title = {
val recordMap = record.toMap()
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = recordMap["train"]?.toString() ?: "列车", style = MaterialTheme.typography.titleLarge)
recordMap["direction"]?.let { direction ->
Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = direction, text = recordMap["train"]?.toString() ?: "列车",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant modifier = Modifier.weight(1f)
)
recordMap["direction"]?.let { direction ->
Text(
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 = { 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(
text = value, text = title,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.titleMedium
modifier = Modifier.padding(vertical = 2.dp)
) )
}
}
position?.let {
Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "坐标: ${String.format("%.6f", it.latitude)}, ${String.format("%.6f", it.longitude)}", text = value.toString(),
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
} }
} }
position?.let {
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 = { confirmButton = {
TextButton(onClick = onDismiss) { 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
)
}
}
}

View File

@@ -1,308 +0,0 @@
package org.noxylva.lbjconsole.ui.screens
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
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) }
var isPressed by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.98f else 1f,
animationSpec = tween(durationMillis = 120),
label = "content_scale"
)
LaunchedEffect(isPressed) {
if (isPressed) {
delay(100)
isPressed = false
}
}
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}小时前"
}
val updateInterval = if (diffInSec < 60) 500L else if (diffInSec < 3600) 30000L else 300000L
delay(updateInterval)
}
}
}
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)
) {
AnimatedContent(
targetState = latestRecord,
transitionSpec = {
fadeIn(
animationSpec = tween(
durationMillis = 300,
easing = FastOutSlowInEasing
)
) + slideInVertically(
initialOffsetY = { it / 4 },
animationSpec = tween(
durationMillis = 300,
easing = FastOutSlowInEasing
)
) togetherWith fadeOut(
animationSpec = tween(
durationMillis = 150,
easing = FastOutLinearInEasing
)
) + slideOutVertically(
targetOffsetY = { -it / 4 },
animationSpec = tween(
durationMillis = 150,
easing = FastOutLinearInEasing
)
)
},
label = "content_animation"
) { record ->
if (record != null) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = true)
) {
isPressed = true
selectedRecord = record
showDetailDialog = true
onRecordClick(record)
}
.padding(8.dp)
.graphicsLayer {
scaleX = scale
scaleY = scale
}
) {
val recordMap = record.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
)
}
}

View File

@@ -15,6 +15,7 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.noxylva.lbjconsole.model.MergeSettings import org.noxylva.lbjconsole.model.MergeSettings
import org.noxylva.lbjconsole.model.GroupBy import org.noxylva.lbjconsole.model.GroupBy
import org.noxylva.lbjconsole.model.TimeWindow import org.noxylva.lbjconsole.model.TimeWindow
@@ -23,7 +24,7 @@ import org.noxylva.lbjconsole.BackgroundService
import org.noxylva.lbjconsole.NotificationService import org.noxylva.lbjconsole.NotificationService
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.DisposableEffect
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -38,21 +39,17 @@ fun SettingsScreen(
onScrollPositionChange: (Int) -> Unit = {}, onScrollPositionChange: (Int) -> Unit = {},
specifiedDeviceAddress: String? = null, specifiedDeviceAddress: String? = null,
searchOrderList: List<String> = emptyList(), searchOrderList: List<String> = emptyList(),
onSpecifiedDeviceSelected: (String?) -> Unit = {} onSpecifiedDeviceSelected: (String?) -> Unit = {},
autoConnectEnabled: Boolean = true,
onAutoConnectEnabledChange: (Boolean) -> Unit = {}
) { ) {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val scrollState = rememberScrollState() val scrollState = rememberScrollState(initial = scrollPosition)
LaunchedEffect(scrollPosition) { DisposableEffect(Unit) {
scrollState.scrollTo(scrollPosition) onDispose {
}
LaunchedEffect(scrollState.value) {
onScrollPositionChange(scrollState.value) onScrollPositionChange(scrollState.value)
} }
LaunchedEffect(deviceName) {
onApplySettings()
} }
Column( Column(
@@ -196,12 +193,16 @@ fun SettingsScreen(
} }
val context = LocalContext.current val context = LocalContext.current
var backgroundServiceEnabled by remember { val notificationService = remember(context) { NotificationService(context) }
mutableStateOf(SettingsActivity.isBackgroundServiceEnabled(context))
var backgroundServiceEnabled by remember { mutableStateOf<Boolean?>(null) }
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(context) {
backgroundServiceEnabled = SettingsActivity.isBackgroundServiceEnabled(context)
} }
val notificationService = remember { NotificationService(context) } var notificationEnabled by remember(context, notificationService) {
var notificationEnabled by remember {
mutableStateOf(notificationService.isNotificationEnabled()) mutableStateOf(notificationService.isNotificationEnabled())
} }
@@ -222,20 +223,25 @@ fun SettingsScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
if (backgroundServiceEnabled == null) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else {
Switch( Switch(
checked = backgroundServiceEnabled, checked = backgroundServiceEnabled!!,
onCheckedChange = { enabled -> onCheckedChange = { enabled ->
backgroundServiceEnabled = enabled backgroundServiceEnabled = enabled
coroutineScope.launch {
SettingsActivity.setBackgroundServiceEnabled(context, enabled) SettingsActivity.setBackgroundServiceEnabled(context, enabled)
if (enabled) { if (enabled) {
BackgroundService.startService(context) BackgroundService.startService(context)
} else { } else {
BackgroundService.stopService(context) BackgroundService.stopService(context)
} }
} }
}
) )
} }
}
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -262,6 +268,29 @@ fun SettingsScreen(
} }
) )
} }
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
)
}
} }
} }

View File

@@ -4,8 +4,8 @@ import android.util.Log
import org.osmdroid.util.GeoPoint import org.osmdroid.util.GeoPoint
object LocationUtils { object LocationUtil {
private const val TAG = "LocationUtils" private const val TAG = "LocationUtil"
fun parsePositionInfo(positionInfo: String): GeoPoint? { fun parsePositionInfo(positionInfo: String): GeoPoint? {
@@ -52,7 +52,7 @@ object LocationUtils {
val minuteEndIndex = dmsString.indexOf('') val minuteEndIndex = dmsString.indexOf('')
if (minuteEndIndex == -1) { if (minuteEndIndex == -1) {
return degrees return null
} }
val minutes = dmsString.substring(degreeIndex + 1, minuteEndIndex).toDouble() val minutes = dmsString.substring(degreeIndex + 1, minuteEndIndex).toDouble()

View File

@@ -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)
}
}

View File

@@ -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
View File

0
app/src/main/res/drawable/ic_launcher_foreground.xml Normal file → Executable file
View File

0
app/src/main/res/drawable/ic_notification.xml Normal file → Executable file
View File

0
app/src/main/res/drawable/ic_person.xml Normal file → Executable file
View File

0
app/src/main/res/layout/activity_settings.xml Normal file → Executable file
View File

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

0
app/src/main/res/mipmap-anydpi/ic_launcher_round.xml Normal file → Executable file
View File

0
app/src/main/res/mipmap-hdpi/ic_launcher.webp Normal file → Executable file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

0
app/src/main/res/raw/loco_info.csv Normal file → Executable file
View File

0
app/src/main/res/values/colors.xml Normal file → Executable file
View File

0
app/src/main/res/values/strings.xml Normal file → Executable file
View File

0
app/src/main/res/values/themes.xml Normal file → Executable file
View File

0
app/src/main/res/xml/backup_rules.xml Normal file → Executable file
View File

0
app/src/main/res/xml/data_extraction_rules.xml Normal file → Executable file
View File

0
app/src/main/res/xml/file_paths.xml Normal file → Executable file
View File

View File

@@ -8,6 +8,8 @@ espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.9.0" lifecycleRuntimeKtx = "2.9.0"
activityCompose = "1.10.1" activityCompose = "1.10.1"
composeBom = "2024.04.01" composeBom = "2024.04.01"
room = "2.6.1"
startup = "1.1.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", 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" }

0
gradlew vendored Normal file → Executable file
View File

View File

@@ -19,6 +19,6 @@ dependencyResolutionManagement {
} }
} }
rootProject.name = "LBJ Receiver" rootProject.name = "LBJ_Console"
include(":app") include(":app")