Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1773370d6 | ||
|
|
c8ab5f7ff8 | ||
|
|
e1d02a8a55 | ||
|
|
aaf414d384 | ||
|
|
3edc8632be | ||
|
|
799410eeb2 | ||
|
|
d64138cea5 | ||
|
|
a1a9a479f9 | ||
|
|
9389ef6e6a |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -18,4 +18,5 @@ local.properties
|
||||
.*.bat
|
||||
*.jks
|
||||
*.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.
|
||||
|
||||
## Roadmap
|
||||
- Record filtering (train number, time range)
|
||||
- Record management page optimization
|
||||
- Optional train merge by locomotive/number
|
||||
|
||||
# License
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "org.noxylva.lbjconsole"
|
||||
minSdk = 29
|
||||
targetSdk = 35
|
||||
versionCode = 3
|
||||
versionName = "0.0.3"
|
||||
versionCode = 7
|
||||
versionName = "0.0.7"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -81,7 +81,7 @@ dependencies {
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
implementation("org.json:json:20231013")
|
||||
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-mapsforge:6.1.16")
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<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"/>
|
||||
|
||||
@@ -22,14 +25,14 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.LBJReceiver"
|
||||
android:theme="@style/Theme.LBJConsole"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.LBJReceiver">
|
||||
android:theme="@style/Theme.LBJConsole">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -37,6 +40,19 @@
|
||||
</intent-filter>
|
||||
</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
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
|
||||
@@ -18,9 +18,7 @@ import java.util.*
|
||||
|
||||
class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
companion object {
|
||||
const val TAG = "LBJ_BT"
|
||||
const val SCAN_PERIOD = 10000L
|
||||
|
||||
const val TAG = "LBJ_BT"
|
||||
val SERVICE_UUID = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb")
|
||||
val CHAR_UUID = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
@@ -42,20 +40,69 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
private var targetDeviceName: String? = null
|
||||
private var bluetoothLeScanner: BluetoothLeScanner? = null
|
||||
|
||||
private var continuousScanning = false
|
||||
private var autoReconnect = true
|
||||
private var lastKnownDeviceAddress: String? = null
|
||||
private var connectionAttempts = 0
|
||||
private var isReconnecting = false
|
||||
private 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() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val device = result.device
|
||||
val deviceName = device.name
|
||||
if (targetDeviceName != null) {
|
||||
if (deviceName == null || !deviceName.equals(targetDeviceName, ignoreCase = true)) {
|
||||
return
|
||||
|
||||
val shouldShowDevice = when {
|
||||
targetDeviceName != null -> {
|
||||
deviceName != null && deviceName.equals(targetDeviceName, ignoreCase = true)
|
||||
}
|
||||
else -> {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldShowDevice) {
|
||||
Log.d(TAG, "Showing filtered device: $deviceName")
|
||||
scanCallback?.invoke(device)
|
||||
}
|
||||
|
||||
if (!isConnected && !isReconnecting && !isDialogOpen && !isAutoConnectBlocked) {
|
||||
val deviceAddress = device.address
|
||||
val isSpecifiedDevice = specifiedDeviceAddress == deviceAddress
|
||||
val isTargetDevice = targetDeviceName != null && deviceName != null && deviceName.equals(targetDeviceName, ignoreCase = true)
|
||||
val isKnownDevice = lastKnownDeviceAddress == deviceAddress
|
||||
val isSpecificTargetAddress = targetDeviceAddress == deviceAddress
|
||||
|
||||
if (isSpecificTargetAddress || isSpecifiedDevice || (specifiedDeviceAddress == null && isTargetDevice) || (specifiedDeviceAddress == null && isKnownDevice)) {
|
||||
val priority = when {
|
||||
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)
|
||||
}
|
||||
}
|
||||
scanCallback?.invoke(device)
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
Log.e(TAG, "BLE scan failed code=$errorCode")
|
||||
if (continuousScanning) {
|
||||
handler.post {
|
||||
restartScan()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,12 +154,9 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
return
|
||||
}
|
||||
|
||||
handler.postDelayed({
|
||||
stopScan()
|
||||
}, SCAN_PERIOD)
|
||||
|
||||
isScanning = true
|
||||
Log.d(TAG, "Starting BLE scan target=${targetDeviceName ?: "Any"}")
|
||||
continuousScanning = true
|
||||
Log.d(TAG, "Starting continuous BLE scan target=${targetDeviceName ?: "Any"}")
|
||||
bluetoothLeScanner?.startScan(leScanCallback)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Scan security error: ${e.message}")
|
||||
@@ -127,6 +171,40 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
if (isScanning) {
|
||||
bluetoothLeScanner?.stopScan(leScanCallback)
|
||||
isScanning = false
|
||||
continuousScanning = false
|
||||
Log.d(TAG, "Stopped BLE scan")
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun restartScan() {
|
||||
if (!continuousScanning) return
|
||||
|
||||
try {
|
||||
bluetoothLeScanner?.stopScan(leScanCallback)
|
||||
bluetoothLeScanner?.startScan(leScanCallback)
|
||||
isScanning = true
|
||||
Log.d(TAG, "Restarted BLE scan")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to restart scan: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun connectImmediately(address: String) {
|
||||
if (isReconnecting) return
|
||||
isReconnecting = true
|
||||
|
||||
handler.post {
|
||||
connect(address) { connected ->
|
||||
isReconnecting = false
|
||||
if (connected) {
|
||||
connectionAttempts = 0
|
||||
Log.i(TAG, "Successfully connected to $address")
|
||||
} else {
|
||||
connectionAttempts++
|
||||
Log.w(TAG, "Connection attempt $connectionAttempts failed for $address")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,17 +261,7 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
bluetoothGatt = device.connectGatt(context, false, this, BluetoothDevice.TRANSPORT_LE)
|
||||
|
||||
Log.d(TAG, "Connecting to address=$address")
|
||||
|
||||
|
||||
handler.postDelayed({
|
||||
if (!isConnected && deviceAddress == address) {
|
||||
Log.e(TAG, "Connection timeout reconnecting")
|
||||
|
||||
bluetoothGatt?.close()
|
||||
bluetoothGatt =
|
||||
device.connectGatt(context, false, this, BluetoothDevice.TRANSPORT_LE)
|
||||
}
|
||||
}, 10000)
|
||||
|
||||
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
@@ -211,7 +279,66 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
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")
|
||||
}
|
||||
|
||||
|
||||
@@ -286,30 +413,31 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.e(TAG, "Connection error status=$status")
|
||||
isConnected = false
|
||||
|
||||
isReconnecting = false
|
||||
|
||||
if (status == 133 || status == 8) {
|
||||
Log.e(TAG, "GATT error closing connection")
|
||||
Log.e(TAG, "GATT error, attempting immediate reconnection")
|
||||
try {
|
||||
gatt.close()
|
||||
bluetoothGatt = null
|
||||
|
||||
bluetoothLeScanner = null
|
||||
|
||||
deviceAddress?.let { address ->
|
||||
handler.postDelayed({
|
||||
Log.d(TAG, "Reconnecting to device")
|
||||
val device =
|
||||
BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address)
|
||||
bluetoothGatt = device.connectGatt(
|
||||
context,
|
||||
false,
|
||||
this,
|
||||
BluetoothDevice.TRANSPORT_LE
|
||||
)
|
||||
}, 2000)
|
||||
if (autoReconnect) {
|
||||
Log.d(TAG, "Immediate reconnection to device")
|
||||
handler.post {
|
||||
val device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address)
|
||||
bluetoothGatt = device.connectGatt(
|
||||
context,
|
||||
false,
|
||||
this,
|
||||
BluetoothDevice.TRANSPORT_LE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Reconnect error: ${e.message}")
|
||||
Log.e(TAG, "Immediate reconnect error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,32 +448,42 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
when (newState) {
|
||||
BluetoothProfile.STATE_CONNECTED -> {
|
||||
isConnected = true
|
||||
isReconnecting = false
|
||||
isManualDisconnect = false
|
||||
connectionAttempts = 0
|
||||
Log.i(TAG, "Connected to GATT server")
|
||||
|
||||
handler.post { connectionStateCallback?.invoke(true) }
|
||||
|
||||
deviceAddress?.let { address ->
|
||||
handler.post { connectionSuccessCallback?.invoke(address) }
|
||||
}
|
||||
|
||||
|
||||
handler.postDelayed({
|
||||
handler.post {
|
||||
try {
|
||||
gatt.discoverServices()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Service discovery failed: ${e.message}")
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
isConnected = false
|
||||
Log.i(TAG, "Disconnected from GATT server")
|
||||
isReconnecting = false
|
||||
Log.i(TAG, "Disconnected from GATT server, manual=$isManualDisconnect")
|
||||
|
||||
handler.post { connectionStateCallback?.invoke(false) }
|
||||
handler.post {
|
||||
connectionStateCallback?.invoke(false)
|
||||
if (!isManualDisconnect) {
|
||||
connectionLostCallback?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!deviceAddress.isNullOrBlank()) {
|
||||
handler.postDelayed({
|
||||
Log.d(TAG, "Reconnecting after disconnect")
|
||||
connect(deviceAddress!!, connectionStateCallback)
|
||||
}, 3000)
|
||||
if (!deviceAddress.isNullOrBlank() && autoReconnect && highFrequencyReconnect && !isManualDisconnect) {
|
||||
startHighFrequencyReconnect(deviceAddress!!)
|
||||
} else if (isManualDisconnect) {
|
||||
Log.d(TAG, "Manual disconnect - no auto reconnect")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -357,12 +495,14 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
private var lastDataTime = 0L
|
||||
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onCharacteristicChanged(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic
|
||||
) {
|
||||
super.onCharacteristicChanged(gatt, characteristic)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val newData = characteristic.value?.let {
|
||||
String(it, StandardCharsets.UTF_8)
|
||||
} ?: return
|
||||
@@ -381,18 +521,17 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
val bufferContent = dataBuffer.toString()
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
|
||||
if (lastDataTime > 0 && currentTime - lastDataTime > 5000) {
|
||||
Log.w(TAG, "Data timeout ${(currentTime - lastDataTime) / 1000}s")
|
||||
|
||||
if (lastDataTime > 0) {
|
||||
val timeDiff = currentTime - lastDataTime
|
||||
if (timeDiff > 10000) {
|
||||
Log.w(TAG, "Long data gap: ${timeDiff / 1000}s")
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "Buffer size=${dataBuffer.length} bytes")
|
||||
|
||||
|
||||
tryExtractJson(bufferContent)
|
||||
|
||||
|
||||
lastDataTime = currentTime
|
||||
}
|
||||
|
||||
@@ -511,9 +650,16 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
)
|
||||
if (descriptor != null) {
|
||||
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
val writeResult = gatt.writeDescriptor(descriptor)
|
||||
Log.d(TAG, "Descriptor write result=$writeResult")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val writeResult = gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
|
||||
Log.d(TAG, "Descriptor write result=$writeResult")
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
@Suppress("DEPRECATION")
|
||||
val writeResult = gatt.writeDescriptor(descriptor)
|
||||
Log.d(TAG, "Descriptor write result=$writeResult")
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Descriptor not found")
|
||||
|
||||
@@ -540,10 +686,134 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
|
||||
|
||||
|
||||
private fun requestDataAfterDelay() {
|
||||
handler.postDelayed({
|
||||
handler.post {
|
||||
statusCallback?.let { callback ->
|
||||
getStatus(callback)
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoReconnect(enabled: Boolean) {
|
||||
autoReconnect = enabled
|
||||
Log.d(TAG, "Auto reconnect set to: $enabled")
|
||||
}
|
||||
|
||||
fun 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 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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
162
app/src/main/java/org/noxylva/lbjconsole/NotificationService.kt
Normal file
162
app/src/main/java/org/noxylva/lbjconsole/NotificationService.kt
Normal file
@@ -0,0 +1,162 @@
|
||||
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 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 directionText = when (trainRecord.direction) {
|
||||
1 -> "下行"
|
||||
3 -> "上行"
|
||||
else -> "未知"
|
||||
}
|
||||
|
||||
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 "列车"
|
||||
|
||||
val title = trainDisplay
|
||||
val content = buildString {
|
||||
append(directionText)
|
||||
if (isValidValue(trainRecord.route)) {
|
||||
append("\n线路: ${trainRecord.route.trim()}")
|
||||
}
|
||||
if (isValidValue(trainRecord.speed)) {
|
||||
append("\n速度: ${trainRecord.speed.trim()} km/h")
|
||||
}
|
||||
if (isValidValue(trainRecord.position)) {
|
||||
append("\n位置: ${trainRecord.position.trim()} km")
|
||||
}
|
||||
}
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(content)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(content))
|
||||
.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, "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,43 @@
|
||||
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_AND_LOCO("车次号+机车号"),
|
||||
TRAIN_ONLY("仅车次号"),
|
||||
LOCO_ONLY("仅机车号")
|
||||
}
|
||||
|
||||
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_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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
companion object {
|
||||
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 receivedTimestamp: Date = Date()
|
||||
var train: String = ""
|
||||
@@ -29,10 +36,15 @@ class TrainRecord(jsonData: JSONObject? = null) {
|
||||
private var _coordinates: GeoPoint? = null
|
||||
|
||||
init {
|
||||
uniqueId = if (jsonData?.has("uniqueId") == true) {
|
||||
jsonData.getString("uniqueId")
|
||||
} else {
|
||||
generateUniqueId()
|
||||
}
|
||||
|
||||
jsonData?.let {
|
||||
try {
|
||||
if (jsonData.has("timestamp")) {
|
||||
|
||||
timestamp = Date(jsonData.getLong("timestamp"))
|
||||
}
|
||||
|
||||
@@ -166,6 +178,7 @@ class TrainRecord(jsonData: JSONObject? = null) {
|
||||
|
||||
fun toJSON(): JSONObject {
|
||||
val json = JSONObject()
|
||||
json.put("uniqueId", uniqueId)
|
||||
json.put("timestamp", timestamp.time)
|
||||
json.put("receivedTimestamp", receivedTimestamp.time)
|
||||
json.put("train", train)
|
||||
@@ -181,4 +194,14 @@ class TrainRecord(jsonData: JSONObject? = null) {
|
||||
json.put("rssi", rssi)
|
||||
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.os.Environment
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.*
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
@@ -19,15 +20,23 @@ class TrainRecordManager(private val context: Context) {
|
||||
const val MAX_RECORDS = 1000
|
||||
private const val PREFS_NAME = "train_records"
|
||||
private const val KEY_RECORDS = "records"
|
||||
private const val KEY_MERGE_SETTINGS = "merge_settings"
|
||||
}
|
||||
|
||||
|
||||
private val trainRecords = CopyOnWriteArrayList<TrainRecord>()
|
||||
private val recordCount = AtomicInteger(0)
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
var mergeSettings = MergeSettings()
|
||||
private set
|
||||
|
||||
init {
|
||||
loadRecords()
|
||||
ioScope.launch {
|
||||
loadRecords()
|
||||
loadMergeSettings()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -140,15 +149,17 @@ class TrainRecordManager(private val context: Context) {
|
||||
}
|
||||
|
||||
private fun saveRecords() {
|
||||
try {
|
||||
val jsonArray = JSONArray()
|
||||
for (record in trainRecords) {
|
||||
jsonArray.put(record.toJSON())
|
||||
ioScope.launch {
|
||||
try {
|
||||
val jsonArray = JSONArray()
|
||||
for (record in trainRecords) {
|
||||
jsonArray.put(record.toJSON())
|
||||
}
|
||||
prefs.edit().putString(KEY_RECORDS, jsonArray.toString()).apply()
|
||||
Log.d(TAG, "Saved ${trainRecords.size} records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save records: ${e.message}")
|
||||
}
|
||||
prefs.edit().putString(KEY_RECORDS, jsonArray.toString()).apply()
|
||||
Log.d(TAG, "Saved ${trainRecords.size} records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save records: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,4 +188,112 @@ class TrainRecordManager(private val context: Context) {
|
||||
fun getRecordCount(): Int {
|
||||
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 groupedRecords = mutableMapOf<String, MutableList<TrainRecord>>()
|
||||
val currentTime = Date()
|
||||
|
||||
records.forEach { record ->
|
||||
val groupKey = generateGroupKey(record, settings.groupBy)
|
||||
if (groupKey != null) {
|
||||
val withinTimeWindow = settings.timeWindow.seconds?.let { windowSeconds ->
|
||||
(currentTime.time - record.timestamp.time) / 1000 <= windowSeconds
|
||||
} ?: true
|
||||
|
||||
if (withinTimeWindow) {
|
||||
groupedRecords.getOrPut(groupKey) { mutableListOf() }.add(record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 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 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) }
|
||||
|
||||
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) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
@@ -135,50 +178,7 @@ 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) {
|
||||
@@ -306,7 +306,7 @@ fun MapScreen(
|
||||
|
||||
val locationProvider = GpsMyLocationProvider(ctx).apply {
|
||||
locationUpdateMinDistance = 10f
|
||||
locationUpdateMinTime = 1000
|
||||
locationUpdateMinTime = 5000
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
package org.noxylva.lbjconsole.ui.screens
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.clickable
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -28,6 +33,20 @@ fun MonitorScreen(
|
||||
) {
|
||||
var showDetailDialog by remember { mutableStateOf(false) }
|
||||
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) }
|
||||
@@ -41,7 +60,8 @@ fun MonitorScreen(
|
||||
diffInSec < 3600 -> "${diffInSec / 60}分钟前"
|
||||
else -> "${diffInSec / 3600}小时前"
|
||||
}
|
||||
delay(1000)
|
||||
val updateInterval = if (diffInSec < 60) 500L else if (diffInSec < 3600) 30000L else 300000L
|
||||
delay(updateInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,20 +95,57 @@ fun MonitorScreen(
|
||||
.fillMaxWidth()
|
||||
.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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
.clickable {
|
||||
selectedRecord = latestRecord
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = rememberRipple(bounded = true)
|
||||
) {
|
||||
isPressed = true
|
||||
selectedRecord = record
|
||||
showDetailDialog = true
|
||||
onRecordClick(latestRecord)
|
||||
onRecordClick(record)
|
||||
}
|
||||
.padding(8.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
) {
|
||||
|
||||
val recordMap = latestRecord.toMap()
|
||||
val recordMap = record.toMap()
|
||||
|
||||
|
||||
Row(
|
||||
@@ -208,6 +265,7 @@ fun MonitorScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -247,4 +305,4 @@ private fun InfoItem(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,28 @@ package org.noxylva.lbjconsole.ui.screens
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
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)
|
||||
@Composable
|
||||
@@ -15,44 +31,382 @@ fun SettingsScreen(
|
||||
deviceName: String,
|
||||
onDeviceNameChange: (String) -> 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 = {}
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(scrollPosition) {
|
||||
scrollState.scrollTo(scrollPosition)
|
||||
}
|
||||
|
||||
LaunchedEffect(scrollState.value) {
|
||||
onScrollPositionChange(scrollState.value)
|
||||
}
|
||||
|
||||
LaunchedEffect(deviceName) {
|
||||
onApplySettings()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
.verticalScroll(scrollState)
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = deviceName,
|
||||
onValueChange = onDeviceNameChange,
|
||||
label = { Text("蓝牙设备名称") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onApplySettings,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text("应用设置")
|
||||
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(
|
||||
value = deviceName,
|
||||
onValueChange = onDeviceNameChange,
|
||||
label = { Text("设备名称") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.DeviceHub,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
|
||||
if (searchOrderList.isNotEmpty()) {
|
||||
var deviceAddressExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = deviceAddressExpanded,
|
||||
onExpandedChange = { deviceAddressExpanded = !deviceAddressExpanded }
|
||||
) {
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Text(
|
||||
text = "LBJ Console v$appVersion by undef-i",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.clickable {
|
||||
uriHandler.openUri("https://github.com/undef-i")
|
||||
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
|
||||
var backgroundServiceEnabled by remember {
|
||||
mutableStateOf(SettingsActivity.isBackgroundServiceEnabled(context))
|
||||
}
|
||||
|
||||
val notificationService = remember { NotificationService(context) }
|
||||
var notificationEnabled by remember {
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Text(
|
||||
text = "LBJ Console v$appVersion by undef-i",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
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
|
||||
fun LBJReceiverTheme(
|
||||
fun LBJConsoleTheme(
|
||||
darkTheme: 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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.LBJReceiver" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
<style name="Theme.LBJConsole" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
Reference in New Issue
Block a user