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>? {
// 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
@@ -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<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)
private val keyLoTWCall = "lotwCallsign"
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.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<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 {
@@ -65,4 +65,56 @@ class LoTWRepositoryTest {
val satQso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>AO-07\n<EOR>\n"
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).
*/
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 getWorkedGrids(): 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
//region # LoTW confirmed-grids settings
@@ -118,6 +118,10 @@
<string name="map_period">周期:% .0f min</string>
<string name="map_velocity">速度:%.2f km/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>
<!-- Settings screen -->
@@ -143,6 +143,10 @@
<string name="map_period">Period: %.0f min</string>
<string name="map_velocity">Velocity: %.2f km/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>
<!-- Settings screen -->
@@ -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<String> = 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
@@ -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<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) {
// 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<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
@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]
@@ -28,6 +28,7 @@ data class MapState(
val isUtc: Boolean = false,
val isGridMode: Boolean = false,
val workedGrids: Set<String> = emptySet(),
val workedGridQsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap(),
val stationPosition: GeoPos? = null,
val orbitalPass: OrbitalPass,
val track: List<List<GeoPos>>? = null,
@@ -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
@@ -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)
}
}
}