feat: add record merging functionality and optimize settings page

This commit is contained in:
Nedifinita
2025-07-19 21:07:11 +08:00
parent 2aa0dd00a2
commit cbceab8771
8 changed files with 1795 additions and 420 deletions

View File

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

View File

@@ -42,9 +42,12 @@ import org.json.JSONObject
import org.osmdroid.config.Configuration import org.osmdroid.config.Configuration
import org.noxylva.lbjconsole.model.TrainRecord import org.noxylva.lbjconsole.model.TrainRecord
import org.noxylva.lbjconsole.model.TrainRecordManager import org.noxylva.lbjconsole.model.TrainRecordManager
import org.noxylva.lbjconsole.model.MergeSettings
import org.noxylva.lbjconsole.ui.screens.HistoryScreen import org.noxylva.lbjconsole.ui.screens.HistoryScreen
import org.noxylva.lbjconsole.ui.screens.MergedHistoryScreen
import org.noxylva.lbjconsole.ui.screens.MapScreen import org.noxylva.lbjconsole.ui.screens.MapScreen
import org.noxylva.lbjconsole.ui.screens.SettingsScreen import org.noxylva.lbjconsole.ui.screens.SettingsScreen
import org.noxylva.lbjconsole.ui.theme.LBJReceiverTheme import org.noxylva.lbjconsole.ui.theme.LBJReceiverTheme
import org.noxylva.lbjconsole.util.LocoInfoUtil import org.noxylva.lbjconsole.util.LocoInfoUtil
import java.util.* import java.util.*
@@ -87,8 +90,13 @@ class MainActivity : ComponentActivity() {
private var historyScrollOffset by mutableStateOf(0) private var historyScrollOffset by mutableStateOf(0)
private var mapCenterPosition by mutableStateOf<Pair<Double, Double>?>(null) private var mapCenterPosition by mutableStateOf<Pair<Double, Double>?>(null)
private var mapZoomLevel by mutableStateOf(10.0) private var mapZoomLevel by mutableStateOf(10.0)
private var mapRailwayLayerVisible by mutableStateOf(true) private var mapRailwayLayerVisible by mutableStateOf(true)
private var settingsScrollPosition by mutableStateOf(0)
private var mergeSettings by mutableStateOf(MergeSettings())
private var targetDeviceName = "LBJReceiver" private var targetDeviceName = "LBJReceiver"
@@ -236,7 +244,7 @@ class MainActivity : ComponentActivity() {
isConnected = bleClient.isConnected(), isConnected = bleClient.isConnected(),
isScanning = isScanning, isScanning = isScanning,
currentTab = currentTab, currentTab = currentTab,
onTabChange = { tab -> onTabChange = { tab ->
currentTab = tab currentTab = tab
saveSettings() saveSettings()
}, },
@@ -256,12 +264,18 @@ class MainActivity : ComponentActivity() {
}, },
allRecords = if (trainRecordManager.getFilteredRecords().isNotEmpty()) allRecords = trainRecordManager.getMixedRecords(),
trainRecordManager.getFilteredRecords() else trainRecordManager.getAllRecords(), mergedRecords = trainRecordManager.getMergedRecords(),
recordCount = trainRecordManager.getRecordCount(), recordCount = trainRecordManager.getRecordCount(),
filterTrain = filterTrain, filterTrain = filterTrain,
filterRoute = filterRoute, filterRoute = filterRoute,
filterDirection = filterDirection, filterDirection = filterDirection,
mergeSettings = mergeSettings,
onMergeSettingsChange = { newSettings ->
mergeSettings = newSettings
trainRecordManager.updateMergeSettings(newSettings)
saveSettings()
},
historyEditMode = historyEditMode, historyEditMode = historyEditMode,
@@ -279,6 +293,13 @@ class MainActivity : ComponentActivity() {
}, },
settingsScrollPosition = settingsScrollPosition,
onSettingsScrollPositionChange = { position ->
settingsScrollPosition = position
saveSettings()
},
mapCenterPosition = mapCenterPosition, mapCenterPosition = mapCenterPosition,
mapZoomLevel = mapZoomLevel, mapZoomLevel = mapZoomLevel,
mapRailwayLayerVisible = mapRailwayLayerVisible, mapRailwayLayerVisible = mapRailwayLayerVisible,
@@ -332,7 +353,6 @@ class MainActivity : ComponentActivity() {
onApplySettings = { onApplySettings = {
saveSettings() saveSettings()
targetDeviceName = settingsDeviceName targetDeviceName = settingsDeviceName
Toast.makeText(this, "设备名称 '${settingsDeviceName}' 已保存,下次连接时生效", Toast.LENGTH_LONG).show()
Log.d(TAG, "Applied settings deviceName=${settingsDeviceName}") Log.d(TAG, "Applied settings deviceName=${settingsDeviceName}")
}, },
appVersion = getAppVersion(), appVersion = getAppVersion(),
@@ -343,7 +363,7 @@ class MainActivity : ComponentActivity() {
ConnectionDialog( ConnectionDialog(
isScanning = isScanning, isScanning = isScanning,
devices = foundDevices, devices = foundDevices,
onDismiss = { onDismiss = {
showConnectionDialog = false showConnectionDialog = false
stopScan() stopScan()
}, },
@@ -360,6 +380,8 @@ class MainActivity : ComponentActivity() {
} }
) )
} }
} }
} }
} }
@@ -585,6 +607,7 @@ class MainActivity : ComponentActivity() {
historyScrollPosition = settingsPrefs.getInt("history_scroll_position", 0) historyScrollPosition = settingsPrefs.getInt("history_scroll_position", 0)
historyScrollOffset = settingsPrefs.getInt("history_scroll_offset", 0) historyScrollOffset = settingsPrefs.getInt("history_scroll_offset", 0)
settingsScrollPosition = settingsPrefs.getInt("settings_scroll_position", 0)
val centerLat = settingsPrefs.getFloat("map_center_lat", Float.NaN) val centerLat = settingsPrefs.getFloat("map_center_lat", Float.NaN)
val centerLon = settingsPrefs.getFloat("map_center_lon", Float.NaN) val centerLon = settingsPrefs.getFloat("map_center_lon", Float.NaN)
@@ -595,6 +618,8 @@ class MainActivity : ComponentActivity() {
mapZoomLevel = settingsPrefs.getFloat("map_zoom_level", 10.0f).toDouble() mapZoomLevel = settingsPrefs.getFloat("map_zoom_level", 10.0f).toDouble()
mapRailwayLayerVisible = settingsPrefs.getBoolean("map_railway_visible", true) mapRailwayLayerVisible = settingsPrefs.getBoolean("map_railway_visible", true)
mergeSettings = trainRecordManager.mergeSettings
Log.d(TAG, "Loaded settings deviceName=${settingsDeviceName} tab=${currentTab}") Log.d(TAG, "Loaded settings deviceName=${settingsDeviceName} tab=${currentTab}")
} }
@@ -608,6 +633,7 @@ class MainActivity : ComponentActivity() {
.putString("history_expanded_states", historyExpandedStates.map { "${it.key}:${it.value}" }.joinToString(";")) .putString("history_expanded_states", historyExpandedStates.map { "${it.key}:${it.value}" }.joinToString(";"))
.putInt("history_scroll_position", historyScrollPosition) .putInt("history_scroll_position", historyScrollPosition)
.putInt("history_scroll_offset", historyScrollOffset) .putInt("history_scroll_offset", historyScrollOffset)
.putInt("settings_scroll_position", settingsScrollPosition)
.putFloat("map_zoom_level", mapZoomLevel.toFloat()) .putFloat("map_zoom_level", mapZoomLevel.toFloat())
.putBoolean("map_railway_visible", mapRailwayLayerVisible) .putBoolean("map_railway_visible", mapRailwayLayerVisible)
@@ -656,11 +682,14 @@ fun MainContent(
onClearMonitorLog: () -> Unit, onClearMonitorLog: () -> Unit,
allRecords: List<TrainRecord>, allRecords: List<Any>,
mergedRecords: List<org.noxylva.lbjconsole.model.MergedTrainRecord>,
recordCount: Int, recordCount: Int,
filterTrain: String, filterTrain: String,
filterRoute: String, filterRoute: String,
filterDirection: String, filterDirection: String,
mergeSettings: MergeSettings,
onMergeSettingsChange: (MergeSettings) -> Unit,
onFilterChange: (String, String, String) -> Unit, onFilterChange: (String, String, String) -> Unit,
onClearFilter: () -> Unit, onClearFilter: () -> Unit,
onClearRecords: () -> Unit, onClearRecords: () -> Unit,
@@ -685,6 +714,10 @@ fun MainContent(
onHistoryStateChange: (Boolean, Set<String>, Map<String, Boolean>, Int, Int) -> Unit, onHistoryStateChange: (Boolean, Set<String>, Map<String, Boolean>, Int, Int) -> Unit,
settingsScrollPosition: Int,
onSettingsScrollPositionChange: (Int) -> Unit,
mapCenterPosition: Pair<Double, Double>?, mapCenterPosition: Pair<Double, Double>?,
mapZoomLevel: Double, mapZoomLevel: Double,
mapRailwayLayerVisible: Boolean, mapRailwayLayerVisible: Boolean,
@@ -770,8 +803,22 @@ fun MainContent(
IconButton( IconButton(
onClick = { onClick = {
if (historySelectedRecords.isNotEmpty()) { if (historySelectedRecords.isNotEmpty()) {
val recordsToDelete = allRecords.filter { val recordsToDelete = mutableListOf<TrainRecord>()
historySelectedRecords.contains(it.timestamp.time.toString()) allRecords.forEach { item ->
when (item) {
is TrainRecord -> {
if (historySelectedRecords.contains(item.timestamp.time.toString())) {
recordsToDelete.add(item)
}
}
is org.noxylva.lbjconsole.model.MergedTrainRecord -> {
item.records.forEach { record ->
if (historySelectedRecords.contains(record.timestamp.time.toString())) {
recordsToDelete.add(record)
}
}
}
}
} }
onDeleteRecords(recordsToDelete) onDeleteRecords(recordsToDelete)
onHistoryStateChange(false, emptySet(), historyExpandedStates, historyScrollPosition, historyScrollOffset) onHistoryStateChange(false, emptySet(), historyExpandedStates, historyScrollPosition, historyScrollOffset)
@@ -823,31 +870,42 @@ fun MainContent(
.padding(paddingValues) .padding(paddingValues)
) { ) {
when (currentTab) { when (currentTab) {
0 -> HistoryScreen( 0 -> {
records = allRecords, HistoryScreen(
latestRecord = latestRecord, records = allRecords,
lastUpdateTime = lastUpdateTime, latestRecord = latestRecord,
temporaryStatusMessage = temporaryStatusMessage, lastUpdateTime = lastUpdateTime,
locoInfoUtil = locoInfoUtil, temporaryStatusMessage = temporaryStatusMessage,
onClearRecords = onClearRecords, locoInfoUtil = locoInfoUtil,
onRecordClick = onRecordClick, mergeSettings = mergeSettings,
onClearLog = onClearMonitorLog, onClearRecords = onClearRecords,
onDeleteRecords = onDeleteRecords, onRecordClick = onRecordClick,
editMode = historyEditMode, onClearLog = onClearMonitorLog,
selectedRecords = historySelectedRecords, onDeleteRecords = onDeleteRecords,
expandedStates = historyExpandedStates, editMode = historyEditMode,
scrollPosition = historyScrollPosition, selectedRecords = historySelectedRecords,
scrollOffset = historyScrollOffset, expandedStates = historyExpandedStates,
onStateChange = onHistoryStateChange scrollPosition = historyScrollPosition,
) scrollOffset = historyScrollOffset,
onStateChange = onHistoryStateChange
)
}
2 -> SettingsScreen( 2 -> SettingsScreen(
deviceName = deviceName, deviceName = deviceName,
onDeviceNameChange = onDeviceNameChange, onDeviceNameChange = onDeviceNameChange,
onApplySettings = onApplySettings, onApplySettings = onApplySettings,
appVersion = appVersion appVersion = appVersion,
mergeSettings = mergeSettings,
onMergeSettingsChange = onMergeSettingsChange,
scrollPosition = settingsScrollPosition,
onScrollPositionChange = onSettingsScrollPositionChange
) )
3 -> MapScreen( 3 -> MapScreen(
records = if (allRecords.isNotEmpty()) allRecords else recentRecords, records = if (allRecords.isNotEmpty()) {
allRecords.filterIsInstance<TrainRecord>()
} else {
recentRecords
},
centerPosition = mapCenterPosition, centerPosition = mapCenterPosition,
zoomLevel = mapZoomLevel, zoomLevel = mapZoomLevel,
railwayLayerVisible = mapRailwayLayerVisible, railwayLayerVisible = mapRailwayLayerVisible,

View File

@@ -0,0 +1,43 @@
package org.noxylva.lbjconsole.model
data class MergeSettings(
val enabled: Boolean = true,
val groupBy: GroupBy = GroupBy.TRAIN_AND_LOCO,
val timeWindow: TimeWindow = TimeWindow.UNLIMITED
)
enum class GroupBy(val displayName: String) {
TRAIN_AND_LOCO("车次号+机车号"),
TRAIN_ONLY("仅车次号"),
LOCO_ONLY("仅机车号")
}
enum class TimeWindow(val displayName: String, val seconds: Long?) {
ONE_HOUR("1小时", 3600),
TWO_HOURS("2小时", 7200),
SIX_HOURS("6小时", 21600),
TWELVE_HOURS("12小时", 43200),
ONE_DAY("24小时", 86400),
UNLIMITED("不限时间", null)
}
fun generateGroupKey(record: TrainRecord, groupBy: GroupBy): String? {
return when (groupBy) {
GroupBy.TRAIN_AND_LOCO -> {
val train = record.train.trim()
val loco = record.loco.trim()
if (train.isNotEmpty() && train != "<NUL>" &&
loco.isNotEmpty() && loco != "<NUL>") {
"${train}_${loco}"
} else null
}
GroupBy.TRAIN_ONLY -> {
val train = record.train.trim()
if (train.isNotEmpty() && train != "<NUL>") train else null
}
GroupBy.LOCO_ONLY -> {
val loco = record.loco.trim()
if (loco.isNotEmpty() && loco != "<NUL>") loco else null
}
}
}

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

@@ -19,6 +19,7 @@ class TrainRecordManager(private val context: Context) {
const val MAX_RECORDS = 1000 const val MAX_RECORDS = 1000
private const val PREFS_NAME = "train_records" private const val PREFS_NAME = "train_records"
private const val KEY_RECORDS = "records" private const val KEY_RECORDS = "records"
private const val KEY_MERGE_SETTINGS = "merge_settings"
} }
@@ -26,8 +27,12 @@ class TrainRecordManager(private val context: Context) {
private val recordCount = AtomicInteger(0) private val recordCount = AtomicInteger(0)
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
var mergeSettings = MergeSettings()
private set
init { init {
loadRecords() loadRecords()
loadMergeSettings()
} }
@@ -177,4 +182,110 @@ class TrainRecordManager(private val context: Context) {
fun getRecordCount(): Int { fun getRecordCount(): Int {
return recordCount.get() return recordCount.get()
} }
fun updateMergeSettings(newSettings: MergeSettings) {
mergeSettings = newSettings
saveMergeSettings()
}
fun getMergedRecords(): List<MergedTrainRecord> {
if (!mergeSettings.enabled) {
return emptyList()
}
val records = getFilteredRecords()
return processRecordsForMerging(records, mergeSettings)
}
fun getMixedRecords(): List<Any> {
if (!mergeSettings.enabled) {
return getFilteredRecords()
}
val allRecords = getFilteredRecords()
val mergedRecords = processRecordsForMerging(allRecords, mergeSettings)
val mergedRecordIds = mergedRecords.flatMap { merged ->
merged.records.map { it.timestamp.time.toString() }
}.toSet()
val singleRecords = allRecords.filter { record ->
!mergedRecordIds.contains(record.timestamp.time.toString())
}
val mixedList = mutableListOf<Any>()
mixedList.addAll(mergedRecords)
mixedList.addAll(singleRecords)
return mixedList.sortedByDescending { item ->
when (item) {
is MergedTrainRecord -> item.latestRecord.timestamp
is TrainRecord -> item.timestamp
else -> Date(0)
}
}
}
private fun processRecordsForMerging(records: List<TrainRecord>, settings: MergeSettings): List<MergedTrainRecord> {
val groupedRecords = mutableMapOf<String, MutableList<TrainRecord>>()
val currentTime = Date()
records.forEach { record ->
val groupKey = generateGroupKey(record, settings.groupBy)
if (groupKey != null) {
val withinTimeWindow = settings.timeWindow.seconds?.let { windowSeconds ->
(currentTime.time - record.timestamp.time) / 1000 <= windowSeconds
} ?: true
if (withinTimeWindow) {
groupedRecords.getOrPut(groupKey) { mutableListOf() }.add(record)
}
}
}
return groupedRecords.mapNotNull { (groupKey, groupRecords) ->
if (groupRecords.size >= 2) {
val sortedRecords = groupRecords.sortedBy { it.timestamp }
val latestRecord = sortedRecords.maxByOrNull { it.timestamp }!!
MergedTrainRecord(
groupKey = groupKey,
records = sortedRecords,
latestRecord = latestRecord
)
} else null
}.sortedByDescending { it.latestRecord.timestamp }
}
private fun saveMergeSettings() {
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

@@ -0,0 +1,490 @@
package org.noxylva.lbjconsole.ui.screens
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
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.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
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.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import org.osmdroid.tileprovider.MapTileProviderBasic
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.tileprovider.tilesource.XYTileSource
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polyline
import org.osmdroid.views.overlay.TilesOverlay
import org.noxylva.lbjconsole.model.MergedTrainRecord
import org.noxylva.lbjconsole.model.TrainRecord
import org.noxylva.lbjconsole.util.LocoInfoUtil
import java.text.SimpleDateFormat
import java.util.*
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun MergedHistoryScreen(
mergedRecords: List<MergedTrainRecord>,
latestRecord: TrainRecord?,
lastUpdateTime: Date?,
temporaryStatusMessage: String? = null,
locoInfoUtil: LocoInfoUtil? = null,
onClearRecords: () -> Unit = {},
onRecordClick: (TrainRecord) -> Unit = {},
onClearLog: () -> Unit = {},
onDeleteRecords: (List<TrainRecord>) -> Unit = {},
onDeleteMergedRecord: (MergedTrainRecord) -> Unit = {},
editMode: Boolean = false,
selectedRecords: Set<String> = emptySet(),
expandedStates: Map<String, Boolean> = emptyMap(),
scrollPosition: Int = 0,
scrollOffset: Int = 0,
onStateChange: (Boolean, Set<String>, Map<String, Boolean>, Int, Int) -> Unit = { _, _, _, _, _ -> }
) {
var isInEditMode by remember(editMode) { mutableStateOf(editMode) }
val selectedRecordsList = remember(selectedRecords) {
mutableStateListOf<TrainRecord>().apply {
addAll(mergedRecords.flatMap { it.records }.filter {
selectedRecords.contains(it.timestamp.time.toString())
})
}
}
val expandedStatesMap = remember(expandedStates) {
mutableStateMapOf<String, Boolean>().apply { putAll(expandedStates) }
}
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = scrollPosition,
initialFirstVisibleItemScrollOffset = scrollOffset
)
LaunchedEffect(isInEditMode, selectedRecordsList.size) {
val selectedIds = selectedRecordsList.map { it.timestamp.time.toString() }.toSet()
onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(),
listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
}
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.weight(1.0f)
) {
if (mergedRecords.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"暂无合并记录",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.outline
)
Text(
"请检查合并设置或等待更多数据",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f)
)
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(mergedRecords) { mergedRecord ->
MergedRecordCard(
mergedRecord = mergedRecord,
isExpanded = expandedStatesMap[mergedRecord.groupKey] == true,
onExpandToggle = {
expandedStatesMap[mergedRecord.groupKey] =
!(expandedStatesMap[mergedRecord.groupKey] ?: false)
},
locoInfoUtil = locoInfoUtil
)
}
}
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun MergedRecordCard(
mergedRecord: MergedTrainRecord,
isExpanded: Boolean,
onExpandToggle: () -> Unit,
locoInfoUtil: LocoInfoUtil?
) {
val record = mergedRecord.latestRecord
val recordMap = record.toMap(showDetailedTime = true)
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
shape = RoundedCornerShape(8.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onExpandToggle,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = true)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (recordMap.containsKey("time")) {
Column {
recordMap["time"]?.split("\n")?.forEach { timeLine ->
Text(
text = timeLine,
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "${record.rssi} dBm",
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(2.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
val trainDisplay = recordMap["train"]?.toString() ?: "未知列车"
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = trainDisplay,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = MaterialTheme.colorScheme.primary
)
val directionText = when (record.direction) {
1 -> ""
3 -> ""
else -> ""
}
if (directionText.isNotEmpty()) {
Surface(
shape = RoundedCornerShape(2.dp),
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = directionText,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.surface,
maxLines = 1,
modifier = Modifier.offset(y = (-2).dp)
)
}
}
}
}
val formattedInfo = when {
record.locoType.isNotEmpty() && record.loco.isNotEmpty() -> {
val shortLoco = if (record.loco.length > 5) {
record.loco.takeLast(5)
} else {
record.loco
}
"${record.locoType}-${shortLoco}"
}
record.locoType.isNotEmpty() -> record.locoType
record.loco.isNotEmpty() -> record.loco
else -> ""
}
if (formattedInfo.isNotEmpty() && formattedInfo != "<NUL>") {
Text(
text = formattedInfo,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
val routeStr = record.route.trim()
val isValidRoute = routeStr.isNotEmpty() && !routeStr.all { it == '*' }
val position = record.position.trim()
val isValidPosition = position.isNotEmpty() &&
!position.all { it == '-' || it == '.' } &&
position != "<NUL>"
if (isValidRoute || isValidPosition) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.height(24.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
if (isValidRoute) {
Text(
text = "$routeStr",
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.alignByBaseline()
)
}
if (isValidPosition) {
Text(
text = "${position}K",
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.alignByBaseline()
)
}
}
}
val speed = record.speed.trim()
val isValidSpeed = speed.isNotEmpty() &&
!speed.all { it == '*' || it == '-' } &&
speed != "NUL" &&
speed != "<NUL>"
if (isValidSpeed) {
Text(
text = "${speed} km/h",
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
}
if (locoInfoUtil != null && record.locoType.isNotEmpty() && record.loco.isNotEmpty()) {
val locoInfoText = locoInfoUtil.getLocoInfoDisplay(record.locoType, record.loco)
if (locoInfoText != null) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = locoInfoText,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
}
if (isExpanded) {
Spacer(modifier = Modifier.height(12.dp))
Divider()
Spacer(modifier = Modifier.height(8.dp))
Text(
"记录详情",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
mergedRecord.records.forEach { recordItem ->
val timeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
) {
Text(
text = timeFormat.format(recordItem.timestamp),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
val locationText = buildString {
if (recordItem.route.isNotEmpty() && recordItem.route != "<NUL>") {
append(recordItem.route)
}
if (recordItem.position.isNotEmpty() && recordItem.position != "<NUL>") {
if (isNotEmpty()) append(" ")
append("${recordItem.position}K")
}
}
Text(
text = locationText.ifEmpty { "位置未知" },
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
val directionText = when (recordItem.direction) {
1 -> "下行"
3 -> "上行"
else -> "未知"
}
Text(
text = directionText,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val speedText = if (recordItem.speed.isNotEmpty() &&
recordItem.speed != "<NUL>" &&
!recordItem.speed.all { it == '*' || it == '-' }) {
"${recordItem.speed}km/h"
} else {
"速度未知"
}
Text(
text = speedText,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
val coordinates = mergedRecord.getAllCoordinates()
val recordsWithCoordinates = mergedRecord.records.filter { it.getCoordinates() != null }
if (coordinates.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
Text(
"行进路径 (${coordinates.size}/${mergedRecord.records.size} 个记录有位置信息)",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.clip(RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
AndroidView(
factory = { context ->
MapView(context).apply {
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(true)
zoomController.setVisibility(org.osmdroid.views.CustomZoomButtonsController.Visibility.NEVER)
try {
val railwayTileSource = XYTileSource(
"OpenRailwayMap", 8, 16, 256, ".png",
arrayOf(
"https://a.tiles.openrailwaymap.org/standard/",
"https://b.tiles.openrailwaymap.org/standard/",
"https://c.tiles.openrailwaymap.org/standard/"
),
"© OpenRailwayMap contributors, © OpenStreetMap contributors"
)
val railwayProvider = MapTileProviderBasic(context)
railwayProvider.tileSource = railwayTileSource
val railwayOverlay = TilesOverlay(railwayProvider, context)
overlays.add(railwayOverlay)
} catch (e: Exception) {
e.printStackTrace()
}
if (coordinates.size > 1) {
val polyline = Polyline().apply {
setPoints(coordinates)
outlinePaint.color = android.graphics.Color.BLUE
outlinePaint.strokeWidth = 5f
}
overlays.add(polyline)
}
coordinates.forEachIndexed { index, coord ->
val marker = Marker(this).apply {
position = coord
title = when (index) {
0 -> "起点"
coordinates.lastIndex -> "终点"
else -> "经过点 ${index + 1}"
}
}
overlays.add(marker)
}
val centerLat = coordinates.map { it.latitude }.average()
val centerLon = coordinates.map { it.longitude }.average()
controller.setCenter(org.osmdroid.util.GeoPoint(centerLat, centerLon))
controller.setZoom(12.0)
}
},
update = { mapView ->
mapView.invalidate()
}
)
}
}
}
}
}
}
}

View File

@@ -2,12 +2,24 @@ package org.noxylva.lbjconsole.ui.screens
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.noxylva.lbjconsole.model.MergeSettings
import org.noxylva.lbjconsole.model.GroupBy
import org.noxylva.lbjconsole.model.TimeWindow
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.LaunchedEffect
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -15,44 +27,220 @@ fun SettingsScreen(
deviceName: String, deviceName: String,
onDeviceNameChange: (String) -> Unit, onDeviceNameChange: (String) -> Unit,
onApplySettings: () -> Unit, onApplySettings: () -> Unit,
appVersion: String = "Unknown" appVersion: String = "Unknown",
mergeSettings: MergeSettings,
onMergeSettingsChange: (MergeSettings) -> Unit,
scrollPosition: Int = 0,
onScrollPositionChange: (Int) -> Unit = {}
) { ) {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val scrollState = rememberScrollState()
LaunchedEffect(scrollPosition) {
scrollState.scrollTo(scrollPosition)
}
LaunchedEffect(scrollState.value) {
onScrollPositionChange(scrollState.value)
}
LaunchedEffect(deviceName) {
onApplySettings()
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp), .verticalScroll(scrollState)
horizontalAlignment = Alignment.CenterHorizontally .padding(20.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) { ) {
Column( Card(
horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp) colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
),
shape = RoundedCornerShape(16.dp)
) { ) {
OutlinedTextField( Column(
value = deviceName, modifier = Modifier.padding(20.dp),
onValueChange = onDeviceNameChange, verticalArrangement = Arrangement.spacedBy(16.dp)
label = { Text("蓝牙设备名称") },
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = onApplySettings,
modifier = Modifier.fillMaxWidth()
) { ) {
Text("应用设置") Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Default.Bluetooth,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
"蓝牙设备",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
OutlinedTextField(
value = deviceName,
onValueChange = onDeviceNameChange,
label = { Text("设备名称") },
leadingIcon = {
Icon(
imageVector = Icons.Default.DeviceHub,
contentDescription = null
)
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
} }
} }
Spacer(modifier = Modifier.weight(1f)) 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.height(20.dp))
Text( Text(
text = "LBJ Console v$appVersion by undef-i", text = "LBJ Console v$appVersion by undef-i",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clickable { textAlign = TextAlign.Center,
uriHandler.openUri("https://github.com/undef-i") modifier = Modifier
} .fillMaxWidth()
) .clip(RoundedCornerShape(12.dp))
.clickable {
uriHandler.openUri("https://github.com/undef-i")
}
.padding(16.dp)
)
} }
} }