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

View File

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

View File

@@ -19,8 +19,6 @@ import java.util.*
class BLEClient(private val context: Context) : BluetoothGattCallback() { class BLEClient(private val context: Context) : BluetoothGattCallback() {
companion object { companion object {
const val TAG = "LBJ_BT" const val TAG = "LBJ_BT"
const val SCAN_PERIOD = 10000L
val SERVICE_UUID = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb") val SERVICE_UUID = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb")
val CHAR_UUID = UUID.fromString("0000ffe1-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 targetDeviceName: String? = null
private var bluetoothLeScanner: BluetoothLeScanner? = 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() { private val leScanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) { override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device val device = result.device
val deviceName = device.name val deviceName = device.name
if (targetDeviceName != null) {
if (deviceName == null || !deviceName.equals(targetDeviceName, ignoreCase = true)) { val shouldShowDevice = when {
return 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)
)
} }
} }
if (shouldShowDevice) {
Log.d(TAG, "Showing filtered device: $deviceName")
scanCallback?.invoke(device) 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) { override fun onScanFailed(errorCode: Int) {
Log.e(TAG, "BLE scan failed code=$errorCode") 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 { try {
scanCallback = callback scanCallback = callback
this.targetDeviceName = targetDeviceName 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") Log.e(TAG, "Bluetooth adapter unavailable")
return return
} }
if (!bluetoothAdapter.isEnabled) { if (bluetoothAdapter.isEnabled != true) {
Log.e(TAG, "Bluetooth adapter disabled") Log.e(TAG, "Bluetooth adapter disabled")
return return
} }
@@ -106,12 +154,9 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
return return
} }
handler.postDelayed({
stopScan()
}, SCAN_PERIOD)
isScanning = true 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) bluetoothLeScanner?.startScan(leScanCallback)
} catch (e: SecurityException) { } catch (e: SecurityException) {
Log.e(TAG, "Scan security error: ${e.message}") Log.e(TAG, "Scan security error: ${e.message}")
@@ -126,6 +171,40 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
if (isScanning) { if (isScanning) {
bluetoothLeScanner?.stopScan(leScanCallback) bluetoothLeScanner?.stopScan(leScanCallback)
isScanning = false 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 { 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") Log.e(TAG, "Bluetooth adapter unavailable")
handler.post { onConnectionStateChange?.invoke(false) } handler.post { onConnectionStateChange?.invoke(false) }
return false return false
} }
if (!bluetoothAdapter.isEnabled) { if (bluetoothAdapter.isEnabled != true) {
Log.e(TAG, "Bluetooth adapter is disabled") Log.e(TAG, "Bluetooth adapter is disabled")
handler.post { onConnectionStateChange?.invoke(false) } handler.post { onConnectionStateChange?.invoke(false) }
return false return false
@@ -183,16 +263,6 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
Log.d(TAG, "Connecting to address=$address") 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 return true
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Connection failed: ${e.message}") Log.e(TAG, "Connection failed: ${e.message}")
@@ -284,30 +354,30 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
if (status != BluetoothGatt.GATT_SUCCESS) { if (status != BluetoothGatt.GATT_SUCCESS) {
Log.e(TAG, "Connection error status=$status") Log.e(TAG, "Connection error status=$status")
isConnected = false isConnected = false
isReconnecting = false
if (status == 133 || status == 8) { if (status == 133 || status == 8) {
Log.e(TAG, "GATT error closing connection") Log.e(TAG, "GATT error, attempting immediate reconnection")
try { try {
gatt.close() gatt.close()
bluetoothGatt = null bluetoothGatt = null
deviceAddress?.let { address -> deviceAddress?.let { address ->
handler.postDelayed({ if (autoReconnect) {
Log.d(TAG, "Reconnecting to device") Log.d(TAG, "Immediate reconnection to device")
val device = handler.post {
BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address) val device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address)
bluetoothGatt = device.connectGatt( bluetoothGatt = device.connectGatt(
context, context,
false, false,
this, this,
BluetoothDevice.TRANSPORT_LE BluetoothDevice.TRANSPORT_LE
) )
}, 2000) }
}
} }
} catch (e: Exception) { } 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) { when (newState) {
BluetoothProfile.STATE_CONNECTED -> { BluetoothProfile.STATE_CONNECTED -> {
isConnected = true isConnected = true
isReconnecting = false
connectionAttempts = 0
Log.i(TAG, "Connected to GATT server") Log.i(TAG, "Connected to GATT server")
handler.post { connectionStateCallback?.invoke(true) } handler.post { connectionStateCallback?.invoke(true) }
handler.post {
handler.postDelayed({
try { try {
gatt.discoverServices() gatt.discoverServices()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Service discovery failed: ${e.message}") Log.e(TAG, "Service discovery failed: ${e.message}")
} }
}, 500) }
} }
BluetoothProfile.STATE_DISCONNECTED -> { BluetoothProfile.STATE_DISCONNECTED -> {
isConnected = false isConnected = false
isReconnecting = false
Log.i(TAG, "Disconnected from GATT server") Log.i(TAG, "Disconnected from GATT server")
handler.post { connectionStateCallback?.invoke(false) } handler.post { connectionStateCallback?.invoke(false) }
if (!deviceAddress.isNullOrBlank()) { if (!deviceAddress.isNullOrBlank() && autoReconnect) {
handler.postDelayed({ handler.post {
Log.d(TAG, "Reconnecting after disconnect") Log.d(TAG, "Immediate reconnection after disconnect")
connect(deviceAddress!!, connectionStateCallback) connect(deviceAddress!!, connectionStateCallback)
}, 3000) }
} }
} }
} }
@@ -355,12 +427,14 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
private var lastDataTime = 0L private var lastDataTime = 0L
@Suppress("DEPRECATION")
override fun onCharacteristicChanged( override fun onCharacteristicChanged(
gatt: BluetoothGatt, gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic characteristic: BluetoothGattCharacteristic
) { ) {
super.onCharacteristicChanged(gatt, characteristic) super.onCharacteristicChanged(gatt, characteristic)
@Suppress("DEPRECATION")
val newData = characteristic.value?.let { val newData = characteristic.value?.let {
String(it, StandardCharsets.UTF_8) String(it, StandardCharsets.UTF_8)
} ?: return } ?: return
@@ -379,18 +453,17 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
val bufferContent = dataBuffer.toString() val bufferContent = dataBuffer.toString()
val currentTime = System.currentTimeMillis() val currentTime = System.currentTimeMillis()
if (lastDataTime > 0) {
if (lastDataTime > 0 && currentTime - lastDataTime > 5000) { val timeDiff = currentTime - lastDataTime
Log.w(TAG, "Data timeout ${(currentTime - lastDataTime) / 1000}s") if (timeDiff > 10000) {
Log.w(TAG, "Long data gap: ${timeDiff / 1000}s")
}
} }
Log.d(TAG, "Buffer size=${dataBuffer.length} bytes") Log.d(TAG, "Buffer size=${dataBuffer.length} bytes")
tryExtractJson(bufferContent) tryExtractJson(bufferContent)
lastDataTime = currentTime lastDataTime = currentTime
} }
@@ -509,9 +582,16 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
) )
if (descriptor != null) { if (descriptor != null) {
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 descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
@Suppress("DEPRECATION")
val writeResult = gatt.writeDescriptor(descriptor) val writeResult = gatt.writeDescriptor(descriptor)
Log.d(TAG, "Descriptor write result=$writeResult") Log.d(TAG, "Descriptor write result=$writeResult")
}
} else { } else {
Log.e(TAG, "Descriptor not found") Log.e(TAG, "Descriptor not found")
@@ -538,10 +618,19 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
private fun requestDataAfterDelay() { private fun requestDataAfterDelay() {
handler.postDelayed({ handler.post {
statusCallback?.let { callback -> statusCallback?.let { callback ->
getStatus(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.Manifest
import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import java.io.File import java.io.File
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -16,6 +18,8 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* 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.material.icons.filled.LocationOn
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
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
@@ -74,11 +80,31 @@ class MainActivity : ComponentActivity() {
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 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( private val requestPermissions = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions() ActivityResultContracts.RequestMultiplePermissions()
@@ -88,9 +114,8 @@ class MainActivity : ComponentActivity() {
val locationPermissionsGranted = permissions.filter { it.key.contains("LOCATION") }.all { it.value } val locationPermissionsGranted = permissions.filter { it.key.contains("LOCATION") }.all { it.value }
if (bluetoothPermissionsGranted && locationPermissionsGranted) { if (bluetoothPermissionsGranted && locationPermissionsGranted) {
Log.d(TAG, "Permissions granted") Log.d(TAG, "Permissions granted, starting auto scan and connect")
startAutoScanAndConnect()
startScan()
} else { } else {
Log.e(TAG, "Missing permissions: $permissions") Log.e(TAG, "Missing permissions: $permissions")
deviceStatus = "需要蓝牙和位置权限" deviceStatus = "需要蓝牙和位置权限"
@@ -194,7 +219,13 @@ class MainActivity : ComponentActivity() {
Log.e(TAG, "OSM cache config failed", e) Log.e(TAG, "OSM cache config failed", e)
} }
saveSettings()
enableEdgeToEdge() enableEdgeToEdge()
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = false
}
setContent { setContent {
LBJReceiverTheme { LBJReceiverTheme {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -205,7 +236,10 @@ class MainActivity : ComponentActivity() {
isConnected = bleClient.isConnected(), isConnected = bleClient.isConnected(),
isScanning = isScanning, isScanning = isScanning,
currentTab = currentTab, currentTab = currentTab,
onTabChange = { tab -> currentTab = tab }, onTabChange = { tab ->
currentTab = tab
saveSettings()
},
onConnectClick = { showConnectionDialog = true }, onConnectClick = { showConnectionDialog = true },
@@ -228,6 +262,32 @@ class MainActivity : ComponentActivity() {
filterTrain = filterTrain, filterTrain = filterTrain,
filterRoute = filterRoute, filterRoute = filterRoute,
filterDirection = filterDirection, 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 -> onFilterChange = { train, route, direction ->
filterTrain = train filterTrain = train
filterRoute = route filterRoute = route
@@ -248,11 +308,7 @@ class MainActivity : ComponentActivity() {
temporaryStatusMessage = null temporaryStatusMessage = null
} }
}, },
onExportRecords = {
scope.launch {
exportRecordsToCSV()
}
},
onDeleteRecords = { records -> onDeleteRecords = { records ->
scope.launch { scope.launch {
val deletedCount = trainRecordManager.deleteRecords(records) val deletedCount = trainRecordManager.deleteRecords(records)
@@ -279,10 +335,10 @@ class MainActivity : ComponentActivity() {
Toast.makeText(this, "设备名称 '${settingsDeviceName}' 已保存,下次连接时生效", Toast.LENGTH_LONG).show() Toast.makeText(this, "设备名称 '${settingsDeviceName}' 已保存,下次连接时生效", Toast.LENGTH_LONG).show()
Log.d(TAG, "Applied settings deviceName=${settingsDeviceName}") Log.d(TAG, "Applied settings deviceName=${settingsDeviceName}")
}, },
appVersion = getAppVersion(),
locoInfoUtil = locoInfoUtil locoInfoUtil = locoInfoUtil
) )
// 显示连接对话框
if (showConnectionDialog) { if (showConnectionDialog) {
ConnectionDialog( ConnectionDialog(
isScanning = isScanning, isScanning = isScanning,
@@ -314,9 +370,9 @@ class MainActivity : ComponentActivity() {
deviceStatus = "正在连接..." deviceStatus = "正在连接..."
Log.d(TAG, "Connecting to device name=${device.name ?: "Unknown"} address=${device.address}") Log.d(TAG, "Connecting to device name=${device.name ?: "Unknown"} address=${device.address}")
// 检查蓝牙适配器状态 val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() val bluetoothAdapter = bluetoothManager.adapter
if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled) { if (bluetoothAdapter == null || bluetoothAdapter.isEnabled != true) {
deviceStatus = "蓝牙未启用" deviceStatus = "蓝牙未启用"
Log.e(TAG, "Bluetooth adapter unavailable or disabled") Log.e(TAG, "Bluetooth adapter unavailable or disabled")
return return
@@ -326,16 +382,16 @@ class MainActivity : ComponentActivity() {
runOnUiThread { runOnUiThread {
if (connected) { if (connected) {
deviceStatus = "已连接" deviceStatus = "已连接"
temporaryStatusMessage = null
Log.d(TAG, "Connected to device name=${device.name ?: "Unknown"}") Log.d(TAG, "Connected to device name=${device.name ?: "Unknown"}")
} else { } else {
deviceStatus = "连接失败或已断开连接" deviceStatus = "连接失败,正在重试..."
Log.e(TAG, "Connection failed name=${device.name ?: "Unknown"}") Log.e(TAG, "Connection failed, auto-retry enabled for name=${device.name ?: "Unknown"}")
} }
} }
} }
deviceAddress = device.address deviceAddress = device.address
stopScan()
} }
@@ -346,6 +402,7 @@ class MainActivity : ComponentActivity() {
try { try {
val isTestData = jsonData.optBoolean("test_flag", false) val isTestData = jsonData.optBoolean("test_flag", false)
lastUpdateTime = Date() lastUpdateTime = Date()
temporaryStatusMessage = null
if (isTestData) { if (isTestData) {
Log.i(TAG, "Received keep-alive signal") 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) { private fun updateTemporaryStatusMessage(message: String) {
temporaryStatusMessage = message temporaryStatusMessage = message
Handler(Looper.getMainLooper()).postDelayed({
if (temporaryStatusMessage == message) {
temporaryStatusMessage = null
}
}, 3000)
} }
private fun startScan() { private fun startAutoScanAndConnect() {
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() 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) { if (bluetoothAdapter == null) {
Log.e(TAG, "Bluetooth adapter unavailable") Log.e(TAG, "Bluetooth adapter unavailable")
deviceStatus = "设备不支持蓝牙" deviceStatus = "设备不支持蓝牙"
@@ -435,26 +473,59 @@ class MainActivity : ComponentActivity() {
return 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 isScanning = true
foundDevices = emptyList() foundDevices = emptyList()
val targetDeviceName = settingsDeviceName.ifBlank { null } val targetDeviceName = if (settingsDeviceName.isNotBlank() && settingsDeviceName != "LBJReceiver") {
Log.d(TAG, "Starting BLE scan target=${targetDeviceName ?: "Any"}") settingsDeviceName
} else {
null
}
Log.d(TAG, "Starting continuous BLE scan target=${targetDeviceName ?: "Any"} (settings=${settingsDeviceName})")
bleClient.scanDevices(targetDeviceName) { device -> bleClient.scanDevices(targetDeviceName) { device ->
if (!foundDevices.any { it.address == device.address }) { if (!foundDevices.any { it.address == device.address }) {
Log.d(TAG, "Found device name=${device.name ?: "Unknown"} address=${device.address}") Log.d(TAG, "Found device name=${device.name ?: "Unknown"} address=${device.address}")
foundDevices = foundDevices + device 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
}
}
} }
} }
} }
@@ -466,6 +537,21 @@ class MainActivity : ComponentActivity() {
Log.d(TAG, "Stopped BLE scan") 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() { private fun updateDeviceList() {
foundDevices = scanResults.map { it.device } foundDevices = scanResults.map { it.device }
@@ -475,15 +561,79 @@ class MainActivity : ComponentActivity() {
private fun loadSettings() { private fun loadSettings() {
settingsDeviceName = settingsPrefs.getString("device_name", "LBJReceiver") ?: "LBJReceiver" settingsDeviceName = settingsPrefs.getString("device_name", "LBJReceiver") ?: "LBJReceiver"
targetDeviceName = settingsDeviceName 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() { private fun saveSettings() {
settingsPrefs.edit() val editor = settingsPrefs.edit()
.putString("device_name", settingsDeviceName) .putString("device_name", settingsDeviceName)
.apply() .putInt("current_tab", currentTab)
Log.d(TAG, "Saved settings deviceName=${settingsDeviceName}") .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, onFilterChange: (String, String, String) -> Unit,
onClearFilter: () -> Unit, onClearFilter: () -> Unit,
onClearRecords: () -> Unit, onClearRecords: () -> Unit,
onExportRecords: () -> Unit,
onDeleteRecords: (List<TrainRecord>) -> Unit, onDeleteRecords: (List<TrainRecord>) -> Unit,
deviceName: String, deviceName: String,
onDeviceNameChange: (String) -> Unit, onDeviceNameChange: (String) -> Unit,
onApplySettings: () -> 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) val statusColor = if (isConnected) Color(0xFF4CAF50) else Color(0xFFFF5722)
@@ -539,7 +704,8 @@ fun MainContent(
diffInSec < 3600 -> "${diffInSec / 60}分钟前" diffInSec < 3600 -> "${diffInSec / 60}分钟前"
else -> "${diffInSec / 3600}小时前" else -> "${diffInSec / 3600}小时前"
} }
delay(1000) val updateInterval = if (diffInSec < 60) 500L else if (diffInSec < 3600) 30000L else 300000L
delay(updateInterval)
} }
} else { } else {
timeSinceLastUpdate.value = null timeSinceLastUpdate.value = null
@@ -548,6 +714,7 @@ fun MainContent(
Scaffold( Scaffold(
topBar = { topBar = {
Box {
TopAppBar( TopAppBar(
title = { Text("LBJ Console") }, title = { Text("LBJ Console") },
actions = { actions = {
@@ -579,6 +746,51 @@ fun MainContent(
} }
} }
) )
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 = { bottomBar = {
NavigationBar { NavigationBar {
@@ -618,19 +830,28 @@ fun MainContent(
temporaryStatusMessage = temporaryStatusMessage, temporaryStatusMessage = temporaryStatusMessage,
locoInfoUtil = locoInfoUtil, locoInfoUtil = locoInfoUtil,
onClearRecords = onClearRecords, onClearRecords = onClearRecords,
onExportRecords = onExportRecords,
onRecordClick = onRecordClick, onRecordClick = onRecordClick,
onClearLog = onClearMonitorLog, onClearLog = onClearMonitorLog,
onDeleteRecords = onDeleteRecords onDeleteRecords = onDeleteRecords,
editMode = historyEditMode,
selectedRecords = historySelectedRecords,
expandedStates = historyExpandedStates,
scrollPosition = historyScrollPosition,
scrollOffset = historyScrollOffset,
onStateChange = onHistoryStateChange
) )
2 -> SettingsScreen( 2 -> SettingsScreen(
deviceName = deviceName, deviceName = deviceName,
onDeviceNameChange = onDeviceNameChange, onDeviceNameChange = onDeviceNameChange,
onApplySettings = onApplySettings, onApplySettings = onApplySettings,
appVersion = appVersion
) )
3 -> MapScreen( 3 -> MapScreen(
records = if (allRecords.isNotEmpty()) allRecords else recentRecords, 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 timestamp: Date = Date()
var receivedTimestamp: Date = Date()
var train: String = "" var train: String = ""
var direction: Int = 0 var direction: Int = 0
var speed: String = "" var speed: String = ""
@@ -34,6 +35,17 @@ class TrainRecord(jsonData: JSONObject? = null) {
timestamp = Date(jsonData.getLong("timestamp")) 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) updateFromJson(it)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to initialize TrainRecord from JSON: ${e.message}") Log.e(TAG, "Failed to initialize TrainRecord from JSON: ${e.message}")
@@ -96,7 +108,7 @@ class TrainRecord(jsonData: JSONObject? = null) {
!trimmed.all { it == '*' } !trimmed.all { it == '*' }
} }
fun toMap(): Map<String, String> { fun toMap(showDetailedTime: Boolean = false): Map<String, String> {
val directionText = when (direction) { val directionText = when (direction) {
1 -> "下行" 1 -> "下行"
3 -> "上行" 3 -> "上行"
@@ -114,12 +126,32 @@ class TrainRecord(jsonData: JSONObject? = null) {
val map = mutableMapOf<String, String>() 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 (trainDisplay.isNotEmpty()) map["train"] = trainDisplay
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()} 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(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()}"
@@ -135,6 +167,7 @@ class TrainRecord(jsonData: JSONObject? = null) {
fun toJSON(): JSONObject { fun toJSON(): JSONObject {
val json = JSONObject() val json = JSONObject()
json.put("timestamp", timestamp.time) json.put("timestamp", timestamp.time)
json.put("receivedTimestamp", receivedTimestamp.time)
json.put("train", train) json.put("train", train)
json.put("dir", direction) json.put("dir", direction)
json.put("speed", speed) json.put("speed", speed)

View File

@@ -38,6 +38,7 @@ class TrainRecordManager(private val context: Context) {
fun addRecord(jsonData: JSONObject): TrainRecord { fun addRecord(jsonData: JSONObject): TrainRecord {
val record = TrainRecord(jsonData) val record = TrainRecord(jsonData)
record.receivedTimestamp = Date()
trainRecords.add(0, record) trainRecords.add(0, record)
@@ -171,43 +172,6 @@ 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 { fun getRecordCount(): Int {

View File

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

View File

@@ -68,7 +68,19 @@ fun TrainInfoCard(
} }
Text( 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, fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )

View File

@@ -140,7 +140,6 @@ fun TrainRecordsListWithToolbar(
records: List<TrainRecord>, records: List<TrainRecord>,
onRecordClick: (TrainRecord) -> Unit, onRecordClick: (TrainRecord) -> Unit,
onFilterClick: () -> Unit, onFilterClick: () -> Unit,
onExportClick: () -> Unit,
onClearClick: () -> Unit, onClearClick: () -> Unit,
onDeleteRecords: (List<TrainRecord>) -> Unit, onDeleteRecords: (List<TrainRecord>) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@@ -198,12 +197,6 @@ fun TrainRecordsListWithToolbar(
contentDescription = "筛选" 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.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -44,18 +46,33 @@ fun HistoryScreen(
temporaryStatusMessage: String? = null, temporaryStatusMessage: String? = null,
locoInfoUtil: LocoInfoUtil? = null, locoInfoUtil: LocoInfoUtil? = null,
onClearRecords: () -> Unit = {}, onClearRecords: () -> Unit = {},
onExportRecords: () -> Unit = {},
onRecordClick: (TrainRecord) -> Unit = {}, onRecordClick: (TrainRecord) -> Unit = {},
onClearLog: () -> 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 val refreshKey = latestRecord?.timestamp?.time ?: 0
var isInEditMode by remember { mutableStateOf(false) } var isInEditMode by remember(editMode) { mutableStateOf(editMode) }
val selectedRecords = remember { mutableStateListOf<TrainRecord>() } 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 expandedStates = remember { mutableStateMapOf<String, Boolean>() } val listState = rememberLazyListState(
initialFirstVisibleItemIndex = scrollPosition,
initialFirstVisibleItemScrollOffset = scrollOffset
)
val timeSinceLastUpdate = remember { mutableStateOf<String?>(null) } val timeSinceLastUpdate = remember { mutableStateOf<String?>(null) }
@@ -69,7 +86,8 @@ fun HistoryScreen(
diffInSec < 3600 -> "${diffInSec / 60}分钟前" diffInSec < 3600 -> "${diffInSec / 60}分钟前"
else -> "${diffInSec / 3600}小时前" 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 records
} }
fun exitEditMode() {
isInEditMode = false LaunchedEffect(isInEditMode, selectedRecordsList.size) {
selectedRecords.clear() val selectedIds = selectedRecordsList.map { it.timestamp.time.toString() }.toSet()
onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
} }
LaunchedEffect(selectedRecords.size) { LaunchedEffect(expandedStatesMap.toMap()) {
if (selectedRecords.isEmpty() && isInEditMode) { if (!isInEditMode) {
exitEditMode() 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(selectedRecordsList.size) {
if (selectedRecordsList.isEmpty() && isInEditMode) {
isInEditMode = false
onStateChange(false, emptySet(), expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
} }
} }
@@ -126,11 +160,12 @@ fun HistoryScreen(
} }
} else { } else {
LazyColumn( LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
items(filteredRecords) { record -> items(filteredRecords) { record ->
val isSelected = selectedRecords.contains(record) val isSelected = selectedRecordsList.contains(record)
val cardColor = when { val cardColor = when {
isSelected -> MaterialTheme.colorScheme.primaryContainer isSelected -> MaterialTheme.colorScheme.primaryContainer
else -> MaterialTheme.colorScheme.surface else -> MaterialTheme.colorScheme.surface
@@ -151,14 +186,14 @@ fun HistoryScreen(
onClick = { onClick = {
if (isInEditMode) { if (isInEditMode) {
if (isSelected) { if (isSelected) {
selectedRecords.remove(record) selectedRecordsList.remove(record)
} else { } else {
selectedRecords.add(record) selectedRecordsList.add(record)
} }
} else { } else {
val id = record.timestamp.time.toString() val id = record.timestamp.time.toString()
expandedStates[id] = expandedStatesMap[id] =
!(expandedStates[id] ?: false) !(expandedStatesMap[id] ?: false)
if (record == latestRecord) { if (record == latestRecord) {
onRecordClick(record) onRecordClick(record)
} }
@@ -167,8 +202,8 @@ fun HistoryScreen(
onLongClick = { onLongClick = {
if (!isInEditMode) { if (!isInEditMode) {
isInEditMode = true isInEditMode = true
selectedRecords.clear() selectedRecordsList.clear()
selectedRecords.add(record) selectedRecordsList.add(record)
} }
}, },
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
@@ -178,9 +213,37 @@ fun HistoryScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -190,21 +253,6 @@ fun HistoryScreen(
val trainDisplay = val trainDisplay =
recordMap["train"]?.toString() ?: "未知列车" 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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp) horizontalArrangement = Arrangement.spacedBy(6.dp)
@@ -243,6 +291,22 @@ fun HistoryScreen(
} }
} }
} }
}
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 -> ""
}
if (formattedInfo.isNotEmpty() && formattedInfo != "<NUL>") { if (formattedInfo.isNotEmpty() && formattedInfo != "<NUL>") {
Text( Text(
@@ -253,25 +317,6 @@ fun HistoryScreen(
} }
} }
Text(
text = "${record.rssi} dBm",
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(4.dp))
if (recordMap.containsKey("time")) {
recordMap["time"]?.split("\n")?.forEach { timeLine ->
Text(
text = timeLine,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Row( Row(
@@ -342,8 +387,7 @@ fun HistoryScreen(
} }
} }
val recordId = record.timestamp.time.toString() if (isExpanded) {
if (expandedStates[recordId] == true) {
val coordinates = remember { record.getCoordinates() } val coordinates = remember { record.getCoordinates() }
if (coordinates != null) { 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.*
import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider
import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay 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 org.noxylva.lbjconsole.model.TrainRecord
import java.io.File import java.io.File
@@ -41,7 +44,11 @@ import java.io.File
fun MapScreen( fun MapScreen(
records: List<TrainRecord>, records: List<TrainRecord>,
onCenterMap: () -> Unit = {}, 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 context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
@@ -90,7 +97,7 @@ fun MapScreen(
var selectedRecord by remember { mutableStateOf<TrainRecord?>(null) } var selectedRecord by remember { mutableStateOf<TrainRecord?>(null) }
var dialogPosition by remember { mutableStateOf<GeoPoint?>(null) } var dialogPosition by remember { mutableStateOf<GeoPoint?>(null) }
var railwayLayerVisible by remember { mutableStateOf(true) } var railwayLayerVisibleState by remember(railwayLayerVisible) { mutableStateOf(railwayLayerVisible) }
DisposableEffect(lifecycleOwner) { DisposableEffect(lifecycleOwner) {
@@ -277,6 +284,12 @@ fun MapScreen(
} }
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()) { if (validRecords.isNotEmpty()) {
validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint -> validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint ->
controller.setCenter(lastPoint) controller.setCenter(lastPoint)
@@ -286,13 +299,14 @@ fun MapScreen(
controller.setCenter(defaultPosition) controller.setCenter(defaultPosition)
controller.setZoom(10.0) controller.setZoom(10.0)
} }
}
try { try {
val locationProvider = GpsMyLocationProvider(ctx).apply { val locationProvider = GpsMyLocationProvider(ctx).apply {
locationUpdateMinDistance = 10f locationUpdateMinDistance = 10f
locationUpdateMinTime = 1000 locationUpdateMinTime = 5000
} }
@@ -304,14 +318,14 @@ fun MapScreen(
myLocation?.let { location -> myLocation?.let { location ->
currentLocation = GeoPoint(location.latitude, location.longitude) currentLocation = GeoPoint(location.latitude, location.longitude)
if (!isMapInitialized) { if (!isMapInitialized && centerPosition == null) {
controller.setCenter(location) controller.setCenter(location)
controller.setZoom(15.0) controller.setZoom(15.0)
isMapInitialized = true isMapInitialized = true
Log.d("MapScreen", "Map initialized with GPS position: $location") Log.d("MapScreen", "Map initialized with GPS position: $location")
} }
} ?: run { } ?: run {
if (!isMapInitialized) { if (!isMapInitialized && centerPosition == null) {
if (validRecords.isNotEmpty()) { if (validRecords.isNotEmpty()) {
validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint -> validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint ->
controller.setCenter(lastPoint) controller.setCenter(lastPoint)
@@ -327,7 +341,7 @@ fun MapScreen(
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
if (!isMapInitialized) { if (!isMapInitialized && centerPosition == null) {
if (validRecords.isNotEmpty()) { if (validRecords.isNotEmpty()) {
validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint -> validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint ->
controller.setCenter(lastPoint) controller.setCenter(lastPoint)
@@ -357,6 +371,31 @@ fun MapScreen(
setAlignBottom(true) setAlignBottom(true)
setLineWidth(2.0f) setLineWidth(2.0f)
}.also { overlays.add(it) } }.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) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
onLocationError("Map component initialization failed: ${e.localizedMessage}") onLocationError("Map component initialization failed: ${e.localizedMessage}")
@@ -381,7 +420,7 @@ fun MapScreen(
coroutineScope.launch { coroutineScope.launch {
updateMarkers() updateMarkers()
updateRailwayLayerVisibility(railwayLayerVisible) updateRailwayLayerVisibility(railwayLayerVisibleState)
} }
} }
) )
@@ -430,15 +469,26 @@ fun MapScreen(
FloatingActionButton( FloatingActionButton(
onClick = { onClick = {
railwayLayerVisible = !railwayLayerVisible railwayLayerVisibleState = !railwayLayerVisibleState
updateRailwayLayerVisibility(railwayLayerVisible) 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), modifier = Modifier.size(40.dp),
containerColor = if (railwayLayerVisible) containerColor = if (railwayLayerVisibleState)
MaterialTheme.colorScheme.primary.copy(alpha = 0.9f) MaterialTheme.colorScheme.primary.copy(alpha = 0.9f)
else else
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f), MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f),
contentColor = if (railwayLayerVisible) contentColor = if (railwayLayerVisibleState)
MaterialTheme.colorScheme.onPrimary MaterialTheme.colorScheme.onPrimary
else else
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer

View File

@@ -41,7 +41,8 @@ fun MonitorScreen(
diffInSec < 3600 -> "${diffInSec / 60}分钟前" diffInSec < 3600 -> "${diffInSec / 60}分钟前"
else -> "${diffInSec / 3600}小时前" else -> "${diffInSec / 3600}小时前"
} }
delay(1000) val updateInterval = if (diffInSec < 60) 500L else if (diffInSec < 3600) 30000L else 300000L
delay(updateInterval)
} }
} }
} }

View File

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