diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt index 66de088d..b965c50c 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt @@ -270,22 +270,35 @@ class SettingsRepo( } // Marked stations in unworked gridsquares, persisted as a single JSON - // object: {"OL62":{"c":call,"t":epochMs}}. One mark per grid. + // object: {"OL62":[{"c":call,"t":epochMs}, ...]}. Each grid holds an + // ordered list (first = pinned/top). v14.1 stored a single object per + // grid; reads transparently migrate that old shape to a one-element list. private val keyMarkedGridStations = "markedGridStations" - override fun getMarkedGridStations(): Map { + override fun getMarkedGridStations(): Map> { val json = preferences.getString(keyMarkedGridStations, null).orEmpty() if (json.isBlank()) return emptyMap() return try { val root = org.json.JSONObject(json) - val result = mutableMapOf() + val result = mutableMapOf>() for (grid in root.keys()) { - val o = root.optJSONObject(grid) ?: continue - val call = o.optString("c").ifBlank { continue } - result[grid] = com.rtbishop.look4sat.core.domain.model.MarkedStation( - call = call, - epochMs = o.optLong("t") - ) + val value = root.opt(grid) + val list = when (value) { + // v14.2+ shape: JSON array of station objects. + is org.json.JSONArray -> (0 until value.length()).mapNotNull { i -> + val o = value.optJSONObject(i) ?: return@mapNotNull null + val call = o.optString("c").ifBlank { return@mapNotNull null } + com.rtbishop.look4sat.core.domain.model.MarkedStation(call = call, epochMs = o.optLong("t")) + } + // v14.1 legacy shape: a single station object per grid. + is org.json.JSONObject -> { + val call = value.optString("c").ifBlank { null } + if (call == null) emptyList() + else listOf(com.rtbishop.look4sat.core.domain.model.MarkedStation(call = call, epochMs = value.optLong("t"))) + } + else -> emptyList() + } + if (list.isNotEmpty()) result[grid] = list } result } catch (_: Exception) { @@ -293,15 +306,18 @@ class SettingsRepo( } } - override fun setMarkedGridStations(stations: Map) { + override fun setMarkedGridStations(stations: Map>) { val root = org.json.JSONObject() - for ((grid, station) in stations) { - root.put( - grid, - org.json.JSONObject() - .put("c", station.call) - .put("t", station.epochMs) - ) + for ((grid, list) in stations) { + val array = org.json.JSONArray() + for (station in list) { + array.put( + org.json.JSONObject() + .put("c", station.call) + .put("t", station.epochMs) + ) + } + root.put(grid, array) } preferences.edit { putString(keyMarkedGridStations, root.toString()) } } diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt index 40bb2c7a..b3d96db5 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt @@ -313,9 +313,9 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou roamedGrids.value = grids } - override fun getMarkedGridStations(): Map = emptyMap() + override fun getMarkedGridStations(): Map> = emptyMap() - override fun setMarkedGridStations(stations: Map) = Unit + override fun setMarkedGridStations(stations: Map>) = Unit } private fun defaultDataSourcesSettings(): DataSourcesSettings { diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt index b17933cf..5af97f0d 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt @@ -189,8 +189,8 @@ private class FakeSettingsRepoForSearch : ISettingsRepo { override fun setWorkedGridQsos(qsos: Map>) = Unit override fun getRoamedGrids(): Set = emptySet() override fun setRoamedGrids(grids: Set) = Unit - override fun getMarkedGridStations(): Map = emptyMap() - override fun setMarkedGridStations(stations: Map) = Unit + override fun getMarkedGridStations(): Map> = emptyMap() + override fun setMarkedGridStations(stations: Map>) = Unit override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit override fun getLastLotwSyncDate(): String = "" override fun setLastLotwSyncDate(date: String) = Unit diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt index af88f8be..cc80662a 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt @@ -106,10 +106,10 @@ interface ISettingsRepo { fun getRoamedGrids(): Set fun setRoamedGrids(grids: Set) /** Stations the user marked as "want to contact" in unworked gridsquares - * (grid -> mark). One mark per grid; cleared automatically when the grid - * becomes worked. */ - fun getMarkedGridStations(): Map - fun setMarkedGridStations(stations: Map) + * (grid -> ordered list of marks; the first is the pinned/top one). The + * whole grid clears automatically once it becomes worked. */ + fun getMarkedGridStations(): Map> + fun setMarkedGridStations(stations: Map>) //endregion //region # LoTW confirmed-grids settings diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt index 580a55ca..78da21e5 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt @@ -262,9 +262,9 @@ class LoTWGridSyncTest { override fun setWorkedGridQsos(qsos: Map>) { workedGridQsos = qsos } override fun getRoamedGrids(): Set = roamedGrids override fun setRoamedGrids(grids: Set) { roamedGrids = grids } - private var markedGridStations: Map = emptyMap() - override fun getMarkedGridStations(): Map = markedGridStations - override fun setMarkedGridStations(stations: Map) { markedGridStations = stations } + private var markedGridStations: Map> = emptyMap() + override fun getMarkedGridStations(): Map> = markedGridStations + override fun setMarkedGridStations(stations: Map>) { markedGridStations = stations } override fun getLastLotwSyncDate(): String = lastSyncDate override fun setLastLotwSyncDate(date: String) { lastSyncDate = date } override fun getLastLotwSyncCallsign(): String = lastSyncCallsign diff --git a/core/presentation/src/main/res/drawable/ic_pin.xml b/core/presentation/src/main/res/drawable/ic_pin.xml new file mode 100644 index 00000000..39834c46 --- /dev/null +++ b/core/presentation/src/main/res/drawable/ic_pin.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index ee69950a..362b4177 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -137,6 +137,7 @@ 标记 标记 删除标记 + 置顶 日照中 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 0972b70e..16da41ab 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -157,6 +157,7 @@ Mark Marked Remove mark + Pin to top Visible diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MaidenheadGridOverlay.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MaidenheadGridOverlay.kt index b694b8d8..66aa8dc3 100644 --- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MaidenheadGridOverlay.kt +++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MaidenheadGridOverlay.kt @@ -93,9 +93,10 @@ class MaidenheadGridOverlay : Overlay() { var workedGrids: Set = emptySet() /** Marked gridsquares (4-char, uppercase) — unworked grids the user has - * marked a station they want to contact in; drawn red, same alpha as the - * green worked fill. Value carries the marked callsign for label mode. */ - var markedGrids: Map = emptyMap() + * marked stations they want to contact in; drawn red, same alpha as the + * green worked fill. Value is the ordered mark list (first = pinned); + * label mode shows the FIRST marked callsign. */ + var markedGrids: Map> = emptyMap() /** Grid-mode first-call labels: label worked (green) cells with the first * callsign worked in that grid instead of the Maidenhead code; non-worked @@ -449,7 +450,7 @@ class MaidenheadGridOverlay : Overlay() { label in workedGrids -> firstCallsByGrid[label]?.let { call -> canvas.drawText(call, (xLeft + xRight) / 2f, yCenter, firstCallPaint) } - label in markedGrids -> markedGrids[label]?.let { station -> + label in markedGrids -> markedGrids[label]?.firstOrNull()?.let { station -> canvas.drawText(station.call, (xLeft + xRight) / 2f, yCenter, firstCallPaint) } } diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt index 625676ba..9366f37e 100644 --- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt +++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt @@ -429,13 +429,14 @@ private fun MapScreen( WorkedGridQsoDialog( grid = grid, qsos = uiState.workedGridQsos[grid].orEmpty().sortedBy { it.epochMs }, - marked = uiState.markedGrids[grid], + marked = uiState.markedGrids[grid].orEmpty(), isUtc = uiState.isUtc, matchCalculating = matchCalculating, onDismiss = { selectedGrid = null }, onMatch = { onMatchGrid(grid) }, onMark = { call -> onAction(MapAction.SetMarkedStation(grid, call)) }, - onRemoveMark = { onAction(MapAction.RemoveMarkedStation(grid)) } + onRemoveMark = { call -> onAction(MapAction.RemoveMarkedStation(grid, call)) }, + onPinMark = { call -> onAction(MapAction.PinMarkedStation(grid, call)) } ) } } @@ -446,13 +447,14 @@ private fun MapScreen( private fun WorkedGridQsoDialog( grid: String, qsos: List, - marked: com.rtbishop.look4sat.core.domain.model.MarkedStation?, + marked: List, isUtc: Boolean, matchCalculating: Boolean, onDismiss: () -> Unit, onMatch: () -> Unit, onMark: (String) -> Unit, - onRemoveMark: () -> Unit + onRemoveMark: (String) -> Unit, + onPinMark: (String) -> Unit ) { var showMarkInput by remember { mutableStateOf(false) } var callInput by remember { mutableStateOf("") } @@ -512,11 +514,20 @@ private fun WorkedGridQsoDialog( .heightIn(max = 380.dp) .verticalScroll(rememberScrollState()) ) { - // A marked station shows first as a red reminder row: red - // callsign, "标记" in the count column, the marking date in - // the date column, and a small delete icon to remove it. - if (marked != null) { - MarkedStationRow(marked = marked, isUtc = isUtc, onRemoveMark = onRemoveMark) + // Marked stations show first as red reminder rows: pale-yellow + // callsign, "标记" in the count column, the marking date in the + // date column, a small pin icon (first = pinned, grey; others + // pale yellow) and a delete icon to remove the mark. + val showPin = marked.size > 1 + marked.forEachIndexed { index, station -> + MarkedStationRow( + marked = station, + isPinned = index == 0, + showPin = showPin, + isUtc = isUtc, + onRemoveMark = { onRemoveMark(station.call) }, + onPinMark = { onPinMark(station.call) } + ) androidx.compose.material3.HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) } if (qsos.isNotEmpty()) { @@ -535,7 +546,7 @@ private fun WorkedGridQsoDialog( if (qsos.isEmpty()) { TextButton( onClick = { - callInput = marked?.call.orEmpty() + callInput = "" showMarkInput = true }, modifier = Modifier.fillMaxWidth() @@ -585,8 +596,11 @@ private fun WorkedGridQsoDialog( @Composable private fun MarkedStationRow( marked: com.rtbishop.look4sat.core.domain.model.MarkedStation, + isPinned: Boolean, + showPin: Boolean, isUtc: Boolean, - onRemoveMark: () -> Unit + onRemoveMark: () -> Unit, + onPinMark: () -> Unit ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -596,18 +610,37 @@ private fun MarkedStationRow( text = marked.call, style = MaterialTheme.typography.titleMedium, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, - color = ComposeColor(0xFFFF3B30), + color = ComposeColor(0xFFFFE082), maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) - Text( - text = stringResource(R.string.grid_mark_label), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = androidx.compose.ui.text.style.TextAlign.Center, + // "标记" column: pin icon (only when the grid has multiple marks) sits + // left of the label; pinned (first) row's icon is grey, others pale + // yellow — tapping moves that mark to the top. + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, modifier = Modifier.weight(1f) - ) + ) { + if (showPin) { + Icon( + painter = painterResource(R.drawable.ic_pin), + contentDescription = stringResource(R.string.grid_mark_pin), + tint = if (isPinned) ComposeColor(0xFF9E9E9E) else ComposeColor(0xFFFFE082), + modifier = Modifier + .size(18.dp) + .clickable(onClick = onPinMark) + ) + Spacer(Modifier.width(2.dp)) + } + Text( + text = stringResource(R.string.grid_mark_label), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End, @@ -1056,7 +1089,7 @@ private fun setGridMode( centerOnStation: Boolean = false, showFirstCallLabels: Boolean = false, firstCallsByGrid: Map = emptyMap(), - markedGrids: Map = emptyMap() + markedGrids: Map> = emptyMap() ) { try { val gridOverlay = mapView.overlays[OVERLAY_GRID] diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt index 7f5ac687..6510ee34 100644 --- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt +++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt @@ -34,8 +34,9 @@ data class MapState( val workedGridQsos: Map> = emptyMap(), /** 4-char gridsquares the station operated from (LoTW MY_GRIDSQUARE) — blue stripes. */ val roamedGrids: Set = emptySet(), - /** Stations the user marked in unworked gridsquares (grid -> mark) — red fill. */ - val markedGrids: Map = emptyMap(), + /** Stations the user marked in unworked gridsquares (grid -> ordered list; + * first = pinned/top) — red fill. */ + val markedGrids: Map> = emptyMap(), val stationPosition: GeoPos? = null, val orbitalPass: OrbitalPass, val track: List>? = null, @@ -55,7 +56,8 @@ sealed interface MapAction { data class ToggleGridMode(val value: Boolean) : MapAction data class ToggleFirstCallLabels(val value: Boolean) : MapAction data class SetMarkedStation(val grid: String, val call: String) : MapAction - data class RemoveMarkedStation(val grid: String) : MapAction + data class RemoveMarkedStation(val grid: String, val call: String) : MapAction + data class PinMarkedStation(val grid: String, val call: String) : MapAction data class SetVisible(val isVisible: Boolean) : MapAction } diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt index 496815bf..dee6c078 100644 --- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt +++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt @@ -121,19 +121,37 @@ class MapViewModel( is MapAction.ToggleGridMode -> settingsRepo.updateOtherSettings { it.copy(stateOfMapGrid = action.value) } is MapAction.ToggleFirstCallLabels -> settingsRepo.updateOtherSettings { it.copy(stateOfMapFirstCall = action.value) } is MapAction.SetMarkedStation -> { - val updated = settingsRepo.getMarkedGridStations() + - (action.grid to com.rtbishop.look4sat.core.domain.model.MarkedStation( - call = action.call.trim().uppercase(), - epochMs = System.currentTimeMillis() - )) + val call = action.call.trim().uppercase() + val existing = settingsRepo.getMarkedGridStations() + val current = existing[action.grid].orEmpty() + // 重复呼号忽略: 同一网格已标记过的呼号不再重复添加. + val updated = if (current.any { it.call == call }) existing + else existing + (action.grid to current + com.rtbishop.look4sat.core.domain.model.MarkedStation( + call = call, + epochMs = System.currentTimeMillis() + )) settingsRepo.setMarkedGridStations(updated) _uiState.update { it.copy(markedGrids = updated) } } is MapAction.RemoveMarkedStation -> { - val updated = settingsRepo.getMarkedGridStations() - action.grid + val existing = settingsRepo.getMarkedGridStations() + val current = existing[action.grid].orEmpty() + val remaining = current.filterNot { it.call == action.call } + val updated = if (remaining.isEmpty()) existing - action.grid + else existing + (action.grid to remaining) settingsRepo.setMarkedGridStations(updated) _uiState.update { it.copy(markedGrids = updated) } } + is MapAction.PinMarkedStation -> { + val existing = settingsRepo.getMarkedGridStations() + val current = existing[action.grid].orEmpty() + val target = current.firstOrNull { it.call == action.call } + if (target != null) { + val updated = existing + (action.grid to listOf(target) + current.filterNot { it.call == action.call }) + settingsRepo.setMarkedGridStations(updated) + _uiState.update { it.copy(markedGrids = updated) } + } + } is MapAction.SetVisible -> isScreenVisible.value = action.isVisible } } diff --git a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt index 443d7856..88db0c56 100644 --- a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt +++ b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt @@ -115,8 +115,8 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti override fun setWorkedGridQsos(qsos: Map>) = TODO() override fun getRoamedGrids(): Set = TODO() override fun setRoamedGrids(grids: Set) = TODO() - override fun getMarkedGridStations(): Map = emptyMap() - override fun setMarkedGridStations(stations: Map) = Unit + override fun getMarkedGridStations(): Map> = emptyMap() + override fun setMarkedGridStations(stations: Map>) = Unit override fun updateLoTWSettings(settings: LoTWSettings) = TODO() override fun getLastLotwSyncDate(): String = "" override fun setLastLotwSyncDate(date: String) = Unit