Compare commits

...
Author SHA1 Message Date
atsunatsu 9c12b1eccb fix(map): own grid never striped — bold outline only
The station's own gridsquare is in the roamed set (LoTW MY_GRIDSQUARE includes
the home grid). Exclude it from the blue stripe overlay at both zoom levels so
the current cell shows only its bold cyan outline, matching the user's request.

chore: bump version to 4.4.6-ba7opf.10
2026-09-11 21:17:12 +08:00
atsunatsu d56392f3c6 feat(ui): top-right 日程按钮 — add current/next pass to system calendar on radar and passes pages
Restores the original repo's calendar function (ic_calendar → AddToCalendar via
CalendarContract ACTION_INSERT) to the top-right of both pages, replacing the
map buttons:
- Radar page: back to upstream's addToCalendar (current pass name/aos/los).
- Passes page: new PassesAction.AddToCalendar wired through PassesViewModel +
  container.provideAddToCalendar() (next pass name/aos/los); left button back
  to the pass filter (reverts the earlier radios-button swap).
The map button is gone from both top bars — the map is now a bottom-nav tab.
Also drops the now-dead openMapRequest/navigateToMap navigation chain in
NavRoot/MainScreen that only the radar map button used.
2026-09-11 21:03:59 +08:00
atsunatsu 14b98cc7b1 feat(cw): ship 64-bit CW decoder — arm64-v8a jniLibs from Morse Expert 1.15 Play split
Brings in libnativedecoderjni.so for arm64-v8a (from Iu-yang1/Look4Sat-FT4,
the Morse Expert 1.15 official Play arm64 split) and lifts the ABI pin from
armeabi-v7a-only to armeabi-v7a + arm64-v8a in both feature/cw and app. 64-bit
devices now load the native decoder natively instead of falling back to ARM32
(or crashing when the arm64 lib was missing).
2026-09-11 20:54:25 +08:00
atsunatsu 200d2274de chore: bump version to 4.4.6-ba7opf.9.19 2026-09-11 20:42:30 +08:00
atsunatsu 3d950f90ad feat(ui): map as 4th bottom-nav tab, passes top bar restored to upstream layout, bigger QSO expand arrow
- Map moves from the passes-page top-right button to the bottom navigation
  bar at position 4 from the left (Satellites, Passes, AMSAT, Map, Mutual,
  Settings), matching the upstream nav order.
- Passes top bar restored to the original repo layout: left = radios dialog
  (ic_radios), right = passes filter/schedule dialog (ic_filter) — the map
  button is gone from the top bar.
- Worked-grid QSO dialog expand arrow enlarged from bodySmall to 18sp.
2026-09-11 20:42:27 +08:00
atsunatsu 1cc0bc770c 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).
2026-09-11 20:27:46 +08:00
atsunatsu 84ba25c163 fix(map): disable vertical map repetition — eliminate spurious vertical artifact lines
osmdroid's vertical (Y) map repetition wraps points below the viewport up to
the top edge. Antarctica's full-circle ring then draws a full-height artifact
line whenever its X range crosses the screen at low zoom. The map must never
repeat vertically, so disable setVerticalMapRepetitionEnabled.
2026-09-11 20:27:34 +08:00
atsunatsu 5bf4546ee3 chore: bump version to 4.4.6-ba7opf.9.18 2026-09-10 01:36:18 +08:00
atsunatsu 030a6eecc9 fix(map): split antimeridian edges instead of dropping — closes WAZ zone 1/19/31/32 outlines
splitRingAtAntimeridian used to drop any edge crossing ±180°, leaving
visible gaps in WAZ zone outlines (zone 31's 40°N Pacific boundary,
zone 1/19's polar arcs, zone 32's Antarctic arc). The edge is now split
in two at ±180° with interpolated latitude, each half kept in its own
segment, so outlines close across the map's left/right edges.
normalizeLon now maps to (-180, 180] so edges ENDING on the
antimeridian are not mistaken for crossings.
2026-09-10 01:05:25 +08:00
21 changed files with 317 additions and 71 deletions

No files matched your search

+6 -1
View File
@@ -18,7 +18,12 @@ android {
namespace = libs.versions.packageName.get()
defaultConfig {
applicationId = "cn.ba7opf.look4sat"
ndk { abiFilters.add("armeabi-v7a") }
// CW decoder ships for both ABIs (see feature/cw jniLibs); the APK must
// not be pinned to 32-bit only or 64-bit devices fall back to ARM32.
ndk {
abiFilters.add("armeabi-v7a")
abiFilters.add("arm64-v8a")
}
}
signingConfigs {
create("release") {
@@ -51,7 +51,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -94,16 +93,11 @@ import com.rtbishop.look4sat.feature.status.SatStatusDestination
fun NavRoot(deeplink: String? = null) {
val rootBackStack = rememberNavBackStack(Screen.Passes)
val deeplinkResolver = DeeplinkResolver()
var openMapRequest by remember { mutableIntStateOf(0) }
LaunchedEffect(deeplink) {
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
}
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
val navigateToMap: () -> Unit = {
while (rootBackStack.size > 1) rootBackStack.removeAt(rootBackStack.size - 1)
openMapRequest += 1
}
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
slideOutHorizontally(tween(300)) { -it / 3 }
@@ -124,9 +118,7 @@ fun NavRoot(deeplink: String? = null) {
entryProvider = entryProvider {
entry<Screen.Passes> {
MainScreen(
navigateToRadar = navigateToRadar,
openMapRequest = openMapRequest,
onOpenMapRequestHandled = { openMapRequest = 0 }
navigateToRadar = navigateToRadar
)
}
entry<RadarDestination> {
@@ -134,7 +126,7 @@ fun NavRoot(deeplink: String? = null) {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
RadarDestination(navigateUp = navigateBack, navigateToMap = navigateToMap)
RadarDestination(navigateUp = navigateBack)
}
}
}
@@ -143,25 +135,16 @@ fun NavRoot(deeplink: String? = null) {
@Composable
fun MainScreen(
navigateToRadar: () -> Unit = {},
openMapRequest: Int = 0,
onOpenMapRequestHandled: () -> Unit = {}
navigateToRadar: () -> Unit = {}
) {
val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
val navigateToMap: () -> Unit = {
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
backStack.add(Screen.Map)
}
LaunchedEffect(openMapRequest) {
if (openMapRequest > 0) {
navigateToMap()
onOpenMapRequestHandled()
}
}
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.AMSAT, Screen.Mutual, Screen.Settings)
// Map sits at position 4 from the left, matching the upstream nav order
// (Satellites, Passes, AMSAT/Status, Map, ...), with the fork's Mutual tab
// appended before Settings.
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.AMSAT, Screen.Map, Screen.Mutual, Screen.Settings)
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
@@ -185,7 +168,7 @@ fun MainScreen(
val isSelected = when (currentKey) {
is Screen.Satellites -> screen is Screen.Satellites
is Screen.Passes -> screen is Screen.Passes
is Screen.Map -> screen is Screen.Passes
is Screen.Map -> screen is Screen.Map
is Screen.AMSAT -> screen is Screen.AMSAT
is Screen.Mutual -> screen is Screen.Mutual
is Screen.Settings -> screen is Screen.Settings
@@ -196,7 +179,7 @@ fun MainScreen(
label = { Text(stringResource(screen.titleResId)) },
selected = isSelected,
onClick = {
if (isSelected && !(currentKey is Screen.Map && screen is Screen.Passes)) return@item
if (isSelected) return@item
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
if (screen !is Screen.Passes) backStack.add(screen)
}
@@ -236,8 +219,7 @@ fun MainScreen(
container.setMutualPassData(MutualPassData())
container.satelliteRepo.selectPass(catNum, aosTime)
navigateToRadar()
},
navigateToMap = navigateToMap
}
)
}
entry<Screen.Map> {
@@ -47,8 +47,11 @@ class LoTWRepository : ILoTWRepository {
override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult =
withContext(Dispatchers.IO) {
fetchReportBody(callsign, password).fold(
onSuccess = { body -> parseBoth(body)?.let { LoTWResult.Success(it.first, it.second) }
?: LoTWResult.RateLimited },
onSuccess = { body ->
parseBoth(body)?.let { (grids, _, roamed) ->
LoTWResult.Success(grids, emptyMap(), roamed)
} ?: LoTWResult.RateLimited
},
onFailure = { toResult(it) }
)
}
@@ -58,8 +61,11 @@ class LoTWRepository : ILoTWRepository {
password: String
): LoTWResult = withContext(Dispatchers.IO) {
fetchReportBody(callsign, password).fold(
onSuccess = { body -> parseBoth(body)?.let { LoTWResult.Success(it.first, it.second) }
?: LoTWResult.RateLimited },
onSuccess = { body ->
parseBoth(body)?.let { (grids, qsos, roamed) ->
LoTWResult.Success(grids, qsos, roamed)
} ?: LoTWResult.RateLimited
},
onFailure = { toResult(it) }
)
}
@@ -71,11 +77,14 @@ class LoTWRepository : ILoTWRepository {
else -> LoTWResult.NetworkError(e.message ?: e.javaClass.simpleName)
}
/** Single report fetch feeding both the grid set and the per-QSO detail. */
private fun parseBoth(body: String): Pair<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>>? {
/** Single report fetch feeding the grid set, the per-QSO detail and the roamed set. */
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 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> {
@@ -294,6 +303,39 @@ class LoTWRepository : ILoTWRepository {
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 {
const val BASE_URL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
}
@@ -239,6 +239,29 @@ class SettingsRepo(
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)
private val keyLoTWCall = "lotwCallsign"
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 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 {
@@ -130,6 +130,46 @@ class LoTWRepositoryTest {
// 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)
@Test
@@ -102,6 +102,9 @@ interface ISettingsRepo {
/** 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>>)
/** Distinct 4-char gridsquares the account operated from (LoTW <MY_GRIDSQUARE>). */
fun getRoamedGrids(): Set<String>
fun setRoamedGrids(grids: Set<String>)
//endregion
//region # LoTW confirmed-grids settings
@@ -23,7 +23,10 @@ sealed class LoTWResult {
/** Report downloaded and parsed successfully. */
data class Success(
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()
/** HTTP 200 but LoTW replied with its login-error page (bad callsign/password). */
+3 -2
View File
@@ -10,10 +10,11 @@ android {
compileOptions {
encoding = "UTF-8"
}
// 照搬 Morse Expert 1.15: native 解码器仅 armeabi-v7a
// Morse Expert 1.15 官方 Play 分包提供 ARM32/ARM64 原生解码器
// (jniLibs/armeabi-v7a + arm64-v8a), 两个 ABI 都打进 APK
defaultConfig {
ndk {
abiFilters += listOf("armeabi-v7a")
abiFilters += listOf("armeabi-v7a", "arm64-v8a")
}
}
}
@@ -261,9 +261,23 @@ class AwardBoundaryOverlay : Overlay() {
val prevLon = pts[i - 1][0]
val lon = pts[i][0]
if (abs(lon - prevLon) > 180.0) {
// Crossed the antimeridian: start a fresh segment.
// Crossed the antimeridian. Do NOT drop this edge — that would
// leave a visible gap in the outline (WAZ Pacific zones 1/19/
// 31/32, e.g. zone 31's 40°N boundary from 130°W to 160°E).
// Split the edge IN TWO at ±180 instead: each half stays in its
// own segment, so the outline closes across the map's left and
// right edges. The crossing latitude is interpolated along the
// edge (constant for latitude-aligned edges).
val a = pts[i - 1]
val b = pts[i]
val span = 360.0 - abs(lon - prevLon) // shortest angular distance
val edgeLon = if (prevLon < lon) -180.0 else 180.0 // side being crossed
val t = abs(edgeLon - prevLon) / span
val latAt = a[1] + (b[1] - a[1]) * t
cur.add(doubleArrayOf(edgeLon, latAt))
if (cur.size >= 2) segments.add(cur)
cur = mutableListOf()
cur.add(doubleArrayOf(-edgeLon, latAt))
}
cur.add(pts[i])
}
@@ -378,8 +392,12 @@ class AwardBoundaryOverlay : Overlay() {
private fun normalizeLon(lon: Double): Double {
var l = lon % 360.0
if (l >= 180.0) l -= 360.0
if (l < -180.0) l += 360.0
// Normalize into (-180, 180] — +180, not -180. An edge whose endpoint
// sits ON the antimeridian (e.g. a ring starting at -180°) must not be
// seen as a >180° jump away from a nearby 165° vertex; keeping the
// antimeridian value as +180 makes such edges short and un-split.
if (l <= -180.0) l += 360.0
if (l > 180.0) l -= 360.0
return l
}
@@ -56,8 +56,16 @@ class MaidenheadGridOverlay : Overlay() {
style = android.graphics.Paint.Style.FILL
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 {
strokeWidth = 4.5f
strokeWidth = 6f
style = Paint.Style.STROKE
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"}. */
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. */
var ownGrid: String? = null
@@ -120,8 +134,10 @@ class MaidenheadGridOverlay : Overlay() {
val worldTurns = ceil(((rightLon - leftLon) / 360.0) - 1e-9).toInt().coerceAtLeast(0)
val colRepeats = if (worldTurns > 0) worldTurns else 0
// The station's own grid square (4-char, only meaningful at sub-square zoom)
val ownCell = ownGrid?.takeIf { zoom >= GRID_ZOOM_SUB }
// The station's own grid: 4-char square at sub-square zoom, 2-char
// 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).
val selectedCell = selectedGrid?.takeIf { zoom >= GRID_ZOOM_SUB }
@@ -182,6 +198,57 @@ 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 fully 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). The station's OWN grid is excluded: it
// already carries the bold outline and must not be striped (user req).
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
val label = cellLabel(lat, lon, zoom)
if (label in roamedGrids && label != ownGrid) {
drawStripes(canvas, xLeft, yTop, xRight, yBottom)
}
}
}
} else {
for (grid in roamedGrids) {
// The own grid keeps only its bold outline — no stripes.
if (grid == ownGrid) continue
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:
// the boundary lines between adjacent worked squares must stay visible
// so the grid structure remains readable (reverted 2026-09-09 — the
@@ -287,6 +354,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). */
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
@@ -370,5 +457,7 @@ class MaidenheadGridOverlay : Overlay() {
const val LABEL_ZOOM_SUB = 6.5
const val MIN_LABEL_CELL_PX = 48f
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
// only consumer and it resets when leaving the page.
var selectedGrid by remember { mutableStateOf<String?>(null) }
// Selected award filter (null = "All" = plain worked-grid view).
var selectedAward by remember { mutableStateOf<AwardType?>(null) }
// Selected award filter. Entering grid mode defaults to VUCC (the plain
// 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
// the store changes (LoTW/Wavelog sync).
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.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) {
// 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.
val shouldCenter = uiState.isGridMode && !prevGridMode
setGridMode(
uiState.isGridMode, uiState.workedGrids, view,
uiState.isGridMode, uiState.workedGrids, uiState.roamedGrids, view,
if (shouldCenter) uiState.stationPosition else null
)
if (!shouldCenter || uiState.stationPosition != null) {
@@ -383,7 +389,7 @@ private fun WorkedGridCallRow(
)
Text(
text = if (expanded) " ▴" else " ▾",
style = MaterialTheme.typography.bodySmall,
fontSize = 18.sp,
color = MaterialTheme.colorScheme.primary
)
}
@@ -631,6 +637,7 @@ private fun setAwardMode(award: AwardType, workedCodes: Set<String>, mapView: Ma
private fun setGridMode(
gridMode: Boolean,
workedGrids: Set<String>,
roamedGrids: Set<String>,
mapView: MapView,
stationPosition: GeoPos?
) {
@@ -639,11 +646,13 @@ private fun setGridMode(
if (gridOverlay is MaidenheadGridOverlay) {
gridOverlay.isEnabled = gridMode
gridOverlay.workedGrids = workedGrids
gridOverlay.roamedGrids = roamedGrids
gridOverlay.ownGrid = ownGridOf(stationPosition)
} else {
mapView.overlays[OVERLAY_GRID] = MaidenheadGridOverlay().apply {
isEnabled = gridMode
this.workedGrids = workedGrids
this.roamedGrids = roamedGrids
this.ownGrid = ownGridOf(stationPosition)
}
}
@@ -927,6 +936,11 @@ private fun rememberMapViewWithLifecycle(): MapView {
MapView(context).apply {
setMultiTouchControls(true)
setUseDataConnection(false)
// Vertical map repetition wraps points below the viewport up to the
// top edge (osmdroid Y-wrap). Antarctica's full-circle ring then
// draws a full-height artifact line whenever its X range crosses
// the screen at low zoom. The map must never repeat vertically.
setVerticalMapRepetitionEnabled(false)
setTileSource(tileSource)
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels, isVertical)
maxZoomLevel = 7.0
@@ -29,6 +29,8 @@ data class MapState(
val isGridMode: Boolean = false,
val workedGrids: Set<String> = emptySet(),
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 orbitalPass: OrbitalPass,
val track: List<List<GeoPos>>? = null,
@@ -82,7 +82,8 @@ class MapViewModel(
_uiState.update {
it.copy(
workedGrids = settingsRepo.getWorkedGrids(),
workedGridQsos = settingsRepo.getWorkedGridQsos()
workedGridQsos = settingsRepo.getWorkedGridQsos(),
roamedGrids = settingsRepo.getRoamedGrids()
)
}
}
@@ -85,22 +85,20 @@ import java.util.TimeZone
@Composable
fun PassesDestination(
navigateToRadar: (Int, Long) -> Unit,
navigateToMap: () -> Unit
navigateToRadar: (Int, Long) -> Unit
) {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
PassesScreen(uiState, viewModel::onAction, navigateToRadar, navigateToMap)
PassesScreen(uiState, viewModel::onAction, navigateToRadar)
}
@Composable
private fun PassesScreen(
uiState: PassesState,
onAction: (PassesAction) -> Unit,
navigateToRadar: (Int, Long) -> Unit,
navigateToMap: () -> Unit
navigateToRadar: (Int, Long) -> Unit
) {
if (uiState.isPassesDialogShown) {
PassesFilterDialog(
@@ -167,7 +165,19 @@ private fun PassesScreen(
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
},
endAction = {
IconCard(action = navigateToMap, resId = R.drawable.ic_map)
// 日程功能: 把下个过境写入系统日历 (原仓库雷达页的 addToCalendar).
IconCard(
action = {
onAction(
PassesAction.AddToCalendar(
uiState.nextPass.name,
uiState.nextPass.aosTime,
uiState.nextPass.losTime
)
)
},
resId = R.drawable.ic_calendar
)
}
)
}
@@ -61,4 +61,6 @@ sealed interface PassesAction {
data object ToggleRadiosDialog : PassesAction
data class FocusCatNum(val catNum: Int) : PassesAction
data object ClearFocus : PassesAction
/** 日程功能: 把下个过境写入系统日历 (ic_calendar 按钮). */
data class AddToCalendar(val name: String, val aosTime: Long, val losTime: Long) : PassesAction
}
@@ -28,6 +28,7 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
import com.rtbishop.look4sat.core.domain.utility.round
import com.rtbishop.look4sat.core.domain.utility.toTimerString
import com.rtbishop.look4sat.core.presentation.getDefaultPass
@@ -46,7 +47,8 @@ import java.util.TimeZone
class PassesViewModel(
private val satelliteRepo: ISatelliteRepo,
private val settingsRepo: ISettingsRepo
private val settingsRepo: ISettingsRepo,
private val addToCalendar: IAddToCalendar
) : ViewModel() {
private val defaultPass = getDefaultPass()
@@ -151,6 +153,8 @@ class PassesViewModel(
_uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) }
is PassesAction.FocusCatNum -> _uiState.update { it.copy(focusedCatNum = action.catNum) }
PassesAction.ClearFocus -> _uiState.update { it.copy(focusedCatNum = null) }
is PassesAction.AddToCalendar ->
addToCalendar(action.name, action.aosTime, action.losTime)
}
}
@@ -345,7 +349,8 @@ class PassesViewModel(
initializer {
PassesViewModel(
satelliteRepo = container.satelliteRepo,
settingsRepo = container.settingsRepo
settingsRepo = container.settingsRepo,
addToCalendar = container.provideAddToCalendar()
)
}
}
@@ -84,7 +84,7 @@ private enum class RadarPage(val title: String) {
}
@Composable
fun RadarDestination(navigateUp: () -> Unit, navigateToMap: () -> Unit) {
fun RadarDestination(navigateUp: () -> Unit) {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
@@ -96,12 +96,6 @@ fun RadarDestination(navigateUp: () -> Unit, navigateToMap: () -> Unit) {
}
navigateUp()
}
val navigateToMapAndClearMutual = {
if (container.mutualPassData.value.endTime > 0L) {
container.setMutualPassData(MutualPassData())
}
navigateToMap()
}
LaunchedEffect(mutualData.endTime) {
if (mutualData.endTime <= 0L) return@LaunchedEffect
while (true) {
@@ -127,7 +121,7 @@ fun RadarDestination(navigateUp: () -> Unit, navigateToMap: () -> Unit) {
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
viewModel.onAction(RadarAction.CwPermissionResult(granted))
}
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, navigateToMapAndClearMutual, mutualData, requestMicPermission = {
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
})
}
@@ -137,11 +131,14 @@ private fun RadarScreen(
uiState: RadarState,
onAction: (RadarAction) -> Unit,
navigateUp: () -> Unit,
navigateToMap: () -> Unit,
mutualData: MutualPassData,
requestMicPermission: () -> Unit
) {
val upcomingPass = uiState.currentPass ?: getDefaultPass()
// 日程功能: 把当前过境写入系统日历(原仓库的 addToCalendar, ic_calendar 按钮)
val addToCalendar: () -> Unit = {
uiState.currentPass?.let { onAction(RadarAction.AddToCalendar(it.name, it.aosTime, it.losTime)) }
}
// Station-B overlay: full track line (only where B's elevation > 0) + live position dot
// at the current moment, same display mode as the local station.
val trackB = remember(mutualData.trackSamples) {
@@ -177,7 +174,7 @@ private fun RadarScreen(
TopBar {
IconCard(action = navigateUp, resId = R.drawable.ic_back)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
IconCard(action = navigateToMap, resId = R.drawable.ic_map)
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
}
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
} else {
@@ -185,7 +182,7 @@ private fun RadarScreen(
IconCard(action = navigateUp, resId = R.drawable.ic_back)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
IconCard(action = navigateToMap, resId = R.drawable.ic_map)
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
}
}
if (isVertical) {
@@ -207,6 +207,7 @@ class SettingsViewModel(
// synced set fully replaces the stored worked grids.
settingsRepo.setWorkedGrids(result.grids)
settingsRepo.setWorkedGridQsos(result.qsos)
settingsRepo.setRoamedGrids(result.roamedGrids)
_uiState.update { state ->
state.copy(
lotwSyncing = false, workedGridsCount = result.grids.size,
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "467"
appVersionCode = "468"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.6-ba7opf.9.17"
appVersionName = "4.4.6-ba7opf.10"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry