diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt index e0055629..2a1adc5d 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt @@ -47,8 +47,11 @@ class LoTWRepository : ILoTWRepository { override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult = withContext(Dispatchers.IO) { fetchReportBody(callsign, password).fold( - onSuccess = { body -> parseBoth(body)?.let { LoTWResult.Success(it.first, it.second) } - ?: LoTWResult.RateLimited }, + onSuccess = { body -> + parseBoth(body)?.let { (grids, _, roamed) -> + LoTWResult.Success(grids, emptyMap(), roamed) + } ?: LoTWResult.RateLimited + }, onFailure = { toResult(it) } ) } @@ -58,8 +61,11 @@ class LoTWRepository : ILoTWRepository { password: String ): LoTWResult = withContext(Dispatchers.IO) { fetchReportBody(callsign, password).fold( - onSuccess = { body -> parseBoth(body)?.let { LoTWResult.Success(it.first, it.second) } - ?: LoTWResult.RateLimited }, + onSuccess = { body -> + parseBoth(body)?.let { (grids, qsos, roamed) -> + LoTWResult.Success(grids, qsos, roamed) + } ?: LoTWResult.RateLimited + }, onFailure = { toResult(it) } ) } @@ -71,11 +77,14 @@ class LoTWRepository : ILoTWRepository { else -> LoTWResult.NetworkError(e.message ?: e.javaClass.simpleName) } - /** Single report fetch feeding both the grid set and the per-QSO detail. */ - private fun parseBoth(body: String): Pair, Map>>? { + /** Single report fetch feeding the grid set, the per-QSO detail and the roamed set. */ + private fun parseBoth( + body: String + ): Triple, Map>, Set>? { val grids = parseConfirmedGrids(body) ?: return null val qsos = parseConfirmedGridQsos(body) ?: return null - return grids to qsos + val roamed = parseRoamedGrids(body) ?: return null + return Triple(grids, qsos, roamed) } private fun fetchReportBody(callsign: String, password: String): Result { @@ -294,6 +303,39 @@ class LoTWRepository : ILoTWRepository { return grids } + /** + * Distinct 4-char gridsquares the account itself operated from, taken from + * ADIF of satellite QSO records only. This is the + * "roamed / activated" set shown as blue stripes on the map — the grids + * where the operator's own station was located during confirmed QSOs (a + * rover may log several distinct grids; the home grid is included too). + * Returns null when the body is not an ADIF report (mirrors the other parsers). + */ + internal fun parseRoamedGrids(body: String): Set? { + if (!body.contains("", ignoreCase = true)) return null + val grids = mutableSetOf() + var propMode: String? = null + for (raw in body.lineSequence()) { + val line = raw.trim() + when { + line.equals("", ignoreCase = true) -> propMode = null + line.startsWith(" { + propMode = line.substringAfter('>').substringBefore("E<").trim().uppercase() + } + line.startsWith(" { + // MY_GRIDSQUARE must not be mistaken for GRIDSQUARE (the + // opposite station's grid) — only own-station grids count. + if (propMode == "SAT") { + val value = line.substringAfter('>').substringBefore("E<") + val field = value.trim().uppercase() + if (field.length >= 4) grids.add(field.take(4)) + } + } + } + } + return grids + } + private companion object { const val BASE_URL = "https://lotw.arrl.org/lotwuser/lotwreport.adi" } 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 2a63bb37..a0490663 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 @@ -239,6 +239,29 @@ class SettingsRepo( preferences.edit { putString(keyWorkedGridQsos, root.toString()) } } + // Distinct 4-char gridsquares the account operated from (LoTW , + // satellite QSOs only). Stored as a JSONArray like workedGrids. + private val keyRoamedGrids = "roamedGrids" + + override fun getRoamedGrids(): Set { + val json = preferences.getString(keyRoamedGrids, null).orEmpty() + if (json.isBlank()) return emptySet() + return try { + val array = org.json.JSONArray(json) + (0 until array.length()).mapNotNull { i -> + array.optString(i).takeIf { it.isNotBlank() } + }.toSet() + } catch (_: Exception) { + emptySet() + } + } + + override fun setRoamedGrids(grids: Set) { + val array = org.json.JSONArray() + grids.sorted().forEach { array.put(it) } + preferences.edit { putString(keyRoamedGrids, array.toString()) } + } + // LoTW credentials (stored locally on the device only) private val keyLoTWCall = "lotwCallsign" private val keyLoTWPass = "lotwPassword" 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 d795c38c..c6782091 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 @@ -296,6 +296,14 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou override fun getWorkedGridQsos(): Map> = emptyMap() override fun setWorkedGridQsos(qsos: Map>) = Unit + + override fun getRoamedGrids(): Set = emptySet() + + private val roamedGrids = MutableStateFlow(emptySet()) + + override fun setRoamedGrids(grids: Set) { + roamedGrids.value = grids + } } private fun defaultDataSourcesSettings(): DataSourcesSettings { diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt index 1abc3a57..1d9f8d88 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt @@ -130,6 +130,46 @@ class LoTWRepositoryTest { // endregion + // region parseRoamedGrids (MY_GRIDSQUARE = grids the account operated from) + + @Test + fun parseRoamedGridsCollectsOwnStationGrids() { + // Satellite QSOs carry MY_GRIDSQUARE = the grid the account itself + // operated from (the "roamed/activated" set shown as blue stripes). + val qso1 = "BA7OPF\n20260820\nSAT\nIO-86\n" + + "OL62\nPM95\n\n" + val qso2 = "BA7OPF\n20260901\nSAT\nIO-86\n" + + "OL72\nNL47\n\n" + assertEquals(setOf("OL62", "OL72"), repo.parseRoamedGrids(report(qso1, qso2))) + } + + @Test + fun parseRoamedGridsIgnoresGroundQsos() { + val ground = "BA7OPF\n20260821\nOL72\n\n" + assertEquals(emptySet(), repo.parseRoamedGrids(report(ground))) + } + + @Test + fun parseRoamedGridsDoesNotConfuseGridsquareWithMyGridsquare() { + // GRIDSQUARE (opposite station's grid) must never leak into the roamed set. + val qso = "SAT\nSO-50\nOL62\n" + + "OM60IL\n\n" + assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso))) + } + + @Test + fun parseRoamedGridsTruncatesSixCharToFour() { + val qso = "SAT\nIO-86\nOL62AA\n\n" + assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso))) + } + + @Test + fun parseRoamedGridsRejectsBodyWithoutEoh() { + assertNull(repo.parseRoamedGrids("Username/password incorrect")) + } + + // endregion + // region failure classification (fetchReportBody -> LoTWResult mapping) @Test 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 3a6be42f..87f1828d 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 @@ -102,6 +102,9 @@ interface ISettingsRepo { /** Confirmed satellite QSOs grouped by worked 4-char gridsquare (map tap detail). */ fun getWorkedGridQsos(): Map> fun setWorkedGridQsos(qsos: Map>) + /** Distinct 4-char gridsquares the account operated from (LoTW ). */ + fun getRoamedGrids(): Set + fun setRoamedGrids(grids: Set) //endregion //region # LoTW confirmed-grids settings diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/LoTWResult.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/LoTWResult.kt index 46c266c2..8c5149bd 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/LoTWResult.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/LoTWResult.kt @@ -23,7 +23,10 @@ sealed class LoTWResult { /** Report downloaded and parsed successfully. */ data class Success( val grids: Set, - val qsos: Map> + val qsos: Map>, + /** Distinct 4-char gridsquares the account itself operated from + * (ADIF , satellite QSOs only) — "roamed/activated" grids. */ + val roamedGrids: Set = emptySet() ) : LoTWResult() /** HTTP 200 but LoTW replied with its login-error page (bad callsign/password). */ 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 80f47f23..287168fa 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 @@ -56,8 +56,16 @@ class MaidenheadGridOverlay : Overlay() { style = android.graphics.Paint.Style.FILL color = Color.argb(90, 76, 217, 100) } + private val roamStripePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 7f + // Fully opaque blue: stripe bands stay pure blue even over a green + // worked fill — no alpha blend into a teal/green mix (user requirement: + // the two colors must never stack into a single mixed color). + color = Color.argb(255, 66, 133, 244) + } private val ownLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - strokeWidth = 4.5f + strokeWidth = 6f style = Paint.Style.STROKE color = Color.argb(255, 90, 200, 255) } @@ -70,6 +78,12 @@ class MaidenheadGridOverlay : Overlay() { /** Worked gridsquares (4-char, uppercase) to highlight, e.g. {"OL62", "PM95"}. */ var workedGrids: Set = emptySet() + /** + * Gridsquares the station operated from (4-char, uppercase) — drawn with + * blue 45° stripes (GridMaster-style zebra), e.g. {"OL62", "PM95"}. + */ + var roamedGrids: Set = emptySet() + /** The station's own 4-char gridsquare, drawn with a distinct outline. */ var ownGrid: String? = null @@ -120,8 +134,10 @@ class MaidenheadGridOverlay : Overlay() { val worldTurns = ceil(((rightLon - leftLon) / 360.0) - 1e-9).toInt().coerceAtLeast(0) val colRepeats = if (worldTurns > 0) worldTurns else 0 - // The station's own grid square (4-char, only meaningful at sub-square zoom) - val ownCell = ownGrid?.takeIf { zoom >= GRID_ZOOM_SUB } + // The station's own grid: 4-char square at sub-square zoom, 2-char + // field at field zoom. Always marked so the "you are here" outline is + // visible even at the default entry zoom (below GRID_ZOOM_SUB). + val ownCell = ownGrid?.let { if (zoom >= GRID_ZOOM_SUB) it else it.take(2) } // The tapped worked grid gets a distinct outline (same zoom gate). val selectedCell = selectedGrid?.takeIf { zoom >= GRID_ZOOM_SUB } @@ -182,6 +198,53 @@ class MaidenheadGridOverlay : Overlay() { } } + // Roamed/activated grid stripes: blue 45° zebra (GridMaster style) over + // every cell the station operated from. Drawn AFTER the worked fills so + // a worked+roamed cell shows blue stripes with green between them — the + // stripe paint is nearly opaque, so the two colors never alpha-blend + // into a teal/green mix. Same geometry as the worked fills (per-4-char + // cell at both zoom levels). + if (roamedGrids.isNotEmpty()) { + if (zoom >= GRID_ZOOM_SUB) { + for (row in firstRow..lastRow) { + val lat = row * cellLat + if (lat < -90.0 || lat >= 90.0) continue + val topLatCell = lat + cellLat + if (topLatCell > 90.0) continue + val yTop = projectionToY(projection, topLatCell) ?: continue + val yBottom = projectionToY(projection, lat) ?: continue + for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) { + val lon = col * cellLon + val xLeftBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue + val xRightBase = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue + val xLeft = xLeftBase + turn * worldWidthPx.toFloat() + val xRight = xRightBase + turn * worldWidthPx.toFloat() + if (xRight < 0f || xLeft > canvas.width) continue + if (cellLabel(lat, lon, zoom) in roamedGrids) { + drawStripes(canvas, xLeft, yTop, xRight, yBottom) + } + } + } + } else { + for (grid in roamedGrids) { + val cell = gridCellBounds(grid) ?: continue + for (turn in -colRepeats..colRepeats) { + val dLon = turn * 360.0 + if (cell.lonRight + dLon <= leftLon || cell.lonLeft + dLon >= rightLon) continue + val yTop = projectionToY(projection, cell.latTop) ?: continue + val yBottom = projectionToY(projection, cell.latBottom) ?: continue + val xLeftBase = projectionToX(projection, cell.lonLeft, centerLon, worldWidthPx) ?: continue + val xRightBase = projectionToX(projection, cell.lonRight, centerLon, worldWidthPx) ?: continue + val xLeft = xLeftBase + turn * worldWidthPx.toFloat() + val xRight = xRightBase + turn * worldWidthPx.toFloat() + if (xRight < 0f || xLeft > canvas.width) continue + if (yBottom < 0f || yTop > canvas.height) continue + drawStripes(canvas, xLeft, yTop, xRight, yBottom) + } + } + } + } + // Vertical lines (meridians). Worked-grid cells are NOT excluded: // the boundary lines between adjacent worked squares must stay visible // so the grid structure remains readable (reverted 2026-09-09 — the @@ -287,6 +350,26 @@ class MaidenheadGridOverlay : Overlay() { } } + /** + * Draws 45° diagonal stripes (GridMaster-style zebra) clipped to the cell + * rectangle, from top-left to bottom-right. The paint is nearly opaque so + * stripes stay blue even over a green worked fill underneath. + */ + private fun drawStripes(canvas: Canvas, xLeft: Float, yTop: Float, xRight: Float, yBottom: Float) { + if (xRight <= xLeft || yBottom <= yTop) return + canvas.save() + canvas.clipRect(xLeft, yTop, xRight, yBottom) + val height = yBottom - yTop + // Start one stripe-width left of the cell so the top-left corner is + // always covered; each stripe runs from (x, top) to (x+height, bottom). + var x = xLeft - height + while (x < xRight) { + canvas.drawLine(x, yTop, x + height, yBottom, roamStripePaint) + x += STRIPE_SPACING_PX + } + canvas.restore() + } + /** X pixel for a longitude (meridians are vertical in Web Mercator). */ private fun projectionToX(projection: Projection, lon: Double, centerLon: Double, worldWidthPx: Double): Float? { // Do NOT use osmdroid's toPixels() here: at low zoom its wrap-around @@ -370,5 +453,7 @@ class MaidenheadGridOverlay : Overlay() { const val LABEL_ZOOM_SUB = 6.5 const val MIN_LABEL_CELL_PX = 48f const val MAX_OVERSHOOT_PX = 64 + /** Center-to-center spacing of the roamed-grid zebra stripes, in px. */ + const val STRIPE_SPACING_PX = 16f } } 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 8d347a2a..c2c7ea4a 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 @@ -157,8 +157,9 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView: // Tapped worked grid -> centered QSO dialog. Local UI state: the map is the // only consumer and it resets when leaving the page. var selectedGrid by remember { mutableStateOf(null) } - // Selected award filter (null = "All" = plain worked-grid view). - var selectedAward by remember { mutableStateOf(null) } + // Selected award filter. Entering grid mode defaults to VUCC (the plain + // worked-grid view); the reset effect below re-asserts that on every entry. + var selectedAward by remember { mutableStateOf(AwardType.VUCC) } // Six-award progress derived from the confirmed QSO store; recomputed when // the store changes (LoTW/Wavelog sync). val awardProgress: List = remember(uiState.workedGridQsos) { @@ -192,6 +193,11 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView: (mapView.overlays.getOrNull(OVERLAY_GRID) as? MaidenheadGridOverlay)?.selectedGrid = selectedGrid mapView.invalidate() } + // Re-assert the VUCC default each time grid mode is entered; while already + // in grid mode the user's chip choice is preserved. + LaunchedEffect(uiState.isGridMode) { + if (uiState.isGridMode) selectedAward = AwardType.VUCC + } LaunchedEffect(uiState.track) { // In grid mode the map is centered on the local grid square; following @@ -249,7 +255,7 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView: // stationPosition would swallow the centering forever. val shouldCenter = uiState.isGridMode && !prevGridMode setGridMode( - uiState.isGridMode, uiState.workedGrids, view, + uiState.isGridMode, uiState.workedGrids, uiState.roamedGrids, view, if (shouldCenter) uiState.stationPosition else null ) if (!shouldCenter || uiState.stationPosition != null) { @@ -631,6 +637,7 @@ private fun setAwardMode(award: AwardType, workedCodes: Set, mapView: Ma private fun setGridMode( gridMode: Boolean, workedGrids: Set, + roamedGrids: Set, mapView: MapView, stationPosition: GeoPos? ) { @@ -639,11 +646,13 @@ private fun setGridMode( if (gridOverlay is MaidenheadGridOverlay) { gridOverlay.isEnabled = gridMode gridOverlay.workedGrids = workedGrids + gridOverlay.roamedGrids = roamedGrids gridOverlay.ownGrid = ownGridOf(stationPosition) } else { mapView.overlays[OVERLAY_GRID] = MaidenheadGridOverlay().apply { isEnabled = gridMode this.workedGrids = workedGrids + this.roamedGrids = roamedGrids 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 45e84e19..79cbb924 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 @@ -29,6 +29,8 @@ data class MapState( val isGridMode: Boolean = false, val workedGrids: Set = emptySet(), val workedGridQsos: Map> = emptyMap(), + /** 4-char gridsquares the station operated from (LoTW MY_GRIDSQUARE) — blue stripes. */ + val roamedGrids: Set = emptySet(), val stationPosition: GeoPos? = null, val orbitalPass: OrbitalPass, val track: List>? = null, 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 283e1341..8582dec3 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 @@ -82,7 +82,8 @@ class MapViewModel( _uiState.update { it.copy( workedGrids = settingsRepo.getWorkedGrids(), - workedGridQsos = settingsRepo.getWorkedGridQsos() + workedGridQsos = settingsRepo.getWorkedGridQsos(), + roamedGrids = settingsRepo.getRoamedGrids() ) } } diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt index 0e8a789b..f2404ebc 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt @@ -207,6 +207,7 @@ class SettingsViewModel( // synced set fully replaces the stored worked grids. settingsRepo.setWorkedGrids(result.grids) settingsRepo.setWorkedGridQsos(result.qsos) + settingsRepo.setRoamedGrids(result.roamedGrids) _uiState.update { state -> state.copy( lotwSyncing = false, workedGridsCount = result.grids.size,