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" 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.*
@@ -89,6 +92,11 @@ class MainActivity : ComponentActivity() {
private var mapZoomLevel by mutableStateOf(10.0) private var mapZoomLevel by mutableStateOf(10.0)
private var mapRailwayLayerVisible by mutableStateOf(true) private var mapRailwayLayerVisible by mutableStateOf(true)
private var settingsScrollPosition by mutableStateOf(0)
private var mergeSettings by mutableStateOf(MergeSettings())
private var targetDeviceName = "LBJReceiver" private var targetDeviceName = "LBJReceiver"
@@ -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(),
@@ -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,12 +870,14 @@ fun MainContent(
.padding(paddingValues) .padding(paddingValues)
) { ) {
when (currentTab) { when (currentTab) {
0 -> HistoryScreen( 0 -> {
HistoryScreen(
records = allRecords, records = allRecords,
latestRecord = latestRecord, latestRecord = latestRecord,
lastUpdateTime = lastUpdateTime, lastUpdateTime = lastUpdateTime,
temporaryStatusMessage = temporaryStatusMessage, temporaryStatusMessage = temporaryStatusMessage,
locoInfoUtil = locoInfoUtil, locoInfoUtil = locoInfoUtil,
mergeSettings = mergeSettings,
onClearRecords = onClearRecords, onClearRecords = onClearRecords,
onRecordClick = onRecordClick, onRecordClick = onRecordClick,
onClearLog = onClearMonitorLog, onClearLog = onClearMonitorLog,
@@ -840,14 +889,23 @@ fun MainContent(
scrollOffset = historyScrollOffset, scrollOffset = historyScrollOffset,
onStateChange = onHistoryStateChange onStateChange = onHistoryStateChange
) )
}
2 -> SettingsScreen( 2 -> SettingsScreen(
deviceName = deviceName, deviceName = deviceName,
onDeviceNameChange = onDeviceNameChange, onDeviceNameChange = onDeviceNameChange,
onApplySettings = onApplySettings, onApplySettings = onApplySettings,
appVersion = appVersion appVersion = appVersion,
mergeSettings = mergeSettings,
onMergeSettingsChange = onMergeSettingsChange,
scrollPosition = settingsScrollPosition,
onScrollPositionChange = onSettingsScrollPositionChange
) )
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

@@ -33,139 +33,26 @@ import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider
import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
import org.osmdroid.views.overlay.TilesOverlay import org.osmdroid.views.overlay.TilesOverlay
import org.noxylva.lbjconsole.model.TrainRecord import org.noxylva.lbjconsole.model.TrainRecord
import org.noxylva.lbjconsole.model.MergedTrainRecord
import org.noxylva.lbjconsole.model.MergeSettings
import org.noxylva.lbjconsole.model.GroupBy
import org.noxylva.lbjconsole.util.LocoInfoUtil import org.noxylva.lbjconsole.util.LocoInfoUtil
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun HistoryScreen( fun TrainRecordItem(
records: List<TrainRecord>, record: TrainRecord,
isSelected: Boolean,
isInEditMode: Boolean,
expandedStatesMap: MutableMap<String, Boolean>,
latestRecord: TrainRecord?, latestRecord: TrainRecord?,
lastUpdateTime: Date?, locoInfoUtil: LocoInfoUtil?,
temporaryStatusMessage: String? = null, onRecordClick: (TrainRecord) -> Unit,
locoInfoUtil: LocoInfoUtil? = null, onToggleSelection: (TrainRecord) -> Unit,
onClearRecords: () -> Unit = {}, onLongClick: (TrainRecord) -> Unit
onRecordClick: (TrainRecord) -> Unit = {},
onClearLog: () -> Unit = {},
onDeleteRecords: (List<TrainRecord>) -> 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 = { _, _, _, _, _ -> }
) { ) {
val refreshKey = latestRecord?.timestamp?.time ?: 0
var isInEditMode by remember(editMode) { mutableStateOf(editMode) }
val selectedRecordsList = remember(selectedRecords) {
mutableStateListOf<TrainRecord>().apply {
addAll(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
)
val timeSinceLastUpdate = remember { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = lastUpdateTime) {
if (lastUpdateTime != null) {
while (true) {
val now = Date()
val diffInSec = (now.time - lastUpdateTime.time) / 1000
timeSinceLastUpdate.value = when {
diffInSec < 60 -> "${diffInSec}秒前"
diffInSec < 3600 -> "${diffInSec / 60}分钟前"
else -> "${diffInSec / 3600}小时前"
}
val updateInterval = if (diffInSec < 60) 500L else if (diffInSec < 3600) 30000L else 300000L
delay(updateInterval)
}
}
}
val filteredRecords = remember(records, refreshKey) {
records
}
LaunchedEffect(isInEditMode, selectedRecordsList.size) {
val selectedIds = selectedRecordsList.map { it.timestamp.time.toString() }.toSet()
onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
}
LaunchedEffect(expandedStatesMap.toMap()) {
if (!isInEditMode) {
val selectedIds = selectedRecordsList.map { it.timestamp.time.toString() }.toSet()
onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
}
}
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
if (!isInEditMode) {
val selectedIds = selectedRecordsList.map { it.timestamp.time.toString() }.toSet()
onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
}
}
LaunchedEffect(selectedRecordsList.size) {
if (selectedRecordsList.isEmpty() && isInEditMode) {
isInEditMode = false
onStateChange(false, emptySet(), 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 (filteredRecords.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"暂无列车信息",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.outline
)
if (lastUpdateTime != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
"上次接收数据: ${
SimpleDateFormat(
"HH:mm:ss",
Locale.getDefault()
).format(lastUpdateTime)
}",
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(filteredRecords) { record ->
val isSelected = selectedRecordsList.contains(record)
val cardColor = when { val cardColor = when {
isSelected -> MaterialTheme.colorScheme.primaryContainer isSelected -> MaterialTheme.colorScheme.primaryContainer
else -> MaterialTheme.colorScheme.surface else -> MaterialTheme.colorScheme.surface
@@ -185,27 +72,16 @@ fun HistoryScreen(
.combinedClickable( .combinedClickable(
onClick = { onClick = {
if (isInEditMode) { if (isInEditMode) {
if (isSelected) { onToggleSelection(record)
selectedRecordsList.remove(record)
} else {
selectedRecordsList.add(record)
}
} else { } else {
val id = record.timestamp.time.toString() val id = record.timestamp.time.toString()
expandedStatesMap[id] = expandedStatesMap[id] = !(expandedStatesMap[id] ?: false)
!(expandedStatesMap[id] ?: false)
if (record == latestRecord) { if (record == latestRecord) {
onRecordClick(record) onRecordClick(record)
} }
} }
}, },
onLongClick = { onLongClick = { onLongClick(record) },
if (!isInEditMode) {
isInEditMode = true
selectedRecordsList.clear()
selectedRecordsList.add(record)
}
},
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = true) indication = rememberRipple(bounded = true)
) )
@@ -250,8 +126,7 @@ fun HistoryScreen(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
val trainDisplay = val trainDisplay = recordMap["train"]?.toString() ?: "未知列车"
recordMap["train"]?.toString() ?: "未知列车"
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -302,7 +177,6 @@ fun HistoryScreen(
} }
"${record.locoType}-${shortLoco}" "${record.locoType}-${shortLoco}"
} }
record.locoType.isNotEmpty() -> record.locoType record.locoType.isNotEmpty() -> record.locoType
record.loco.isNotEmpty() -> record.loco record.loco.isNotEmpty() -> record.loco
else -> "" else -> ""
@@ -324,8 +198,7 @@ fun HistoryScreen(
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
val routeStr = record.route.trim() val routeStr = record.route.trim()
val isValidRoute = val isValidRoute = routeStr.isNotEmpty() && !routeStr.all { it == '*' }
routeStr.isNotEmpty() && !routeStr.all { it == '*' }
val position = record.position.trim() val position = record.position.trim()
val isValidPosition = position.isNotEmpty() && val isValidPosition = position.isNotEmpty() &&
@@ -395,7 +268,6 @@ fun HistoryScreen(
} }
if (coordinates != null) { if (coordinates != null) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -414,15 +286,11 @@ fun HistoryScreen(
setTileSource(TileSourceFactory.MAPNIK) setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(true) setMultiTouchControls(true)
zoomController.setVisibility(org.osmdroid.views.CustomZoomButtonsController.Visibility.NEVER) zoomController.setVisibility(org.osmdroid.views.CustomZoomButtonsController.Visibility.NEVER)
isHorizontalMapRepetitionEnabled = isHorizontalMapRepetitionEnabled = false
false isVerticalMapRepetitionEnabled = false
isVerticalMapRepetitionEnabled =
false
setHasTransientState(true) setHasTransientState(true)
setOnTouchListener { v, event -> setOnTouchListener { v, event ->
v.parent?.requestDisallowInterceptTouchEvent( v.parent?.requestDisallowInterceptTouchEvent(true)
true
)
false false
} }
controller.setZoom(10.0) controller.setZoom(10.0)
@@ -431,12 +299,8 @@ fun HistoryScreen(
this.setUseDataConnection(true) this.setUseDataConnection(true)
try { try {
val railwayTileSource = val railwayTileSource = XYTileSource(
XYTileSource( "OpenRailwayMap", 8, 16, 256, ".png",
"OpenRailwayMap",
8, 16,
256,
".png",
arrayOf( arrayOf(
"https://a.tiles.openrailwaymap.org/standard/", "https://a.tiles.openrailwaymap.org/standard/",
"https://b.tiles.openrailwaymap.org/standard/", "https://b.tiles.openrailwaymap.org/standard/",
@@ -445,44 +309,26 @@ fun HistoryScreen(
"© OpenRailwayMap contributors, © OpenStreetMap contributors" "© OpenRailwayMap contributors, © OpenStreetMap contributors"
) )
val railwayProvider = val railwayProvider = MapTileProviderBasic(context)
MapTileProviderBasic(context) railwayProvider.tileSource = railwayTileSource
railwayProvider.tileSource =
railwayTileSource
val railwayOverlay = val railwayOverlay = TilesOverlay(railwayProvider, context)
TilesOverlay( railwayOverlay.loadingBackgroundColor = android.graphics.Color.TRANSPARENT
railwayProvider, railwayOverlay.loadingLineColor = android.graphics.Color.TRANSPARENT
context
)
railwayOverlay.loadingBackgroundColor =
android.graphics.Color.TRANSPARENT
railwayOverlay.loadingLineColor =
android.graphics.Color.TRANSPARENT
overlays.add(railwayOverlay) overlays.add(railwayOverlay)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }
try { try {
val locationProvider = val locationProvider = GpsMyLocationProvider(context).apply {
GpsMyLocationProvider( locationUpdateMinDistance = 10f
context locationUpdateMinTime = 1000
).apply {
locationUpdateMinDistance =
10f
locationUpdateMinTime =
1000
} }
MyLocationNewOverlay( MyLocationNewOverlay(locationProvider, this).apply {
locationProvider,
this
).apply {
enableMyLocation() enableMyLocation()
}.also { overlays.add(it) } }.also { overlays.add(it) }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
@@ -491,34 +337,18 @@ fun HistoryScreen(
val marker = Marker(this) val marker = Marker(this)
marker.position = coordinates marker.position = coordinates
val latStr = String.format( val latStr = String.format("%.4f", coordinates.latitude)
"%.4f", val lonStr = String.format("%.4f", coordinates.longitude)
coordinates.latitude val coordStr = "${latStr}°N, ${lonStr}°E"
) marker.title = recordMap["train"]?.toString() ?: "列车"
val lonStr = String.format(
"%.4f",
coordinates.longitude
)
val coordStr =
"${latStr}°N, ${lonStr}°E"
marker.title =
recordMap["train"]?.toString()
?: "列车"
marker.snippet = coordStr marker.snippet = coordStr
marker.setInfoWindowAnchor(Marker.ANCHOR_CENTER, 0f)
marker.setInfoWindowAnchor(
Marker.ANCHOR_CENTER,
0f
)
overlays.add(marker) overlays.add(marker)
marker.showInfoWindow() marker.showInfoWindow()
} }
}, },
update = { mapView -> update = { mapView -> mapView.invalidate() }
mapView.invalidate()
}
) )
} }
} }
@@ -534,6 +364,641 @@ fun HistoryScreen(
} }
} }
} }
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MergedTrainRecordItem(
mergedRecord: MergedTrainRecord,
expandedStatesMap: MutableMap<String, Boolean>,
locoInfoUtil: LocoInfoUtil?,
mergeSettings: MergeSettings? = null,
isInEditMode: Boolean = false,
selectedRecords: List<TrainRecord> = emptyList(),
onToggleSelection: (TrainRecord) -> Unit = {},
onLongClick: (TrainRecord) -> Unit = {}
) {
val recordId = mergedRecord.groupKey
val isExpanded = expandedStatesMap[recordId] == true
val latestRecord = mergedRecord.latestRecord
val hasSelectedRecords = mergedRecord.records.any { selectedRecords.contains(it) }
val cardColor = when {
hasSelectedRecords -> MaterialTheme.colorScheme.primaryContainer
else -> MaterialTheme.colorScheme.surface
}
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
colors = CardDefaults.cardColors(
containerColor = cardColor
),
shape = RoundedCornerShape(8.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = {
if (isInEditMode) {
if (hasSelectedRecords) {
mergedRecord.records.forEach { record ->
if (selectedRecords.contains(record)) {
onToggleSelection(record)
}
}
} else {
mergedRecord.records.forEach { record ->
if (!selectedRecords.contains(record)) {
onToggleSelection(record)
}
}
}
} else {
expandedStatesMap[recordId] = !isExpanded
}
},
onLongClick = {
if (!isInEditMode) {
onLongClick(mergedRecord.records.first())
}
},
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = true)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
val recordMap = latestRecord.toMap(showDetailedTime = true)
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
)
}
}
}
Text(
text = "${latestRecord.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 (latestRecord.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 {
latestRecord.locoType.isNotEmpty() && latestRecord.loco.isNotEmpty() -> {
val shortLoco = if (latestRecord.loco.length > 5) {
latestRecord.loco.takeLast(5)
} else {
latestRecord.loco
}
"${latestRecord.locoType}-${shortLoco}"
}
latestRecord.locoType.isNotEmpty() -> latestRecord.locoType
latestRecord.loco.isNotEmpty() -> latestRecord.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 = latestRecord.route.trim()
val isValidRoute = routeStr.isNotEmpty() && !routeStr.all { it == '*' }
val position = latestRecord.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 = latestRecord.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 && latestRecord.locoType.isNotEmpty() && latestRecord.loco.isNotEmpty()) {
val locoInfoText = locoInfoUtil.getLocoInfoDisplay(
latestRecord.locoType,
latestRecord.loco
)
if (locoInfoText != null) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = locoInfoText,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
}
if (isExpanded) {
val coordinates = remember { latestRecord.getCoordinates() }
if (coordinates != null) {
Spacer(modifier = Modifier.height(8.dp))
}
if (coordinates != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.padding(vertical = 4.dp)
.clip(RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
AndroidView(
modifier = Modifier.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {},
factory = { context ->
MapView(context).apply {
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(true)
zoomController.setVisibility(org.osmdroid.views.CustomZoomButtonsController.Visibility.NEVER)
isHorizontalMapRepetitionEnabled = false
isVerticalMapRepetitionEnabled = false
setHasTransientState(true)
setOnTouchListener { v, event ->
v.parent?.requestDisallowInterceptTouchEvent(true)
false
}
controller.setZoom(10.0)
controller.setCenter(coordinates)
this.isTilesScaledToDpi = true
this.setUseDataConnection(true)
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)
railwayOverlay.loadingBackgroundColor = android.graphics.Color.TRANSPARENT
railwayOverlay.loadingLineColor = android.graphics.Color.TRANSPARENT
overlays.add(railwayOverlay)
} catch (e: Exception) {
e.printStackTrace()
}
try {
val locationProvider = GpsMyLocationProvider(context).apply {
locationUpdateMinDistance = 10f
locationUpdateMinTime = 1000
}
MyLocationNewOverlay(locationProvider, this).apply {
enableMyLocation()
}.also { overlays.add(it) }
} catch (e: Exception) {
e.printStackTrace()
}
val marker = Marker(this)
marker.position = coordinates
val latStr = String.format("%.4f", coordinates.latitude)
val lonStr = String.format("%.4f", coordinates.longitude)
val coordStr = "${latStr}°N, ${lonStr}°E"
marker.title = recordMap["train"]?.toString() ?: "列车"
marker.snippet = coordStr
marker.setInfoWindowAnchor(Marker.ANCHOR_CENTER, 0f)
overlays.add(marker)
marker.showInfoWindow()
}
},
update = { mapView -> mapView.invalidate() }
)
}
}
if (recordMap.containsKey("position_info")) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = recordMap["position_info"] ?: "",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider()
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)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = timeFormat.format(recordItem.timestamp),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
val extraInfo = when (mergeSettings?.groupBy) {
GroupBy.LOCO_ONLY -> {
if (recordItem.train.isNotEmpty() && recordItem.train != "<NUL>") {
recordItem.train
} else null
}
GroupBy.TRAIN_ONLY -> {
if (recordItem.loco.isNotEmpty() && recordItem.loco != "<NUL>") {
"${recordItem.locoType}-${recordItem.loco}"
} else null
}
else -> null
}
if (extraInfo != null) {
Text(
text = extraInfo,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
}
}
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
)
}
}
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun HistoryScreen(
records: List<Any>,
latestRecord: TrainRecord?,
lastUpdateTime: Date?,
temporaryStatusMessage: String? = null,
locoInfoUtil: LocoInfoUtil? = null,
mergeSettings: MergeSettings? = null,
onClearRecords: () -> Unit = {},
onRecordClick: (TrainRecord) -> Unit = {},
onClearLog: () -> Unit = {},
onDeleteRecords: (List<TrainRecord>) -> 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 = { _, _, _, _, _ -> }
) {
val refreshKey = latestRecord?.timestamp?.time ?: 0
var isInEditMode by remember(editMode) { mutableStateOf(editMode) }
val selectedRecordsList = remember(selectedRecords) {
mutableStateListOf<TrainRecord>().apply {
records.forEach { item ->
when (item) {
is TrainRecord -> {
if (selectedRecords.contains(item.timestamp.time.toString())) {
add(item)
}
}
is MergedTrainRecord -> {
item.records.forEach { record ->
if (selectedRecords.contains(record.timestamp.time.toString())) {
add(record)
}
}
}
}
}
}
}
val expandedStatesMap = remember(expandedStates) {
mutableStateMapOf<String, Boolean>().apply { putAll(expandedStates) }
}
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = scrollPosition,
initialFirstVisibleItemScrollOffset = scrollOffset
)
val timeSinceLastUpdate = remember { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = lastUpdateTime) {
if (lastUpdateTime != null) {
while (true) {
val now = Date()
val diffInSec = (now.time - lastUpdateTime.time) / 1000
timeSinceLastUpdate.value = when {
diffInSec < 60 -> "${diffInSec}秒前"
diffInSec < 3600 -> "${diffInSec / 60}分钟前"
else -> "${diffInSec / 3600}小时前"
}
val updateInterval = if (diffInSec < 60) 500L else if (diffInSec < 3600) 30000L else 300000L
delay(updateInterval)
}
}
}
val filteredRecords = remember(records, refreshKey) {
records
}
LaunchedEffect(isInEditMode, selectedRecordsList.size) {
val selectedIds = selectedRecordsList.map { it.timestamp.time.toString() }.toSet()
onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
}
LaunchedEffect(expandedStatesMap.toMap()) {
if (!isInEditMode) {
val selectedIds = selectedRecordsList.map { it.timestamp.time.toString() }.toSet()
onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
}
}
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
if (!isInEditMode) {
val selectedIds = selectedRecordsList.map { it.timestamp.time.toString() }.toSet()
onStateChange(isInEditMode, selectedIds, expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
}
}
LaunchedEffect(selectedRecordsList.size) {
if (selectedRecordsList.isEmpty() && isInEditMode) {
isInEditMode = false
onStateChange(false, emptySet(), 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 (filteredRecords.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"暂无列车信息",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.outline
)
if (lastUpdateTime != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
"上次接收数据: ${
SimpleDateFormat(
"HH:mm:ss",
Locale.getDefault()
).format(lastUpdateTime)
}",
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(filteredRecords) { item ->
when (item) {
is TrainRecord -> {
TrainRecordItem(
record = item,
isSelected = selectedRecordsList.contains(item),
isInEditMode = isInEditMode,
expandedStatesMap = expandedStatesMap,
latestRecord = latestRecord,
locoInfoUtil = locoInfoUtil,
onRecordClick = onRecordClick,
onToggleSelection = { record ->
if (selectedRecordsList.contains(record)) {
selectedRecordsList.remove(record)
} else {
selectedRecordsList.add(record)
}
},
onLongClick = { record ->
if (!isInEditMode) {
isInEditMode = true
selectedRecordsList.clear()
selectedRecordsList.add(record)
}
}
)
}
is MergedTrainRecord -> {
MergedTrainRecordItem(
mergedRecord = item,
expandedStatesMap = expandedStatesMap,
locoInfoUtil = locoInfoUtil,
mergeSettings = mergeSettings,
isInEditMode = isInEditMode,
selectedRecords = selectedRecordsList,
onToggleSelection = { record ->
if (selectedRecordsList.contains(record)) {
selectedRecordsList.remove(record)
} else {
selectedRecordsList.add(record)
}
},
onLongClick = { record ->
if (!isInEditMode) {
isInEditMode = true
selectedRecordsList.clear()
selectedRecordsList.add(record)
}
}
)
}
}
} }
} }
} }

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)
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
),
shape = RoundedCornerShape(16.dp)
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Default.Bluetooth,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
"蓝牙设备",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
OutlinedTextField( OutlinedTextField(
value = deviceName, value = deviceName,
onValueChange = onDeviceNameChange, onValueChange = onDeviceNameChange,
label = { Text("蓝牙设备名称") }, label = { Text("设备名称") },
modifier = Modifier.fillMaxWidth(), leadingIcon = {
Icon(
imageVector = Icons.Default.DeviceHub,
contentDescription = null
) )
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
}
}
Button( Card(
onClick = onApplySettings, modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth() colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
),
shape = RoundedCornerShape(16.dp)
) { ) {
Text("应用设置") 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
}
)
}
} }
} }
Spacer(modifier = Modifier.weight(1f)) 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,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable {
uriHandler.openUri("https://github.com/undef-i") uriHandler.openUri("https://github.com/undef-i")
} }
.padding(16.dp)
) )
} }
} }