mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-05 20:17:29 +00:00
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:
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 {
|
||||||
|
|||||||
+52
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -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>>>?
|
||||||
}
|
}
|
||||||
+3
@@ -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 -->
|
||||||
|
|||||||
+21
-9
@@ -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
|
||||||
|
|||||||
+6
-4
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user