mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
feat(map): VUCC default chip, own-grid bold at all zooms, roamed-grid blue stripes
- Grid mode now opens with the VUCC award chip selected by default (re-asserted on every grid-mode entry; the user's in-mode chip choice is preserved). - The station's own grid outline is now drawn at every zoom level: 2-char field border below the sub-square zoom, 4-char square border above it (was hidden at the default entry zoom), stroke widened to 6f. - Grids the account operated from (LoTW <MY_GRIDSQUARE>, satellite QSOs only) are covered with GridMaster-style blue 45° zebra stripes. The stripe paint is fully opaque so a worked+roamed cell shows pure-blue stripes with green between them — the two colors never alpha-blend into a teal/green mix. - LoTW sync now parses and persists the roamed set (LoTWResult.Success + SettingsRepo key 'roamedGrids'), plumbed through MapState/MapViewModel into MaidenheadGridOverlay. Unit tests cover MY_GRIDSQUARE parsing (SAT-only, GRIDSQUARE non-leak, 4-char truncation).
This commit is contained in:
1 parent
84ba25c163
commit
1cc0bc770c
11 files changed
+232
-15
No files matched your search
+49
-7
@@ -47,8 +47,11 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult =
|
override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
fetchReportBody(callsign, password).fold(
|
fetchReportBody(callsign, password).fold(
|
||||||
onSuccess = { body -> parseBoth(body)?.let { LoTWResult.Success(it.first, it.second) }
|
onSuccess = { body ->
|
||||||
?: LoTWResult.RateLimited },
|
parseBoth(body)?.let { (grids, _, roamed) ->
|
||||||
|
LoTWResult.Success(grids, emptyMap(), roamed)
|
||||||
|
} ?: LoTWResult.RateLimited
|
||||||
|
},
|
||||||
onFailure = { toResult(it) }
|
onFailure = { toResult(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -58,8 +61,11 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
password: String
|
password: String
|
||||||
): LoTWResult = withContext(Dispatchers.IO) {
|
): LoTWResult = withContext(Dispatchers.IO) {
|
||||||
fetchReportBody(callsign, password).fold(
|
fetchReportBody(callsign, password).fold(
|
||||||
onSuccess = { body -> parseBoth(body)?.let { LoTWResult.Success(it.first, it.second) }
|
onSuccess = { body ->
|
||||||
?: LoTWResult.RateLimited },
|
parseBoth(body)?.let { (grids, qsos, roamed) ->
|
||||||
|
LoTWResult.Success(grids, qsos, roamed)
|
||||||
|
} ?: LoTWResult.RateLimited
|
||||||
|
},
|
||||||
onFailure = { toResult(it) }
|
onFailure = { toResult(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -71,11 +77,14 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
else -> LoTWResult.NetworkError(e.message ?: e.javaClass.simpleName)
|
else -> LoTWResult.NetworkError(e.message ?: e.javaClass.simpleName)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Single report fetch feeding both the grid set and the per-QSO detail. */
|
/** Single report fetch feeding the grid set, the per-QSO detail and the roamed set. */
|
||||||
private fun parseBoth(body: String): Pair<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>>? {
|
private fun parseBoth(
|
||||||
|
body: String
|
||||||
|
): Triple<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>, Set<String>>? {
|
||||||
val grids = parseConfirmedGrids(body) ?: return null
|
val grids = parseConfirmedGrids(body) ?: return null
|
||||||
val qsos = parseConfirmedGridQsos(body) ?: return null
|
val qsos = parseConfirmedGridQsos(body) ?: return null
|
||||||
return grids to qsos
|
val roamed = parseRoamedGrids(body) ?: return null
|
||||||
|
return Triple(grids, qsos, roamed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchReportBody(callsign: String, password: String): Result<String> {
|
private fun fetchReportBody(callsign: String, password: String): Result<String> {
|
||||||
@@ -294,6 +303,39 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
return grids
|
return grids
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distinct 4-char gridsquares the account itself operated from, taken from
|
||||||
|
* ADIF <MY_GRIDSQUARE> of satellite QSO records only. This is the
|
||||||
|
* "roamed / activated" set shown as blue stripes on the map — the grids
|
||||||
|
* where the operator's own station was located during confirmed QSOs (a
|
||||||
|
* rover may log several distinct grids; the home grid is included too).
|
||||||
|
* Returns null when the body is not an ADIF report (mirrors the other parsers).
|
||||||
|
*/
|
||||||
|
internal fun parseRoamedGrids(body: String): Set<String>? {
|
||||||
|
if (!body.contains("<eoh>", ignoreCase = true)) return null
|
||||||
|
val grids = mutableSetOf<String>()
|
||||||
|
var propMode: String? = null
|
||||||
|
for (raw in body.lineSequence()) {
|
||||||
|
val line = raw.trim()
|
||||||
|
when {
|
||||||
|
line.equals("<EOR>", ignoreCase = true) -> propMode = null
|
||||||
|
line.startsWith("<PROP_MODE:") -> {
|
||||||
|
propMode = line.substringAfter('>').substringBefore("E<").trim().uppercase()
|
||||||
|
}
|
||||||
|
line.startsWith("<MY_GRIDSQUARE:") -> {
|
||||||
|
// MY_GRIDSQUARE must not be mistaken for GRIDSQUARE (the
|
||||||
|
// opposite station's grid) — only own-station grids count.
|
||||||
|
if (propMode == "SAT") {
|
||||||
|
val value = line.substringAfter('>').substringBefore("E<")
|
||||||
|
val field = value.trim().uppercase()
|
||||||
|
if (field.length >= 4) grids.add(field.take(4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return grids
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val BASE_URL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
|
const val BASE_URL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,6 +239,29 @@ class SettingsRepo(
|
|||||||
preferences.edit { putString(keyWorkedGridQsos, root.toString()) }
|
preferences.edit { putString(keyWorkedGridQsos, root.toString()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Distinct 4-char gridsquares the account operated from (LoTW <MY_GRIDSQUARE>,
|
||||||
|
// satellite QSOs only). Stored as a JSONArray like workedGrids.
|
||||||
|
private val keyRoamedGrids = "roamedGrids"
|
||||||
|
|
||||||
|
override fun getRoamedGrids(): Set<String> {
|
||||||
|
val json = preferences.getString(keyRoamedGrids, null).orEmpty()
|
||||||
|
if (json.isBlank()) return emptySet()
|
||||||
|
return try {
|
||||||
|
val array = org.json.JSONArray(json)
|
||||||
|
(0 until array.length()).mapNotNull { i ->
|
||||||
|
array.optString(i).takeIf { it.isNotBlank() }
|
||||||
|
}.toSet()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptySet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setRoamedGrids(grids: Set<String>) {
|
||||||
|
val array = org.json.JSONArray()
|
||||||
|
grids.sorted().forEach { array.put(it) }
|
||||||
|
preferences.edit { putString(keyRoamedGrids, array.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"
|
||||||
|
|||||||
@@ -296,6 +296,14 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
|
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
|
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) = Unit
|
||||||
|
|
||||||
|
override fun getRoamedGrids(): Set<String> = emptySet()
|
||||||
|
|
||||||
|
private val roamedGrids = MutableStateFlow(emptySet<String>())
|
||||||
|
|
||||||
|
override fun setRoamedGrids(grids: Set<String>) {
|
||||||
|
roamedGrids.value = grids
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||||
|
|||||||
+40
@@ -130,6 +130,46 @@ class LoTWRepositoryTest {
|
|||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
|
// region parseRoamedGrids (MY_GRIDSQUARE = grids the account operated from)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseRoamedGridsCollectsOwnStationGrids() {
|
||||||
|
// Satellite QSOs carry MY_GRIDSQUARE = the grid the account itself
|
||||||
|
// operated from (the "roamed/activated" set shown as blue stripes).
|
||||||
|
val qso1 = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260820\n<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n" +
|
||||||
|
"<MY_GRIDSQUARE:4>OL62\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
||||||
|
val qso2 = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260901\n<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n" +
|
||||||
|
"<MY_GRIDSQUARE:4>OL72\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||||
|
assertEquals(setOf("OL62", "OL72"), repo.parseRoamedGrids(report(qso1, qso2)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseRoamedGridsIgnoresGroundQsos() {
|
||||||
|
val ground = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<MY_GRIDSQUARE:4>OL72\n<EOR>\n"
|
||||||
|
assertEquals(emptySet<String>(), repo.parseRoamedGrids(report(ground)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseRoamedGridsDoesNotConfuseGridsquareWithMyGridsquare() {
|
||||||
|
// GRIDSQUARE (opposite station's grid) must never leak into the roamed set.
|
||||||
|
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<MY_GRIDSQUARE:4>OL62\n" +
|
||||||
|
"<GRIDSQUARE:6>OM60IL\n<EOR>\n"
|
||||||
|
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseRoamedGridsTruncatesSixCharToFour() {
|
||||||
|
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n<MY_GRIDSQUARE:6>OL62AA\n<EOR>\n"
|
||||||
|
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseRoamedGridsRejectsBodyWithoutEoh() {
|
||||||
|
assertNull(repo.parseRoamedGrids("<HTML>Username/password incorrect</HTML>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
// region failure classification (fetchReportBody -> LoTWResult mapping)
|
// region failure classification (fetchReportBody -> LoTWResult mapping)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+3
@@ -102,6 +102,9 @@ interface ISettingsRepo {
|
|||||||
/** Confirmed satellite QSOs grouped by worked 4-char gridsquare (map tap detail). */
|
/** 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 getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>
|
||||||
fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>)
|
fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>)
|
||||||
|
/** Distinct 4-char gridsquares the account operated from (LoTW <MY_GRIDSQUARE>). */
|
||||||
|
fun getRoamedGrids(): Set<String>
|
||||||
|
fun setRoamedGrids(grids: Set<String>)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # LoTW confirmed-grids settings
|
//region # LoTW confirmed-grids settings
|
||||||
|
|||||||
+4
-1
@@ -23,7 +23,10 @@ sealed class LoTWResult {
|
|||||||
/** Report downloaded and parsed successfully. */
|
/** Report downloaded and parsed successfully. */
|
||||||
data class Success(
|
data class Success(
|
||||||
val grids: Set<String>,
|
val grids: Set<String>,
|
||||||
val qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>
|
val qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>,
|
||||||
|
/** Distinct 4-char gridsquares the account itself operated from
|
||||||
|
* (ADIF <MY_GRIDSQUARE>, satellite QSOs only) — "roamed/activated" grids. */
|
||||||
|
val roamedGrids: Set<String> = emptySet()
|
||||||
) : LoTWResult()
|
) : LoTWResult()
|
||||||
|
|
||||||
/** HTTP 200 but LoTW replied with its login-error page (bad callsign/password). */
|
/** HTTP 200 but LoTW replied with its login-error page (bad callsign/password). */
|
||||||
|
|||||||
+88
-3
@@ -56,8 +56,16 @@ class MaidenheadGridOverlay : Overlay() {
|
|||||||
style = android.graphics.Paint.Style.FILL
|
style = android.graphics.Paint.Style.FILL
|
||||||
color = Color.argb(90, 76, 217, 100)
|
color = Color.argb(90, 76, 217, 100)
|
||||||
}
|
}
|
||||||
|
private val roamStripePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
strokeWidth = 7f
|
||||||
|
// Fully opaque blue: stripe bands stay pure blue even over a green
|
||||||
|
// worked fill — no alpha blend into a teal/green mix (user requirement:
|
||||||
|
// the two colors must never stack into a single mixed color).
|
||||||
|
color = Color.argb(255, 66, 133, 244)
|
||||||
|
}
|
||||||
private val ownLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
private val ownLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
strokeWidth = 4.5f
|
strokeWidth = 6f
|
||||||
style = Paint.Style.STROKE
|
style = Paint.Style.STROKE
|
||||||
color = Color.argb(255, 90, 200, 255)
|
color = Color.argb(255, 90, 200, 255)
|
||||||
}
|
}
|
||||||
@@ -70,6 +78,12 @@ class MaidenheadGridOverlay : Overlay() {
|
|||||||
/** 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()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gridsquares the station operated from (4-char, uppercase) — drawn with
|
||||||
|
* blue 45° stripes (GridMaster-style zebra), e.g. {"OL62", "PM95"}.
|
||||||
|
*/
|
||||||
|
var roamedGrids: Set<String> = emptySet()
|
||||||
|
|
||||||
/** 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
|
||||||
|
|
||||||
@@ -120,8 +134,10 @@ class MaidenheadGridOverlay : Overlay() {
|
|||||||
val worldTurns = ceil(((rightLon - leftLon) / 360.0) - 1e-9).toInt().coerceAtLeast(0)
|
val worldTurns = ceil(((rightLon - leftLon) / 360.0) - 1e-9).toInt().coerceAtLeast(0)
|
||||||
val colRepeats = if (worldTurns > 0) worldTurns else 0
|
val colRepeats = if (worldTurns > 0) worldTurns else 0
|
||||||
|
|
||||||
// The station's own grid square (4-char, only meaningful at sub-square zoom)
|
// The station's own grid: 4-char square at sub-square zoom, 2-char
|
||||||
val ownCell = ownGrid?.takeIf { zoom >= GRID_ZOOM_SUB }
|
// field at field zoom. Always marked so the "you are here" outline is
|
||||||
|
// visible even at the default entry zoom (below GRID_ZOOM_SUB).
|
||||||
|
val ownCell = ownGrid?.let { if (zoom >= GRID_ZOOM_SUB) it else it.take(2) }
|
||||||
// The tapped worked grid gets a distinct outline (same zoom gate).
|
// The tapped worked grid gets a distinct outline (same zoom gate).
|
||||||
val selectedCell = selectedGrid?.takeIf { zoom >= GRID_ZOOM_SUB }
|
val selectedCell = selectedGrid?.takeIf { zoom >= GRID_ZOOM_SUB }
|
||||||
|
|
||||||
@@ -182,6 +198,53 @@ class MaidenheadGridOverlay : Overlay() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Roamed/activated grid stripes: blue 45° zebra (GridMaster style) over
|
||||||
|
// every cell the station operated from. Drawn AFTER the worked fills so
|
||||||
|
// a worked+roamed cell shows blue stripes with green between them — the
|
||||||
|
// stripe paint is nearly opaque, so the two colors never alpha-blend
|
||||||
|
// into a teal/green mix. Same geometry as the worked fills (per-4-char
|
||||||
|
// cell at both zoom levels).
|
||||||
|
if (roamedGrids.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) ?: continue
|
||||||
|
val yBottom = projectionToY(projection, lat) ?: continue
|
||||||
|
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
|
||||||
|
val lon = col * cellLon
|
||||||
|
val xLeftBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
|
||||||
|
val xRightBase = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
|
||||||
|
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
|
||||||
|
val xRight = xRightBase + turn * worldWidthPx.toFloat()
|
||||||
|
if (xRight < 0f || xLeft > canvas.width) continue
|
||||||
|
if (cellLabel(lat, lon, zoom) in roamedGrids) {
|
||||||
|
drawStripes(canvas, xLeft, yTop, xRight, yBottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (grid in roamedGrids) {
|
||||||
|
val cell = gridCellBounds(grid) ?: continue
|
||||||
|
for (turn in -colRepeats..colRepeats) {
|
||||||
|
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 xLeftBase = projectionToX(projection, cell.lonLeft, centerLon, worldWidthPx) ?: continue
|
||||||
|
val xRightBase = projectionToX(projection, cell.lonRight, centerLon, worldWidthPx) ?: continue
|
||||||
|
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
|
||||||
|
val xRight = xRightBase + turn * worldWidthPx.toFloat()
|
||||||
|
if (xRight < 0f || xLeft > canvas.width) continue
|
||||||
|
if (yBottom < 0f || yTop > canvas.height) continue
|
||||||
|
drawStripes(canvas, xLeft, yTop, xRight, yBottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Vertical lines (meridians). Worked-grid cells are NOT excluded:
|
// Vertical lines (meridians). Worked-grid cells are NOT excluded:
|
||||||
// the boundary lines between adjacent worked squares must stay visible
|
// the boundary lines between adjacent worked squares must stay visible
|
||||||
// so the grid structure remains readable (reverted 2026-09-09 — the
|
// so the grid structure remains readable (reverted 2026-09-09 — the
|
||||||
@@ -287,6 +350,26 @@ class MaidenheadGridOverlay : Overlay() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws 45° diagonal stripes (GridMaster-style zebra) clipped to the cell
|
||||||
|
* rectangle, from top-left to bottom-right. The paint is nearly opaque so
|
||||||
|
* stripes stay blue even over a green worked fill underneath.
|
||||||
|
*/
|
||||||
|
private fun drawStripes(canvas: Canvas, xLeft: Float, yTop: Float, xRight: Float, yBottom: Float) {
|
||||||
|
if (xRight <= xLeft || yBottom <= yTop) return
|
||||||
|
canvas.save()
|
||||||
|
canvas.clipRect(xLeft, yTop, xRight, yBottom)
|
||||||
|
val height = yBottom - yTop
|
||||||
|
// Start one stripe-width left of the cell so the top-left corner is
|
||||||
|
// always covered; each stripe runs from (x, top) to (x+height, bottom).
|
||||||
|
var x = xLeft - height
|
||||||
|
while (x < xRight) {
|
||||||
|
canvas.drawLine(x, yTop, x + height, yBottom, roamStripePaint)
|
||||||
|
x += STRIPE_SPACING_PX
|
||||||
|
}
|
||||||
|
canvas.restore()
|
||||||
|
}
|
||||||
|
|
||||||
/** X pixel for a longitude (meridians are vertical in Web Mercator). */
|
/** X pixel for a longitude (meridians are vertical in Web Mercator). */
|
||||||
private fun projectionToX(projection: Projection, lon: Double, centerLon: Double, worldWidthPx: Double): Float? {
|
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
|
// Do NOT use osmdroid's toPixels() here: at low zoom its wrap-around
|
||||||
@@ -370,5 +453,7 @@ class MaidenheadGridOverlay : Overlay() {
|
|||||||
const val LABEL_ZOOM_SUB = 6.5
|
const val LABEL_ZOOM_SUB = 6.5
|
||||||
const val MIN_LABEL_CELL_PX = 48f
|
const val MIN_LABEL_CELL_PX = 48f
|
||||||
const val MAX_OVERSHOOT_PX = 64
|
const val MAX_OVERSHOOT_PX = 64
|
||||||
|
/** Center-to-center spacing of the roamed-grid zebra stripes, in px. */
|
||||||
|
const val STRIPE_SPACING_PX = 16f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,8 +157,9 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
|||||||
// Tapped worked grid -> centered QSO dialog. Local UI state: the map is the
|
// Tapped worked grid -> centered QSO dialog. Local UI state: the map is the
|
||||||
// only consumer and it resets when leaving the page.
|
// only consumer and it resets when leaving the page.
|
||||||
var selectedGrid by remember { mutableStateOf<String?>(null) }
|
var selectedGrid by remember { mutableStateOf<String?>(null) }
|
||||||
// Selected award filter (null = "All" = plain worked-grid view).
|
// Selected award filter. Entering grid mode defaults to VUCC (the plain
|
||||||
var selectedAward by remember { mutableStateOf<AwardType?>(null) }
|
// worked-grid view); the reset effect below re-asserts that on every entry.
|
||||||
|
var selectedAward by remember { mutableStateOf<AwardType?>(AwardType.VUCC) }
|
||||||
// Six-award progress derived from the confirmed QSO store; recomputed when
|
// Six-award progress derived from the confirmed QSO store; recomputed when
|
||||||
// the store changes (LoTW/Wavelog sync).
|
// the store changes (LoTW/Wavelog sync).
|
||||||
val awardProgress: List<AwardProgress> = remember(uiState.workedGridQsos) {
|
val awardProgress: List<AwardProgress> = remember(uiState.workedGridQsos) {
|
||||||
@@ -192,6 +193,11 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
|||||||
(mapView.overlays.getOrNull(OVERLAY_GRID) as? MaidenheadGridOverlay)?.selectedGrid = selectedGrid
|
(mapView.overlays.getOrNull(OVERLAY_GRID) as? MaidenheadGridOverlay)?.selectedGrid = selectedGrid
|
||||||
mapView.invalidate()
|
mapView.invalidate()
|
||||||
}
|
}
|
||||||
|
// Re-assert the VUCC default each time grid mode is entered; while already
|
||||||
|
// in grid mode the user's chip choice is preserved.
|
||||||
|
LaunchedEffect(uiState.isGridMode) {
|
||||||
|
if (uiState.isGridMode) selectedAward = AwardType.VUCC
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -249,7 +255,7 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
|||||||
// stationPosition would swallow the centering forever.
|
// stationPosition would swallow the centering forever.
|
||||||
val shouldCenter = uiState.isGridMode && !prevGridMode
|
val shouldCenter = uiState.isGridMode && !prevGridMode
|
||||||
setGridMode(
|
setGridMode(
|
||||||
uiState.isGridMode, uiState.workedGrids, view,
|
uiState.isGridMode, uiState.workedGrids, uiState.roamedGrids, view,
|
||||||
if (shouldCenter) uiState.stationPosition else null
|
if (shouldCenter) uiState.stationPosition else null
|
||||||
)
|
)
|
||||||
if (!shouldCenter || uiState.stationPosition != null) {
|
if (!shouldCenter || uiState.stationPosition != null) {
|
||||||
@@ -631,6 +637,7 @@ private fun setAwardMode(award: AwardType, workedCodes: Set<String>, mapView: Ma
|
|||||||
private fun setGridMode(
|
private fun setGridMode(
|
||||||
gridMode: Boolean,
|
gridMode: Boolean,
|
||||||
workedGrids: Set<String>,
|
workedGrids: Set<String>,
|
||||||
|
roamedGrids: Set<String>,
|
||||||
mapView: MapView,
|
mapView: MapView,
|
||||||
stationPosition: GeoPos?
|
stationPosition: GeoPos?
|
||||||
) {
|
) {
|
||||||
@@ -639,11 +646,13 @@ private fun setGridMode(
|
|||||||
if (gridOverlay is MaidenheadGridOverlay) {
|
if (gridOverlay is MaidenheadGridOverlay) {
|
||||||
gridOverlay.isEnabled = gridMode
|
gridOverlay.isEnabled = gridMode
|
||||||
gridOverlay.workedGrids = workedGrids
|
gridOverlay.workedGrids = workedGrids
|
||||||
|
gridOverlay.roamedGrids = roamedGrids
|
||||||
gridOverlay.ownGrid = ownGridOf(stationPosition)
|
gridOverlay.ownGrid = ownGridOf(stationPosition)
|
||||||
} else {
|
} else {
|
||||||
mapView.overlays[OVERLAY_GRID] = MaidenheadGridOverlay().apply {
|
mapView.overlays[OVERLAY_GRID] = MaidenheadGridOverlay().apply {
|
||||||
isEnabled = gridMode
|
isEnabled = gridMode
|
||||||
this.workedGrids = workedGrids
|
this.workedGrids = workedGrids
|
||||||
|
this.roamedGrids = roamedGrids
|
||||||
this.ownGrid = ownGridOf(stationPosition)
|
this.ownGrid = ownGridOf(stationPosition)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ data class MapState(
|
|||||||
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 workedGridQsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap(),
|
||||||
|
/** 4-char gridsquares the station operated from (LoTW MY_GRIDSQUARE) — blue stripes. */
|
||||||
|
val roamedGrids: Set<String> = emptySet(),
|
||||||
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,
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ class MapViewModel(
|
|||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
workedGrids = settingsRepo.getWorkedGrids(),
|
workedGrids = settingsRepo.getWorkedGrids(),
|
||||||
workedGridQsos = settingsRepo.getWorkedGridQsos()
|
workedGridQsos = settingsRepo.getWorkedGridQsos(),
|
||||||
|
roamedGrids = settingsRepo.getRoamedGrids()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -207,6 +207,7 @@ class SettingsViewModel(
|
|||||||
// synced set fully replaces the stored worked grids.
|
// synced set fully replaces the stored worked grids.
|
||||||
settingsRepo.setWorkedGrids(result.grids)
|
settingsRepo.setWorkedGrids(result.grids)
|
||||||
settingsRepo.setWorkedGridQsos(result.qsos)
|
settingsRepo.setWorkedGridQsos(result.qsos)
|
||||||
|
settingsRepo.setRoamedGrids(result.roamedGrids)
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
state.copy(
|
state.copy(
|
||||||
lotwSyncing = false, workedGridsCount = result.grids.size,
|
lotwSyncing = false, workedGridsCount = result.grids.size,
|
||||||
|
|||||||
Reference in new issue
Block a user