feat: add record merging functionality and optimize settings page

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

View File

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

View File

@@ -42,9 +42,12 @@ 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.MergedHistoryScreen
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.util.LocoInfoUtil
import java.util.*
@@ -87,8 +90,13 @@ class MainActivity : ComponentActivity() {
private var historyScrollOffset by mutableStateOf(0)
private var mapCenterPosition by mutableStateOf<Pair<Double, Double>?>(null)
private var mapZoomLevel by mutableStateOf(10.0)
private var mapRailwayLayerVisible by mutableStateOf(true)
private var mapRailwayLayerVisible by mutableStateOf(true)
private var settingsScrollPosition by mutableStateOf(0)
private var mergeSettings by mutableStateOf(MergeSettings())
private var targetDeviceName = "LBJReceiver"
@@ -236,7 +244,7 @@ class MainActivity : ComponentActivity() {
isConnected = bleClient.isConnected(),
isScanning = isScanning,
currentTab = currentTab,
onTabChange = { tab ->
onTabChange = { tab ->
currentTab = tab
saveSettings()
},
@@ -256,12 +264,18 @@ class MainActivity : ComponentActivity() {
},
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)
saveSettings()
},
historyEditMode = historyEditMode,
@@ -279,6 +293,13 @@ class MainActivity : ComponentActivity() {
},
settingsScrollPosition = settingsScrollPosition,
onSettingsScrollPositionChange = { position ->
settingsScrollPosition = position
saveSettings()
},
mapCenterPosition = mapCenterPosition,
mapZoomLevel = mapZoomLevel,
mapRailwayLayerVisible = mapRailwayLayerVisible,
@@ -332,7 +353,6 @@ class MainActivity : ComponentActivity() {
onApplySettings = {
saveSettings()
targetDeviceName = settingsDeviceName
Toast.makeText(this, "设备名称 '${settingsDeviceName}' 已保存,下次连接时生效", Toast.LENGTH_LONG).show()
Log.d(TAG, "Applied settings deviceName=${settingsDeviceName}")
},
appVersion = getAppVersion(),
@@ -343,7 +363,7 @@ class MainActivity : ComponentActivity() {
ConnectionDialog(
isScanning = isScanning,
devices = foundDevices,
onDismiss = {
onDismiss = {
showConnectionDialog = false
stopScan()
},
@@ -360,6 +380,8 @@ class MainActivity : ComponentActivity() {
}
)
}
}
}
}
@@ -585,6 +607,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,6 +618,8 @@ class MainActivity : ComponentActivity() {
mapZoomLevel = settingsPrefs.getFloat("map_zoom_level", 10.0f).toDouble()
mapRailwayLayerVisible = settingsPrefs.getBoolean("map_railway_visible", true)
mergeSettings = trainRecordManager.mergeSettings
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(";"))
.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)
@@ -656,11 +682,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,6 +714,10 @@ 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,
@@ -770,8 +803,22 @@ fun MainContent(
IconButton(
onClick = {
if (historySelectedRecords.isNotEmpty()) {
val recordsToDelete = allRecords.filter {
historySelectedRecords.contains(it.timestamp.time.toString())
val recordsToDelete = mutableListOf<TrainRecord>()
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)
onHistoryStateChange(false, emptySet(), historyExpandedStates, historyScrollPosition, historyScrollOffset)
@@ -823,31 +870,42 @@ fun MainContent(
.padding(paddingValues)
) {
when (currentTab) {
0 -> HistoryScreen(
records = allRecords,
latestRecord = latestRecord,
lastUpdateTime = lastUpdateTime,
temporaryStatusMessage = temporaryStatusMessage,
locoInfoUtil = locoInfoUtil,
onClearRecords = onClearRecords,
onRecordClick = onRecordClick,
onClearLog = onClearMonitorLog,
onDeleteRecords = onDeleteRecords,
editMode = historyEditMode,
selectedRecords = historySelectedRecords,
expandedStates = historyExpandedStates,
scrollPosition = historyScrollPosition,
scrollOffset = historyScrollOffset,
onStateChange = onHistoryStateChange
)
0 -> {
HistoryScreen(
records = allRecords,
latestRecord = latestRecord,
lastUpdateTime = lastUpdateTime,
temporaryStatusMessage = temporaryStatusMessage,
locoInfoUtil = locoInfoUtil,
mergeSettings = mergeSettings,
onClearRecords = onClearRecords,
onRecordClick = onRecordClick,
onClearLog = onClearMonitorLog,
onDeleteRecords = onDeleteRecords,
editMode = historyEditMode,
selectedRecords = historySelectedRecords,
expandedStates = historyExpandedStates,
scrollPosition = historyScrollPosition,
scrollOffset = historyScrollOffset,
onStateChange = onHistoryStateChange
)
}
2 -> SettingsScreen(
deviceName = deviceName,
onDeviceNameChange = onDeviceNameChange,
onApplySettings = onApplySettings,
appVersion = appVersion
appVersion = appVersion,
mergeSettings = mergeSettings,
onMergeSettingsChange = onMergeSettingsChange,
scrollPosition = settingsScrollPosition,
onScrollPositionChange = onSettingsScrollPositionChange
)
3 -> MapScreen(
records = if (allRecords.isNotEmpty()) allRecords else recentRecords,
records = if (allRecords.isNotEmpty()) {
allRecords.filterIsInstance<TrainRecord>()
} else {
recentRecords
},
centerPosition = mapCenterPosition,
zoomLevel = mapZoomLevel,
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
private const val PREFS_NAME = "train_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 prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
var mergeSettings = MergeSettings()
private set
init {
loadRecords()
loadMergeSettings()
}
@@ -177,4 +182,110 @@ 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.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.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.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.noxylva.lbjconsole.model.MergeSettings
import org.noxylva.lbjconsole.model.GroupBy
import org.noxylva.lbjconsole.model.TimeWindow
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.LaunchedEffect
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -15,44 +27,220 @@ 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 = {}
) {
val uriHandler = LocalUriHandler.current
val scrollState = rememberScrollState()
LaunchedEffect(scrollPosition) {
scrollState.scrollTo(scrollPosition)
}
LaunchedEffect(scrollState.value) {
onScrollPositionChange(scrollState.value)
}
LaunchedEffect(deviceName) {
onApplySettings()
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
.verticalScroll(scrollState)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
),
shape = RoundedCornerShape(16.dp)
) {
OutlinedTextField(
value = deviceName,
onValueChange = onDeviceNameChange,
label = { Text("蓝牙设备名称") },
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = onApplySettings,
modifier = Modifier.fillMaxWidth()
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("应用设置")
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Default.Bluetooth,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
"蓝牙设备",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
OutlinedTextField(
value = deviceName,
onValueChange = onDeviceNameChange,
label = { Text("设备名称") },
leadingIcon = {
Icon(
imageVector = Icons.Default.DeviceHub,
contentDescription = null
)
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
}
}
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 = "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")
}
)
text = "LBJ Console v$appVersion by undef-i",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable {
uriHandler.openUri("https://github.com/undef-i")
}
.padding(16.dp)
)
}
}