feat: update locomotive type support and optimized position display format

This commit is contained in:
Nedifinita
2025-08-19 19:10:04 +08:00
parent 0f98b6bcf7
commit 0bf7033c6c
9 changed files with 147 additions and 215 deletions

View File

@@ -3,14 +3,15 @@
LBJ Console 是一款 Android 应用程序,用于通过 BLE 从 [SX1276_Receive_LBJ](https://github.com/undef-i/SX1276_Receive_LBJ) 设备接收并显示列车预警消息,功能包括: LBJ Console 是一款 Android 应用程序,用于通过 BLE 从 [SX1276_Receive_LBJ](https://github.com/undef-i/SX1276_Receive_LBJ) 设备接收并显示列车预警消息,功能包括:
- 接收列车预警消息,支持可选的手机推送通知。 - 接收列车预警消息,支持可选的手机推送通知。
- 显示预警消息的 GPS 信息于地图 - 在地图上显示预警消息的 GPS 信息。
- 基于内置数据文件显示机车配属和车次类型。 - 基于内置数据文件显示机车配属,机车类型和车次类型。
## 数据文件 ## 数据文件
LBJ Console 依赖以下数据文件,位于 `app/src/main/assets/` 目录,用于支持机车配属和车次信息的展示: LBJ Console 依赖以下数据文件,位于 `app/src/main/assets/` 目录,用于支持机车配属和车次信息的展示:
- `loco_info.csv`:包含机车配属信息,格式为 `机车型号,机车编号起始值,机车编号结束值,所属铁路局及机务段,备注` - `loco_info.csv`:包含机车配属信息,格式为 `机车型号,机车编号起始值,机车编号结束值,所属铁路局及机务段,备注`
- `loco_type_info.csv`:包含机车类型编码信息,格式为 `机车类型编码,机车类型`
- `train_info.csv`:包含车次类型信息,格式为 `正则表达式,车次类型` - `train_info.csv`:包含车次类型信息,格式为 `正则表达式,车次类型`

View File

@@ -13,8 +13,8 @@ android {
applicationId = "org.noxylva.lbjconsole" applicationId = "org.noxylva.lbjconsole"
minSdk = 29 minSdk = 29
targetSdk = 35 targetSdk = 35
versionCode = 11 versionCode = 13
versionName = "0.1.2" versionName = "0.1.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -145,7 +145,7 @@ class NotificationService(private val context: Context) {
} }
if (isValidValue(trainRecord.position)) { if (isValidValue(trainRecord.position)) {
remoteViews.setTextViewText(R.id.notification_position, "${trainRecord.position.trim()}K") remoteViews.setTextViewText(R.id.notification_position, "${trainRecord.position.trim().removeSuffix(".")}K")
remoteViews.setViewVisibility(R.id.notification_position, View.VISIBLE) remoteViews.setViewVisibility(R.id.notification_position, View.VISIBLE)
} else { } else {
remoteViews.setViewVisibility(R.id.notification_position, View.GONE) remoteViews.setViewVisibility(R.id.notification_position, View.GONE)

View File

@@ -85,7 +85,6 @@ class TrainRecord(jsonData: JSONObject? = null) {
time = jsonData.optString("time", "") time = jsonData.optString("time", "")
loco = jsonData.optString("loco", "") loco = jsonData.optString("loco", "")
// 不再直接从JSON获取loco_type而是从loco字段前三位获取
locoType = if (loco.isNotEmpty()) { locoType = if (loco.isNotEmpty()) {
val prefix = if (loco.length >= 3) loco.take(3) else loco val prefix = if (loco.length >= 3) loco.take(3) else loco
LocoTypeUtil?.getLocoTypeByCode(prefix) ?: "" LocoTypeUtil?.getLocoTypeByCode(prefix) ?: ""
@@ -161,12 +160,14 @@ class TrainRecord(jsonData: JSONObject? = null) {
trainDisplay?.takeIf { it.isNotEmpty() }?.let { map["train"] = it } 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)
} }
@@ -180,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

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

@@ -271,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()
@@ -640,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()
@@ -923,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
) )

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) Text(
text = recordMap["train"]?.toString() ?: "列车",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f)
)
recordMap["direction"]?.let { direction -> recordMap["direction"]?.let { direction ->
Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = direction, text = (direction as? Int)?.directionText() ?: direction.toString(),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp
),
modifier = Modifier.padding(start = 8.dp)
) )
} }
} }
}, },
text = { text = {
Column { Column(
modifier = Modifier
record.toMap().forEach { (key, value) -> .fillMaxWidth()
if (key != "train" && key != "direction") { .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
}
Text( Text(
text = value, text = title,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.titleMedium
modifier = Modifier.padding(vertical = 2.dp) )
Text(
text = value.toString(),
style = MaterialTheme.typography.bodyMedium
) )
} }
} }
position?.let { position?.let {
Spacer(modifier = Modifier.height(4.dp)) Row(
Text( modifier = Modifier
text = "坐标: ${String.format("%.6f", it.latitude)}, ${String.format("%.6f", it.longitude)}", .fillMaxWidth()
style = MaterialTheme.typography.bodyMedium .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

@@ -13,7 +13,7 @@ class LocoTypeUtil(private val context: Context) {
private fun loadLocoTypeMapping() { private fun loadLocoTypeMapping() {
try { try {
context.assets.open("loco_number_info.csv").use { inputStream -> context.assets.open("loco_type_info.csv").use { inputStream ->
BufferedReader(InputStreamReader(inputStream)).use { reader -> BufferedReader(InputStreamReader(inputStream)).use { reader ->
reader.lines().forEach { line -> reader.lines().forEach { line ->
val parts = line.split(",") val parts = line.split(",")