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.
This commit is contained in:
atsunatsu committed 2026-09-21 09:44:43 +08:00
1 parent dcd16a0196
commit 4b82332133
8 files changed
+127 -11

No files matched your search

@@ -84,6 +84,7 @@ class SettingsRepo(
private val keyStateOfLightTheme = "stateOfLightTheme" private val keyStateOfLightTheme = "stateOfLightTheme"
private val keyStateOfNightMode = "stateOfNightMode" private val keyStateOfNightMode = "stateOfNightMode"
private val keyStateOfMapGrid = "stateOfMapGrid" private val keyStateOfMapGrid = "stateOfMapGrid"
private val keyStateOfMapFirstCall = "stateOfMapFirstCall"
private val keyStationAltitude = "stationAltitude" private val keyStationAltitude = "stationAltitude"
private val keyStationLatitude = "stationLatitude" private val keyStationLatitude = "stationLatitude"
private val keyStationLongitude = "stationLongitude" private val keyStationLongitude = "stationLongitude"
@@ -546,6 +547,7 @@ class SettingsRepo(
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme) putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
putBoolean(keyStateOfNightMode, new.stateOfNightMode) putBoolean(keyStateOfNightMode, new.stateOfNightMode)
putBoolean(keyStateOfMapGrid, new.stateOfMapGrid) putBoolean(keyStateOfMapGrid, new.stateOfMapGrid)
putBoolean(keyStateOfMapFirstCall, new.stateOfMapFirstCall)
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning) putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew) putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
putString(keySstvMode, new.sstvMode) putString(keySstvMode, new.sstvMode)
@@ -565,6 +567,7 @@ class SettingsRepo(
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false), stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false), stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
stateOfMapGrid = preferences.getBoolean(keyStateOfMapGrid, false), stateOfMapGrid = preferences.getBoolean(keyStateOfMapGrid, false),
stateOfMapFirstCall = preferences.getBoolean(keyStateOfMapFirstCall, false),
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true), shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true), shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto", sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
@@ -60,6 +60,10 @@ data class OtherSettings(
val stateOfLightTheme: Boolean, val stateOfLightTheme: Boolean,
val stateOfNightMode: Boolean = false, val stateOfNightMode: Boolean = false,
val stateOfMapGrid: 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 shouldSeeWarning: Boolean,
val shouldSeeWhatsNew: Boolean, val shouldSeeWhatsNew: Boolean,
val sstvMode: String = "Auto", val sstvMode: String = "Auto",
@@ -109,6 +109,7 @@
<string name="map_next">下一个</string> <string name="map_next">下一个</string>
<string name="map_grid_mode">网格模式</string> <string name="map_grid_mode">网格模式</string>
<string name="map_satellite_mode">卫星模式</string> <string name="map_satellite_mode">卫星模式</string>
<string name="map_first_call_mode">首通呼号</string>
<string name="map_azimuth">方位角:%.1f°</string> <string name="map_azimuth">方位角:%.1f°</string>
<string name="map_elevation">仰角:%.1f°</string> <string name="map_elevation">仰角:%.1f°</string>
<string name="map_altitude">高度:%.0f km</string> <string name="map_altitude">高度:%.0f km</string>
@@ -133,6 +133,7 @@
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string> <string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
<string name="map_grid_mode">Grid mode</string> <string name="map_grid_mode">Grid mode</string>
<string name="map_satellite_mode">Satellite mode</string> <string name="map_satellite_mode">Satellite mode</string>
<string name="map_first_call_mode">First call</string>
<string name="map_azimuth">Azimuth: %.1f°</string> <string name="map_azimuth">Azimuth: %.1f°</string>
<string name="map_elevation">Elevation: %.1f°</string> <string name="map_elevation">Elevation: %.1f°</string>
<string name="map_altitude">Altitude: %.0f km</string> <string name="map_altitude">Altitude: %.0f km</string>
@@ -52,6 +52,13 @@ class MaidenheadGridOverlay : Overlay() {
color = Color.argb(220, 255, 224, 130) color = Color.argb(220, 255, 224, 130)
setShadowLayer(3f, 2f, 2f, Color.BLACK) 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 { private val workedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = android.graphics.Paint.Style.FILL style = android.graphics.Paint.Style.FILL
color = Color.argb(90, 76, 217, 100) 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"}. */ /** Worked gridsquares (4-char, uppercase) to highlight, e.g. {"OL62", "PM95"}. */
var workedGrids: Set<String> = emptySet() var workedGrids: Set<String> = 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<String, String> = emptyMap()
/** /**
* Gridsquares the station operated from (4-char, uppercase) — drawn with * Gridsquares the station operated from (4-char, uppercase) — drawn with
* blue 45° stripes (GridMaster-style zebra), e.g. {"OL62", "PM95"}. * 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- // 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 // 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). // (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 val showLabels = zoom >= LABEL_ZOOM_SUB || cellLat == FIELD_LAT
if (!showLabels) return if (!showLabels) return
// Estimate on-screen cell height to avoid clutter at low zoom: // Estimate on-screen cell height to avoid clutter at low zoom:
@@ -358,8 +378,9 @@ class MaidenheadGridOverlay : Overlay() {
val pixelsPerDegree = Math.abs(y2 - y1) val pixelsPerDegree = Math.abs(y2 - y1)
if (pixelsPerDegree * cellLat < MIN_LABEL_CELL_PX) return if (pixelsPerDegree * cellLat < MIN_LABEL_CELL_PX) return
labelPaint.textAlign = Paint.Align.CENTER val activePaint = if (showFirstCallLabels) firstCallPaint else labelPaint
val fontMetrics = labelPaint.fontMetrics activePaint.textAlign = Paint.Align.CENTER
val fontMetrics = activePaint.fontMetrics
val textHalfHeight = (fontMetrics.descent + fontMetrics.ascent) / 2f val textHalfHeight = (fontMetrics.descent + fontMetrics.ascent) / 2f
for (row in firstRow..lastRow) { for (row in firstRow..lastRow) {
val lat = row * cellLat val lat = row * cellLat
@@ -386,7 +407,17 @@ class MaidenheadGridOverlay : Overlay() {
val xRight = xRightBase + turn * worldWidthPx.toFloat() val xRight = xRightBase + turn * worldWidthPx.toFloat()
if (xRight < 0f || xLeft > canvas.width) continue if (xRight < 0f || xLeft > canvas.width) continue
val label = cellLabel(lat, lon, zoom) 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)
}
} }
} }
} }
@@ -215,6 +215,13 @@ private fun MapScreen(
} }
m 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<String, String> = 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 // 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 // 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. // (requires a LoTW resync) — then VUCC falls back to the global count.
@@ -337,7 +344,10 @@ private fun MapScreen(
setGridMode( setGridMode(
uiState.isGridMode, workedGrids, uiState.roamedGrids, view, uiState.isGridMode, workedGrids, uiState.roamedGrids, view,
uiState.stationPosition, uiState.stationPosition,
centerOnStation = shouldCenter centerOnStation = shouldCenter,
// 首通呼号标签只作用于 VUCC 视图(用户要求); "All"/其他奖状不过滤.
showFirstCallLabels = uiState.showFirstCallLabels && selectedAward == AwardType.VUCC,
firstCallsByGrid = firstCallsByGrid
) )
if (!shouldCenter || uiState.stationPosition != null) { if (!shouldCenter || uiState.stationPosition != null) {
prevGridMode = uiState.isGridMode prevGridMode = uiState.isGridMode
@@ -372,13 +382,27 @@ private fun MapScreen(
.padding(8.dp) .padding(8.dp)
) )
} }
GridModeToggle( // Top-right: grid-mode pill + (grid mode only) the first-call
isGridMode = uiState.isGridMode, // label pill, stacked vertically.
onToggle = { onAction(MapAction.ToggleGridMode(it)) }, Column(
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .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 // endregion
// region Map overlay helpers // region Map overlay helpers
@@ -843,7 +901,9 @@ private fun setGridMode(
roamedGrids: Set<String>, roamedGrids: Set<String>,
mapView: MapView, mapView: MapView,
stationPosition: GeoPos?, stationPosition: GeoPos?,
centerOnStation: Boolean = false centerOnStation: Boolean = false,
showFirstCallLabels: Boolean = false,
firstCallsByGrid: Map<String, String> = emptyMap()
) { ) {
try { try {
val gridOverlay = mapView.overlays[OVERLAY_GRID] val gridOverlay = mapView.overlays[OVERLAY_GRID]
@@ -851,6 +911,9 @@ private fun setGridMode(
gridOverlay.isEnabled = gridMode gridOverlay.isEnabled = gridMode
gridOverlay.workedGrids = workedGrids gridOverlay.workedGrids = workedGrids
gridOverlay.roamedGrids = roamedGrids gridOverlay.roamedGrids = roamedGrids
// 首通呼号模式: 绿格标注第一个通联的呼号, 非绿格不显示标签.
gridOverlay.showFirstCallLabels = showFirstCallLabels
gridOverlay.firstCallsByGrid = firstCallsByGrid
// ownGrid must be set on EVERY update — the position is available // ownGrid must be set on EVERY update — the position is available
// regardless of whether this frame centers (centering happens only // regardless of whether this frame centers (centering happens only
// on entry, but passing null here would wipe the bold outline). // on entry, but passing null here would wipe the bold outline).
@@ -860,6 +923,8 @@ private fun setGridMode(
isEnabled = gridMode isEnabled = gridMode
this.workedGrids = workedGrids this.workedGrids = workedGrids
this.roamedGrids = roamedGrids this.roamedGrids = roamedGrids
this.showFirstCallLabels = showFirstCallLabels
this.firstCallsByGrid = firstCallsByGrid
this.ownGrid = ownGridOf(stationPosition) this.ownGrid = ownGridOf(stationPosition)
} }
} }
@@ -27,6 +27,9 @@ data class MapState(
val isLightUi: Boolean = false, val isLightUi: Boolean = false,
val isUtc: Boolean = false, val isUtc: Boolean = false,
val isGridMode: 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<String> = emptySet(), val workedGrids: Set<String> = emptySet(),
val workedGridQsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap(), val workedGridQsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap(),
/** 4-char gridsquares the station operated from (LoTW MY_GRIDSQUARE) — blue stripes. */ /** 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 SelectItem(val item: OrbitalObject) : MapAction
data class SelectDefaultItem(val catnum: Int) : MapAction data class SelectDefaultItem(val catnum: Int) : MapAction
data class ToggleGridMode(val value: Boolean) : MapAction data class ToggleGridMode(val value: Boolean) : MapAction
data class ToggleFirstCallLabels(val value: Boolean) : MapAction
data class SetVisible(val isVisible: Boolean) : MapAction data class SetVisible(val isVisible: Boolean) : MapAction
} }
@@ -79,7 +79,13 @@ class MapViewModel(
init { init {
viewModelScope.launch { viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings -> 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 { viewModelScope.launch {
@@ -105,6 +111,7 @@ class MapViewModel(
is MapAction.SelectItem -> selectSatellite(action.item) is MapAction.SelectItem -> selectSatellite(action.item)
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum) is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
is MapAction.ToggleGridMode -> settingsRepo.updateOtherSettings { it.copy(stateOfMapGrid = action.value) } 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 is MapAction.SetVisible -> isScreenVisible.value = action.isVisible
} }
} }