Compare commits

...
Author SHA1 Message Date
atsunatsu 3b0874d707 chore: bump version to 4.4.6-ba7opf.9.8 2026-09-08 02:56:16 +08:00
atsunatsu 2c5b1479f1 test(lotw): VUCC up to 4 grids in one contact; clarify comment 2026-09-08 02:51:24 +08:00
atsunatsu 39d4498a54 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
2026-09-08 02:47:54 +08:00
atsunatsu 9ed2aebc9d fix(lotw): only collect satellite QSO grids (PROP_MODE=SAT); keep both grids of VUCC_GRIDS pairs 2026-09-08 02:09:25 +08:00
atsunatsu 72d5e13acf tune(map): 4-char sub-square labels at zoom>=7; zoom 6 shows grid lines only 2026-09-08 01:55:40 +08:00
atsunatsu 736dc23aae chore(map): drop unused FIELD_ZOOM_LABELS constant 2026-09-08 01:52:01 +08:00
atsunatsu 052ba79c94 fix(map): field labels visible at all zooms (pixel-size gated), not gated by FIELD_ZOOM_LABELS 2026-09-08 01:51:14 +08:00
atsunatsu cb648caf65 tune(map): raise thresholds — sub-square grid lines at zoom>=6 (was 5), field labels at zoom>=4 (was 3) 2026-09-08 01:48:24 +08:00
atsunatsu 05170541ed fix(map): grid mode centering via view.post (first-layout reset); antimeridian unwrap of visible bbox fixes Pacific-centered vanishing grid; tile meridians/fills across world repeats at low zoom 2026-09-08 01:39:21 +08:00
atsunatsu e75d2dc410 chore: bump version to 4.4.6-ba7opf.9.7 2026-09-08 00:27:17 +08:00
atsunatsu d55a07bc26 feat(map): fill individual worked 2x1 degree squares green at field zoom without square grid lines 2026-09-08 00:14:05 +08:00
atsunatsu c372386a77 fix(data): LoTW sync resilience — longer timeouts (30s/120s), gzip transfer, follow redirects; ARRL server-side latency is the bottleneck 2026-09-08 00:05:22 +08:00
atsunatsu de6ade025c feat(settings): LoTW dialog — hint rewritten without Wavelog, confirm button is now Sync, remove separate sync button 2026-09-07 23:29:19 +08:00
atsunatsu 2eb6d6cd5b fix(map): stop satellite tracking animation in grid mode so it stays centered on the local grid 2026-09-07 22:54:19 +08:00
atsunatsu c09f718aae feat: mutual chip uses theme primary yellow, plain text distance; top bar text indent; low-zoom meridians via direct Mercator math (bypass osmdroid wrap bug); remove Wavelog sync entry — LoTW is the sole grid source (replace instead of merge) 2026-09-07 19:10:44 +08:00
atsunatsu a9fe0b74e9 feat: mutual match screen target-grid chip (needle bearing + grid + distance); grid mode centering no longer swallowed when station position is not yet available 2026-09-07 17:54:14 +08:00
atsunatsu 2c9a5b376f feat: release-notes body in update checker; grid mode centers on station grid on every entry; own grid square drawn with thick outline; low-zoom meridian vanishing while panning fixed 2026-09-07 15:06:33 +08:00
21 changed files with 1096 additions and 156 deletions

No files matched your search

@@ -57,15 +57,25 @@ class LoTWRepository : ILoTWRepository {
}
try {
val connection = URL("$BASE_URL?$query").openConnection() as HttpURLConnection
connection.connectTimeout = 15_000
connection.readTimeout = 60_000
// ARRL can be slow to accept connections from mobile networks
// (long TLS handshakes across the Pacific, occasional server-side
// queueing). 30s connect + 120s read gives the request enough
// headroom; the sync button stays disabled meanwhile so users
// see progress rather than a hung dialog.
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@withContext null
}
val body = connection.inputStream.bufferedReader().use { it.readText() }
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@withContext null
parseConfirmedGrids(body)
@@ -75,6 +85,145 @@ 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 list of grids
// ("EN52en,EN53fa"), up to four for contacts spanning
// several squares; 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
@@ -84,14 +233,33 @@ class LoTWRepository : ILoTWRepository {
// (LoTW writes it lowercase). Match case-insensitively to be safe.
if (!body.contains("<eoh>", ignoreCase = true)) return null
val grids = mutableSetOf<String>()
// ADIF fields of one QSO record span multiple lines and are terminated by
// <EOR>. Satellite QSOs carry <PROP_MODE:3>SAT (plus <SAT_NAME>); ground
// QSOs omit it. Grid fields (GRIDSQUARE / VUCC_GRIDS) must only be
// collected for records whose PROP_MODE is SAT, otherwise the map mixes
// in terrestrial contacts.
var propMode: String? = null
for (raw in body.lineSequence()) {
val line = raw.trim()
val value = when {
line.startsWith("<GRIDSQUARE:") -> line.substringAfter('>')
line.startsWith("<VUCC_GRIDS:") -> line.substringAfter('>')
else -> continue
}.substringBefore("E<").trim().uppercase()
if (value.length >= 4) grids.add(value.take(4))
when {
line.equals("<EOR>", ignoreCase = true) -> propMode = null
line.startsWith("<PROP_MODE:") -> {
propMode = line.substringAfter('>').substringBefore("E<").trim().uppercase()
}
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
if (propMode == "SAT") {
// VUCC_GRIDS holds a comma-separated PAIR of grids
// ("EN52en,EN53fa") for contacts spanning two squares —
// split on ',' and take the 4-char field of each, or the
// second grid is silently dropped.
val value = line.substringAfter('>').substringBefore("E<")
value.split(',').forEach { grid ->
val field = grid.trim().uppercase()
if (field.length >= 4) grids.add(field.take(4))
}
}
}
}
}
return grids
}
@@ -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"
@@ -58,6 +58,14 @@ class UpdateRepository(
val title = Regex("<title>(.*?)</title>", RegexOption.DOT_MATCHES_ALL)
.find(html)?.groupValues?.get(1)?.trim()
?.substringBefore("·")?.removePrefix("Release")?.trim() ?: tag
// Release notes live in the page's markdown-body section (first occurrence
// is the release description). Strip HTML tags for plain-text display.
val raw = Regex("<div[^>]*class=\"[^\"]*markdown-body[^\"]*\"[^>]*>(.*?)</div>", RegexOption.DOT_MATCHES_ALL)
.find(html)?.groupValues?.get(1) ?: ""
val body = raw.replace(Regex("<[^>]+>"), "")
.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
.replace("&quot;", "\"").replace("&#39;", "'")
.trim()
// The release APK asset follows the fixed naming scheme used by the build:
// Look4Sat-<tag without leading v>-release.apk. When the page was fetched
// through an accelerator mirror (https://<mirror>/https://github.com/...),
@@ -72,7 +80,7 @@ class UpdateRepository(
return LatestRelease(
versionTag = tag,
title = title,
body = "",
body = body,
apkUrl = apkUrl
)
}
@@ -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
@@ -214,7 +215,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
)
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
OtherSettings(false, false, false, false, false, false, false, false)
OtherSettings(
false, false, false, false, false, false, false,
shouldSeeWarning = false, shouldSeeWhatsNew = false
)
)
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
@@ -270,6 +274,28 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = Unit
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
override fun getWorkedGrids(): Set<String> = emptySet()
private val workedGrids = MutableStateFlow(emptySet<String>())
override fun setWorkedGrids(grids: Set<String>) {
workedGrids.value = grids
}
override val lotwSettings: StateFlow<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 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 {
@@ -0,0 +1,131 @@
package com.rtbishop.look4sat.core.data.repository
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class LoTWRepositoryTest {
private val repo = LoTWRepository()
private fun report(vararg records: String): String =
buildString {
append("<ADIF_VERS:5>3.1.4\n")
append("<PROGRAMID:8>look4sat\n")
append("<eoh>\n")
records.forEach { append(it).append("\n") }
}
@Test
fun parseRejectsBodyWithoutEoh() {
assertNull(repo.parseConfirmedGrids("<HTML>Username/password incorrect</HTML>"))
}
@Test
fun parseCollectsOnlySatelliteGrids() {
// Satellite QSO: PROP_MODE SAT, confirmed, opponent grid.
val satQso = "<CALL:6>BA7OPF\n" +
"<QSO_DATE:8>20260820\n" +
"<PROP_MODE:3>SAT\n" +
"<SAT_NAME:5>FO-29\n" +
"<GRIDSQUARE:4>NL47\n" +
"<EOR>\n"
// Ground QSO: same grid but no PROP_MODE -> must be excluded.
val groundQso = "<CALL:6>BA7OPF\n" +
"<QSO_DATE:8>20260821\n" +
"<GRIDSQUARE:4>NL47\n" +
"<EOR>\n"
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
assertEquals(setOf("NL47"), result)
}
@Test
fun parseSupportsVuccGridPairsAndSixCharGrids() {
val satQso = "<PROP_MODE:3>SAT\n" +
"<SAT_NAME:5>SO-50\n" +
"<GRIDSQUARE:6>OM60IL\n" +
"<VUCC_GRIDS:11>EN52en,EN53fa\n" +
"<EOR>\n"
val result = repo.parseConfirmedGrids(report(satQso))
assertEquals(setOf("OM60", "EN52", "EN53"), result)
}
@Test
fun parsePropModeDoesNotLeakAcrossRecords() {
// PROP_MODE SAT record first, then a ground QSO with a grid — the second
// record must not inherit the satellite flag after its own <EOR>.
val satQso = "<PROP_MODE:3>SAT\n<GRIDSQUARE:4>OL62\n<EOR>\n"
val groundQso = "<GRIDSQUARE:4>PM00\n<EOR>\n"
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
assertEquals(setOf("OL62"), result)
}
@Test
fun parseReturnsEmptySetForReportWithoutGrids() {
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 parseQsosVuccQuadrupleFeedsAllFourGrids() {
// VUCC allows up to four grids in one contact.
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:23>EN52en,EN53fa,EN42gj,EN43kh\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(qso))!!
assertEquals(setOf("EN52", "EN53", "EN42", "EN43"), result.keys)
result.values.forEach { assertEquals(1, it.size) }
}
@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
@@ -210,12 +210,20 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
}
@Composable
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
fun CardButton(
onClick: () -> Unit,
text: String,
modifier: Modifier = Modifier,
isEnabled: Boolean = true
) {
ElevatedButton(
onClick = onClick,
enabled = isEnabled,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
),
shape = MaterialTheme.shapes.small,
modifier = modifier,
@@ -316,9 +324,21 @@ fun InfoDialog(
@Composable
fun SharedDialog(
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
title: String,
onCancel: () -> Unit,
onAccept: () -> Unit,
acceptText: String? = null,
acceptEnabled: Boolean = true,
content: @Composable () -> Unit
) {
SharedDialog(title = title, onDismissRequest = onCancel, onCancel = onCancel, onAccept = onAccept) { _ ->
SharedDialog(
title = title,
onDismissRequest = onCancel,
onCancel = onCancel,
onAccept = onAccept,
acceptText = acceptText,
acceptEnabled = acceptEnabled
) { _ ->
content()
}
}
@@ -329,6 +349,8 @@ fun SharedDialog(
onDismissRequest: () -> Unit,
onCancel: (() -> Unit)? = null,
onAccept: (() -> Unit)? = null,
acceptText: String? = null,
acceptEnabled: Boolean = true,
titleFontSize: Int = 16,
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
content: @Composable (padding: Dp) -> Unit
@@ -356,7 +378,11 @@ fun SharedDialog(
.padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp)
)
if (onAccept != null) {
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
CardButton(
onClick = onAccept,
text = acceptText ?: stringResource(R.string.btn_accept),
isEnabled = acceptEnabled
)
}
}
content(padding)
@@ -0,0 +1,142 @@
package com.rtbishop.look4sat.core.presentation
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Format a Maidenhead locator for display. Per IARU convention and the user's
* preference: field letters (1-2) and square digits (3-4) uppercase, sub-square
* letters (5-6 of a 6/8-char locator) lowercase, e.g. "OL62" / "OL62kl".
*/
fun formatGridDisplay(grid: String): String? {
val g = grid.trim().uppercase()
if (g.length !in listOf(4, 6, 8) || g.any { !it.isLetterOrDigit() }) return null
if (g.length == 4) return g
if (g.length == 6) return g.take(4) + g.drop(4).lowercase()
return g.take(4) + g.drop(4).take(2).lowercase() + g.drop(6)
}
/**
* Top-bar chip for the mutual-match screen: navigation needle (rotated by the
* great-circle bearing to the target grid, 0 deg = north), grid locator and
* distance. Shows a dimmed placeholder when no target grid is entered.
*/
@Composable
fun GridTargetChip(
grid: String?,
distanceKm: Double?,
bearingDeg: Double?,
modifier: Modifier = Modifier
) {
val configured = grid != null && distanceKm != null && bearingDeg != null
// The app's signature accent yellow (MaterialTheme primary in both themes).
val accent = MaterialTheme.colorScheme.primary
val placeholder = MaterialTheme.colorScheme.onSurfaceVariant
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier
.height(36.dp)
.background(
color = MaterialTheme.colorScheme.surface.copy(alpha = if (configured) 0.9f else 0.45f),
shape = RoundedCornerShape(12.dp)
)
.padding(horizontal = 12.dp)
) {
NavigationNeedle(
bearingDeg = bearingDeg ?: 0.0,
color = if (configured) accent else placeholder,
dimmed = !configured,
modifier = Modifier.size(22.dp)
)
Text(
text = grid?.let { formatGridDisplay(it) } ?: "----",
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
color = if (configured) accent else placeholder,
letterSpacing = 1.sp
)
Text(
text = distanceKm?.let { "${"%,.0f".format(it)} km" } ?: "-- km",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
)
}
}
/**
* Map-style navigation needle: slim triangular pointer whose tip points at
* [bearingDeg] (0 = north/up, clockwise positive).
*/
@Composable
private fun NavigationNeedle(
bearingDeg: Double,
color: Color,
dimmed: Boolean,
modifier: Modifier = Modifier
) {
Canvas(modifier = modifier) {
val cx = size.width / 2f
val cy = size.height / 2f
val r = size.minDimension / 2f
if (dimmed) {
drawCircle(color = color, radius = r * 0.92f, style = Stroke(width = 1.5f))
}
rotate(degrees = bearingDeg.toFloat(), pivot = Offset(cx, cy)) {
val path = Path().apply {
moveTo(cx, cy - r * 0.9f) // tip (north before rotation)
lineTo(cx - r * 0.42f, cy + r * 0.55f)
lineTo(cx, cy + r * 0.28f)
lineTo(cx + r * 0.42f, cy + r * 0.55f)
close()
}
drawPath(path, color = color)
}
}
}
/** Great-circle distance in km between two positions. */
fun greatCircleDistanceKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val rad = Math.PI / 180.0
val dLat = (lat2 - lat1) * rad
val dLon = (lon2 - lon1) * rad
val a = sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * rad) * cos(lat2 * rad) * sin(dLon / 2) * sin(dLon / 2)
return 6371.0 * 2 * kotlin.math.atan2(kotlin.math.sqrt(a), kotlin.math.sqrt(1 - a))
}
/** Initial great-circle bearing in degrees (0 = north, clockwise). */
fun greatCircleBearingDeg(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val rad = Math.PI / 180.0
val dLon = (lon2 - lon1) * rad
val y = sin(dLon) * cos(lat2 * rad)
val x = cos(lat1 * rad) * sin(lat2 * rad) -
sin(lat1 * rad) * cos(lat2 * rad) * cos(dLon)
return (Math.atan2(y, x) / rad + 360.0) % 360.0
}
@@ -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 -->
@@ -151,8 +155,8 @@
<string name="prefs_lotw_title">LoTW 已确认网格</string>
<string name="prefs_lotw_callsign">LoTW 呼号</string>
<string name="prefs_lotw_password">LoTW 密码</string>
<string name="prefs_lotw_hint">直接从 ARRL LoTW 拉取已确认 QSO 的网格并与 Wavelog 网格合并(当前 %1$d 个)。密码仅保存在手机本地。填好直接点同步,会自动保存并拉取。</string>
<string name="prefs_lotw_sync">立即同步</string>
<string name="prefs_lotw_hint">直接从 ARRL LoTW 拉取该呼号已确认 QSO 的网格并覆盖本地已存集合(当前 %1$d 个)。密码仅保存在手机本地。点"同步"会自动保存凭据并拉取。</string>
<string name="prefs_lotw_sync">同步</string>
<string name="prefs_lotw_syncing">同步中…</string>
<string name="prefs_lotw_configured">已从 LoTW 同步 — 共 %1$d 个网格</string>
<string name="prefs_lotw_not_configured">未配置 — 直接登录 ARRL LoTW 拉取已确认 QSO 的网格,无需自建服务器</string>
@@ -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 -->
@@ -186,8 +190,8 @@
<string name="prefs_lotw_title">LoTW confirmed grids</string>
<string name="prefs_lotw_callsign">LoTW callsign</string>
<string name="prefs_lotw_password">LoTW password</string>
<string name="prefs_lotw_hint">Fetch confirmed-QSO grids directly from ARRL LoTW and merge them with Wavelog grids (currently %1$d). The password is stored on this device only. Just tap Sync — credentials are saved automatically.</string>
<string name="prefs_lotw_sync">Sync now</string>
<string name="prefs_lotw_hint">Fetch confirmed-QSO grids directly from ARRL LoTW for this callsign and replace the stored set (currently %1$d). The password is stored on this device only. Tapping Sync saves the credentials and fetches in one step.</string>
<string name="prefs_lotw_sync">Sync</string>
<string name="prefs_lotw_syncing">Syncing…</string>
<string name="prefs_lotw_configured">Synced from LoTW — %1$d grids total</string>
<string name="prefs_lotw_not_configured">Not configured — pull confirmed grids directly from ARRL LoTW, no self-hosted server needed</string>
@@ -56,6 +56,16 @@ class MaidenheadGridOverlay : Overlay() {
style = android.graphics.Paint.Style.FILL
color = Color.argb(90, 76, 217, 100)
}
private val ownLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
strokeWidth = 4.5f
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()
@@ -63,10 +73,17 @@ 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
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
if (shadow || !isEnabled) return
val projection = mapView.projection
val zoom = mapView.zoomLevelDouble
canvasWidthPx = canvas.width.toFloat()
val cellLat = if (zoom >= GRID_ZOOM_SUB) SUB_SQUARE_LAT else FIELD_LAT
val cellLon = if (zoom >= GRID_ZOOM_SUB) SUB_SQUARE_LON else FIELD_LON
@@ -75,8 +92,19 @@ class MaidenheadGridOverlay : Overlay() {
val south = projection.fromPixels(canvas.width, canvas.height)
val topLat = max(north.latitude, south.latitude).coerceIn(-90.0, 90.0)
val bottomLat = min(north.latitude, south.latitude).coerceIn(-90.0, 90.0)
val leftLon = min(north.longitude, south.longitude)
val rightLon = max(north.longitude, south.longitude)
// Longitude: osmdroid normalizes to [-180, 180), so when the view
// straddles the antimeridian (e.g. left edge 170°E, right edge 170°W)
// the raw min/max swap sides and the bounding box spans the wrong way
// (center on the Pacific → grid painted 180° off-screen). Unwrap by
// shifting one edge by +360° so left < right again.
var lonA = north.longitude
var lonB = south.longitude
if (lonA > lonB) {
val t = lonA; lonA = lonB; lonB = t
}
if (lonB - lonA > 180.0) lonA += 360.0
val leftLon = lonA
val rightLon = lonB
val firstRow = floor(bottomLat / cellLat).toInt()
val lastRow = ceil(topLat / cellLat).toInt()
@@ -85,23 +113,60 @@ class MaidenheadGridOverlay : Overlay() {
val firstCol = floor(leftLon / cellLon).toInt()
val lastCol = ceil(rightLon / cellLon).toInt()
val centerLon = (leftLon + rightLon) / 2.0
// World width in screen pixels for the current zoom (256 px per tile,
// 2^zoom tiles across the whole 360° world). Used by the custom
// Mercator-X computation in projectionToX.
val worldWidthPx = 256.0 * Math.pow(2.0, zoom)
// At low zoom the world is narrower than the viewport and osmdroid shows
// repeating copies on both sides. The visible bounding box spans more
// than 360° of longitude there; extend the column range by whole world
// turns so meridians, fills and labels tile across the repeats too.
val worldTurns = ceil(((rightLon - leftLon) / 360.0) - 1e-9).toInt().coerceAtLeast(0)
val colRepeats = if (worldTurns > 0) worldTurns else 0
// Worked-grid highlight fills (only meaningful at sub-square zoom)
if (workedGrids.isNotEmpty() && 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)
val yBottom = projectionToY(projection, lat)
if (yTop == null || yBottom == null) continue
for (col in firstCol..lastCol) {
val lon = col * cellLon
val xLeft = projectionToX(projection, lon, centerLon) ?: continue
val xRight = projectionToX(projection, lon + cellLon, centerLon) ?: continue
if (xRight < 0f || xLeft > canvas.width) continue
if (cellLabel(lat, lon, zoom) in workedGrids) {
// 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.
// - Field zoom (two-char labels): do NOT fill whole fields; instead fill
// the individual 2°x1° squares that were worked, without drawing the
// square grid lines — so you see green patches inside the field.
if (workedGrids.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)
val yBottom = projectionToY(projection, lat)
if (yTop == null || yBottom == null) continue
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
val lon = col * cellLon
val xLeft = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
val xRight = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
if (xRight < 0f || xLeft > canvas.width) continue
if (cellLabel(lat, lon, zoom) in workedGrids) {
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
}
}
}
} else {
for (grid in workedGrids) {
val cell = gridCellBounds(grid) ?: continue
for (turn in -colRepeats..colRepeats) {
// Shift the cell by whole world turns to cover repeats.
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 xLeft = projectionToX(projection, cell.lonLeft + dLon, centerLon, worldWidthPx) ?: continue
val xRight = projectionToX(projection, cell.lonRight + dLon, centerLon, worldWidthPx) ?: continue
if (xRight < 0f || xLeft > canvas.width) continue
if (yBottom < 0f || yTop > canvas.height) continue
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
}
}
@@ -109,9 +174,9 @@ class MaidenheadGridOverlay : Overlay() {
}
// Vertical lines (meridians)
for (col in firstCol..lastCol) {
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
val lon = col * cellLon
val x = projectionToX(projection, lon, centerLon)
val x = projectionToX(projection, lon, centerLon, worldWidthPx)
if (x == null) continue
canvas.drawLine(x, 0f, x, canvas.height.toFloat(), linePaint)
}
@@ -124,13 +189,35 @@ class MaidenheadGridOverlay : Overlay() {
canvas.drawLine(0f, y, canvas.width.toFloat(), y, linePaint)
}
// Labels: centered in each cell, only when the cell is large enough on
// screen to hold a label (avoid clutter at low zoom)
val showLabels = when {
zoom >= GRID_ZOOM_SUB -> true
zoom >= FIELD_ZOOM_LABELS -> true
else -> false
// The station's own grid square: redraw its four borders thicker on top.
// 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 firstCol..lastCol) {
val lon = col * cellLon
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
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)
}
}
}
// Labels: centered in each cell, only when the cell is large enough on
// screen to hold a label (avoid clutter at low zoom).
// Field (2-char) labels show at every zoom, subject only to the pixel-
// size check below; sub-square (4-char) labels only from LABEL_ZOOM_SUB
// (one level above the grid lines, so zoom 6 shows lines but no names).
val showLabels = zoom >= LABEL_ZOOM_SUB || cellLat == FIELD_LAT
if (!showLabels) return
// Estimate on-screen cell height to avoid clutter at low zoom:
// project two points 1° apart in latitude and measure the pixel distance.
@@ -152,10 +239,10 @@ class MaidenheadGridOverlay : Overlay() {
val yBottom = projectionToY(projection, lat) ?: continue
val yCenter = (yTop + yBottom) / 2f - textHalfHeight
if (yBottom < 0f || yTop > canvas.height) continue
for (col in firstCol..lastCol) {
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
val lon = col * cellLon
val xLeft = projectionToX(projection, lon, centerLon) ?: continue
val xRight = projectionToX(projection, lon + cellLon, centerLon) ?: continue
val xLeft = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
val xRight = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
if (xRight < 0f || xLeft > canvas.width) continue
val label = cellLabel(lat, lon, zoom)
canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint)
@@ -164,18 +251,18 @@ class MaidenheadGridOverlay : Overlay() {
}
/** X pixel for a longitude (meridians are vertical in Web Mercator). */
private fun projectionToX(projection: Projection, lon: Double, centerLon: Double): Float? {
// osmdroid clips Mercator X to [0, mapSize], which destroys the projection of
// longitudes outside the [0, 360) window of the current view (visible at low
// zoom where the whole world is narrower than the viewport). Normalizing the
// longitude to the equivalent value closest to the view center keeps every
// meridian's X near the center, safely inside the clip window.
var normalized = lon
while (normalized < centerLon - 180.0) normalized += 360.0
while (normalized > centerLon + 180.0) normalized -= 360.0
val geo = org.osmdroid.util.GeoPoint(0.0, normalized)
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
// logic (getCloserPixel) mis-projects longitudes far from the view
// center, which makes meridians vanish while panning. Mercator X is
// linear in longitude, so compute it directly:
// x = screenCenterX + (lon - centerLon) / 360 * worldWidthPx
var delta = lon - centerLon
while (delta > 180.0) delta -= 360.0
while (delta < -180.0) delta += 360.0
val geo = org.osmdroid.util.GeoPoint(0.0, centerLon)
val p = projection.toPixels(geo, null)
return if (p.x in -MAX_OVERSHOOT_PX..MAX_OVERSHOOT_PX + 4096) p.x.toFloat() else null
return (p.x + delta / 360.0 * worldWidthPx).toFloat()
}
/** Y pixel for a latitude, or null when outside the viewport. */
@@ -200,6 +287,30 @@ class MaidenheadGridOverlay : Overlay() {
return "$field$squareLon$squareLat"
}
/**
* Geographic bounds of a 4-char Maidenhead square (e.g. "OL62"):
* 2° wide in longitude, 1° tall in latitude.
*/
private fun gridCellBounds(grid: String): CellBounds? {
val g = grid.trim().uppercase()
if (g.length < 4) return null
val fieldLon = g[0] - 'A'
val fieldLat = g[1] - 'A'
val sqLon = g[2] - '0'
val sqLat = g[3] - '0'
if (fieldLon !in 0..17 || fieldLat !in 0..17 || sqLon !in 0..9 || sqLat !in 0..9) return null
val lonLeft = -180.0 + fieldLon * FIELD_LON + sqLon * SUB_SQUARE_LON
val latBottom = -90.0 + fieldLat * FIELD_LAT + sqLat * SUB_SQUARE_LAT
return CellBounds(lonLeft, lonLeft + SUB_SQUARE_LON, latBottom, latBottom + SUB_SQUARE_LAT)
}
private data class CellBounds(
val lonLeft: Double,
val lonRight: Double,
val latBottom: Double,
val latTop: Double
)
private fun normalizeLon(lon: Double): Double {
var l = lon % 360.0
if (l >= 180.0) l -= 360.0
@@ -207,13 +318,19 @@ 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
const val SUB_SQUARE_LON = 2.0
const val GRID_ZOOM_SUB = 5.0
const val FIELD_ZOOM_LABELS = 3.0
// Zoom at which the 2°x1° sub-square grid lines/fills appear.
// 5.0 → 6.0: at zoom 5 a full field spans too little screen width and
// the sub-square grid is too dense to read; 6 roughly doubles the
// on-screen size of each square.
const val GRID_ZOOM_SUB = 6.0
// Zoom at which the 4-char sub-square names appear (one level above the
// grid lines: zoom 6 = lines only, zoom 7+ = lines + names).
const val LABEL_ZOOM_SUB = 7.0
const val MIN_LABEL_CELL_PX = 48f
const val MAX_OVERSHOOT_PX = 64
}
@@ -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,7 +142,41 @@ 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
// track update, so skip the tracking animation entirely.
if (uiState.isGridMode) return@LaunchedEffect
val firstPos = uiState.track?.firstOrNull()?.firstOrNull() ?: return@LaunchedEffect
mapView.controller.animateTo(GeoPoint(firstPos.latitude, firstPos.longitude))
}
@@ -161,15 +199,22 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
}
ElevatedCard(modifier = Modifier.weight(1f)) {
Box(contentAlignment = Alignment.BottomCenter) {
// Track grid-mode transitions so the map only re-centers when the
// mode is switched ON, not on every recomposition.
var prevGridMode by remember { mutableStateOf(uiState.isGridMode) }
// Track grid-mode transitions; initial value false so that entering
// the map page with grid mode already ON also centers the map.
var prevGridMode by remember { mutableStateOf(false) }
AndroidView({ mapView }) { view ->
// Center on the station grid whenever entering grid mode.
// Only mark the transition as consumed once a real position
// was available; otherwise a first frame with a null
// stationPosition would swallow the centering forever.
val shouldCenter = uiState.isGridMode && !prevGridMode
setGridMode(
uiState.isGridMode, uiState.workedGrids, view,
if (uiState.isGridMode && !prevGridMode) uiState.stationPosition else null
if (shouldCenter) uiState.stationPosition else null
)
prevGridMode = uiState.isGridMode
if (!shouldCenter || uiState.stationPosition != null) {
prevGridMode = uiState.isGridMode
}
if (!uiState.isGridMode) {
uiState.stationPosition?.let { setStationPosition(it, view) }
uiState.track?.let { setSatelliteTrack(it, view) }
@@ -189,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) {
@@ -289,10 +481,12 @@ private fun setGridMode(
if (gridOverlay is MaidenheadGridOverlay) {
gridOverlay.isEnabled = gridMode
gridOverlay.workedGrids = workedGrids
gridOverlay.ownGrid = ownGridOf(stationPosition)
} else {
mapView.overlays[OVERLAY_GRID] = MaidenheadGridOverlay().apply {
isEnabled = gridMode
this.workedGrids = workedGrids
this.ownGrid = ownGridOf(stationPosition)
}
}
// Satellite-related layers are hidden in grid mode; the grid overlay
@@ -301,7 +495,10 @@ private fun setGridMode(
mapView.overlays.getOrNull(index)?.isEnabled = !gridMode
}
// Entering grid mode: center on the station's current grid square,
// keeping the current zoom level unchanged.
// keeping the current zoom level unchanged. post{} is essential: on the
// first composition this runs before MapView's first layout, and a
// setCenter issued pre-layout is discarded when the view lays out —
// the map then keeps its default center forever.
if (gridMode) {
val pos = stationPosition ?: return
val lat = pos.latitude
@@ -310,13 +507,36 @@ private fun setGridMode(
val fieldLon = ((lon + 180.0) / 20.0).toInt().coerceIn(0, 17)
val centerLat = fieldLat * 10.0 + 5.0
val centerLon = fieldLon * 20.0 + 10.0
mapView.controller.setCenter(GeoPoint(centerLat, centerLon))
mapView.post { mapView.controller.setCenter(GeoPoint(centerLat, centerLon)) }
}
} catch (e: Exception) {
println(e)
}
}
/** The station's 4-char Maidenhead gridsquare, e.g. "OL62", or null without a position. */
private fun ownGridOf(stationPosition: GeoPos?): String? {
val pos = stationPosition ?: return null
val lat = pos.latitude
val lon = pos.longitude
val fieldLat = ((lat + 90.0) / 10.0).toInt().coerceIn(0, 17)
val fieldLon = ((lon + 180.0) / 20.0).toInt().coerceIn(0, 17)
val subLat = ((lat + 90.0) % 10.0).toInt()
val subLon = ((lon + 180.0) % 20.0 / 2.0).toInt()
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
@@ -63,6 +63,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.repository.TrackSampleData
import com.rtbishop.look4sat.core.presentation.GridTargetChip
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.TopBar
@@ -88,7 +89,8 @@ fun MutualScreen(
Text(
text = stringResource(R.string.mutual_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 12.dp)
)
},
bottomInfo = {
@@ -98,7 +100,9 @@ fun MutualScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
modifier = Modifier
.weight(1f)
.padding(start = 12.dp)
)
},
endAction = {
@@ -114,7 +118,29 @@ fun MutualScreen(
strokeWidth = 2.dp
)
}
MutualStatusChip(state)
// Target-grid chip: station B grid (station A defaults to
// the user's own position), bearing/distance from A to B.
val gridB = state.stationBGrid.trim().uppercase()
val posA = com.rtbishop.look4sat.core.domain.utility.qthToPosition(state.stationAGrid)
?: state.stationALat.toDoubleOrNull()?.let { lat ->
state.stationALon.toDoubleOrNull()?.let { lon ->
com.rtbishop.look4sat.core.domain.predict.GeoPos(lat, lon)
}
}
val posB = com.rtbishop.look4sat.core.domain.utility.qthToPosition(gridB)
if (posA != null && posB != null) {
GridTargetChip(
grid = gridB,
distanceKm = com.rtbishop.look4sat.core.presentation.greatCircleDistanceKm(
posA.latitude, posA.longitude, posB.latitude, posB.longitude
),
bearingDeg = com.rtbishop.look4sat.core.presentation.greatCircleBearingDeg(
posA.latitude, posA.longitude, posB.latitude, posB.longitude
)
)
} else {
GridTargetChip(grid = null, distanceKm = null, bearingDeg = null)
}
}
}
)
@@ -238,10 +238,13 @@ fun LoTWDialog(
SharedDialog(
title = stringResource(R.string.prefs_lotw_title),
onCancel = dismiss,
// The confirm button IS the sync action: saving credentials and
// fetching confirmed grids happen in one step.
onAccept = {
onSave(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value))
dismiss()
}
onSync(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value))
},
acceptText = stringResource(if (isSyncing) R.string.prefs_lotw_syncing else R.string.prefs_lotw_sync),
acceptEnabled = !isSyncing
) {
OutlinedTextField(
value = call.value,
@@ -273,20 +276,6 @@ fun LoTWDialog(
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
// Sync uses the values typed in the fields directly — saving and syncing
// happen in one step, no need to close and reopen the dialog.
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
) {
TextButton(
onClick = { onSync(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value)) },
enabled = !isSyncing
) {
Text(text = if (isSyncing) stringResource(R.string.prefs_lotw_syncing)
else stringResource(R.string.prefs_lotw_sync))
}
}
Spacer(modifier = Modifier.height(0.dp))
}
}
@@ -203,18 +203,6 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
)
}
if (dialogs.wavelog) {
WavelogDialog(
initialSettings = uiState.wavelogSettings,
workedGridsCount = uiState.workedGridsCount,
isSyncing = uiState.wavelogSyncing,
message = uiState.wavelogMessage,
dismiss = { dialogs.wavelog = false },
onSave = { onAction(SettingsAction.UpdateWavelog(it)) },
onSync = { onAction(SettingsAction.SyncWorkedGrids(it)) }
)
}
if (dialogs.lotw) {
LoTWDialog(
initialSettings = uiState.lotwSettings,
@@ -330,13 +318,6 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
onRadioControlClick = { dialogs.radioControl = true }
)
}
item {
WavelogCard(
settings = uiState.wavelogSettings,
workedGridsCount = uiState.workedGridsCount,
showWavelogDialog = { dialogs.wavelog = true }
)
}
item {
LoTWCard(
settings = uiState.lotwSettings,
@@ -581,44 +562,6 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole
}
}
@Preview(showBackground = true)
@Composable
private fun WavelogCardPreview() = MainTheme {
WavelogCard(settings = WavelogSettings("http://192.168.1.10", "wl2_demo"), workedGridsCount = 42) {}
}
@Composable
private fun WavelogCard(
settings: WavelogSettings,
workedGridsCount: Int,
showWavelogDialog: () -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
text = stringResource(id = R.string.prefs_wavelog_title),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = if (settings.isConfigured) {
stringResource(R.string.prefs_wavelog_configured, workedGridsCount)
} else {
stringResource(R.string.prefs_wavelog_not_configured)
},
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)
Spacer(modifier = Modifier.height(4.dp))
CardButton(
onClick = showWavelogDialog,
text = stringResource(id = R.string.prefs_wavelog_configure),
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun LoTWCard(
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
@@ -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 {
// Merge with Wavelog grids: union of both sources, all confirmed/worked
val merged = settingsRepo.getWorkedGrids() + lotwGrids
settingsRepo.setWorkedGrids(merged)
state.copy(lotwSyncing = false, workedGridsCount = merged.size, lotwMessage = null)
// Wavelog entry removed: LoTW is now the only source, so the
// synced set fully replaces the stored worked grids.
val (grids, qsos) = lotwResult
settingsRepo.setWorkedGrids(grids)
settingsRepo.setWorkedGridQsos(qsos)
state.copy(lotwSyncing = false, workedGridsCount = grids.size, lotwMessage = null)
}
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "455"
appVersionCode = "459"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.6-ba7opf.9.3"
appVersionName = "4.4.6-ba7opf.9.8"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry