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
This commit is contained in:
atsunatsu committed 2026-09-08 02:47:54 +08:00
1 parent 9ed2aebc9d
commit 39d4498a54
14 files changed
+557 -14

No files matched your search

@@ -85,6 +85,144 @@ class LoTWRepository : ILoTWRepository {
} }
} }
override suspend fun fetchConfirmedGridQsos(
callsign: String,
password: String
): Pair<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>>? {
// 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<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>? {
if (!body.contains("<eoh>", ignoreCase = true)) return null
// One ADIF record = fields up to <EOR>. We buffer the fields we care
// about, then emit a GridQso on <EOR> when the record is a satellite
// QSO (PROP_MODE=SAT) carrying a grid.
val result = mutableMapOf<String, MutableList<com.rtbishop.look4sat.core.domain.model.GridQso>>()
var propMode: String? = null
var call = ""
var qsoDate = ""
var timeOn = ""
var satName = ""
var mode = ""
var bandUp = ""
var bandDown = ""
val gridsInRecord = mutableListOf<String>()
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("<EOR>", ignoreCase = true) -> {
emitRecord()
resetRecord()
}
line.startsWith("<PROP_MODE:") ->
propMode = adifValue(line).uppercase()
line.startsWith("<CALL:") ->
call = adifValue(line).uppercase()
line.startsWith("<QSO_DATE:") ->
qsoDate = adifValue(line)
line.startsWith("<TIME_ON:") ->
timeOn = adifValue(line)
line.startsWith("<SAT_NAME:") ->
satName = adifValue(line)
line.startsWith("<MODE:") ->
mode = adifValue(line)
line.startsWith("<BAND_RX:") ->
bandUp = adifValue(line).uppercase()
line.startsWith("<BAND:") && !line.startsWith("<BAND_RX:") ->
bandDown = adifValue(line).uppercase()
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
// 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: "<GRIDSQUARE:4>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<String>? { internal fun parseConfirmedGrids(body: String): Set<String>? {
// LoTW answers with ADIF text; on bad credentials it returns a short error page // LoTW answers with ADIF text; on bad credentials it returns a short error page
// containing "password=?" or an <eoh>-less block. Treat anything without a header // containing "password=?" or an <eoh>-less block. Treat anything without a header
@@ -179,6 +179,57 @@ class SettingsRepo(
preferences.edit { putString(keyWorkedGrids, array.toString()) } 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<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> {
val json = preferences.getString(keyWorkedGridQsos, null).orEmpty()
if (json.isBlank()) return emptyMap()
return try {
val root = org.json.JSONObject(json)
val result = mutableMapOf<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>()
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<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) {
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) // LoTW credentials (stored locally on the device only)
private val keyLoTWCall = "lotwCallsign" private val keyLoTWCall = "lotwCallsign"
private val keyLoTWPass = "lotwPassword" private val keyLoTWPass = "lotwPassword"
@@ -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.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.DatabaseState 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.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings 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()) MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) = Unit
} }
private fun defaultDataSourcesSettings(): DataSourcesSettings { private fun defaultDataSourcesSettings(): DataSourcesSettings {
@@ -65,4 +65,56 @@ class LoTWRepositoryTest {
val satQso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>AO-07\n<EOR>\n" val satQso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>AO-07\n<EOR>\n"
assertEquals(emptySet<String>(), repo.parseConfirmedGrids(report(satQso))) assertEquals(emptySet<String>(), repo.parseConfirmedGrids(report(satQso)))
} }
// region parseConfirmedGridQsos
private val satQsoFull = "<CALL:5>A50QO\n" +
"<QSO_DATE:8>20260820\n" +
"<TIME_ON:6>113045\n" +
"<PROP_MODE:3>SAT\n" +
"<SAT_NAME:5>FO-29\n" +
"<MODE:2>CW\n" +
"<BAND:3>70CM\n" +
"<BAND_RX:3>2M\n" +
"<GRIDSQUARE:4>NL47\n" +
"<EOR>\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 = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<GRIDSQUARE:4>NL47\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(satQsoFull, groundQso))!!
assertEquals(1, result["NL47"]!!.size)
}
@Test
fun parseQsosVuccPairFeedsBothGrids() {
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
"<QSO_DATE:8>20260819\n<TIME_ON:4>1231\n" +
"<VUCC_GRIDS:11>EN52en,EN53fa\n<EOR>\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("<HTML>Username/password incorrect</HTML>"))
}
// endregion
} }
@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}
@@ -25,4 +25,14 @@ interface ILoTWRepository {
* Returns the 4-char grid set, or null on any failure (network / bad credentials). * Returns the 4-char grid set, or null on any failure (network / bad credentials).
*/ */
suspend fun fetchConfirmedGrids(callsign: String, password: String): Set<String>? suspend fun fetchConfirmedGrids(callsign: String, password: String): Set<String>?
/**
* 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<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>>?
} }
@@ -99,6 +99,9 @@ interface ISettingsRepo {
fun updateWavelogSettings(settings: WavelogSettings) fun updateWavelogSettings(settings: WavelogSettings)
fun getWorkedGrids(): Set<String> fun getWorkedGrids(): Set<String>
fun setWorkedGrids(grids: Set<String>) fun setWorkedGrids(grids: Set<String>)
/** Confirmed satellite QSOs grouped by worked 4-char gridsquare (map tap detail). */
fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>
fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>)
//endregion //endregion
//region # LoTW confirmed-grids settings //region # LoTW confirmed-grids settings
@@ -118,6 +118,10 @@
<string name="map_period">周期:% .0f min</string> <string name="map_period">周期:% .0f min</string>
<string name="map_velocity">速度:%.2f km/s</string> <string name="map_velocity">速度:%.2f km/s</string>
<string name="map_visibility">能见度:%s</string> <string name="map_visibility">能见度:%s</string>
<!-- Worked-grid QSO dialog -->
<string name="grid_qso_calls_count">%d 个呼号 · %d 次通联</string>
<string name="grid_qso_first">首通</string>
<string name="grid_qso_no_sat">卫星未知</string>
<string name="map_visible">日照中</string> <string name="map_visible">日照中</string>
<!-- Settings screen --> <!-- Settings screen -->
@@ -143,6 +143,10 @@
<string name="map_period">Period: %.0f min</string> <string name="map_period">Period: %.0f min</string>
<string name="map_velocity">Velocity: %.2f km/s</string> <string name="map_velocity">Velocity: %.2f km/s</string>
<string name="map_visibility">Visibility: %s</string> <string name="map_visibility">Visibility: %s</string>
<!-- Worked-grid QSO dialog -->
<string name="grid_qso_calls_count">%d callsigns · %d QSOs</string>
<string name="grid_qso_first">First</string>
<string name="grid_qso_no_sat">satellite unknown</string>
<string name="map_visible">Visible</string> <string name="map_visible">Visible</string>
<!-- Settings screen --> <!-- Settings screen -->
@@ -61,6 +61,11 @@ class MaidenheadGridOverlay : Overlay() {
style = Paint.Style.STROKE style = Paint.Style.STROKE
color = Color.argb(255, 90, 200, 255) 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"}. */ /** Worked gridsquares (4-char, uppercase) to highlight, e.g. {"OL62", "PM95"}. */
var workedGrids: Set<String> = emptySet() var workedGrids: Set<String> = emptySet()
@@ -68,6 +73,9 @@ class MaidenheadGridOverlay : Overlay() {
/** The station's own 4-char gridsquare, drawn with a distinct outline. */ /** The station's own 4-char gridsquare, drawn with a distinct outline. */
var ownGrid: String? = null 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. */ /** Viewport width, refreshed each draw; used by projectionToX bounds. */
private var canvasWidthPx = 1080f 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) // The station's own grid square (4-char, only meaningful at sub-square zoom)
val ownCell = ownGrid?.takeIf { zoom >= GRID_ZOOM_SUB } 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. // Worked-grid highlight fills.
// - Sub-square zoom: fill each worked 4-char cell directly. // - 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. // The station's own grid square: redraw its four borders thicker on top.
if (ownCell != null) { // The tapped worked grid gets the same treatment in a different color.
val ownColIdx = firstCol..lastCol if (ownCell != null || selectedCell != null) {
for (row in firstRow..lastRow) { for (row in firstRow..lastRow) {
val lat = row * cellLat val lat = row * cellLat
if (lat < -90.0 || lat >= 90.0) continue if (lat < -90.0 || lat >= 90.0) continue
for (col in ownColIdx) { for (col in firstCol..lastCol) {
val lon = col * cellLon 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 yTop = projectionToY(projection, lat + cellLat) ?: continue
val yBottom = projectionToY(projection, lat) ?: continue val yBottom = projectionToY(projection, lat) ?: continue
val xLeft = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue val xLeft = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
val xRight = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue val xRight = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
canvas.drawLine(xLeft, yTop, xRight, yTop, ownLinePaint) val paint = if (label == selectedCell) selectedLinePaint else ownLinePaint
canvas.drawLine(xLeft, yBottom, xRight, yBottom, ownLinePaint) canvas.drawLine(xLeft, yTop, xRight, yTop, paint)
canvas.drawLine(xLeft, yTop, xLeft, yBottom, ownLinePaint) canvas.drawLine(xLeft, yBottom, xRight, yBottom, paint)
canvas.drawLine(xRight, yTop, xRight, yBottom, ownLinePaint) canvas.drawLine(xLeft, yTop, xLeft, yBottom, paint)
canvas.drawLine(xRight, yTop, xRight, yBottom, paint)
} }
} }
} }
@@ -306,7 +318,7 @@ class MaidenheadGridOverlay : Overlay() {
return l return l
} }
private companion object { internal companion object {
const val FIELD_LAT = 10.0 const val FIELD_LAT = 10.0
const val FIELD_LON = 20.0 const val FIELD_LON = 20.0
const val SUB_SQUARE_LAT = 1.0 const val SUB_SQUARE_LAT = 1.0
@@ -25,14 +25,18 @@ import android.graphics.Paint
import android.graphics.Rect import android.graphics.Rect
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import androidx.collection.LruCache import androidx.collection.LruCache
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width 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.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard 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 timeString = uiState.mapData?.aosTime ?: "00:00:00"
val isTimeAos = uiState.mapData?.isTimeAos ?: true 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<String?>(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) { LaunchedEffect(uiState.track) {
// In grid mode the map is centered on the local grid square; following // In grid mode the map is centered on the local grid square; following
// the satellite subpoint here would override that centering on every // 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<com.rtbishop.look4sat.core.domain.model.GridQso>,
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<com.rtbishop.look4sat.core.domain.model.GridQso>,
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 // region Map data composables
@Composable @Composable
private fun MapDataCard(data: MapData) { private fun MapDataCard(data: MapData) {
@@ -345,6 +526,17 @@ private fun ownGridOf(stationPosition: GeoPos?): String? {
return "${'A' + fieldLon}${'A' + fieldLat}$subLon$subLat" 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) { private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
try { try {
val overlay = mapView.overlays[OVERLAY_STATION] val overlay = mapView.overlays[OVERLAY_STATION]
@@ -28,6 +28,7 @@ data class MapState(
val isUtc: Boolean = false, val isUtc: Boolean = false,
val isGridMode: Boolean = false, val isGridMode: 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 stationPosition: GeoPos? = null, val stationPosition: GeoPos? = null,
val orbitalPass: OrbitalPass, val orbitalPass: OrbitalPass,
val track: List<List<GeoPos>>? = null, val track: List<List<GeoPos>>? = null,
@@ -79,7 +79,12 @@ class MapViewModel(
} }
viewModelScope.launch { viewModelScope.launch {
settingsRepo.wavelogSettings.collectLatest { _ -> 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 val (selectedCatNum, _) = satelliteRepo.selectedPass.value
@@ -201,15 +201,17 @@ class SettingsViewModel(
settingsRepo.updateLoTWSettings(settings) settingsRepo.updateLoTWSettings(settings)
_uiState.update { it.copy(lotwSyncing = true, lotwMessage = null) } _uiState.update { it.copy(lotwSyncing = true, lotwMessage = null) }
viewModelScope.launch { viewModelScope.launch {
val lotwGrids = lotwRepo.fetchConfirmedGrids(settings.callsign, settings.password) val lotwResult = lotwRepo.fetchConfirmedGridQsos(settings.callsign, settings.password)
_uiState.update { state -> _uiState.update { state ->
if (lotwGrids == null) { if (lotwResult == null) {
state.copy(lotwSyncing = false, lotwMessage = "LoTW sync failed — check callsign/password/network") state.copy(lotwSyncing = false, lotwMessage = "LoTW sync failed — check callsign/password/network")
} else { } else {
// Wavelog entry removed: LoTW is now the only source, so the // Wavelog entry removed: LoTW is now the only source, so the
// synced set fully replaces the stored worked grids. // synced set fully replaces the stored worked grids.
settingsRepo.setWorkedGrids(lotwGrids) val (grids, qsos) = lotwResult
state.copy(lotwSyncing = false, workedGridsCount = lotwGrids.size, lotwMessage = null) settingsRepo.setWorkedGrids(grids)
settingsRepo.setWorkedGridQsos(qsos)
state.copy(lotwSyncing = false, workedGridsCount = grids.size, lotwMessage = null)
} }
} }
} }