Compare commits

...
Author SHA1 Message Date
atsunatsu e75d2dc410 chore: bump version to 4.4.6-ba7opf.9.7 2026-09-08 00:27:17 +08:00
atsunatsu d55a07bc26 feat(map): fill individual worked 2x1 degree squares green at field zoom without square grid lines 2026-09-08 00:14:05 +08:00
atsunatsu c372386a77 fix(data): LoTW sync resilience — longer timeouts (30s/120s), gzip transfer, follow redirects; ARRL server-side latency is the bottleneck 2026-09-08 00:05:22 +08:00
atsunatsu de6ade025c feat(settings): LoTW dialog — hint rewritten without Wavelog, confirm button is now Sync, remove separate sync button 2026-09-07 23:29:19 +08:00
atsunatsu 2eb6d6cd5b fix(map): stop satellite tracking animation in grid mode so it stays centered on the local grid 2026-09-07 22:54:19 +08:00
atsunatsu c09f718aae feat: mutual chip uses theme primary yellow, plain text distance; top bar text indent; low-zoom meridians via direct Mercator math (bypass osmdroid wrap bug); remove Wavelog sync entry — LoTW is the sole grid source (replace instead of merge) 2026-09-07 19:10:44 +08:00
atsunatsu a9fe0b74e9 feat: mutual match screen target-grid chip (needle bearing + grid + distance); grid mode centering no longer swallowed when station position is not yet available 2026-09-07 17:54:14 +08:00
atsunatsu 2c9a5b376f feat: release-notes body in update checker; grid mode centers on station grid on every entry; own grid square drawn with thick outline; low-zoom meridian vanishing while panning fixed 2026-09-07 15:06:33 +08:00
atsunatsu 422e480b2f release: v4.4.6-ba7opf.9.3 2026-09-07 14:07:57 +08:00
atsunatsu efd6931abb fix: LoTW sync — force full report with qso_qslsince (LoTW default only returns new confirmations since last query) and accept lowercase <eoh> header 2026-09-07 13:51:41 +08:00
atsunatsu c7ca77a943 fix: sync dialogs use typed values directly (save+sync in one step); LoTW card wording; center map on station grid when entering grid mode; center grid labels in cells; update-check falls back to GitHub accelerator mirrors 2026-09-07 07:22:41 +08:00
atsunatsu be6473e692 feat: LoTW confirmed-grids sync card — fetch confirmed QSO grids directly from ARRL LoTW (lotwreport.adi) and merge with Wavelog grids; credentials stored locally 2026-09-07 06:55:42 +08:00
atsunatsu 75d162301b fix: meridians vanish at low zoom (osmdroid clips Mercator X; normalize longitude to view center); hide satellite layer and bottom info card in grid mode 2026-09-07 06:28:58 +08:00
atsunatsu 07c09ec3e9 fix: VersionComparator parses multi-level suffixes (4.4.6-ba7opf.9.1) by comparing all numeric segments, not just the last one 2026-09-07 06:11:31 +08:00
23 changed files with 792 additions and 107 deletions

No files matched your search

@@ -36,6 +36,7 @@ import com.rtbishop.look4sat.core.data.repository.SelectionRepo
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
import com.rtbishop.look4sat.core.data.repository.UpdateRepository
import com.rtbishop.look4sat.core.data.repository.LoTWRepository
import com.rtbishop.look4sat.core.data.repository.WavelogRepository
import com.rtbishop.look4sat.core.data.source.LocalSource
import com.rtbishop.look4sat.core.data.source.RemoteSource
@@ -49,6 +50,7 @@ import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.IRadioController
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
import com.rtbishop.look4sat.core.domain.repository.IReporter
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
@@ -84,6 +86,7 @@ class MainContainer(private val context: Context) : IMainContainer {
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
override val updateRepo by lazy { UpdateRepository(remoteSource) }
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
override val radioTrackingService: IRadioTrackingService by lazy {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
@@ -0,0 +1,112 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/**
* Fetches confirmed gridsquares directly from ARRL LoTW via the official report endpoint:
* GET https://lotw.arrl.org/lotwuser/lotwreport.adi?login=<call>&password=<pwd>
* &qso_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes&qso_qslsince=<date>
*
* Only QSL_RCVD=Y records are returned by LoTW for qso_qsl=yes, so every grid in the
* report is a *confirmed* grid (green on the map). GRIDSQUARE may be a 4- or 6-char
* value; VUCC_GRIDS ("EN52en,EN53fa") also yields 4-char fields. All values are
* truncated/expanded to the 4-char form used by the map overlay.
*
* ARRL rate-limits the report endpoint (roughly once per hour per account), which is
* fine for a manual sync button.
*/
class LoTWRepository : ILoTWRepository {
override suspend fun fetchConfirmedGrids(callsign: String, password: String): Set<String>? =
withContext(Dispatchers.IO) {
val call = callsign.trim().uppercase()
val pwd = password.trim()
if (call.isBlank() || pwd.isBlank()) return@withContext null
// qso_qslsince with an early date forces a FULL confirmed-QSL report.
// Without it, LoTW applies a "system supplied default" since-date and
// only returns confirmations newer than the account's last query —
// subsequent syncs would return an empty/incremental report.
val since = "2000-01-01"
val query = buildString {
append("login=").append(URLEncoder.encode(call, "UTF-8"))
append("&password=").append(URLEncoder.encode(pwd, "UTF-8"))
append("&qso_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes")
append("&qso_qslsince=").append(URLEncoder.encode(since, "UTF-8"))
}
try {
val connection = URL("$BASE_URL?$query").openConnection() as HttpURLConnection
// ARRL can be slow to accept connections from mobile networks
// (long TLS handshakes across the Pacific, occasional server-side
// queueing). 30s connect + 120s read gives the request enough
// headroom; the sync button stays disabled meanwhile so users
// see progress rather than a hung dialog.
connection.connectTimeout = 30_000
connection.readTimeout = 120_000
connection.requestMethod = "GET"
connection.setRequestProperty("Accept-Encoding", "gzip")
connection.instanceFollowRedirects = true
val code = connection.responseCode
if (code !in 200..299) {
connection.disconnect()
return@withContext null
}
val stream = connection.inputStream
val body = ("gzip".equals(connection.contentEncoding, ignoreCase = true))
.let { gz -> if (gz) java.util.zip.GZIPInputStream(stream) else stream }
.bufferedReader().use { it.readText() }
connection.disconnect()
if (body.contains(" password=") && !body.startsWith("ARRL")) return@withContext null
parseConfirmedGrids(body)
} catch (e: Exception) {
println("LoTWRepository fetch failure: $e")
null
}
}
internal fun parseConfirmedGrids(body: String): Set<String>? {
// LoTW answers with ADIF text; on bad credentials it returns a short error page
// containing "password=?" or an <eoh>-less block. Treat anything without a header
// marker as failure so the caller can show a sensible message.
// LoTW answers with ADIF text; on bad credentials it returns a short error
// page without an <eoh> header terminator. Real reports always carry <eoh>
// (LoTW writes it lowercase). Match case-insensitively to be safe.
if (!body.contains("<eoh>", ignoreCase = true)) return null
val grids = mutableSetOf<String>()
for (raw in body.lineSequence()) {
val line = raw.trim()
val value = when {
line.startsWith("<GRIDSQUARE:") -> line.substringAfter('>')
line.startsWith("<VUCC_GRIDS:") -> line.substringAfter('>')
else -> continue
}.substringBefore("E<").trim().uppercase()
if (value.length >= 4) grids.add(value.take(4))
}
return grids
}
private companion object {
const val BASE_URL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
}
}
@@ -178,6 +178,31 @@ class SettingsRepo(
grids.sorted().forEach { array.put(it) }
preferences.edit { putString(keyWorkedGrids, array.toString()) }
}
// LoTW credentials (stored locally on the device only)
private val keyLoTWCall = "lotwCallsign"
private val keyLoTWPass = "lotwPassword"
override val lotwSettings: kotlinx.coroutines.flow.StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
get() = _lotwSettings
private val _lotwSettings = MutableStateFlow(getLoTWSettings())
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) {
preferences.edit {
putString(keyLoTWCall, settings.callsign.trim().uppercase())
putString(keyLoTWPass, settings.password)
}
_lotwSettings.value = settings.copy(
callsign = settings.callsign.trim().uppercase(),
password = settings.password
)
}
private fun getLoTWSettings(): com.rtbishop.look4sat.core.domain.model.LoTWSettings =
com.rtbishop.look4sat.core.domain.model.LoTWSettings(
callsign = preferences.getString(keyLoTWCall, null).orEmpty(),
password = preferences.getString(keyLoTWPass, null).orEmpty()
)
//endregion
//region # Transceivers settings
@@ -16,15 +16,21 @@ class UpdateRepository(
// the API endpoint is rate-limited to 60 requests/hour per IP, which is
// quickly exhausted on shared egress IPs (e.g. VPN proxies), causing 403
// failures. The web endpoint redirects to the latest tag with no such limit.
val result = remoteSource.getNetworkStream(LATEST_RELEASE_URL)
val stream = result.stream ?: return@withContext null
try {
val html = stream.bufferedReader().use { it.readText() }
parseRelease(html)
} catch (e: Exception) {
println("UpdateRepository parse failure: $e")
null
// When GitHub is unreachable (common on mainland-China networks without a
// proxy), fall back to community GitHub accelerator mirrors that proxy the
// same page; each mirror resolves the identical tag and asset URLs.
for (baseUrl in LATEST_RELEASE_URLS) {
val result = remoteSource.getNetworkStream(baseUrl)
val stream = result.stream ?: continue
val parsed = try {
parseRelease(stream.bufferedReader().use { it.readText() }, baseUrl)
} catch (e: Exception) {
println("UpdateRepository parse failure: $e")
null
}
if (parsed != null) return@withContext parsed
}
null
}
override suspend fun downloadApk(url: String, dest: File): Boolean = withContext(Dispatchers.IO) {
@@ -40,7 +46,7 @@ class UpdateRepository(
}
}
private fun parseRelease(html: String): LatestRelease? {
private fun parseRelease(html: String, sourceUrl: String): LatestRelease? {
// The redirect target is the latest release's tag page. Extract the tag
// from the og:url meta tag ("…/releases/tag/v4.4.6-ba7opf.8") — stable and
// unambiguous. The <title> also holds the release name, but that is free
@@ -52,21 +58,47 @@ class UpdateRepository(
val title = Regex("<title>(.*?)</title>", RegexOption.DOT_MATCHES_ALL)
.find(html)?.groupValues?.get(1)?.trim()
?.substringBefore("·")?.removePrefix("Release")?.trim() ?: tag
// Release notes live in the page's markdown-body section (first occurrence
// is the release description). Strip HTML tags for plain-text display.
val raw = Regex("<div[^>]*class=\"[^\"]*markdown-body[^\"]*\"[^>]*>(.*?)</div>", RegexOption.DOT_MATCHES_ALL)
.find(html)?.groupValues?.get(1) ?: ""
val body = raw.replace(Regex("<[^>]+>"), "")
.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
.replace("&quot;", "\"").replace("&#39;", "'")
.trim()
// The release APK asset follows the fixed naming scheme used by the build:
// Look4Sat-<tag without leading v>-release.apk
val apkUrl = "$DOWNLOAD_BASE_URL/$tag/Look4Sat-${tag.removePrefix("v")}-release.apk"
// Look4Sat-<tag without leading v>-release.apk. When the page was fetched
// through an accelerator mirror (https://<mirror>/https://github.com/...),
// download through the same mirror — raw github.com is unreachable on the
// networks that needed the mirror in the first place.
val mirrorPrefix = sourceUrl.substringBefore("https://github.com")
val apkUrl = if (mirrorPrefix.isEmpty()) {
"$DOWNLOAD_BASE_URL/$tag/Look4Sat-${tag.removePrefix("v")}-release.apk"
} else {
"${mirrorPrefix}https://github.com/atsunatsu/Look4Sat/releases/download/$tag/Look4Sat-${tag.removePrefix("v")}-release.apk"
}
return LatestRelease(
versionTag = tag,
title = title,
body = "",
body = body,
apkUrl = apkUrl
)
}
private companion object {
// Web pages are used instead of api.github.com to avoid the 60 req/hour
// anonymous rate limit (see getLatestRelease above).
const val LATEST_RELEASE_URL = "https://github.com/atsunatsu/Look4Sat/releases/latest"
// anonymous rate limit (see getLatestRelease above). The GitHub URL is
// tried first; if it is unreachable (no proxy on mainland networks) the
// accelerator mirrors are tried in order. Download URLs always point at
// the mirrors too — raw github.com release downloads are equally blocked
// without a proxy, so a mirror-resolved tag must be downloaded via the
// same mirror.
val LATEST_RELEASE_URLS = listOf(
"https://github.com/atsunatsu/Look4Sat/releases/latest",
"https://ghfast.top/https://github.com/atsunatsu/Look4Sat/releases/latest",
"https://gh.llkk.cc/https://github.com/atsunatsu/Look4Sat/releases/latest",
"https://github.moeyy.xyz/https://github.com/atsunatsu/Look4Sat/releases/latest"
)
const val DOWNLOAD_BASE_URL = "https://github.com/atsunatsu/Look4Sat/releases/download"
}
}
@@ -84,6 +84,13 @@ data class WavelogSettings(
val isConfigured: Boolean get() = url.isNotBlank() && token.isNotBlank()
}
data class LoTWSettings(
val callsign: String = "",
val password: String = ""
) {
val isConfigured: Boolean get() = callsign.isNotBlank() && password.isNotBlank()
}
data class RadioControlSettings(
val enabled: Boolean,
val radioModel: String,
@@ -0,0 +1,28 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
/** Fetches confirmed gridsquares directly from ARRL LoTW. */
interface ILoTWRepository {
/**
* Fetch all confirmed (QSL_RCVD=Y) gridsquares for the given LoTW account.
* Returns the 4-char grid set, or null on any failure (network / bad credentials).
*/
suspend fun fetchConfirmedGrids(callsign: String, password: String): Set<String>?
}
@@ -33,6 +33,7 @@ interface IMainContainer {
val amSatRepo: IAmSatRepository
val updateRepo: IUpdateRepository
val wavelogRepo: IWavelogRepository
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
val radioTrackingService: IRadioTrackingService
val mutualPassData: StateFlow<MutualPassData>
fun setMutualPassData(data: MutualPassData)
@@ -100,4 +100,9 @@ interface ISettingsRepo {
fun getWorkedGrids(): Set<String>
fun setWorkedGrids(grids: Set<String>)
//endregion
//region # LoTW confirmed-grids settings
val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings)
//endregion
}
@@ -2,11 +2,12 @@ package com.rtbishop.look4sat.core.domain.utility
/**
* Compares two version strings of the form "<major>.<minor>.<patch>[-<build>]" used by the
* BA7OPF fork releases (e.g. "4.4.6-ba7opf.6" or "v4.4.6-ba7opf.7").
* BA7OPF fork releases (e.g. "4.4.6-ba7opf.6" or "v4.4.6-ba7opf.9.1").
*
* The comparison is done on the numeric version segments first; when the base versions are
* equal, the trailing build number (if any) decides. This lets a "4.4.6-ba7opf.7" release be
* detected as newer than "4.4.6-ba7opf.6" while "4.5.0" trumps any "4.4.x" build.
* equal, the trailing build numbers of the suffix decide. All numeric segments of the suffix
* are compared as a sequence, so multi-level suffixes work correctly:
* "4.4.6-ba7opf.9.1" > "4.4.6-ba7opf.9" > "4.4.6-ba7opf.8".
*/
object VersionComparator {
@@ -20,25 +21,28 @@ object VersionComparator {
val k = curr.first.getOrElse(i) { 0 }
if (c != k) return c > k
}
return cand.second > curr.second
val maxBuild = maxOf(cand.second.size, curr.second.size)
for (i in 0 until maxBuild) {
val c = cand.second.getOrElse(i) { 0 }
val k = curr.second.getOrElse(i) { 0 }
if (c != k) return c > k
}
return false
}
/**
* Parses a version string into (numeric base segments, trailing build number).
* "v4.4.6-ba7opf.7" -> ([4,4,6], 7); "4.4.6" -> ([4,4,6], 0); "4.4.6-ba7opf" -> ([4,4,6], 0).
* Parses a version string into (numeric base segments, numeric suffix segments).
* "v4.4.6-ba7opf.9.1" -> ([4,4,6], [9,1]); "4.4.6-ba7opf.8" -> ([4,4,6], [8]);
* "4.4.6" -> ([4,4,6], []); "4.4.6-ba7opf" -> ([4,4,6], []).
*/
fun parse(version: String): Pair<List<Int>, Int> {
fun parse(version: String): Pair<List<Int>, List<Int>> {
val cleaned = version.trim().removePrefix("v")
val dashIndex = cleaned.indexOf('-')
val basePart = if (dashIndex >= 0) cleaned.substring(0, dashIndex) else cleaned
val suffixPart = if (dashIndex >= 0) cleaned.substring(dashIndex + 1) else ""
val base = basePart.split('.').mapNotNull { it.toIntOrNull() }
// The build number is the trailing numeric segment of the suffix (e.g. "ba7opf.7" -> 7).
val build = suffixPart
.split('.')
.lastOrNull()
?.toIntOrNull()
?: 0
// All numeric segments of the suffix, in order (e.g. "ba7opf.9.1" -> [9, 1]).
val build = suffixPart.split('.').mapNotNull { it.toIntOrNull() }
return base to build
}
}
@@ -40,9 +40,19 @@ class VersionComparatorTest {
}
@Test
fun `parse extracts base and build number`() {
assertEquals(listOf(4, 4, 6) to 7, VersionComparator.parse("v4.4.6-ba7opf.7"))
assertEquals(listOf(4, 4, 6) to 0, VersionComparator.parse("4.4.6"))
assertEquals(listOf(4, 4, 6) to 0, VersionComparator.parse("4.4.6-ba7opf"))
fun `multi level suffixes compare correctly`() {
// The .9.1 hotfix case that exposed the old single-build-number logic.
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.9.1", "4.4.6-ba7opf.9"))
assertTrue(VersionComparator.isNewer("v4.4.6-ba7opf.9.1", "4.4.6-ba7opf.8"))
assertFalse(VersionComparator.isNewer("4.4.6-ba7opf.9", "4.4.6-ba7opf.9.1"))
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.10", "4.4.6-ba7opf.9.1"))
}
@Test
fun `parse extracts base and suffix segments`() {
assertEquals(listOf(4, 4, 6) to listOf(7), VersionComparator.parse("v4.4.6-ba7opf.7"))
assertEquals(listOf(4, 4, 6) to listOf(9, 1), VersionComparator.parse("v4.4.6-ba7opf.9.1"))
assertEquals(listOf(4, 4, 6) to emptyList<Int>(), VersionComparator.parse("4.4.6"))
assertEquals(listOf(4, 4, 6) to emptyList<Int>(), VersionComparator.parse("4.4.6-ba7opf"))
}
}
@@ -210,12 +210,20 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
}
@Composable
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
fun CardButton(
onClick: () -> Unit,
text: String,
modifier: Modifier = Modifier,
isEnabled: Boolean = true
) {
ElevatedButton(
onClick = onClick,
enabled = isEnabled,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
),
shape = MaterialTheme.shapes.small,
modifier = modifier,
@@ -316,9 +324,21 @@ fun InfoDialog(
@Composable
fun SharedDialog(
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
title: String,
onCancel: () -> Unit,
onAccept: () -> Unit,
acceptText: String? = null,
acceptEnabled: Boolean = true,
content: @Composable () -> Unit
) {
SharedDialog(title = title, onDismissRequest = onCancel, onCancel = onCancel, onAccept = onAccept) { _ ->
SharedDialog(
title = title,
onDismissRequest = onCancel,
onCancel = onCancel,
onAccept = onAccept,
acceptText = acceptText,
acceptEnabled = acceptEnabled
) { _ ->
content()
}
}
@@ -329,6 +349,8 @@ fun SharedDialog(
onDismissRequest: () -> Unit,
onCancel: (() -> Unit)? = null,
onAccept: (() -> Unit)? = null,
acceptText: String? = null,
acceptEnabled: Boolean = true,
titleFontSize: Int = 16,
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
content: @Composable (padding: Dp) -> Unit
@@ -356,7 +378,11 @@ fun SharedDialog(
.padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp)
)
if (onAccept != null) {
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
CardButton(
onClick = onAccept,
text = acceptText ?: stringResource(R.string.btn_accept),
isEnabled = acceptEnabled
)
}
}
content(padding)
@@ -0,0 +1,142 @@
package com.rtbishop.look4sat.core.presentation
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Format a Maidenhead locator for display. Per IARU convention and the user's
* preference: field letters (1-2) and square digits (3-4) uppercase, sub-square
* letters (5-6 of a 6/8-char locator) lowercase, e.g. "OL62" / "OL62kl".
*/
fun formatGridDisplay(grid: String): String? {
val g = grid.trim().uppercase()
if (g.length !in listOf(4, 6, 8) || g.any { !it.isLetterOrDigit() }) return null
if (g.length == 4) return g
if (g.length == 6) return g.take(4) + g.drop(4).lowercase()
return g.take(4) + g.drop(4).take(2).lowercase() + g.drop(6)
}
/**
* Top-bar chip for the mutual-match screen: navigation needle (rotated by the
* great-circle bearing to the target grid, 0 deg = north), grid locator and
* distance. Shows a dimmed placeholder when no target grid is entered.
*/
@Composable
fun GridTargetChip(
grid: String?,
distanceKm: Double?,
bearingDeg: Double?,
modifier: Modifier = Modifier
) {
val configured = grid != null && distanceKm != null && bearingDeg != null
// The app's signature accent yellow (MaterialTheme primary in both themes).
val accent = MaterialTheme.colorScheme.primary
val placeholder = MaterialTheme.colorScheme.onSurfaceVariant
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier
.height(36.dp)
.background(
color = MaterialTheme.colorScheme.surface.copy(alpha = if (configured) 0.9f else 0.45f),
shape = RoundedCornerShape(12.dp)
)
.padding(horizontal = 12.dp)
) {
NavigationNeedle(
bearingDeg = bearingDeg ?: 0.0,
color = if (configured) accent else placeholder,
dimmed = !configured,
modifier = Modifier.size(22.dp)
)
Text(
text = grid?.let { formatGridDisplay(it) } ?: "----",
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
color = if (configured) accent else placeholder,
letterSpacing = 1.sp
)
Text(
text = distanceKm?.let { "${"%,.0f".format(it)} km" } ?: "-- km",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
)
}
}
/**
* Map-style navigation needle: slim triangular pointer whose tip points at
* [bearingDeg] (0 = north/up, clockwise positive).
*/
@Composable
private fun NavigationNeedle(
bearingDeg: Double,
color: Color,
dimmed: Boolean,
modifier: Modifier = Modifier
) {
Canvas(modifier = modifier) {
val cx = size.width / 2f
val cy = size.height / 2f
val r = size.minDimension / 2f
if (dimmed) {
drawCircle(color = color, radius = r * 0.92f, style = Stroke(width = 1.5f))
}
rotate(degrees = bearingDeg.toFloat(), pivot = Offset(cx, cy)) {
val path = Path().apply {
moveTo(cx, cy - r * 0.9f) // tip (north before rotation)
lineTo(cx - r * 0.42f, cy + r * 0.55f)
lineTo(cx, cy + r * 0.28f)
lineTo(cx + r * 0.42f, cy + r * 0.55f)
close()
}
drawPath(path, color = color)
}
}
}
/** Great-circle distance in km between two positions. */
fun greatCircleDistanceKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val rad = Math.PI / 180.0
val dLat = (lat2 - lat1) * rad
val dLon = (lon2 - lon1) * rad
val a = sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * rad) * cos(lat2 * rad) * sin(dLon / 2) * sin(dLon / 2)
return 6371.0 * 2 * kotlin.math.atan2(kotlin.math.sqrt(a), kotlin.math.sqrt(1 - a))
}
/** Initial great-circle bearing in degrees (0 = north, clockwise). */
fun greatCircleBearingDeg(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val rad = Math.PI / 180.0
val dLon = (lon2 - lon1) * rad
val y = sin(dLon) * cos(lat2 * rad)
val x = cos(lat1 * rad) * sin(lat2 * rad) -
sin(lat1 * rad) * cos(lat2 * rad) * cos(dLon)
return (Math.atan2(y, x) / rad + 360.0) % 360.0
}
@@ -145,9 +145,17 @@
<string name="prefs_wavelog_not_configured">未配置 — 连接你的 Wavelog 日志以在地图上高亮已通联网格</string>
<string name="prefs_wavelog_url">Wavelog 地址</string>
<string name="prefs_wavelog_token">API key :日志 ID</string>
<string name="prefs_wavelog_hint">已同步网格数:%1$d。格式:API key:日志ID。请先保存,再点同步。</string>
<string name="prefs_wavelog_hint">已同步网格数:%1$d。格式:API key:日志ID。填好直接点同步,会自动保存并拉取。</string>
<string name="prefs_wavelog_sync">立即同步</string>
<string name="prefs_wavelog_syncing">同步中…</string>
<string name="prefs_lotw_title">LoTW 已确认网格</string>
<string name="prefs_lotw_callsign">LoTW 呼号</string>
<string name="prefs_lotw_password">LoTW 密码</string>
<string name="prefs_lotw_hint">直接从 ARRL LoTW 拉取该呼号已确认 QSO 的网格并覆盖本地已存集合(当前 %1$d 个)。密码仅保存在手机本地。点"同步"会自动保存凭据并拉取。</string>
<string name="prefs_lotw_sync">同步</string>
<string name="prefs_lotw_syncing">同步中…</string>
<string name="prefs_lotw_configured">已从 LoTW 同步 — 共 %1$d 个网格</string>
<string name="prefs_lotw_not_configured">未配置 — 直接登录 ARRL LoTW 拉取已确认 QSO 的网格,无需自建服务器</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
<string name="prefs_data_title">卫星数据更新</string>
@@ -180,9 +180,17 @@
<string name="prefs_wavelog_not_configured">Not configured — connect your Wavelog logbook to highlight worked grids on the map</string>
<string name="prefs_wavelog_url">Wavelog URL</string>
<string name="prefs_wavelog_token">API key : logbook ID</string>
<string name="prefs_wavelog_hint">Worked grids synced: %1$d. Format: API key, colon, logbook ID. Save first, then tap Sync.</string>
<string name="prefs_wavelog_hint">Worked grids synced: %1$d. Format: API key, colon, logbook ID. Just tap Sync — credentials are saved automatically.</string>
<string name="prefs_wavelog_sync">Sync now</string>
<string name="prefs_wavelog_syncing">Syncing…</string>
<string name="prefs_lotw_title">LoTW confirmed grids</string>
<string name="prefs_lotw_callsign">LoTW callsign</string>
<string name="prefs_lotw_password">LoTW password</string>
<string name="prefs_lotw_hint">Fetch confirmed-QSO grids directly from ARRL LoTW for this callsign and replace the stored set (currently %1$d). The password is stored on this device only. Tapping Sync saves the credentials and fetches in one step.</string>
<string name="prefs_lotw_sync">Sync</string>
<string name="prefs_lotw_syncing">Syncing…</string>
<string name="prefs_lotw_configured">Synced from LoTW — %1$d grids total</string>
<string name="prefs_lotw_not_configured">Not configured — pull confirmed grids directly from ARRL LoTW, no self-hosted server needed</string>
<string name="prefs_locator_text">Set station\'s position using locator</string>
<string name="prefs_data_title">Satellite data</string>
@@ -56,6 +56,11 @@ class MaidenheadGridOverlay : Overlay() {
style = android.graphics.Paint.Style.FILL
color = Color.argb(90, 76, 217, 100)
}
private val ownLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
strokeWidth = 4.5f
style = Paint.Style.STROKE
color = Color.argb(255, 90, 200, 255)
}
/** Worked gridsquares (4-char, uppercase) to highlight, e.g. {"OL62", "PM95"}. */
var workedGrids: Set<String> = emptySet()
@@ -63,10 +68,14 @@ class MaidenheadGridOverlay : Overlay() {
/** The station's own 4-char gridsquare, drawn with a distinct outline. */
var ownGrid: String? = null
/** Viewport width, refreshed each draw; used by projectionToX bounds. */
private var canvasWidthPx = 1080f
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
if (shadow || !isEnabled) return
val projection = mapView.projection
val zoom = mapView.zoomLevelDouble
canvasWidthPx = canvas.width.toFloat()
val cellLat = if (zoom >= GRID_ZOOM_SUB) SUB_SQUARE_LAT else FIELD_LAT
val cellLon = if (zoom >= GRID_ZOOM_SUB) SUB_SQUARE_LON else FIELD_LON
@@ -84,33 +93,61 @@ class MaidenheadGridOverlay : Overlay() {
// crosses the antimeridian; index arithmetic still works (PMxx at 360° == same grid)
val firstCol = floor(leftLon / cellLon).toInt()
val lastCol = ceil(rightLon / cellLon).toInt()
val centerLon = (leftLon + rightLon) / 2.0
// World width in screen pixels for the current zoom (256 px per tile,
// 2^zoom tiles across the whole 360° world). Used by the custom
// Mercator-X computation in projectionToX.
val worldWidthPx = 256.0 * Math.pow(2.0, zoom)
// Worked-grid highlight fills (only meaningful at sub-square zoom)
if (workedGrids.isNotEmpty() && zoom >= GRID_ZOOM_SUB) {
for (row in firstRow..lastRow) {
val lat = row * cellLat
if (lat < -90.0 || lat >= 90.0) continue
val topLatCell = lat + cellLat
if (topLatCell > 90.0) continue
val yTop = projectionToY(projection, topLatCell)
val yBottom = projectionToY(projection, lat)
if (yTop == null || yBottom == null) continue
for (col in firstCol..lastCol) {
val lon = col * cellLon
val xLeft = projectionToX(projection, lon) ?: continue
val xRight = projectionToX(projection, lon + cellLon) ?: continue
if (xRight < 0f || xLeft > canvas.width) continue
if (cellLabel(lat, lon, zoom) in workedGrids) {
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
// The station's own grid square (4-char, only meaningful at sub-square zoom)
val ownCell = ownGrid?.takeIf { zoom >= GRID_ZOOM_SUB }
// Worked-grid highlight fills.
// - Sub-square zoom: fill each worked 4-char cell directly.
// - Field zoom (two-char labels): do NOT fill whole fields; instead fill
// the individual 2°x1° squares that were worked, without drawing the
// square grid lines — so you see green patches inside the field.
if (workedGrids.isNotEmpty()) {
if (zoom >= GRID_ZOOM_SUB) {
for (row in firstRow..lastRow) {
val lat = row * cellLat
if (lat < -90.0 || lat >= 90.0) continue
val topLatCell = lat + cellLat
if (topLatCell > 90.0) continue
val yTop = projectionToY(projection, topLatCell)
val yBottom = projectionToY(projection, lat)
if (yTop == null || yBottom == null) continue
for (col in firstCol..lastCol) {
val lon = col * cellLon
val xLeft = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
val xRight = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
if (xRight < 0f || xLeft > canvas.width) continue
if (cellLabel(lat, lon, zoom) in workedGrids) {
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
}
}
}
} else {
for (grid in workedGrids) {
val cell = gridCellBounds(grid) ?: continue
if (cell.lonRight <= leftLon || cell.lonLeft >= rightLon ||
cell.latTop <= bottomLat || cell.latBottom >= topLat
) continue
val yTop = projectionToY(projection, cell.latTop) ?: continue
val yBottom = projectionToY(projection, cell.latBottom) ?: continue
val xLeft = projectionToX(projection, cell.lonLeft, centerLon, worldWidthPx) ?: continue
val xRight = projectionToX(projection, cell.lonRight, centerLon, worldWidthPx) ?: continue
if (xRight < 0f || xLeft > canvas.width) continue
if (yBottom < 0f || yTop > canvas.height) continue
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
}
}
}
// Vertical lines (meridians)
for (col in firstCol..lastCol) {
val lon = col * cellLon
val x = projectionToX(projection, lon)
val x = projectionToX(projection, lon, centerLon, worldWidthPx)
if (x == null) continue
canvas.drawLine(x, 0f, x, canvas.height.toFloat(), linePaint)
}
@@ -123,8 +160,29 @@ class MaidenheadGridOverlay : Overlay() {
canvas.drawLine(0f, y, canvas.width.toFloat(), y, linePaint)
}
// Labels: place at the top-left corner of each cell, only when the cell
// is large enough on screen to hold a label (avoid clutter at low zoom)
// The station's own grid square: redraw its four borders thicker on top.
if (ownCell != null) {
val ownColIdx = firstCol..lastCol
for (row in firstRow..lastRow) {
val lat = row * cellLat
if (lat < -90.0 || lat >= 90.0) continue
for (col in ownColIdx) {
val lon = col * cellLon
if (cellLabel(lat, lon, zoom) != ownCell) continue
val yTop = projectionToY(projection, lat + cellLat) ?: continue
val yBottom = projectionToY(projection, lat) ?: continue
val xLeft = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
val xRight = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
canvas.drawLine(xLeft, yTop, xRight, yTop, ownLinePaint)
canvas.drawLine(xLeft, yBottom, xRight, yBottom, ownLinePaint)
canvas.drawLine(xLeft, yTop, xLeft, yBottom, ownLinePaint)
canvas.drawLine(xRight, yTop, xRight, yBottom, ownLinePaint)
}
}
}
// Labels: centered in each cell, only when the cell is large enough on
// screen to hold a label (avoid clutter at low zoom)
val showLabels = when {
zoom >= GRID_ZOOM_SUB -> true
zoom >= FIELD_ZOOM_LABELS -> true
@@ -139,29 +197,42 @@ class MaidenheadGridOverlay : Overlay() {
val pixelsPerDegree = Math.abs(y2 - y1)
if (pixelsPerDegree * cellLat < MIN_LABEL_CELL_PX) return
val labelOffset = 8f
labelPaint.textAlign = Paint.Align.CENTER
val fontMetrics = labelPaint.fontMetrics
val textHalfHeight = (fontMetrics.descent + fontMetrics.ascent) / 2f
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 y = projectionToY(projection, topLatCell) ?: continue
if (y < -labelPaint.textSize || y > canvas.height) continue
val yTop = projectionToY(projection, topLatCell) ?: continue
val yBottom = projectionToY(projection, lat) ?: continue
val yCenter = (yTop + yBottom) / 2f - textHalfHeight
if (yBottom < 0f || yTop > canvas.height) continue
for (col in firstCol..lastCol) {
val lon = col * cellLon
val x = projectionToX(projection, lon) ?: continue
if (x < -200f || x > canvas.width) continue
val xLeft = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
val xRight = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
if (xRight < 0f || xLeft > canvas.width) continue
val label = cellLabel(lat, lon, zoom)
canvas.drawText(label, x + labelOffset, y + labelPaint.textSize + labelOffset, labelPaint)
canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint)
}
}
}
/** X pixel for a longitude (meridians are vertical in Web Mercator). */
private fun projectionToX(projection: Projection, lon: Double): Float? {
val geo = org.osmdroid.util.GeoPoint(0.0, lon)
private fun projectionToX(projection: Projection, lon: Double, centerLon: Double, worldWidthPx: Double): Float? {
// Do NOT use osmdroid's toPixels() here: at low zoom its wrap-around
// logic (getCloserPixel) mis-projects longitudes far from the view
// center, which makes meridians vanish while panning. Mercator X is
// linear in longitude, so compute it directly:
// x = screenCenterX + (lon - centerLon) / 360 * worldWidthPx
var delta = lon - centerLon
while (delta > 180.0) delta -= 360.0
while (delta < -180.0) delta += 360.0
val geo = org.osmdroid.util.GeoPoint(0.0, centerLon)
val p = projection.toPixels(geo, null)
return if (p.x in -MAX_OVERSHOOT_PX..MAX_OVERSHOOT_PX + 4096) p.x.toFloat() else null
return (p.x + delta / 360.0 * worldWidthPx).toFloat()
}
/** Y pixel for a latitude, or null when outside the viewport. */
@@ -186,6 +257,30 @@ class MaidenheadGridOverlay : Overlay() {
return "$field$squareLon$squareLat"
}
/**
* Geographic bounds of a 4-char Maidenhead square (e.g. "OL62"):
* 2° wide in longitude, 1° tall in latitude.
*/
private fun gridCellBounds(grid: String): CellBounds? {
val g = grid.trim().uppercase()
if (g.length < 4) return null
val fieldLon = g[0] - 'A'
val fieldLat = g[1] - 'A'
val sqLon = g[2] - '0'
val sqLat = g[3] - '0'
if (fieldLon !in 0..17 || fieldLat !in 0..17 || sqLon !in 0..9 || sqLat !in 0..9) return null
val lonLeft = -180.0 + fieldLon * FIELD_LON + sqLon * SUB_SQUARE_LON
val latBottom = -90.0 + fieldLat * FIELD_LAT + sqLat * SUB_SQUARE_LAT
return CellBounds(lonLeft, lonLeft + SUB_SQUARE_LON, latBottom, latBottom + SUB_SQUARE_LAT)
}
private data class CellBounds(
val lonLeft: Double,
val lonRight: Double,
val latBottom: Double,
val latTop: Double
)
private fun normalizeLon(lon: Double): Double {
var l = lon % 360.0
if (l >= 180.0) l -= 360.0
@@ -42,7 +42,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
@@ -137,6 +139,10 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
val isTimeAos = uiState.mapData?.isTimeAos ?: true
LaunchedEffect(uiState.track) {
// In grid mode the map is centered on the local grid square; following
// the satellite subpoint here would override that centering on every
// track update, so skip the tracking animation entirely.
if (uiState.isGridMode) return@LaunchedEffect
val firstPos = uiState.track?.firstOrNull()?.firstOrNull() ?: return@LaunchedEffect
mapView.controller.animateTo(GeoPoint(firstPos.latitude, firstPos.longitude))
}
@@ -159,8 +165,22 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
}
ElevatedCard(modifier = Modifier.weight(1f)) {
Box(contentAlignment = Alignment.BottomCenter) {
// Track grid-mode transitions; initial value false so that entering
// the map page with grid mode already ON also centers the map.
var prevGridMode by remember { mutableStateOf(false) }
AndroidView({ mapView }) { view ->
setGridMode(uiState.isGridMode, uiState.workedGrids, view)
// Center on the station grid whenever entering grid mode.
// Only mark the transition as consumed once a real position
// was available; otherwise a first frame with a null
// stationPosition would swallow the centering forever.
val shouldCenter = uiState.isGridMode && !prevGridMode
setGridMode(
uiState.isGridMode, uiState.workedGrids, view,
if (shouldCenter) uiState.stationPosition else null
)
if (!shouldCenter || uiState.stationPosition != null) {
prevGridMode = uiState.isGridMode
}
if (!uiState.isGridMode) {
uiState.stationPosition?.let { setStationPosition(it, view) }
uiState.track?.let { setSatelliteTrack(it, view) }
@@ -173,7 +193,9 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
view.invalidate()
}
uiState.mapData?.let { mapData ->
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
if (!uiState.isGridMode) {
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
}
}
}
}
@@ -267,16 +289,23 @@ private fun MapDataCards(data: MapData) {
// region Map overlay helpers
/** Toggle the grid-mode layer visibility on/off without recreating any overlay. */
private fun setGridMode(gridMode: Boolean, workedGrids: Set<String>, mapView: MapView) {
private fun setGridMode(
gridMode: Boolean,
workedGrids: Set<String>,
mapView: MapView,
stationPosition: GeoPos?
) {
try {
val gridOverlay = mapView.overlays[OVERLAY_GRID]
if (gridOverlay is MaidenheadGridOverlay) {
gridOverlay.isEnabled = gridMode
gridOverlay.workedGrids = workedGrids
gridOverlay.ownGrid = ownGridOf(stationPosition)
} else {
mapView.overlays[OVERLAY_GRID] = MaidenheadGridOverlay().apply {
isEnabled = gridMode
this.workedGrids = workedGrids
this.ownGrid = ownGridOf(stationPosition)
}
}
// Satellite-related layers are hidden in grid mode; the grid overlay
@@ -284,11 +313,35 @@ private fun setGridMode(gridMode: Boolean, workedGrids: Set<String>, mapView: Ma
for (index in OVERLAY_STATION..OVERLAY_MOON) {
mapView.overlays.getOrNull(index)?.isEnabled = !gridMode
}
// Entering grid mode: center on the station's current grid square,
// keeping the current zoom level unchanged.
if (gridMode) {
val pos = stationPosition ?: return
val lat = pos.latitude
val lon = pos.longitude
val fieldLat = ((lat + 90.0) / 10.0).toInt().coerceIn(0, 17)
val fieldLon = ((lon + 180.0) / 20.0).toInt().coerceIn(0, 17)
val centerLat = fieldLat * 10.0 + 5.0
val centerLon = fieldLon * 20.0 + 10.0
mapView.controller.setCenter(GeoPoint(centerLat, centerLon))
}
} catch (e: Exception) {
println(e)
}
}
/** The station's 4-char Maidenhead gridsquare, e.g. "OL62", or null without a position. */
private fun ownGridOf(stationPosition: GeoPos?): String? {
val pos = stationPosition ?: return null
val lat = pos.latitude
val lon = pos.longitude
val fieldLat = ((lat + 90.0) / 10.0).toInt().coerceIn(0, 17)
val fieldLon = ((lon + 180.0) / 20.0).toInt().coerceIn(0, 17)
val subLat = ((lat + 90.0) % 10.0).toInt()
val subLon = ((lon + 180.0) % 20.0 / 2.0).toInt()
return "${'A' + fieldLon}${'A' + fieldLat}$subLon$subLat"
}
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
try {
val overlay = mapView.overlays[OVERLAY_STATION]
@@ -84,6 +84,7 @@ class MapViewModel(
}
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
selectDefaultSatellite(if (selectedCatNum != 0) selectedCatNum else -1)
getStationPosition()
}
fun onAction(action: MapAction) {
@@ -63,6 +63,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.repository.TrackSampleData
import com.rtbishop.look4sat.core.presentation.GridTargetChip
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.TopBar
@@ -88,7 +89,8 @@ fun MutualScreen(
Text(
text = stringResource(R.string.mutual_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 12.dp)
)
},
bottomInfo = {
@@ -98,7 +100,9 @@ fun MutualScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
modifier = Modifier
.weight(1f)
.padding(start = 12.dp)
)
},
endAction = {
@@ -114,7 +118,29 @@ fun MutualScreen(
strokeWidth = 2.dp
)
}
MutualStatusChip(state)
// Target-grid chip: station B grid (station A defaults to
// the user's own position), bearing/distance from A to B.
val gridB = state.stationBGrid.trim().uppercase()
val posA = com.rtbishop.look4sat.core.domain.utility.qthToPosition(state.stationAGrid)
?: state.stationALat.toDoubleOrNull()?.let { lat ->
state.stationALon.toDoubleOrNull()?.let { lon ->
com.rtbishop.look4sat.core.domain.predict.GeoPos(lat, lon)
}
}
val posB = com.rtbishop.look4sat.core.domain.utility.qthToPosition(gridB)
if (posA != null && posB != null) {
GridTargetChip(
grid = gridB,
distanceKm = com.rtbishop.look4sat.core.presentation.greatCircleDistanceKm(
posA.latitude, posA.longitude, posB.latitude, posB.longitude
),
bearingDeg = com.rtbishop.look4sat.core.presentation.greatCircleBearingDeg(
posA.latitude, posA.longitude, posB.latitude, posB.longitude
)
)
} else {
GridTargetChip(grid = null, distanceKm = null, bearingDeg = null)
}
}
}
)
@@ -164,7 +164,7 @@ fun WavelogDialog(
message: String?,
dismiss: () -> Unit,
onSave: (com.rtbishop.look4sat.core.domain.model.WavelogSettings) -> Unit,
onSync: () -> Unit
onSync: (com.rtbishop.look4sat.core.domain.model.WavelogSettings) -> Unit
) {
val url = rememberSaveable { mutableStateOf(initialSettings.url) }
val token = rememberSaveable { mutableStateOf(initialSettings.token) }
@@ -205,12 +205,16 @@ fun WavelogDialog(
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
// Sync button row (inside dialog content so token must be saved first)
// Sync uses the values typed in the fields directly — saving and syncing
// happen in one step, no need to close and reopen the dialog.
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
) {
TextButton(onClick = onSync, enabled = !isSyncing) {
TextButton(
onClick = { onSync(com.rtbishop.look4sat.core.domain.model.WavelogSettings(url.value, token.value)) },
enabled = !isSyncing
) {
Text(text = if (isSyncing) stringResource(R.string.prefs_wavelog_syncing)
else stringResource(R.string.prefs_wavelog_sync))
}
@@ -219,6 +223,63 @@ fun WavelogDialog(
}
}
@Composable
fun LoTWDialog(
initialSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
workedGridsCount: Int,
isSyncing: Boolean,
message: String?,
dismiss: () -> Unit,
onSave: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit,
onSync: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit
) {
val call = rememberSaveable { mutableStateOf(initialSettings.callsign) }
val pass = rememberSaveable { mutableStateOf(initialSettings.password) }
SharedDialog(
title = stringResource(R.string.prefs_lotw_title),
onCancel = dismiss,
// The confirm button IS the sync action: saving credentials and
// fetching confirmed grids happen in one step.
onAccept = {
onSync(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value))
},
acceptText = stringResource(if (isSyncing) R.string.prefs_lotw_syncing else R.string.prefs_lotw_sync),
acceptEnabled = !isSyncing
) {
OutlinedTextField(
value = call.value,
onValueChange = { call.value = it.uppercase() },
label = { Text(text = stringResource(id = R.string.prefs_lotw_callsign)) },
placeholder = { Text(text = "BA7OPF") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
OutlinedTextField(
value = pass.value,
onValueChange = { pass.value = it },
label = { Text(text = stringResource(id = R.string.prefs_lotw_password)) },
placeholder = { Text(text = "********") },
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
Text(
text = stringResource(R.string.prefs_lotw_hint, workedGridsCount),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
if (message != null) {
Text(
text = message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
Spacer(modifier = Modifier.height(0.dp))
}
}
@Preview(showBackground = true)
@Composable
private fun TransceiversDialogPreview() {
@@ -203,15 +203,15 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
)
}
if (dialogs.wavelog) {
WavelogDialog(
initialSettings = uiState.wavelogSettings,
if (dialogs.lotw) {
LoTWDialog(
initialSettings = uiState.lotwSettings,
workedGridsCount = uiState.workedGridsCount,
isSyncing = uiState.wavelogSyncing,
message = uiState.wavelogMessage,
dismiss = { dialogs.wavelog = false },
onSave = { onAction(SettingsAction.UpdateWavelog(it)) },
onSync = { onAction(SettingsAction.SyncWorkedGrids) }
isSyncing = uiState.lotwSyncing,
message = uiState.lotwMessage,
dismiss = { dialogs.lotw = false },
onSave = { onAction(SettingsAction.UpdateLoTW(it)) },
onSync = { onAction(SettingsAction.SyncLoTWGrids(it)) }
)
}
@@ -319,10 +319,10 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
)
}
item {
WavelogCard(
settings = uiState.wavelogSettings,
LoTWCard(
settings = uiState.lotwSettings,
workedGridsCount = uiState.workedGridsCount,
showWavelogDialog = { dialogs.wavelog = true }
showLoTWDialog = { dialogs.lotw = true }
)
}
item { OtherCard(uiState.otherSettings, onAction) }
@@ -562,37 +562,31 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole
}
}
@Preview(showBackground = true)
@Composable
private fun WavelogCardPreview() = MainTheme {
WavelogCard(settings = WavelogSettings("http://192.168.1.10", "wl2_demo"), workedGridsCount = 42) {}
}
@Composable
private fun WavelogCard(
settings: WavelogSettings,
private fun LoTWCard(
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
workedGridsCount: Int,
showWavelogDialog: () -> Unit
showLoTWDialog: () -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
text = stringResource(id = R.string.prefs_wavelog_title),
text = stringResource(id = R.string.prefs_lotw_title),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = if (settings.isConfigured) {
stringResource(R.string.prefs_wavelog_configured, workedGridsCount)
stringResource(R.string.prefs_lotw_configured, workedGridsCount)
} else {
stringResource(R.string.prefs_wavelog_not_configured)
stringResource(R.string.prefs_lotw_not_configured)
},
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)
Spacer(modifier = Modifier.height(4.dp))
CardButton(
onClick = showWavelogDialog,
onClick = showLoTWDialog,
text = stringResource(id = R.string.prefs_wavelog_configure),
modifier = Modifier.fillMaxWidth()
)
@@ -720,6 +714,7 @@ private class DialogVisibility {
var bluetooth by mutableStateOf(false)
var radioControl by mutableStateOf(false)
var wavelog by mutableStateOf(false)
var lotw by mutableStateOf(false)
}
@Composable
@@ -727,12 +722,13 @@ private fun rememberDialogVisibility(): DialogVisibility {
return rememberSaveable(saver = run {
androidx.compose.runtime.saveable.Saver(
save = {
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog)
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw)
},
restore = {
DialogVisibility().apply {
position = it[0]; locator = it[1]; dataSources = it[2]
network = it[3]; bluetooth = it[4]; radioControl = it[5]; wavelog = it[6]
lotw = it.getOrElse(7) { false }
}
}
)
@@ -59,6 +59,9 @@ data class SettingsState(
val workedGridsCount: Int = 0,
val wavelogSyncing: Boolean = false,
val wavelogMessage: String? = null,
val lotwSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings = com.rtbishop.look4sat.core.domain.model.LoTWSettings(),
val lotwSyncing: Boolean = false,
val lotwMessage: String? = null,
val updateChecker: UpdateCheckerState = UpdateCheckerState()
)
@@ -93,7 +96,11 @@ sealed interface SettingsAction {
// Wavelog worked grids
data class UpdateWavelog(val settings: WavelogSettings) : SettingsAction
data object SyncWorkedGrids : SettingsAction
data class SyncWorkedGrids(val settings: WavelogSettings) : SettingsAction
// LoTW confirmed grids
data class UpdateLoTW(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction
data class SyncLoTWGrids(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction
// Update checker
data object CheckForUpdate : SettingsAction
@@ -23,6 +23,7 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
@@ -41,6 +42,7 @@ class SettingsViewModel(
private val settingsRepo: ISettingsRepo,
private val updateRepo: IUpdateRepository,
private val wavelogRepo: IWavelogRepository,
private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository,
private val apkFile: File,
private val showToast: IShowToast
) : ViewModel() {
@@ -116,6 +118,11 @@ class SettingsViewModel(
_uiState.update { it.copy(wavelogSettings = settings) }
}
}
viewModelScope.launch {
settingsRepo.lotwSettings.collect { settings ->
_uiState.update { it.copy(lotwSettings = settings) }
}
}
}
@@ -149,7 +156,10 @@ class SettingsViewModel(
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
// Wavelog worked grids
is SettingsAction.UpdateWavelog -> settingsRepo.updateWavelogSettings(action.settings)
SettingsAction.SyncWorkedGrids -> syncWorkedGrids()
is SettingsAction.SyncWorkedGrids -> syncWorkedGrids(action.settings)
// LoTW confirmed grids
is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings)
// Update checker
SettingsAction.CheckForUpdate -> checkForUpdate()
SettingsAction.DownloadUpdate -> downloadUpdate()
@@ -161,12 +171,13 @@ class SettingsViewModel(
// region Wavelog worked grids
private fun syncWorkedGrids() {
val settings = _uiState.value.wavelogSettings
private fun syncWorkedGrids(settings: WavelogSettings) {
if (!settings.isConfigured) {
_uiState.update { it.copy(wavelogMessage = "Wavelog URL/token not configured") }
return
}
// Persist the credentials first, then sync with the freshly-typed values.
settingsRepo.updateWavelogSettings(settings)
_uiState.update { it.copy(wavelogSyncing = true, wavelogMessage = null) }
viewModelScope.launch {
val grids = wavelogRepo.fetchWorkedGrids(settings.url, settings.token)
@@ -181,6 +192,29 @@ class SettingsViewModel(
}
}
private fun syncLoTWGrids(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) {
if (!settings.isConfigured) {
_uiState.update { it.copy(lotwMessage = "LoTW callsign/password not configured") }
return
}
// Persist the credentials first, then sync with the freshly-typed values.
settingsRepo.updateLoTWSettings(settings)
_uiState.update { it.copy(lotwSyncing = true, lotwMessage = null) }
viewModelScope.launch {
val lotwGrids = lotwRepo.fetchConfirmedGrids(settings.callsign, settings.password)
_uiState.update { state ->
if (lotwGrids == null) {
state.copy(lotwSyncing = false, lotwMessage = "LoTW sync failed — check callsign/password/network")
} else {
// Wavelog entry removed: LoTW is now the only source, so the
// synced set fully replaces the stored worked grids.
settingsRepo.setWorkedGrids(lotwGrids)
state.copy(lotwSyncing = false, workedGridsCount = lotwGrids.size, lotwMessage = null)
}
}
}
}
// endregion
// region Update checker
@@ -305,6 +339,7 @@ class SettingsViewModel(
settingsRepo = container.settingsRepo,
updateRepo = container.updateRepo,
wavelogRepo = container.wavelogRepo,
lotwRepo = container.lotwRepo,
apkFile = File(context.cacheDir, "look4sat-update.apk"),
showToast = container.provideShowToast()
)
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "453"
appVersionCode = "459"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.6-ba7opf.9.1"
appVersionName = "4.4.6-ba7opf.9.7"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry