From 4b823321332ad21e0f69ae166d9ea888330e7b78 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Mon, 21 Sep 2026 09:44:43 +0800 Subject: [PATCH] feat(map): first-call callsign label toggle in VUCC grid mode Add a 'first call' pill toggle below the grid-mode switch (map top-right, visible only in grid mode + VUCC award). When on, worked (green) cells are labelled with the first callsign worked in that grid (earliest confirmed QSO by time) instead of the Maidenhead code; non-worked cells get no label at all. Persisted via OtherSettings.stateOfMapFirstCall (full MVI chain). The feature is VUCC-only: All and other award overlays never draw it. --- .../core/data/repository/SettingsRepo.kt | 3 + .../look4sat/core/domain/model/Settings.kt | 4 + .../src/main/res/values-zh/strings.xml | 1 + .../src/main/res/values/strings.xml | 1 + .../feature/map/MaidenheadGridOverlay.kt | 37 ++++++++- .../look4sat/feature/map/MapScreen.kt | 79 +++++++++++++++++-- .../rtbishop/look4sat/feature/map/MapState.kt | 4 + .../look4sat/feature/map/MapViewModel.kt | 9 ++- 8 files changed, 127 insertions(+), 11 deletions(-) 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 6bfe79e8..e0f68c64 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 @@ -84,6 +84,7 @@ class SettingsRepo( private val keyStateOfLightTheme = "stateOfLightTheme" private val keyStateOfNightMode = "stateOfNightMode" private val keyStateOfMapGrid = "stateOfMapGrid" + private val keyStateOfMapFirstCall = "stateOfMapFirstCall" private val keyStationAltitude = "stationAltitude" private val keyStationLatitude = "stationLatitude" private val keyStationLongitude = "stationLongitude" @@ -546,6 +547,7 @@ class SettingsRepo( putBoolean(keyStateOfLightTheme, new.stateOfLightTheme) putBoolean(keyStateOfNightMode, new.stateOfNightMode) putBoolean(keyStateOfMapGrid, new.stateOfMapGrid) + putBoolean(keyStateOfMapFirstCall, new.stateOfMapFirstCall) putBoolean(keyShouldSeeWarning, new.shouldSeeWarning) putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew) putString(keySstvMode, new.sstvMode) @@ -565,6 +567,7 @@ class SettingsRepo( stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false), stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false), stateOfMapGrid = preferences.getBoolean(keyStateOfMapGrid, false), + stateOfMapFirstCall = preferences.getBoolean(keyStateOfMapFirstCall, false), shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true), shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true), sstvMode = preferences.getString(keySstvMode, null) ?: "Auto", diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt index 4f22bda9..b6b50d13 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt @@ -60,6 +60,10 @@ data class OtherSettings( val stateOfLightTheme: Boolean, val stateOfNightMode: Boolean = false, val stateOfMapGrid: Boolean = false, + /** Grid mode: label worked (green) cells with the FIRST callsign worked + * in that grid instead of the Maidenhead code; non-worked cells get no + * label at all. */ + val stateOfMapFirstCall: Boolean = false, val shouldSeeWarning: Boolean, val shouldSeeWhatsNew: Boolean, val sstvMode: String = "Auto", diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index a4d6da17..59cf3fef 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -109,6 +109,7 @@ 下一个 网格模式 卫星模式 + 首通呼号 方位角:%.1f° 仰角:%.1f° 高度:%.0f km diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 8681dd60..3f3592a3 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -133,6 +133,7 @@ © OpenStreetMap contributors Grid mode Satellite mode + First call Azimuth: %.1f° Elevation: %.1f° Altitude: %.0f km 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 32ff3e73..98e8f809 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 @@ -52,6 +52,13 @@ class MaidenheadGridOverlay : Overlay() { color = Color.argb(220, 255, 224, 130) setShadowLayer(3f, 2f, 2f, Color.BLACK) } + // 首通呼号标签: 比 4 字符网格代码略小, 以容纳更长的呼号(如 BG2GQG). + private val firstCallPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + textSize = 20f + style = android.graphics.Paint.Style.FILL + color = Color.argb(230, 255, 224, 130) + setShadowLayer(3f, 2f, 2f, Color.BLACK) + } private val workedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = android.graphics.Paint.Style.FILL color = Color.argb(90, 76, 217, 100) @@ -79,6 +86,15 @@ class MaidenheadGridOverlay : Overlay() { /** Worked gridsquares (4-char, uppercase) to highlight, e.g. {"OL62", "PM95"}. */ var workedGrids: Set = emptySet() + /** Grid-mode first-call labels: label worked (green) cells with the first + * callsign worked in that grid instead of the Maidenhead code; non-worked + * cells get no label at all. Only meaningful at sub-square zoom. */ + var showFirstCallLabels: Boolean = false + + /** Worked grid -> first callsign worked in it (earliest QSO by time). + * Only read when [showFirstCallLabels] is on. */ + var firstCallsByGrid: Map = emptyMap() + /** * Gridsquares the station operated from (4-char, uppercase) — drawn with * blue 45° stripes (GridMaster-style zebra), e.g. {"OL62", "PM95"}. @@ -348,6 +364,10 @@ class MaidenheadGridOverlay : Overlay() { // Field (2-char) labels show at every zoom, subject only to the pixel- // size check below; sub-square (4-char) labels only from LABEL_ZOOM_SUB // (one level above the grid lines, so zoom 6 shows lines but no names). + // First-call labels replace the 4-char grid codes and only exist at + // sub-square zoom; at field zoom NO labels are drawn at all (the user + // requirement is that non-worked cells carry no grid characters). + if (showFirstCallLabels && zoom < LABEL_ZOOM_SUB) return val showLabels = zoom >= LABEL_ZOOM_SUB || cellLat == FIELD_LAT if (!showLabels) return // Estimate on-screen cell height to avoid clutter at low zoom: @@ -358,8 +378,9 @@ class MaidenheadGridOverlay : Overlay() { val pixelsPerDegree = Math.abs(y2 - y1) if (pixelsPerDegree * cellLat < MIN_LABEL_CELL_PX) return - labelPaint.textAlign = Paint.Align.CENTER - val fontMetrics = labelPaint.fontMetrics + val activePaint = if (showFirstCallLabels) firstCallPaint else labelPaint + activePaint.textAlign = Paint.Align.CENTER + val fontMetrics = activePaint.fontMetrics val textHalfHeight = (fontMetrics.descent + fontMetrics.ascent) / 2f for (row in firstRow..lastRow) { val lat = row * cellLat @@ -386,7 +407,17 @@ class MaidenheadGridOverlay : Overlay() { val xRight = xRightBase + turn * worldWidthPx.toFloat() if (xRight < 0f || xLeft > canvas.width) continue val label = cellLabel(lat, lon, zoom) - canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint) + if (showFirstCallLabels) { + // 首通呼号模式: 只有绿格(worked)标注该格第一个通联的呼号, + // 非绿格空着不写网格字符. + if (label in workedGrids) { + firstCallsByGrid[label]?.let { call -> + canvas.drawText(call, (xLeft + xRight) / 2f, yCenter, firstCallPaint) + } + } + } else { + canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint) + } } } } 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 fdf7cee6..6a53c089 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 @@ -215,6 +215,13 @@ private fun MapScreen( } m } + // First callsign worked in each grid (earliest QSO by time), used by the + // "首通呼号" label mode — derived from the same QSO store as the worked fills. + val firstCallsByGrid: Map = remember(uiState.workedGridQsos) { + uiState.workedGridQsos.mapNotNull { (grid, qsos) -> + qsos.minByOrNull { it.epochMs }?.let { grid to it.call } + }.toMap() + } // Selected operated grid for VUCC counting; defaults to the grid with the // most worked grids. Null when the store carries no per-QSO myGrid data // (requires a LoTW resync) — then VUCC falls back to the global count. @@ -337,7 +344,10 @@ private fun MapScreen( setGridMode( uiState.isGridMode, workedGrids, uiState.roamedGrids, view, uiState.stationPosition, - centerOnStation = shouldCenter + centerOnStation = shouldCenter, + // 首通呼号标签只作用于 VUCC 视图(用户要求); "All"/其他奖状不过滤. + showFirstCallLabels = uiState.showFirstCallLabels && selectedAward == AwardType.VUCC, + firstCallsByGrid = firstCallsByGrid ) if (!shouldCenter || uiState.stationPosition != null) { prevGridMode = uiState.isGridMode @@ -372,13 +382,27 @@ private fun MapScreen( .padding(8.dp) ) } - GridModeToggle( - isGridMode = uiState.isGridMode, - onToggle = { onAction(MapAction.ToggleGridMode(it)) }, + // Top-right: grid-mode pill + (grid mode only) the first-call + // label pill, stacked vertically. + Column( modifier = Modifier .align(Alignment.TopEnd) - .padding(8.dp) - ) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.End + ) { + GridModeToggle( + isGridMode = uiState.isGridMode, + onToggle = { onAction(MapAction.ToggleGridMode(it)) } + ) + // 首通呼号开关仅 VUCC 视图显示(与 VuccGridSelector 同条件). + if (uiState.isGridMode && selectedAward == AwardType.VUCC) { + FirstCallToggle( + checked = uiState.showFirstCallLabels, + onToggle = { onAction(MapAction.ToggleFirstCallLabels(it)) } + ) + } + } } } } @@ -800,6 +824,40 @@ private fun GridModeToggle( } } +/** + * 网格模式下的"首通呼号"开关: 打开后绿格(worked)的标签显示该格第一个通联的 + * 呼号, 非绿格不显示任何文字. 仅在网格模式下显示(与网格模式开关叠放). + */ +@Composable +private fun FirstCallToggle( + checked: Boolean, + onToggle: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + Surface( + color = ComposeColor.Black.copy(alpha = 0.45f), + shape = RoundedCornerShape(percent = 50), + modifier = modifier + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 12.dp, end = 6.dp, top = 2.dp, bottom = 2.dp) + ) { + Text( + text = stringResource(R.string.map_first_call_mode), + color = ComposeColor.White, + fontSize = 12.sp, + modifier = Modifier.padding(end = 2.dp) + ) + Switch( + checked = checked, + onCheckedChange = onToggle, + modifier = Modifier.scale(0.75f) + ) + } + } +} + // endregion // region Map overlay helpers @@ -843,7 +901,9 @@ private fun setGridMode( roamedGrids: Set, mapView: MapView, stationPosition: GeoPos?, - centerOnStation: Boolean = false + centerOnStation: Boolean = false, + showFirstCallLabels: Boolean = false, + firstCallsByGrid: Map = emptyMap() ) { try { val gridOverlay = mapView.overlays[OVERLAY_GRID] @@ -851,6 +911,9 @@ private fun setGridMode( gridOverlay.isEnabled = gridMode gridOverlay.workedGrids = workedGrids gridOverlay.roamedGrids = roamedGrids + // 首通呼号模式: 绿格标注第一个通联的呼号, 非绿格不显示标签. + gridOverlay.showFirstCallLabels = showFirstCallLabels + gridOverlay.firstCallsByGrid = firstCallsByGrid // ownGrid must be set on EVERY update — the position is available // regardless of whether this frame centers (centering happens only // on entry, but passing null here would wipe the bold outline). @@ -860,6 +923,8 @@ private fun setGridMode( isEnabled = gridMode this.workedGrids = workedGrids this.roamedGrids = roamedGrids + this.showFirstCallLabels = showFirstCallLabels + this.firstCallsByGrid = firstCallsByGrid this.ownGrid = ownGridOf(stationPosition) } } 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 13907be6..558791b1 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 @@ -27,6 +27,9 @@ data class MapState( val isLightUi: Boolean = false, val isUtc: Boolean = false, val isGridMode: Boolean = false, + /** Grid mode: label worked (green) cells with the first callsign worked in + * that grid instead of the grid code; non-worked cells get no label. */ + val showFirstCallLabels: Boolean = false, val workedGrids: Set = emptySet(), val workedGridQsos: Map> = emptyMap(), /** 4-char gridsquares the station operated from (LoTW MY_GRIDSQUARE) — blue stripes. */ @@ -48,6 +51,7 @@ sealed interface MapAction { data class SelectItem(val item: OrbitalObject) : MapAction data class SelectDefaultItem(val catnum: Int) : MapAction data class ToggleGridMode(val value: Boolean) : MapAction + data class ToggleFirstCallLabels(val value: Boolean) : 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 e00bdd5b..aa587f58 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 @@ -79,7 +79,13 @@ class MapViewModel( init { viewModelScope.launch { settingsRepo.otherSettings.collectLatest { settings -> - _uiState.update { it.copy(isUtc = settings.stateOfUtc, isGridMode = settings.stateOfMapGrid) } + _uiState.update { + it.copy( + isUtc = settings.stateOfUtc, + isGridMode = settings.stateOfMapGrid, + showFirstCallLabels = settings.stateOfMapFirstCall + ) + } } } viewModelScope.launch { @@ -105,6 +111,7 @@ class MapViewModel( is MapAction.SelectItem -> selectSatellite(action.item) is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum) is MapAction.ToggleGridMode -> settingsRepo.updateOtherSettings { it.copy(stateOfMapGrid = action.value) } + is MapAction.ToggleFirstCallLabels -> settingsRepo.updateOtherSettings { it.copy(stateOfMapFirstCall = action.value) } is MapAction.SetVisible -> isScreenVisible.value = action.isVisible } }