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
}
}