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.*
|
||||||
@@ -87,8 +90,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
private var historyScrollOffset by mutableStateOf(0)
|
private var historyScrollOffset by mutableStateOf(0)
|
||||||
private var mapCenterPosition by mutableStateOf<Pair<Double, Double>?>(null)
|
private var mapCenterPosition by mutableStateOf<Pair<Double, Double>?>(null)
|
||||||
private var mapZoomLevel by mutableStateOf(10.0)
|
private var mapZoomLevel by mutableStateOf(10.0)
|
||||||
private var mapRailwayLayerVisible by mutableStateOf(true)
|
private var mapRailwayLayerVisible by mutableStateOf(true)
|
||||||
|
|
||||||
|
private var settingsScrollPosition by mutableStateOf(0)
|
||||||
|
|
||||||
|
private var mergeSettings by mutableStateOf(MergeSettings())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private var targetDeviceName = "LBJReceiver"
|
private var targetDeviceName = "LBJReceiver"
|
||||||
|
|
||||||
@@ -236,7 +244,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
isConnected = bleClient.isConnected(),
|
isConnected = bleClient.isConnected(),
|
||||||
isScanning = isScanning,
|
isScanning = isScanning,
|
||||||
currentTab = currentTab,
|
currentTab = currentTab,
|
||||||
onTabChange = { tab ->
|
onTabChange = { tab ->
|
||||||
currentTab = tab
|
currentTab = tab
|
||||||
saveSettings()
|
saveSettings()
|
||||||
},
|
},
|
||||||
@@ -256,12 +264,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
allRecords = if (trainRecordManager.getFilteredRecords().isNotEmpty())
|
allRecords = trainRecordManager.getMixedRecords(),
|
||||||
trainRecordManager.getFilteredRecords() else trainRecordManager.getAllRecords(),
|
mergedRecords = trainRecordManager.getMergedRecords(),
|
||||||
recordCount = trainRecordManager.getRecordCount(),
|
recordCount = trainRecordManager.getRecordCount(),
|
||||||
filterTrain = filterTrain,
|
filterTrain = filterTrain,
|
||||||
filterRoute = filterRoute,
|
filterRoute = filterRoute,
|
||||||
filterDirection = filterDirection,
|
filterDirection = filterDirection,
|
||||||
|
mergeSettings = mergeSettings,
|
||||||
|
onMergeSettingsChange = { newSettings ->
|
||||||
|
mergeSettings = newSettings
|
||||||
|
trainRecordManager.updateMergeSettings(newSettings)
|
||||||
|
saveSettings()
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
historyEditMode = historyEditMode,
|
historyEditMode = historyEditMode,
|
||||||
@@ -279,6 +293,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
settingsScrollPosition = settingsScrollPosition,
|
||||||
|
onSettingsScrollPositionChange = { position ->
|
||||||
|
settingsScrollPosition = position
|
||||||
|
saveSettings()
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
mapCenterPosition = mapCenterPosition,
|
mapCenterPosition = mapCenterPosition,
|
||||||
mapZoomLevel = mapZoomLevel,
|
mapZoomLevel = mapZoomLevel,
|
||||||
mapRailwayLayerVisible = mapRailwayLayerVisible,
|
mapRailwayLayerVisible = mapRailwayLayerVisible,
|
||||||
@@ -332,7 +353,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
onApplySettings = {
|
onApplySettings = {
|
||||||
saveSettings()
|
saveSettings()
|
||||||
targetDeviceName = settingsDeviceName
|
targetDeviceName = settingsDeviceName
|
||||||
Toast.makeText(this, "设备名称 '${settingsDeviceName}' 已保存,下次连接时生效", Toast.LENGTH_LONG).show()
|
|
||||||
Log.d(TAG, "Applied settings deviceName=${settingsDeviceName}")
|
Log.d(TAG, "Applied settings deviceName=${settingsDeviceName}")
|
||||||
},
|
},
|
||||||
appVersion = getAppVersion(),
|
appVersion = getAppVersion(),
|
||||||
@@ -343,7 +363,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
ConnectionDialog(
|
ConnectionDialog(
|
||||||
isScanning = isScanning,
|
isScanning = isScanning,
|
||||||
devices = foundDevices,
|
devices = foundDevices,
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
showConnectionDialog = false
|
showConnectionDialog = false
|
||||||
stopScan()
|
stopScan()
|
||||||
},
|
},
|
||||||
@@ -360,6 +380,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -585,6 +607,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
historyScrollPosition = settingsPrefs.getInt("history_scroll_position", 0)
|
historyScrollPosition = settingsPrefs.getInt("history_scroll_position", 0)
|
||||||
historyScrollOffset = settingsPrefs.getInt("history_scroll_offset", 0)
|
historyScrollOffset = settingsPrefs.getInt("history_scroll_offset", 0)
|
||||||
|
settingsScrollPosition = settingsPrefs.getInt("settings_scroll_position", 0)
|
||||||
|
|
||||||
val centerLat = settingsPrefs.getFloat("map_center_lat", Float.NaN)
|
val centerLat = settingsPrefs.getFloat("map_center_lat", Float.NaN)
|
||||||
val centerLon = settingsPrefs.getFloat("map_center_lon", Float.NaN)
|
val centerLon = settingsPrefs.getFloat("map_center_lon", Float.NaN)
|
||||||
@@ -595,6 +618,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
mapZoomLevel = settingsPrefs.getFloat("map_zoom_level", 10.0f).toDouble()
|
mapZoomLevel = settingsPrefs.getFloat("map_zoom_level", 10.0f).toDouble()
|
||||||
mapRailwayLayerVisible = settingsPrefs.getBoolean("map_railway_visible", true)
|
mapRailwayLayerVisible = settingsPrefs.getBoolean("map_railway_visible", true)
|
||||||
|
|
||||||
|
mergeSettings = trainRecordManager.mergeSettings
|
||||||
|
|
||||||
Log.d(TAG, "Loaded settings deviceName=${settingsDeviceName} tab=${currentTab}")
|
Log.d(TAG, "Loaded settings deviceName=${settingsDeviceName} tab=${currentTab}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,6 +633,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
.putString("history_expanded_states", historyExpandedStates.map { "${it.key}:${it.value}" }.joinToString(";"))
|
.putString("history_expanded_states", historyExpandedStates.map { "${it.key}:${it.value}" }.joinToString(";"))
|
||||||
.putInt("history_scroll_position", historyScrollPosition)
|
.putInt("history_scroll_position", historyScrollPosition)
|
||||||
.putInt("history_scroll_offset", historyScrollOffset)
|
.putInt("history_scroll_offset", historyScrollOffset)
|
||||||
|
.putInt("settings_scroll_position", settingsScrollPosition)
|
||||||
.putFloat("map_zoom_level", mapZoomLevel.toFloat())
|
.putFloat("map_zoom_level", mapZoomLevel.toFloat())
|
||||||
.putBoolean("map_railway_visible", mapRailwayLayerVisible)
|
.putBoolean("map_railway_visible", mapRailwayLayerVisible)
|
||||||
|
|
||||||
@@ -656,11 +682,14 @@ fun MainContent(
|
|||||||
onClearMonitorLog: () -> Unit,
|
onClearMonitorLog: () -> Unit,
|
||||||
|
|
||||||
|
|
||||||
allRecords: List<TrainRecord>,
|
allRecords: List<Any>,
|
||||||
|
mergedRecords: List<org.noxylva.lbjconsole.model.MergedTrainRecord>,
|
||||||
recordCount: Int,
|
recordCount: Int,
|
||||||
filterTrain: String,
|
filterTrain: String,
|
||||||
filterRoute: String,
|
filterRoute: String,
|
||||||
filterDirection: String,
|
filterDirection: String,
|
||||||
|
mergeSettings: MergeSettings,
|
||||||
|
onMergeSettingsChange: (MergeSettings) -> Unit,
|
||||||
onFilterChange: (String, String, String) -> Unit,
|
onFilterChange: (String, String, String) -> Unit,
|
||||||
onClearFilter: () -> Unit,
|
onClearFilter: () -> Unit,
|
||||||
onClearRecords: () -> Unit,
|
onClearRecords: () -> Unit,
|
||||||
@@ -685,6 +714,10 @@ fun MainContent(
|
|||||||
onHistoryStateChange: (Boolean, Set<String>, Map<String, Boolean>, Int, Int) -> Unit,
|
onHistoryStateChange: (Boolean, Set<String>, Map<String, Boolean>, Int, Int) -> Unit,
|
||||||
|
|
||||||
|
|
||||||
|
settingsScrollPosition: Int,
|
||||||
|
onSettingsScrollPositionChange: (Int) -> Unit,
|
||||||
|
|
||||||
|
|
||||||
mapCenterPosition: Pair<Double, Double>?,
|
mapCenterPosition: Pair<Double, Double>?,
|
||||||
mapZoomLevel: Double,
|
mapZoomLevel: Double,
|
||||||
mapRailwayLayerVisible: Boolean,
|
mapRailwayLayerVisible: Boolean,
|
||||||
@@ -770,8 +803,22 @@ fun MainContent(
|
|||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (historySelectedRecords.isNotEmpty()) {
|
if (historySelectedRecords.isNotEmpty()) {
|
||||||
val recordsToDelete = allRecords.filter {
|
val recordsToDelete = mutableListOf<TrainRecord>()
|
||||||
historySelectedRecords.contains(it.timestamp.time.toString())
|
allRecords.forEach { item ->
|
||||||
|
when (item) {
|
||||||
|
is TrainRecord -> {
|
||||||
|
if (historySelectedRecords.contains(item.timestamp.time.toString())) {
|
||||||
|
recordsToDelete.add(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is org.noxylva.lbjconsole.model.MergedTrainRecord -> {
|
||||||
|
item.records.forEach { record ->
|
||||||
|
if (historySelectedRecords.contains(record.timestamp.time.toString())) {
|
||||||
|
recordsToDelete.add(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onDeleteRecords(recordsToDelete)
|
onDeleteRecords(recordsToDelete)
|
||||||
onHistoryStateChange(false, emptySet(), historyExpandedStates, historyScrollPosition, historyScrollOffset)
|
onHistoryStateChange(false, emptySet(), historyExpandedStates, historyScrollPosition, historyScrollOffset)
|
||||||
@@ -823,31 +870,42 @@ fun MainContent(
|
|||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
) {
|
) {
|
||||||
when (currentTab) {
|
when (currentTab) {
|
||||||
0 -> HistoryScreen(
|
0 -> {
|
||||||
records = allRecords,
|
HistoryScreen(
|
||||||
latestRecord = latestRecord,
|
records = allRecords,
|
||||||
lastUpdateTime = lastUpdateTime,
|
latestRecord = latestRecord,
|
||||||
temporaryStatusMessage = temporaryStatusMessage,
|
lastUpdateTime = lastUpdateTime,
|
||||||
locoInfoUtil = locoInfoUtil,
|
temporaryStatusMessage = temporaryStatusMessage,
|
||||||
onClearRecords = onClearRecords,
|
locoInfoUtil = locoInfoUtil,
|
||||||
onRecordClick = onRecordClick,
|
mergeSettings = mergeSettings,
|
||||||
onClearLog = onClearMonitorLog,
|
onClearRecords = onClearRecords,
|
||||||
onDeleteRecords = onDeleteRecords,
|
onRecordClick = onRecordClick,
|
||||||
editMode = historyEditMode,
|
onClearLog = onClearMonitorLog,
|
||||||
selectedRecords = historySelectedRecords,
|
onDeleteRecords = onDeleteRecords,
|
||||||
expandedStates = historyExpandedStates,
|
editMode = historyEditMode,
|
||||||
scrollPosition = historyScrollPosition,
|
selectedRecords = historySelectedRecords,
|
||||||
scrollOffset = historyScrollOffset,
|
expandedStates = historyExpandedStates,
|
||||||
onStateChange = onHistoryStateChange
|
scrollPosition = historyScrollPosition,
|
||||||
)
|
scrollOffset = historyScrollOffset,
|
||||||
|
onStateChange = onHistoryStateChange
|
||||||
|
)
|
||||||
|
}
|
||||||
2 -> SettingsScreen(
|
2 -> SettingsScreen(
|
||||||
deviceName = deviceName,
|
deviceName = deviceName,
|
||||||
onDeviceNameChange = onDeviceNameChange,
|
onDeviceNameChange = onDeviceNameChange,
|
||||||
onApplySettings = onApplySettings,
|
onApplySettings = onApplySettings,
|
||||||
appVersion = appVersion
|
appVersion = appVersion,
|
||||||
|
mergeSettings = mergeSettings,
|
||||||
|
onMergeSettingsChange = onMergeSettingsChange,
|
||||||
|
scrollPosition = settingsScrollPosition,
|
||||||
|
onScrollPositionChange = onSettingsScrollPositionChange
|
||||||
)
|
)
|
||||||
3 -> MapScreen(
|
3 -> MapScreen(
|
||||||
records = if (allRecords.isNotEmpty()) allRecords else recentRecords,
|
records = if (allRecords.isNotEmpty()) {
|
||||||
|
allRecords.filterIsInstance<TrainRecord>()
|
||||||
|
} else {
|
||||||
|
recentRecords
|
||||||
|
},
|
||||||
centerPosition = mapCenterPosition,
|
centerPosition = mapCenterPosition,
|
||||||
zoomLevel = mapZoomLevel,
|
zoomLevel = mapZoomLevel,
|
||||||
railwayLayerVisible = mapRailwayLayerVisible,
|
railwayLayerVisible = mapRailwayLayerVisible,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
) {
|
) {
|
||||||
Column(
|
Card(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
Column(
|
||||||
value = deviceName,
|
modifier = Modifier.padding(20.dp),
|
||||||
onValueChange = onDeviceNameChange,
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
label = { Text("蓝牙设备名称") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = onApplySettings,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
) {
|
||||||
Text("应用设置")
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Bluetooth,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"蓝牙设备",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = deviceName,
|
||||||
|
onValueChange = onDeviceNameChange,
|
||||||
|
label = { Text("设备名称") },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.DeviceHub,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.MergeType,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"记录合并",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"启用记录合并",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = mergeSettings.enabled,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
onMergeSettingsChange(mergeSettings.copy(enabled = enabled))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mergeSettings.enabled) {
|
||||||
|
var groupByExpanded by remember { mutableStateOf(false) }
|
||||||
|
var timeWindowExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = groupByExpanded,
|
||||||
|
onExpandedChange = { groupByExpanded = !groupByExpanded }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = mergeSettings.groupBy.displayName,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("分组方式") },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Group,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
ExposedDropdownMenuDefaults.TrailingIcon(expanded = groupByExpanded)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = groupByExpanded,
|
||||||
|
onDismissRequest = { groupByExpanded = false }
|
||||||
|
) {
|
||||||
|
GroupBy.values().forEach { groupBy ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(groupBy.displayName) },
|
||||||
|
onClick = {
|
||||||
|
onMergeSettingsChange(mergeSettings.copy(groupBy = groupBy))
|
||||||
|
groupByExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = timeWindowExpanded,
|
||||||
|
onExpandedChange = { timeWindowExpanded = !timeWindowExpanded }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = mergeSettings.timeWindow.displayName,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("时间窗口") },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Schedule,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
ExposedDropdownMenuDefaults.TrailingIcon(expanded = timeWindowExpanded)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = timeWindowExpanded,
|
||||||
|
onDismissRequest = { timeWindowExpanded = false }
|
||||||
|
) {
|
||||||
|
TimeWindow.values().forEach { timeWindow ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(timeWindow.displayName) },
|
||||||
|
onClick = {
|
||||||
|
onMergeSettingsChange(mergeSettings.copy(timeWindow = timeWindow))
|
||||||
|
timeWindowExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "LBJ Console v$appVersion by undef-i",
|
text = "LBJ Console v$appVersion by undef-i",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.clickable {
|
textAlign = TextAlign.Center,
|
||||||
uriHandler.openUri("https://github.com/undef-i")
|
modifier = Modifier
|
||||||
}
|
.fillMaxWidth()
|
||||||
)
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.clickable {
|
||||||
|
uriHandler.openUri("https://github.com/undef-i")
|
||||||
|
}
|
||||||
|
.padding(16.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user