feat: add timestamp logging for received messages, optimize page details

This commit is contained in:
Nedifinita
2025-07-18 23:53:55 +08:00
parent 936b960d6a
commit a60b8c58ff
9 changed files with 331 additions and 154 deletions

View File

@@ -3,12 +3,9 @@
LBJ Console is an Android app designed to receive and display LBJ messages via BLE from the [SX1276_Receive_LBJ](https://github.com/undef-i/SX1276_Receive_LBJ) device. LBJ Console is an Android app designed to receive and display LBJ messages via BLE from the [SX1276_Receive_LBJ](https://github.com/undef-i/SX1276_Receive_LBJ) device.
## Roadmap ## Roadmap
- Tab state persistence
- Record filtering (train number, time range) - Record filtering (train number, time range)
- Record management page optimization - Record management page optimization
- Optional train merge by locomotive/number - Optional train merge by locomotive/number
- Offline data storage
- Add record timestamps
# License # License

View File

@@ -12,8 +12,8 @@ android {
applicationId = "org.noxylva.lbjconsole" applicationId = "org.noxylva.lbjconsole"
minSdk = 29 minSdk = 29
targetSdk = 35 targetSdk = 35
versionCode = 2 versionCode = 3
versionName = "0.0.2" versionName = "0.0.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -72,7 +72,17 @@ class MainActivity : ComponentActivity() {
private var settingsDeviceName by mutableStateOf("LBJReceiver") private var settingsDeviceName by mutableStateOf("LBJReceiver")
private var temporaryStatusMessage by mutableStateOf<String?>(null) private var temporaryStatusMessage by mutableStateOf<String?>(null)
private var historyEditMode by mutableStateOf(false)
private var historySelectedRecords by mutableStateOf<Set<String>>(emptySet())
private var historyExpandedStates by mutableStateOf<Map<String, Boolean>>(emptyMap())
private var historyScrollPosition by mutableStateOf(0)
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 targetDeviceName = "LBJReceiver" private var targetDeviceName = "LBJReceiver"
@@ -205,6 +215,8 @@ class MainActivity : ComponentActivity() {
Log.e(TAG, "OSM cache config failed", e) Log.e(TAG, "OSM cache config failed", e)
} }
saveSettings()
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
LBJReceiverTheme { LBJReceiverTheme {
@@ -216,7 +228,10 @@ class MainActivity : ComponentActivity() {
isConnected = bleClient.isConnected(), isConnected = bleClient.isConnected(),
isScanning = isScanning, isScanning = isScanning,
currentTab = currentTab, currentTab = currentTab,
onTabChange = { tab -> currentTab = tab }, onTabChange = { tab ->
currentTab = tab
saveSettings()
},
onConnectClick = { showConnectionDialog = true }, onConnectClick = { showConnectionDialog = true },
@@ -239,6 +254,32 @@ class MainActivity : ComponentActivity() {
filterTrain = filterTrain, filterTrain = filterTrain,
filterRoute = filterRoute, filterRoute = filterRoute,
filterDirection = filterDirection, filterDirection = filterDirection,
historyEditMode = historyEditMode,
historySelectedRecords = historySelectedRecords,
historyExpandedStates = historyExpandedStates,
historyScrollPosition = historyScrollPosition,
historyScrollOffset = historyScrollOffset,
onHistoryStateChange = { editMode, selectedRecords, expandedStates, scrollPosition, scrollOffset ->
historyEditMode = editMode
historySelectedRecords = selectedRecords
historyExpandedStates = expandedStates
historyScrollPosition = scrollPosition
historyScrollOffset = scrollOffset
saveSettings()
},
mapCenterPosition = mapCenterPosition,
mapZoomLevel = mapZoomLevel,
mapRailwayLayerVisible = mapRailwayLayerVisible,
onMapStateChange = { centerPos, zoomLevel, railwayVisible ->
mapCenterPosition = centerPos
mapZoomLevel = zoomLevel
mapRailwayLayerVisible = railwayVisible
saveSettings()
},
onFilterChange = { train, route, direction -> onFilterChange = { train, route, direction ->
filterTrain = train filterTrain = train
filterRoute = route filterRoute = route
@@ -259,11 +300,7 @@ class MainActivity : ComponentActivity() {
temporaryStatusMessage = null temporaryStatusMessage = null
} }
}, },
onExportRecords = {
scope.launch {
exportRecordsToCSV()
}
},
onDeleteRecords = { records -> onDeleteRecords = { records ->
scope.launch { scope.launch {
val deletedCount = trainRecordManager.deleteRecords(records) val deletedCount = trainRecordManager.deleteRecords(records)
@@ -294,7 +331,6 @@ class MainActivity : ComponentActivity() {
locoInfoUtil = locoInfoUtil locoInfoUtil = locoInfoUtil
) )
// 显示连接对话框
if (showConnectionDialog) { if (showConnectionDialog) {
ConnectionDialog( ConnectionDialog(
isScanning = isScanning, isScanning = isScanning,
@@ -326,7 +362,6 @@ class MainActivity : ComponentActivity() {
deviceStatus = "正在连接..." deviceStatus = "正在连接..."
Log.d(TAG, "Connecting to device name=${device.name ?: "Unknown"} address=${device.address}") Log.d(TAG, "Connecting to device name=${device.name ?: "Unknown"} address=${device.address}")
// 检查蓝牙适配器状态
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val bluetoothAdapter = bluetoothManager.adapter val bluetoothAdapter = bluetoothManager.adapter
if (bluetoothAdapter == null || bluetoothAdapter.isEnabled != true) { if (bluetoothAdapter == null || bluetoothAdapter.isEnabled != true) {
@@ -397,30 +432,7 @@ class MainActivity : ComponentActivity() {
} }
private fun exportRecordsToCSV() {
val records = trainRecordManager.getFilteredRecords()
val file = trainRecordManager.exportToCsv(records)
if (file != null) {
try {
val uri = FileProvider.getUriForFile(
this,
"${applicationContext.packageName}.provider",
file
)
val intent = Intent(Intent.ACTION_SEND)
intent.type = "text/csv"
intent.putExtra(Intent.EXTRA_STREAM, uri)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
startActivity(Intent.createChooser(intent, "分享CSV文件"))
} catch (e: Exception) {
Log.e(TAG, "CSV export failed: ${e.message}")
Toast.makeText(this, "导出失败: ${e.message}", Toast.LENGTH_SHORT).show()
}
} else {
Toast.makeText(this, "导出CSV文件失败", Toast.LENGTH_SHORT).show()
}
}
private fun updateTemporaryStatusMessage(message: String) { private fun updateTemporaryStatusMessage(message: String) {
@@ -464,7 +476,6 @@ class MainActivity : ComponentActivity() {
stopScan() stopScan()
connectToDevice(device) connectToDevice(device)
} else { } else {
// 如果没有指定目标设备名称,或者找到的设备不是目标设备,显示连接对话框
if (targetDeviceName == null) { if (targetDeviceName == null) {
showConnectionDialog = true showConnectionDialog = true
} }
@@ -489,15 +500,69 @@ class MainActivity : ComponentActivity() {
private fun loadSettings() { private fun loadSettings() {
settingsDeviceName = settingsPrefs.getString("device_name", "LBJReceiver") ?: "LBJReceiver" settingsDeviceName = settingsPrefs.getString("device_name", "LBJReceiver") ?: "LBJReceiver"
targetDeviceName = settingsDeviceName targetDeviceName = settingsDeviceName
Log.d(TAG, "Loaded settings deviceName=${settingsDeviceName}")
currentTab = settingsPrefs.getInt("current_tab", 0)
historyEditMode = settingsPrefs.getBoolean("history_edit_mode", false)
val selectedRecordsStr = settingsPrefs.getString("history_selected_records", "")
historySelectedRecords = if (selectedRecordsStr.isNullOrEmpty()) {
emptySet()
} else {
selectedRecordsStr.split(",").toSet()
}
val expandedStatesStr = settingsPrefs.getString("history_expanded_states", "")
historyExpandedStates = if (expandedStatesStr.isNullOrEmpty()) {
emptyMap()
} else {
expandedStatesStr.split(";").mapNotNull { pair ->
val parts = pair.split(":")
if (parts.size == 2) parts[0] to (parts[1] == "true") else null
}.toMap()
}
historyScrollPosition = settingsPrefs.getInt("history_scroll_position", 0)
historyScrollOffset = settingsPrefs.getInt("history_scroll_offset", 0)
val centerLat = settingsPrefs.getFloat("map_center_lat", Float.NaN)
val centerLon = settingsPrefs.getFloat("map_center_lon", Float.NaN)
mapCenterPosition = if (!centerLat.isNaN() && !centerLon.isNaN()) {
centerLat.toDouble() to centerLon.toDouble()
} else null
mapZoomLevel = settingsPrefs.getFloat("map_zoom_level", 10.0f).toDouble()
mapRailwayLayerVisible = settingsPrefs.getBoolean("map_railway_visible", true)
Log.d(TAG, "Loaded settings deviceName=${settingsDeviceName} tab=${currentTab}")
} }
private fun saveSettings() { private fun saveSettings() {
settingsPrefs.edit() val editor = settingsPrefs.edit()
.putString("device_name", settingsDeviceName) .putString("device_name", settingsDeviceName)
.apply() .putInt("current_tab", currentTab)
Log.d(TAG, "Saved settings deviceName=${settingsDeviceName}") .putBoolean("history_edit_mode", historyEditMode)
.putString("history_selected_records", historySelectedRecords.joinToString(","))
.putString("history_expanded_states", historyExpandedStates.map { "${it.key}:${it.value}" }.joinToString(";"))
.putInt("history_scroll_position", historyScrollPosition)
.putInt("history_scroll_offset", historyScrollOffset)
.putFloat("map_zoom_level", mapZoomLevel.toFloat())
.putBoolean("map_railway_visible", mapRailwayLayerVisible)
mapCenterPosition?.let { (lat, lon) ->
editor.putFloat("map_center_lat", lat.toFloat())
editor.putFloat("map_center_lon", lon.toFloat())
}
editor.apply()
Log.d(TAG, "Saved settings deviceName=${settingsDeviceName} tab=${currentTab} mapCenter=${mapCenterPosition} zoom=${mapZoomLevel}")
}
override fun onPause() {
super.onPause()
saveSettings()
Log.d(TAG, "App paused, settings saved")
} }
} }
@@ -528,7 +593,7 @@ fun MainContent(
onFilterChange: (String, String, String) -> Unit, onFilterChange: (String, String, String) -> Unit,
onClearFilter: () -> Unit, onClearFilter: () -> Unit,
onClearRecords: () -> Unit, onClearRecords: () -> Unit,
onExportRecords: () -> Unit,
onDeleteRecords: (List<TrainRecord>) -> Unit, onDeleteRecords: (List<TrainRecord>) -> Unit,
@@ -538,7 +603,21 @@ fun MainContent(
appVersion: String, appVersion: String,
locoInfoUtil: LocoInfoUtil locoInfoUtil: LocoInfoUtil,
historyEditMode: Boolean,
historySelectedRecords: Set<String>,
historyExpandedStates: Map<String, Boolean>,
historyScrollPosition: Int,
historyScrollOffset: Int,
onHistoryStateChange: (Boolean, Set<String>, Map<String, Boolean>, Int, Int) -> Unit,
mapCenterPosition: Pair<Double, Double>?,
mapZoomLevel: Double,
mapRailwayLayerVisible: Boolean,
onMapStateChange: (Pair<Double, Double>?, Double, Boolean) -> Unit
) { ) {
val statusColor = if (isConnected) Color(0xFF4CAF50) else Color(0xFFFF5722) val statusColor = if (isConnected) Color(0xFF4CAF50) else Color(0xFFFF5722)
@@ -633,10 +712,16 @@ fun MainContent(
temporaryStatusMessage = temporaryStatusMessage, temporaryStatusMessage = temporaryStatusMessage,
locoInfoUtil = locoInfoUtil, locoInfoUtil = locoInfoUtil,
onClearRecords = onClearRecords, onClearRecords = onClearRecords,
onExportRecords = onExportRecords,
onRecordClick = onRecordClick, onRecordClick = onRecordClick,
onClearLog = onClearMonitorLog, onClearLog = onClearMonitorLog,
onDeleteRecords = onDeleteRecords onDeleteRecords = onDeleteRecords,
editMode = historyEditMode,
selectedRecords = historySelectedRecords,
expandedStates = historyExpandedStates,
scrollPosition = historyScrollPosition,
scrollOffset = historyScrollOffset,
onStateChange = onHistoryStateChange
) )
2 -> SettingsScreen( 2 -> SettingsScreen(
deviceName = deviceName, deviceName = deviceName,
@@ -646,7 +731,10 @@ fun MainContent(
) )
3 -> MapScreen( 3 -> MapScreen(
records = if (allRecords.isNotEmpty()) allRecords else recentRecords, records = if (allRecords.isNotEmpty()) allRecords else recentRecords,
onCenterMap = {} centerPosition = mapCenterPosition,
zoomLevel = mapZoomLevel,
railwayLayerVisible = mapRailwayLayerVisible,
onStateChange = onMapStateChange
) )
} }
} }

View File

@@ -12,6 +12,7 @@ class TrainRecord(jsonData: JSONObject? = null) {
} }
var timestamp: Date = Date() var timestamp: Date = Date()
var receivedTimestamp: Date = Date()
var train: String = "" var train: String = ""
var direction: Int = 0 var direction: Int = 0
var speed: String = "" var speed: String = ""
@@ -34,6 +35,17 @@ class TrainRecord(jsonData: JSONObject? = null) {
timestamp = Date(jsonData.getLong("timestamp")) timestamp = Date(jsonData.getLong("timestamp"))
} }
if (jsonData.has("receivedTimestamp")) {
receivedTimestamp = Date(jsonData.getLong("receivedTimestamp"))
} else {
receivedTimestamp = if (jsonData.has("timestamp")) {
Date(jsonData.getLong("timestamp"))
} else {
Date()
}
}
updateFromJson(it) updateFromJson(it)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to initialize TrainRecord from JSON: ${e.message}") Log.e(TAG, "Failed to initialize TrainRecord from JSON: ${e.message}")
@@ -96,7 +108,7 @@ class TrainRecord(jsonData: JSONObject? = null) {
!trimmed.all { it == '*' } !trimmed.all { it == '*' }
} }
fun toMap(): Map<String, String> { fun toMap(showDetailedTime: Boolean = false): Map<String, String> {
val directionText = when (direction) { val directionText = when (direction) {
1 -> "下行" 1 -> "下行"
3 -> "上行" 3 -> "上行"
@@ -114,12 +126,32 @@ class TrainRecord(jsonData: JSONObject? = null) {
val map = mutableMapOf<String, String>() val map = mutableMapOf<String, String>()
val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault())
map["timestamp"] = dateFormat.format(timestamp)
map["receivedTimestamp"] = dateFormat.format(receivedTimestamp)
if (trainDisplay.isNotEmpty()) map["train"] = trainDisplay if (trainDisplay.isNotEmpty()) map["train"] = trainDisplay
if (directionText != "未知") map["direction"] = directionText if (directionText != "未知") map["direction"] = directionText
if (isValidValue(speed)) map["speed"] = "速度: ${speed.trim()} km/h" if (isValidValue(speed)) map["speed"] = "速度: ${speed.trim()} km/h"
if (isValidValue(position)) map["position"] = "位置: ${position.trim()} km" if (isValidValue(position)) map["position"] = "位置: ${position.trim()} km"
if (isValidValue(time)) map["time"] = "列车时间: ${time.trim()}" val timeToDisplay = if (showDetailedTime) {
val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault())
if (isValidValue(time)) {
"列车时间: $time\n接收时间: ${dateFormat.format(receivedTimestamp)}"
} else {
dateFormat.format(receivedTimestamp)
}
} else {
val currentTime = System.currentTimeMillis()
val diffInSec = (currentTime - receivedTimestamp.time) / 1000
when {
diffInSec < 60 -> "${diffInSec}秒前"
diffInSec < 3600 -> "${diffInSec / 60}分钟前"
else -> "${diffInSec / 3600}小时前"
}
}
map["time"] = timeToDisplay
if (isValidValue(loco)) map["loco"] = "机车号: ${loco.trim()}" if (isValidValue(loco)) map["loco"] = "机车号: ${loco.trim()}"
if (isValidValue(locoType)) map["loco_type"] = "型号: ${locoType.trim()}" if (isValidValue(locoType)) map["loco_type"] = "型号: ${locoType.trim()}"
if (isValidValue(route)) map["route"] = "线路: ${route.trim()}" if (isValidValue(route)) map["route"] = "线路: ${route.trim()}"
@@ -135,6 +167,7 @@ class TrainRecord(jsonData: JSONObject? = null) {
fun toJSON(): JSONObject { fun toJSON(): JSONObject {
val json = JSONObject() val json = JSONObject()
json.put("timestamp", timestamp.time) json.put("timestamp", timestamp.time)
json.put("receivedTimestamp", receivedTimestamp.time)
json.put("train", train) json.put("train", train)
json.put("dir", direction) json.put("dir", direction)
json.put("speed", speed) json.put("speed", speed)

View File

@@ -38,6 +38,7 @@ class TrainRecordManager(private val context: Context) {
fun addRecord(jsonData: JSONObject): TrainRecord { fun addRecord(jsonData: JSONObject): TrainRecord {
val record = TrainRecord(jsonData) val record = TrainRecord(jsonData)
record.receivedTimestamp = Date()
trainRecords.add(0, record) trainRecords.add(0, record)
@@ -170,44 +171,7 @@ class TrainRecordManager(private val context: Context) {
} }
} }
fun exportToCsv(records: List<TrainRecord>): File? {
try {
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val fileName = "train_records_$timeStamp.csv"
val downloadsDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
val file = File(downloadsDir, fileName)
FileWriter(file).use { writer ->
writer.append("时间戳,列车号,列车类型,方向,速度,位置,时间,机车号,机车类型,路线,位置信息,信号强度\n")
for (record in records) {
val map = record.toMap()
writer.append(map["timestamp"]).append(",")
writer.append(map["train"]).append(",")
writer.append(map["lbj_class"]).append(",")
writer.append(map["direction"]).append(",")
writer.append(map["speed"]?.replace(" km/h", "") ?: "").append(",")
writer.append(map["position"]?.replace(" km", "") ?: "").append(",")
writer.append(map["time"]).append(",")
writer.append(map["loco"]).append(",")
writer.append(map["loco_type"]).append(",")
writer.append(map["route"]).append(",")
writer.append(map["position_info"]).append(",")
writer.append(map["rssi"]?.replace(" dBm", "") ?: "").append("\n")
}
}
return file
} catch (e: Exception) {
Log.e(TAG, "Error exporting to CSV: ${e.message}")
return null
}
}
fun getRecordCount(): Int { fun getRecordCount(): Int {

View File

@@ -68,7 +68,19 @@ fun TrainInfoCard(
} }
Text( Text(
text = recordMap["timestamp"]?.toString()?.split(" ")?.getOrNull(1) ?: "", text = run {
val trainTime = trainRecord.time.trim()
if (trainTime.isNotEmpty() && trainTime != "NUL" && trainTime != "<NUL>" && trainTime != "NA" && trainTime != "<NA>") {
trainTime
} else {
val receivedTime = recordMap["receivedTimestamp"]?.toString() ?: ""
if (receivedTime.contains(" ")) {
receivedTime.split(" ")[1]
} else {
java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault()).format(trainRecord.receivedTimestamp)
}
}
},
fontSize = 12.sp, fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -138,4 +150,4 @@ private fun CompactInfoItem(
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
} }
} }

View File

@@ -140,7 +140,6 @@ fun TrainRecordsListWithToolbar(
records: List<TrainRecord>, records: List<TrainRecord>,
onRecordClick: (TrainRecord) -> Unit, onRecordClick: (TrainRecord) -> Unit,
onFilterClick: () -> Unit, onFilterClick: () -> Unit,
onExportClick: () -> Unit,
onClearClick: () -> Unit, onClearClick: () -> Unit,
onDeleteRecords: (List<TrainRecord>) -> Unit, onDeleteRecords: (List<TrainRecord>) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@@ -198,12 +197,6 @@ fun TrainRecordsListWithToolbar(
contentDescription = "筛选" contentDescription = "筛选"
) )
} }
IconButton(onClick = onExportClick) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "导出"
)
}
} }
} }
} }

View File

@@ -7,7 +7,9 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -44,18 +46,33 @@ fun HistoryScreen(
temporaryStatusMessage: String? = null, temporaryStatusMessage: String? = null,
locoInfoUtil: LocoInfoUtil? = null, locoInfoUtil: LocoInfoUtil? = null,
onClearRecords: () -> Unit = {}, onClearRecords: () -> Unit = {},
onExportRecords: () -> Unit = {},
onRecordClick: (TrainRecord) -> Unit = {}, onRecordClick: (TrainRecord) -> Unit = {},
onClearLog: () -> Unit = {}, onClearLog: () -> Unit = {},
onDeleteRecords: (List<TrainRecord>) -> 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 val refreshKey = latestRecord?.timestamp?.time ?: 0
var isInEditMode by remember { mutableStateOf(false) } var isInEditMode by remember(editMode) { mutableStateOf(editMode) }
val selectedRecords = remember { mutableStateListOf<TrainRecord>() } val selectedRecordsList = remember(selectedRecords) {
mutableStateListOf<TrainRecord>().apply {
val expandedStates = remember { mutableStateMapOf<String, Boolean>() } 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) } val timeSinceLastUpdate = remember { mutableStateOf<String?>(null) }
@@ -79,11 +96,32 @@ fun HistoryScreen(
fun exitEditMode() { fun exitEditMode() {
isInEditMode = false isInEditMode = false
selectedRecords.clear() selectedRecordsList.clear()
onStateChange(false, emptySet(), expandedStatesMap.toMap(), listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
} }
LaunchedEffect(selectedRecords.size) { LaunchedEffect(isInEditMode, selectedRecordsList.size) {
if (selectedRecords.isEmpty() && isInEditMode) { 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) {
delay(300)
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) {
exitEditMode() exitEditMode()
} }
} }
@@ -126,11 +164,12 @@ fun HistoryScreen(
} }
} else { } else {
LazyColumn( LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
items(filteredRecords) { record -> items(filteredRecords) { record ->
val isSelected = selectedRecords.contains(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
@@ -151,14 +190,14 @@ fun HistoryScreen(
onClick = { onClick = {
if (isInEditMode) { if (isInEditMode) {
if (isSelected) { if (isSelected) {
selectedRecords.remove(record) selectedRecordsList.remove(record)
} else { } else {
selectedRecords.add(record) selectedRecordsList.add(record)
} }
} else { } else {
val id = record.timestamp.time.toString() val id = record.timestamp.time.toString()
expandedStates[id] = expandedStatesMap[id] =
!(expandedStates[id] ?: false) !(expandedStatesMap[id] ?: false)
if (record == latestRecord) { if (record == latestRecord) {
onRecordClick(record) onRecordClick(record)
} }
@@ -167,8 +206,8 @@ fun HistoryScreen(
onLongClick = { onLongClick = {
if (!isInEditMode) { if (!isInEditMode) {
isInEditMode = true isInEditMode = true
selectedRecords.clear() selectedRecordsList.clear()
selectedRecords.add(record) selectedRecordsList.add(record)
} }
}, },
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
@@ -180,7 +219,9 @@ fun HistoryScreen(
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp) .padding(16.dp)
) { ) {
val recordMap = record.toMap() val recordId = record.timestamp.time.toString()
val isExpanded = expandedStatesMap[recordId] == true
val recordMap = record.toMap(showDetailedTime = isExpanded)
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -266,7 +307,7 @@ fun HistoryScreen(
recordMap["time"]?.split("\n")?.forEach { timeLine -> recordMap["time"]?.split("\n")?.forEach { timeLine ->
Text( Text(
text = timeLine, text = timeLine,
fontSize = 12.sp, fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
@@ -342,8 +383,7 @@ fun HistoryScreen(
} }
} }
val recordId = record.timestamp.time.toString() if (isExpanded) {
if (expandedStates[recordId] == true) {
val coordinates = remember { record.getCoordinates() } val coordinates = remember { record.getCoordinates() }
if (coordinates != null) { if (coordinates != null) {
@@ -527,15 +567,15 @@ fun HistoryScreen(
) )
} }
Text( Text(
"已选择 ${selectedRecords.size} 条记录", "已选择 ${selectedRecordsList.size} 条记录",
color = MaterialTheme.colorScheme.onPrimary color = MaterialTheme.colorScheme.onPrimary
) )
} }
IconButton( IconButton(
onClick = { onClick = {
if (selectedRecords.isNotEmpty()) { if (selectedRecordsList.isNotEmpty()) {
onDeleteRecords(selectedRecords.toList()) onDeleteRecords(selectedRecordsList.toList())
exitEditMode() exitEditMode()
} }
} }

View File

@@ -33,6 +33,9 @@ import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.* import org.osmdroid.views.overlay.*
import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider
import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
import org.osmdroid.events.MapListener
import org.osmdroid.events.ScrollEvent
import org.osmdroid.events.ZoomEvent
import org.noxylva.lbjconsole.model.TrainRecord import org.noxylva.lbjconsole.model.TrainRecord
import java.io.File import java.io.File
@@ -41,7 +44,11 @@ import java.io.File
fun MapScreen( fun MapScreen(
records: List<TrainRecord>, records: List<TrainRecord>,
onCenterMap: () -> Unit = {}, onCenterMap: () -> Unit = {},
onLocationError: (String) -> Unit = {} onLocationError: (String) -> Unit = {},
centerPosition: Pair<Double, Double>? = null,
zoomLevel: Double = 10.0,
railwayLayerVisible: Boolean = true,
onStateChange: (Pair<Double, Double>?, Double, Boolean) -> Unit = { _, _, _ -> }
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
@@ -90,7 +97,7 @@ fun MapScreen(
var selectedRecord by remember { mutableStateOf<TrainRecord?>(null) } var selectedRecord by remember { mutableStateOf<TrainRecord?>(null) }
var dialogPosition by remember { mutableStateOf<GeoPoint?>(null) } var dialogPosition by remember { mutableStateOf<GeoPoint?>(null) }
var railwayLayerVisible by remember { mutableStateOf(true) } var railwayLayerVisibleState by remember(railwayLayerVisible) { mutableStateOf(railwayLayerVisible) }
DisposableEffect(lifecycleOwner) { DisposableEffect(lifecycleOwner) {
@@ -277,14 +284,21 @@ fun MapScreen(
} }
if (validRecords.isNotEmpty()) { centerPosition?.let { (lat, lon) ->
validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint -> controller.setCenter(GeoPoint(lat, lon))
controller.setCenter(lastPoint) controller.setZoom(zoomLevel)
controller.setZoom(12.0) isMapInitialized = true
Log.d("MapScreen", "Map initialized with saved state: lat=$lat, lon=$lon, zoom=$zoomLevel")
} ?: run {
if (validRecords.isNotEmpty()) {
validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint ->
controller.setCenter(lastPoint)
controller.setZoom(12.0)
}
} else {
controller.setCenter(defaultPosition)
controller.setZoom(10.0)
} }
} else {
controller.setCenter(defaultPosition)
controller.setZoom(10.0)
} }
@@ -304,30 +318,30 @@ fun MapScreen(
myLocation?.let { location -> myLocation?.let { location ->
currentLocation = GeoPoint(location.latitude, location.longitude) currentLocation = GeoPoint(location.latitude, location.longitude)
if (!isMapInitialized) { if (!isMapInitialized && centerPosition == null) {
controller.setCenter(location) controller.setCenter(location)
controller.setZoom(15.0) controller.setZoom(15.0)
isMapInitialized = true isMapInitialized = true
Log.d("MapScreen", "Map initialized with GPS position: $location") Log.d("MapScreen", "Map initialized with GPS position: $location")
} }
} ?: run { } ?: run {
if (!isMapInitialized) { if (!isMapInitialized && centerPosition == null) {
if (validRecords.isNotEmpty()) { if (validRecords.isNotEmpty()) {
validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint -> validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint ->
controller.setCenter(lastPoint) controller.setCenter(lastPoint)
controller.setZoom(12.0) controller.setZoom(12.0)
isMapInitialized = true isMapInitialized = true
Log.d("MapScreen", "Map initialized with last record position: $lastPoint") Log.d("MapScreen", "Map initialized with last record position: $lastPoint")
} }
} else { } else {
controller.setCenter(defaultPosition) controller.setCenter(defaultPosition)
isMapInitialized = true isMapInitialized = true
} }
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
if (!isMapInitialized) { if (!isMapInitialized && centerPosition == null) {
if (validRecords.isNotEmpty()) { if (validRecords.isNotEmpty()) {
validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint -> validRecords.lastOrNull()?.getCoordinates()?.let { lastPoint ->
controller.setCenter(lastPoint) controller.setCenter(lastPoint)
@@ -357,6 +371,31 @@ fun MapScreen(
setAlignBottom(true) setAlignBottom(true)
setLineWidth(2.0f) setLineWidth(2.0f)
}.also { overlays.add(it) } }.also { overlays.add(it) }
addMapListener(object : MapListener {
override fun onScroll(event: ScrollEvent?): Boolean {
val center = mapCenter
val zoom = zoomLevelDouble
onStateChange(
center.latitude to center.longitude,
zoom,
railwayLayerVisibleState
)
return true
}
override fun onZoom(event: ZoomEvent?): Boolean {
val center = mapCenter
val zoom = zoomLevelDouble
onStateChange(
center.latitude to center.longitude,
zoom,
railwayLayerVisibleState
)
return true
}
})
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
onLocationError("Map component initialization failed: ${e.localizedMessage}") onLocationError("Map component initialization failed: ${e.localizedMessage}")
@@ -381,7 +420,7 @@ fun MapScreen(
coroutineScope.launch { coroutineScope.launch {
updateMarkers() updateMarkers()
updateRailwayLayerVisibility(railwayLayerVisible) updateRailwayLayerVisibility(railwayLayerVisibleState)
} }
} }
) )
@@ -430,15 +469,26 @@ fun MapScreen(
FloatingActionButton( FloatingActionButton(
onClick = { onClick = {
railwayLayerVisible = !railwayLayerVisible railwayLayerVisibleState = !railwayLayerVisibleState
updateRailwayLayerVisibility(railwayLayerVisible) updateRailwayLayerVisibility(railwayLayerVisibleState)
mapViewRef.value?.let { mapView ->
val center = mapView.mapCenter
val zoom = mapView.zoomLevelDouble
onStateChange(
center.latitude to center.longitude,
zoom,
railwayLayerVisibleState
)
}
}, },
modifier = Modifier.size(40.dp), modifier = Modifier.size(40.dp),
containerColor = if (railwayLayerVisible) containerColor = if (railwayLayerVisibleState)
MaterialTheme.colorScheme.primary.copy(alpha = 0.9f) MaterialTheme.colorScheme.primary.copy(alpha = 0.9f)
else else
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f), MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f),
contentColor = if (railwayLayerVisible) contentColor = if (railwayLayerVisibleState)
MaterialTheme.colorScheme.onPrimary MaterialTheme.colorScheme.onPrimary
else else
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer