mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9fe0b74e9 | ||
|
|
2c9a5b376f | ||
|
|
422e480b2f | ||
|
|
efd6931abb | ||
|
|
c7ca77a943 | ||
|
|
be6473e692 |
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,102 @@
|
||||
/*
|
||||
* 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
|
||||
connection.connectTimeout = 15_000
|
||||
connection.readTimeout = 60_000
|
||||
connection.requestMethod = "GET"
|
||||
val code = connection.responseCode
|
||||
if (code !in 200..299) {
|
||||
connection.disconnect()
|
||||
return@withContext null
|
||||
}
|
||||
val body = connection.inputStream.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
|
||||
|
||||
+46
-14
@@ -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("<", "<").replace(">", ">").replace("&", "&")
|
||||
.replace(""", "\"").replace("'", "'")
|
||||
.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,
|
||||
|
||||
+28
@@ -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>?
|
||||
}
|
||||
+1
@@ -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)
|
||||
|
||||
+5
@@ -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
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
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
|
||||
val accent = MaterialTheme.colorScheme.tertiaryContainer
|
||||
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 = if (configured) Color.White else placeholder,
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = if (configured) MaterialTheme.colorScheme.onSurfaceVariant else Color.Transparent,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
.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 的网格并与 Wavelog 网格合并(当前 %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 and merge them with Wavelog grids (currently %1$d). The password is stored on this device only. Just tap Sync — credentials are saved automatically.</string>
|
||||
<string name="prefs_lotw_sync">Sync now</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>
|
||||
|
||||
+51
-9
@@ -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
|
||||
|
||||
@@ -86,6 +95,9 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
val lastCol = ceil(rightLon / cellLon).toInt()
|
||||
val centerLon = (leftLon + rightLon) / 2.0
|
||||
|
||||
// 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 (only meaningful at sub-square zoom)
|
||||
if (workedGrids.isNotEmpty() && zoom >= GRID_ZOOM_SUB) {
|
||||
for (row in firstRow..lastRow) {
|
||||
@@ -124,8 +136,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) ?: continue
|
||||
val xRight = projectionToX(projection, lon + cellLon, centerLon) ?: 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
|
||||
@@ -140,20 +173,25 @@ 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, centerLon) ?: continue
|
||||
if (x < -200f || x > canvas.width) continue
|
||||
val xLeft = projectionToX(projection, lon, centerLon) ?: continue
|
||||
val xRight = projectionToX(projection, lon + cellLon, centerLon) ?: 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,7 +208,11 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
while (normalized > centerLon + 180.0) normalized -= 360.0
|
||||
val geo = org.osmdroid.util.GeoPoint(0.0, normalized)
|
||||
val p = projection.toPixels(geo, null)
|
||||
return if (p.x in -MAX_OVERSHOOT_PX..MAX_OVERSHOOT_PX + 4096) p.x.toFloat() else null
|
||||
// Accept generous horizontal overshoot: at low zoom the world repeats and
|
||||
// a meridian just off-screen may still be projected; rejecting too early
|
||||
// makes lines vanish while panning. 2x viewport width is plenty.
|
||||
val limit = canvasWidthPx * 2f + MAX_OVERSHOOT_PX
|
||||
return if (p.x >= -limit && p.x <= limit) p.x.toFloat() else null
|
||||
}
|
||||
|
||||
/** Y pixel for a latitude, or null when outside the viewport. */
|
||||
|
||||
@@ -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
|
||||
@@ -159,8 +161,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) }
|
||||
@@ -269,16 +285,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
|
||||
@@ -286,11 +309,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
|
||||
@@ -114,7 +115,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+75
-3
@@ -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,74 @@ 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,
|
||||
onAccept = {
|
||||
onSave(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value))
|
||||
dismiss()
|
||||
}
|
||||
) {
|
||||
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)
|
||||
)
|
||||
}
|
||||
// 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(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value)) },
|
||||
enabled = !isSyncing
|
||||
) {
|
||||
Text(text = if (isSyncing) stringResource(R.string.prefs_lotw_syncing)
|
||||
else stringResource(R.string.prefs_lotw_sync))
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(0.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun TransceiversDialogPreview() {
|
||||
|
||||
+55
-2
@@ -211,7 +211,19 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
message = uiState.wavelogMessage,
|
||||
dismiss = { dialogs.wavelog = false },
|
||||
onSave = { onAction(SettingsAction.UpdateWavelog(it)) },
|
||||
onSync = { onAction(SettingsAction.SyncWorkedGrids) }
|
||||
onSync = { onAction(SettingsAction.SyncWorkedGrids(it)) }
|
||||
)
|
||||
}
|
||||
|
||||
if (dialogs.lotw) {
|
||||
LoTWDialog(
|
||||
initialSettings = uiState.lotwSettings,
|
||||
workedGridsCount = uiState.workedGridsCount,
|
||||
isSyncing = uiState.lotwSyncing,
|
||||
message = uiState.lotwMessage,
|
||||
dismiss = { dialogs.lotw = false },
|
||||
onSave = { onAction(SettingsAction.UpdateLoTW(it)) },
|
||||
onSync = { onAction(SettingsAction.SyncLoTWGrids(it)) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -325,6 +337,13 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
showWavelogDialog = { dialogs.wavelog = true }
|
||||
)
|
||||
}
|
||||
item {
|
||||
LoTWCard(
|
||||
settings = uiState.lotwSettings,
|
||||
workedGridsCount = uiState.workedGridsCount,
|
||||
showLoTWDialog = { dialogs.lotw = true }
|
||||
)
|
||||
}
|
||||
item { OtherCard(uiState.otherSettings, onAction) }
|
||||
item { CardCredits() }
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
@@ -600,6 +619,38 @@ private fun WavelogCard(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoTWCard(
|
||||
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
||||
workedGridsCount: Int,
|
||||
showLoTWDialog: () -> Unit
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Text(
|
||||
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_lotw_configured, workedGridsCount)
|
||||
} else {
|
||||
stringResource(R.string.prefs_lotw_not_configured)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
CardButton(
|
||||
onClick = showLoTWDialog,
|
||||
text = stringResource(id = R.string.prefs_wavelog_configure),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun formatUpdateTime(updateTime: Long): String {
|
||||
val timePattern = stringResource(id = R.string.prefs_updated_time)
|
||||
@@ -720,6 +771,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 +779,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 }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
+38
-3
@@ -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 {
|
||||
// Merge with Wavelog grids: union of both sources, all confirmed/worked
|
||||
val merged = settingsRepo.getWorkedGrids() + lotwGrids
|
||||
settingsRepo.setWorkedGrids(merged)
|
||||
state.copy(lotwSyncing = false, workedGridsCount = merged.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()
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "454"
|
||||
appVersionCode = "457"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.6-ba7opf.9.2"
|
||||
appVersionName = "4.4.6-ba7opf.9.5"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user