4 Commits

13 changed files with 719 additions and 367 deletions

View File

@@ -3,12 +3,9 @@
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.
## Roadmap
- Tab state persistence
- Record filtering (train number, time range)
- Record management page optimization
- Optional train merge by locomotive/number
- Offline data storage
- Add record timestamps
# License

View File

@@ -12,8 +12,8 @@ android {
applicationId = "org.noxylva.lbjconsole"
minSdk = 29
targetSdk = 35
versionCode = 1
versionName = "0.0.1"
versionCode = 4
versionName = "0.0.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -18,9 +18,7 @@ import java.util.*
class BLEClient(private val context: Context) : BluetoothGattCallback() {
companion object {
const val TAG = "LBJ_BT"
const val SCAN_PERIOD = 10000L
const val TAG = "LBJ_BT"
val SERVICE_UUID = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb")
val CHAR_UUID = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb")
@@ -42,20 +40,69 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
private var targetDeviceName: String? = null
private var bluetoothLeScanner: BluetoothLeScanner? = null
private var continuousScanning = false
private var autoReconnect = true
private var lastKnownDeviceAddress: String? = null
private var connectionAttempts = 0
private var isReconnecting = false
private val leScanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device
val deviceName = device.name
if (targetDeviceName != null) {
if (deviceName == null || !deviceName.equals(targetDeviceName, ignoreCase = true)) {
return
val shouldShowDevice = when {
targetDeviceName != null -> {
deviceName != null && deviceName.equals(targetDeviceName, ignoreCase = true)
}
else -> {
deviceName != null && (
deviceName.contains("LBJ", ignoreCase = true) ||
deviceName.contains("Receiver", ignoreCase = true) ||
deviceName.contains("Train", ignoreCase = true) ||
deviceName.contains("Console", ignoreCase = true) ||
deviceName.contains("ESP", ignoreCase = true) ||
deviceName.contains("Arduino", ignoreCase = true) ||
deviceName.contains("BLE", ignoreCase = true) ||
deviceName.contains("UART", ignoreCase = true) ||
deviceName.contains("Serial", ignoreCase = true)
) && !(
deviceName.contains("Midea", ignoreCase = true) ||
deviceName.contains("TV", ignoreCase = true) ||
deviceName.contains("Phone", ignoreCase = true) ||
deviceName.contains("Watch", ignoreCase = true) ||
deviceName.contains("Headset", ignoreCase = true) ||
deviceName.contains("Speaker", ignoreCase = true)
)
}
}
scanCallback?.invoke(device)
if (shouldShowDevice) {
Log.d(TAG, "Showing filtered device: $deviceName")
scanCallback?.invoke(device)
}
if (targetDeviceName != null && !isConnected && !isReconnecting) {
if (deviceName != null && deviceName.equals(targetDeviceName, ignoreCase = true)) {
Log.i(TAG, "Found target device: $deviceName, auto-connecting")
lastKnownDeviceAddress = device.address
connectImmediately(device.address)
}
}
if (lastKnownDeviceAddress == device.address && !isConnected && !isReconnecting) {
Log.i(TAG, "Found known device, reconnecting immediately")
connectImmediately(device.address)
}
}
override fun onScanFailed(errorCode: Int) {
Log.e(TAG, "BLE scan failed code=$errorCode")
if (continuousScanning) {
handler.post {
restartScan()
}
}
}
}
@@ -90,12 +137,13 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
try {
scanCallback = callback
this.targetDeviceName = targetDeviceName
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() ?: run {
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val bluetoothAdapter = bluetoothManager.adapter ?: run {
Log.e(TAG, "Bluetooth adapter unavailable")
return
}
if (!bluetoothAdapter.isEnabled) {
if (bluetoothAdapter.isEnabled != true) {
Log.e(TAG, "Bluetooth adapter disabled")
return
}
@@ -106,12 +154,9 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
return
}
handler.postDelayed({
stopScan()
}, SCAN_PERIOD)
isScanning = true
Log.d(TAG, "Starting BLE scan target=${targetDeviceName ?: "Any"}")
continuousScanning = true
Log.d(TAG, "Starting continuous BLE scan target=${targetDeviceName ?: "Any"}")
bluetoothLeScanner?.startScan(leScanCallback)
} catch (e: SecurityException) {
Log.e(TAG, "Scan security error: ${e.message}")
@@ -126,6 +171,40 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
if (isScanning) {
bluetoothLeScanner?.stopScan(leScanCallback)
isScanning = false
continuousScanning = false
Log.d(TAG, "Stopped BLE scan")
}
}
@SuppressLint("MissingPermission")
private fun restartScan() {
if (!continuousScanning) return
try {
bluetoothLeScanner?.stopScan(leScanCallback)
bluetoothLeScanner?.startScan(leScanCallback)
isScanning = true
Log.d(TAG, "Restarted BLE scan")
} catch (e: Exception) {
Log.e(TAG, "Failed to restart scan: ${e.message}")
}
}
private fun connectImmediately(address: String) {
if (isReconnecting) return
isReconnecting = true
handler.post {
connect(address) { connected ->
isReconnecting = false
if (connected) {
connectionAttempts = 0
Log.i(TAG, "Successfully connected to $address")
} else {
connectionAttempts++
Log.w(TAG, "Connection attempt $connectionAttempts failed for $address")
}
}
}
}
@@ -150,13 +229,14 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
}
try {
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() ?: run {
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val bluetoothAdapter = bluetoothManager.adapter ?: run {
Log.e(TAG, "Bluetooth adapter unavailable")
handler.post { onConnectionStateChange?.invoke(false) }
return false
}
if (!bluetoothAdapter.isEnabled) {
if (bluetoothAdapter.isEnabled != true) {
Log.e(TAG, "Bluetooth adapter is disabled")
handler.post { onConnectionStateChange?.invoke(false) }
return false
@@ -181,17 +261,7 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
bluetoothGatt = device.connectGatt(context, false, this, BluetoothDevice.TRANSPORT_LE)
Log.d(TAG, "Connecting to address=$address")
handler.postDelayed({
if (!isConnected && deviceAddress == address) {
Log.e(TAG, "Connection timeout reconnecting")
bluetoothGatt?.close()
bluetoothGatt =
device.connectGatt(context, false, this, BluetoothDevice.TRANSPORT_LE)
}
}, 10000)
return true
} catch (e: Exception) {
@@ -284,30 +354,30 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.e(TAG, "Connection error status=$status")
isConnected = false
isReconnecting = false
if (status == 133 || status == 8) {
Log.e(TAG, "GATT error closing connection")
Log.e(TAG, "GATT error, attempting immediate reconnection")
try {
gatt.close()
bluetoothGatt = null
deviceAddress?.let { address ->
handler.postDelayed({
Log.d(TAG, "Reconnecting to device")
val device =
BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address)
bluetoothGatt = device.connectGatt(
context,
false,
this,
BluetoothDevice.TRANSPORT_LE
)
}, 2000)
if (autoReconnect) {
Log.d(TAG, "Immediate reconnection to device")
handler.post {
val device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address)
bluetoothGatt = device.connectGatt(
context,
false,
this,
BluetoothDevice.TRANSPORT_LE
)
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Reconnect error: ${e.message}")
Log.e(TAG, "Immediate reconnect error: ${e.message}")
}
}
@@ -318,32 +388,34 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
isConnected = true
isReconnecting = false
connectionAttempts = 0
Log.i(TAG, "Connected to GATT server")
handler.post { connectionStateCallback?.invoke(true) }
handler.postDelayed({
handler.post {
try {
gatt.discoverServices()
} catch (e: Exception) {
Log.e(TAG, "Service discovery failed: ${e.message}")
}
}, 500)
}
}
BluetoothProfile.STATE_DISCONNECTED -> {
isConnected = false
isReconnecting = false
Log.i(TAG, "Disconnected from GATT server")
handler.post { connectionStateCallback?.invoke(false) }
if (!deviceAddress.isNullOrBlank()) {
handler.postDelayed({
Log.d(TAG, "Reconnecting after disconnect")
if (!deviceAddress.isNullOrBlank() && autoReconnect) {
handler.post {
Log.d(TAG, "Immediate reconnection after disconnect")
connect(deviceAddress!!, connectionStateCallback)
}, 3000)
}
}
}
}
@@ -355,12 +427,14 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
private var lastDataTime = 0L
@Suppress("DEPRECATION")
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic
) {
super.onCharacteristicChanged(gatt, characteristic)
@Suppress("DEPRECATION")
val newData = characteristic.value?.let {
String(it, StandardCharsets.UTF_8)
} ?: return
@@ -379,18 +453,17 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
val bufferContent = dataBuffer.toString()
val currentTime = System.currentTimeMillis()
if (lastDataTime > 0 && currentTime - lastDataTime > 5000) {
Log.w(TAG, "Data timeout ${(currentTime - lastDataTime) / 1000}s")
if (lastDataTime > 0) {
val timeDiff = currentTime - lastDataTime
if (timeDiff > 10000) {
Log.w(TAG, "Long data gap: ${timeDiff / 1000}s")
}
}
Log.d(TAG, "Buffer size=${dataBuffer.length} bytes")
tryExtractJson(bufferContent)
lastDataTime = currentTime
}
@@ -509,9 +582,16 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
)
if (descriptor != null) {
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
val writeResult = gatt.writeDescriptor(descriptor)
Log.d(TAG, "Descriptor write result=$writeResult")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val writeResult = gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
Log.d(TAG, "Descriptor write result=$writeResult")
} else {
@Suppress("DEPRECATION")
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
@Suppress("DEPRECATION")
val writeResult = gatt.writeDescriptor(descriptor)
Log.d(TAG, "Descriptor write result=$writeResult")
}
} else {
Log.e(TAG, "Descriptor not found")
@@ -538,10 +618,19 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
private fun requestDataAfterDelay() {
handler.postDelayed({
handler.post {
statusCallback?.let { callback ->
getStatus(callback)
}
}, 1000)
}
}
fun setAutoReconnect(enabled: Boolean) {
autoReconnect = enabled
Log.d(TAG, "Auto reconnect set to: $enabled")
}
fun getConnectionAttempts(): Int = connectionAttempts
fun getLastKnownDeviceAddress(): String? = lastKnownDeviceAddress
}

View File

@@ -3,8 +3,10 @@ package org.noxylva.lbjconsole
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import java.io.File
import android.os.Build
import android.os.Bundle
@@ -16,6 +18,8 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.ui.graphics.toArgb
import androidx.core.view.WindowCompat
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -25,10 +29,12 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -71,13 +77,33 @@ class MainActivity : ComponentActivity() {
private var settingsDeviceName by mutableStateOf("LBJReceiver")
private var temporaryStatusMessage by mutableStateOf<String?>(null)
private var temporaryStatusMessage by mutableStateOf<String?>(null)
private var historyEditMode by mutableStateOf(false)
private var historySelectedRecords by mutableStateOf<Set<String>>(emptySet())
private var historyExpandedStates by mutableStateOf<Map<String, Boolean>>(emptyMap())
private var historyScrollPosition by mutableStateOf(0)
private var historyScrollOffset by mutableStateOf(0)
private var mapCenterPosition by mutableStateOf<Pair<Double, Double>?>(null)
private var mapZoomLevel by mutableStateOf(10.0)
private var mapRailwayLayerVisible by mutableStateOf(true)
private var targetDeviceName = "LBJReceiver"
private val settingsPrefs by lazy { getSharedPreferences("app_settings", Context.MODE_PRIVATE) }
private val settingsPrefs by lazy { getSharedPreferences("app_settings", Context.MODE_PRIVATE) }
private fun getAppVersion(): String {
return try {
val packageInfo = packageManager.getPackageInfo(packageName, 0)
packageInfo.versionName ?: "Unknown"
} catch (e: Exception) {
Log.e(TAG, "Failed to get app version", e)
"Unknown"
}
}
private val requestPermissions = registerForActivityResult(
@@ -88,9 +114,8 @@ class MainActivity : ComponentActivity() {
val locationPermissionsGranted = permissions.filter { it.key.contains("LOCATION") }.all { it.value }
if (bluetoothPermissionsGranted && locationPermissionsGranted) {
Log.d(TAG, "Permissions granted")
startScan()
Log.d(TAG, "Permissions granted, starting auto scan and connect")
startAutoScanAndConnect()
} else {
Log.e(TAG, "Missing permissions: $permissions")
deviceStatus = "需要蓝牙和位置权限"
@@ -194,7 +219,13 @@ class MainActivity : ComponentActivity() {
Log.e(TAG, "OSM cache config failed", e)
}
saveSettings()
enableEdgeToEdge()
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = false
}
setContent {
LBJReceiverTheme {
val scope = rememberCoroutineScope()
@@ -205,7 +236,10 @@ class MainActivity : ComponentActivity() {
isConnected = bleClient.isConnected(),
isScanning = isScanning,
currentTab = currentTab,
onTabChange = { tab -> currentTab = tab },
onTabChange = { tab ->
currentTab = tab
saveSettings()
},
onConnectClick = { showConnectionDialog = true },
@@ -228,6 +262,32 @@ class MainActivity : ComponentActivity() {
filterTrain = filterTrain,
filterRoute = filterRoute,
filterDirection = filterDirection,
historyEditMode = historyEditMode,
historySelectedRecords = historySelectedRecords,
historyExpandedStates = historyExpandedStates,
historyScrollPosition = historyScrollPosition,
historyScrollOffset = historyScrollOffset,
onHistoryStateChange = { editMode, selectedRecords, expandedStates, scrollPosition, scrollOffset ->
historyEditMode = editMode
historySelectedRecords = selectedRecords
historyExpandedStates = expandedStates
historyScrollPosition = scrollPosition
historyScrollOffset = scrollOffset
saveSettings()
},
mapCenterPosition = mapCenterPosition,
mapZoomLevel = mapZoomLevel,
mapRailwayLayerVisible = mapRailwayLayerVisible,
onMapStateChange = { centerPos, zoomLevel, railwayVisible ->
mapCenterPosition = centerPos
mapZoomLevel = zoomLevel
mapRailwayLayerVisible = railwayVisible
saveSettings()
},
onFilterChange = { train, route, direction ->
filterTrain = train
filterRoute = route
@@ -248,11 +308,7 @@ class MainActivity : ComponentActivity() {
temporaryStatusMessage = null
}
},
onExportRecords = {
scope.launch {
exportRecordsToCSV()
}
},
onDeleteRecords = { records ->
scope.launch {
val deletedCount = trainRecordManager.deleteRecords(records)
@@ -279,10 +335,10 @@ class MainActivity : ComponentActivity() {
Toast.makeText(this, "设备名称 '${settingsDeviceName}' 已保存,下次连接时生效", Toast.LENGTH_LONG).show()
Log.d(TAG, "Applied settings deviceName=${settingsDeviceName}")
},
appVersion = getAppVersion(),
locoInfoUtil = locoInfoUtil
)
// 显示连接对话框
if (showConnectionDialog) {
ConnectionDialog(
isScanning = isScanning,
@@ -314,9 +370,9 @@ class MainActivity : ComponentActivity() {
deviceStatus = "正在连接..."
Log.d(TAG, "Connecting to device name=${device.name ?: "Unknown"} address=${device.address}")
// 检查蓝牙适配器状态
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled) {
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val bluetoothAdapter = bluetoothManager.adapter
if (bluetoothAdapter == null || bluetoothAdapter.isEnabled != true) {
deviceStatus = "蓝牙未启用"
Log.e(TAG, "Bluetooth adapter unavailable or disabled")
return
@@ -326,16 +382,16 @@ class MainActivity : ComponentActivity() {
runOnUiThread {
if (connected) {
deviceStatus = "已连接"
temporaryStatusMessage = null
Log.d(TAG, "Connected to device name=${device.name ?: "Unknown"}")
} else {
deviceStatus = "连接失败或已断开连接"
Log.e(TAG, "Connection failed name=${device.name ?: "Unknown"}")
deviceStatus = "连接失败,正在重试..."
Log.e(TAG, "Connection failed, auto-retry enabled for name=${device.name ?: "Unknown"}")
}
}
}
deviceAddress = device.address
stopScan()
}
@@ -345,7 +401,8 @@ class MainActivity : ComponentActivity() {
runOnUiThread {
try {
val isTestData = jsonData.optBoolean("test_flag", false)
lastUpdateTime = Date()
lastUpdateTime = Date()
temporaryStatusMessage = null
if (isTestData) {
Log.i(TAG, "Received keep-alive signal")
@@ -384,45 +441,26 @@ class MainActivity : ComponentActivity() {
}
private fun exportRecordsToCSV() {
val records = trainRecordManager.getFilteredRecords()
val file = trainRecordManager.exportToCsv(records)
if (file != null) {
try {
val uri = FileProvider.getUriForFile(
this,
"${applicationContext.packageName}.provider",
file
)
val intent = Intent(Intent.ACTION_SEND)
intent.type = "text/csv"
intent.putExtra(Intent.EXTRA_STREAM, uri)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
startActivity(Intent.createChooser(intent, "分享CSV文件"))
} catch (e: Exception) {
Log.e(TAG, "CSV export failed: ${e.message}")
Toast.makeText(this, "导出失败: ${e.message}", Toast.LENGTH_SHORT).show()
}
} else {
Toast.makeText(this, "导出CSV文件失败", Toast.LENGTH_SHORT).show()
}
}
private fun updateTemporaryStatusMessage(message: String) {
temporaryStatusMessage = message
Handler(Looper.getMainLooper()).postDelayed({
if (temporaryStatusMessage == message) {
temporaryStatusMessage = null
}
}, 3000)
}
private fun startScan() {
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
private fun startAutoScanAndConnect() {
Log.d(TAG, "Starting auto scan and connect")
if (!hasBluetoothPermissions()) {
Log.e(TAG, "Missing bluetooth permissions for auto scan")
deviceStatus = "需要蓝牙和位置权限"
return
}
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val bluetoothAdapter = bluetoothManager.adapter
if (bluetoothAdapter == null) {
Log.e(TAG, "Bluetooth adapter unavailable")
deviceStatus = "设备不支持蓝牙"
@@ -435,26 +473,59 @@ class MainActivity : ComponentActivity() {
return
}
bleClient.setAutoReconnect(true)
val targetDeviceName = if (settingsDeviceName.isNotBlank() && settingsDeviceName != "LBJReceiver") {
settingsDeviceName
} else {
"LBJReceiver"
}
Log.d(TAG, "Auto scanning for target device: $targetDeviceName")
deviceStatus = "正在自动扫描连接..."
bleClient.scanDevices(targetDeviceName) { device ->
val deviceName = device.name ?: "Unknown"
Log.d(TAG, "Auto scan found device: $deviceName")
if (deviceName.equals(targetDeviceName, ignoreCase = true)) {
Log.d(TAG, "Found target device, auto connecting to: $deviceName")
bleClient.stopScan()
connectToDevice(device)
}
}
}
private fun startScan() {
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val bluetoothAdapter = bluetoothManager.adapter
if (bluetoothAdapter == null) {
Log.e(TAG, "Bluetooth adapter unavailable")
deviceStatus = "设备不支持蓝牙"
return
}
if (!bluetoothAdapter.isEnabled) {
Log.e(TAG, "Bluetooth adapter disabled")
deviceStatus = "请启用蓝牙"
return
}
bleClient.setAutoReconnect(true)
isScanning = true
foundDevices = emptyList()
val targetDeviceName = settingsDeviceName.ifBlank { null }
Log.d(TAG, "Starting BLE scan target=${targetDeviceName ?: "Any"}")
val targetDeviceName = if (settingsDeviceName.isNotBlank() && settingsDeviceName != "LBJReceiver") {
settingsDeviceName
} else {
null
}
Log.d(TAG, "Starting continuous BLE scan target=${targetDeviceName ?: "Any"} (settings=${settingsDeviceName})")
bleClient.scanDevices(targetDeviceName) { device ->
if (!foundDevices.any { it.address == device.address }) {
Log.d(TAG, "Found device name=${device.name ?: "Unknown"} address=${device.address}")
foundDevices = foundDevices + device
if (targetDeviceName != null && device.name == targetDeviceName) {
Log.d(TAG, "Found target=$targetDeviceName, connecting")
stopScan()
connectToDevice(device)
} else {
// 如果没有指定目标设备名称,或者找到的设备不是目标设备,显示连接对话框
if (targetDeviceName == null) {
showConnectionDialog = true
}
}
}
}
}
@@ -465,6 +536,21 @@ class MainActivity : ComponentActivity() {
bleClient.stopScan()
Log.d(TAG, "Stopped BLE scan")
}
private fun hasBluetoothPermissions(): Boolean {
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
} else {
ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_ADMIN) == PackageManager.PERMISSION_GRANTED
}
val locationPermissions = ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
return bluetoothPermissions && locationPermissions
}
private fun updateDeviceList() {
@@ -475,15 +561,79 @@ class MainActivity : ComponentActivity() {
private fun loadSettings() {
settingsDeviceName = settingsPrefs.getString("device_name", "LBJReceiver") ?: "LBJReceiver"
targetDeviceName = settingsDeviceName
Log.d(TAG, "Loaded settings deviceName=${settingsDeviceName}")
currentTab = settingsPrefs.getInt("current_tab", 0)
historyEditMode = settingsPrefs.getBoolean("history_edit_mode", false)
val selectedRecordsStr = settingsPrefs.getString("history_selected_records", "")
historySelectedRecords = if (selectedRecordsStr.isNullOrEmpty()) {
emptySet()
} else {
selectedRecordsStr.split(",").toSet()
}
val expandedStatesStr = settingsPrefs.getString("history_expanded_states", "")
historyExpandedStates = if (expandedStatesStr.isNullOrEmpty()) {
emptyMap()
} else {
expandedStatesStr.split(";").mapNotNull { pair ->
val parts = pair.split(":")
if (parts.size == 2) parts[0] to (parts[1] == "true") else null
}.toMap()
}
historyScrollPosition = settingsPrefs.getInt("history_scroll_position", 0)
historyScrollOffset = settingsPrefs.getInt("history_scroll_offset", 0)
val centerLat = settingsPrefs.getFloat("map_center_lat", Float.NaN)
val centerLon = settingsPrefs.getFloat("map_center_lon", Float.NaN)
mapCenterPosition = if (!centerLat.isNaN() && !centerLon.isNaN()) {
centerLat.toDouble() to centerLon.toDouble()
} else null
mapZoomLevel = settingsPrefs.getFloat("map_zoom_level", 10.0f).toDouble()
mapRailwayLayerVisible = settingsPrefs.getBoolean("map_railway_visible", true)
Log.d(TAG, "Loaded settings deviceName=${settingsDeviceName} tab=${currentTab}")
}
private fun saveSettings() {
settingsPrefs.edit()
val editor = settingsPrefs.edit()
.putString("device_name", settingsDeviceName)
.apply()
Log.d(TAG, "Saved settings deviceName=${settingsDeviceName}")
.putInt("current_tab", currentTab)
.putBoolean("history_edit_mode", historyEditMode)
.putString("history_selected_records", historySelectedRecords.joinToString(","))
.putString("history_expanded_states", historyExpandedStates.map { "${it.key}:${it.value}" }.joinToString(";"))
.putInt("history_scroll_position", historyScrollPosition)
.putInt("history_scroll_offset", historyScrollOffset)
.putFloat("map_zoom_level", mapZoomLevel.toFloat())
.putBoolean("map_railway_visible", mapRailwayLayerVisible)
mapCenterPosition?.let { (lat, lon) ->
editor.putFloat("map_center_lat", lat.toFloat())
editor.putFloat("map_center_lon", lon.toFloat())
}
editor.apply()
Log.d(TAG, "Saved settings deviceName=${settingsDeviceName} tab=${currentTab} mapCenter=${mapCenterPosition} zoom=${mapZoomLevel}")
}
override fun onResume() {
super.onResume()
Log.d(TAG, "App resumed")
if (hasBluetoothPermissions() && !bleClient.isConnected()) {
Log.d(TAG, "App resumed and not connected, starting auto scan")
startAutoScanAndConnect()
}
}
override fun onPause() {
super.onPause()
saveSettings()
Log.d(TAG, "App paused, settings saved")
}
}
@@ -514,16 +664,31 @@ fun MainContent(
onFilterChange: (String, String, String) -> Unit,
onClearFilter: () -> Unit,
onClearRecords: () -> Unit,
onExportRecords: () -> Unit,
onDeleteRecords: (List<TrainRecord>) -> Unit,
deviceName: String,
onDeviceNameChange: (String) -> Unit,
onApplySettings: () -> Unit,
appVersion: String,
locoInfoUtil: LocoInfoUtil
locoInfoUtil: LocoInfoUtil,
historyEditMode: Boolean,
historySelectedRecords: Set<String>,
historyExpandedStates: Map<String, Boolean>,
historyScrollPosition: Int,
historyScrollOffset: Int,
onHistoryStateChange: (Boolean, Set<String>, Map<String, Boolean>, Int, Int) -> Unit,
mapCenterPosition: Pair<Double, Double>?,
mapZoomLevel: Double,
mapRailwayLayerVisible: Boolean,
onMapStateChange: (Pair<Double, Double>?, Double, Boolean) -> Unit
) {
val statusColor = if (isConnected) Color(0xFF4CAF50) else Color(0xFFFF5722)
@@ -539,7 +704,8 @@ fun MainContent(
diffInSec < 3600 -> "${diffInSec / 60}分钟前"
else -> "${diffInSec / 3600}小时前"
}
delay(1000)
val updateInterval = if (diffInSec < 60) 500L else if (diffInSec < 3600) 30000L else 300000L
delay(updateInterval)
}
} else {
timeSinceLastUpdate.value = null
@@ -548,37 +714,83 @@ fun MainContent(
Scaffold(
topBar = {
TopAppBar(
title = { Text("LBJ Console") },
actions = {
timeSinceLastUpdate.value?.let { time ->
Text(
text = time,
modifier = Modifier.padding(end = 8.dp),
style = MaterialTheme.typography.bodySmall
)
}
Box(
modifier = Modifier
.size(10.dp)
.background(
color = statusColor,
shape = CircleShape
Box {
TopAppBar(
title = { Text("LBJ Console") },
actions = {
timeSinceLastUpdate.value?.let { time ->
Text(
text = time,
modifier = Modifier.padding(end = 8.dp),
style = MaterialTheme.typography.bodySmall
)
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = onConnectClick) {
Icon(
imageVector = Icons.Default.Bluetooth,
contentDescription = "连接蓝牙设备"
}
Box(
modifier = Modifier
.size(10.dp)
.background(
color = statusColor,
shape = CircleShape
)
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = onConnectClick) {
Icon(
imageVector = Icons.Default.Bluetooth,
contentDescription = "连接蓝牙设备"
)
}
}
)
if (historyEditMode && currentTab == 0) {
TopAppBar(
title = {
Text(
"已选择 ${historySelectedRecords.size} 条记录",
color = MaterialTheme.colorScheme.onPrimary
)
},
navigationIcon = {
IconButton(onClick = {
onHistoryStateChange(false, emptySet(), historyExpandedStates, historyScrollPosition, historyScrollOffset)
}) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "取消",
tint = MaterialTheme.colorScheme.onPrimary
)
}
},
actions = {
IconButton(
onClick = {
if (historySelectedRecords.isNotEmpty()) {
val recordsToDelete = allRecords.filter {
historySelectedRecords.contains(it.timestamp.time.toString())
}
onDeleteRecords(recordsToDelete)
onHistoryStateChange(false, emptySet(), historyExpandedStates, historyScrollPosition, historyScrollOffset)
}
}
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "删除所选记录",
tint = MaterialTheme.colorScheme.onPrimary
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary
)
)
}
)
}
},
bottomBar = {
NavigationBar {
@@ -618,19 +830,28 @@ fun MainContent(
temporaryStatusMessage = temporaryStatusMessage,
locoInfoUtil = locoInfoUtil,
onClearRecords = onClearRecords,
onExportRecords = onExportRecords,
onRecordClick = onRecordClick,
onClearLog = onClearMonitorLog,
onDeleteRecords = onDeleteRecords
onDeleteRecords = onDeleteRecords,
editMode = historyEditMode,
selectedRecords = historySelectedRecords,
expandedStates = historyExpandedStates,
scrollPosition = historyScrollPosition,
scrollOffset = historyScrollOffset,
onStateChange = onHistoryStateChange
)
2 -> SettingsScreen(
deviceName = deviceName,
onDeviceNameChange = onDeviceNameChange,
onApplySettings = onApplySettings,
appVersion = appVersion
)
3 -> MapScreen(
records = if (allRecords.isNotEmpty()) allRecords else recentRecords,
onCenterMap = {}
centerPosition = mapCenterPosition,
zoomLevel = mapZoomLevel,
railwayLayerVisible = mapRailwayLayerVisible,
onStateChange = onMapStateChange
)
}
}

View File

@@ -12,6 +12,7 @@ class TrainRecord(jsonData: JSONObject? = null) {
}
var timestamp: Date = Date()
var receivedTimestamp: Date = Date()
var train: String = ""
var direction: Int = 0
var speed: String = ""
@@ -34,6 +35,17 @@ class TrainRecord(jsonData: JSONObject? = null) {
timestamp = Date(jsonData.getLong("timestamp"))
}
if (jsonData.has("receivedTimestamp")) {
receivedTimestamp = Date(jsonData.getLong("receivedTimestamp"))
} else {
receivedTimestamp = if (jsonData.has("timestamp")) {
Date(jsonData.getLong("timestamp"))
} else {
Date()
}
}
updateFromJson(it)
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize TrainRecord from JSON: ${e.message}")
@@ -96,7 +108,7 @@ class TrainRecord(jsonData: JSONObject? = null) {
!trimmed.all { it == '*' }
}
fun toMap(): Map<String, String> {
fun toMap(showDetailedTime: Boolean = false): Map<String, String> {
val directionText = when (direction) {
1 -> "下行"
3 -> "上行"
@@ -114,12 +126,32 @@ class TrainRecord(jsonData: JSONObject? = null) {
val map = mutableMapOf<String, String>()
val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault())
map["timestamp"] = dateFormat.format(timestamp)
map["receivedTimestamp"] = dateFormat.format(receivedTimestamp)
if (trainDisplay.isNotEmpty()) map["train"] = trainDisplay
if (directionText != "未知") map["direction"] = directionText
if (isValidValue(speed)) map["speed"] = "速度: ${speed.trim()} km/h"
if (isValidValue(position)) map["position"] = "位置: ${position.trim()} km"
if (isValidValue(time)) map["time"] = "列车时间: ${time.trim()}"
val timeToDisplay = if (showDetailedTime) {
val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault())
if (isValidValue(time)) {
"列车时间: $time\n接收时间: ${dateFormat.format(receivedTimestamp)}"
} else {
dateFormat.format(receivedTimestamp)
}
} else {
val currentTime = System.currentTimeMillis()
val diffInSec = (currentTime - receivedTimestamp.time) / 1000
when {
diffInSec < 60 -> "${diffInSec}秒前"
diffInSec < 3600 -> "${diffInSec / 60}分钟前"
else -> "${diffInSec / 3600}小时前"
}
}
map["time"] = timeToDisplay
if (isValidValue(loco)) map["loco"] = "机车号: ${loco.trim()}"
if (isValidValue(locoType)) map["loco_type"] = "型号: ${locoType.trim()}"
if (isValidValue(route)) map["route"] = "线路: ${route.trim()}"
@@ -135,6 +167,7 @@ class TrainRecord(jsonData: JSONObject? = null) {
fun toJSON(): JSONObject {
val json = JSONObject()
json.put("timestamp", timestamp.time)
json.put("receivedTimestamp", receivedTimestamp.time)
json.put("train", train)
json.put("dir", direction)
json.put("speed", speed)

View File

@@ -38,6 +38,7 @@ class TrainRecordManager(private val context: Context) {
fun addRecord(jsonData: JSONObject): TrainRecord {
val record = TrainRecord(jsonData)
record.receivedTimestamp = Date()
trainRecords.add(0, record)
@@ -170,44 +171,7 @@ class TrainRecordManager(private val context: Context) {
}
}
fun exportToCsv(records: List<TrainRecord>): File? {
try {
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val fileName = "train_records_$timeStamp.csv"
val downloadsDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
val file = File(downloadsDir, fileName)
FileWriter(file).use { writer ->
writer.append("时间戳,列车号,列车类型,方向,速度,位置,时间,机车号,机车类型,路线,位置信息,信号强度\n")
for (record in records) {
val map = record.toMap()
writer.append(map["timestamp"]).append(",")
writer.append(map["train"]).append(",")
writer.append(map["lbj_class"]).append(",")
writer.append(map["direction"]).append(",")
writer.append(map["speed"]?.replace(" km/h", "") ?: "").append(",")
writer.append(map["position"]?.replace(" km", "") ?: "").append(",")
writer.append(map["time"]).append(",")
writer.append(map["loco"]).append(",")
writer.append(map["loco_type"]).append(",")
writer.append(map["route"]).append(",")
writer.append(map["position_info"]).append(",")
writer.append(map["rssi"]?.replace(" dBm", "") ?: "").append("\n")
}
}
return file
} catch (e: Exception) {
Log.e(TAG, "Error exporting to CSV: ${e.message}")
return null
}
}
fun getRecordCount(): Int {

View File

@@ -82,7 +82,7 @@ fun TrainDetailDialog(
DetailItem("机车类型", recordMap["loco_type"] ?: "--")
DetailItem("列车类型", recordMap["lbj_class"] ?: "--")
Divider(modifier = Modifier.padding(vertical = 8.dp))
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
DetailItem("路线", recordMap["route"] ?: "--")

View File

@@ -68,7 +68,19 @@ fun TrainInfoCard(
}
Text(
text = recordMap["timestamp"]?.toString()?.split(" ")?.getOrNull(1) ?: "",
text = run {
val trainTime = trainRecord.time.trim()
if (trainTime.isNotEmpty() && trainTime != "NUL" && trainTime != "<NUL>" && trainTime != "NA" && trainTime != "<NA>") {
trainTime
} else {
val receivedTime = recordMap["receivedTimestamp"]?.toString() ?: ""
if (receivedTime.contains(" ")) {
receivedTime.split(" ")[1]
} else {
java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault()).format(trainRecord.receivedTimestamp)
}
}
},
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -138,4 +150,4 @@ private fun CompactInfoItem(
color = MaterialTheme.colorScheme.onSurface
)
}
}
}

View File

@@ -140,7 +140,6 @@ fun TrainRecordsListWithToolbar(
records: List<TrainRecord>,
onRecordClick: (TrainRecord) -> Unit,
onFilterClick: () -> Unit,
onExportClick: () -> Unit,
onClearClick: () -> Unit,
onDeleteRecords: (List<TrainRecord>) -> Unit,
modifier: Modifier = Modifier
@@ -198,12 +197,6 @@ fun TrainRecordsListWithToolbar(
contentDescription = "筛选"
)
}
IconButton(onClick = onExportClick) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "导出"
)
}
}
}
}

View File

@@ -7,7 +7,9 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material.icons.Icons
@@ -44,18 +46,33 @@ fun HistoryScreen(
temporaryStatusMessage: String? = null,
locoInfoUtil: LocoInfoUtil? = null,
onClearRecords: () -> Unit = {},
onExportRecords: () -> Unit = {},
onRecordClick: (TrainRecord) -> Unit = {},
onClearLog: () -> Unit = {},
onDeleteRecords: (List<TrainRecord>) -> Unit = {}
onDeleteRecords: (List<TrainRecord>) -> Unit = {},
editMode: Boolean = false,
selectedRecords: Set<String> = emptySet(),
expandedStates: Map<String, Boolean> = emptyMap(),
scrollPosition: Int = 0,
scrollOffset: Int = 0,
onStateChange: (Boolean, Set<String>, Map<String, Boolean>, Int, Int) -> Unit = { _, _, _, _, _ -> }
) {
val refreshKey = latestRecord?.timestamp?.time ?: 0
var isInEditMode by remember { mutableStateOf(false) }
val selectedRecords = remember { mutableStateListOf<TrainRecord>() }
val expandedStates = remember { mutableStateMapOf<String, Boolean>() }
var isInEditMode by remember(editMode) { mutableStateOf(editMode) }
val selectedRecordsList = remember(selectedRecords) {
mutableStateListOf<TrainRecord>().apply {
addAll(records.filter { selectedRecords.contains(it.timestamp.time.toString()) })
}
}
val expandedStatesMap = remember(expandedStates) {
mutableStateMapOf<String, Boolean>().apply { putAll(expandedStates) }
}
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = scrollPosition,
initialFirstVisibleItemScrollOffset = scrollOffset
)
val timeSinceLastUpdate = remember { mutableStateOf<String?>(null) }
@@ -69,7 +86,8 @@ fun HistoryScreen(
diffInSec < 3600 -> "${diffInSec / 60}分钟前"
else -> "${diffInSec / 3600}小时前"
}
delay(1000)
val updateInterval = if (diffInSec < 60) 500L else if (diffInSec < 3600) 30000L else 300000L
delay(updateInterval)
}
}
}
@@ -77,14 +95,30 @@ fun HistoryScreen(
records
}
fun exitEditMode() {
isInEditMode = false
selectedRecords.clear()
LaunchedEffect(isInEditMode, selectedRecordsList.size) {
val selectedIds = selectedRecordsList.map { it.timestamp.time.toString() }.toSet()
onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
}
LaunchedEffect(expandedStatesMap.toMap()) {
if (!isInEditMode) {
val selectedIds = selectedRecordsList.map { it.timestamp.time.toString() }.toSet()
onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
}
}
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
if (!isInEditMode) {
val selectedIds = selectedRecordsList.map { it.timestamp.time.toString() }.toSet()
onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
}
}
LaunchedEffect(selectedRecords.size) {
if (selectedRecords.isEmpty() && isInEditMode) {
exitEditMode()
LaunchedEffect(selectedRecordsList.size) {
if (selectedRecordsList.isEmpty() && isInEditMode) {
isInEditMode = false
onStateChange(false, emptySet(), expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
}
}
@@ -126,11 +160,12 @@ fun HistoryScreen(
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(filteredRecords) { record ->
val isSelected = selectedRecords.contains(record)
val isSelected = selectedRecordsList.contains(record)
val cardColor = when {
isSelected -> MaterialTheme.colorScheme.primaryContainer
else -> MaterialTheme.colorScheme.surface
@@ -151,14 +186,14 @@ fun HistoryScreen(
onClick = {
if (isInEditMode) {
if (isSelected) {
selectedRecords.remove(record)
selectedRecordsList.remove(record)
} else {
selectedRecords.add(record)
selectedRecordsList.add(record)
}
} else {
val id = record.timestamp.time.toString()
expandedStates[id] =
!(expandedStates[id] ?: false)
expandedStatesMap[id] =
!(expandedStatesMap[id] ?: false)
if (record == latestRecord) {
onRecordClick(record)
}
@@ -167,8 +202,8 @@ fun HistoryScreen(
onLongClick = {
if (!isInEditMode) {
isInEditMode = true
selectedRecords.clear()
selectedRecords.add(record)
selectedRecordsList.clear()
selectedRecordsList.add(record)
}
},
interactionSource = remember { MutableInteractionSource() },
@@ -178,9 +213,37 @@ fun HistoryScreen(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
val recordMap = record.toMap()
val recordId = record.timestamp.time.toString()
val isExpanded = expandedStatesMap[recordId] == true
val recordMap = record.toMap(showDetailedTime = true)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (recordMap.containsKey("time")) {
Column {
recordMap["time"]?.split("\n")?.forEach { timeLine ->
Text(
text = timeLine,
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Text(
text = "${record.rssi} dBm",
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(2.dp))
Row(
modifier = Modifier.fillMaxWidth(),
@@ -190,21 +253,6 @@ fun HistoryScreen(
val trainDisplay =
recordMap["train"]?.toString() ?: "未知列车"
val formattedInfo = when {
record.locoType.isNotEmpty() && record.loco.isNotEmpty() -> {
val shortLoco = if (record.loco.length > 5) {
record.loco.takeLast(5)
} else {
record.loco
}
"${record.locoType}-${shortLoco}"
}
record.locoType.isNotEmpty() -> record.locoType
record.loco.isNotEmpty() -> record.loco
else -> ""
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
@@ -243,30 +291,27 @@ fun HistoryScreen(
}
}
}
if (formattedInfo.isNotEmpty() && formattedInfo != "<NUL>") {
Text(
text = formattedInfo,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Text(
text = "${record.rssi} dBm",
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
val formattedInfo = when {
record.locoType.isNotEmpty() && record.loco.isNotEmpty() -> {
val shortLoco = if (record.loco.length > 5) {
record.loco.takeLast(5)
} else {
record.loco
}
"${record.locoType}-${shortLoco}"
}
Spacer(modifier = Modifier.height(4.dp))
if (recordMap.containsKey("time")) {
recordMap["time"]?.split("\n")?.forEach { timeLine ->
record.locoType.isNotEmpty() -> record.locoType
record.loco.isNotEmpty() -> record.loco
else -> ""
}
if (formattedInfo.isNotEmpty() && formattedInfo != "<NUL>") {
Text(
text = timeLine,
fontSize = 12.sp,
text = formattedInfo,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@@ -342,8 +387,7 @@ fun HistoryScreen(
}
}
val recordId = record.timestamp.time.toString()
if (expandedStates[recordId] == true) {
if (isExpanded) {
val coordinates = remember { record.getCoordinates() }
if (coordinates != null) {
@@ -497,57 +541,4 @@ fun HistoryScreen(
}
}
if (isInEditMode) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.background(MaterialTheme.colorScheme.primary)
) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
IconButton(onClick = { exitEditMode() }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "取消",
tint = MaterialTheme.colorScheme.onPrimary
)
}
Text(
"已选择 ${selectedRecords.size} 条记录",
color = MaterialTheme.colorScheme.onPrimary
)
}
IconButton(
onClick = {
if (selectedRecords.isNotEmpty()) {
onDeleteRecords(selectedRecords.toList())
exitEditMode()
}
}
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "删除所选记录",
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
}
}
}
}

View File

@@ -33,6 +33,9 @@ import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.*
import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider
import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
import org.osmdroid.events.MapListener
import org.osmdroid.events.ScrollEvent
import org.osmdroid.events.ZoomEvent
import org.noxylva.lbjconsole.model.TrainRecord
import java.io.File
@@ -41,7 +44,11 @@ import java.io.File
fun MapScreen(
records: List<TrainRecord>,
onCenterMap: () -> Unit = {},
onLocationError: (String) -> Unit = {}
onLocationError: (String) -> Unit = {},
centerPosition: Pair<Double, Double>? = null,
zoomLevel: Double = 10.0,
railwayLayerVisible: Boolean = true,
onStateChange: (Pair<Double, Double>?, Double, Boolean) -> Unit = { _, _, _ -> }
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
@@ -90,7 +97,7 @@ fun MapScreen(
var selectedRecord by remember { mutableStateOf<TrainRecord?>(null) }
var dialogPosition by remember { mutableStateOf<GeoPoint?>(null) }
var railwayLayerVisible by remember { mutableStateOf(true) }
var railwayLayerVisibleState by remember(railwayLayerVisible) { mutableStateOf(railwayLayerVisible) }
DisposableEffect(lifecycleOwner) {
@@ -277,14 +284,21 @@ fun MapScreen(
}
if (validRecords.isNotEmpty()) {
validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint ->
controller.setCenter(lastPoint)
controller.setZoom(12.0)
centerPosition?.let { (lat, lon) ->
controller.setCenter(GeoPoint(lat, lon))
controller.setZoom(zoomLevel)
isMapInitialized = true
Log.d("MapScreen", "Map initialized with saved state: lat=$lat, lon=$lon, zoom=$zoomLevel")
} ?: run {
if (validRecords.isNotEmpty()) {
validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint ->
controller.setCenter(lastPoint)
controller.setZoom(12.0)
}
} else {
controller.setCenter(defaultPosition)
controller.setZoom(10.0)
}
} else {
controller.setCenter(defaultPosition)
controller.setZoom(10.0)
}
@@ -292,7 +306,7 @@ fun MapScreen(
val locationProvider = GpsMyLocationProvider(ctx).apply {
locationUpdateMinDistance = 10f
locationUpdateMinTime = 1000
locationUpdateMinTime = 5000
}
@@ -304,30 +318,30 @@ fun MapScreen(
myLocation?.let { location ->
currentLocation = GeoPoint(location.latitude, location.longitude)
if (!isMapInitialized) {
controller.setCenter(location)
controller.setZoom(15.0)
isMapInitialized = true
Log.d("MapScreen", "Map initialized with GPS position: $location")
}
if (!isMapInitialized && centerPosition == null) {
controller.setCenter(location)
controller.setZoom(15.0)
isMapInitialized = true
Log.d("MapScreen", "Map initialized with GPS position: $location")
}
} ?: run {
if (!isMapInitialized) {
if (validRecords.isNotEmpty()) {
validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint ->
controller.setCenter(lastPoint)
controller.setZoom(12.0)
isMapInitialized = true
Log.d("MapScreen", "Map initialized with last record position: $lastPoint")
}
} else {
controller.setCenter(defaultPosition)
isMapInitialized = true
}
}
if (!isMapInitialized && centerPosition == null) {
if (validRecords.isNotEmpty()) {
validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint ->
controller.setCenter(lastPoint)
controller.setZoom(12.0)
isMapInitialized = true
Log.d("MapScreen", "Map initialized with last record position: $lastPoint")
}
} else {
controller.setCenter(defaultPosition)
isMapInitialized = true
}
}
}
} catch (e: Exception) {
e.printStackTrace()
if (!isMapInitialized) {
if (!isMapInitialized && centerPosition == null) {
if (validRecords.isNotEmpty()) {
validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint ->
controller.setCenter(lastPoint)
@@ -357,6 +371,31 @@ fun MapScreen(
setAlignBottom(true)
setLineWidth(2.0f)
}.also { overlays.add(it) }
addMapListener(object : MapListener {
override fun onScroll(event: ScrollEvent?): Boolean {
val center = mapCenter
val zoom = zoomLevelDouble
onStateChange(
center.latitude to center.longitude,
zoom,
railwayLayerVisibleState
)
return true
}
override fun onZoom(event: ZoomEvent?): Boolean {
val center = mapCenter
val zoom = zoomLevelDouble
onStateChange(
center.latitude to center.longitude,
zoom,
railwayLayerVisibleState
)
return true
}
})
} catch (e: Exception) {
e.printStackTrace()
onLocationError("Map component initialization failed: ${e.localizedMessage}")
@@ -381,7 +420,7 @@ fun MapScreen(
coroutineScope.launch {
updateMarkers()
updateRailwayLayerVisibility(railwayLayerVisible)
updateRailwayLayerVisibility(railwayLayerVisibleState)
}
}
)
@@ -430,15 +469,26 @@ fun MapScreen(
FloatingActionButton(
onClick = {
railwayLayerVisible = !railwayLayerVisible
updateRailwayLayerVisibility(railwayLayerVisible)
railwayLayerVisibleState = !railwayLayerVisibleState
updateRailwayLayerVisibility(railwayLayerVisibleState)
mapViewRef.value?.let { mapView ->
val center = mapView.mapCenter
val zoom = mapView.zoomLevelDouble
onStateChange(
center.latitude to center.longitude,
zoom,
railwayLayerVisibleState
)
}
},
modifier = Modifier.size(40.dp),
containerColor = if (railwayLayerVisible)
containerColor = if (railwayLayerVisibleState)
MaterialTheme.colorScheme.primary.copy(alpha = 0.9f)
else
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f),
contentColor = if (railwayLayerVisible)
contentColor = if (railwayLayerVisibleState)
MaterialTheme.colorScheme.onPrimary
else
MaterialTheme.colorScheme.onPrimaryContainer

View File

@@ -41,7 +41,8 @@ fun MonitorScreen(
diffInSec < 3600 -> "${diffInSec / 60}分钟前"
else -> "${diffInSec / 3600}小时前"
}
delay(1000)
val updateInterval = if (diffInSec < 60) 500L else if (diffInSec < 3600) 30000L else 300000L
delay(updateInterval)
}
}
}
@@ -247,4 +248,4 @@ private fun InfoItem(
color = MaterialTheme.colorScheme.onSurface
)
}
}
}

View File

@@ -14,7 +14,8 @@ import androidx.compose.ui.unit.dp
fun SettingsScreen(
deviceName: String,
onDeviceNameChange: (String) -> Unit,
onApplySettings: () -> Unit
onApplySettings: () -> Unit,
appVersion: String = "Unknown"
) {
val uriHandler = LocalUriHandler.current
@@ -46,7 +47,7 @@ fun SettingsScreen(
Spacer(modifier = Modifier.weight(1f))
Text(
text = "LBJ Console v0.0.1 by undef-i",
text = "LBJ Console v$appVersion by undef-i",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clickable {