Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39bb8cb440 | ||
|
|
be8dc6bc72 | ||
|
|
cd3128c24b | ||
|
|
e1773370d6 | ||
|
|
c8ab5f7ff8 | ||
|
|
e1d02a8a55 | ||
|
|
aaf414d384 | ||
|
|
3edc8632be | ||
|
|
799410eeb2 | ||
|
|
d64138cea5 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,3 +19,4 @@ local.properties
|
|||||||
*.jks
|
*.jks
|
||||||
*.keystore
|
*.keystore
|
||||||
*.base64
|
*.base64
|
||||||
|
docs
|
||||||
@@ -2,10 +2,6 @@
|
|||||||
|
|
||||||
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
|
|
||||||
- Record filtering (train number, time range)
|
|
||||||
- Record management page optimization
|
|
||||||
- Optional train merge by locomotive/number
|
|
||||||
|
|
||||||
# License
|
# License
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "org.noxylva.lbjconsole"
|
applicationId = "org.noxylva.lbjconsole"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 4
|
versionCode = 8
|
||||||
versionName = "0.0.4"
|
versionName = "0.0.8"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,7 @@ dependencies {
|
|||||||
debugImplementation(libs.androidx.ui.test.manifest)
|
debugImplementation(libs.androidx.ui.test.manifest)
|
||||||
implementation("org.json:json:20231013")
|
implementation("org.json:json:20231013")
|
||||||
implementation("androidx.compose.material:material-icons-extended:1.5.4")
|
implementation("androidx.compose.material:material-icons-extended:1.5.4")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||||
|
|
||||||
implementation("org.osmdroid:osmdroid-android:6.1.16")
|
implementation("org.osmdroid:osmdroid-android:6.1.16")
|
||||||
implementation("org.osmdroid:osmdroid-mapsforge:6.1.16")
|
implementation("org.osmdroid:osmdroid-mapsforge:6.1.16")
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
|
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
|
||||||
|
|
||||||
@@ -22,14 +25,14 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.LBJReceiver"
|
android:theme="@style/Theme.LBJConsole"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:theme="@style/Theme.LBJReceiver">
|
android:theme="@style/Theme.LBJConsole">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
@@ -37,6 +40,19 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".SettingsActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:label="Settings"
|
||||||
|
android:parentActivityName=".MainActivity"
|
||||||
|
android:theme="@style/Theme.LBJConsole" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".BackgroundService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.provider"
|
android:authorities="${applicationId}.provider"
|
||||||
|
|||||||
@@ -45,6 +45,16 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
|||||||
private var lastKnownDeviceAddress: String? = null
|
private var lastKnownDeviceAddress: String? = null
|
||||||
private var connectionAttempts = 0
|
private var connectionAttempts = 0
|
||||||
private var isReconnecting = false
|
private var isReconnecting = false
|
||||||
|
private var highFrequencyReconnect = true
|
||||||
|
private var reconnectHandler = Handler(Looper.getMainLooper())
|
||||||
|
private var reconnectRunnable: Runnable? = null
|
||||||
|
private var connectionLostCallback: (() -> Unit)? = null
|
||||||
|
private var connectionSuccessCallback: ((String) -> Unit)? = null
|
||||||
|
private var specifiedDeviceAddress: String? = null
|
||||||
|
private var targetDeviceAddress: String? = null
|
||||||
|
private var isDialogOpen = false
|
||||||
|
private var isManualDisconnect = false
|
||||||
|
private var isAutoConnectBlocked = 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) {
|
||||||
@@ -56,24 +66,7 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
|||||||
deviceName != null && deviceName.equals(targetDeviceName, ignoreCase = true)
|
deviceName != null && deviceName.equals(targetDeviceName, ignoreCase = true)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
deviceName != null && (
|
true
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,17 +75,24 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
|||||||
scanCallback?.invoke(device)
|
scanCallback?.invoke(device)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetDeviceName != null && !isConnected && !isReconnecting) {
|
if (!isConnected && !isReconnecting && !isDialogOpen && !isAutoConnectBlocked) {
|
||||||
if (deviceName != null && deviceName.equals(targetDeviceName, ignoreCase = true)) {
|
val deviceAddress = device.address
|
||||||
Log.i(TAG, "Found target device: $deviceName, auto-connecting")
|
val isSpecifiedDevice = specifiedDeviceAddress == deviceAddress
|
||||||
lastKnownDeviceAddress = device.address
|
val isTargetDevice = targetDeviceName != null && deviceName != null && deviceName.equals(targetDeviceName, ignoreCase = true)
|
||||||
connectImmediately(device.address)
|
val isKnownDevice = lastKnownDeviceAddress == deviceAddress
|
||||||
}
|
val isSpecificTargetAddress = targetDeviceAddress == deviceAddress
|
||||||
}
|
|
||||||
|
|
||||||
if (lastKnownDeviceAddress == device.address && !isConnected && !isReconnecting) {
|
if (isSpecificTargetAddress || isSpecifiedDevice || (specifiedDeviceAddress == null && isTargetDevice) || (specifiedDeviceAddress == null && isKnownDevice)) {
|
||||||
Log.i(TAG, "Found known device, reconnecting immediately")
|
val priority = when {
|
||||||
connectImmediately(device.address)
|
isSpecificTargetAddress -> "specific target address"
|
||||||
|
isSpecifiedDevice -> "specified device"
|
||||||
|
isTargetDevice -> "target device name"
|
||||||
|
else -> "known device"
|
||||||
|
}
|
||||||
|
Log.i(TAG, "Found device ($priority): $deviceName, auto-connecting")
|
||||||
|
lastKnownDeviceAddress = deviceAddress
|
||||||
|
connectImmediately(deviceAddress)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,10 +276,96 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
|||||||
return isConnected
|
return isConnected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun checkActualConnectionState(): Boolean {
|
||||||
|
bluetoothGatt?.let { gatt ->
|
||||||
|
try {
|
||||||
|
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
val connectedDevices = bluetoothManager.getConnectedDevices(BluetoothProfile.GATT)
|
||||||
|
val isActuallyConnected = connectedDevices.any { it.address == deviceAddress }
|
||||||
|
|
||||||
|
if (isActuallyConnected && !isConnected) {
|
||||||
|
Log.d(TAG, "Found existing GATT connection, updating internal state")
|
||||||
|
isConnected = true
|
||||||
|
return true
|
||||||
|
} else if (!isActuallyConnected && isConnected) {
|
||||||
|
Log.d(TAG, "GATT connection lost, updating internal state")
|
||||||
|
isConnected = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return isActuallyConnected
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error checking actual connection state: ${e.message}")
|
||||||
|
return isConnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isConnected
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
bluetoothGatt?.disconnect()
|
Log.d(TAG, "Manual disconnect initiated")
|
||||||
|
isConnected = false
|
||||||
|
isManualDisconnect = true
|
||||||
|
isAutoConnectBlocked = true
|
||||||
|
stopHighFrequencyReconnect()
|
||||||
|
stopScan()
|
||||||
|
|
||||||
|
bluetoothGatt?.let { gatt ->
|
||||||
|
try {
|
||||||
|
gatt.disconnect()
|
||||||
|
Thread.sleep(100)
|
||||||
|
gatt.close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Disconnect error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bluetoothGatt = null
|
||||||
|
|
||||||
|
dataBuffer.clear()
|
||||||
|
connectionStateCallback = null
|
||||||
|
|
||||||
|
Log.d(TAG, "Manual disconnect - auto connect blocked, deviceAddress preserved: $deviceAddress")
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun connectManually(address: String, onConnectionStateChange: ((Boolean) -> Unit)? = null): Boolean {
|
||||||
|
Log.d(TAG, "Manual connection to device: $address")
|
||||||
|
|
||||||
|
stopScan()
|
||||||
|
stopHighFrequencyReconnect()
|
||||||
|
|
||||||
|
isManualDisconnect = false
|
||||||
|
isAutoConnectBlocked = false
|
||||||
|
autoReconnect = true
|
||||||
|
highFrequencyReconnect = true
|
||||||
|
return connect(address, onConnectionStateChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun closeManually() {
|
||||||
|
Log.d(TAG, "Manual close - will restore auto reconnect")
|
||||||
|
|
||||||
|
isConnected = false
|
||||||
|
isManualDisconnect = false
|
||||||
|
isAutoConnectBlocked = false
|
||||||
|
bluetoothGatt?.let { gatt ->
|
||||||
|
try {
|
||||||
|
gatt.disconnect()
|
||||||
|
gatt.close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Close error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bluetoothGatt = null
|
||||||
|
deviceAddress = null
|
||||||
|
|
||||||
|
autoReconnect = true
|
||||||
|
highFrequencyReconnect = true
|
||||||
|
|
||||||
|
Log.d(TAG, "Auto reconnect mechanism restored and GATT cleaned up")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -361,6 +447,7 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
|||||||
try {
|
try {
|
||||||
gatt.close()
|
gatt.close()
|
||||||
bluetoothGatt = null
|
bluetoothGatt = null
|
||||||
|
bluetoothLeScanner = null
|
||||||
|
|
||||||
deviceAddress?.let { address ->
|
deviceAddress?.let { address ->
|
||||||
if (autoReconnect) {
|
if (autoReconnect) {
|
||||||
@@ -389,11 +476,16 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
|||||||
BluetoothProfile.STATE_CONNECTED -> {
|
BluetoothProfile.STATE_CONNECTED -> {
|
||||||
isConnected = true
|
isConnected = true
|
||||||
isReconnecting = false
|
isReconnecting = false
|
||||||
|
isManualDisconnect = false
|
||||||
connectionAttempts = 0
|
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) }
|
||||||
|
|
||||||
|
deviceAddress?.let { address ->
|
||||||
|
handler.post { connectionSuccessCallback?.invoke(address) }
|
||||||
|
}
|
||||||
|
|
||||||
handler.post {
|
handler.post {
|
||||||
try {
|
try {
|
||||||
gatt.discoverServices()
|
gatt.discoverServices()
|
||||||
@@ -406,17 +498,20 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
|||||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||||
isConnected = false
|
isConnected = false
|
||||||
isReconnecting = false
|
isReconnecting = false
|
||||||
Log.i(TAG, "Disconnected from GATT server")
|
Log.i(TAG, "Disconnected from GATT server, manual=$isManualDisconnect")
|
||||||
|
|
||||||
handler.post { connectionStateCallback?.invoke(false) }
|
|
||||||
|
|
||||||
|
|
||||||
if (!deviceAddress.isNullOrBlank() && autoReconnect) {
|
|
||||||
handler.post {
|
handler.post {
|
||||||
Log.d(TAG, "Immediate reconnection after disconnect")
|
connectionStateCallback?.invoke(false)
|
||||||
connect(deviceAddress!!, connectionStateCallback)
|
if (!isManualDisconnect) {
|
||||||
|
connectionLostCallback?.invoke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!deviceAddress.isNullOrBlank() && autoReconnect && highFrequencyReconnect && !isManualDisconnect) {
|
||||||
|
startHighFrequencyReconnect(deviceAddress!!)
|
||||||
|
} else if (isManualDisconnect) {
|
||||||
|
Log.d(TAG, "Manual disconnect - no auto reconnect")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -630,7 +725,122 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
|||||||
Log.d(TAG, "Auto reconnect set to: $enabled")
|
Log.d(TAG, "Auto reconnect set to: $enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setHighFrequencyReconnect(enabled: Boolean) {
|
||||||
|
highFrequencyReconnect = enabled
|
||||||
|
if (!enabled) {
|
||||||
|
stopHighFrequencyReconnect()
|
||||||
|
}
|
||||||
|
Log.d(TAG, "High frequency reconnect set to: $enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setConnectionLostCallback(callback: (() -> Unit)?) {
|
||||||
|
connectionLostCallback = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setConnectionSuccessCallback(callback: ((String) -> Unit)?) {
|
||||||
|
connectionSuccessCallback = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSpecifiedDeviceAddress(address: String?) {
|
||||||
|
specifiedDeviceAddress = address
|
||||||
|
Log.d(TAG, "Set specified device address: $address")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSpecifiedDeviceAddress(): String? = specifiedDeviceAddress
|
||||||
|
|
||||||
|
fun setDialogOpen(isOpen: Boolean) {
|
||||||
|
isDialogOpen = isOpen
|
||||||
|
Log.d(TAG, "Dialog open state set to: $isOpen")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAutoConnectBlocked(blocked: Boolean) {
|
||||||
|
isAutoConnectBlocked = blocked
|
||||||
|
Log.d(TAG, "Auto connect blocked set to: $blocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetManualDisconnectState() {
|
||||||
|
isManualDisconnect = false
|
||||||
|
isAutoConnectBlocked = false
|
||||||
|
Log.d(TAG, "Manual disconnect state reset - auto reconnect enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTargetDeviceAddress(address: String?) {
|
||||||
|
targetDeviceAddress = address
|
||||||
|
Log.d(TAG, "Set target device address: $address")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getTargetDeviceAddress(): String? = targetDeviceAddress
|
||||||
|
|
||||||
|
private fun startHighFrequencyReconnect(address: String) {
|
||||||
|
stopHighFrequencyReconnect()
|
||||||
|
|
||||||
|
Log.d(TAG, "Starting high frequency reconnect for: $address")
|
||||||
|
|
||||||
|
reconnectRunnable = Runnable {
|
||||||
|
if (!isConnected && autoReconnect && highFrequencyReconnect) {
|
||||||
|
Log.d(TAG, "High frequency reconnect attempt ${connectionAttempts + 1} for: $address")
|
||||||
|
connect(address, connectionStateCallback)
|
||||||
|
|
||||||
|
if (!isConnected) {
|
||||||
|
val delay = when {
|
||||||
|
connectionAttempts < 10 -> 100L
|
||||||
|
connectionAttempts < 30 -> 200L
|
||||||
|
connectionAttempts < 60 -> 500L
|
||||||
|
else -> 1000L
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectHandler.postDelayed(reconnectRunnable!!, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectHandler.post(reconnectRunnable!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopHighFrequencyReconnect() {
|
||||||
|
reconnectRunnable?.let {
|
||||||
|
reconnectHandler.removeCallbacks(it)
|
||||||
|
reconnectRunnable = null
|
||||||
|
Log.d(TAG, "Stopped high frequency reconnect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getConnectionAttempts(): Int = connectionAttempts
|
fun getConnectionAttempts(): Int = connectionAttempts
|
||||||
|
|
||||||
fun getLastKnownDeviceAddress(): String? = lastKnownDeviceAddress
|
fun getLastKnownDeviceAddress(): String? = lastKnownDeviceAddress
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun disconnectAndCleanup() {
|
||||||
|
isConnected = false
|
||||||
|
autoReconnect = false
|
||||||
|
highFrequencyReconnect = false
|
||||||
|
isManualDisconnect = false
|
||||||
|
isAutoConnectBlocked = false
|
||||||
|
stopHighFrequencyReconnect()
|
||||||
|
stopScan()
|
||||||
|
|
||||||
|
bluetoothGatt?.let { gatt ->
|
||||||
|
try {
|
||||||
|
gatt.disconnect()
|
||||||
|
Thread.sleep(200)
|
||||||
|
gatt.close()
|
||||||
|
Log.d(TAG, "GATT connection cleaned up")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Cleanup error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bluetoothGatt = null
|
||||||
|
bluetoothLeScanner = null
|
||||||
|
deviceAddress = null
|
||||||
|
connectionAttempts = 0
|
||||||
|
|
||||||
|
dataBuffer.clear()
|
||||||
|
connectionStateCallback = null
|
||||||
|
statusCallback = null
|
||||||
|
trainInfoCallback = null
|
||||||
|
connectionLostCallback = null
|
||||||
|
connectionSuccessCallback = null
|
||||||
|
|
||||||
|
Log.d(TAG, "BLE client fully disconnected and cleaned up")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
123
app/src/main/java/org/noxylva/lbjconsole/BackgroundService.kt
Normal file
123
app/src/main/java/org/noxylva/lbjconsole/BackgroundService.kt
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package org.noxylva.lbjconsole
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.PowerManager
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
|
||||||
|
class BackgroundService : Service() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val NOTIFICATION_ID = 1001
|
||||||
|
private const val CHANNEL_ID = "background_service_channel"
|
||||||
|
private const val CHANNEL_NAME = "Background Service"
|
||||||
|
|
||||||
|
fun startService(context: Context) {
|
||||||
|
try {
|
||||||
|
val intent = Intent(context, BackgroundService::class.java)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Service start failed, ignore silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopService(context: Context) {
|
||||||
|
val intent = Intent(context, BackgroundService::class.java)
|
||||||
|
context.stopService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createNotificationChannel()
|
||||||
|
acquireWakeLock()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
try {
|
||||||
|
val notification = createNotification()
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
releaseWakeLock()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
CHANNEL_NAME,
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = "Keep app running in background"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotification(): Notification {
|
||||||
|
val intent = Intent(this, MainActivity::class.java)
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle("LBJ Console")
|
||||||
|
.setContentText("Running in background")
|
||||||
|
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun acquireWakeLock() {
|
||||||
|
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
wakeLock = powerManager.newWakeLock(
|
||||||
|
PowerManager.PARTIAL_WAKE_LOCK,
|
||||||
|
"LBJConsole::BackgroundWakeLock"
|
||||||
|
)
|
||||||
|
wakeLock?.acquire()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseWakeLock() {
|
||||||
|
wakeLock?.let {
|
||||||
|
if (it.isHeld) {
|
||||||
|
it.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wakeLock = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,32 +20,50 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
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.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material.icons.filled.LocationOn
|
import androidx.compose.material.icons.filled.LocationOn
|
||||||
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
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.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.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.osmdroid.config.Configuration
|
import org.osmdroid.config.Configuration
|
||||||
import org.noxylva.lbjconsole.model.TrainRecord
|
import org.noxylva.lbjconsole.model.TrainRecord
|
||||||
import org.noxylva.lbjconsole.model.TrainRecordManager
|
import org.noxylva.lbjconsole.model.TrainRecordManager
|
||||||
|
import org.noxylva.lbjconsole.model.MergeSettings
|
||||||
import org.noxylva.lbjconsole.ui.screens.HistoryScreen
|
import org.noxylva.lbjconsole.ui.screens.HistoryScreen
|
||||||
|
|
||||||
import org.noxylva.lbjconsole.ui.screens.MapScreen
|
import org.noxylva.lbjconsole.ui.screens.MapScreen
|
||||||
import org.noxylva.lbjconsole.ui.screens.SettingsScreen
|
import org.noxylva.lbjconsole.ui.screens.SettingsScreen
|
||||||
import org.noxylva.lbjconsole.ui.theme.LBJReceiverTheme
|
|
||||||
|
import org.noxylva.lbjconsole.ui.theme.LBJConsoleTheme
|
||||||
import org.noxylva.lbjconsole.util.LocoInfoUtil
|
import org.noxylva.lbjconsole.util.LocoInfoUtil
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
@@ -57,10 +75,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
private val bleClient by lazy { BLEClient(this) }
|
private val bleClient by lazy { BLEClient(this) }
|
||||||
private val trainRecordManager by lazy { TrainRecordManager(this) }
|
private val trainRecordManager by lazy { TrainRecordManager(this) }
|
||||||
private val locoInfoUtil by lazy { LocoInfoUtil(this) }
|
private val locoInfoUtil by lazy { LocoInfoUtil(this) }
|
||||||
|
private val notificationService by lazy { NotificationService(this) }
|
||||||
|
|
||||||
|
|
||||||
private var deviceStatus by mutableStateOf("未连接")
|
private var deviceStatus by mutableStateOf("未连接")
|
||||||
private var deviceAddress by mutableStateOf("")
|
private var deviceAddress by mutableStateOf<String?>(null)
|
||||||
private var isScanning by mutableStateOf(false)
|
private var isScanning by mutableStateOf(false)
|
||||||
private var foundDevices by mutableStateOf(listOf<BluetoothDevice>())
|
private var foundDevices by mutableStateOf(listOf<BluetoothDevice>())
|
||||||
private var scanResults = mutableListOf<ScanResult>()
|
private var scanResults = mutableListOf<ScanResult>()
|
||||||
@@ -68,7 +87,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
private var showConnectionDialog by mutableStateOf(false)
|
private var showConnectionDialog by mutableStateOf(false)
|
||||||
private var lastUpdateTime by mutableStateOf<Date?>(null)
|
private var lastUpdateTime by mutableStateOf<Date?>(null)
|
||||||
private var latestRecord by mutableStateOf<TrainRecord?>(null)
|
private var latestRecord by mutableStateOf<TrainRecord?>(null)
|
||||||
private var recentRecords by mutableStateOf<List<TrainRecord>>(emptyList())
|
private var recentRecords = mutableStateListOf<TrainRecord>()
|
||||||
|
|
||||||
|
|
||||||
private var filterTrain by mutableStateOf("")
|
private var filterTrain by mutableStateOf("")
|
||||||
@@ -89,8 +108,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
private var mapZoomLevel by mutableStateOf(10.0)
|
private var mapZoomLevel by mutableStateOf(10.0)
|
||||||
private var mapRailwayLayerVisible by mutableStateOf(true)
|
private var mapRailwayLayerVisible by mutableStateOf(true)
|
||||||
|
|
||||||
|
private var settingsScrollPosition by mutableStateOf(0)
|
||||||
|
|
||||||
|
private var mergeSettings by mutableStateOf(MergeSettings())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private var targetDeviceName = "LBJReceiver"
|
private var targetDeviceName = "LBJReceiver"
|
||||||
|
private var specifiedDeviceAddress by mutableStateOf<String?>(null)
|
||||||
|
private var searchOrderList by mutableStateOf(listOf<String>())
|
||||||
|
private var showDisconnectButton by mutableStateOf(false)
|
||||||
|
private var autoConnectEnabled by mutableStateOf(true)
|
||||||
|
|
||||||
|
|
||||||
private val settingsPrefs by lazy { getSharedPreferences("app_settings", Context.MODE_PRIVATE) }
|
private val settingsPrefs by lazy { getSharedPreferences("app_settings", Context.MODE_PRIVATE) }
|
||||||
@@ -178,6 +206,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
))
|
))
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
|
||||||
requestPermissions.launch(permissions.toTypedArray())
|
requestPermissions.launch(permissions.toTypedArray())
|
||||||
|
|
||||||
|
|
||||||
@@ -185,6 +217,27 @@ class MainActivity : ComponentActivity() {
|
|||||||
handleTrainInfo(jsonData)
|
handleTrainInfo(jsonData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bleClient.setHighFrequencyReconnect(true)
|
||||||
|
bleClient.setConnectionLostCallback {
|
||||||
|
runOnUiThread {
|
||||||
|
deviceStatus = "连接丢失,正在重连..."
|
||||||
|
showDisconnectButton = false
|
||||||
|
if (showConnectionDialog) {
|
||||||
|
foundDevices = emptyList()
|
||||||
|
startScan()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bleClient.setConnectionSuccessCallback { address ->
|
||||||
|
runOnUiThread {
|
||||||
|
deviceAddress = address
|
||||||
|
deviceStatus = "已连接"
|
||||||
|
showDisconnectButton = true
|
||||||
|
Log.d(TAG, "Connection success callback: address=$address")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -208,10 +261,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
osmdroidBasePath = osmCacheDir
|
osmdroidBasePath = osmCacheDir
|
||||||
osmdroidTileCache = tileCache
|
osmdroidTileCache = tileCache
|
||||||
expirationOverrideDuration = 86400000L * 7
|
expirationOverrideDuration = 86400000L * 7
|
||||||
tileDownloadThreads = 2
|
tileDownloadThreads = 4
|
||||||
tileFileSystemThreads = 2
|
tileFileSystemThreads = 4
|
||||||
|
|
||||||
setUserAgentValue("LBJReceiver/1.0")
|
setUserAgentValue("LBJConsole/1.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "OSM cache configured")
|
Log.d(TAG, "OSM cache configured")
|
||||||
@@ -221,13 +274,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
saveSettings()
|
saveSettings()
|
||||||
|
|
||||||
|
if (SettingsActivity.isBackgroundServiceEnabled(this)) {
|
||||||
|
BackgroundService.startService(this)
|
||||||
|
}
|
||||||
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||||
isAppearanceLightStatusBars = false
|
isAppearanceLightStatusBars = false
|
||||||
}
|
}
|
||||||
setContent {
|
setContent {
|
||||||
LBJReceiverTheme {
|
LBJConsoleTheme {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
Surface(modifier = Modifier.fillMaxSize()) {
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
@@ -240,7 +297,30 @@ class MainActivity : ComponentActivity() {
|
|||||||
currentTab = tab
|
currentTab = tab
|
||||||
saveSettings()
|
saveSettings()
|
||||||
},
|
},
|
||||||
onConnectClick = { showConnectionDialog = true },
|
onConnectClick = {
|
||||||
|
showConnectionDialog = true
|
||||||
|
},
|
||||||
|
onDisconnectClick = {
|
||||||
|
bleClient.disconnectAndCleanup()
|
||||||
|
showDisconnectButton = false
|
||||||
|
deviceStatus = "已断开连接"
|
||||||
|
Log.d(TAG, "User disconnected device")
|
||||||
|
},
|
||||||
|
showDisconnectButton = showDisconnectButton,
|
||||||
|
specifiedDeviceAddress = specifiedDeviceAddress,
|
||||||
|
searchOrderList = searchOrderList,
|
||||||
|
onSpecifiedDeviceSelected = { address ->
|
||||||
|
specifiedDeviceAddress = address
|
||||||
|
bleClient.setSpecifiedDeviceAddress(address)
|
||||||
|
saveSettings()
|
||||||
|
Log.d(TAG, "Set specified device address: $address")
|
||||||
|
},
|
||||||
|
autoConnectEnabled = autoConnectEnabled,
|
||||||
|
onAutoConnectEnabledChange = { enabled ->
|
||||||
|
autoConnectEnabled = enabled
|
||||||
|
saveSettings()
|
||||||
|
Log.d(TAG, "Auto connect enabled: $enabled")
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
latestRecord = latestRecord,
|
latestRecord = latestRecord,
|
||||||
@@ -251,17 +331,25 @@ class MainActivity : ComponentActivity() {
|
|||||||
Log.d(TAG, "Record clicked train=${record.train}")
|
Log.d(TAG, "Record clicked train=${record.train}")
|
||||||
},
|
},
|
||||||
onClearMonitorLog = {
|
onClearMonitorLog = {
|
||||||
recentRecords = emptyList()
|
recentRecords.clear()
|
||||||
temporaryStatusMessage = null
|
temporaryStatusMessage = null
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
allRecords = if (trainRecordManager.getFilteredRecords().isNotEmpty())
|
allRecords = trainRecordManager.getMixedRecords(),
|
||||||
trainRecordManager.getFilteredRecords() else trainRecordManager.getAllRecords(),
|
mergedRecords = trainRecordManager.getMergedRecords(),
|
||||||
recordCount = trainRecordManager.getRecordCount(),
|
recordCount = trainRecordManager.getRecordCount(),
|
||||||
filterTrain = filterTrain,
|
filterTrain = filterTrain,
|
||||||
filterRoute = filterRoute,
|
filterRoute = filterRoute,
|
||||||
filterDirection = filterDirection,
|
filterDirection = filterDirection,
|
||||||
|
mergeSettings = mergeSettings,
|
||||||
|
onMergeSettingsChange = { newSettings ->
|
||||||
|
mergeSettings = newSettings
|
||||||
|
trainRecordManager.updateMergeSettings(newSettings)
|
||||||
|
historyEditMode = false
|
||||||
|
historySelectedRecords = emptySet()
|
||||||
|
saveSettings()
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
historyEditMode = historyEditMode,
|
historyEditMode = historyEditMode,
|
||||||
@@ -279,6 +367,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
settingsScrollPosition = settingsScrollPosition,
|
||||||
|
onSettingsScrollPositionChange = { position ->
|
||||||
|
settingsScrollPosition = position
|
||||||
|
saveSettings()
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
mapCenterPosition = mapCenterPosition,
|
mapCenterPosition = mapCenterPosition,
|
||||||
mapZoomLevel = mapZoomLevel,
|
mapZoomLevel = mapZoomLevel,
|
||||||
mapRailwayLayerVisible = mapRailwayLayerVisible,
|
mapRailwayLayerVisible = mapRailwayLayerVisible,
|
||||||
@@ -303,7 +398,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
onClearRecords = {
|
onClearRecords = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
trainRecordManager.clearRecords()
|
trainRecordManager.clearRecords()
|
||||||
recentRecords = emptyList()
|
recentRecords.clear()
|
||||||
latestRecord = null
|
latestRecord = null
|
||||||
temporaryStatusMessage = null
|
temporaryStatusMessage = null
|
||||||
}
|
}
|
||||||
@@ -313,12 +408,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
val deletedCount = trainRecordManager.deleteRecords(records)
|
val deletedCount = trainRecordManager.deleteRecords(records)
|
||||||
if (deletedCount > 0) {
|
if (deletedCount > 0) {
|
||||||
Toast.makeText(
|
|
||||||
this@MainActivity,
|
|
||||||
"已删除 $deletedCount 条记录",
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
|
|
||||||
if (records.contains(latestRecord)) {
|
if (records.contains(latestRecord)) {
|
||||||
latestRecord = null
|
latestRecord = null
|
||||||
}
|
}
|
||||||
@@ -330,22 +419,39 @@ class MainActivity : ComponentActivity() {
|
|||||||
deviceName = settingsDeviceName,
|
deviceName = settingsDeviceName,
|
||||||
onDeviceNameChange = { newName -> settingsDeviceName = newName },
|
onDeviceNameChange = { newName -> settingsDeviceName = newName },
|
||||||
onApplySettings = {
|
onApplySettings = {
|
||||||
saveSettings()
|
if (targetDeviceName != settingsDeviceName) {
|
||||||
targetDeviceName = settingsDeviceName
|
targetDeviceName = settingsDeviceName
|
||||||
Toast.makeText(this, "设备名称 '${settingsDeviceName}' 已保存,下次连接时生效", Toast.LENGTH_LONG).show()
|
|
||||||
Log.d(TAG, "Applied settings deviceName=${settingsDeviceName}")
|
Log.d(TAG, "Applied settings deviceName=${settingsDeviceName}")
|
||||||
|
saveSettings()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
appVersion = getAppVersion(),
|
appVersion = getAppVersion(),
|
||||||
locoInfoUtil = locoInfoUtil
|
locoInfoUtil = locoInfoUtil,
|
||||||
|
onOpenSettings = {
|
||||||
|
val intent = Intent(this@MainActivity, SettingsActivity::class.java)
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (showConnectionDialog) {
|
if (showConnectionDialog) {
|
||||||
|
LaunchedEffect(showConnectionDialog) {
|
||||||
|
bleClient.setDialogOpen(true)
|
||||||
|
if (!bleClient.isConnected() && !isScanning) {
|
||||||
|
foundDevices = emptyList()
|
||||||
|
startScan()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ConnectionDialog(
|
ConnectionDialog(
|
||||||
isScanning = isScanning,
|
isScanning = isScanning,
|
||||||
devices = foundDevices,
|
devices = foundDevices,
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
showConnectionDialog = false
|
showConnectionDialog = false
|
||||||
stopScan()
|
stopScan()
|
||||||
|
bleClient.resetManualDisconnectState()
|
||||||
|
if (!bleClient.isConnected()) {
|
||||||
|
startAutoScanAndConnect()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onScan = {
|
onScan = {
|
||||||
if (isScanning) {
|
if (isScanning) {
|
||||||
@@ -356,17 +462,36 @@ class MainActivity : ComponentActivity() {
|
|||||||
},
|
},
|
||||||
onConnect = { device ->
|
onConnect = { device ->
|
||||||
showConnectionDialog = false
|
showConnectionDialog = false
|
||||||
connectToDevice(device)
|
bleClient.setDialogOpen(false)
|
||||||
}
|
connectToDeviceManually(device)
|
||||||
|
},
|
||||||
|
onDisconnect = {
|
||||||
|
bleClient.disconnect()
|
||||||
|
deviceStatus = "已断开连接"
|
||||||
|
deviceAddress = null
|
||||||
|
showDisconnectButton = false
|
||||||
|
Log.d(TAG, "Disconnected from device")
|
||||||
|
startScan()
|
||||||
|
},
|
||||||
|
isConnected = bleClient.isConnected(),
|
||||||
|
targetDeviceName = settingsDeviceName,
|
||||||
|
deviceAddress = deviceAddress
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
LaunchedEffect(showConnectionDialog) {
|
||||||
|
bleClient.setDialogOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun connectToDevice(device: BluetoothDevice) {
|
private fun connectToDevice(device: BluetoothDevice) {
|
||||||
|
bleClient.setAutoConnectBlocked(false)
|
||||||
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}")
|
||||||
|
|
||||||
@@ -383,9 +508,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (connected) {
|
if (connected) {
|
||||||
deviceStatus = "已连接"
|
deviceStatus = "已连接"
|
||||||
temporaryStatusMessage = null
|
temporaryStatusMessage = null
|
||||||
|
showDisconnectButton = true
|
||||||
|
|
||||||
|
val newOrderList = listOf(device.address) + searchOrderList.filter { it != device.address }
|
||||||
|
searchOrderList = newOrderList.take(10)
|
||||||
|
saveSettings()
|
||||||
|
Log.d(TAG, "Updated search order list with: ${device.address}")
|
||||||
|
|
||||||
Log.d(TAG, "Connected to device name=${device.name ?: "Unknown"}")
|
Log.d(TAG, "Connected to device name=${device.name ?: "Unknown"}")
|
||||||
} else {
|
} else {
|
||||||
deviceStatus = "连接失败,正在重试..."
|
deviceStatus = "连接失败,正在重试..."
|
||||||
|
showDisconnectButton = false
|
||||||
Log.e(TAG, "Connection failed, auto-retry enabled for name=${device.name ?: "Unknown"}")
|
Log.e(TAG, "Connection failed, auto-retry enabled for name=${device.name ?: "Unknown"}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,6 +527,43 @@ class MainActivity : ComponentActivity() {
|
|||||||
deviceAddress = device.address
|
deviceAddress = device.address
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun connectToDeviceManually(device: BluetoothDevice) {
|
||||||
|
bleClient.setAutoConnectBlocked(false)
|
||||||
|
deviceStatus = "正在连接..."
|
||||||
|
Log.d(TAG, "Manually connecting to device name=${device.name ?: "Unknown"} address=${device.address}")
|
||||||
|
|
||||||
|
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
val bluetoothAdapter = bluetoothManager.adapter
|
||||||
|
if (bluetoothAdapter == null || bluetoothAdapter.isEnabled != true) {
|
||||||
|
deviceStatus = "蓝牙未启用"
|
||||||
|
Log.e(TAG, "Bluetooth adapter unavailable or disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bleClient.connectManually(device.address) { connected ->
|
||||||
|
runOnUiThread {
|
||||||
|
if (connected) {
|
||||||
|
deviceStatus = "已连接"
|
||||||
|
temporaryStatusMessage = null
|
||||||
|
showDisconnectButton = true
|
||||||
|
|
||||||
|
val newOrderList = listOf(device.address) + searchOrderList.filter { it != device.address }
|
||||||
|
searchOrderList = newOrderList.take(10)
|
||||||
|
saveSettings()
|
||||||
|
Log.d(TAG, "Updated search order list with: ${device.address}")
|
||||||
|
|
||||||
|
Log.d(TAG, "Manually connected to device name=${device.name ?: "Unknown"}")
|
||||||
|
} else {
|
||||||
|
deviceStatus = "连接失败"
|
||||||
|
showDisconnectButton = false
|
||||||
|
Log.e(TAG, "Manual connection failed for name=${device.name ?: "Unknown"}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceAddress = device.address
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun handleTrainInfo(jsonData: JSONObject) {
|
private fun handleTrainInfo(jsonData: JSONObject) {
|
||||||
Log.d(TAG, "Received train data=${jsonData.toString().take(50)}...")
|
Log.d(TAG, "Received train data=${jsonData.toString().take(50)}...")
|
||||||
@@ -413,13 +583,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
val record = trainRecordManager.addRecord(jsonData)
|
val record = trainRecordManager.addRecord(jsonData)
|
||||||
Log.d(TAG, "Added record train=${record.train} direction=${record.direction}")
|
Log.d(TAG, "Added record train=${record.train} direction=${record.direction}")
|
||||||
|
|
||||||
|
if (notificationService.isNotificationEnabled()) {
|
||||||
|
notificationService.showTrainNotification(record)
|
||||||
|
}
|
||||||
|
|
||||||
latestRecord = record
|
latestRecord = record
|
||||||
|
|
||||||
val newList = mutableListOf<TrainRecord>()
|
recentRecords.removeAll { it.train == record.train && it.time == record.time }
|
||||||
newList.add(record)
|
recentRecords.add(0, record)
|
||||||
newList.addAll(recentRecords.filterNot { it.train == record.train && it.time == record.time })
|
if (recentRecords.size > 10) {
|
||||||
recentRecords = newList.take(10)
|
recentRecords.removeRange(10, recentRecords.size)
|
||||||
|
}
|
||||||
|
|
||||||
Log.d(TAG, "Updated UI train=${record.train}")
|
Log.d(TAG, "Updated UI train=${record.train}")
|
||||||
forceUiRefresh()
|
forceUiRefresh()
|
||||||
@@ -451,6 +625,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
|
|
||||||
private fun startAutoScanAndConnect() {
|
private fun startAutoScanAndConnect() {
|
||||||
|
if (!autoConnectEnabled) {
|
||||||
|
Log.d(TAG, "Auto connect disabled, skipping auto scan")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Log.d(TAG, "Starting auto scan and connect")
|
Log.d(TAG, "Starting auto scan and connect")
|
||||||
|
|
||||||
if (!hasBluetoothPermissions()) {
|
if (!hasBluetoothPermissions()) {
|
||||||
@@ -475,10 +654,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
bleClient.setAutoReconnect(true)
|
bleClient.setAutoReconnect(true)
|
||||||
|
|
||||||
val targetDeviceName = if (settingsDeviceName.isNotBlank() && settingsDeviceName != "LBJReceiver") {
|
val targetDeviceName = if (settingsDeviceName.isNotBlank()) {
|
||||||
settingsDeviceName
|
settingsDeviceName
|
||||||
} else {
|
} else {
|
||||||
"LBJReceiver"
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "Auto scanning for target device: $targetDeviceName")
|
Log.d(TAG, "Auto scanning for target device: $targetDeviceName")
|
||||||
@@ -515,20 +694,23 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
isScanning = true
|
isScanning = true
|
||||||
foundDevices = emptyList()
|
foundDevices = emptyList()
|
||||||
val targetDeviceName = if (settingsDeviceName.isNotBlank() && settingsDeviceName != "LBJReceiver") {
|
|
||||||
|
val targetDeviceName = if (settingsDeviceName.isNotBlank()) {
|
||||||
settingsDeviceName
|
settingsDeviceName
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
Log.d(TAG, "Starting continuous BLE scan target=${targetDeviceName ?: "Any"} (settings=${settingsDeviceName})")
|
Log.d(TAG, "Starting BLE scan target=${targetDeviceName ?: "Any"} (settings=${settingsDeviceName})")
|
||||||
|
|
||||||
bleClient.scanDevices(targetDeviceName) { device ->
|
bleClient.scanDevices(targetDeviceName) { device ->
|
||||||
|
runOnUiThread {
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun stopScan() {
|
private fun stopScan() {
|
||||||
@@ -585,6 +767,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
historyScrollPosition = settingsPrefs.getInt("history_scroll_position", 0)
|
historyScrollPosition = settingsPrefs.getInt("history_scroll_position", 0)
|
||||||
historyScrollOffset = settingsPrefs.getInt("history_scroll_offset", 0)
|
historyScrollOffset = settingsPrefs.getInt("history_scroll_offset", 0)
|
||||||
|
settingsScrollPosition = settingsPrefs.getInt("settings_scroll_position", 0)
|
||||||
|
|
||||||
val centerLat = settingsPrefs.getFloat("map_center_lat", Float.NaN)
|
val centerLat = settingsPrefs.getFloat("map_center_lat", Float.NaN)
|
||||||
val centerLon = settingsPrefs.getFloat("map_center_lon", Float.NaN)
|
val centerLon = settingsPrefs.getFloat("map_center_lon", Float.NaN)
|
||||||
@@ -595,11 +778,27 @@ class MainActivity : ComponentActivity() {
|
|||||||
mapZoomLevel = settingsPrefs.getFloat("map_zoom_level", 10.0f).toDouble()
|
mapZoomLevel = settingsPrefs.getFloat("map_zoom_level", 10.0f).toDouble()
|
||||||
mapRailwayLayerVisible = settingsPrefs.getBoolean("map_railway_visible", true)
|
mapRailwayLayerVisible = settingsPrefs.getBoolean("map_railway_visible", true)
|
||||||
|
|
||||||
Log.d(TAG, "Loaded settings deviceName=${settingsDeviceName} tab=${currentTab}")
|
mergeSettings = trainRecordManager.mergeSettings
|
||||||
|
|
||||||
|
specifiedDeviceAddress = settingsPrefs.getString("specified_device_address", null)
|
||||||
|
|
||||||
|
val searchOrderStr = settingsPrefs.getString("search_order_list", "")
|
||||||
|
searchOrderList = if (searchOrderStr.isNullOrEmpty()) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
searchOrderStr.split(",").filter { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
autoConnectEnabled = settingsPrefs.getBoolean("auto_connect_enabled", true)
|
||||||
|
|
||||||
|
bleClient.setSpecifiedDeviceAddress(specifiedDeviceAddress)
|
||||||
|
|
||||||
|
Log.d(TAG, "Loaded settings deviceName=${settingsDeviceName} tab=${currentTab} specifiedDevice=${specifiedDeviceAddress} searchOrder=${searchOrderList.size} autoConnect=${autoConnectEnabled}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun saveSettings() {
|
private fun saveSettings() {
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
val editor = settingsPrefs.edit()
|
val editor = settingsPrefs.edit()
|
||||||
.putString("device_name", settingsDeviceName)
|
.putString("device_name", settingsDeviceName)
|
||||||
.putInt("current_tab", currentTab)
|
.putInt("current_tab", currentTab)
|
||||||
@@ -608,8 +807,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
.putString("history_expanded_states", historyExpandedStates.map { "${it.key}:${it.value}" }.joinToString(";"))
|
.putString("history_expanded_states", historyExpandedStates.map { "${it.key}:${it.value}" }.joinToString(";"))
|
||||||
.putInt("history_scroll_position", historyScrollPosition)
|
.putInt("history_scroll_position", historyScrollPosition)
|
||||||
.putInt("history_scroll_offset", historyScrollOffset)
|
.putInt("history_scroll_offset", historyScrollOffset)
|
||||||
|
.putInt("settings_scroll_position", settingsScrollPosition)
|
||||||
.putFloat("map_zoom_level", mapZoomLevel.toFloat())
|
.putFloat("map_zoom_level", mapZoomLevel.toFloat())
|
||||||
.putBoolean("map_railway_visible", mapRailwayLayerVisible)
|
.putBoolean("map_railway_visible", mapRailwayLayerVisible)
|
||||||
|
.putString("specified_device_address", specifiedDeviceAddress)
|
||||||
|
.putString("search_order_list", searchOrderList.joinToString(","))
|
||||||
|
.putBoolean("auto_connect_enabled", autoConnectEnabled)
|
||||||
|
|
||||||
mapCenterPosition?.let { (lat, lon) ->
|
mapCenterPosition?.let { (lat, lon) ->
|
||||||
editor.putFloat("map_center_lat", lat.toFloat())
|
editor.putFloat("map_center_lat", lat.toFloat())
|
||||||
@@ -619,20 +822,41 @@ class MainActivity : ComponentActivity() {
|
|||||||
editor.apply()
|
editor.apply()
|
||||||
Log.d(TAG, "Saved settings deviceName=${settingsDeviceName} tab=${currentTab} mapCenter=${mapCenterPosition} zoom=${mapZoomLevel}")
|
Log.d(TAG, "Saved settings deviceName=${settingsDeviceName} tab=${currentTab} mapCenter=${mapCenterPosition} zoom=${mapZoomLevel}")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
Log.d(TAG, "App resumed")
|
Log.d(TAG, "App resumed")
|
||||||
|
|
||||||
if (hasBluetoothPermissions() && !bleClient.isConnected()) {
|
bleClient.setHighFrequencyReconnect(true)
|
||||||
|
|
||||||
|
if (hasBluetoothPermissions()) {
|
||||||
|
val actuallyConnected = bleClient.checkActualConnectionState()
|
||||||
|
|
||||||
|
if (actuallyConnected) {
|
||||||
|
showDisconnectButton = true
|
||||||
|
deviceStatus = "已连接"
|
||||||
|
Log.d(TAG, "App resumed - connection verified")
|
||||||
|
} else if (autoConnectEnabled) {
|
||||||
Log.d(TAG, "App resumed and not connected, starting auto scan")
|
Log.d(TAG, "App resumed and not connected, starting auto scan")
|
||||||
startAutoScanAndConnect()
|
startAutoScanAndConnect()
|
||||||
|
} else {
|
||||||
|
deviceStatus = "未连接"
|
||||||
|
showDisconnectButton = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
saveSettings()
|
saveSettings()
|
||||||
|
if (isFinishing) {
|
||||||
|
bleClient.disconnectAndCleanup()
|
||||||
|
Log.d(TAG, "App finishing, BLE cleaned up")
|
||||||
|
} else {
|
||||||
|
bleClient.setHighFrequencyReconnect(false)
|
||||||
|
Log.d(TAG, "App paused, reduced reconnect frequency")
|
||||||
|
}
|
||||||
Log.d(TAG, "App paused, settings saved")
|
Log.d(TAG, "App paused, settings saved")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -646,6 +870,13 @@ fun MainContent(
|
|||||||
currentTab: Int,
|
currentTab: Int,
|
||||||
onTabChange: (Int) -> Unit,
|
onTabChange: (Int) -> Unit,
|
||||||
onConnectClick: () -> Unit,
|
onConnectClick: () -> Unit,
|
||||||
|
onDisconnectClick: () -> Unit,
|
||||||
|
showDisconnectButton: Boolean,
|
||||||
|
specifiedDeviceAddress: String?,
|
||||||
|
searchOrderList: List<String>,
|
||||||
|
onSpecifiedDeviceSelected: (String?) -> Unit,
|
||||||
|
autoConnectEnabled: Boolean,
|
||||||
|
onAutoConnectEnabledChange: (Boolean) -> Unit,
|
||||||
|
|
||||||
|
|
||||||
latestRecord: TrainRecord?,
|
latestRecord: TrainRecord?,
|
||||||
@@ -656,11 +887,14 @@ fun MainContent(
|
|||||||
onClearMonitorLog: () -> Unit,
|
onClearMonitorLog: () -> Unit,
|
||||||
|
|
||||||
|
|
||||||
allRecords: List<TrainRecord>,
|
allRecords: List<Any>,
|
||||||
|
mergedRecords: List<org.noxylva.lbjconsole.model.MergedTrainRecord>,
|
||||||
recordCount: Int,
|
recordCount: Int,
|
||||||
filterTrain: String,
|
filterTrain: String,
|
||||||
filterRoute: String,
|
filterRoute: String,
|
||||||
filterDirection: String,
|
filterDirection: String,
|
||||||
|
mergeSettings: MergeSettings,
|
||||||
|
onMergeSettingsChange: (MergeSettings) -> Unit,
|
||||||
onFilterChange: (String, String, String) -> Unit,
|
onFilterChange: (String, String, String) -> Unit,
|
||||||
onClearFilter: () -> Unit,
|
onClearFilter: () -> Unit,
|
||||||
onClearRecords: () -> Unit,
|
onClearRecords: () -> Unit,
|
||||||
@@ -685,10 +919,16 @@ fun MainContent(
|
|||||||
onHistoryStateChange: (Boolean, Set<String>, Map<String, Boolean>, Int, Int) -> Unit,
|
onHistoryStateChange: (Boolean, Set<String>, Map<String, Boolean>, Int, Int) -> Unit,
|
||||||
|
|
||||||
|
|
||||||
|
settingsScrollPosition: Int,
|
||||||
|
onSettingsScrollPositionChange: (Int) -> Unit,
|
||||||
|
|
||||||
|
|
||||||
mapCenterPosition: Pair<Double, Double>?,
|
mapCenterPosition: Pair<Double, Double>?,
|
||||||
mapZoomLevel: Double,
|
mapZoomLevel: Double,
|
||||||
mapRailwayLayerVisible: Boolean,
|
mapRailwayLayerVisible: Boolean,
|
||||||
onMapStateChange: (Pair<Double, Double>?, Double, Boolean) -> Unit
|
onMapStateChange: (Pair<Double, Double>?, Double, Boolean) -> Unit,
|
||||||
|
|
||||||
|
onOpenSettings: () -> Unit
|
||||||
) {
|
) {
|
||||||
val statusColor = if (isConnected) Color(0xFF4CAF50) else Color(0xFFFF5722)
|
val statusColor = if (isConnected) Color(0xFF4CAF50) else Color(0xFFFF5722)
|
||||||
|
|
||||||
@@ -750,8 +990,33 @@ fun MainContent(
|
|||||||
if (historyEditMode && currentTab == 0) {
|
if (historyEditMode && currentTab == 0) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
|
val totalSelectedCount = run {
|
||||||
|
val processedMergedRecords = mutableSetOf<String>()
|
||||||
|
var count = 0
|
||||||
|
|
||||||
|
historySelectedRecords.forEach { selectedId ->
|
||||||
|
val foundItem = allRecords.find { item ->
|
||||||
|
when (item) {
|
||||||
|
is TrainRecord -> item.uniqueId == selectedId
|
||||||
|
is org.noxylva.lbjconsole.model.MergedTrainRecord -> item.records.any { it.uniqueId == selectedId }
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (foundItem) {
|
||||||
|
is TrainRecord -> count += 1
|
||||||
|
is org.noxylva.lbjconsole.model.MergedTrainRecord -> {
|
||||||
|
if (!processedMergedRecords.contains(foundItem.groupKey)) {
|
||||||
|
count += foundItem.records.size
|
||||||
|
processedMergedRecords.add(foundItem.groupKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
"已选择 ${historySelectedRecords.size} 条记录",
|
"已选择 $totalSelectedCount 条记录",
|
||||||
color = MaterialTheme.colorScheme.onPrimary
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -770,10 +1035,39 @@ fun MainContent(
|
|||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (historySelectedRecords.isNotEmpty()) {
|
if (historySelectedRecords.isNotEmpty()) {
|
||||||
val recordsToDelete = allRecords.filter {
|
val recordsToDelete = mutableSetOf<TrainRecord>()
|
||||||
historySelectedRecords.contains(it.timestamp.time.toString())
|
val idToRecordMap = mutableMapOf<String, TrainRecord>()
|
||||||
|
val idToMergedRecordMap = mutableMapOf<String, org.noxylva.lbjconsole.model.MergedTrainRecord>()
|
||||||
|
|
||||||
|
allRecords.forEach { item ->
|
||||||
|
when (item) {
|
||||||
|
is TrainRecord -> {
|
||||||
|
idToRecordMap[item.uniqueId] = item
|
||||||
}
|
}
|
||||||
onDeleteRecords(recordsToDelete)
|
is org.noxylva.lbjconsole.model.MergedTrainRecord -> {
|
||||||
|
item.records.forEach { record ->
|
||||||
|
idToRecordMap[record.uniqueId] = record
|
||||||
|
idToMergedRecordMap[record.uniqueId] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val processedMergedRecordKeys = mutableSetOf<String>()
|
||||||
|
|
||||||
|
historySelectedRecords.forEach { selectedId ->
|
||||||
|
val mergedRecord = idToMergedRecordMap[selectedId]
|
||||||
|
if (mergedRecord != null && !processedMergedRecordKeys.contains(mergedRecord.groupKey)) {
|
||||||
|
recordsToDelete.addAll(mergedRecord.records)
|
||||||
|
processedMergedRecordKeys.add(mergedRecord.groupKey)
|
||||||
|
} else if (mergedRecord == null) {
|
||||||
|
idToRecordMap[selectedId]?.let { record ->
|
||||||
|
recordsToDelete.add(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDeleteRecords(recordsToDelete.toList())
|
||||||
onHistoryStateChange(false, emptySet(), historyExpandedStates, historyScrollPosition, historyScrollOffset)
|
onHistoryStateChange(false, emptySet(), historyExpandedStates, historyScrollPosition, historyScrollOffset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -823,12 +1117,14 @@ fun MainContent(
|
|||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
) {
|
) {
|
||||||
when (currentTab) {
|
when (currentTab) {
|
||||||
0 -> HistoryScreen(
|
0 -> {
|
||||||
|
HistoryScreen(
|
||||||
records = allRecords,
|
records = allRecords,
|
||||||
latestRecord = latestRecord,
|
latestRecord = latestRecord,
|
||||||
lastUpdateTime = lastUpdateTime,
|
lastUpdateTime = lastUpdateTime,
|
||||||
temporaryStatusMessage = temporaryStatusMessage,
|
temporaryStatusMessage = temporaryStatusMessage,
|
||||||
locoInfoUtil = locoInfoUtil,
|
locoInfoUtil = locoInfoUtil,
|
||||||
|
mergeSettings = mergeSettings,
|
||||||
onClearRecords = onClearRecords,
|
onClearRecords = onClearRecords,
|
||||||
onRecordClick = onRecordClick,
|
onRecordClick = onRecordClick,
|
||||||
onClearLog = onClearMonitorLog,
|
onClearLog = onClearMonitorLog,
|
||||||
@@ -840,14 +1136,37 @@ fun MainContent(
|
|||||||
scrollOffset = historyScrollOffset,
|
scrollOffset = historyScrollOffset,
|
||||||
onStateChange = onHistoryStateChange
|
onStateChange = onHistoryStateChange
|
||||||
)
|
)
|
||||||
|
}
|
||||||
2 -> SettingsScreen(
|
2 -> SettingsScreen(
|
||||||
deviceName = deviceName,
|
deviceName = deviceName,
|
||||||
onDeviceNameChange = onDeviceNameChange,
|
onDeviceNameChange = onDeviceNameChange,
|
||||||
onApplySettings = onApplySettings,
|
onApplySettings = onApplySettings,
|
||||||
appVersion = appVersion
|
appVersion = appVersion,
|
||||||
|
mergeSettings = mergeSettings,
|
||||||
|
onMergeSettingsChange = onMergeSettingsChange,
|
||||||
|
scrollPosition = settingsScrollPosition,
|
||||||
|
onScrollPositionChange = onSettingsScrollPositionChange,
|
||||||
|
specifiedDeviceAddress = specifiedDeviceAddress,
|
||||||
|
searchOrderList = searchOrderList,
|
||||||
|
onSpecifiedDeviceSelected = onSpecifiedDeviceSelected,
|
||||||
|
autoConnectEnabled = autoConnectEnabled,
|
||||||
|
onAutoConnectEnabledChange = onAutoConnectEnabledChange
|
||||||
)
|
)
|
||||||
3 -> MapScreen(
|
3 -> MapScreen(
|
||||||
records = if (allRecords.isNotEmpty()) allRecords else recentRecords,
|
records = if (allRecords.isNotEmpty()) {
|
||||||
|
val trainRecords = mutableListOf<TrainRecord>()
|
||||||
|
allRecords.forEach { item ->
|
||||||
|
when (item) {
|
||||||
|
is TrainRecord -> trainRecords.add(item)
|
||||||
|
is org.noxylva.lbjconsole.model.MergedTrainRecord -> {
|
||||||
|
trainRecords.addAll(item.records)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trainRecords
|
||||||
|
} else {
|
||||||
|
recentRecords
|
||||||
|
},
|
||||||
centerPosition = mapCenterPosition,
|
centerPosition = mapCenterPosition,
|
||||||
zoomLevel = mapZoomLevel,
|
zoomLevel = mapZoomLevel,
|
||||||
railwayLayerVisible = mapRailwayLayerVisible,
|
railwayLayerVisible = mapRailwayLayerVisible,
|
||||||
@@ -864,59 +1183,232 @@ fun ConnectionDialog(
|
|||||||
devices: List<BluetoothDevice>,
|
devices: List<BluetoothDevice>,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onScan: () -> Unit,
|
onScan: () -> Unit,
|
||||||
onConnect: (BluetoothDevice) -> Unit
|
onConnect: (BluetoothDevice) -> Unit,
|
||||||
|
onDisconnect: () -> Unit = {},
|
||||||
|
isConnected: Boolean = false,
|
||||||
|
targetDeviceName: String = "LBJReceiver",
|
||||||
|
deviceAddress: String? = null
|
||||||
) {
|
) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("连接设备") },
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "蓝牙设备",
|
||||||
|
style = MaterialTheme.typography.headlineSmall
|
||||||
|
)
|
||||||
|
},
|
||||||
text = {
|
text = {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(
|
||||||
Button(
|
|
||||||
onClick = onScan,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text(if (isScanning) "停止扫描" else "扫描设备")
|
if (!isConnected) {
|
||||||
}
|
Button(
|
||||||
|
onClick = onScan,
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = if (isScanning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
if (isScanning) {
|
if (isScanning) {
|
||||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
CircularProgressIndicator(
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "扫描设备",
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (devices.isNotEmpty() && !isConnected) {
|
||||||
|
Text(
|
||||||
|
text = "发现 ${devices.size} 个设备",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.heightIn(max = 200.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
items(devices.filter { !isConnected }) { device ->
|
||||||
|
var isPressed by remember { mutableStateOf(false) }
|
||||||
|
var isHovered by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val cardScale by animateFloatAsState(
|
||||||
|
targetValue = when {
|
||||||
|
isPressed -> 0.96f
|
||||||
|
isHovered -> 1.02f
|
||||||
|
else -> 1f
|
||||||
|
},
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessLow
|
||||||
|
),
|
||||||
|
label = "cardScale"
|
||||||
|
)
|
||||||
|
|
||||||
|
val cardElevation by animateDpAsState(
|
||||||
|
targetValue = when {
|
||||||
|
isPressed -> 1.dp
|
||||||
|
isHovered -> 6.dp
|
||||||
|
else -> 2.dp
|
||||||
|
},
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 150,
|
||||||
|
easing = FastOutSlowInEasing
|
||||||
|
),
|
||||||
|
label = "cardElevation"
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(isPressed) {
|
||||||
|
if (isPressed) {
|
||||||
|
delay(120)
|
||||||
|
isPressed = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (devices.isEmpty()) {
|
|
||||||
Text("未找到设备")
|
|
||||||
} else {
|
|
||||||
Column {
|
|
||||||
devices.forEach { device ->
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp)
|
.graphicsLayer {
|
||||||
.clickable { onConnect(device) }
|
scaleX = cardScale
|
||||||
|
scaleY = cardScale
|
||||||
|
}
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(
|
||||||
|
onPress = {
|
||||||
|
isPressed = true
|
||||||
|
isHovered = true
|
||||||
|
tryAwaitRelease()
|
||||||
|
isHovered = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = cardElevation)
|
||||||
) {
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = rememberRipple(bounded = true)
|
||||||
|
) {
|
||||||
|
isPressed = true
|
||||||
|
onConnect(device)
|
||||||
|
}
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Bluetooth,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(8.dp)
|
modifier = Modifier.weight(1f)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = device.name ?: "未知设备",
|
text = device.name ?: "未知设备",
|
||||||
fontWeight = FontWeight.Bold
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = device.address,
|
text = device.address,
|
||||||
style = MaterialTheme.typography.bodySmall
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (isScanning && !isConnected) {
|
||||||
|
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 32.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isConnected) Icons.Default.Bluetooth else Icons.Default.BluetoothSearching,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = if (isConnected)
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = if (isConnected) "设备已连接" else "未发现设备",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (isConnected)
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = if (isConnected)
|
||||||
|
deviceAddress?.ifEmpty { "未知地址" } ?: "未知地址"
|
||||||
|
else
|
||||||
|
"请确保设备已开启并处于可发现状态",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
if (isConnected) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onDisconnect,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.BluetoothDisabled,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("断开连接")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = onDismiss) {
|
TextButton(onClick = onDismiss) {
|
||||||
Text("取消")
|
Text("关闭")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
217
app/src/main/java/org/noxylva/lbjconsole/NotificationService.kt
Normal file
217
app/src/main/java/org/noxylva/lbjconsole/NotificationService.kt
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
package org.noxylva.lbjconsole
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.RemoteViews
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.noxylva.lbjconsole.model.TrainRecord
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationService(private val context: Context) {
|
||||||
|
companion object {
|
||||||
|
const val TAG = "NotificationService"
|
||||||
|
const val CHANNEL_ID = "lbj_messages"
|
||||||
|
const val CHANNEL_NAME = "LBJ Messages"
|
||||||
|
const val NOTIFICATION_ID_BASE = 2000
|
||||||
|
const val PREFS_NAME = "notification_settings"
|
||||||
|
const val KEY_ENABLED = "notifications_enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val notificationManager = NotificationManagerCompat.from(context)
|
||||||
|
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
private var notificationIdCounter = NOTIFICATION_ID_BASE
|
||||||
|
|
||||||
|
init {
|
||||||
|
createNotificationChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
CHANNEL_NAME,
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
).apply {
|
||||||
|
description = "Real-time LBJ train message notifications"
|
||||||
|
enableVibration(true)
|
||||||
|
setShowBadge(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
Log.d(TAG, "Notification channel created")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isNotificationEnabled(): Boolean {
|
||||||
|
return prefs.getBoolean(KEY_ENABLED, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setNotificationEnabled(enabled: Boolean) {
|
||||||
|
prefs.edit().putBoolean(KEY_ENABLED, enabled).apply()
|
||||||
|
Log.d(TAG, "Notification enabled set to: $enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isValidValue(value: String): Boolean {
|
||||||
|
val trimmed = value.trim()
|
||||||
|
return trimmed.isNotEmpty() &&
|
||||||
|
trimmed != "NUL" &&
|
||||||
|
trimmed != "<NUL>" &&
|
||||||
|
trimmed != "NA" &&
|
||||||
|
trimmed != "<NA>" &&
|
||||||
|
!trimmed.all { it == '*' }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showTrainNotification(trainRecord: TrainRecord) {
|
||||||
|
if (!isNotificationEnabled()) {
|
||||||
|
Log.d(TAG, "Notifications disabled, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
}
|
||||||
|
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val remoteViews = RemoteViews(context.packageName, R.layout.notification_train_record)
|
||||||
|
val trainDisplay = if (isValidValue(trainRecord.lbjClass) && isValidValue(trainRecord.train)) {
|
||||||
|
"${trainRecord.lbjClass.trim()}${trainRecord.train.trim()}"
|
||||||
|
} else if (isValidValue(trainRecord.lbjClass)) {
|
||||||
|
trainRecord.lbjClass.trim()
|
||||||
|
} else if (isValidValue(trainRecord.train)) {
|
||||||
|
trainRecord.train.trim()
|
||||||
|
} else "列车"
|
||||||
|
remoteViews.setTextViewText(R.id.notification_train_number, trainDisplay)
|
||||||
|
|
||||||
|
val directionText = when (trainRecord.direction) {
|
||||||
|
1 -> "下"
|
||||||
|
3 -> "上"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
if (directionText.isNotEmpty()) {
|
||||||
|
remoteViews.setTextViewText(R.id.notification_direction, directionText)
|
||||||
|
remoteViews.setViewVisibility(R.id.notification_direction, View.VISIBLE)
|
||||||
|
} else {
|
||||||
|
remoteViews.setViewVisibility(R.id.notification_direction, View.GONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
val locoInfo = when {
|
||||||
|
isValidValue(trainRecord.locoType) && isValidValue(trainRecord.loco) -> {
|
||||||
|
val shortLoco = if (trainRecord.loco.length > 5) {
|
||||||
|
trainRecord.loco.takeLast(5)
|
||||||
|
} else {
|
||||||
|
trainRecord.loco
|
||||||
|
}
|
||||||
|
"${trainRecord.locoType}-${shortLoco}"
|
||||||
|
}
|
||||||
|
isValidValue(trainRecord.locoType) -> trainRecord.locoType
|
||||||
|
isValidValue(trainRecord.loco) -> trainRecord.loco
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
if (locoInfo.isNotEmpty()) {
|
||||||
|
remoteViews.setTextViewText(R.id.notification_loco_info, locoInfo)
|
||||||
|
remoteViews.setViewVisibility(R.id.notification_loco_info, View.VISIBLE)
|
||||||
|
} else {
|
||||||
|
remoteViews.setViewVisibility(R.id.notification_loco_info, View.GONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValidValue(trainRecord.route)) {
|
||||||
|
remoteViews.setTextViewText(R.id.notification_route, trainRecord.route.trim())
|
||||||
|
remoteViews.setViewVisibility(R.id.notification_route, View.VISIBLE)
|
||||||
|
} else {
|
||||||
|
remoteViews.setViewVisibility(R.id.notification_route, View.GONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValidValue(trainRecord.position)) {
|
||||||
|
remoteViews.setTextViewText(R.id.notification_position, "${trainRecord.position.trim()}K")
|
||||||
|
remoteViews.setViewVisibility(R.id.notification_position, View.VISIBLE)
|
||||||
|
} else {
|
||||||
|
remoteViews.setViewVisibility(R.id.notification_position, View.GONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValidValue(trainRecord.speed)) {
|
||||||
|
remoteViews.setTextViewText(R.id.notification_speed, "${trainRecord.speed.trim()} km/h")
|
||||||
|
remoteViews.setViewVisibility(R.id.notification_speed, View.VISIBLE)
|
||||||
|
} else {
|
||||||
|
remoteViews.setViewVisibility(R.id.notification_speed, View.GONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteViews.setOnClickPendingIntent(R.id.notification_train_number, pendingIntent)
|
||||||
|
|
||||||
|
val summaryParts = mutableListOf<String>()
|
||||||
|
|
||||||
|
val routeAndDirection = when {
|
||||||
|
isValidValue(trainRecord.route) && directionText.isNotEmpty() -> "${trainRecord.route.trim()}${directionText}行"
|
||||||
|
isValidValue(trainRecord.route) -> trainRecord.route.trim()
|
||||||
|
directionText.isNotEmpty() -> "${directionText}行"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
routeAndDirection?.let { summaryParts.add(it) }
|
||||||
|
if (locoInfo.isNotEmpty()) summaryParts.add(locoInfo)
|
||||||
|
|
||||||
|
val summaryText = summaryParts.joinToString(" • ")
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(trainDisplay)
|
||||||
|
.setContentText(summaryText)
|
||||||
|
.setCustomContentView(remoteViews)
|
||||||
|
.setCustomBigContentView(remoteViews)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setWhen(trainRecord.timestamp.time)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val notificationId = notificationIdCounter++
|
||||||
|
if (notificationIdCounter > NOTIFICATION_ID_BASE + 1000) {
|
||||||
|
notificationIdCounter = NOTIFICATION_ID_BASE
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationManager.notify(notificationId, notification)
|
||||||
|
Log.d(TAG, "Custom notification sent for train: ${trainRecord.train}")
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to show notification: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showTrainNotification(jsonData: JSONObject) {
|
||||||
|
if (!isNotificationEnabled()) {
|
||||||
|
Log.d(TAG, "Notifications disabled, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val trainRecord = TrainRecord(jsonData)
|
||||||
|
showTrainNotification(trainRecord)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to create TrainRecord from JSON: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasNotificationPermission(): Boolean {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
notificationManager.areNotificationsEnabled()
|
||||||
|
} else {
|
||||||
|
notificationManager.areNotificationsEnabled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/src/main/java/org/noxylva/lbjconsole/SettingsActivity.kt
Normal file
63
app/src/main/java/org/noxylva/lbjconsole/SettingsActivity.kt
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package org.noxylva.lbjconsole
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Switch
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
|
||||||
|
class SettingsActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFS_NAME = "lbj_console_settings"
|
||||||
|
private const val KEY_BACKGROUND_SERVICE = "background_service_enabled"
|
||||||
|
|
||||||
|
fun isBackgroundServiceEnabled(context: Context): Boolean {
|
||||||
|
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getBoolean(KEY_BACKGROUND_SERVICE, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBackgroundServiceEnabled(context: Context, enabled: Boolean) {
|
||||||
|
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit().putBoolean(KEY_BACKGROUND_SERVICE, enabled).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var backgroundServiceSwitch: Switch
|
||||||
|
private lateinit var prefs: SharedPreferences
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_settings)
|
||||||
|
|
||||||
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
|
supportActionBar?.title = "Settings"
|
||||||
|
|
||||||
|
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
initViews()
|
||||||
|
setupListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initViews() {
|
||||||
|
backgroundServiceSwitch = findViewById(R.id.switch_background_service)
|
||||||
|
backgroundServiceSwitch.isChecked = isBackgroundServiceEnabled(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupListeners() {
|
||||||
|
backgroundServiceSwitch.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
setBackgroundServiceEnabled(this, isChecked)
|
||||||
|
|
||||||
|
if (isChecked) {
|
||||||
|
BackgroundService.startService(this)
|
||||||
|
} else {
|
||||||
|
BackgroundService.stopService(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSupportNavigateUp(): Boolean {
|
||||||
|
onBackPressed()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package org.noxylva.lbjconsole.model
|
||||||
|
|
||||||
|
data class MergeSettings(
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val groupBy: GroupBy = GroupBy.TRAIN_AND_LOCO,
|
||||||
|
val timeWindow: TimeWindow = TimeWindow.UNLIMITED
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class GroupBy(val displayName: String) {
|
||||||
|
TRAIN_ONLY("车次号"),
|
||||||
|
LOCO_ONLY("机车号"),
|
||||||
|
TRAIN_OR_LOCO("车次号或机车号"),
|
||||||
|
TRAIN_AND_LOCO("车次号与机车号")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class TimeWindow(val displayName: String, val seconds: Long?) {
|
||||||
|
ONE_HOUR("1小时", 3600),
|
||||||
|
TWO_HOURS("2小时", 7200),
|
||||||
|
SIX_HOURS("6小时", 21600),
|
||||||
|
TWELVE_HOURS("12小时", 43200),
|
||||||
|
ONE_DAY("24小时", 86400),
|
||||||
|
UNLIMITED("不限时间", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateGroupKey(record: TrainRecord, groupBy: GroupBy): String? {
|
||||||
|
return when (groupBy) {
|
||||||
|
GroupBy.TRAIN_ONLY -> {
|
||||||
|
val train = record.train.trim()
|
||||||
|
if (train.isNotEmpty() && train != "<NUL>") train else null
|
||||||
|
}
|
||||||
|
GroupBy.LOCO_ONLY -> {
|
||||||
|
val loco = record.loco.trim()
|
||||||
|
if (loco.isNotEmpty() && loco != "<NUL>") loco else null
|
||||||
|
}
|
||||||
|
GroupBy.TRAIN_OR_LOCO -> {
|
||||||
|
val train = record.train.trim()
|
||||||
|
val loco = record.loco.trim()
|
||||||
|
when {
|
||||||
|
train.isNotEmpty() && train != "<NUL>" -> train
|
||||||
|
loco.isNotEmpty() && loco != "<NUL>" -> loco
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GroupBy.TRAIN_AND_LOCO -> {
|
||||||
|
val train = record.train.trim()
|
||||||
|
val loco = record.loco.trim()
|
||||||
|
if (train.isNotEmpty() && train != "<NUL>" &&
|
||||||
|
loco.isNotEmpty() && loco != "<NUL>") {
|
||||||
|
"${train}_${loco}"
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package org.noxylva.lbjconsole.model
|
||||||
|
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
data class MergedTrainRecord(
|
||||||
|
val groupKey: String,
|
||||||
|
val records: List<TrainRecord>,
|
||||||
|
val latestRecord: TrainRecord
|
||||||
|
) {
|
||||||
|
val recordCount: Int get() = records.size
|
||||||
|
val timeSpan: Pair<Date, Date> get() =
|
||||||
|
records.minByOrNull { it.timestamp }!!.timestamp to
|
||||||
|
records.maxByOrNull { it.timestamp }!!.timestamp
|
||||||
|
|
||||||
|
fun getAllCoordinates() = records.mapNotNull { it.getCoordinates() }
|
||||||
|
|
||||||
|
fun getUniqueRoutes() = records.map { it.route }.filter { it.isNotEmpty() && it != "<NUL>" }.toSet()
|
||||||
|
|
||||||
|
fun getUniquePositions() = records.map { it.position }.filter { it.isNotEmpty() && it != "<NUL>" }.toSet()
|
||||||
|
}
|
||||||
@@ -9,8 +9,15 @@ import org.noxylva.lbjconsole.util.LocationUtils
|
|||||||
class TrainRecord(jsonData: JSONObject? = null) {
|
class TrainRecord(jsonData: JSONObject? = null) {
|
||||||
companion object {
|
companion object {
|
||||||
const val TAG = "TrainRecord"
|
const val TAG = "TrainRecord"
|
||||||
|
private var nextId = 0L
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun generateUniqueId(): String {
|
||||||
|
return "${System.currentTimeMillis()}_${++nextId}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val uniqueId: String
|
||||||
var timestamp: Date = Date()
|
var timestamp: Date = Date()
|
||||||
var receivedTimestamp: Date = Date()
|
var receivedTimestamp: Date = Date()
|
||||||
var train: String = ""
|
var train: String = ""
|
||||||
@@ -29,10 +36,15 @@ class TrainRecord(jsonData: JSONObject? = null) {
|
|||||||
private var _coordinates: GeoPoint? = null
|
private var _coordinates: GeoPoint? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
uniqueId = if (jsonData?.has("uniqueId") == true) {
|
||||||
|
jsonData.getString("uniqueId")
|
||||||
|
} else {
|
||||||
|
generateUniqueId()
|
||||||
|
}
|
||||||
|
|
||||||
jsonData?.let {
|
jsonData?.let {
|
||||||
try {
|
try {
|
||||||
if (jsonData.has("timestamp")) {
|
if (jsonData.has("timestamp")) {
|
||||||
|
|
||||||
timestamp = Date(jsonData.getLong("timestamp"))
|
timestamp = Date(jsonData.getLong("timestamp"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +178,7 @@ class TrainRecord(jsonData: JSONObject? = null) {
|
|||||||
|
|
||||||
fun toJSON(): JSONObject {
|
fun toJSON(): JSONObject {
|
||||||
val json = JSONObject()
|
val json = JSONObject()
|
||||||
|
json.put("uniqueId", uniqueId)
|
||||||
json.put("timestamp", timestamp.time)
|
json.put("timestamp", timestamp.time)
|
||||||
json.put("receivedTimestamp", receivedTimestamp.time)
|
json.put("receivedTimestamp", receivedTimestamp.time)
|
||||||
json.put("train", train)
|
json.put("train", train)
|
||||||
@@ -181,4 +194,14 @@ class TrainRecord(jsonData: JSONObject? = null) {
|
|||||||
json.put("rssi", rssi)
|
json.put("rssi", rssi)
|
||||||
return json
|
return json
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other !is TrainRecord) return false
|
||||||
|
return uniqueId == other.uniqueId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return uniqueId.hashCode()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.*
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -19,15 +20,23 @@ class TrainRecordManager(private val context: Context) {
|
|||||||
const val MAX_RECORDS = 1000
|
const val MAX_RECORDS = 1000
|
||||||
private const val PREFS_NAME = "train_records"
|
private const val PREFS_NAME = "train_records"
|
||||||
private const val KEY_RECORDS = "records"
|
private const val KEY_RECORDS = "records"
|
||||||
|
private const val KEY_MERGE_SETTINGS = "merge_settings"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private val trainRecords = CopyOnWriteArrayList<TrainRecord>()
|
private val trainRecords = CopyOnWriteArrayList<TrainRecord>()
|
||||||
private val recordCount = AtomicInteger(0)
|
private val recordCount = AtomicInteger(0)
|
||||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
var mergeSettings = MergeSettings()
|
||||||
|
private set
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
ioScope.launch {
|
||||||
loadRecords()
|
loadRecords()
|
||||||
|
loadMergeSettings()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -140,6 +149,7 @@ class TrainRecordManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun saveRecords() {
|
private fun saveRecords() {
|
||||||
|
ioScope.launch {
|
||||||
try {
|
try {
|
||||||
val jsonArray = JSONArray()
|
val jsonArray = JSONArray()
|
||||||
for (record in trainRecords) {
|
for (record in trainRecords) {
|
||||||
@@ -151,6 +161,7 @@ class TrainRecordManager(private val context: Context) {
|
|||||||
Log.e(TAG, "Failed to save records: ${e.message}")
|
Log.e(TAG, "Failed to save records: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun loadRecords() {
|
private fun loadRecords() {
|
||||||
@@ -177,4 +188,165 @@ class TrainRecordManager(private val context: Context) {
|
|||||||
fun getRecordCount(): Int {
|
fun getRecordCount(): Int {
|
||||||
return recordCount.get()
|
return recordCount.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateMergeSettings(newSettings: MergeSettings) {
|
||||||
|
mergeSettings = newSettings
|
||||||
|
saveMergeSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun getMergedRecords(): List<MergedTrainRecord> {
|
||||||
|
if (!mergeSettings.enabled) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val records = getFilteredRecords()
|
||||||
|
return processRecordsForMerging(records, mergeSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMixedRecords(): List<Any> {
|
||||||
|
if (!mergeSettings.enabled) {
|
||||||
|
return getFilteredRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
val allRecords = getFilteredRecords()
|
||||||
|
val mergedRecords = processRecordsForMerging(allRecords, mergeSettings)
|
||||||
|
|
||||||
|
val mergedRecordIds = mergedRecords.flatMap { merged ->
|
||||||
|
merged.records.map { it.uniqueId }
|
||||||
|
}.toSet()
|
||||||
|
|
||||||
|
val singleRecords = allRecords.filter { record ->
|
||||||
|
!mergedRecordIds.contains(record.uniqueId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val mixedList = mutableListOf<Any>()
|
||||||
|
mixedList.addAll(mergedRecords)
|
||||||
|
mixedList.addAll(singleRecords)
|
||||||
|
|
||||||
|
return mixedList.sortedByDescending { item ->
|
||||||
|
when (item) {
|
||||||
|
is MergedTrainRecord -> item.latestRecord.timestamp
|
||||||
|
is TrainRecord -> item.timestamp
|
||||||
|
else -> Date(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processRecordsForMerging(records: List<TrainRecord>, settings: MergeSettings): List<MergedTrainRecord> {
|
||||||
|
val currentTime = Date()
|
||||||
|
val validRecords = records.filter { record ->
|
||||||
|
settings.timeWindow.seconds?.let { windowSeconds ->
|
||||||
|
(currentTime.time - record.timestamp.time) / 1000 <= windowSeconds
|
||||||
|
} ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (settings.groupBy) {
|
||||||
|
GroupBy.TRAIN_OR_LOCO -> processTrainOrLocoMerging(validRecords)
|
||||||
|
else -> {
|
||||||
|
val groupedRecords = mutableMapOf<String, MutableList<TrainRecord>>()
|
||||||
|
validRecords.forEach { record ->
|
||||||
|
val groupKey = generateGroupKey(record, settings.groupBy)
|
||||||
|
if (groupKey != null) {
|
||||||
|
groupedRecords.getOrPut(groupKey) { mutableListOf() }.add(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groupedRecords.mapNotNull { (groupKey, groupRecords) ->
|
||||||
|
if (groupRecords.size >= 2) {
|
||||||
|
val sortedRecords = groupRecords.sortedBy { it.timestamp }
|
||||||
|
val latestRecord = sortedRecords.maxByOrNull { it.timestamp }!!
|
||||||
|
MergedTrainRecord(
|
||||||
|
groupKey = groupKey,
|
||||||
|
records = sortedRecords,
|
||||||
|
latestRecord = latestRecord
|
||||||
|
)
|
||||||
|
} else null
|
||||||
|
}.sortedByDescending { it.latestRecord.timestamp }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processTrainOrLocoMerging(records: List<TrainRecord>): List<MergedTrainRecord> {
|
||||||
|
val groups = mutableListOf<MutableList<TrainRecord>>()
|
||||||
|
|
||||||
|
records.forEach { record ->
|
||||||
|
val train = record.train.trim()
|
||||||
|
val loco = record.loco.trim()
|
||||||
|
|
||||||
|
if ((train.isEmpty() || train == "<NUL>") && (loco.isEmpty() || loco == "<NUL>")) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
|
||||||
|
var foundGroup: MutableList<TrainRecord>? = null
|
||||||
|
|
||||||
|
for (group in groups) {
|
||||||
|
val shouldMerge = group.any { existingRecord ->
|
||||||
|
val existingTrain = existingRecord.train.trim()
|
||||||
|
val existingLoco = existingRecord.loco.trim()
|
||||||
|
|
||||||
|
(train.isNotEmpty() && train != "<NUL>" && train == existingTrain) ||
|
||||||
|
(loco.isNotEmpty() && loco != "<NUL>" && loco == existingLoco)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldMerge) {
|
||||||
|
foundGroup = group
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundGroup != null) {
|
||||||
|
foundGroup.add(record)
|
||||||
|
} else {
|
||||||
|
groups.add(mutableListOf(record))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups.mapNotNull { groupRecords ->
|
||||||
|
if (groupRecords.size >= 2) {
|
||||||
|
val sortedRecords = groupRecords.sortedBy { it.timestamp }
|
||||||
|
val latestRecord = sortedRecords.maxByOrNull { it.timestamp }!!
|
||||||
|
val groupKey = "${latestRecord.train}_OR_${latestRecord.loco}"
|
||||||
|
MergedTrainRecord(
|
||||||
|
groupKey = groupKey,
|
||||||
|
records = sortedRecords,
|
||||||
|
latestRecord = latestRecord
|
||||||
|
)
|
||||||
|
} else null
|
||||||
|
}.sortedByDescending { it.latestRecord.timestamp }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveMergeSettings() {
|
||||||
|
ioScope.launch {
|
||||||
|
try {
|
||||||
|
val json = JSONObject().apply {
|
||||||
|
put("enabled", mergeSettings.enabled)
|
||||||
|
put("groupBy", mergeSettings.groupBy.name)
|
||||||
|
put("timeWindow", mergeSettings.timeWindow.name)
|
||||||
|
}
|
||||||
|
prefs.edit().putString(KEY_MERGE_SETTINGS, json.toString()).apply()
|
||||||
|
Log.d(TAG, "Saved merge settings")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to save merge settings: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadMergeSettings() {
|
||||||
|
try {
|
||||||
|
val jsonStr = prefs.getString(KEY_MERGE_SETTINGS, null)
|
||||||
|
if (jsonStr != null) {
|
||||||
|
val json = JSONObject(jsonStr)
|
||||||
|
mergeSettings = MergeSettings(
|
||||||
|
enabled = json.getBoolean("enabled"),
|
||||||
|
groupBy = GroupBy.valueOf(json.getString("groupBy")),
|
||||||
|
timeWindow = TimeWindow.valueOf(json.getString("timeWindow"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Loaded merge settings: $mergeSettings")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to load merge settings: ${e.message}")
|
||||||
|
mergeSettings = MergeSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
package org.noxylva.lbjconsole.ui.components
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import org.noxylva.lbjconsole.model.TrainRecord
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TrainInfoCard(
|
|
||||||
trainRecord: TrainRecord,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val recordMap = trainRecord.toMap()
|
|
||||||
|
|
||||||
Card(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 4.dp, horizontal = 6.dp),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(10.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = recordMap["train"]?.toString() ?: "",
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 16.sp
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
|
|
||||||
val directionStr = recordMap["direction"]?.toString() ?: ""
|
|
||||||
val directionColor = when(directionStr) {
|
|
||||||
"上行" -> MaterialTheme.colorScheme.primary
|
|
||||||
"下行" -> MaterialTheme.colorScheme.secondary
|
|
||||||
else -> MaterialTheme.colorScheme.onSurface
|
|
||||||
}
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
shape = RoundedCornerShape(4.dp),
|
|
||||||
color = directionColor.copy(alpha = 0.1f),
|
|
||||||
modifier = Modifier.padding(horizontal = 2.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = directionStr,
|
|
||||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = directionColor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = run {
|
|
||||||
val trainTime = trainRecord.time.trim()
|
|
||||||
if (trainTime.isNotEmpty() && trainTime != "NUL" && trainTime != "<NUL>" && trainTime != "NA" && trainTime != "<NA>") {
|
|
||||||
trainTime
|
|
||||||
} else {
|
|
||||||
val receivedTime = recordMap["receivedTimestamp"]?.toString() ?: ""
|
|
||||||
if (receivedTime.contains(" ")) {
|
|
||||||
receivedTime.split(" ")[1]
|
|
||||||
} else {
|
|
||||||
java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault()).format(trainRecord.receivedTimestamp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "速度: ${recordMap["speed"] ?: ""}",
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "位置: ${recordMap["position"] ?: ""}",
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
HorizontalDivider(thickness = 0.5.dp)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
CompactInfoItem(label = "机车号", value = recordMap["loco"]?.toString() ?: "")
|
|
||||||
CompactInfoItem(label = "线路", value = recordMap["route"]?.toString() ?: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
CompactInfoItem(label = "类型", value = recordMap["lbj_class"]?.toString() ?: "")
|
|
||||||
CompactInfoItem(label = "信号", value = recordMap["rssi"]?.toString() ?: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CompactInfoItem(
|
|
||||||
label: String,
|
|
||||||
value: String,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 2.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "$label: ",
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = value,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
package org.noxylva.lbjconsole.ui.components
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Clear
|
|
||||||
import androidx.compose.material.icons.filled.FilterList
|
|
||||||
import androidx.compose.material.icons.filled.Share
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import org.noxylva.lbjconsole.model.TrainRecord
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TrainRecordsList(
|
|
||||||
records: List<TrainRecord>,
|
|
||||||
onRecordClick: (TrainRecord) -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
if (records.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = "暂无历史记录",
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentPadding = PaddingValues(vertical = 4.dp, horizontal = 8.dp)
|
|
||||||
) {
|
|
||||||
items(records) { record ->
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 2.dp)
|
|
||||||
.clickable { onRecordClick(record) },
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(8.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
|
|
||||||
Column {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = record.train,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 15.sp
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
|
|
||||||
|
|
||||||
val directionText = when (record.direction) {
|
|
||||||
1 -> "下行"
|
|
||||||
3 -> "上行"
|
|
||||||
else -> "未知"
|
|
||||||
}
|
|
||||||
|
|
||||||
val directionColor = when(record.direction) {
|
|
||||||
1 -> MaterialTheme.colorScheme.secondary
|
|
||||||
3 -> MaterialTheme.colorScheme.primary
|
|
||||||
else -> MaterialTheme.colorScheme.onSurface
|
|
||||||
}
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
color = directionColor.copy(alpha = 0.1f),
|
|
||||||
shape = MaterialTheme.shapes.small
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = directionText,
|
|
||||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
|
|
||||||
fontSize = 11.sp,
|
|
||||||
color = directionColor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "位置: ${record.position} km",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.End
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "${record.speed} km/h",
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
|
|
||||||
|
|
||||||
val timeStr = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(record.timestamp)
|
|
||||||
Text(
|
|
||||||
text = timeStr,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TrainRecordsListWithToolbar(
|
|
||||||
records: List<TrainRecord>,
|
|
||||||
onRecordClick: (TrainRecord) -> Unit,
|
|
||||||
onFilterClick: () -> Unit,
|
|
||||||
onClearClick: () -> Unit,
|
|
||||||
onDeleteRecords: (List<TrainRecord>) -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
var selectedRecords by remember { mutableStateOf<MutableSet<TrainRecord>>(mutableSetOf()) }
|
|
||||||
var selectionMode by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Column(modifier = modifier.fillMaxSize()) {
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
tonalElevation = 3.dp,
|
|
||||||
shadowElevation = 3.dp
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = if (selectionMode) "已选择 ${selectedRecords.size} 条" else "历史记录 (${records.size})",
|
|
||||||
style = MaterialTheme.typography.titleMedium
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
if (selectionMode) {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
if (selectedRecords.isNotEmpty()) {
|
|
||||||
onDeleteRecords(selectedRecords.toList())
|
|
||||||
}
|
|
||||||
selectionMode = false
|
|
||||||
selectedRecords = mutableSetOf()
|
|
||||||
},
|
|
||||||
colors = ButtonDefaults.textButtonColors(
|
|
||||||
contentColor = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text("删除")
|
|
||||||
}
|
|
||||||
TextButton(onClick = {
|
|
||||||
selectionMode = false
|
|
||||||
selectedRecords = mutableSetOf()
|
|
||||||
}) {
|
|
||||||
Text("取消")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
IconButton(onClick = onFilterClick) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.FilterList,
|
|
||||||
contentDescription = "筛选"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
contentPadding = PaddingValues(vertical = 4.dp, horizontal = 8.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
|
||||||
items(records.chunked(2)) { rowRecords ->
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 4.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
rowRecords.forEach { record ->
|
|
||||||
val isSelected = selectedRecords.contains(record)
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.clickable {
|
|
||||||
if (selectionMode) {
|
|
||||||
if (isSelected) {
|
|
||||||
selectedRecords.remove(record)
|
|
||||||
} else {
|
|
||||||
selectedRecords.add(record)
|
|
||||||
}
|
|
||||||
if (selectedRecords.isEmpty()) {
|
|
||||||
selectionMode = false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onRecordClick(record)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(vertical = 2.dp),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(8.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
if (selectionMode) {
|
|
||||||
Checkbox(
|
|
||||||
checked = isSelected,
|
|
||||||
onCheckedChange = { checked ->
|
|
||||||
if (checked) {
|
|
||||||
selectedRecords.add(record)
|
|
||||||
} else {
|
|
||||||
selectedRecords.remove(record)
|
|
||||||
}
|
|
||||||
if (selectedRecords.isEmpty()) {
|
|
||||||
selectionMode = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.padding(end = 8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = record.train,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 15.sp,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!selectionMode) {
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
selectionMode = true
|
|
||||||
selectedRecords = mutableSetOf(record)
|
|
||||||
},
|
|
||||||
modifier = Modifier.size(32.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Clear,
|
|
||||||
contentDescription = "删除",
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (record.speed.isNotEmpty() || record.position.isNotEmpty()) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
if (record.speed.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = "${record.speed} km/h",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (record.position.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = "${record.position} km",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val timeStr = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(record.timestamp)
|
|
||||||
Text(
|
|
||||||
text = timeStr,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,49 @@ fun MapScreen(
|
|||||||
|
|
||||||
var railwayLayerVisibleState by remember(railwayLayerVisible) { mutableStateOf(railwayLayerVisible) }
|
var railwayLayerVisibleState by remember(railwayLayerVisible) { mutableStateOf(railwayLayerVisible) }
|
||||||
|
|
||||||
|
fun updateMarkers() {
|
||||||
|
val mapView = mapViewRef.value ?: return
|
||||||
|
|
||||||
|
mapView.overlays.removeAll { it is Marker }
|
||||||
|
|
||||||
|
validRecords.forEach { record ->
|
||||||
|
record.getCoordinates()?.let { point ->
|
||||||
|
val marker = Marker(mapView).apply {
|
||||||
|
position = point
|
||||||
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||||
|
|
||||||
|
val recordMap = record.toMap()
|
||||||
|
title = recordMap["train"]?.toString() ?: "列车"
|
||||||
|
|
||||||
|
val latStr = String.format("%.4f", point.latitude)
|
||||||
|
val lonStr = String.format("%.4f", point.longitude)
|
||||||
|
val coordStr = "${latStr}°N, ${lonStr}°E"
|
||||||
|
snippet = coordStr
|
||||||
|
|
||||||
|
setInfoWindowAnchor(Marker.ANCHOR_CENTER, 0f)
|
||||||
|
|
||||||
|
setOnMarkerClickListener { clickedMarker, _ ->
|
||||||
|
selectedRecord = record
|
||||||
|
dialogPosition = point
|
||||||
|
showDetailDialog = true
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mapView.overlays.add(marker)
|
||||||
|
marker.showInfoWindow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mapView.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(records) {
|
||||||
|
if (isMapInitialized) {
|
||||||
|
updateMarkers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
DisposableEffect(lifecycleOwner) {
|
DisposableEffect(lifecycleOwner) {
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
@@ -136,49 +179,6 @@ fun MapScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun updateMarkers() {
|
|
||||||
val mapView = mapViewRef.value ?: return
|
|
||||||
|
|
||||||
|
|
||||||
mapView.overlays.removeAll { it is Marker }
|
|
||||||
|
|
||||||
|
|
||||||
validRecords.forEach { record ->
|
|
||||||
record.getCoordinates()?.let { point ->
|
|
||||||
val marker = Marker(mapView).apply {
|
|
||||||
position = point
|
|
||||||
|
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
|
||||||
|
|
||||||
val recordMap = record.toMap()
|
|
||||||
title = recordMap["train"]?.toString() ?: "列车"
|
|
||||||
|
|
||||||
val latStr = String.format("%.4f", point.latitude)
|
|
||||||
val lonStr = String.format("%.4f", point.longitude)
|
|
||||||
val coordStr = "${latStr}°N, ${lonStr}°E"
|
|
||||||
snippet = coordStr
|
|
||||||
|
|
||||||
setInfoWindowAnchor(Marker.ANCHOR_CENTER, 0f)
|
|
||||||
|
|
||||||
setOnMarkerClickListener { clickedMarker, _ ->
|
|
||||||
selectedRecord = record
|
|
||||||
dialogPosition = point
|
|
||||||
showDetailDialog = true
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mapView.overlays.add(marker)
|
|
||||||
marker.showInfoWindow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
mapView.invalidate()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fun updateRailwayLayerVisibility(visible: Boolean) {
|
fun updateRailwayLayerVisibility(visible: Boolean) {
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
package org.noxylva.lbjconsole.ui.screens
|
package org.noxylva.lbjconsole.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
||||||
import androidx.compose.ui.unit.TextUnit
|
import androidx.compose.ui.unit.TextUnit
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -28,6 +33,20 @@ fun MonitorScreen(
|
|||||||
) {
|
) {
|
||||||
var showDetailDialog by remember { mutableStateOf(false) }
|
var showDetailDialog by remember { mutableStateOf(false) }
|
||||||
var selectedRecord by remember { mutableStateOf<TrainRecord?>(null) }
|
var selectedRecord by remember { mutableStateOf<TrainRecord?>(null) }
|
||||||
|
var isPressed by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val scale by animateFloatAsState(
|
||||||
|
targetValue = if (isPressed) 0.98f else 1f,
|
||||||
|
animationSpec = tween(durationMillis = 120),
|
||||||
|
label = "content_scale"
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(isPressed) {
|
||||||
|
if (isPressed) {
|
||||||
|
delay(100)
|
||||||
|
isPressed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
val timeSinceLastUpdate = remember { mutableStateOf<String?>(null) }
|
val timeSinceLastUpdate = remember { mutableStateOf<String?>(null) }
|
||||||
@@ -76,20 +95,57 @@ fun MonitorScreen(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
) {
|
) {
|
||||||
if (latestRecord != null) {
|
AnimatedContent(
|
||||||
|
targetState = latestRecord,
|
||||||
|
transitionSpec = {
|
||||||
|
fadeIn(
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 300,
|
||||||
|
easing = FastOutSlowInEasing
|
||||||
|
)
|
||||||
|
) + slideInVertically(
|
||||||
|
initialOffsetY = { it / 4 },
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 300,
|
||||||
|
easing = FastOutSlowInEasing
|
||||||
|
)
|
||||||
|
) togetherWith fadeOut(
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 150,
|
||||||
|
easing = FastOutLinearInEasing
|
||||||
|
)
|
||||||
|
) + slideOutVertically(
|
||||||
|
targetOffsetY = { -it / 4 },
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 150,
|
||||||
|
easing = FastOutLinearInEasing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = "content_animation"
|
||||||
|
) { record ->
|
||||||
|
if (record != null) {
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(8.dp)
|
.clickable(
|
||||||
.clickable {
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
selectedRecord = latestRecord
|
indication = rememberRipple(bounded = true)
|
||||||
|
) {
|
||||||
|
isPressed = true
|
||||||
|
selectedRecord = record
|
||||||
showDetailDialog = true
|
showDetailDialog = true
|
||||||
onRecordClick(latestRecord)
|
onRecordClick(record)
|
||||||
|
}
|
||||||
|
.padding(8.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val recordMap = latestRecord.toMap()
|
val recordMap = record.toMap()
|
||||||
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
@@ -210,6 +266,7 @@ fun MonitorScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,29 @@ package org.noxylva.lbjconsole.ui.screens
|
|||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import org.noxylva.lbjconsole.model.MergeSettings
|
||||||
|
import org.noxylva.lbjconsole.model.GroupBy
|
||||||
|
import org.noxylva.lbjconsole.model.TimeWindow
|
||||||
|
import org.noxylva.lbjconsole.SettingsActivity
|
||||||
|
import org.noxylva.lbjconsole.BackgroundService
|
||||||
|
import org.noxylva.lbjconsole.NotificationService
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -15,44 +32,407 @@ fun SettingsScreen(
|
|||||||
deviceName: String,
|
deviceName: String,
|
||||||
onDeviceNameChange: (String) -> Unit,
|
onDeviceNameChange: (String) -> Unit,
|
||||||
onApplySettings: () -> Unit,
|
onApplySettings: () -> Unit,
|
||||||
appVersion: String = "Unknown"
|
appVersion: String = "Unknown",
|
||||||
|
mergeSettings: MergeSettings,
|
||||||
|
onMergeSettingsChange: (MergeSettings) -> Unit,
|
||||||
|
scrollPosition: Int = 0,
|
||||||
|
onScrollPositionChange: (Int) -> Unit = {},
|
||||||
|
specifiedDeviceAddress: String? = null,
|
||||||
|
searchOrderList: List<String> = emptyList(),
|
||||||
|
onSpecifiedDeviceSelected: (String?) -> Unit = {},
|
||||||
|
autoConnectEnabled: Boolean = true,
|
||||||
|
onAutoConnectEnabledChange: (Boolean) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
LaunchedEffect(scrollPosition) {
|
||||||
|
if (scrollState.value != scrollPosition) {
|
||||||
|
scrollState.scrollTo(scrollPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(scrollState.value) {
|
||||||
|
delay(50)
|
||||||
|
onScrollPositionChange(scrollState.value)
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(16.dp),
|
.verticalScroll(scrollState)
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
.padding(20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
modifier = Modifier.padding(20.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Bluetooth,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"蓝牙设备",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = deviceName,
|
value = deviceName,
|
||||||
onValueChange = onDeviceNameChange,
|
onValueChange = onDeviceNameChange,
|
||||||
label = { Text("蓝牙设备名称") },
|
label = { Text("设备名称") },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.DeviceHub,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Button(
|
if (searchOrderList.isNotEmpty()) {
|
||||||
onClick = onApplySettings,
|
var deviceAddressExpanded by remember { mutableStateOf(false) }
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = deviceAddressExpanded,
|
||||||
|
onExpandedChange = { deviceAddressExpanded = !deviceAddressExpanded }
|
||||||
) {
|
) {
|
||||||
Text("应用设置")
|
OutlinedTextField(
|
||||||
|
value = specifiedDeviceAddress ?: "无",
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("指定设备地址") },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.LocationOn,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
ExposedDropdownMenuDefaults.TrailingIcon(expanded = deviceAddressExpanded)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = deviceAddressExpanded,
|
||||||
|
onDismissRequest = { deviceAddressExpanded = false }
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("无") },
|
||||||
|
onClick = {
|
||||||
|
onSpecifiedDeviceSelected(null)
|
||||||
|
deviceAddressExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
searchOrderList.forEach { address ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(address)
|
||||||
|
if (address == specifiedDeviceAddress) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = "已指定",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onSpecifiedDeviceSelected(address)
|
||||||
|
deviceAddressExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Settings,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"应用设置",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val notificationService = remember(context) { NotificationService(context) }
|
||||||
|
|
||||||
|
var backgroundServiceEnabled by remember(context) {
|
||||||
|
mutableStateOf(SettingsActivity.isBackgroundServiceEnabled(context))
|
||||||
|
}
|
||||||
|
|
||||||
|
var notificationEnabled by remember(context, notificationService) {
|
||||||
|
mutableStateOf(notificationService.isNotificationEnabled())
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"后台保活服务",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"保持应用在后台运行",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = backgroundServiceEnabled,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
backgroundServiceEnabled = enabled
|
||||||
|
SettingsActivity.setBackgroundServiceEnabled(context, enabled)
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
BackgroundService.startService(context)
|
||||||
|
} else {
|
||||||
|
BackgroundService.stopService(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"LBJ消息通知",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"实时接收列车LBJ消息通知",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = notificationEnabled,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
notificationEnabled = enabled
|
||||||
|
notificationService.setNotificationEnabled(enabled)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"自动连接",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"自动连接蓝牙设备",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = autoConnectEnabled,
|
||||||
|
onCheckedChange = onAutoConnectEnabledChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.MergeType,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"记录合并",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"启用记录合并",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = mergeSettings.enabled,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
onMergeSettingsChange(mergeSettings.copy(enabled = enabled))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mergeSettings.enabled) {
|
||||||
|
var groupByExpanded by remember { mutableStateOf(false) }
|
||||||
|
var timeWindowExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = groupByExpanded,
|
||||||
|
onExpandedChange = { groupByExpanded = !groupByExpanded }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = mergeSettings.groupBy.displayName,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("分组方式") },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Group,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
ExposedDropdownMenuDefaults.TrailingIcon(expanded = groupByExpanded)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = groupByExpanded,
|
||||||
|
onDismissRequest = { groupByExpanded = false }
|
||||||
|
) {
|
||||||
|
GroupBy.values().forEach { groupBy ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(groupBy.displayName) },
|
||||||
|
onClick = {
|
||||||
|
onMergeSettingsChange(mergeSettings.copy(groupBy = groupBy))
|
||||||
|
groupByExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = timeWindowExpanded,
|
||||||
|
onExpandedChange = { timeWindowExpanded = !timeWindowExpanded }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = mergeSettings.timeWindow.displayName,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("时间窗口") },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Schedule,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
ExposedDropdownMenuDefaults.TrailingIcon(expanded = timeWindowExpanded)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = timeWindowExpanded,
|
||||||
|
onDismissRequest = { timeWindowExpanded = false }
|
||||||
|
) {
|
||||||
|
TimeWindow.values().forEach { timeWindow ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(timeWindow.displayName) },
|
||||||
|
onClick = {
|
||||||
|
onMergeSettingsChange(mergeSettings.copy(timeWindow = timeWindow))
|
||||||
|
timeWindowExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "LBJ Console v$appVersion 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 {
|
textAlign = TextAlign.Center,
|
||||||
uriHandler.openUri("https://github.com/undef-i")
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.clickable {
|
||||||
|
uriHandler.openUri("https://github.com/undef-i/LBJ_Console")
|
||||||
}
|
}
|
||||||
|
.padding(12.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ private val LightColorScheme = lightColorScheme(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LBJReceiverTheme(
|
fun LBJConsoleTheme(
|
||||||
darkTheme: Boolean = true,
|
darkTheme: Boolean = true,
|
||||||
|
|
||||||
dynamicColor: Boolean = true,
|
dynamicColor: Boolean = true,
|
||||||
|
|||||||
10
app/src/main/res/drawable/ic_notification.xml
Normal file
10
app/src/main/res/drawable/ic_notification.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="@android:color/white">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09C6.04,10.33 6,10.66 6,11v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z"/>
|
||||||
|
</vector>
|
||||||
55
app/src/main/res/layout/activity_settings.xml
Normal file
55
app/src/main/res/layout/activity_settings.xml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:background="?android:attr/selectableItemBackground"
|
||||||
|
android:clickable="true">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Background Service"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textColor="@android:color/black"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Keep app running in background"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="@android:color/darker_gray"
|
||||||
|
android:layout_marginTop="4dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
android:id="@+id/switch_background_service"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="16dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="@android:color/darker_gray"
|
||||||
|
android:layout_marginHorizontal="16dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
99
app/src/main/res/layout/notification_train_record.xml
Normal file
99
app/src/main/res/layout/notification_train_record.xml
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="4dp"
|
||||||
|
android:background="@android:color/transparent">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:layout_marginBottom="4dp"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/notification_train_number"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="G1234"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="?android:attr/textColorPrimary"
|
||||||
|
android:layout_marginEnd="4dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/notification_direction"
|
||||||
|
android:layout_width="16dp"
|
||||||
|
android:layout_height="16dp"
|
||||||
|
android:text="下"
|
||||||
|
android:textSize="10sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:background="@android:color/black"
|
||||||
|
android:gravity="center"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/notification_loco_info"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="CRH380D-1234"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textColor="?android:attr/textColorPrimary" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/notification_route"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="京沪高铁"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="?android:attr/textColorPrimary"
|
||||||
|
android:layout_marginEnd="4dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/notification_position"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="1234K"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="?android:attr/textColorPrimary" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/notification_speed"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="300 km/h"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="?android:attr/textColorPrimary" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
|
|
||||||
<style name="Theme.LBJReceiver" parent="android:Theme.Material.Light.NoActionBar" />
|
<style name="Theme.LBJConsole" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
</resources>
|
</resources>
|
||||||
Reference in New Issue
Block a user