Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1a9a479f9 | ||
|
|
9389ef6e6a | ||
|
|
a60b8c58ff | ||
|
|
936b960d6a |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"] ?: "--")
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 = "导出"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user