10 Commits

Author SHA1 Message Date
Nedifinita
39bb8cb440 fix: optimize the logic for saving scroll position 2025-08-01 17:35:34 +08:00
Nedifinita
be8dc6bc72 feat: add custom train information notification layout 2025-07-26 17:31:09 +08:00
Nedifinita
cd3128c24b feat: add option for automatically connecting to Bluetooth devices 2025-07-26 17:08:08 +08:00
Nedifinita
e1773370d6 fix: simplify device name matching logic 2025-07-26 01:00:24 +08:00
Nedifinita
c8ab5f7ff8 feat: add LBJ message notification 2025-07-26 00:40:45 +08:00
Nedifinita
e1d02a8a55 feat: add background keep-alive service and related setting functions 2025-07-26 00:19:56 +08:00
Nedifinita
aaf414d384 refactor: optimize record management and UI interaction logic
- Move the loading and saving operations of TrainRecordManager to the IO goroutine for execution
- Optimize the data structure of recentRecords in MainActivity to be a mutableStateList
- Improve the interaction effect and device connection status display of ConnectionDialog
- Delete the MergedHistoryScreen file that is no longer in use
- Increase the number of threads for map tile downloads and file system operations
2025-07-25 23:40:14 +08:00
Nedifinita
3edc8632be feat: add animation effects and visual feedback 2025-07-22 23:18:50 +08:00
Nedifinita
799410eeb2 feat: add BLE disconnection cleanup and enhance record management 2025-07-22 17:29:15 +08:00
Nedifinita
d64138cea5 feat: add record merging functionality and optimize settings page 2025-07-19 21:07:11 +08:00
24 changed files with 3133 additions and 1103 deletions

1
.gitignore vendored
View File

@@ -19,3 +19,4 @@ local.properties
*.jks
*.keystore
*.base64
docs

View File

@@ -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

View File

@@ -12,8 +12,8 @@ android {
applicationId = "org.noxylva.lbjconsole"
minSdk = 29
targetSdk = 35
versionCode = 4
versionName = "0.0.4"
versionCode = 8
versionName = "0.0.8"
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")

View File

@@ -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"

View File

@@ -45,6 +45,16 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
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) {
@@ -56,24 +66,7 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
deviceName != null && deviceName.equals(targetDeviceName, ignoreCase = true)
}
else -> {
deviceName != null && (
deviceName.contains("LBJ", ignoreCase = true) ||
deviceName.contains("Receiver", ignoreCase = true) ||
deviceName.contains("Train", ignoreCase = true) ||
deviceName.contains("Console", ignoreCase = true) ||
deviceName.contains("ESP", ignoreCase = true) ||
deviceName.contains("Arduino", ignoreCase = true) ||
deviceName.contains("BLE", ignoreCase = true) ||
deviceName.contains("UART", ignoreCase = true) ||
deviceName.contains("Serial", ignoreCase = true)
) && !(
deviceName.contains("Midea", ignoreCase = true) ||
deviceName.contains("TV", ignoreCase = true) ||
deviceName.contains("Phone", ignoreCase = true) ||
deviceName.contains("Watch", ignoreCase = true) ||
deviceName.contains("Headset", ignoreCase = true) ||
deviceName.contains("Speaker", ignoreCase = true)
)
true
}
}
@@ -82,17 +75,24 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
scanCallback?.invoke(device)
}
if (targetDeviceName != null && !isConnected && !isReconnecting) {
if (deviceName != null && deviceName.equals(targetDeviceName, ignoreCase = true)) {
Log.i(TAG, "Found target device: $deviceName, auto-connecting")
lastKnownDeviceAddress = device.address
connectImmediately(device.address)
}
}
if (!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 (lastKnownDeviceAddress == device.address && !isConnected && !isReconnecting) {
Log.i(TAG, "Found known device, reconnecting immediately")
connectImmediately(device.address)
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)
}
}
}
@@ -276,10 +276,96 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
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")
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 {
gatt.close()
bluetoothGatt = null
bluetoothLeScanner = null
deviceAddress?.let { address ->
if (autoReconnect) {
@@ -389,11 +476,16 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
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.post {
try {
gatt.discoverServices()
@@ -406,17 +498,20 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
BluetoothProfile.STATE_DISCONNECTED -> {
isConnected = 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 {
Log.d(TAG, "Immediate reconnection after disconnect")
connect(deviceAddress!!, connectionStateCallback)
connectionStateCallback?.invoke(false)
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")
}
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")
}
}

View 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
}
}

View File

@@ -20,32 +20,50 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.ui.graphics.toArgb
import androidx.core.view.WindowCompat
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.filled.*
import androidx.compose.material.icons.filled.LocationOn
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.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
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.FileProvider
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import org.json.JSONObject
import org.osmdroid.config.Configuration
import org.noxylva.lbjconsole.model.TrainRecord
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.MapScreen
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 java.util.*
import androidx.lifecycle.lifecycleScope
@@ -57,10 +75,11 @@ class MainActivity : ComponentActivity() {
private val bleClient by lazy { BLEClient(this) }
private val trainRecordManager by lazy { TrainRecordManager(this) }
private val locoInfoUtil by lazy { LocoInfoUtil(this) }
private val notificationService by lazy { NotificationService(this) }
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 foundDevices by mutableStateOf(listOf<BluetoothDevice>())
private var scanResults = mutableListOf<ScanResult>()
@@ -68,7 +87,7 @@ class MainActivity : ComponentActivity() {
private var showConnectionDialog by mutableStateOf(false)
private var lastUpdateTime by mutableStateOf<Date?>(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("")
@@ -89,8 +108,17 @@ class MainActivity : ComponentActivity() {
private var mapZoomLevel by mutableStateOf(10.0)
private var mapRailwayLayerVisible by mutableStateOf(true)
private var settingsScrollPosition by mutableStateOf(0)
private var mergeSettings by mutableStateOf(MergeSettings())
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) }
@@ -178,6 +206,10 @@ class MainActivity : ComponentActivity() {
Manifest.permission.ACCESS_COARSE_LOCATION
))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
}
requestPermissions.launch(permissions.toTypedArray())
@@ -185,6 +217,27 @@ class MainActivity : ComponentActivity() {
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 {
try {
@@ -208,10 +261,10 @@ class MainActivity : ComponentActivity() {
osmdroidBasePath = osmCacheDir
osmdroidTileCache = tileCache
expirationOverrideDuration = 86400000L * 7
tileDownloadThreads = 2
tileFileSystemThreads = 2
tileDownloadThreads = 4
tileFileSystemThreads = 4
setUserAgentValue("LBJReceiver/1.0")
setUserAgentValue("LBJConsole/1.0")
}
Log.d(TAG, "OSM cache configured")
@@ -221,13 +274,17 @@ class MainActivity : ComponentActivity() {
saveSettings()
if (SettingsActivity.isBackgroundServiceEnabled(this)) {
BackgroundService.startService(this)
}
enableEdgeToEdge()
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = false
}
setContent {
LBJReceiverTheme {
LBJConsoleTheme {
val scope = rememberCoroutineScope()
Surface(modifier = Modifier.fillMaxSize()) {
@@ -240,7 +297,30 @@ class MainActivity : ComponentActivity() {
currentTab = tab
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,
@@ -251,17 +331,25 @@ class MainActivity : ComponentActivity() {
Log.d(TAG, "Record clicked train=${record.train}")
},
onClearMonitorLog = {
recentRecords = emptyList()
recentRecords.clear()
temporaryStatusMessage = null
},
allRecords = if (trainRecordManager.getFilteredRecords().isNotEmpty())
trainRecordManager.getFilteredRecords() else trainRecordManager.getAllRecords(),
allRecords = trainRecordManager.getMixedRecords(),
mergedRecords = trainRecordManager.getMergedRecords(),
recordCount = trainRecordManager.getRecordCount(),
filterTrain = filterTrain,
filterRoute = filterRoute,
filterDirection = filterDirection,
mergeSettings = mergeSettings,
onMergeSettingsChange = { newSettings ->
mergeSettings = newSettings
trainRecordManager.updateMergeSettings(newSettings)
historyEditMode = false
historySelectedRecords = emptySet()
saveSettings()
},
historyEditMode = historyEditMode,
@@ -279,6 +367,13 @@ class MainActivity : ComponentActivity() {
},
settingsScrollPosition = settingsScrollPosition,
onSettingsScrollPositionChange = { position ->
settingsScrollPosition = position
saveSettings()
},
mapCenterPosition = mapCenterPosition,
mapZoomLevel = mapZoomLevel,
mapRailwayLayerVisible = mapRailwayLayerVisible,
@@ -303,7 +398,7 @@ class MainActivity : ComponentActivity() {
onClearRecords = {
scope.launch {
trainRecordManager.clearRecords()
recentRecords = emptyList()
recentRecords.clear()
latestRecord = null
temporaryStatusMessage = null
}
@@ -313,12 +408,6 @@ class MainActivity : ComponentActivity() {
scope.launch {
val deletedCount = trainRecordManager.deleteRecords(records)
if (deletedCount > 0) {
Toast.makeText(
this@MainActivity,
"已删除 $deletedCount 条记录",
Toast.LENGTH_SHORT
).show()
if (records.contains(latestRecord)) {
latestRecord = null
}
@@ -330,22 +419,39 @@ class MainActivity : ComponentActivity() {
deviceName = settingsDeviceName,
onDeviceNameChange = { newName -> settingsDeviceName = newName },
onApplySettings = {
saveSettings()
if (targetDeviceName != settingsDeviceName) {
targetDeviceName = settingsDeviceName
Toast.makeText(this, "设备名称 '${settingsDeviceName}' 已保存,下次连接时生效", Toast.LENGTH_LONG).show()
Log.d(TAG, "Applied settings deviceName=${settingsDeviceName}")
saveSettings()
}
},
appVersion = getAppVersion(),
locoInfoUtil = locoInfoUtil
locoInfoUtil = locoInfoUtil,
onOpenSettings = {
val intent = Intent(this@MainActivity, SettingsActivity::class.java)
startActivity(intent)
}
)
if (showConnectionDialog) {
LaunchedEffect(showConnectionDialog) {
bleClient.setDialogOpen(true)
if (!bleClient.isConnected() && !isScanning) {
foundDevices = emptyList()
startScan()
}
}
ConnectionDialog(
isScanning = isScanning,
devices = foundDevices,
onDismiss = {
showConnectionDialog = false
stopScan()
bleClient.resetManualDisconnectState()
if (!bleClient.isConnected()) {
startAutoScanAndConnect()
}
},
onScan = {
if (isScanning) {
@@ -356,17 +462,36 @@ class MainActivity : ComponentActivity() {
},
onConnect = { device ->
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) {
bleClient.setAutoConnectBlocked(false)
deviceStatus = "正在连接..."
Log.d(TAG, "Connecting to device name=${device.name ?: "Unknown"} address=${device.address}")
@@ -383,9 +508,17 @@ class MainActivity : ComponentActivity() {
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, "Connected to device name=${device.name ?: "Unknown"}")
} else {
deviceStatus = "连接失败,正在重试..."
showDisconnectButton = false
Log.e(TAG, "Connection failed, auto-retry enabled for name=${device.name ?: "Unknown"}")
}
}
@@ -394,6 +527,43 @@ class MainActivity : ComponentActivity() {
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) {
Log.d(TAG, "Received train data=${jsonData.toString().take(50)}...")
@@ -413,13 +583,17 @@ class MainActivity : ComponentActivity() {
val record = trainRecordManager.addRecord(jsonData)
Log.d(TAG, "Added record train=${record.train} direction=${record.direction}")
if (notificationService.isNotificationEnabled()) {
notificationService.showTrainNotification(record)
}
latestRecord = record
val newList = mutableListOf<TrainRecord>()
newList.add(record)
newList.addAll(recentRecords.filterNot { it.train == record.train && it.time == record.time })
recentRecords = newList.take(10)
recentRecords.removeAll { it.train == record.train && it.time == record.time }
recentRecords.add(0, record)
if (recentRecords.size > 10) {
recentRecords.removeRange(10, recentRecords.size)
}
Log.d(TAG, "Updated UI train=${record.train}")
forceUiRefresh()
@@ -451,6 +625,11 @@ class MainActivity : ComponentActivity() {
private fun startAutoScanAndConnect() {
if (!autoConnectEnabled) {
Log.d(TAG, "Auto connect disabled, skipping auto scan")
return
}
Log.d(TAG, "Starting auto scan and connect")
if (!hasBluetoothPermissions()) {
@@ -475,10 +654,10 @@ class MainActivity : ComponentActivity() {
bleClient.setAutoReconnect(true)
val targetDeviceName = if (settingsDeviceName.isNotBlank() && settingsDeviceName != "LBJReceiver") {
val targetDeviceName = if (settingsDeviceName.isNotBlank()) {
settingsDeviceName
} else {
"LBJReceiver"
null
}
Log.d(TAG, "Auto scanning for target device: $targetDeviceName")
@@ -515,20 +694,23 @@ class MainActivity : ComponentActivity() {
isScanning = true
foundDevices = emptyList()
val targetDeviceName = if (settingsDeviceName.isNotBlank() && settingsDeviceName != "LBJReceiver") {
val targetDeviceName = if (settingsDeviceName.isNotBlank()) {
settingsDeviceName
} else {
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 ->
runOnUiThread {
if (!foundDevices.any { it.address == device.address }) {
Log.d(TAG, "Found device name=${device.name ?: "Unknown"} address=${device.address}")
foundDevices = foundDevices + device
}
}
}
}
private fun stopScan() {
@@ -585,6 +767,7 @@ class MainActivity : ComponentActivity() {
historyScrollPosition = settingsPrefs.getInt("history_scroll_position", 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 centerLon = settingsPrefs.getFloat("map_center_lon", Float.NaN)
@@ -595,11 +778,27 @@ class MainActivity : ComponentActivity() {
mapZoomLevel = settingsPrefs.getFloat("map_zoom_level", 10.0f).toDouble()
mapRailwayLayerVisible = settingsPrefs.getBoolean("map_railway_visible", true)
Log.d(TAG, "Loaded settings deviceName=${settingsDeviceName} tab=${currentTab}")
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() {
lifecycleScope.launch(Dispatchers.IO) {
val editor = settingsPrefs.edit()
.putString("device_name", settingsDeviceName)
.putInt("current_tab", currentTab)
@@ -608,8 +807,12 @@ class MainActivity : ComponentActivity() {
.putString("history_expanded_states", historyExpandedStates.map { "${it.key}:${it.value}" }.joinToString(";"))
.putInt("history_scroll_position", historyScrollPosition)
.putInt("history_scroll_offset", historyScrollOffset)
.putInt("settings_scroll_position", settingsScrollPosition)
.putFloat("map_zoom_level", mapZoomLevel.toFloat())
.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) ->
editor.putFloat("map_center_lat", lat.toFloat())
@@ -619,20 +822,41 @@ class MainActivity : ComponentActivity() {
editor.apply()
Log.d(TAG, "Saved settings deviceName=${settingsDeviceName} tab=${currentTab} mapCenter=${mapCenterPosition} zoom=${mapZoomLevel}")
}
}
override fun onResume() {
super.onResume()
Log.d(TAG, "App resumed")
if (hasBluetoothPermissions() && !bleClient.isConnected()) {
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")
startAutoScanAndConnect()
} else {
deviceStatus = "未连接"
showDisconnectButton = false
}
}
}
override fun onPause() {
super.onPause()
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")
}
}
@@ -646,6 +870,13 @@ fun MainContent(
currentTab: Int,
onTabChange: (Int) -> Unit,
onConnectClick: () -> Unit,
onDisconnectClick: () -> Unit,
showDisconnectButton: Boolean,
specifiedDeviceAddress: String?,
searchOrderList: List<String>,
onSpecifiedDeviceSelected: (String?) -> Unit,
autoConnectEnabled: Boolean,
onAutoConnectEnabledChange: (Boolean) -> Unit,
latestRecord: TrainRecord?,
@@ -656,11 +887,14 @@ fun MainContent(
onClearMonitorLog: () -> Unit,
allRecords: List<TrainRecord>,
allRecords: List<Any>,
mergedRecords: List<org.noxylva.lbjconsole.model.MergedTrainRecord>,
recordCount: Int,
filterTrain: String,
filterRoute: String,
filterDirection: String,
mergeSettings: MergeSettings,
onMergeSettingsChange: (MergeSettings) -> Unit,
onFilterChange: (String, String, String) -> Unit,
onClearFilter: () -> Unit,
onClearRecords: () -> Unit,
@@ -685,10 +919,16 @@ fun MainContent(
onHistoryStateChange: (Boolean, Set<String>, Map<String, Boolean>, Int, Int) -> Unit,
settingsScrollPosition: Int,
onSettingsScrollPositionChange: (Int) -> Unit,
mapCenterPosition: Pair<Double, Double>?,
mapZoomLevel: Double,
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)
@@ -750,8 +990,33 @@ fun MainContent(
if (historyEditMode && currentTab == 0) {
TopAppBar(
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(
"已选择 ${historySelectedRecords.size} 条记录",
"已选择 $totalSelectedCount 条记录",
color = MaterialTheme.colorScheme.onPrimary
)
},
@@ -770,10 +1035,39 @@ fun MainContent(
IconButton(
onClick = {
if (historySelectedRecords.isNotEmpty()) {
val recordsToDelete = allRecords.filter {
historySelectedRecords.contains(it.timestamp.time.toString())
val recordsToDelete = mutableSetOf<TrainRecord>()
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)
}
}
@@ -823,12 +1117,14 @@ fun MainContent(
.padding(paddingValues)
) {
when (currentTab) {
0 -> HistoryScreen(
0 -> {
HistoryScreen(
records = allRecords,
latestRecord = latestRecord,
lastUpdateTime = lastUpdateTime,
temporaryStatusMessage = temporaryStatusMessage,
locoInfoUtil = locoInfoUtil,
mergeSettings = mergeSettings,
onClearRecords = onClearRecords,
onRecordClick = onRecordClick,
onClearLog = onClearMonitorLog,
@@ -840,14 +1136,37 @@ fun MainContent(
scrollOffset = historyScrollOffset,
onStateChange = onHistoryStateChange
)
}
2 -> SettingsScreen(
deviceName = deviceName,
onDeviceNameChange = onDeviceNameChange,
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(
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,
zoomLevel = mapZoomLevel,
railwayLayerVisible = mapRailwayLayerVisible,
@@ -864,59 +1183,232 @@ fun ConnectionDialog(
devices: List<BluetoothDevice>,
onDismiss: () -> Unit,
onScan: () -> Unit,
onConnect: (BluetoothDevice) -> Unit
onConnect: (BluetoothDevice) -> Unit,
onDisconnect: () -> Unit = {},
isConnected: Boolean = false,
targetDeviceName: String = "LBJReceiver",
deviceAddress: String? = null
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("连接设备") },
title = {
Text(
text = "蓝牙设备",
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Button(
onClick = onScan,
Column(
modifier = Modifier.fillMaxWidth()
) {
Text(if (isScanning) "停止扫描" else "扫描设备")
}
Spacer(modifier = Modifier.height(8.dp))
if (!isConnected) {
Button(
onClick = onScan,
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) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
Spacer(modifier = Modifier.height(8.dp))
CircularProgressIndicator(
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(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { onConnect(device) }
.graphicsLayer {
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(
modifier = Modifier.padding(8.dp)
modifier = Modifier.weight(1f)
) {
Text(
text = device.name ?: "未知设备",
fontWeight = FontWeight.Bold
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
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 = {
TextButton(onClick = onDismiss) {
Text("取消")
Text("关闭")
}
}
)

View 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()
}
}
}

View 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
}
}

View File

@@ -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
}
}
}

View File

@@ -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()
}

View File

@@ -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()
}
}

View File

@@ -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 {
ioScope.launch {
loadRecords()
loadMergeSettings()
}
}
@@ -140,6 +149,7 @@ class TrainRecordManager(private val context: Context) {
}
private fun saveRecords() {
ioScope.launch {
try {
val jsonArray = JSONArray()
for (record in trainRecords) {
@@ -151,6 +161,7 @@ class TrainRecordManager(private val context: Context) {
Log.e(TAG, "Failed to save records: ${e.message}")
}
}
}
private fun loadRecords() {
@@ -177,4 +188,165 @@ 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 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()
}
}
}

View File

@@ -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
)
}
}

View File

@@ -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
)
}
}
}
}
}
}
}
}
}

View File

@@ -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 ->
@@ -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) {

View File

@@ -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) }
@@ -76,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(
@@ -210,6 +266,7 @@ fun MonitorScreen(
}
}
}
}
}
}

View File

@@ -2,12 +2,29 @@ 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 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)
@Composable
@@ -15,44 +32,407 @@ 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 = {},
autoConnectEnabled: Boolean = true,
onAutoConnectEnabledChange: (Boolean) -> Unit = {}
) {
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(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
.verticalScroll(scrollState)
.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(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(20.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(
value = deviceName,
onValueChange = onDeviceNameChange,
label = { Text("蓝牙设备名称") },
label = { Text("设备名称") },
leadingIcon = {
Icon(
imageVector = Icons.Default.DeviceHub,
contentDescription = null
)
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
Button(
onClick = onApplySettings,
modifier = Modifier.fillMaxWidth()
if (searchOrderList.isNotEmpty()) {
var deviceAddressExpanded by remember { mutableStateOf(false) }
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 = "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")
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable {
uriHandler.openUri("https://github.com/undef-i/LBJ_Console")
}
.padding(12.dp)
)
}
}

View File

@@ -24,7 +24,7 @@ private val LightColorScheme = lightColorScheme(
)
@Composable
fun LBJReceiverTheme(
fun LBJConsoleTheme(
darkTheme: Boolean = true,
dynamicColor: Boolean = true,

View 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>

View 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>

View 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>

View File

@@ -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>