feat: add record merging functionality and optimize settings page
This commit is contained in:
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -535,6 +365,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user