mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c12b1eccb | ||
|
|
d56392f3c6 | ||
|
|
14b98cc7b1 | ||
|
|
200d2274de | ||
|
|
3d950f90ad | ||
|
|
1cc0bc770c | ||
|
|
84ba25c163 | ||
|
|
5bf4546ee3 | ||
|
|
030a6eecc9 |
No files matched your search
@@ -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> {
|
||||
|
||||
+49
-7
@@ -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 {
|
||||
|
||||
+40
@@ -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
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+4
-1
@@ -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). */
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
+21
-3
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+92
-3
@@ -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
|
||||
}
|
||||
+7
-2
@@ -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) {
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user