From 39d4498a545339d42e205c3a57c8e2e74a6c0907 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Tue, 8 Sep 2026 02:47:54 +0800 Subject: [PATCH] feat(map): tap worked grid at zoom>=7 to show per-grid satellite QSO dialog - LoTW sync now stores per-QSO detail (call/time/sat/mode/bands) grouped by gridsquare (PROP_MODE=SAT only), single JSON blob in SharedPreferences - GridQso model derives band label (V/U, U/V, V/A...) from BAND_RX/BAND - Tap detection via osmdroid MapEventsOverlay; selected grid outlined green - Centered dialog: calls sorted by first-contact time, first-QSO summary, expandable call rows list subsequent QSOs in that grid; tap outside closes --- .../core/data/repository/LoTWRepository.kt | 138 +++++++++++++ .../core/data/repository/SettingsRepo.kt | 51 +++++ .../core/data/repository/DatabaseRepoTest.kt | 5 + .../data/repository/LoTWRepositoryTest.kt | 52 +++++ .../look4sat/core/domain/model/GridQso.kt | 64 ++++++ .../core/domain/repository/ILoTWRepository.kt | 10 + .../core/domain/repository/ISettingsRepo.kt | 3 + .../src/main/res/values-zh/strings.xml | 4 + .../src/main/res/values/strings.xml | 4 + .../feature/map/MaidenheadGridOverlay.kt | 30 ++- .../look4sat/feature/map/MapScreen.kt | 192 ++++++++++++++++++ .../rtbishop/look4sat/feature/map/MapState.kt | 1 + .../look4sat/feature/map/MapViewModel.kt | 7 +- .../feature/settings/SettingsViewModel.kt | 10 +- 14 files changed, 557 insertions(+), 14 deletions(-) create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/GridQso.kt 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 5ef60f57..36e210b1 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 @@ -85,6 +85,144 @@ class LoTWRepository : ILoTWRepository { } } + override suspend fun fetchConfirmedGridQsos( + callsign: String, + password: String + ): Pair, Map>>? { + // Single report fetch: grids are derived from the same body as the + // per-QSO detail (avoids a second ARRL hit and keeps the two datasets + // perfectly consistent). + val body = fetchReportBody(callsign, password) ?: return null + val grids = parseConfirmedGrids(body) ?: return null + val qsos = parseConfirmedGridQsos(body) ?: return null + return grids to qsos + } + + private fun fetchReportBody(callsign: String, password: String): String? { + val call = callsign.trim().uppercase() + val pwd = password.trim() + if (call.isBlank() || pwd.isBlank()) return null + val since = "2000-01-01" + val query = buildString { + append("login=").append(URLEncoder.encode(call, "UTF-8")) + append("&password=").append(URLEncoder.encode(pwd, "UTF-8")) + append("&qso_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes") + append("&qso_qslsince=").append(URLEncoder.encode(since, "UTF-8")) + } + return try { + val connection = URL("$BASE_URL?$query").openConnection() as HttpURLConnection + connection.connectTimeout = 30_000 + connection.readTimeout = 120_000 + connection.requestMethod = "GET" + connection.setRequestProperty("Accept-Encoding", "gzip") + connection.instanceFollowRedirects = true + val code = connection.responseCode + if (code !in 200..299) { + connection.disconnect() + return null + } + val stream = connection.inputStream + val body = ("gzip".equals(connection.contentEncoding, ignoreCase = true)) + .let { gz -> if (gz) java.util.zip.GZIPInputStream(stream) else stream } + .bufferedReader().use { it.readText() } + connection.disconnect() + if (body.contains(" password=") && !body.startsWith("ARRL")) return null + body + } catch (e: Exception) { + println("LoTWRepository fetch failure: $e") + null + } + } + + internal fun parseConfirmedGridQsos( + body: String + ): Map>? { + if (!body.contains("", ignoreCase = true)) return null + // One ADIF record = fields up to . We buffer the fields we care + // about, then emit a GridQso on when the record is a satellite + // QSO (PROP_MODE=SAT) carrying a grid. + val result = mutableMapOf>() + var propMode: String? = null + var call = "" + var qsoDate = "" + var timeOn = "" + var satName = "" + var mode = "" + var bandUp = "" + var bandDown = "" + val gridsInRecord = mutableListOf() + + fun emitRecord() { + if (propMode != "SAT" || gridsInRecord.isEmpty()) return + val epochMs = adifTimestampToEpoch(qsoDate, timeOn) + val qso = com.rtbishop.look4sat.core.domain.model.GridQso( + call = call, epochMs = epochMs, satName = satName, + mode = mode, bandUp = bandUp, bandDown = bandDown + ) + for (grid in gridsInRecord) { + result.getOrPut(grid) { mutableListOf() }.add(qso) + } + } + + fun resetRecord() { + propMode = null; call = ""; qsoDate = ""; timeOn = "" + satName = ""; mode = ""; bandUp = ""; bandDown = "" + gridsInRecord.clear() + } + + for (raw in body.lineSequence()) { + val line = raw.trim() + when { + line.equals("", ignoreCase = true) -> { + emitRecord() + resetRecord() + } + line.startsWith(" + propMode = adifValue(line).uppercase() + line.startsWith(" + call = adifValue(line).uppercase() + line.startsWith(" + qsoDate = adifValue(line) + line.startsWith(" + timeOn = adifValue(line) + line.startsWith(" + satName = adifValue(line) + line.startsWith(" + mode = adifValue(line) + line.startsWith(" + bandUp = adifValue(line).uppercase() + line.startsWith(" + bandDown = adifValue(line).uppercase() + line.startsWith(" { + // VUCC_GRIDS holds a comma-separated PAIR of grids + // ("EN52en,EN53fa"); split and keep every 4-char field. + adifValue(line).split(',').forEach { grid -> + val field = grid.trim().uppercase() + if (field.length >= 4) gridsInRecord.add(field.take(4)) + } + } + } + } + return result + } + + /** ADIF field value: "OL62" -> "OL62". */ + private fun adifValue(line: String): String = + line.substringAfter('>').substringBefore("E<").trim() + + /** "20260820" + "1130" (or "113000") -> UTC epoch ms; 0 when unparseable. */ + private fun adifTimestampToEpoch(date: String, time: String): Long = try { + val d = date.trim() + val t = time.trim().padEnd(6, '0').take(6) + val fmt = java.time.format.DateTimeFormatterBuilder() + .appendPattern("yyyyMMddHHmmss") + .toFormatter() + .withZone(java.time.ZoneOffset.UTC) + java.time.Instant.from(fmt.parse(d + t)).toEpochMilli() + } catch (_: Exception) { + 0L + } + internal fun parseConfirmedGrids(body: String): Set? { // LoTW answers with ADIF text; on bad credentials it returns a short error page // containing "password=?" or an -less block. Treat anything without a header 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 b9ed48c7..a81df864 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 @@ -179,6 +179,57 @@ class SettingsRepo( preferences.edit { putString(keyWorkedGrids, array.toString()) } } + // Confirmed satellite QSOs per worked gridsquare, persisted as a single JSON + // object: {"OL62":[{"c":call,"t":epochMs,"s":sat,"m":mode,"bu":up,"bd":down}]}. + private val keyWorkedGridQsos = "workedGridQsos" + + override fun getWorkedGridQsos(): Map> { + val json = preferences.getString(keyWorkedGridQsos, null).orEmpty() + if (json.isBlank()) return emptyMap() + return try { + val root = org.json.JSONObject(json) + val result = mutableMapOf>() + for (grid in root.keys()) { + val array = root.optJSONArray(grid) ?: continue + val list = (0 until array.length()).mapNotNull { i -> + val o = array.optJSONObject(i) ?: return@mapNotNull null + com.rtbishop.look4sat.core.domain.model.GridQso( + call = o.optString("c"), + epochMs = o.optLong("t"), + satName = o.optString("s"), + mode = o.optString("m"), + bandUp = o.optString("bu"), + bandDown = o.optString("bd") + ) + } + if (list.isNotEmpty()) result[grid] = list + } + result + } catch (_: Exception) { + emptyMap() + } + } + + override fun setWorkedGridQsos(qsos: Map>) { + val root = org.json.JSONObject() + for ((grid, list) in qsos) { + val array = org.json.JSONArray() + for (q in list) { + array.put( + org.json.JSONObject() + .put("c", q.call) + .put("t", q.epochMs) + .put("s", q.satName) + .put("m", q.mode) + .put("bu", q.bandUp) + .put("bd", q.bandDown) + ) + } + root.put(grid, array) + } + preferences.edit { putString(keyWorkedGridQsos, root.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 5cce9e71..d795c38c 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 @@ -19,6 +19,7 @@ package com.rtbishop.look4sat.core.data.repository import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings import com.rtbishop.look4sat.core.domain.model.DatabaseState +import com.rtbishop.look4sat.core.domain.model.GridQso import com.rtbishop.look4sat.core.domain.model.OtherSettings import com.rtbishop.look4sat.core.domain.model.PassesSettings import com.rtbishop.look4sat.core.domain.model.RCSettings @@ -291,6 +292,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings()) override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit + + override fun getWorkedGridQsos(): Map> = emptyMap() + + override fun setWorkedGridQsos(qsos: Map>) = Unit } 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 9f6cba64..ec351b07 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 @@ -65,4 +65,56 @@ class LoTWRepositoryTest { val satQso = "SAT\nAO-07\n\n" assertEquals(emptySet(), repo.parseConfirmedGrids(report(satQso))) } + + // region parseConfirmedGridQsos + + private val satQsoFull = "A50QO\n" + + "20260820\n" + + "113045\n" + + "SAT\n" + + "FO-29\n" + + "CW\n" + + "70CM\n" + + "2M\n" + + "NL47\n" + + "\n" + + @Test + fun parseQsosExtractsSatelliteDetail() { + val result = repo.parseConfirmedGridQsos(report(satQsoFull))!! + val qsos = result["NL47"]!! + assertEquals(1, qsos.size) + val qso = qsos[0] + assertEquals("A50QO", qso.call) + assertEquals("FO-29", qso.satName) + assertEquals("CW", qso.mode) + assertEquals("2M", qso.bandUp) + assertEquals("70CM", qso.bandDown) + assertEquals("V/U", qso.bandLabel) + assertEquals(2026, java.time.Instant.ofEpochMilli(qso.epochMs).atZone(java.time.ZoneOffset.UTC).year) + } + + @Test + fun parseQsosExcludesGroundQsos() { + val groundQso = "BA7OPF\n20260821\nNL47\n\n" + val result = repo.parseConfirmedGridQsos(report(satQsoFull, groundQso))!! + assertEquals(1, result["NL47"]!!.size) + } + + @Test + fun parseQsosVuccPairFeedsBothGrids() { + val qso = "SAT\nSO-50\n" + + "20260819\n1231\n" + + "EN52en,EN53fa\n\n" + val result = repo.parseConfirmedGridQsos(report(qso))!! + assertEquals(setOf("EN52", "EN53"), result.keys) + assertEquals(result["EN52"]!!.first(), result["EN53"]!!.first()) + } + + @Test + fun parseQsosRejectsBodyWithoutEoh() { + assertNull(repo.parseConfirmedGridQsos("Username/password incorrect")) + } + + // endregion } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/GridQso.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/GridQso.kt new file mode 100644 index 00000000..e6744f9c --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/GridQso.kt @@ -0,0 +1,64 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.domain.model + +/** + * One confirmed satellite QSO as reported by LoTW, kept per worked gridsquare + * so the map can show, for any worked grid, which callsigns were worked there + * and when. + * + * @param call opposite station callsign (as logged by the opposite op) + * @param epochMs QSO date+time in UTC milliseconds + * @param satName satellite name (e.g. "FO-29") + * @param mode ADIF mode (FM / CW / SSB ...) + * @param bandUp uplink band as reported by LoTW (BAND_RX: "70CM", "2M", "10M"...) + * @param bandDown downlink band (BAND: "2M", "70CM"...) — may be empty when + * LoTW did not include it + */ +data class GridQso( + val call: String, + val epochMs: Long, + val satName: String, + val mode: String, + val bandUp: String, + val bandDown: String +) { + /** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */ + val bandLabel: String + get() { + val up = bandAbbrev(bandUp) + val down = bandAbbrev(bandDown) + return when { + up.isNotEmpty() && down.isNotEmpty() -> "$up/$down" + up.isNotEmpty() -> up + else -> "" + } + } + + private fun bandAbbrev(band: String): String = when (band.trim().uppercase()) { + "70CM" -> "U" + "2M" -> "V" + "10M" -> "A" + "6M" -> "V" + "1.2G" -> "L" + "2.4G" -> "S" + "23CM" -> "L" + "13CM" -> "S" + else -> band.trim().uppercase() + } +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWRepository.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWRepository.kt index b19d3ee5..d78a547a 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWRepository.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWRepository.kt @@ -25,4 +25,14 @@ interface ILoTWRepository { * Returns the 4-char grid set, or null on any failure (network / bad credentials). */ suspend fun fetchConfirmedGrids(callsign: String, password: String): Set? + + /** + * Same report, but keeps the per-QSO detail of every confirmed satellite + * QSO (call / time / satellite / mode / bands), grouped by worked 4-char + * gridsquare. Returns null on any failure. + */ + suspend fun fetchConfirmedGridQsos( + callsign: String, + password: String + ): Pair, Map>>? } 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 88446eea..3a6be42f 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 @@ -99,6 +99,9 @@ interface ISettingsRepo { fun updateWavelogSettings(settings: WavelogSettings) fun getWorkedGrids(): Set fun setWorkedGrids(grids: Set) + /** Confirmed satellite QSOs grouped by worked 4-char gridsquare (map tap detail). */ + fun getWorkedGridQsos(): Map> + fun setWorkedGridQsos(qsos: Map>) //endregion //region # LoTW confirmed-grids settings diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 05d22c58..8d6d50a8 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -118,6 +118,10 @@ 周期:% .0f min 速度:%.2f km/s 能见度:%s + + %d 个呼号 · %d 次通联 + 首通 + 卫星未知 日照中 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 95cad857..08043a76 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -143,6 +143,10 @@ Period: %.0f min Velocity: %.2f km/s Visibility: %s + + %d callsigns · %d QSOs + First + satellite unknown 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 ef307990..95df1f77 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 @@ -61,6 +61,11 @@ class MaidenheadGridOverlay : Overlay() { style = Paint.Style.STROKE color = Color.argb(255, 90, 200, 255) } + private val selectedLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + strokeWidth = 4.5f + style = Paint.Style.STROKE + color = Color.argb(255, 76, 217, 100) + } /** Worked gridsquares (4-char, uppercase) to highlight, e.g. {"OL62", "PM95"}. */ var workedGrids: Set = emptySet() @@ -68,6 +73,9 @@ class MaidenheadGridOverlay : Overlay() { /** The station's own 4-char gridsquare, drawn with a distinct outline. */ var ownGrid: String? = null + /** The currently tapped worked grid (4-char) highlighted with a distinct outline, or null. */ + var selectedGrid: String? = null + /** Viewport width, refreshed each draw; used by projectionToX bounds. */ private var canvasWidthPx = 1080f @@ -118,6 +126,8 @@ class MaidenheadGridOverlay : Overlay() { // The station's own grid square (4-char, only meaningful at sub-square zoom) val ownCell = ownGrid?.takeIf { zoom >= GRID_ZOOM_SUB } + // The tapped worked grid gets a distinct outline (same zoom gate). + val selectedCell = selectedGrid?.takeIf { zoom >= GRID_ZOOM_SUB } // Worked-grid highlight fills. // - Sub-square zoom: fill each worked 4-char cell directly. @@ -180,22 +190,24 @@ class MaidenheadGridOverlay : Overlay() { } // The station's own grid square: redraw its four borders thicker on top. - if (ownCell != null) { - val ownColIdx = firstCol..lastCol + // The tapped worked grid gets the same treatment in a different color. + if (ownCell != null || selectedCell != null) { for (row in firstRow..lastRow) { val lat = row * cellLat if (lat < -90.0 || lat >= 90.0) continue - for (col in ownColIdx) { + for (col in firstCol..lastCol) { val lon = col * cellLon - if (cellLabel(lat, lon, zoom) != ownCell) continue + val label = cellLabel(lat, lon, zoom) + if (label != ownCell && label != selectedCell) continue val yTop = projectionToY(projection, lat + cellLat) ?: continue val yBottom = projectionToY(projection, lat) ?: continue val xLeft = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue val xRight = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue - canvas.drawLine(xLeft, yTop, xRight, yTop, ownLinePaint) - canvas.drawLine(xLeft, yBottom, xRight, yBottom, ownLinePaint) - canvas.drawLine(xLeft, yTop, xLeft, yBottom, ownLinePaint) - canvas.drawLine(xRight, yTop, xRight, yBottom, ownLinePaint) + val paint = if (label == selectedCell) selectedLinePaint else ownLinePaint + canvas.drawLine(xLeft, yTop, xRight, yTop, paint) + canvas.drawLine(xLeft, yBottom, xRight, yBottom, paint) + canvas.drawLine(xLeft, yTop, xLeft, yBottom, paint) + canvas.drawLine(xRight, yTop, xRight, yBottom, paint) } } } @@ -306,7 +318,7 @@ class MaidenheadGridOverlay : Overlay() { return l } - private companion object { + internal companion object { const val FIELD_LAT = 10.0 const val FIELD_LON = 20.0 const val SUB_SQUARE_LAT = 1.0 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 a8ba6ae3..54baa79b 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 @@ -25,14 +25,18 @@ import android.graphics.Paint import android.graphics.Rect import android.graphics.drawable.Drawable import androidx.collection.LruCache +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard @@ -138,6 +142,36 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView: val timeString = uiState.mapData?.aosTime ?: "00:00:00" val isTimeAos = uiState.mapData?.isTimeAos ?: true + // 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) } + // Attach the tap listener whenever grid mode / worked grids change. + val workedGrids = uiState.workedGrids + val isGridMode = uiState.isGridMode + DisposableEffect(isGridMode, workedGrids) { + val receiver = object : org.osmdroid.events.MapEventsReceiver { + override fun singleTapConfirmedHelper(p: GeoPoint?): Boolean { + if (!isGridMode || p == null) return false + val zoom = mapView.zoomLevelDouble + if (zoom < MaidenheadGridOverlay.LABEL_ZOOM_SUB) return false + val grid = gridOfPoint(p.latitude, p.longitude) ?: return false + if (grid !in workedGrids) return false + selectedGrid = grid + return true + } + + override fun longPressHelper(p: GeoPoint?): Boolean = false + } + val eventsOverlay = org.osmdroid.views.overlay.MapEventsOverlay(receiver) + mapView.overlays.add(eventsOverlay) + onDispose { mapView.overlays.remove(eventsOverlay) } + } + // Keep the overlay's selected highlight in sync with the dialog. + LaunchedEffect(selectedGrid) { + (mapView.overlays.getOrNull(OVERLAY_GRID) as? MaidenheadGridOverlay)?.selectedGrid = selectedGrid + mapView.invalidate() + } + LaunchedEffect(uiState.track) { // In grid mode the map is centered on the local grid square; following // the satellite subpoint here would override that centering on every @@ -200,8 +234,155 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView: } } } + // Centered dialog listing the tapped worked grid's confirmed satellite QSOs. + // Dismissed by tapping outside (no explicit close button). + selectedGrid?.let { grid -> + WorkedGridQsoDialog( + grid = grid, + qsos = uiState.workedGridQsos[grid].orEmpty().sortedBy { it.epochMs }, + isUtc = uiState.isUtc, + onDismiss = { selectedGrid = null } + ) + } } +// region Worked-grid QSO dialog + +@Composable +private fun WorkedGridQsoDialog( + grid: String, + qsos: List, + isUtc: Boolean, + onDismiss: () -> Unit +) { + androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) { + // Centered card, ~85% width, internal scroll for long lists. + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + modifier = Modifier.fillMaxWidth(0.88f) + ) { + Column { + // Header: grid + counts + Column(Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) { + Text( + text = grid, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + ) + val distinctCalls = qsos.map { it.call }.distinct().size + Text( + text = stringResource(R.string.grid_qso_calls_count, distinctCalls, qsos.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + androidx.compose.material3.HorizontalDivider() + if (qsos.isEmpty()) { + Text( + text = stringResource(R.string.grid_qso_no_sat), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(16.dp) + ) + } else { + // Group by callsign preserving first-contact order (list is + // already sorted oldest-first); expandable rows. + val grouped = remember(qsos) { + qsos.groupBy { it.call }.entries.sortedBy { it.value.first().epochMs } + } + Column( + Modifier + .heightIn(max = 380.dp) + .verticalScroll(rememberScrollState()) + ) { + grouped.forEach { (call, callQsos) -> + WorkedGridCallRow(call, callQsos = callQsos, isUtc = isUtc) + androidx.compose.material3.HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + } + } + } + } + } + } +} + +@Composable +private fun WorkedGridCallRow( + call: String, + callQsos: List, + isUtc: Boolean, +) { + var expanded by remember { mutableStateOf(false) } + val first = callQsos.first() + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + ) { + Text( + text = call, + style = MaterialTheme.typography.titleMedium, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.weight(1f) + ) + Text( + text = formatDate(first.epochMs, isUtc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = if (expanded) " ▴" else " ▾", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } + // First-QSO summary line, always visible. + Text( + text = stringResource(R.string.grid_qso_first) + " · " + qsoSummary(first, isUtc), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 3.dp) + ) + // Subsequent QSOs in this grid, revealed on expand. + if (expanded && callQsos.size > 1) { + callQsos.drop(1).forEachIndexed { index, qso -> + val ordinal = "${index + 2}" + Text( + text = "$ordinal " + qsoSummary(qso, isUtc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 3.dp) + ) + } + } + } +} + +private fun qsoSummary(qso: com.rtbishop.look4sat.core.domain.model.GridQso, isUtc: Boolean): String { + val sat = qso.satName.ifBlank { "?" } + val mode = qso.mode.ifBlank { "?" } + val band = qso.bandLabel + val time = formatTime(qso.epochMs, isUtc) + return listOf(sat, mode, band, time).filter { it.isNotBlank() }.joinToString(" · ") +} + +private fun formatDate(epochMs: Long, isUtc: Boolean): String { + if (epochMs <= 0L) return "--" + val zone = if (isUtc) java.time.ZoneOffset.UTC else java.time.ZoneId.systemDefault() + return java.time.Instant.ofEpochMilli(epochMs).atZone(zone) + .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd")) +} + +private fun formatTime(epochMs: Long, isUtc: Boolean): String { + if (epochMs <= 0L) return "--:--" + val zone = if (isUtc) java.time.ZoneOffset.UTC else java.time.ZoneId.systemDefault() + return java.time.Instant.ofEpochMilli(epochMs).atZone(zone) + .format(java.time.format.DateTimeFormatter.ofPattern(if (isUtc) "HH:mm'Z'" else "HH:mm")) +} +// endregion + // region Map data composables @Composable private fun MapDataCard(data: MapData) { @@ -345,6 +526,17 @@ private fun ownGridOf(stationPosition: GeoPos?): String? { return "${'A' + fieldLon}${'A' + fieldLat}$subLon$subLat" } +/** The 4-char Maidenhead gridsquare containing the given point, or null outside the grid. */ +private fun gridOfPoint(latitude: Double, longitude: Double): String? { + if (latitude < -90.0 || latitude >= 90.0) return null + val lon = ((longitude + 180.0) % 360.0 + 360.0) % 360.0 - 180.0 + val fieldLat = ((latitude + 90.0) / 10.0).toInt().coerceIn(0, 17) + val fieldLon = ((lon + 180.0) / 20.0).toInt().coerceIn(0, 17) + val subLat = ((latitude + 90.0) % 10.0).toInt() + val subLon = ((lon + 180.0) % 20.0 / 2.0).toInt() + return "${'A' + fieldLon}${'A' + fieldLat}$subLon$subLat" +} + private fun setStationPosition(stationPos: GeoPos, mapView: MapView) { try { val overlay = mapView.overlays[OVERLAY_STATION] 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 a9293fb3..30945784 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 @@ -28,6 +28,7 @@ data class MapState( val isUtc: Boolean = false, val isGridMode: Boolean = false, val workedGrids: Set = emptySet(), + val workedGridQsos: Map> = emptyMap(), 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 6532b36e..bacec457 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,12 @@ class MapViewModel( } viewModelScope.launch { settingsRepo.wavelogSettings.collectLatest { _ -> - _uiState.update { it.copy(workedGrids = settingsRepo.getWorkedGrids()) } + _uiState.update { + it.copy( + workedGrids = settingsRepo.getWorkedGrids(), + workedGridQsos = settingsRepo.getWorkedGridQsos() + ) + } } } val (selectedCatNum, _) = satelliteRepo.selectedPass.value 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 17c7cac8..9d8fa30f 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 @@ -201,15 +201,17 @@ class SettingsViewModel( settingsRepo.updateLoTWSettings(settings) _uiState.update { it.copy(lotwSyncing = true, lotwMessage = null) } viewModelScope.launch { - val lotwGrids = lotwRepo.fetchConfirmedGrids(settings.callsign, settings.password) + val lotwResult = lotwRepo.fetchConfirmedGridQsos(settings.callsign, settings.password) _uiState.update { state -> - if (lotwGrids == null) { + if (lotwResult == null) { state.copy(lotwSyncing = false, lotwMessage = "LoTW sync failed — check callsign/password/network") } else { // Wavelog entry removed: LoTW is now the only source, so the // synced set fully replaces the stored worked grids. - settingsRepo.setWorkedGrids(lotwGrids) - state.copy(lotwSyncing = false, workedGridsCount = lotwGrids.size, lotwMessage = null) + val (grids, qsos) = lotwResult + settingsRepo.setWorkedGrids(grids) + settingsRepo.setWorkedGridQsos(qsos) + state.copy(lotwSyncing = false, workedGridsCount = grids.size, lotwMessage = null) } } }