From cbceab8771745489e37d6d8bd3a3d2a1e2e5a8a1 Mon Sep 17 00:00:00 2001 From: Nedifinita Date: Sat, 19 Jul 2025 21:07:11 +0800 Subject: [PATCH] feat: add record merging functionality and optimize settings page --- app/build.gradle.kts | 4 +- .../org/noxylva/lbjconsole/MainActivity.kt | 114 +- .../noxylva/lbjconsole/model/MergeSettings.kt | 43 + .../lbjconsole/model/MergedTrainRecord.kt | 20 + .../lbjconsole/model/TrainRecordManager.kt | 111 ++ .../lbjconsole/ui/screens/HistoryScreen.kt | 1195 ++++++++++++----- .../ui/screens/MergedHistoryScreen.kt | 490 +++++++ .../lbjconsole/ui/screens/SettingsScreen.kt | 238 +++- 8 files changed, 1795 insertions(+), 420 deletions(-) create mode 100644 app/src/main/java/org/noxylva/lbjconsole/model/MergeSettings.kt create mode 100644 app/src/main/java/org/noxylva/lbjconsole/model/MergedTrainRecord.kt create mode 100644 app/src/main/java/org/noxylva/lbjconsole/ui/screens/MergedHistoryScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 565fc72..c7ffb0b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "org.noxylva.lbjconsole" minSdk = 29 targetSdk = 35 - versionCode = 4 - versionName = "0.0.4" + versionCode = 5 + versionName = "0.0.5" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/org/noxylva/lbjconsole/MainActivity.kt b/app/src/main/java/org/noxylva/lbjconsole/MainActivity.kt index 8723704..8428544 100644 --- a/app/src/main/java/org/noxylva/lbjconsole/MainActivity.kt +++ b/app/src/main/java/org/noxylva/lbjconsole/MainActivity.kt @@ -42,9 +42,12 @@ import org.json.JSONObject import org.osmdroid.config.Configuration import org.noxylva.lbjconsole.model.TrainRecord import org.noxylva.lbjconsole.model.TrainRecordManager +import org.noxylva.lbjconsole.model.MergeSettings import org.noxylva.lbjconsole.ui.screens.HistoryScreen +import org.noxylva.lbjconsole.ui.screens.MergedHistoryScreen import org.noxylva.lbjconsole.ui.screens.MapScreen import org.noxylva.lbjconsole.ui.screens.SettingsScreen + import org.noxylva.lbjconsole.ui.theme.LBJReceiverTheme import org.noxylva.lbjconsole.util.LocoInfoUtil import java.util.* @@ -87,8 +90,13 @@ class MainActivity : ComponentActivity() { private var historyScrollOffset by mutableStateOf(0) private var mapCenterPosition by mutableStateOf?>(null) private var mapZoomLevel by mutableStateOf(10.0) - private var mapRailwayLayerVisible by mutableStateOf(true) + private var mapRailwayLayerVisible by mutableStateOf(true) + private var settingsScrollPosition by mutableStateOf(0) + + private var mergeSettings by mutableStateOf(MergeSettings()) + + private var targetDeviceName = "LBJReceiver" @@ -236,7 +244,7 @@ class MainActivity : ComponentActivity() { isConnected = bleClient.isConnected(), isScanning = isScanning, currentTab = currentTab, - onTabChange = { tab -> + onTabChange = { tab -> currentTab = tab saveSettings() }, @@ -256,12 +264,18 @@ class MainActivity : ComponentActivity() { }, - allRecords = if (trainRecordManager.getFilteredRecords().isNotEmpty()) - trainRecordManager.getFilteredRecords() else trainRecordManager.getAllRecords(), + allRecords = trainRecordManager.getMixedRecords(), + mergedRecords = trainRecordManager.getMergedRecords(), recordCount = trainRecordManager.getRecordCount(), filterTrain = filterTrain, filterRoute = filterRoute, filterDirection = filterDirection, + mergeSettings = mergeSettings, + onMergeSettingsChange = { newSettings -> + mergeSettings = newSettings + trainRecordManager.updateMergeSettings(newSettings) + saveSettings() + }, historyEditMode = historyEditMode, @@ -279,6 +293,13 @@ class MainActivity : ComponentActivity() { }, + settingsScrollPosition = settingsScrollPosition, + onSettingsScrollPositionChange = { position -> + settingsScrollPosition = position + saveSettings() + }, + + mapCenterPosition = mapCenterPosition, mapZoomLevel = mapZoomLevel, mapRailwayLayerVisible = mapRailwayLayerVisible, @@ -332,7 +353,6 @@ class MainActivity : ComponentActivity() { onApplySettings = { saveSettings() targetDeviceName = settingsDeviceName - Toast.makeText(this, "设备名称 '${settingsDeviceName}' 已保存,下次连接时生效", Toast.LENGTH_LONG).show() Log.d(TAG, "Applied settings deviceName=${settingsDeviceName}") }, appVersion = getAppVersion(), @@ -343,7 +363,7 @@ class MainActivity : ComponentActivity() { ConnectionDialog( isScanning = isScanning, devices = foundDevices, - onDismiss = { + onDismiss = { showConnectionDialog = false stopScan() }, @@ -360,6 +380,8 @@ class MainActivity : ComponentActivity() { } ) } + + } } } @@ -585,6 +607,7 @@ class MainActivity : ComponentActivity() { historyScrollPosition = settingsPrefs.getInt("history_scroll_position", 0) historyScrollOffset = settingsPrefs.getInt("history_scroll_offset", 0) + settingsScrollPosition = settingsPrefs.getInt("settings_scroll_position", 0) val centerLat = settingsPrefs.getFloat("map_center_lat", Float.NaN) val centerLon = settingsPrefs.getFloat("map_center_lon", Float.NaN) @@ -595,6 +618,8 @@ class MainActivity : ComponentActivity() { mapZoomLevel = settingsPrefs.getFloat("map_zoom_level", 10.0f).toDouble() mapRailwayLayerVisible = settingsPrefs.getBoolean("map_railway_visible", true) + mergeSettings = trainRecordManager.mergeSettings + Log.d(TAG, "Loaded settings deviceName=${settingsDeviceName} tab=${currentTab}") } @@ -608,6 +633,7 @@ class MainActivity : ComponentActivity() { .putString("history_expanded_states", historyExpandedStates.map { "${it.key}:${it.value}" }.joinToString(";")) .putInt("history_scroll_position", historyScrollPosition) .putInt("history_scroll_offset", historyScrollOffset) + .putInt("settings_scroll_position", settingsScrollPosition) .putFloat("map_zoom_level", mapZoomLevel.toFloat()) .putBoolean("map_railway_visible", mapRailwayLayerVisible) @@ -656,11 +682,14 @@ fun MainContent( onClearMonitorLog: () -> Unit, - allRecords: List, + allRecords: List, + mergedRecords: List, recordCount: Int, filterTrain: String, filterRoute: String, filterDirection: String, + mergeSettings: MergeSettings, + onMergeSettingsChange: (MergeSettings) -> Unit, onFilterChange: (String, String, String) -> Unit, onClearFilter: () -> Unit, onClearRecords: () -> Unit, @@ -685,6 +714,10 @@ fun MainContent( onHistoryStateChange: (Boolean, Set, Map, Int, Int) -> Unit, + settingsScrollPosition: Int, + onSettingsScrollPositionChange: (Int) -> Unit, + + mapCenterPosition: Pair?, mapZoomLevel: Double, mapRailwayLayerVisible: Boolean, @@ -770,8 +803,22 @@ fun MainContent( IconButton( onClick = { if (historySelectedRecords.isNotEmpty()) { - val recordsToDelete = allRecords.filter { - historySelectedRecords.contains(it.timestamp.time.toString()) + val recordsToDelete = mutableListOf() + allRecords.forEach { item -> + when (item) { + is TrainRecord -> { + if (historySelectedRecords.contains(item.timestamp.time.toString())) { + recordsToDelete.add(item) + } + } + is org.noxylva.lbjconsole.model.MergedTrainRecord -> { + item.records.forEach { record -> + if (historySelectedRecords.contains(record.timestamp.time.toString())) { + recordsToDelete.add(record) + } + } + } + } } onDeleteRecords(recordsToDelete) onHistoryStateChange(false, emptySet(), historyExpandedStates, historyScrollPosition, historyScrollOffset) @@ -823,31 +870,42 @@ fun MainContent( .padding(paddingValues) ) { when (currentTab) { - 0 -> HistoryScreen( - records = allRecords, - latestRecord = latestRecord, - lastUpdateTime = lastUpdateTime, - temporaryStatusMessage = temporaryStatusMessage, - locoInfoUtil = locoInfoUtil, - onClearRecords = onClearRecords, - onRecordClick = onRecordClick, - onClearLog = onClearMonitorLog, - onDeleteRecords = onDeleteRecords, - editMode = historyEditMode, - selectedRecords = historySelectedRecords, - expandedStates = historyExpandedStates, - scrollPosition = historyScrollPosition, - scrollOffset = historyScrollOffset, - onStateChange = onHistoryStateChange - ) + 0 -> { + HistoryScreen( + records = allRecords, + latestRecord = latestRecord, + lastUpdateTime = lastUpdateTime, + temporaryStatusMessage = temporaryStatusMessage, + locoInfoUtil = locoInfoUtil, + mergeSettings = mergeSettings, + onClearRecords = onClearRecords, + onRecordClick = onRecordClick, + onClearLog = onClearMonitorLog, + onDeleteRecords = onDeleteRecords, + editMode = historyEditMode, + selectedRecords = historySelectedRecords, + expandedStates = historyExpandedStates, + scrollPosition = historyScrollPosition, + scrollOffset = historyScrollOffset, + onStateChange = onHistoryStateChange + ) + } 2 -> SettingsScreen( deviceName = deviceName, onDeviceNameChange = onDeviceNameChange, onApplySettings = onApplySettings, - appVersion = appVersion + appVersion = appVersion, + mergeSettings = mergeSettings, + onMergeSettingsChange = onMergeSettingsChange, + scrollPosition = settingsScrollPosition, + onScrollPositionChange = onSettingsScrollPositionChange ) 3 -> MapScreen( - records = if (allRecords.isNotEmpty()) allRecords else recentRecords, + records = if (allRecords.isNotEmpty()) { + allRecords.filterIsInstance() + } else { + recentRecords + }, centerPosition = mapCenterPosition, zoomLevel = mapZoomLevel, railwayLayerVisible = mapRailwayLayerVisible, diff --git a/app/src/main/java/org/noxylva/lbjconsole/model/MergeSettings.kt b/app/src/main/java/org/noxylva/lbjconsole/model/MergeSettings.kt new file mode 100644 index 0000000..65d9d44 --- /dev/null +++ b/app/src/main/java/org/noxylva/lbjconsole/model/MergeSettings.kt @@ -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 != "" && + loco.isNotEmpty() && loco != "") { + "${train}_${loco}" + } else null + } + GroupBy.TRAIN_ONLY -> { + val train = record.train.trim() + if (train.isNotEmpty() && train != "") train else null + } + GroupBy.LOCO_ONLY -> { + val loco = record.loco.trim() + if (loco.isNotEmpty() && loco != "") loco else null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/noxylva/lbjconsole/model/MergedTrainRecord.kt b/app/src/main/java/org/noxylva/lbjconsole/model/MergedTrainRecord.kt new file mode 100644 index 0000000..6ce9fa1 --- /dev/null +++ b/app/src/main/java/org/noxylva/lbjconsole/model/MergedTrainRecord.kt @@ -0,0 +1,20 @@ +package org.noxylva.lbjconsole.model + +import java.util.* + +data class MergedTrainRecord( + val groupKey: String, + val records: List, + val latestRecord: TrainRecord +) { + val recordCount: Int get() = records.size + val timeSpan: Pair 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 != "" }.toSet() + + fun getUniquePositions() = records.map { it.position }.filter { it.isNotEmpty() && it != "" }.toSet() +} \ No newline at end of file diff --git a/app/src/main/java/org/noxylva/lbjconsole/model/TrainRecordManager.kt b/app/src/main/java/org/noxylva/lbjconsole/model/TrainRecordManager.kt index 4c0882e..23616d8 100644 --- a/app/src/main/java/org/noxylva/lbjconsole/model/TrainRecordManager.kt +++ b/app/src/main/java/org/noxylva/lbjconsole/model/TrainRecordManager.kt @@ -19,6 +19,7 @@ class TrainRecordManager(private val context: Context) { const val MAX_RECORDS = 1000 private const val PREFS_NAME = "train_records" private const val KEY_RECORDS = "records" + private const val KEY_MERGE_SETTINGS = "merge_settings" } @@ -26,8 +27,12 @@ class TrainRecordManager(private val context: Context) { private val recordCount = AtomicInteger(0) private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + var mergeSettings = MergeSettings() + private set + init { loadRecords() + loadMergeSettings() } @@ -177,4 +182,110 @@ class TrainRecordManager(private val context: Context) { fun getRecordCount(): Int { return recordCount.get() } + + fun updateMergeSettings(newSettings: MergeSettings) { + mergeSettings = newSettings + saveMergeSettings() + } + + + fun getMergedRecords(): List { + if (!mergeSettings.enabled) { + return emptyList() + } + + val records = getFilteredRecords() + return processRecordsForMerging(records, mergeSettings) + } + + fun getMixedRecords(): List { + 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() + 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, settings: MergeSettings): List { + val groupedRecords = mutableMapOf>() + 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() + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/noxylva/lbjconsole/ui/screens/HistoryScreen.kt b/app/src/main/java/org/noxylva/lbjconsole/ui/screens/HistoryScreen.kt index e7c0f0b..69844e0 100644 --- a/app/src/main/java/org/noxylva/lbjconsole/ui/screens/HistoryScreen.kt +++ b/app/src/main/java/org/noxylva/lbjconsole/ui/screens/HistoryScreen.kt @@ -33,18 +33,787 @@ import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay import org.osmdroid.views.overlay.TilesOverlay 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 java.text.SimpleDateFormat import java.util.* +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun TrainRecordItem( + record: TrainRecord, + isSelected: Boolean, + isInEditMode: Boolean, + expandedStatesMap: MutableMap, + latestRecord: TrainRecord?, + locoInfoUtil: LocoInfoUtil?, + onRecordClick: (TrainRecord) -> Unit, + onToggleSelection: (TrainRecord) -> Unit, + onLongClick: (TrainRecord) -> Unit +) { + val cardColor = when { + isSelected -> 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) { + onToggleSelection(record) + } else { + val id = record.timestamp.time.toString() + expandedStatesMap[id] = !(expandedStatesMap[id] ?: false) + if (record == latestRecord) { + onRecordClick(record) + } + } + }, + onLongClick = { onLongClick(record) }, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = true) + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + val recordId = record.timestamp.time.toString() + val isExpanded = expandedStatesMap[recordId] == true + val recordMap = record.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 = "${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 != "") { + 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 != "" + + 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 != "" + 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) { + val coordinates = remember { record.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 + ) + } + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MergedTrainRecordItem( + mergedRecord: MergedTrainRecord, + expandedStatesMap: MutableMap, + locoInfoUtil: LocoInfoUtil?, + mergeSettings: MergeSettings? = null, + isInEditMode: Boolean = false, + selectedRecords: List = 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 != "") { + 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 != "" + + 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 != "" + 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 != "") { + recordItem.train + } else null + } + GroupBy.TRAIN_ONLY -> { + if (recordItem.loco.isNotEmpty() && recordItem.loco != "") { + "${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 != "") { + append(recordItem.route) + } + if (recordItem.position.isNotEmpty() && recordItem.position != "") { + 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 != "" && + !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, + records: List, latestRecord: TrainRecord?, lastUpdateTime: Date?, temporaryStatusMessage: String? = null, locoInfoUtil: LocoInfoUtil? = null, + mergeSettings: MergeSettings? = null, onClearRecords: () -> Unit = {}, onRecordClick: (TrainRecord) -> Unit = {}, onClearLog: () -> Unit = {}, @@ -62,7 +831,22 @@ fun HistoryScreen( var isInEditMode by remember(editMode) { mutableStateOf(editMode) } val selectedRecordsList = remember(selectedRecords) { mutableStateListOf().apply { - addAll(records.filter { selectedRecords.contains(it.timestamp.time.toString()) }) + 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) { @@ -124,7 +908,6 @@ fun HistoryScreen( Box(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) { - Box( modifier = Modifier .fillMaxSize() @@ -164,374 +947,56 @@ fun HistoryScreen( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - items(filteredRecords) { record -> - val isSelected = selectedRecordsList.contains(record) - val cardColor = when { - isSelected -> 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 (isSelected) { - selectedRecordsList.remove(record) - } else { - selectedRecordsList.add(record) - } - } else { - val id = record.timestamp.time.toString() - expandedStatesMap[id] = - !(expandedStatesMap[id] ?: false) - if (record == latestRecord) { - onRecordClick(record) - } - } - }, - onLongClick = { - if (!isInEditMode) { - isInEditMode = true - selectedRecordsList.clear() - selectedRecordsList.add(record) - } - }, - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = true) - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - ) { - val recordId = record.timestamp.time.toString() - val isExpanded = expandedStatesMap[recordId] == true - val recordMap = record.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 - ) - } - } + 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) } - - 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 != "") { - Text( - text = formattedInfo, - fontSize = 14.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + }, + onLongClick = { record -> + if (!isInEditMode) { + isInEditMode = true + selectedRecordsList.clear() + selectedRecordsList.add(record) } } - - 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 != "" - - 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() - ) - } - } + ) + } + 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) } - - val speed = record.speed.trim() - val isValidSpeed = speed.isNotEmpty() && - !speed.all { it == '*' || it == '-' } && - speed != "NUL" && - speed != "" - if (isValidSpeed) { - Text( - text = "${speed} km/h", - fontSize = 16.sp, - color = MaterialTheme.colorScheme.onSurface - ) + }, + onLongClick = { record -> + if (!isInEditMode) { + isInEditMode = true + selectedRecordsList.clear() + selectedRecordsList.add(record) } } - - 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) { - val coordinates = remember { record.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 - ) - } - } - } + ) } } } diff --git a/app/src/main/java/org/noxylva/lbjconsole/ui/screens/MergedHistoryScreen.kt b/app/src/main/java/org/noxylva/lbjconsole/ui/screens/MergedHistoryScreen.kt new file mode 100644 index 0000000..98c0421 --- /dev/null +++ b/app/src/main/java/org/noxylva/lbjconsole/ui/screens/MergedHistoryScreen.kt @@ -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, + latestRecord: TrainRecord?, + lastUpdateTime: Date?, + temporaryStatusMessage: String? = null, + locoInfoUtil: LocoInfoUtil? = null, + onClearRecords: () -> Unit = {}, + onRecordClick: (TrainRecord) -> Unit = {}, + onClearLog: () -> Unit = {}, + onDeleteRecords: (List) -> Unit = {}, + onDeleteMergedRecord: (MergedTrainRecord) -> Unit = {}, + editMode: Boolean = false, + selectedRecords: Set = emptySet(), + expandedStates: Map = emptyMap(), + scrollPosition: Int = 0, + scrollOffset: Int = 0, + onStateChange: (Boolean, Set, Map, Int, Int) -> Unit = { _, _, _, _, _ -> } +) { + var isInEditMode by remember(editMode) { mutableStateOf(editMode) } + val selectedRecordsList = remember(selectedRecords) { + mutableStateListOf().apply { + addAll(mergedRecords.flatMap { it.records }.filter { + selectedRecords.contains(it.timestamp.time.toString()) + }) + } + } + val expandedStatesMap = remember(expandedStates) { + mutableStateMapOf().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 != "") { + 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 != "" + + 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 != "" + 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 != "") { + append(recordItem.route) + } + if (recordItem.position.isNotEmpty() && recordItem.position != "") { + 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 != "" && + !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() + } + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/noxylva/lbjconsole/ui/screens/SettingsScreen.kt b/app/src/main/java/org/noxylva/lbjconsole/ui/screens/SettingsScreen.kt index 5c4bb50..2fb6995 100644 --- a/app/src/main/java/org/noxylva/lbjconsole/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/org/noxylva/lbjconsole/ui/screens/SettingsScreen.kt @@ -2,12 +2,24 @@ package org.noxylva.lbjconsole.ui.screens import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import org.noxylva.lbjconsole.model.MergeSettings +import org.noxylva.lbjconsole.model.GroupBy +import org.noxylva.lbjconsole.model.TimeWindow +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.LaunchedEffect @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -15,44 +27,220 @@ fun SettingsScreen( deviceName: String, onDeviceNameChange: (String) -> Unit, onApplySettings: () -> Unit, - appVersion: String = "Unknown" + appVersion: String = "Unknown", + mergeSettings: MergeSettings, + onMergeSettingsChange: (MergeSettings) -> Unit, + scrollPosition: Int = 0, + onScrollPositionChange: (Int) -> Unit = {} ) { val uriHandler = LocalUriHandler.current + val scrollState = rememberScrollState() + + LaunchedEffect(scrollPosition) { + scrollState.scrollTo(scrollPosition) + } + + LaunchedEffect(scrollState.value) { + onScrollPositionChange(scrollState.value) + } + + LaunchedEffect(deviceName) { + onApplySettings() + } Column( modifier = Modifier .fillMaxSize() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally + .verticalScroll(scrollState) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + shape = RoundedCornerShape(16.dp) ) { - OutlinedTextField( - value = deviceName, - onValueChange = onDeviceNameChange, - label = { Text("蓝牙设备名称") }, - modifier = Modifier.fillMaxWidth(), - ) - - Button( - onClick = onApplySettings, - modifier = Modifier.fillMaxWidth() + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text("应用设置") + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Default.Bluetooth, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text( + "蓝牙设备", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + + OutlinedTextField( + value = deviceName, + onValueChange = onDeviceNameChange, + label = { Text("设备名称") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.DeviceHub, + contentDescription = null + ) + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) } } - Spacer(modifier = Modifier.weight(1f)) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Default.MergeType, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text( + "记录合并", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "启用记录合并", + style = MaterialTheme.typography.bodyMedium + ) + Switch( + checked = mergeSettings.enabled, + onCheckedChange = { enabled -> + onMergeSettingsChange(mergeSettings.copy(enabled = enabled)) + } + ) + } + + if (mergeSettings.enabled) { + var groupByExpanded by remember { mutableStateOf(false) } + var timeWindowExpanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = groupByExpanded, + onExpandedChange = { groupByExpanded = !groupByExpanded } + ) { + OutlinedTextField( + value = mergeSettings.groupBy.displayName, + onValueChange = {}, + readOnly = true, + label = { Text("分组方式") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Group, + contentDescription = null + ) + }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = groupByExpanded) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(), + shape = RoundedCornerShape(12.dp) + ) + ExposedDropdownMenu( + expanded = groupByExpanded, + onDismissRequest = { groupByExpanded = false } + ) { + GroupBy.values().forEach { groupBy -> + DropdownMenuItem( + text = { Text(groupBy.displayName) }, + onClick = { + onMergeSettingsChange(mergeSettings.copy(groupBy = groupBy)) + groupByExpanded = false + } + ) + } + } + } + + ExposedDropdownMenuBox( + expanded = timeWindowExpanded, + onExpandedChange = { timeWindowExpanded = !timeWindowExpanded } + ) { + OutlinedTextField( + value = mergeSettings.timeWindow.displayName, + onValueChange = {}, + readOnly = true, + label = { Text("时间窗口") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Schedule, + contentDescription = null + ) + }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = timeWindowExpanded) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(), + shape = RoundedCornerShape(12.dp) + ) + ExposedDropdownMenu( + expanded = timeWindowExpanded, + onDismissRequest = { timeWindowExpanded = false } + ) { + TimeWindow.values().forEach { timeWindow -> + DropdownMenuItem( + text = { Text(timeWindow.displayName) }, + onClick = { + onMergeSettingsChange(mergeSettings.copy(timeWindow = timeWindow)) + timeWindowExpanded = false + } + ) + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(20.dp)) Text( - text = "LBJ Console v$appVersion by undef-i", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.clickable { - uriHandler.openUri("https://github.com/undef-i") - } - ) + text = "LBJ Console v$appVersion by undef-i", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { + uriHandler.openUri("https://github.com/undef-i") + } + .padding(16.dp) + ) } }