mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f997d95cd4 | ||
|
|
e0a1d279ce | ||
|
|
059c816075 | ||
|
|
60dfa5d600 | ||
|
|
5f1eb239ed | ||
|
|
01ca06b43f | ||
|
|
22363c2549 | ||
|
|
45fe755470 | ||
|
|
0ce177cfbf | ||
|
|
f3fdf61784 | ||
|
|
fc028f852c | ||
|
|
7a865fffd7 | ||
|
|
89220a0b4b | ||
|
|
03d0055ce2 | ||
|
|
afb1e434e8 | ||
|
|
1bed7849a3 | ||
|
|
06f00e1601 | ||
|
|
462c3de155 | ||
|
|
4020381b03 | ||
|
|
b8389f63fe | ||
|
|
e64beee44f | ||
|
|
d2dffc039c | ||
|
|
d7d4a752ed | ||
|
|
6a3ab54226 | ||
|
|
3b3579696b | ||
|
|
826f44829b | ||
|
|
b979524b37 | ||
|
|
9e748f5534 | ||
|
|
25b982ac59 | ||
|
|
6a6399b606 |
No files matched your search
@@ -6,13 +6,12 @@
|
||||
|
||||
## 本仓库特色功能
|
||||
|
||||
- **网格地图模式(Grid Mode)** — 地图页内置 VUCC 网格覆盖图:已通联网格绿色填充、漫游网格蓝色 45° 斜纹(疏朗半透明样式)、当前所在方格淡黄加粗框(field 与 sub-square 缩放均显示);极区(南北纬 80°–90°)网格线与二位标签完整可见;点击已通联网格弹出该网格内通联明细弹窗;进入网格模式默认 VUCC 视图
|
||||
- **奖状边界模式(Award Boundaries)** — 在网格地图上叠加 DXCC / WAPC / WAJA / WAZ / WAS 国界·省界·分区边界,已确认区域绿色填充;香港/澳门作为独立实体显示(DXCC 321/152),港澳标签强制显示
|
||||
- **网格地图模式(Grid Mode)** — 地图页内置 VUCC 网格覆盖图:已通联网格绿色填充、漫游网格蓝色 45° 斜纹(疏朗半透明样式)、当前所在方格淡黄加粗框;点击已通联网格弹出该网格内通联明细弹窗;进入网格模式默认 VUCC 视图
|
||||
- **奖状边界模式(Award Boundaries)** — 在网格地图上叠加 DXCC / WAPC / WAJA / WAZ / WAS 国界·省界·分区边界,已确认区域绿色填充
|
||||
- **LoTW 网格同步** — 直连 ARRL Logbook(账号密码验证)下载确认报告,worked / roamed 网格自动更新(兼容任意字段顺序的 ADIF 解析);同步失败按凭据错误/限流/超时分类提示
|
||||
- **双站过境匹配(Mutual Pass)** — 输入友台经纬度/网格,筛选双方同时可见的卫星过境,在地图上同时显示双方仰角曲线和地面轨迹,支持一键跳转雷达页查看详情
|
||||
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
|
||||
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本(arm64 + armeabi-v7a 双架构)
|
||||
- **Passband 模式** — 支持通过位置滑块(Passband)自动计算 TX/RX 频率,避免切换时跳变
|
||||
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放,启动时预取缓存
|
||||
- **应用内更新检查** — 设置页一键检查 GitHub 最新 Release,展示版本说明并直接下载安装
|
||||
- **自定义 TLE 数据源** — 可添加自定义 TLE/Celestrak CSV 数据源,长按拖拽排序,数据源启停开关与 HTTP 状态码显示
|
||||
|
||||
@@ -24,6 +24,13 @@ import android.os.Bundle
|
||||
import com.rtbishop.look4sat.core.data.injection.MainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||
import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult
|
||||
import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
|
||||
import com.rtbishop.look4sat.core.domain.repository.lotwSyncToday
|
||||
import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.shouldAutoSyncLoTW
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
@@ -41,6 +48,8 @@ class MainApplication : Application(), IContainerProvider {
|
||||
clearAmSatCacheOnAppBackground()
|
||||
// trigger automatic update every 48 hours
|
||||
container.appScope.launch { checkAutoUpdate() }
|
||||
// automatic LoTW grid sync on every app start (gated, see checkLoTWAutoSync)
|
||||
container.appScope.launch { checkLoTWAutoSync() }
|
||||
// load satellite data on every app start
|
||||
container.appScope.launch { container.satelliteRepo.initRepository() }
|
||||
}
|
||||
@@ -79,4 +88,37 @@ class MainApplication : Application(), IContainerProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatic LoTW grid sync, mirroring [checkAutoUpdate]: checked on every
|
||||
* app start, but only pulls when due — LoTW credentials configured, the
|
||||
* LoTW auto-sync toggle on, at least one prior (manual) sync, and not
|
||||
* already synced today (ARRL limits report pulls, ~once a day per account).
|
||||
* Incremental when the callsign is unchanged; a callsign change falls back
|
||||
* to a full replace so no stale grids from another account linger.
|
||||
* Failures are silent and simply retried on the next app start — the manual
|
||||
* sync button still surfaces the explicit cause to the user.
|
||||
*/
|
||||
private suspend fun checkLoTWAutoSync(timeNow: Long = System.currentTimeMillis()) {
|
||||
val settingsRepo = container.settingsRepo
|
||||
val lotwSettings = settingsRepo.lotwSettings.value
|
||||
if (!shouldAutoSyncLoTW(
|
||||
isConfigured = lotwSettings.isConfigured,
|
||||
autoLotwSyncEnabled = settingsRepo.otherSettings.value.stateOfAutoLotwSync,
|
||||
lastSyncDate = settingsRepo.getLastLotwSyncDate(),
|
||||
today = lotwSyncToday(timeNow)
|
||||
)
|
||||
) return
|
||||
val callsign = lotwSettings.callsign.trim().uppercase()
|
||||
val mode = resolveLoTWSyncMode(settingsRepo.getLastLotwSyncCallsign(), callsign, requested = null)
|
||||
val since = if (mode == LoTWSyncMode.Incremental) lotwCursorApi(settingsRepo.getLastLotwSyncDate()) else ""
|
||||
println("Started periodic LoTW grid sync (${mode.name.lowercase()})")
|
||||
val result = container.lotwRepo.fetchConfirmedGridQsos(callsign, lotwSettings.password, since)
|
||||
if (result is LoTWResult.Success) {
|
||||
val count = applyLoTWGridResult(settingsRepo, result, mode, callsign, timeNow)
|
||||
println("Periodic LoTW grid sync finished: $count worked grids")
|
||||
} else {
|
||||
println("Periodic LoTW grid sync skipped (${result::class.simpleName})")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,33 +104,47 @@ fun NavRoot(deeplink: String? = null) {
|
||||
// Reverse: outgoing slides out to the right, incoming drifts in from the left
|
||||
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
|
||||
slideOutHorizontally(tween(300)) { it }
|
||||
NavDisplay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
backStack = rootBackStack,
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { pushTransition },
|
||||
popTransitionSpec = { popTransition },
|
||||
predictivePopTransitionSpec = { popTransition },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Passes> {
|
||||
MainScreen(
|
||||
navigateToRadar = navigateToRadar
|
||||
)
|
||||
}
|
||||
entry<RadarDestination> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
// Elevation color thresholds must be provided at the root level so that BOTH
|
||||
// the tab content (MainScreen) and the root-level RadarDestination see the
|
||||
// user's custom thresholds. RadarDestination is a sibling entry of MainScreen
|
||||
// in this NavDisplay, so a provider inside MainScreen never reaches it.
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||
CompositionLocalProvider(
|
||||
LocalElevationThresholds provides ElevationThresholds(
|
||||
low = otherSettings.lowElevation,
|
||||
high = otherSettings.highElevation
|
||||
)
|
||||
) {
|
||||
NavDisplay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
backStack = rootBackStack,
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { pushTransition },
|
||||
popTransitionSpec = { popTransition },
|
||||
predictivePopTransitionSpec = { popTransition },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Passes> {
|
||||
MainScreen(
|
||||
navigateToRadar = navigateToRadar
|
||||
)
|
||||
}
|
||||
entry<RadarDestination> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+123
-19
@@ -18,9 +18,13 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWPhase
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProgress
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.URL
|
||||
@@ -46,7 +50,7 @@ class LoTWRepository : ILoTWRepository {
|
||||
|
||||
override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
fetchReportBody(callsign, password).fold(
|
||||
fetchReportBody(callsign, password, since = "", onProgress = {}).fold(
|
||||
onSuccess = { body ->
|
||||
parseBoth(body)?.let { (grids, _, roamed) ->
|
||||
LoTWResult.Success(grids, emptyMap(), roamed)
|
||||
@@ -58,9 +62,11 @@ class LoTWRepository : ILoTWRepository {
|
||||
|
||||
override suspend fun fetchConfirmedGridQsos(
|
||||
callsign: String,
|
||||
password: String
|
||||
password: String,
|
||||
since: String,
|
||||
onProgress: (LoTWProgress) -> Unit
|
||||
): LoTWResult = withContext(Dispatchers.IO) {
|
||||
fetchReportBody(callsign, password).fold(
|
||||
fetchReportBody(callsign, password, since, onProgress).fold(
|
||||
onSuccess = { body ->
|
||||
parseBoth(body)?.let { (grids, qsos, roamed) ->
|
||||
LoTWResult.Success(grids, qsos, roamed)
|
||||
@@ -87,33 +93,46 @@ class LoTWRepository : ILoTWRepository {
|
||||
return Triple(grids, qsos, roamed)
|
||||
}
|
||||
|
||||
private fun fetchReportBody(callsign: String, password: String): Result<String> {
|
||||
private suspend fun fetchReportBody(
|
||||
callsign: String,
|
||||
password: String,
|
||||
since: String,
|
||||
onProgress: (LoTWProgress) -> Unit
|
||||
): Result<String> {
|
||||
val call = callsign.trim().uppercase()
|
||||
val pwd = password.trim()
|
||||
if (call.isBlank() || pwd.isBlank()) return Result.failure(IOException("empty credentials"))
|
||||
// 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"
|
||||
// Empty since -> full report. 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 be incremental/empty.
|
||||
val effectiveSince = since.ifBlank { "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"))
|
||||
append("&qso_qslsince=").append(URLEncoder.encode(effectiveSince, "UTF-8"))
|
||||
}
|
||||
return 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
|
||||
// queueing — a busy server took 36.5 s to accept a connection in
|
||||
// testing, so 30 s connect would have killed it).
|
||||
//
|
||||
// NOTE: readTimeout is NOT a total-download cap. It is the longest
|
||||
// wait for the NEXT chunk of data (a stall timeout). A full report
|
||||
// for a huge account (tens of thousands of QSLs ≈ tens of MB at
|
||||
// ARRL's ~10 KB/s stream rate) legitimately takes 30+ minutes;
|
||||
// as long as chunks keep arriving it must never be cut off.
|
||||
// 600 s tolerates long server-side pauses while it generates the
|
||||
// report; a truly dead link (no data at all) still fails within it.
|
||||
connection.connectTimeout = 60_000
|
||||
connection.readTimeout = 600_000
|
||||
connection.requestMethod = "GET"
|
||||
connection.setRequestProperty("Accept-Encoding", "gzip")
|
||||
connection.instanceFollowRedirects = true
|
||||
onProgress(LoTWProgress(LoTWPhase.Connecting))
|
||||
val code = connection.responseCode
|
||||
if (code !in 200..299) {
|
||||
connection.disconnect()
|
||||
@@ -123,9 +142,8 @@ class LoTWRepository : ILoTWRepository {
|
||||
else Result.failure(IOException("HTTP $code"))
|
||||
}
|
||||
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() }
|
||||
val isGzip = "gzip".equals(connection.contentEncoding, ignoreCase = true)
|
||||
val body = readBodyWithProgress(stream, isGzip, onProgress)
|
||||
connection.disconnect()
|
||||
when {
|
||||
// Login failure: HTTP 200 + HTML login page with the error text.
|
||||
@@ -135,6 +153,9 @@ class LoTWRepository : ILoTWRepository {
|
||||
!body.contains("<eoh>", ignoreCase = true) -> Result.failure(RateLimitException())
|
||||
else -> Result.success(body)
|
||||
}
|
||||
} catch (e: java.util.concurrent.CancellationException) {
|
||||
// Cancellation must propagate — it is not a network failure.
|
||||
throw e
|
||||
} catch (e: SocketTimeoutException) {
|
||||
Result.failure(TimeoutException(e.message ?: "timed out"))
|
||||
} catch (e: SSLException) {
|
||||
@@ -145,6 +166,68 @@ class LoTWRepository : ILoTWRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams the report body into a string while reporting download progress.
|
||||
* Once the header's record count (<APP_LoTW_NUMREC>) is seen, the total
|
||||
* body size is estimated as NUMREC * AVG_RECORD_BYTES so the UI can show a
|
||||
* meaningful progress bar and remaining-time estimate.
|
||||
*/
|
||||
private suspend fun readBodyWithProgress(
|
||||
stream: java.io.InputStream,
|
||||
isGzip: Boolean,
|
||||
onProgress: (LoTWProgress) -> Unit
|
||||
): String {
|
||||
val input = if (isGzip) java.util.zip.GZIPInputStream(stream) else stream
|
||||
val reader = java.io.InputStreamReader(input, Charsets.UTF_8)
|
||||
val sb = StringBuilder()
|
||||
val buf = CharArray(8192)
|
||||
var bytesRead = 0L
|
||||
var expected = 0L
|
||||
var qsoCount = 0L
|
||||
var speed = 0L
|
||||
var headerSeen = false
|
||||
var lastSampleMs = System.currentTimeMillis()
|
||||
var lastSampleBytes = 0L
|
||||
var lastEmitMs = 0L
|
||||
while (true) {
|
||||
// Cooperative cancellation: lets the user abort a full sync that
|
||||
// was started by mistake. Throws CancellationException once the
|
||||
// job is cancelled; the blocking read below is fast while chunks
|
||||
// keep arriving, so the abort lands on the next chunk boundary.
|
||||
coroutineContext.ensureActive()
|
||||
val n = reader.read(buf)
|
||||
if (n <= 0) break
|
||||
sb.append(buf, 0, n)
|
||||
bytesRead += n
|
||||
// The record count sits in the report header, long before the data.
|
||||
if (!headerSeen) {
|
||||
NUMREC_REGEX.find(sb)?.let { m ->
|
||||
qsoCount = m.groupValues[1].toLongOrNull() ?: 0L
|
||||
expected = qsoCount * AVG_RECORD_BYTES
|
||||
headerSeen = true
|
||||
}
|
||||
}
|
||||
// Sample speed every 500ms, EMA-smoothed.
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastSampleMs >= 500) {
|
||||
val elapsedS = (now - lastSampleMs) / 1000.0
|
||||
if (elapsedS > 0) {
|
||||
val inst = ((bytesRead - lastSampleBytes) / elapsedS).toLong()
|
||||
speed = if (speed == 0L) inst else (speed * 7 + inst * 3) / 10
|
||||
lastSampleMs = now
|
||||
lastSampleBytes = bytesRead
|
||||
}
|
||||
}
|
||||
// Throttle callbacks to ~4/s so StateFlow updates stay cheap.
|
||||
if (now - lastEmitMs >= 250) {
|
||||
onProgress(LoTWProgress(LoTWPhase.Downloading, bytesRead, expected, speed, qsoCount))
|
||||
lastEmitMs = now
|
||||
}
|
||||
}
|
||||
onProgress(LoTWProgress(LoTWPhase.Downloading, bytesRead, expected, speed, qsoCount))
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
internal class CredentialsException : Exception("bad callsign/password")
|
||||
internal class RateLimitException : Exception("report refused (rate limit / server error)")
|
||||
internal class TimeoutException(message: String) : Exception(message)
|
||||
@@ -169,6 +252,7 @@ class LoTWRepository : ILoTWRepository {
|
||||
var country: String? = null
|
||||
var cqz: Int? = null
|
||||
var state: String? = null
|
||||
var myGrid: String? = null
|
||||
val gridsInRecord = mutableListOf<String>()
|
||||
|
||||
fun emitRecord() {
|
||||
@@ -177,7 +261,8 @@ class LoTWRepository : ILoTWRepository {
|
||||
val qso = com.rtbishop.look4sat.core.domain.model.GridQso(
|
||||
call = call, epochMs = epochMs, satName = satName,
|
||||
mode = mode, bandUp = bandUp, bandDown = bandDown,
|
||||
dxcc = dxcc, country = country, cqz = cqz, state = state
|
||||
dxcc = dxcc, country = country, cqz = cqz, state = state,
|
||||
myGrid = myGrid
|
||||
)
|
||||
for (grid in gridsInRecord) {
|
||||
result.getOrPut(grid) { mutableListOf() }.add(qso)
|
||||
@@ -188,6 +273,7 @@ class LoTWRepository : ILoTWRepository {
|
||||
propMode = null; call = ""; qsoDate = ""; timeOn = ""
|
||||
satName = ""; mode = ""; bandUp = ""; bandDown = ""
|
||||
dxcc = null; country = null; cqz = null; state = null
|
||||
myGrid = null
|
||||
gridsInRecord.clear()
|
||||
}
|
||||
|
||||
@@ -222,6 +308,13 @@ class LoTWRepository : ILoTWRepository {
|
||||
cqz = adifValue(line).toIntOrNull()
|
||||
line.startsWith("<STATE:") ->
|
||||
state = adifValue(line).trim().ifBlank { null }?.let { normalizeState(it) }
|
||||
line.startsWith("<MY_GRIDSQUARE:") -> {
|
||||
// Own-station grid (must not be confused with GRIDSQUARE —
|
||||
// the opposite station's grid). Recorded per QSO so awards
|
||||
// can be counted per operated grid.
|
||||
val value = adifValue(line)
|
||||
if (value.length >= 4) myGrid = value.take(4).uppercase()
|
||||
}
|
||||
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
|
||||
// VUCC_GRIDS holds a comma-separated list of grids
|
||||
// ("EN52en,EN53fa"), up to four for contacts spanning
|
||||
@@ -352,5 +445,16 @@ class LoTWRepository : ILoTWRepository {
|
||||
|
||||
private companion object {
|
||||
const val BASE_URL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
|
||||
|
||||
/** Record count in the report header: `<APP_LoTW_NUMREC:4>2367`. */
|
||||
val NUMREC_REGEX = Regex("<APP_LoTW_NUMREC:\\d+>(\\d+)")
|
||||
/**
|
||||
* Measured 2026-09-16 (BH6RJD, 2367 QSLs): 1.7 MB body streamed at
|
||||
* ~9.5 KB/s ≈ 180 s, i.e. ≈ 720 bytes per ADIF record. Use a
|
||||
* CONSERVATIVE (larger) estimate so the progress bar never hits 100%
|
||||
* before the download truly ends — an under-estimate made the bar sit
|
||||
* at 100% while data was still flowing, read as "finished but stuck".
|
||||
*/
|
||||
const val AVG_RECORD_BYTES = 900L
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.TimeZone
|
||||
@@ -66,8 +68,11 @@ class SatelliteRepo(
|
||||
override suspend fun initRepository() = withContext(dispatcher) {
|
||||
combine(
|
||||
settingsRepo.selectedIds,
|
||||
settingsRepo.stationPosition
|
||||
) { selectedIds, _ -> selectedIds }
|
||||
settingsRepo.stationPosition,
|
||||
// Recalculate passes when the UTC display toggle changes, so an existing
|
||||
// AOS time window is reapplied in the newly selected timezone.
|
||||
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged()
|
||||
) { selectedIds, _, _ -> selectedIds }
|
||||
.collect { selectedIds ->
|
||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||
val settings = settingsRepo.passesSettings.value
|
||||
|
||||
@@ -77,6 +77,7 @@ class SettingsRepo(
|
||||
private val keySelectedTypes = "selectedTypes"
|
||||
private val keySelectedModes = "selectedModes"
|
||||
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
||||
private val keyStateOfAutoLotwSync = "stateOfAutoLotwSync"
|
||||
private val keyStateOfSensors = "stateOfSensors"
|
||||
private val keyStateOfSweep = "stateOfSweep"
|
||||
private val keyStateOfUtc = "stateOfUtc"
|
||||
@@ -204,7 +205,9 @@ class SettingsRepo(
|
||||
dxcc = o.optInt("dx", 0).takeIf { it > 0 },
|
||||
country = o.optString("cty").ifBlank { null },
|
||||
cqz = o.optInt("cq", 0).takeIf { it > 0 },
|
||||
state = o.optString("st").ifBlank { null }
|
||||
state = o.optString("st").ifBlank { null },
|
||||
// Own-grid field: absent in pre-myGrid data -> null.
|
||||
myGrid = o.optString("mg").ifBlank { null }
|
||||
)
|
||||
}
|
||||
if (list.isNotEmpty()) result[grid] = list
|
||||
@@ -232,6 +235,7 @@ class SettingsRepo(
|
||||
.put("cty", q.country ?: "")
|
||||
.put("cq", q.cqz ?: 0)
|
||||
.put("st", q.state ?: "")
|
||||
.put("mg", q.myGrid ?: "")
|
||||
)
|
||||
}
|
||||
root.put(grid, array)
|
||||
@@ -286,6 +290,24 @@ class SettingsRepo(
|
||||
callsign = preferences.getString(keyLoTWCall, null).orEmpty(),
|
||||
password = preferences.getString(keyLoTWPass, null).orEmpty()
|
||||
)
|
||||
|
||||
// Last successful sync bookkeeping: date ("yyyyMMdd") and callsign. Used to
|
||||
// decide incremental (same callsign) vs full (first time / callsign change)
|
||||
// report requests and to merge increments into the stored grid data.
|
||||
private val keyLastLotwSyncDate = "lotwLastSyncDate"
|
||||
private val keyLastLotwSyncCallsign = "lotwLastSyncCallsign"
|
||||
|
||||
override fun getLastLotwSyncDate(): String =
|
||||
preferences.getString(keyLastLotwSyncDate, null).orEmpty()
|
||||
|
||||
override fun setLastLotwSyncDate(date: String) =
|
||||
preferences.edit { putString(keyLastLotwSyncDate, date) }
|
||||
|
||||
override fun getLastLotwSyncCallsign(): String =
|
||||
preferences.getString(keyLastLotwSyncCallsign, null).orEmpty()
|
||||
|
||||
override fun setLastLotwSyncCallsign(callsign: String) =
|
||||
preferences.edit { putString(keyLastLotwSyncCallsign, callsign.trim().uppercase()) }
|
||||
//endregion
|
||||
|
||||
//region # Transceivers settings
|
||||
@@ -517,6 +539,7 @@ class SettingsRepo(
|
||||
val new = transform(current)
|
||||
preferences.edit {
|
||||
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
|
||||
putBoolean(keyStateOfAutoLotwSync, new.stateOfAutoLotwSync)
|
||||
putBoolean(keyStateOfSensors, new.stateOfSensors)
|
||||
putBoolean(keyStateOfSweep, new.stateOfSweep)
|
||||
putBoolean(keyStateOfUtc, new.stateOfUtc)
|
||||
@@ -535,6 +558,7 @@ class SettingsRepo(
|
||||
|
||||
private fun getOtherSettings(): OtherSettings = OtherSettings(
|
||||
stateOfAutoUpdate = preferences.getBoolean(keyStateOfAutoUpdate, true),
|
||||
stateOfAutoLotwSync = preferences.getBoolean(keyStateOfAutoLotwSync, true),
|
||||
stateOfSensors = preferences.getBoolean(keyStateOfSensors, true),
|
||||
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
|
||||
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
|
||||
|
||||
@@ -296,6 +296,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
|
||||
|
||||
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit
|
||||
override fun getLastLotwSyncDate(): String = ""
|
||||
override fun setLastLotwSyncDate(date: String) = Unit
|
||||
override fun getLastLotwSyncCallsign(): String = ""
|
||||
override fun setLastLotwSyncCallsign(callsign: String) = Unit
|
||||
|
||||
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
|
||||
|
||||
|
||||
+19
@@ -3,6 +3,7 @@ package com.rtbishop.look4sat.core.data.repository
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class LoTWRepositoryTest {
|
||||
@@ -150,6 +151,24 @@ class LoTWRepositoryTest {
|
||||
assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosCapturesOwnGridPerQso() {
|
||||
// MY_GRIDSQUARE arrives BEFORE PROP_MODE (ADIF fields are emitted
|
||||
// alphabetically); the per-record buffer must still attach it to the QSO.
|
||||
val qso1 = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<MY_GRIDSQUARE:4>OL62\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<MODE:2>CW\n" +
|
||||
"<BAND:3>70CM\n<BAND_RX:3>2M\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
val qso2 = "<CALL:6>BG7ZFK\n<QSO_DATE:8>20260819\n<MY_GRIDSQUARE:4>OL72\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<MODE:3>FM\n" +
|
||||
"<BAND:3>70CM\n<BAND_RX:3>2M\n<GRIDSQUARE:4>OK48\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(qso1, qso2))!!
|
||||
assertEquals("OL62", result["NL47"]!!.first().myGrid)
|
||||
assertEquals("OL72", result["OK48"]!!.first().myGrid)
|
||||
// Ground QSO (no PROP_MODE=SAT) must not leak its MY_GRIDSQUARE.
|
||||
val ground = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<MY_GRIDSQUARE:4>OL62\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
assertTrue(repo.parseConfirmedGridQsos(report(qso1, ground))!!.values.all { it.all { q -> q.myGrid == "OL62" } })
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region parseRoamedGrids (MY_GRIDSQUARE = grids the account operated from)
|
||||
|
||||
@@ -48,7 +48,9 @@ data class GridQso(
|
||||
val dxcc: Int? = null,
|
||||
val country: String? = null,
|
||||
val cqz: Int? = null,
|
||||
val state: String? = null
|
||||
val state: String? = null,
|
||||
/** 4-char grid the station itself operated from (ADIF MY_GRIDSQUARE). */
|
||||
val myGrid: String? = null
|
||||
) {
|
||||
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
|
||||
val bandLabel: String
|
||||
|
||||
@@ -64,7 +64,10 @@ data class OtherSettings(
|
||||
val shouldSeeWhatsNew: Boolean,
|
||||
val sstvMode: String = "Auto",
|
||||
val lowElevation: Double = 15.0,
|
||||
val highElevation: Double = 45.0
|
||||
val highElevation: Double = 45.0,
|
||||
/** Independent auto-sync toggle for LoTW confirmed grids (separate from the
|
||||
* ephemeris auto-update; only effective after at least one manual sync). */
|
||||
val stateOfAutoLotwSync: Boolean = true
|
||||
)
|
||||
|
||||
data class DataSourcesSettings(
|
||||
|
||||
+35
-1
@@ -30,9 +30,43 @@ interface ILoTWRepository {
|
||||
* Same report, but keeps the per-QSO detail of every confirmed satellite
|
||||
* QSO (call / time / satellite / mode / bands), grouped by worked 4-char
|
||||
* gridsquare. Failure cause is kept explicit.
|
||||
*
|
||||
* @param since QSL-since date ("yyyyMMdd" or "yyyy-MM-dd"); use an early
|
||||
* date for a full report, or the last sync date for an
|
||||
* incremental pull. Empty falls back to a full report.
|
||||
* @param onProgress streamed download progress (phase + bytes + estimate),
|
||||
* invoked from the IO dispatcher; may be called on every
|
||||
* read chunk.
|
||||
*/
|
||||
suspend fun fetchConfirmedGridQsos(
|
||||
callsign: String,
|
||||
password: String
|
||||
password: String,
|
||||
since: String = "",
|
||||
onProgress: (LoTWProgress) -> Unit = {}
|
||||
): LoTWResult
|
||||
}
|
||||
|
||||
/** Streamed progress of a LoTW report download. */
|
||||
data class LoTWProgress(
|
||||
val phase: LoTWPhase,
|
||||
/** Bytes read so far (uncompressed body). */
|
||||
val bytesRead: Long = 0,
|
||||
/** Estimated total body bytes, derived from <APP_LoTW_NUMREC>; 0 before known. */
|
||||
val expectedBytes: Long = 0,
|
||||
/** Smoothed download speed in bytes/second; 0 while unknown. */
|
||||
val speedBps: Long = 0,
|
||||
/** Total QSL records reported in the header (<APP_LoTW_NUMREC>); 0 before known. */
|
||||
val qsoCount: Long = 0
|
||||
) {
|
||||
/** Fraction 0..1 of the estimated body already read; 0 while unknown. */
|
||||
val fraction: Float
|
||||
get() = if (expectedBytes > 0 && bytesRead > 0)
|
||||
(bytesRead.toFloat() / expectedBytes).coerceIn(0f, 1f) else 0f
|
||||
|
||||
/** Estimated seconds remaining; 0 while size or speed is unknown. */
|
||||
val remainingSeconds: Long
|
||||
get() = if (expectedBytes > 0 && speedBps > 0)
|
||||
((expectedBytes - bytesRead) / speedBps).coerceAtLeast(0) else 0L
|
||||
}
|
||||
|
||||
enum class LoTWPhase { Connecting, Downloading }
|
||||
+6
@@ -110,5 +110,11 @@ interface ISettingsRepo {
|
||||
//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)
|
||||
/** Last successful LoTW sync date ("yyyyMMdd", empty when never synced). */
|
||||
fun getLastLotwSyncDate(): String
|
||||
fun setLastLotwSyncDate(date: String)
|
||||
/** Callsign of the last successful LoTW sync (empty when never synced). */
|
||||
fun getLastLotwSyncCallsign(): String
|
||||
fun setLastLotwSyncCallsign(callsign: String)
|
||||
//endregion
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* Shared LoTW sync orchestration used by BOTH the manual sync (settings screen)
|
||||
* and the automatic sync on app start (MainApplication), so the two paths can
|
||||
* never drift apart:
|
||||
*
|
||||
* - [LoTWSyncMode.Incremental] pulls only the confirmations since the last sync
|
||||
* and MERGES them into the stored grids/QSOs (grids only grow; QSO detail is
|
||||
* deduped by call + QSO time).
|
||||
* - [LoTWSyncMode.Full] pulls everything and REPLACES the stored data.
|
||||
*/
|
||||
|
||||
/**
|
||||
* UTC date ("yyyyMMdd") of the given instant — the "already synced today"
|
||||
* gate granularity.
|
||||
*/
|
||||
fun lotwSyncToday(now: Long = System.currentTimeMillis()): String =
|
||||
SimpleDateFormat("yyyyMMdd", Locale.US)
|
||||
.apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||
.format(Date(now))
|
||||
|
||||
/**
|
||||
* Full UTC timestamp cursor ("yyyy-MM-dd HH:mm:ss") written after a
|
||||
* successful sync. This is exactly the format LoTW's qso_qslsince accepts
|
||||
* (measured live 2026-09-17: the legacy "yyyyMMdd" cursor is silently
|
||||
* ignored by LoTW, which falls back to its own system-supplied default),
|
||||
* and it keeps enough precision to display the last-sync time like the
|
||||
* ephemeris update time.
|
||||
*/
|
||||
fun lotwSyncCursor(now: Long = System.currentTimeMillis()): String =
|
||||
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
.apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||
.format(Date(now))
|
||||
|
||||
/** Date part of a stored cursor as "yyyyMMdd", tolerating the legacy
|
||||
* "yyyyMMdd" format and the current "yyyy-MM-dd HH:mm:ss" format. */
|
||||
fun lotwCursorDate(cursor: String): String = when {
|
||||
cursor.length >= 10 && cursor[4] == '-' -> cursor.substring(0, 10).replace("-", "")
|
||||
else -> cursor.take(8)
|
||||
}
|
||||
|
||||
/** qso_qslsince value for the API: passes the full timestamp through and
|
||||
* normalizes a legacy "yyyyMMdd" cursor to the documented "yyyy-MM-dd". */
|
||||
fun lotwCursorApi(cursor: String): String = when {
|
||||
cursor.isBlank() -> ""
|
||||
cursor.length >= 10 && cursor[4] == '-' -> cursor
|
||||
else -> "${cursor.take(4)}-${cursor.substring(4, 6)}-${cursor.substring(6, 8)}"
|
||||
}
|
||||
|
||||
/** Stored cursor parsed to UTC epoch ms for display; 0 when unparseable. */
|
||||
fun lotwCursorEpochMs(cursor: String): Long = try {
|
||||
val pattern = if (cursor.length >= 10 && cursor[4] == '-') "yyyy-MM-dd HH:mm:ss" else "yyyyMMdd"
|
||||
SimpleDateFormat(pattern, Locale.US)
|
||||
.apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||
.parse(cursor)?.time ?: 0L
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the effective sync mode for a given request:
|
||||
* - an explicit [LoTWSyncMode.Full] request is always honored;
|
||||
* - an [LoTWSyncMode.Incremental] request is honored only when the callsign
|
||||
* matches the last sync — a first sync, an unknown/empty record or a
|
||||
* callsign change falls back to [LoTWSyncMode.Full] so no stale grids from
|
||||
* another account linger.
|
||||
* With [requested] == null (automatic sync) it picks incremental whenever
|
||||
* possible and full otherwise.
|
||||
*/
|
||||
fun resolveLoTWSyncMode(
|
||||
lastSyncCallsign: String,
|
||||
callsign: String,
|
||||
requested: LoTWSyncMode?
|
||||
): LoTWSyncMode {
|
||||
if (requested == LoTWSyncMode.Full) return LoTWSyncMode.Full
|
||||
val sameCall = lastSyncCallsign.isNotBlank() && lastSyncCallsign == callsign
|
||||
return if (sameCall) LoTWSyncMode.Incremental else LoTWSyncMode.Full
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the automatic LoTW sync should run now — mirrors the ephemeris
|
||||
* auto-update check: credentials configured, the LoTW auto-sync toggle on, at
|
||||
* least one prior manual sync (the first sync stays manual/full), and not
|
||||
* already synced today (ARRL limits report pulls, ~once a day per account).
|
||||
*/
|
||||
fun shouldAutoSyncLoTW(
|
||||
isConfigured: Boolean,
|
||||
autoLotwSyncEnabled: Boolean,
|
||||
lastSyncDate: String,
|
||||
today: String
|
||||
): Boolean {
|
||||
val lastDate = lotwCursorDate(lastSyncDate)
|
||||
return isConfigured && autoLotwSyncEnabled && lastDate.isNotBlank() && lastDate != today
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a successful LoTW report to the stored grid data and advances the
|
||||
* sync cursor so the next incremental pull asks LoTW for only the
|
||||
* confirmations since today. Persistence runs on the IO dispatcher — building
|
||||
* the per-grid QSO JSON and writing SharedPreferences blocks the calling
|
||||
* thread, and for large accounts (multi-MB JSON) that froze the main thread
|
||||
* after a manual sync ('bar done but app stuck').
|
||||
*
|
||||
* @return the resulting number of worked grids.
|
||||
*/
|
||||
suspend fun applyLoTWGridResult(
|
||||
settingsRepo: ISettingsRepo,
|
||||
result: LoTWResult.Success,
|
||||
mode: LoTWSyncMode,
|
||||
callsign: String,
|
||||
now: Long = System.currentTimeMillis()
|
||||
): Int {
|
||||
val existingGrids = settingsRepo.getWorkedGrids()
|
||||
val existingQsos = settingsRepo.getWorkedGridQsos()
|
||||
val existingRoamed = settingsRepo.getRoamedGrids()
|
||||
val mergedGrids = if (mode == LoTWSyncMode.Incremental) existingGrids + result.grids else result.grids
|
||||
val mergedQsos = if (mode == LoTWSyncMode.Incremental) mergeGridQsos(existingQsos, result.qsos) else result.qsos
|
||||
val mergedRoamed = if (mode == LoTWSyncMode.Incremental) existingRoamed + result.roamedGrids else result.roamedGrids
|
||||
settingsRepo.setLastLotwSyncDate(lotwSyncCursor(now))
|
||||
settingsRepo.setLastLotwSyncCallsign(callsign)
|
||||
withContext(Dispatchers.IO) {
|
||||
settingsRepo.setWorkedGrids(mergedGrids)
|
||||
settingsRepo.setWorkedGridQsos(mergedQsos)
|
||||
settingsRepo.setRoamedGrids(mergedRoamed)
|
||||
}
|
||||
return mergedGrids.size
|
||||
}
|
||||
|
||||
/** Merge fresh QSO detail into existing per-grid lists, dedup by call + QSO time. */
|
||||
fun mergeGridQsos(
|
||||
existing: Map<String, List<GridQso>>,
|
||||
fresh: Map<String, List<GridQso>>
|
||||
): Map<String, List<GridQso>> {
|
||||
val merged = existing.mapValues { (_, list) -> list.toMutableList() }.toMutableMap()
|
||||
fresh.forEach { (grid, list) ->
|
||||
val target = merged.getOrPut(grid) { mutableListOf() }
|
||||
val known = target.mapTo(mutableSetOf()) { it.call to it.epochMs }
|
||||
list.forEach { qso ->
|
||||
if (known.add(qso.call to qso.epochMs)) target.add(qso)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
@@ -46,3 +46,6 @@ sealed class LoTWResult {
|
||||
/** Any other network-level failure (DNS, TLS, connection reset, HTTP 5xx). */
|
||||
data class NetworkError(val detail: String) : LoTWResult()
|
||||
}
|
||||
|
||||
/** What a LoTW sync does: pull everything, or only new QSLs since the last sync. */
|
||||
enum class LoTWSyncMode { Full, Incremental }
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
import com.rtbishop.look4sat.core.domain.model.LoTWSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class LoTWGridSyncTest {
|
||||
|
||||
// 2026-09-16T00:00:00Z — fixed "now" for the cursor assertions.
|
||||
private val now = 1_789_516_800_000L
|
||||
|
||||
// region resolveLoTWSyncMode
|
||||
|
||||
@Test
|
||||
fun resolveModeHonorsExplicitFull() {
|
||||
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("BA7OPF", "BA7OPF", LoTWSyncMode.Full))
|
||||
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("", "BA7OPF", LoTWSyncMode.Full))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveModeAllowsIncrementalOnlyForSameCallsign() {
|
||||
assertEquals(
|
||||
LoTWSyncMode.Incremental,
|
||||
resolveLoTWSyncMode("BA7OPF", "BA7OPF", LoTWSyncMode.Incremental)
|
||||
)
|
||||
assertEquals(
|
||||
LoTWSyncMode.Full,
|
||||
resolveLoTWSyncMode("BG7ABC", "BA7OPF", LoTWSyncMode.Incremental)
|
||||
)
|
||||
assertEquals(
|
||||
LoTWSyncMode.Full,
|
||||
resolveLoTWSyncMode("", "BA7OPF", LoTWSyncMode.Incremental)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveModeForAutoSyncPicksIncrementalWhenPossible() {
|
||||
assertEquals(LoTWSyncMode.Incremental, resolveLoTWSyncMode("BA7OPF", "BA7OPF", null))
|
||||
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("BG7ABC", "BA7OPF", null))
|
||||
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("", "BA7OPF", null))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region shouldAutoSyncLoTW
|
||||
|
||||
@Test
|
||||
fun autoSyncGateRequiresEverything() {
|
||||
val today = "20260916"
|
||||
// Not configured.
|
||||
assertFalse(shouldAutoSyncLoTW(false, true, "2026-09-15 10:00:00", today))
|
||||
// LoTW auto-sync toggle off.
|
||||
assertFalse(shouldAutoSyncLoTW(true, false, "2026-09-15 10:00:00", today))
|
||||
// Never synced manually — first sync stays manual/full.
|
||||
assertFalse(shouldAutoSyncLoTW(true, true, "", today))
|
||||
// Already synced today (both legacy and current cursor formats) — ARRL rate-limit guard.
|
||||
assertFalse(shouldAutoSyncLoTW(true, true, "20260916", today))
|
||||
assertFalse(shouldAutoSyncLoTW(true, true, "2026-09-16 08:00:00", today))
|
||||
// All gates open — yesterday in either format.
|
||||
assertTrue(shouldAutoSyncLoTW(true, true, "20260915", today))
|
||||
assertTrue(shouldAutoSyncLoTW(true, true, "2026-09-15 10:00:00", today))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region cursor helpers
|
||||
|
||||
@Test
|
||||
fun cursorApiNormalizesLegacyAndPassesFullThrough() {
|
||||
assertEquals("2026-09-16", lotwCursorApi("20260916"))
|
||||
assertEquals("2026-09-16 08:16:02", lotwCursorApi("2026-09-16 08:16:02"))
|
||||
assertEquals("", lotwCursorApi(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cursorDateToleratesBothFormats() {
|
||||
assertEquals("20260916", lotwCursorDate("20260916"))
|
||||
assertEquals("20260916", lotwCursorDate("2026-09-16 08:16:02"))
|
||||
assertEquals("", lotwCursorDate(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cursorEpochMsParsesBothFormatsUtc() {
|
||||
// 2026-09-16T00:00:00Z in both spellings.
|
||||
assertEquals(now, lotwCursorEpochMs("20260916"))
|
||||
assertEquals(now, lotwCursorEpochMs("2026-09-16 00:00:00"))
|
||||
assertEquals(0L, lotwCursorEpochMs(""))
|
||||
assertEquals(0L, lotwCursorEpochMs("garbage"))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region mergeGridQsos
|
||||
|
||||
@Test
|
||||
fun mergeGridQsosDedupsByCallAndTime() {
|
||||
val existing = mapOf(
|
||||
"OL62" to listOf(qso("BA7ABC", 1000L)),
|
||||
"OL63" to listOf(qso("BA7DEF", 2000L))
|
||||
)
|
||||
val fresh = mapOf(
|
||||
// Same grid, same QSO re-delivered by an overlapping since-date.
|
||||
"OL62" to listOf(qso("BA7ABC", 1000L)),
|
||||
// Same grid, brand-new QSO.
|
||||
"OL62" to listOf(qso("BA7GHI", 3000L)),
|
||||
// New grid entirely.
|
||||
"PM95" to listOf(qso("BA7JKL", 4000L))
|
||||
)
|
||||
val merged = mergeGridQsos(existing, fresh)
|
||||
assertEquals(setOf("OL62", "OL63", "PM95"), merged.keys)
|
||||
assertEquals(2, merged["OL62"]?.size) // BA7ABC once, BA7GHI added
|
||||
assertEquals(1, merged["OL63"]?.size)
|
||||
assertEquals(1, merged["PM95"]?.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeGridQsosHandlesEmptySides() {
|
||||
val fresh = mapOf("OL62" to listOf(qso("BA7ABC", 1000L)))
|
||||
assertEquals(fresh, mergeGridQsos(emptyMap(), fresh))
|
||||
// An empty incremental pull must leave the stored side untouched.
|
||||
assertEquals(fresh, mergeGridQsos(fresh, emptyMap()))
|
||||
assertEquals(emptyMap<String, List<GridQso>>(), mergeGridQsos(emptyMap(), emptyMap()))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region applyLoTWGridResult
|
||||
|
||||
@Test
|
||||
fun applyIncrementalMergesAndAdvancesCursor() = runBlocking {
|
||||
val repo = FakeSettingsRepo().apply {
|
||||
setWorkedGrids(setOf("OL62"))
|
||||
setWorkedGridQsos(mapOf("OL62" to listOf(qso("BA7ABC", 1000L))))
|
||||
setRoamedGrids(setOf("OL62"))
|
||||
setLastLotwSyncDate("20260915")
|
||||
setLastLotwSyncCallsign("BA7OPF")
|
||||
}
|
||||
val result = LoTWResult.Success(
|
||||
grids = setOf("OL62", "PM95"),
|
||||
qsos = mapOf("OL62" to listOf(qso("BA7ABC", 1000L)), "PM95" to listOf(qso("BA7GHI", 2000L))),
|
||||
roamedGrids = setOf("PM95")
|
||||
)
|
||||
val count = applyLoTWGridResult(repo, result, LoTWSyncMode.Incremental, "BA7OPF", now)
|
||||
|
||||
assertEquals(2, count)
|
||||
assertEquals(setOf("OL62", "PM95"), repo.getWorkedGrids())
|
||||
assertEquals(1, repo.getWorkedGridQsos()["OL62"]?.size) // deduped
|
||||
assertEquals(setOf("OL62", "PM95"), repo.getRoamedGrids())
|
||||
assertEquals("2026-09-16 00:00:00", repo.getLastLotwSyncDate())
|
||||
assertEquals("BA7OPF", repo.getLastLotwSyncCallsign())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun applyFullReplacesEverythingAndAdvancesCursor() = runBlocking {
|
||||
val repo = FakeSettingsRepo().apply {
|
||||
setWorkedGrids(setOf("OL62", "STALE"))
|
||||
setWorkedGridQsos(mapOf("STALE" to listOf(qso("BG7OLD", 1L))))
|
||||
setRoamedGrids(setOf("STALE"))
|
||||
setLastLotwSyncDate("20260910")
|
||||
setLastLotwSyncCallsign("BG7OLD")
|
||||
}
|
||||
val result = LoTWResult.Success(
|
||||
grids = setOf("PM95"),
|
||||
qsos = mapOf("PM95" to listOf(qso("BA7GHI", 2000L))),
|
||||
roamedGrids = setOf("PM95")
|
||||
)
|
||||
val count = applyLoTWGridResult(repo, result, LoTWSyncMode.Full, "BA7OPF", now)
|
||||
|
||||
assertEquals(1, count)
|
||||
assertEquals(setOf("PM95"), repo.getWorkedGrids())
|
||||
assertEquals(setOf("PM95"), repo.getWorkedGridQsos().keys)
|
||||
assertEquals(setOf("PM95"), repo.getRoamedGrids())
|
||||
assertEquals("2026-09-16 00:00:00", repo.getLastLotwSyncDate())
|
||||
assertEquals("BA7OPF", repo.getLastLotwSyncCallsign())
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
private fun qso(call: String, epochMs: Long): GridQso =
|
||||
GridQso(call, epochMs, "FO-29", "FM", "70CM", "2M")
|
||||
|
||||
/**
|
||||
* Minimal ISettingsRepo fake backing only the LoTW sync surface; the rest
|
||||
* throws (mirrors the FakeSettingsRepo pattern used in feature tests).
|
||||
*/
|
||||
private class FakeSettingsRepo : ISettingsRepo {
|
||||
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(23.13, 113.26))
|
||||
override val appVersionName: String = "test"
|
||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||
PassesSettings(hoursAhead = 24, minElevation = 10.0, selectedModes = emptyList())
|
||||
)
|
||||
override val databaseState: StateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||
)
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
OtherSettings(
|
||||
stateOfAutoUpdate = true,
|
||||
stateOfSensors = false,
|
||||
stateOfSweep = false,
|
||||
stateOfUtc = false,
|
||||
stateOfLightTheme = false,
|
||||
stateOfNightMode = false,
|
||||
stateOfMapGrid = false,
|
||||
shouldSeeWarning = false,
|
||||
shouldSeeWhatsNew = false
|
||||
)
|
||||
)
|
||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
|
||||
DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList())
|
||||
)
|
||||
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
|
||||
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
|
||||
|
||||
private var workedGrids: Set<String> = emptySet()
|
||||
private var workedGridQsos: Map<String, List<GridQso>> = emptyMap()
|
||||
private var roamedGrids: Set<String> = emptySet()
|
||||
private var lastSyncDate: String = ""
|
||||
private var lastSyncCallsign: String = ""
|
||||
|
||||
override fun getWorkedGrids(): Set<String> = workedGrids
|
||||
override fun setWorkedGrids(grids: Set<String>) { workedGrids = grids }
|
||||
override fun getWorkedGridQsos(): Map<String, List<GridQso>> = workedGridQsos
|
||||
override fun setWorkedGridQsos(qsos: Map<String, List<GridQso>>) { workedGridQsos = qsos }
|
||||
override fun getRoamedGrids(): Set<String> = roamedGrids
|
||||
override fun setRoamedGrids(grids: Set<String>) { roamedGrids = grids }
|
||||
override fun getLastLotwSyncDate(): String = lastSyncDate
|
||||
override fun setLastLotwSyncDate(date: String) { lastSyncDate = date }
|
||||
override fun getLastLotwSyncCallsign(): String = lastSyncCallsign
|
||||
override fun setLastLotwSyncCallsign(callsign: String) { lastSyncCallsign = callsign }
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = TODO()
|
||||
override fun setSelectedTypes(types: List<String>) = TODO()
|
||||
override fun setPassesSettings(settings: PassesSettings) = TODO()
|
||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
|
||||
override fun setStationPosition(): Boolean = TODO()
|
||||
override fun setStationPosition(locator: String): Boolean = TODO()
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
|
||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
|
||||
override fun updateDatabaseState(state: DatabaseState) = TODO()
|
||||
override fun updateRCSettings(settings: RCSettings) = TODO()
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
override fun setAmSatCallsign(callsign: String) = TODO()
|
||||
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
|
||||
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -375,7 +375,10 @@ fun SharedDialog(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp)
|
||||
.padding(
|
||||
start = if (onCancel != null) padding else 0.dp,
|
||||
end = if (onAccept != null) padding else 0.dp
|
||||
)
|
||||
)
|
||||
if (onAccept != null) {
|
||||
CardButton(
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
<string name="prefs_other_title">Otros ajustes</string>
|
||||
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
|
||||
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
|
||||
<string name="prefs_other_switch_lotw_sync">Sincronizar automáticamente las cuadrículas LoTW confirmadas</string>
|
||||
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
|
||||
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
|
||||
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
<string name="prefs_other_title">Другие настройки</string>
|
||||
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
|
||||
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
|
||||
<string name="prefs_other_switch_lotw_sync">Автосинхронизация подтверждённых сеток LoTW</string>
|
||||
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
|
||||
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
|
||||
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
<string name="prefs_other_title">වෙනත් සැකසුම්</string>
|
||||
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
|
||||
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
|
||||
<string name="prefs_other_switch_lotw_sync">LoTW තහවුරු කළ ජාල ස්වයං සමමුහුර්තකරණය</string>
|
||||
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
|
||||
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
|
||||
<string name="prefs_other_switch_night_mode">රතු රාත්රී පෙරහන සබල කරන්න</string>
|
||||
|
||||
@@ -189,6 +189,7 @@
|
||||
<string name="prefs_other_title">Diğer ayarlar</string>
|
||||
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
|
||||
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
|
||||
<string name="prefs_other_switch_lotw_sync">LoTW onaylı kareleri otomatik senkronize et</string>
|
||||
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
|
||||
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
|
||||
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string>
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
<string name="prefs_other_title">Інші налаштування</string>
|
||||
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
|
||||
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
|
||||
<string name="prefs_other_switch_lotw_sync">Автосинхронізація підтверджених сіток LoTW</string>
|
||||
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
|
||||
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
|
||||
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
|
||||
|
||||
@@ -122,6 +122,8 @@
|
||||
<string name="map_visibility">能见度:%s</string>
|
||||
<!-- Worked-grid QSO dialog -->
|
||||
<string name="grid_qso_calls_count">%d 个呼号 · %d 次通联</string>
|
||||
<string name="grid_qso_count_one">%d QSO</string>
|
||||
<string name="grid_qso_count_many">%d QSOs</string>
|
||||
<string name="grid_qso_first">首通</string>
|
||||
<string name="grid_qso_no_sat">卫星未知</string>
|
||||
<string name="map_visible">日照中</string>
|
||||
@@ -131,7 +133,7 @@
|
||||
<string name="prefs_privacy_title">隐私政策</string>
|
||||
|
||||
<string name="prefs_updated_title">更新:%s</string>
|
||||
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
|
||||
<string name="prefs_updated_time">yyyy年M月d日 HH:mm:ss</string>
|
||||
|
||||
<string name="prefs_loc_title">站点位置</string>
|
||||
<string name="prefs_loc_gps_title">GPS 定位</string>
|
||||
@@ -158,11 +160,23 @@
|
||||
<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_duration_hint">同步通常约需 3–10 秒;首次同步或 ARRL 服务器繁忙时可能更久。</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="lotw_sync_error_not_configured">LoTW 呼号/密码未配置</string>
|
||||
<string name="lotw_sync_error_credentials">LoTW 同步失败——呼号或密码错误</string>
|
||||
<string name="lotw_sync_error_rate_limited">LoTW 同步失败——服务器限流,请等几分钟后再试</string>
|
||||
<string name="lotw_sync_error_timeout">LoTW 同步失败——连接超时,请更换网络重试</string>
|
||||
<string name="lotw_sync_error_network">LoTW 同步失败——网络错误(%1$s)</string>
|
||||
<string name="lotw_sync_full">全量同步</string>
|
||||
<string name="lotw_sync_incremental">增量合并</string>
|
||||
<string name="lotw_sync_rate_hint">下载速度约每 100 条 QSO 需 8 秒(另有 5–40 秒服务器生成时间)</string>
|
||||
<string name="lotw_sync_progress_connecting">正在连接 LoTW 服务器…</string>
|
||||
<string name="lotw_sync_progress_downloading">正在下载报告…</string>
|
||||
<string name="lotw_sync_progress_saving">正在处理下载的数据…</string>
|
||||
<string name="lotw_sync_progress_count">本次同步 %1$d 条 QSO…</string>
|
||||
<string name="lotw_sync_progress_qso">本次同步 %1$d 条 QSO,预计还需 %2$d 秒</string>
|
||||
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
|
||||
|
||||
<string name="prefs_data_title">卫星数据更新</string>
|
||||
@@ -223,6 +237,7 @@
|
||||
<string name="prefs_other_title">其他设置</string>
|
||||
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
||||
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
||||
<string name="prefs_other_switch_lotw_sync">自动同步 LoTW 已确认网格</string>
|
||||
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
|
||||
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
|
||||
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
|
||||
|
||||
@@ -147,6 +147,8 @@
|
||||
<string name="map_visibility">Visibility: %s</string>
|
||||
<!-- Worked-grid QSO dialog -->
|
||||
<string name="grid_qso_calls_count">%d callsigns · %d QSOs</string>
|
||||
<string name="grid_qso_count_one">%d QSO</string>
|
||||
<string name="grid_qso_count_many">%d QSOs</string>
|
||||
<string name="grid_qso_first">First</string>
|
||||
<string name="grid_qso_no_sat">satellite unknown</string>
|
||||
<string name="map_visible">Visible</string>
|
||||
@@ -193,11 +195,23 @@
|
||||
<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_duration_hint">A sync usually takes about 3–10 seconds; the first one or a busy ARRL server can take longer.</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="lotw_sync_error_not_configured">LoTW callsign/password not configured</string>
|
||||
<string name="lotw_sync_error_credentials">LoTW sync failed — callsign or password incorrect</string>
|
||||
<string name="lotw_sync_error_rate_limited">LoTW sync failed — rate limited by server, wait a few minutes and retry</string>
|
||||
<string name="lotw_sync_error_timeout">LoTW sync failed — connection timed out, try another network</string>
|
||||
<string name="lotw_sync_error_network">LoTW sync failed — network error (%1$s)</string>
|
||||
<string name="lotw_sync_full">Full sync</string>
|
||||
<string name="lotw_sync_incremental">Incremental merge</string>
|
||||
<string name="lotw_sync_rate_hint">Download runs at ~8 s per 100 QSOs (plus 5–40 s server generation time)</string>
|
||||
<string name="lotw_sync_progress_connecting">Connecting to LoTW…</string>
|
||||
<string name="lotw_sync_progress_downloading">Downloading report…</string>
|
||||
<string name="lotw_sync_progress_saving">Processing downloaded data…</string>
|
||||
<string name="lotw_sync_progress_count">Syncing %1$d QSOs…</string>
|
||||
<string name="lotw_sync_progress_qso">Syncing %1$d QSOs, ~%2$d s remaining</string>
|
||||
<string name="prefs_locator_text">Set station\'s position using locator</string>
|
||||
|
||||
<string name="prefs_data_title">Satellite data</string>
|
||||
@@ -257,6 +271,7 @@
|
||||
<string name="prefs_other_title">Other settings</string>
|
||||
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
|
||||
<string name="prefs_other_switch_update">Enable automatic data update</string>
|
||||
<string name="prefs_other_switch_lotw_sync">Auto-sync LoTW confirmed grids</string>
|
||||
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
|
||||
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
|
||||
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"type":"dxcc","regions":[{"id":0,"code":176,"name":"Fiji","label_lon":177.9759,"label_lat":-17.9376,"rings":[[[180.0,-16.0671],[180.0,-16.5552],[179.3641,-16.8014],[178.7251,-17.012],[178.5968,-16.6392],[179.0966,-16.434],[179.4135,-16.3791]],[[178.1256,-17.5048],[178.3736,-17.3399],[178.7181,-17.6285],[178.5527,-18.1506],[177.9327,-18.288],[177.3815,-18.1643],[177.285,-17.7247],[177.6709,-17.3811]],[[-179.7933,-16.0209],[-179.9174,-16.5018],[-180.0,-16.5552],[-180.0,-16.0671]]]},{"id":1,"code":470,"name":"Tanzania","label_lon":34.1421,"label_lat":-6.2078,"rings":[[[33.9037,-0.95],[34.0726,-1.0598],[37.6987,-3.097],[37.7669,-3.6771],[39.2022,-4.6768],[38.7405,-5.9089],[38.7998,-6.4757],[39.44,-6.84],[39.47,-7.1],[39.1947,-7.7039],[39.252,-8.0078],[39.1865,-8.4855],[39.5357,-9.1124],[39.9496,-10.0984],[40.3166,-10.3171],[40.3166,-10.3171],[39.521,-10.8969],[38.4276,-11.2852],[37.8276,-11.2688],[37.4713,-11.5688],[36.7752,-11.5945],[36.5141,-11.7209],[35.3124,-11.4391],[34.56,-11.52],[34.28,-10.16],[33.9408,-9.6937],[33.7397,-9.4171],[32.7594,-9.2306],[32.1919,-8.9304],[31.5563,-8.762],[31.1578,-8.5946],[30.74,-8.34],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.7535,-4.4524],[30.1163,-4.0901],[30.5055,-3.5686],[30.7522,-3.3593],[30.743,-3.0343],[30.5277,-2.8076],[30.4697,-2.4139],[30.4697,-2.4138],[30.7583,-2.2872],[30.8161,-1.6989],[30.4191,-1.1347],[30.7699,-1.0146],[31.8662,-1.0274]]]},{"id":2,"code":302,"name":"W. Sahara","label_lon":-12.572,"label_lat":24.2306,"rings":[[[-8.6656,27.6564],[-8.6651,27.5895],[-8.6844,27.3957],[-8.6873,25.8811],[-11.9694,25.9334],[-11.9372,23.3746],[-12.8742,23.2848],[-13.1188,22.7712],[-12.9291,21.3271],[-16.8452,21.3333],[-17.0634,20.9998],[-17.0204,21.4223],[-17.003,21.4207],[-14.751,21.5006],[-14.6308,21.8609],[-14.2212,22.3102],[-13.8911,23.691],[-12.501,24.7701],[-12.0308,26.0309],[-11.7182,26.1041],[-11.3926,26.8834],[-10.5513,26.9908],[-10.1894,26.8609],[-9.7353,26.8609],[-9.413,27.0885],[-8.7949,27.1207],[-8.8178,27.6564]]]},{"id":3,"code":1,"name":"Canada","label_lon":-110.2438,"label_lat":56.7019,"rings":[[[-122.84,49.0],[-122.9742,49.0025],[-124.9102,49.9846],[-125.6246,50.4166],[-127.4356,50.8306],[-127.9928,51.7158],[-127.8503,52.3296],[-129.1298,52.7554],[-129.3052,53.5616],[-130.515,54.2876],[-130.5361,54.8028],[-130.5361,54.8028],[-129.98,55.285],[-130.0078,55.9158],[-131.7078,56.5521],[-132.7304,57.6929],[-133.3556,58.4103],[-134.2711,58.8611],[-134.945,59.2706],[-135.4758,59.7878],[-136.4797,59.4639],[-137.4525,58.905],[-138.3409,59.5621],[-139.039,60.0],[-140.013,60.2768],[-140.9978,60.3064],[-140.9925,66.0],[-140.986,69.712],[-140.986,69.712],[-139.1205,69.471],[-137.5464,68.99],[-136.5036,68.898],[-135.6258,69.3151],[-134.4146,69.6274],[-132.9292,69.5053],[-131.4314,69.9445],[-129.7947,70.1937],[-129.1077,69.7793],[-128.3616,70.0129],[-128.1382,70.4838],[-127.4471,70.3772],[-125.7563,69.4806],[-124.4248,70.1584],[-124.2897,69.3997],[-123.0611,69.5637],[-122.6835,69.8555],[-121.4723,69.7978],[-119.9429,69.3779],[-117.6027,69.0113],[-116.2264,68.8415],[-115.2469,68.9059],[-113.8979,68.3989],[-115.3049,67.9026],[-113.4973,67.6881],[-110.798,67.8061],[-109.9462,67.981],[-108.8802,67.3814],[-107.7924,67.8874],[-108.813,68.3116],[-108.1672,68.6539],[-106.95,68.7],[-106.15,68.8],[-105.3428,68.5612],[-104.3379,68.018],[-103.2211,68.0978],[-101.4543,67.6469],[-99.9019,67.8057],[-98.4432,67.7816],[-98.5586,68.4039],[-97.6695,68.5786],[-96.1199,68.2394],[-96.1259,67.2934],[-95.4894,68.0907],[-94.685,68.0638],[-94.2328,69.069],[-95.3041,69.6857],[-96.4713,70.0898],[-96.3911,71.1948],[-95.2088,71.9205],[-93.89,71.7601],[-92.8782,71.3187],[-91.5196,70.1913],[-92.4069,69.7],[-90.5471,69.4977],[-90.5515,68.475],[-89.2151,69.2587],[-88.0197,68.6151],[-88.3175,67.8734],[-87.3502,67.1987],[-86.3061,67.9215],[-85.5766,68.7846],[-85.522,69.8821],[-84.1008,69.8054],[-82.6226,69.6583],[-81.2804,69.162],[-81.2202,68.6657],[-81.9644,68.1325],[-81.2593,67.5972],[-81.3865,67.1108],[-83.3446,66.4115],[-84.7354,66.2573],[-85.7694,66.5583],[-86.0676,66.0563],[-87.0314,65.213],[-87.3232,64.7756],[-88.483,64.099],[-89.9144,64.0327],[-90.704,63.6102],[-90.77,62.9602],[-91.9334,62.8351],[-93.157,62.0247],[-94.2415,60.8987],[-94.6293,60.1102],[-94.6846,58.9488],[-93.215,58.7821],[-92.7646,57.8457],[-92.297,57.0871],[-90.8977,57.2847],[-89.0395,56.8517],[-88.0398,56.4716],[-87.3242,55.9991],[-86.0712,55.7238],[-85.0118,55.3026],[-83.3606,55.2449],[-82.2729,55.1483],[-82.4362,54.2823],[-82.125,53.277],[-81.4008,52.1579],[-79.9129,51.2084],[-79.143,51.5339],[-78.6019,52.5621],[-79.1242,54.1414],[-79.8296,54.6677],[-78.2287,55.1365],[-77.0956,55.8374],[-76.5414,56.5342],[-76.6232,57.2026],[-77.3023,58.0521],[-78.5169,58.8046],[-77.3368,59.8526],[-77.7727,60.7579],[-78.1069,62.3196],[-77.4107,62.5505],[-75.6962,62.2784],[-74.6682,62.1811],[-73.8399,62.4438],[-72.9085,62.1051],[-71.6771,61.5254],[-71.3737,61.1372],[-69.5904,61.0614],[-69.6203,60.2212]Line truncated
|
||||
{"type":"dxcc","regions":[{"id":0,"code":176,"name":"Fiji","label_lon":177.9759,"label_lat":-17.9376,"rings":[[[180.0,-16.0671],[180.0,-16.5552],[179.3641,-16.8014],[178.7251,-17.012],[178.5968,-16.6392],[179.0966,-16.434],[179.4135,-16.3791],[180.0,-16.0671]],[[178.1256,-17.5048],[178.3736,-17.3399],[178.7181,-17.6285],[178.5527,-18.1506],[177.9327,-18.288],[177.3815,-18.1643],[177.285,-17.7247],[177.6709,-17.3811]],[[-179.7933,-16.0209],[-179.9174,-16.5018],[-180.0,-16.5552],[-180.0,-16.0671]]]},{"id":1,"code":470,"name":"Tanzania","label_lon":34.1421,"label_lat":-6.2078,"rings":[[[33.9037,-0.95],[34.0726,-1.0598],[37.6987,-3.097],[37.7669,-3.6771],[39.2022,-4.6768],[38.7405,-5.9089],[38.7998,-6.4757],[39.44,-6.84],[39.47,-7.1],[39.1947,-7.7039],[39.252,-8.0078],[39.1865,-8.4855],[39.5357,-9.1124],[39.9496,-10.0984],[40.3166,-10.3171],[40.3166,-10.3171],[39.521,-10.8969],[38.4276,-11.2852],[37.8276,-11.2688],[37.4713,-11.5688],[36.7752,-11.5945],[36.5141,-11.7209],[35.3124,-11.4391],[34.56,-11.52],[34.28,-10.16],[33.9408,-9.6937],[33.7397,-9.4171],[32.7594,-9.2306],[32.1919,-8.9304],[31.5563,-8.762],[31.1578,-8.5946],[30.74,-8.34],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.7535,-4.4524],[30.1163,-4.0901],[30.5055,-3.5686],[30.7522,-3.3593],[30.743,-3.0343],[30.5277,-2.8076],[30.4697,-2.4139],[30.4697,-2.4138],[30.7583,-2.2872],[30.8161,-1.6989],[30.4191,-1.1347],[30.7699,-1.0146],[31.8662,-1.0274]]]},{"id":2,"code":302,"name":"W. Sahara","label_lon":-12.572,"label_lat":24.2306,"rings":[[[-8.6656,27.6564],[-8.6651,27.5895],[-8.6844,27.3957],[-8.6873,25.8811],[-11.9694,25.9334],[-11.9372,23.3746],[-12.8742,23.2848],[-13.1188,22.7712],[-12.9291,21.3271],[-16.8452,21.3333],[-17.0634,20.9998],[-17.0204,21.4223],[-17.003,21.4207],[-14.751,21.5006],[-14.6308,21.8609],[-14.2212,22.3102],[-13.8911,23.691],[-12.501,24.7701],[-12.0308,26.0309],[-11.7182,26.1041],[-11.3926,26.8834],[-10.5513,26.9908],[-10.1894,26.8609],[-9.7353,26.8609],[-9.413,27.0885],[-8.7949,27.1207],[-8.8178,27.6564]]]},{"id":3,"code":1,"name":"Canada","label_lon":-110.2438,"label_lat":56.7019,"rings":[[[-122.84,49.0],[-122.9742,49.0025],[-124.9102,49.9846],[-125.6246,50.4166],[-127.4356,50.8306],[-127.9928,51.7158],[-127.8503,52.3296],[-129.1298,52.7554],[-129.3052,53.5616],[-130.515,54.2876],[-130.5361,54.8028],[-130.5361,54.8028],[-129.98,55.285],[-130.0078,55.9158],[-131.7078,56.5521],[-132.7304,57.6929],[-133.3556,58.4103],[-134.2711,58.8611],[-134.945,59.2706],[-135.4758,59.7878],[-136.4797,59.4639],[-137.4525,58.905],[-138.3409,59.5621],[-139.039,60.0],[-140.013,60.2768],[-140.9978,60.3064],[-140.9925,66.0],[-140.986,69.712],[-140.986,69.712],[-139.1205,69.471],[-137.5464,68.99],[-136.5036,68.898],[-135.6258,69.3151],[-134.4146,69.6274],[-132.9292,69.5053],[-131.4314,69.9445],[-129.7947,70.1937],[-129.1077,69.7793],[-128.3616,70.0129],[-128.1382,70.4838],[-127.4471,70.3772],[-125.7563,69.4806],[-124.4248,70.1584],[-124.2897,69.3997],[-123.0611,69.5637],[-122.6835,69.8555],[-121.4723,69.7978],[-119.9429,69.3779],[-117.6027,69.0113],[-116.2264,68.8415],[-115.2469,68.9059],[-113.8979,68.3989],[-115.3049,67.9026],[-113.4973,67.6881],[-110.798,67.8061],[-109.9462,67.981],[-108.8802,67.3814],[-107.7924,67.8874],[-108.813,68.3116],[-108.1672,68.6539],[-106.95,68.7],[-106.15,68.8],[-105.3428,68.5612],[-104.3379,68.018],[-103.2211,68.0978],[-101.4543,67.6469],[-99.9019,67.8057],[-98.4432,67.7816],[-98.5586,68.4039],[-97.6695,68.5786],[-96.1199,68.2394],[-96.1259,67.2934],[-95.4894,68.0907],[-94.685,68.0638],[-94.2328,69.069],[-95.3041,69.6857],[-96.4713,70.0898],[-96.3911,71.1948],[-95.2088,71.9205],[-93.89,71.7601],[-92.8782,71.3187],[-91.5196,70.1913],[-92.4069,69.7],[-90.5471,69.4977],[-90.5515,68.475],[-89.2151,69.2587],[-88.0197,68.6151],[-88.3175,67.8734],[-87.3502,67.1987],[-86.3061,67.9215],[-85.5766,68.7846],[-85.522,69.8821],[-84.1008,69.8054],[-82.6226,69.6583],[-81.2804,69.162],[-81.2202,68.6657],[-81.9644,68.1325],[-81.2593,67.5972],[-81.3865,67.1108],[-83.3446,66.4115],[-84.7354,66.2573],[-85.7694,66.5583],[-86.0676,66.0563],[-87.0314,65.213],[-87.3232,64.7756],[-88.483,64.099],[-89.9144,64.0327],[-90.704,63.6102],[-90.77,62.9602],[-91.9334,62.8351],[-93.157,62.0247],[-94.2415,60.8987],[-94.6293,60.1102],[-94.6846,58.9488],[-93.215,58.7821],[-92.7646,57.8457],[-92.297,57.0871],[-90.8977,57.2847],[-89.0395,56.8517],[-88.0398,56.4716],[-87.3242,55.9991],[-86.0712,55.7238],[-85.0118,55.3026],[-83.3606,55.2449],[-82.2729,55.1483],[-82.4362,54.2823],[-82.125,53.277],[-81.4008,52.1579],[-79.9129,51.2084],[-79.143,51.5339],[-78.6019,52.5621],[-79.1242,54.1414],[-79.8296,54.6677],[-78.2287,55.1365],[-77.0956,55.8374],[-76.5414,56.5342],[-76.6232,57.2026],[-77.3023,58.0521],[-78.5169,58.8046],[-77.3368,59.8526],[-77.7727,60.7579],[-78.1069,62.3196],[-77.4107,62.5505],[-75.6962,62.2784],[-74.6682,62.1811],[-73.8399,62.4438],[-72.9085,62.1051],[-71.6771,61.5254],[-71.3737,61.1372],[-69.5904,61.0614],[Line truncated
|
||||
@@ -1 +1 @@
|
||||
{"type":"waz","regions":[{"id":1,"code":"1","name":"1","label_lon":-140,"label_lat":75,"rings":[[[-109.0,85.0],[-109.0,77.5],[-103.2588,75.2824],[-103.4495,72.3827],[-99.2896,70.6113],[-102.0,68.0],[-102.0,60.0],[-139.0569,60.0001],[-138.7074,59.9061],[-138.6686,59.8091],[-138.626,59.7686],[-137.6062,59.2436],[-137.5007,58.9835],[-137.5266,58.9059],[-137.4512,58.9081],[-137.281,59.001],[-136.8272,59.1604],[-136.5843,59.166],[-136.4897,59.2595],[-136.4956,59.2754],[-136.4695,59.284],[-136.4771,59.4656],[-136.3681,59.449],[-136.3055,59.4644],[-136.2387,59.5242],[-136.2393,59.5592],[-136.3538,59.5998],[-136.1933,59.6401],[-135.9529,59.6622],[-135.4793,59.798],[-135.3601,59.7376],[-135.2336,59.6962],[-135.2198,59.6627],[-135.1575,59.6257],[-135.1179,59.6231],[-135.0289,59.5637],[-135.0275,59.4748],[-135.101,59.4276],[-134.9897,59.3871],[-135.0305,59.3466],[-134.9596,59.2808],[-134.7007,59.2489],[-134.6793,59.1925],[-134.5659,59.1312],[-134.4831,59.1316],[-134.3821,59.0386],[-134.4072,58.9796],[-134.3138,58.962],[-134.3365,58.9235],[-134.2579,58.8612],[-133.8404,58.7293],[-133.7068,58.6117],[-133.3772,58.4303],[-133.462,58.3879],[-133.346,58.2769],[-133.1722,58.1536],[-133.0701,58.0002],[-132.8689,57.8393],[-132.7512,57.6962],[-132.5542,57.4971],[-132.37,57.35],[-132.2487,57.2116],[-132.3688,57.0917],[-132.0449,57.045],[-132.1237,56.8739],[-131.8735,56.806],[-131.9009,56.7537],[-131.8604,56.703],[-131.8353,56.5994],[-131.5815,56.6123],[-131.4716,56.5523],[-131.1726,56.4494],[-131.0884,56.4062],[-130.7825,56.3671],[-130.6242,56.2672],[-130.4683,56.2431],[-130.4264,56.1415],[-130.2468,56.0966],[-130.104,56.1229],[-130.0037,56.0081],[-130.0178,55.9119],[-130.0041,55.9074],[-130.1524,55.7664],[-130.1481,55.7152],[-130.1118,55.6829],[-130.1281,55.5809],[-130.09,55.498],[-130.0413,55.4517],[-130.0211,55.3379],[-129.9742,55.282],[-130.1029,55.1926],[-130.1876,55.0629],[-130.346,54.9177],[-130.5682,54.7922],[-130.6591,54.7631],[-130.6257,54.7381],[-130.6156,54.7074],[-134.5376,54.2944],[-140.0,50.0],[-140.0,40.0],[-190.0,40.0],[-190.0,53.0],[-167.6871,66.6671],[-180.0,75.0],[-180.0,85.0]]]},{"id":2,"code":"2","name":"2","label_lon":-82.5,"label_lat":70,"rings":[[[-109.0,85.0],[-109.0,77.5],[-103.2588,75.2824],[-103.4495,72.3827],[-99.2896,70.6113],[-102.0,68.0],[-102.0,60.0],[-85.0,60.0],[-80.8725,54.5159],[-79.7772,51.8514],[-79.6522,51.6179],[-79.5157,51.4606],[-79.5176,50.0],[-60.0,50.0],[-55.9546,51.7455],[-55.4041,51.765],[-49.0,53.0],[-52.2437,59.5838],[-57.8076,67.1394],[-67.854,74.2269],[-74.0332,75.4972],[-74.8618,77.4191],[-73.5809,78.5637],[-69.8831,79.5013],[-68.2947,80.439],[-60.0073,82.2668],[-60.0,85.0]]]},{"id":3,"code":"3","name":"3","label_lon":-125,"label_lat":45,"rings":[[[-120.0,60.0],[-139.0569,60.0001],[-138.7074,59.9061],[-138.6686,59.8091],[-138.626,59.7686],[-137.6062,59.2436],[-137.5007,58.9835],[-137.5266,58.9059],[-137.4512,58.9081],[-137.281,59.001],[-136.8272,59.1604],[-136.5843,59.166],[-136.4897,59.2595],[-136.4956,59.2754],[-136.4695,59.284],[-136.4771,59.4656],[-136.3681,59.449],[-136.3055,59.4644],[-136.2387,59.5242],[-136.2393,59.5592],[-136.3538,59.5998],[-136.1933,59.6401],[-135.9529,59.6622],[-135.4793,59.798],[-135.3601,59.7376],[-135.2336,59.6962],[-135.2198,59.6627],[-135.1575,59.6257],[-135.1179,59.6231],[-135.0289,59.5637],[-135.0275,59.4748],[-135.101,59.4276],[-134.9897,59.3871],[-135.0305,59.3466],[-134.9596,59.2808],[-134.7007,59.2489],[-134.6793,59.1925],[-134.5659,59.1312],[-134.4831,59.1316],[-134.3821,59.0386],[-134.4072,58.9796],[-134.3138,58.962],[-134.3365,58.9235],[-134.2579,58.8612],[-133.8404,58.7293],[-133.7068,58.6117],[-133.3772,58.4303],[-133.462,58.3879],[-133.346,58.2769],[-133.1722,58.1536],[-133.0701,58.0002],[-132.8689,57.8393],[-132.7512,57.6962],[-132.5542,57.4971],[-132.37,57.35],[-132.2487,57.2116],[-132.3688,57.0917],[-132.0449,57.045],[-132.1237,56.8739],[-131.8735,56.806],[-131.9009,56.7537],[-131.8604,56.703],[-131.8353,56.5994],[-131.5815,56.6123],[-131.4716,56.5523],[-131.1726,56.4494],[-131.0884,56.4062],[-130.7825,56.3671],[-130.6242,56.2672],[-130.4683,56.2431],[-130.4264,56.1415],[-130.2468,56.0966],[-130.104,56.1229],[-130.0037,56.0081],[-130.0178,55.9119],[-130.0041,55.9074],[-130.1524,55.7664],[-130.1481,55.7152],[-130.1118,55.6829],[-130.1281,55.5809],[-130.09,55.498],[-130.0413,55.4517],[-130.0211,55.3379],[-129.9742,55.282],[-130.1029,55.1926],[-130.1876,55.0629],[-130.346,54.9177],[-130.5682,54.7922],[-130.6591,54.7631],[-130.6257,54.7381],[-130.6156,54.7074],[-134.5376,54.2944],[-140.0,50.0],[-140.0,40.0],[-130.0,40.0],[-130.0,32.5],[-120.0,32.5],[-117.1249,32.534],[-114.7197,32.7187],[-114.7643,32.6427],[-114.8087,32.6191],[-114.7997,32.5924],[-114.8137,32.5613],[-114.7932,32.5696],[-114.7916,32.5558],[-114.8057,32.5467],[-114.8135,32.4943],[-111.0748,31.3322],[-109.05,31.3325],[-109.0501,41.0007],[-111.0467,40.9979],[-111.049,44.474],[-111.1227,44.4937],[-111.1394,44.5171],[-111.1317,44.5326],[-111.177,44.5509],[-111.1808,44.569Line truncated
|
||||
{"type":"waz","regions":[{"id":1,"code":"1","name":"1","label_lon":-140,"label_lat":75,"rings":[[[-109.0,85.0],[-109.0,77.5],[-103.2588,75.2824],[-103.4495,72.3827],[-99.2896,70.6113],[-102.0,68.0],[-102.0,60.0],[-139.0569,60.0001],[-138.7074,59.9061],[-138.6686,59.8091],[-138.626,59.7686],[-137.6062,59.2436],[-137.5007,58.9835],[-137.5266,58.9059],[-137.4512,58.9081],[-137.281,59.001],[-136.8272,59.1604],[-136.5843,59.166],[-136.4897,59.2595],[-136.4956,59.2754],[-136.4695,59.284],[-136.4771,59.4656],[-136.3681,59.449],[-136.3055,59.4644],[-136.2387,59.5242],[-136.2393,59.5592],[-136.3538,59.5998],[-136.1933,59.6401],[-135.9529,59.6622],[-135.4793,59.798],[-135.3601,59.7376],[-135.2336,59.6962],[-135.2198,59.6627],[-135.1575,59.6257],[-135.1179,59.6231],[-135.0289,59.5637],[-135.0275,59.4748],[-135.101,59.4276],[-134.9897,59.3871],[-135.0305,59.3466],[-134.9596,59.2808],[-134.7007,59.2489],[-134.6793,59.1925],[-134.5659,59.1312],[-134.4831,59.1316],[-134.3821,59.0386],[-134.4072,58.9796],[-134.3138,58.962],[-134.3365,58.9235],[-134.2579,58.8612],[-133.8404,58.7293],[-133.7068,58.6117],[-133.3772,58.4303],[-133.462,58.3879],[-133.346,58.2769],[-133.1722,58.1536],[-133.0701,58.0002],[-132.8689,57.8393],[-132.7512,57.6962],[-132.5542,57.4971],[-132.37,57.35],[-132.2487,57.2116],[-132.3688,57.0917],[-132.0449,57.045],[-132.1237,56.8739],[-131.8735,56.806],[-131.9009,56.7537],[-131.8604,56.703],[-131.8353,56.5994],[-131.5815,56.6123],[-131.4716,56.5523],[-131.1726,56.4494],[-131.0884,56.4062],[-130.7825,56.3671],[-130.6242,56.2672],[-130.4683,56.2431],[-130.4264,56.1415],[-130.2468,56.0966],[-130.104,56.1229],[-130.0037,56.0081],[-130.0178,55.9119],[-130.0041,55.9074],[-130.1524,55.7664],[-130.1481,55.7152],[-130.1118,55.6829],[-130.1281,55.5809],[-130.09,55.498],[-130.0413,55.4517],[-130.0211,55.3379],[-129.9742,55.282],[-130.1029,55.1926],[-130.1876,55.0629],[-130.346,54.9177],[-130.5682,54.7922],[-130.6591,54.7631],[-130.6257,54.7381],[-130.6156,54.7074],[-134.5376,54.2944],[-140.0,50.0],[-140.0,40.0],[-190.0,40.0],[-190.0,53.0],[-167.6871,66.6671],[-180.0,75.0],[-180.0,85.0],[-109.0,85.0]]]},{"id":2,"code":"2","name":"2","label_lon":-82.5,"label_lat":70,"rings":[[[-109.0,85.0],[-109.0,77.5],[-103.2588,75.2824],[-103.4495,72.3827],[-99.2896,70.6113],[-102.0,68.0],[-102.0,60.0],[-85.0,60.0],[-80.8725,54.5159],[-79.7772,51.8514],[-79.6522,51.6179],[-79.5157,51.4606],[-79.5176,50.0],[-60.0,50.0],[-55.9546,51.7455],[-55.4041,51.765],[-49.0,53.0],[-52.2437,59.5838],[-57.8076,67.1394],[-67.854,74.2269],[-74.0332,75.4972],[-74.8618,77.4191],[-73.5809,78.5637],[-69.8831,79.5013],[-68.2947,80.439],[-60.0073,82.2668],[-60.0,85.0],[-109.0,85.0]]]},{"id":3,"code":"3","name":"3","label_lon":-125,"label_lat":45,"rings":[[[-120.0,60.0],[-139.0569,60.0001],[-138.7074,59.9061],[-138.6686,59.8091],[-138.626,59.7686],[-137.6062,59.2436],[-137.5007,58.9835],[-137.5266,58.9059],[-137.4512,58.9081],[-137.281,59.001],[-136.8272,59.1604],[-136.5843,59.166],[-136.4897,59.2595],[-136.4956,59.2754],[-136.4695,59.284],[-136.4771,59.4656],[-136.3681,59.449],[-136.3055,59.4644],[-136.2387,59.5242],[-136.2393,59.5592],[-136.3538,59.5998],[-136.1933,59.6401],[-135.9529,59.6622],[-135.4793,59.798],[-135.3601,59.7376],[-135.2336,59.6962],[-135.2198,59.6627],[-135.1575,59.6257],[-135.1179,59.6231],[-135.0289,59.5637],[-135.0275,59.4748],[-135.101,59.4276],[-134.9897,59.3871],[-135.0305,59.3466],[-134.9596,59.2808],[-134.7007,59.2489],[-134.6793,59.1925],[-134.5659,59.1312],[-134.4831,59.1316],[-134.3821,59.0386],[-134.4072,58.9796],[-134.3138,58.962],[-134.3365,58.9235],[-134.2579,58.8612],[-133.8404,58.7293],[-133.7068,58.6117],[-133.3772,58.4303],[-133.462,58.3879],[-133.346,58.2769],[-133.1722,58.1536],[-133.0701,58.0002],[-132.8689,57.8393],[-132.7512,57.6962],[-132.5542,57.4971],[-132.37,57.35],[-132.2487,57.2116],[-132.3688,57.0917],[-132.0449,57.045],[-132.1237,56.8739],[-131.8735,56.806],[-131.9009,56.7537],[-131.8604,56.703],[-131.8353,56.5994],[-131.5815,56.6123],[-131.4716,56.5523],[-131.1726,56.4494],[-131.0884,56.4062],[-130.7825,56.3671],[-130.6242,56.2672],[-130.4683,56.2431],[-130.4264,56.1415],[-130.2468,56.0966],[-130.104,56.1229],[-130.0037,56.0081],[-130.0178,55.9119],[-130.0041,55.9074],[-130.1524,55.7664],[-130.1481,55.7152],[-130.1118,55.6829],[-130.1281,55.5809],[-130.09,55.498],[-130.0413,55.4517],[-130.0211,55.3379],[-129.9742,55.282],[-130.1029,55.1926],[-130.1876,55.0629],[-130.346,54.9177],[-130.5682,54.7922],[-130.6591,54.7631],[-130.6257,54.7381],[-130.6156,54.7074],[-134.5376,54.2944],[-140.0,50.0],[-140.0,40.0],[-130.0,40.0],[-130.0,32.5],[-120.0,32.5],[-117.1249,32.534],[-114.7197,32.7187],[-114.7643,32.6427],[-114.8087,32.6191],[-114.7997,32.5924],[-114.8137,32.5613],[-114.7932,32.5696],[-114.7916,32.5558],[-114.8057,32.5467],[-114.8135,32.4943],[-111.0748,31.3322],[-109.05,31.3325],[-109.0501,41.0007],[-111.0467,40.9979],[-111.049,44.474],[-111.1227,44.4937],[-111.1394,44.5171],[-111.1317,44.5326],[-111.17Line truncated
|
||||
+27
-5
@@ -226,7 +226,7 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
if (xRight < 0f || xLeft > canvas.width) continue
|
||||
val label = cellLabel(lat, lon, zoom)
|
||||
if (label in roamedGrids && label != ownGrid) {
|
||||
drawStripes(canvas, xLeft, yTop, xRight, yBottom)
|
||||
drawStripes(canvas, xLeft, yTop, xRight, yBottom, zoom)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,7 +246,7 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
val xRight = xRightBase + turn * worldWidthPx.toFloat()
|
||||
if (xRight < 0f || xLeft > canvas.width) continue
|
||||
if (yBottom < 0f || yTop > canvas.height) continue
|
||||
drawStripes(canvas, xLeft, yTop, xRight, yBottom)
|
||||
drawStripes(canvas, xLeft, yTop, xRight, yBottom, zoom)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -396,17 +396,21 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
* rectangle, from top-left to bottom-right. The paint is nearly opaque so
|
||||
* stripes stay blue even over a green worked fill underneath.
|
||||
*/
|
||||
private fun drawStripes(canvas: Canvas, xLeft: Float, yTop: Float, xRight: Float, yBottom: Float) {
|
||||
private fun drawStripes(canvas: Canvas, xLeft: Float, yTop: Float, xRight: Float, yBottom: Float, zoom: Double) {
|
||||
if (xRight <= xLeft || yBottom <= yTop) return
|
||||
canvas.save()
|
||||
canvas.clipRect(xLeft, yTop, xRight, yBottom)
|
||||
val height = yBottom - yTop
|
||||
// Geographic density is kept constant: spacing is the reference pixel
|
||||
// spacing (STRIPE_SPACING_PX at the map's max zoom) scaled by 2^(zoom-max),
|
||||
// so stripes shrink/grow with the map instead of staying fixed on screen.
|
||||
val spacing = STRIPE_SPACING_PX * Math.pow(2.0, zoom - MAX_GRID_ZOOM).toFloat()
|
||||
// Start one stripe-width left of the cell so the top-left corner is
|
||||
// always covered; each stripe runs from (x, top) to (x+height, bottom).
|
||||
var x = xLeft - height
|
||||
while (x < xRight) {
|
||||
canvas.drawLine(x, yTop, x + height, yBottom, roamStripePaint)
|
||||
x += STRIPE_SPACING_PX
|
||||
x += spacing
|
||||
}
|
||||
canvas.restore()
|
||||
}
|
||||
@@ -428,7 +432,15 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
|
||||
/** Y pixel for a latitude, or null when outside the viewport. */
|
||||
private fun projectionToY(projection: Projection, lat: Double): Float? {
|
||||
val geo = org.osmdroid.util.GeoPoint(lat, 0.0)
|
||||
// Web Mercator is undefined beyond ±85.0511° (osmdroid clamps the world
|
||||
// to TileSystemWebMercator limits). Projecting the polar field rows
|
||||
// (lat=±90: Maidenhead row R = 80..90°N, row A = -90..-80°S) yields
|
||||
// ±9.2e18 pixel coordinates that the canvas cannot rasterize, so the
|
||||
// polar grid lines silently vanish. Clamp to the Mercator limit: the
|
||||
// polar rows then project onto the screen edge and their lines are
|
||||
// drawn (the off-limit sliver beyond 85.05° is invisible anyway).
|
||||
val clamped = lat.coerceIn(-MAX_MERCATOR_LAT, MAX_MERCATOR_LAT)
|
||||
val geo = org.osmdroid.util.GeoPoint(clamped, 0.0)
|
||||
val p = projection.toPixels(geo, null)
|
||||
return p.y.toFloat()
|
||||
}
|
||||
@@ -484,6 +496,9 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
const val FIELD_LON = 20.0
|
||||
const val SUB_SQUARE_LAT = 1.0
|
||||
const val SUB_SQUARE_LON = 2.0
|
||||
// Web Mercator latitude limit (osmdroid TileSystemWebMercator): projecting
|
||||
// beyond it produces ±9.2e18 pixel coordinates the canvas cannot draw.
|
||||
const val MAX_MERCATOR_LAT = 85.05112877980658
|
||||
// Zoom at which the 2°x1° sub-square grid lines/fills appear.
|
||||
// 5.0 → 6.0: at zoom 5 a full field spans too little screen width and
|
||||
// the sub-square grid is too dense to read; 6 roughly doubles the
|
||||
@@ -498,5 +513,12 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
// 44f spacing (was 16f — too dense per user) with 14f-wide stripes;
|
||||
// sparse GridMaster-style zebra.
|
||||
const val STRIPE_SPACING_PX = 44f
|
||||
/**
|
||||
* Reference zoom for stripe spacing: the map's max zoom (MapScreen sets
|
||||
* maxZoomLevel = 7.0). At this zoom stripes are STRIPE_SPACING_PX apart;
|
||||
* at lower zooms spacing scales by 2^(zoom-max) so the geographic
|
||||
* density matches the max-zoom look at every level.
|
||||
*/
|
||||
const val MAX_GRID_ZOOM = 7.0
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -181,11 +182,38 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
var selectedAward by remember { mutableStateOf<AwardType?>(AwardType.VUCC) }
|
||||
// Six-award progress derived from the confirmed QSO store; recomputed when
|
||||
// the store changes (LoTW/Wavelog sync).
|
||||
val awardProgress: List<AwardProgress> = remember(uiState.workedGridQsos) {
|
||||
AwardCalculator.calculate(uiState.workedGridQsos)
|
||||
// Per operated-grid VUCC breakdown: myGrid -> set of worked grids worked from it.
|
||||
val vuccByMyGrid: Map<String, Set<String>> = remember(uiState.workedGridQsos) {
|
||||
val m = mutableMapOf<String, MutableSet<String>>()
|
||||
for ((grid, qsos) in uiState.workedGridQsos) {
|
||||
for (q in qsos) {
|
||||
val mg = q.myGrid ?: continue
|
||||
m.getOrPut(mg) { mutableSetOf() }.add(grid)
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
// Selected operated grid for VUCC counting; defaults to the grid with the
|
||||
// most worked grids. Null when the store carries no per-QSO myGrid data
|
||||
// (requires a LoTW resync) — then VUCC falls back to the global count.
|
||||
var selectedMyGrid by remember(vuccByMyGrid) {
|
||||
mutableStateOf(vuccByMyGrid.maxByOrNull { it.value.size }?.key)
|
||||
}
|
||||
val awardProgress: List<AwardProgress> = remember(uiState.workedGridQsos, vuccByMyGrid, selectedMyGrid) {
|
||||
val vuccGrids = selectedMyGrid?.let { vuccByMyGrid[it].orEmpty() }
|
||||
val base = AwardCalculator.calculate(uiState.workedGridQsos)
|
||||
if (vuccGrids == null) base
|
||||
else base.map { p ->
|
||||
if (p.type == AwardType.VUCC) p.copy(workedKeys = vuccGrids, count = vuccGrids.size) else p
|
||||
}
|
||||
}
|
||||
// Worked grids drawn on the map: filtered by the selected operated grid
|
||||
// (null = all operated grids). Drives both the green fills and the tap
|
||||
// listener below, so switching the selector changes which cells are green.
|
||||
val workedGrids = remember(uiState.workedGrids, vuccByMyGrid, selectedMyGrid) {
|
||||
if (selectedMyGrid == null) uiState.workedGrids
|
||||
else vuccByMyGrid[selectedMyGrid] ?: emptySet()
|
||||
}
|
||||
// Attach the tap listener whenever grid mode / worked grids change.
|
||||
val workedGrids = uiState.workedGrids
|
||||
val isGridMode = uiState.isGridMode
|
||||
DisposableEffect(isGridMode, workedGrids) {
|
||||
val receiver = object : org.osmdroid.events.MapEventsReceiver {
|
||||
@@ -274,7 +302,7 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
// stationPosition would swallow the centering forever.
|
||||
val shouldCenter = uiState.isGridMode && !prevGridMode
|
||||
setGridMode(
|
||||
uiState.isGridMode, uiState.workedGrids, uiState.roamedGrids, view,
|
||||
uiState.isGridMode, workedGrids, uiState.roamedGrids, view,
|
||||
uiState.stationPosition,
|
||||
centerOnStation = shouldCenter
|
||||
)
|
||||
@@ -298,6 +326,19 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
|
||||
}
|
||||
}
|
||||
// Top-left: operated-grid selector for VUCC counting (grid mode only).
|
||||
// Shown whenever per-grid QSO data exists; "All" (null) is available.
|
||||
if (uiState.isGridMode && selectedAward == AwardType.VUCC && vuccByMyGrid.isNotEmpty()) {
|
||||
VuccGridSelector(
|
||||
options = vuccByMyGrid,
|
||||
allCount = uiState.workedGrids.size,
|
||||
selected = selectedMyGrid,
|
||||
onSelect = { selectedMyGrid = it },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
GridModeToggle(
|
||||
isGridMode = uiState.isGridMode,
|
||||
onToggle = { onAction(MapAction.ToggleGridMode(it)) },
|
||||
@@ -400,18 +441,36 @@ private fun WorkedGridCallRow(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = formatDate(first.epochMs, isUtc),
|
||||
// Plurals resources don't work for zh (only 'other' matches), so
|
||||
// pick the singular/plural string explicitly.
|
||||
text = if (callQsos.size == 1) stringResource(R.string.grid_qso_count_one, 1)
|
||||
else stringResource(R.string.grid_qso_count_many, callQsos.size),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = if (expanded) " ▴" else " ▾",
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = formatDate(first.epochMs, isUtc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = if (expanded) " ▴" else " ▾",
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
// First-QSO summary line, always visible.
|
||||
Text(
|
||||
@@ -579,6 +638,97 @@ private fun AwardChipsRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact pill toggle for switching between satellite view and grid mode,
|
||||
* floated over the map's top-right corner. The label reflects the active
|
||||
* mode ("Grid mode" when ON, "Satellite mode" when OFF). Semi-transparent
|
||||
* background keeps the map readable underneath.
|
||||
*/
|
||||
@Composable
|
||||
private fun VuccGridSelector(
|
||||
options: Map<String, Set<String>>,
|
||||
allCount: Int,
|
||||
selected: String?,
|
||||
onSelect: (String?) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
// Most-worked grid first; selection is made on tap. "All" sits last.
|
||||
val sorted = remember(options) { options.entries.sortedByDescending { it.value.size } }
|
||||
Surface(
|
||||
color = ComposeColor.Black.copy(alpha = 0.45f),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = modifier
|
||||
) {
|
||||
// Width = widest child (header or list item), never the full screen:
|
||||
// the expanded rows use fillMaxWidth, which would otherwise stretch
|
||||
// this box to the whole map width.
|
||||
Column(modifier = Modifier.width(IntrinsicSize.Max)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded }
|
||||
.padding(start = 10.dp, end = 6.dp, top = 4.dp, bottom = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (selected == null) "All ($allCount)"
|
||||
else "$selected (${options[selected]?.size ?: 0})",
|
||||
color = ComposeColor.White,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
|
||||
)
|
||||
Text(
|
||||
text = if (expanded) " ▴" else " ▾",
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
}
|
||||
if (expanded) {
|
||||
androidx.compose.material3.HorizontalDivider(color = ComposeColor.White.copy(alpha = 0.2f))
|
||||
sorted.forEach { (grid, grids) ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onSelect(grid)
|
||||
expanded = false
|
||||
}
|
||||
.padding(horizontal = 10.dp, vertical = 3.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "$grid (${grids.size})",
|
||||
color = if (grid == selected) MaterialTheme.colorScheme.primary else ComposeColor.White,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
// "All" always listed last: shows every worked grid combined.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onSelect(null)
|
||||
expanded = false
|
||||
}
|
||||
.padding(horizontal = 10.dp, vertical = 3.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "All ($allCount)",
|
||||
color = if (selected == null) MaterialTheme.colorScheme.primary else ComposeColor.White,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact pill toggle for switching between satellite view and grid mode,
|
||||
* floated over the map's top-right corner. The label reflects the active
|
||||
|
||||
+7
-1
@@ -45,6 +45,7 @@ import com.rtbishop.look4sat.core.presentation.R
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -58,6 +59,7 @@ fun ElevationCurveChart(
|
||||
startTime: Long,
|
||||
endTime: Long,
|
||||
maxElev: Double,
|
||||
isUtc: Boolean = false,
|
||||
progress: Float = 0.5f,
|
||||
onProgressChange: (Float) -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
@@ -74,7 +76,11 @@ fun ElevationCurveChart(
|
||||
val textColorArgb = textColor.toArgb()
|
||||
val onSurfaceArgb = onSurfaceColor.toArgb()
|
||||
val gridColorArgb = gridColor.toArgb()
|
||||
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||
val timeFormat = remember(isUtc) {
|
||||
SimpleDateFormat("HH:mm", Locale.getDefault()).apply {
|
||||
if (isUtc) timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Canvas(
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
@@ -40,6 +41,7 @@ import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -71,6 +73,7 @@ import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
@Composable
|
||||
fun MutualScreen(
|
||||
@@ -186,7 +189,11 @@ private fun MutualContent(
|
||||
onHoursAhead: (Int) -> Unit,
|
||||
onClearError: () -> Unit
|
||||
) {
|
||||
val timeFormat = remember { SimpleDateFormat("MM/dd HH:mm", Locale.getDefault()) }
|
||||
val timeFormat = remember(state.isUtc) {
|
||||
SimpleDateFormat("MM/dd HH:mm", Locale.getDefault()).apply {
|
||||
if (state.isUtc) timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -323,6 +330,7 @@ private fun MutualContent(
|
||||
pass = pass,
|
||||
isExpanded = state.selectedPassIndex == index,
|
||||
timeFormat = timeFormat,
|
||||
isUtc = state.isUtc,
|
||||
minElevA = state.stationAMinElev,
|
||||
minElevB = state.stationBMinElev,
|
||||
onClick = { onSelectPass(if (state.selectedPassIndex == index) -1 else index) },
|
||||
@@ -527,6 +535,7 @@ private fun MutualPassCard(
|
||||
pass: MutualPass,
|
||||
isExpanded: Boolean,
|
||||
timeFormat: SimpleDateFormat,
|
||||
isUtc: Boolean,
|
||||
minElevA: Double,
|
||||
minElevB: Double,
|
||||
onClick: () -> Unit,
|
||||
@@ -558,39 +567,62 @@ private fun MutualPassCard(
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = pass.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "${timeFormat.format(Date(pass.startTime))} - ${timeFormat.format(Date(pass.endTime))}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.mutual_elevation_you, pass.maxElevationA.toInt()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.mutual_elevation_opposite, pass.maxElevationB.toInt()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
// Header: pass info (name/time + elevations) on the left, and a
|
||||
// larger radar shortcut on the right that spans both rows vertically.
|
||||
// The pass info column narrows, so the "Opposite" elevation shifts
|
||||
// left to make room for the taller button.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = pass.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "${timeFormat.format(Date(pass.startTime))} - ${timeFormat.format(Date(pass.endTime))}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.mutual_elevation_you, pass.maxElevationA.toInt()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.mutual_elevation_opposite, pass.maxElevationB.toInt()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
// Radar shortcut, visible even on the collapsed card: a taller
|
||||
// button spanning both header rows. The card's own click
|
||||
// (expand) is not triggered by the IconButton's tap.
|
||||
IconButton(
|
||||
onClick = onNavigateToRadar,
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_radar),
|
||||
contentDescription = stringResource(R.string.mutual_open_radar),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
@@ -622,6 +654,7 @@ private fun MutualPassCard(
|
||||
startTime = visibleStart,
|
||||
endTime = visibleEnd,
|
||||
maxElev = adjustedMaxElev,
|
||||
isUtc = isUtc,
|
||||
progress = dragProgress,
|
||||
onProgressChange = { dragProgress = it }
|
||||
)
|
||||
|
||||
+10
-1
@@ -33,6 +33,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -53,6 +54,7 @@ data class MutualUiState(
|
||||
val isCalculating: Boolean = false,
|
||||
val hasSearched: Boolean = false,
|
||||
val selectedPassIndex: Int = -1,
|
||||
val isUtc: Boolean = false,
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
|
||||
@@ -77,8 +79,15 @@ class MutualViewModel(
|
||||
stationALat = "%.4f".format(pos.latitude),
|
||||
stationALon = "%.4f".format(pos.longitude),
|
||||
stationAMinElev = 0.0,
|
||||
stationBMinElev = 0.0
|
||||
stationBMinElev = 0.0,
|
||||
isUtc = settingsRepo.otherSettings.value.stateOfUtc
|
||||
) }
|
||||
// Keep the time display in sync with the UTC toggle in Settings
|
||||
viewModelScope.launch {
|
||||
settingsRepo.otherSettings.collectLatest { settings ->
|
||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onStationALat(value: String) {
|
||||
|
||||
@@ -2,11 +2,14 @@ package com.rtbishop.look4sat.feature.mutual
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
import com.rtbishop.look4sat.core.domain.model.LoTWSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
@@ -62,7 +65,17 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti
|
||||
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||
)
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
OtherSettings(false, false, false, false, false, false, false, false)
|
||||
OtherSettings(
|
||||
stateOfAutoUpdate = false,
|
||||
stateOfSensors = false,
|
||||
stateOfSweep = false,
|
||||
stateOfUtc = false,
|
||||
stateOfLightTheme = false,
|
||||
stateOfNightMode = false,
|
||||
stateOfMapGrid = false,
|
||||
shouldSeeWarning = false,
|
||||
shouldSeeWhatsNew = false
|
||||
)
|
||||
)
|
||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
|
||||
DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList())
|
||||
@@ -70,6 +83,8 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
|
||||
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = TODO()
|
||||
override fun setSelectedTypes(types: List<String>) = TODO()
|
||||
@@ -93,4 +108,16 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
override fun setAmSatCallsign(callsign: String) = TODO()
|
||||
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
|
||||
override fun getWorkedGrids(): Set<String> = TODO()
|
||||
override fun setWorkedGrids(grids: Set<String>) = TODO()
|
||||
override fun getWorkedGridQsos(): Map<String, List<GridQso>> = TODO()
|
||||
override fun setWorkedGridQsos(qsos: Map<String, List<GridQso>>) = TODO()
|
||||
override fun getRoamedGrids(): Set<String> = TODO()
|
||||
override fun setRoamedGrids(grids: Set<String>) = TODO()
|
||||
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
|
||||
override fun getLastLotwSyncDate(): String = ""
|
||||
override fun setLastLotwSyncDate(date: String) = Unit
|
||||
override fun getLastLotwSyncCallsign(): String = ""
|
||||
override fun setLastLotwSyncCallsign(callsign: String) = Unit
|
||||
}
|
||||
@@ -101,14 +101,17 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
||||
}
|
||||
LaunchedEffect(mutualData.endTime) {
|
||||
if (mutualData.endTime <= 0L) return@LaunchedEffect
|
||||
while (true) {
|
||||
val remainingMs = mutualData.endTime - System.currentTimeMillis()
|
||||
if (remainingMs <= 0L) {
|
||||
navigateUpAndClearMutual()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
// Auto-return only while the mutual pass is actually in progress. An
|
||||
// already-finished pass must NOT bounce the radar page back instantly
|
||||
// (that made the pass-card radar shortcut look broken: tapping it while
|
||||
// the computed pass had ended returned to Mutual immediately).
|
||||
var remainingMs = mutualData.endTime - System.currentTimeMillis()
|
||||
if (remainingMs <= 0L) return@LaunchedEffect
|
||||
while (remainingMs > 0L) {
|
||||
delay(remainingMs.coerceAtMost(1000L))
|
||||
remainingMs = mutualData.endTime - System.currentTimeMillis()
|
||||
}
|
||||
navigateUpAndClearMutual()
|
||||
}
|
||||
// Sync actual permission state on every recomposition so it survives screen re-entry
|
||||
val hasPermission = ContextCompat.checkSelfPermission(
|
||||
|
||||
+75
-15
@@ -50,6 +50,7 @@ import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -86,6 +87,7 @@ import androidx.compose.ui.zIndex
|
||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
@@ -228,23 +230,27 @@ fun LoTWDialog(
|
||||
initialSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
||||
workedGridsCount: Int,
|
||||
isSyncing: Boolean,
|
||||
syncMode: LoTWSyncMode?,
|
||||
progress: com.rtbishop.look4sat.core.domain.repository.LoTWProgress?,
|
||||
message: String?,
|
||||
dismiss: () -> Unit,
|
||||
/** Called on cancel/back while a sync is running: aborts the job. */
|
||||
onCancelSync: () -> Unit,
|
||||
onSave: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit,
|
||||
onSync: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit
|
||||
onSyncFull: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit,
|
||||
onSyncIncremental: (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
|
||||
// While syncing, cancel/back aborts the download instead of merely
|
||||
// hiding the dialog — a full sync started by mistake must be stoppable.
|
||||
onDismissRequest = { if (isSyncing) onCancelSync() else dismiss() },
|
||||
onCancel = { if (isSyncing) onCancelSync() else dismiss() },
|
||||
// The two sync buttons below are the primary actions: saving
|
||||
// credentials and fetching confirmed grids happen in one step.
|
||||
onAccept = null
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = call.value,
|
||||
@@ -268,12 +274,50 @@ fun LoTWDialog(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_lotw_sync_duration_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
if (isSyncing) {
|
||||
// Live progress: once the report header reveals the record count,
|
||||
// switch to "N QSOs, ~X s remaining" with a determinate bar.
|
||||
val progressText = when {
|
||||
progress == null ||
|
||||
progress.phase == com.rtbishop.look4sat.core.domain.repository.LoTWPhase.Connecting ->
|
||||
stringResource(R.string.lotw_sync_progress_connecting)
|
||||
// Download finished (or the size estimate reached its cap):
|
||||
// parsing and saving may still run — say so instead of the bar
|
||||
// sitting at 100% with no feedback, which reads as "stuck".
|
||||
progress.fraction >= 1f ->
|
||||
stringResource(R.string.lotw_sync_progress_saving)
|
||||
progress.qsoCount > 0 && progress.remainingSeconds > 0 ->
|
||||
stringResource(R.string.lotw_sync_progress_qso, progress.qsoCount, progress.remainingSeconds)
|
||||
progress.qsoCount > 0 ->
|
||||
stringResource(R.string.lotw_sync_progress_count, progress.qsoCount)
|
||||
else -> stringResource(R.string.lotw_sync_progress_downloading)
|
||||
}
|
||||
Text(
|
||||
text = progressText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
if (progress != null && progress.expectedBytes > 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = { progress.fraction },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Pre-sync estimate: rate per 100 QSOs (measured ~8 s at ARRL's
|
||||
// ~10 KB/s stream rate), plus the fixed server-side generation time.
|
||||
Text(
|
||||
text = stringResource(R.string.lotw_sync_rate_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
}
|
||||
if (message != null) {
|
||||
Text(
|
||||
text = message,
|
||||
@@ -282,6 +326,22 @@ fun LoTWDialog(
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
|
||||
) {
|
||||
// Full sync bottom-left, incremental merge bottom-right.
|
||||
CardButton(
|
||||
onClick = { onSyncFull(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value)) },
|
||||
text = stringResource(R.string.lotw_sync_full),
|
||||
isEnabled = !isSyncing
|
||||
)
|
||||
CardButton(
|
||||
onClick = { onSyncIncremental(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value)) },
|
||||
text = stringResource(R.string.lotw_sync_incremental),
|
||||
isEnabled = !isSyncing
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(0.dp))
|
||||
}
|
||||
}
|
||||
|
||||
+32
-6
@@ -67,6 +67,7 @@ import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||
@@ -204,14 +205,29 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
)
|
||||
}
|
||||
if (dialogs.lotw) {
|
||||
// LoTW sync failures are typed codes from the ViewModel; render them
|
||||
// through string resources so the dialog follows the system language.
|
||||
val lotwErrorMessage = uiState.lotwError?.let { error ->
|
||||
when (error) {
|
||||
LoTWError.NotConfigured -> stringResource(R.string.lotw_sync_error_not_configured)
|
||||
LoTWError.BadCredentials -> stringResource(R.string.lotw_sync_error_credentials)
|
||||
LoTWError.RateLimited -> stringResource(R.string.lotw_sync_error_rate_limited)
|
||||
LoTWError.Timeout -> stringResource(R.string.lotw_sync_error_timeout)
|
||||
is LoTWError.Network -> stringResource(R.string.lotw_sync_error_network, error.detail)
|
||||
}
|
||||
}
|
||||
LoTWDialog(
|
||||
initialSettings = uiState.lotwSettings,
|
||||
workedGridsCount = uiState.workedGridsCount,
|
||||
isSyncing = uiState.lotwSyncing,
|
||||
message = uiState.lotwMessage,
|
||||
syncMode = uiState.lotwSyncMode,
|
||||
progress = uiState.lotwProgress,
|
||||
message = lotwErrorMessage,
|
||||
dismiss = { dialogs.lotw = false },
|
||||
onCancelSync = { onAction(SettingsAction.CancelLoTWSync); dialogs.lotw = false },
|
||||
onSave = { onAction(SettingsAction.UpdateLoTW(it)) },
|
||||
onSync = { onAction(SettingsAction.SyncLoTWGrids(it)) }
|
||||
onSyncFull = { onAction(SettingsAction.SyncLoTWGrids(it, LoTWSyncMode.Full)) },
|
||||
onSyncIncremental = { onAction(SettingsAction.SyncLoTWGrids(it, LoTWSyncMode.Incremental)) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -322,6 +338,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
LoTWCard(
|
||||
settings = uiState.lotwSettings,
|
||||
workedGridsCount = uiState.workedGridsCount,
|
||||
lastSyncEpochMs = uiState.lotwLastSyncEpochMs,
|
||||
showLoTWDialog = { dialogs.lotw = true }
|
||||
)
|
||||
}
|
||||
@@ -521,7 +538,7 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
|
||||
ElevatedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(312.dp)
|
||||
.height(320.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Text(
|
||||
@@ -534,6 +551,9 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
|
||||
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
|
||||
onAction(SettingsAction.ToggleUpdate(it))
|
||||
}
|
||||
SwitchRow(R.string.prefs_other_switch_lotw_sync, settings.stateOfAutoLotwSync) {
|
||||
onAction(SettingsAction.ToggleAutoLotwSync(it))
|
||||
}
|
||||
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
|
||||
onAction(SettingsAction.ToggleSweep(it))
|
||||
}
|
||||
@@ -543,9 +563,6 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
|
||||
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
|
||||
onAction(SettingsAction.ToggleNightMode(it))
|
||||
}
|
||||
SwitchRow(R.string.prefs_other_switch_map_grid, settings.stateOfMapGrid) {
|
||||
onAction(SettingsAction.ToggleMapGrid(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -566,6 +583,7 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole
|
||||
private fun LoTWCard(
|
||||
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
||||
workedGridsCount: Int,
|
||||
lastSyncEpochMs: Long,
|
||||
showLoTWDialog: () -> Unit
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -584,6 +602,14 @@ private fun LoTWCard(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2
|
||||
)
|
||||
// Last successful sync, shown exactly like the ephemeris update time.
|
||||
if (lastSyncEpochMs != 0L) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = formatUpdateTime(updateTime = lastSyncEpochMs),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
CardButton(
|
||||
onClick = showLoTWDialog,
|
||||
|
||||
+22
-3
@@ -24,6 +24,7 @@ import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
||||
import java.io.File
|
||||
|
||||
data class PositionSettings(
|
||||
@@ -61,10 +62,23 @@ data class SettingsState(
|
||||
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 lotwSyncMode: LoTWSyncMode? = null,
|
||||
val lotwProgress: com.rtbishop.look4sat.core.domain.repository.LoTWProgress? = null,
|
||||
val lotwError: LoTWError? = null,
|
||||
/** Epoch ms of the last successful LoTW sync (0 = never) — shown like the ephemeris update time. */
|
||||
val lotwLastSyncEpochMs: Long = 0L,
|
||||
val updateChecker: UpdateCheckerState = UpdateCheckerState()
|
||||
)
|
||||
|
||||
/** LoTW sync failure, kept as a translatable code until the UI renders it. */
|
||||
sealed interface LoTWError {
|
||||
data object NotConfigured : LoTWError
|
||||
data object BadCredentials : LoTWError
|
||||
data object RateLimited : LoTWError
|
||||
data object Timeout : LoTWError
|
||||
data class Network(val detail: String) : LoTWError
|
||||
}
|
||||
|
||||
sealed interface SettingsAction {
|
||||
// Position
|
||||
data object SetGpsPosition : SettingsAction
|
||||
@@ -81,11 +95,11 @@ sealed interface SettingsAction {
|
||||
// Toggles
|
||||
data class ToggleUtc(val value: Boolean) : SettingsAction
|
||||
data class ToggleUpdate(val value: Boolean) : SettingsAction
|
||||
data class ToggleAutoLotwSync(val value: Boolean) : SettingsAction
|
||||
data class ToggleSweep(val value: Boolean) : SettingsAction
|
||||
data class ToggleSensor(val value: Boolean) : SettingsAction
|
||||
data class ToggleLightTheme(val value: Boolean) : SettingsAction
|
||||
data class ToggleNightMode(val value: Boolean) : SettingsAction
|
||||
data class ToggleMapGrid(val value: Boolean) : SettingsAction
|
||||
|
||||
// Remote control
|
||||
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
||||
@@ -100,7 +114,12 @@ sealed interface 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
|
||||
data class SyncLoTWGrids(
|
||||
val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
||||
val mode: LoTWSyncMode
|
||||
) : SettingsAction
|
||||
/** Abort an in-flight LoTW sync (wrong button / changed mind). */
|
||||
data object CancelLoTWSync : SettingsAction
|
||||
|
||||
// Update checker
|
||||
data object CheckForUpdate : SettingsAction
|
||||
|
||||
+61
-28
@@ -27,6 +27,12 @@ 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
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult
|
||||
import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
|
||||
import com.rtbishop.look4sat.core.domain.repository.lotwCursorEpochMs
|
||||
import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
||||
@@ -49,6 +55,8 @@ class SettingsViewModel(
|
||||
|
||||
private val defaultPosSettings = PositionSettings(false, settingsRepo.stationPosition.value, 0)
|
||||
private val defaultDataSettings = DataSettings(false, 0, 0, 0L)
|
||||
/** In-flight LoTW sync job, cancelled by CancelLoTWSync. */
|
||||
private var lotwSyncJob: kotlinx.coroutines.Job? = null
|
||||
private val _uiState = MutableStateFlow(
|
||||
SettingsState(
|
||||
appVersionName = settingsRepo.appVersionName,
|
||||
@@ -60,7 +68,8 @@ class SettingsViewModel(
|
||||
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
|
||||
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
|
||||
wavelogSettings = settingsRepo.wavelogSettings.value,
|
||||
workedGridsCount = settingsRepo.getWorkedGrids().size
|
||||
workedGridsCount = settingsRepo.getWorkedGrids().size,
|
||||
lotwLastSyncEpochMs = lotwCursorEpochMs(settingsRepo.getLastLotwSyncDate())
|
||||
)
|
||||
)
|
||||
|
||||
@@ -145,11 +154,11 @@ class SettingsViewModel(
|
||||
// Toggles
|
||||
is SettingsAction.ToggleUtc -> settingsRepo.updateOtherSettings { it.copy(stateOfUtc = action.value) }
|
||||
is SettingsAction.ToggleUpdate -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoUpdate = action.value) }
|
||||
is SettingsAction.ToggleAutoLotwSync -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoLotwSync = action.value) }
|
||||
is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) }
|
||||
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
|
||||
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
|
||||
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
|
||||
is SettingsAction.ToggleMapGrid -> settingsRepo.updateOtherSettings { it.copy(stateOfMapGrid = action.value) }
|
||||
// Remote control & data sources
|
||||
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
||||
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
||||
@@ -159,7 +168,8 @@ class SettingsViewModel(
|
||||
is SettingsAction.SyncWorkedGrids -> syncWorkedGrids(action.settings)
|
||||
// LoTW confirmed grids
|
||||
is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
|
||||
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings)
|
||||
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode)
|
||||
SettingsAction.CancelLoTWSync -> cancelLoTWSync()
|
||||
// Update checker
|
||||
SettingsAction.CheckForUpdate -> checkForUpdate()
|
||||
SettingsAction.DownloadUpdate -> downloadUpdate()
|
||||
@@ -192,61 +202,84 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncLoTWGrids(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) {
|
||||
private fun syncLoTWGrids(
|
||||
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
||||
mode: LoTWSyncMode
|
||||
) {
|
||||
if (!settings.isConfigured) {
|
||||
_uiState.update { it.copy(lotwMessage = "LoTW callsign/password not configured") }
|
||||
_uiState.update { it.copy(lotwError = LoTWError.NotConfigured) }
|
||||
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 {
|
||||
when (val result = lotwRepo.fetchConfirmedGridQsos(settings.callsign, settings.password)) {
|
||||
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.Success -> {
|
||||
// Wavelog entry removed: LoTW is now the only source, so the
|
||||
// synced set fully replaces the stored worked grids.
|
||||
settingsRepo.setWorkedGrids(result.grids)
|
||||
settingsRepo.setWorkedGridQsos(result.qsos)
|
||||
settingsRepo.setRoamedGrids(result.roamedGrids)
|
||||
// Incremental only makes sense for the same callsign as the last sync;
|
||||
// a first sync, an unknown/empty record or a callsign change must fall
|
||||
// back to a full report so no stale grids from another account linger.
|
||||
val callsign = settings.callsign.trim().uppercase()
|
||||
val effectiveMode = resolveLoTWSyncMode(settingsRepo.getLastLotwSyncCallsign(), callsign, mode)
|
||||
val since = if (effectiveMode == LoTWSyncMode.Incremental) {
|
||||
lotwCursorApi(settingsRepo.getLastLotwSyncDate())
|
||||
} else ""
|
||||
_uiState.update {
|
||||
it.copy(lotwSyncing = true, lotwSyncMode = effectiveMode, lotwProgress = null, lotwError = null)
|
||||
}
|
||||
lotwSyncJob = viewModelScope.launch {
|
||||
val result = lotwRepo.fetchConfirmedGridQsos(callsign, settings.password, since) { progress ->
|
||||
_uiState.update { it.copy(lotwProgress = progress) }
|
||||
}
|
||||
when (result) {
|
||||
is LoTWResult.Success -> {
|
||||
// Incremental: merge new grids/QSOs into the stored set (dedup
|
||||
// by call + QSO time); full: the fresh report replaces it all.
|
||||
// Shared with the automatic sync on app start (MainApplication).
|
||||
val mergedGridsCount = applyLoTWGridResult(settingsRepo, result, effectiveMode, callsign)
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
lotwSyncing = false, workedGridsCount = result.grids.size,
|
||||
lotwMessage = null
|
||||
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
|
||||
workedGridsCount = mergedGridsCount, lotwError = null,
|
||||
lotwLastSyncEpochMs = lotwCursorEpochMs(settingsRepo.getLastLotwSyncDate())
|
||||
)
|
||||
}
|
||||
}
|
||||
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.BadCredentials ->
|
||||
is LoTWResult.BadCredentials ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
lotwSyncing = false,
|
||||
lotwMessage = "LoTW sync failed — callsign or password incorrect"
|
||||
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
|
||||
lotwError = LoTWError.BadCredentials
|
||||
)
|
||||
}
|
||||
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.RateLimited ->
|
||||
is LoTWResult.RateLimited ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
lotwSyncing = false,
|
||||
lotwMessage = "LoTW sync failed — rate limited by server, wait a few minutes and retry"
|
||||
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
|
||||
lotwError = LoTWError.RateLimited
|
||||
)
|
||||
}
|
||||
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.Timeout ->
|
||||
is LoTWResult.Timeout ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
lotwSyncing = false,
|
||||
lotwMessage = "LoTW sync failed — connection timed out, try another network"
|
||||
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
|
||||
lotwError = LoTWError.Timeout
|
||||
)
|
||||
}
|
||||
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.NetworkError ->
|
||||
is LoTWResult.NetworkError ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
lotwSyncing = false,
|
||||
lotwMessage = "LoTW sync failed — network error (${result.detail})"
|
||||
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
|
||||
lotwError = LoTWError.Network(result.detail)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Abort the in-flight LoTW sync job and reset its progress state. */
|
||||
private fun cancelLoTWSync() {
|
||||
lotwSyncJob?.cancel()
|
||||
lotwSyncJob = null
|
||||
_uiState.update { it.copy(lotwSyncing = false, lotwSyncMode = null, lotwProgress = null) }
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Update checker
|
||||
|
||||
+12
-8
@@ -58,7 +58,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
@@ -316,7 +315,6 @@ private fun HeaderRow(statuses: List<SatStatus>) {
|
||||
/** Satellite row: name takes remaining width; day tiles are fixed-width (tablet-safe). */
|
||||
@Composable
|
||||
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
|
||||
val noReportGray = 0xFFC0C0C0L
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
@@ -329,9 +327,8 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
|
||||
modifier = Modifier.weight(1f).padding(end = 4.dp)
|
||||
)
|
||||
status.days.forEach { day ->
|
||||
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
|
||||
DayCell(
|
||||
slot = slot,
|
||||
day = day,
|
||||
modifier = Modifier.width(TILE_WIDTH).padding(horizontal = 2.dp),
|
||||
onClick = { onClickDay(day) }
|
||||
)
|
||||
@@ -339,10 +336,17 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Day block: newest reported status among the day's 12 slots; gray when none. */
|
||||
/**
|
||||
* Day block: colored by the newest reported status among the day's 12 slots
|
||||
* (gray when none); the number is the TOTAL report count of the whole day
|
||||
* (sum over all slots), not the count of the newest slot alone.
|
||||
*/
|
||||
@Composable
|
||||
private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
|
||||
private fun DayCell(day: SatDay, modifier: Modifier, onClick: () -> Unit) {
|
||||
val noReportGray = 0xFFC0C0C0L
|
||||
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
|
||||
val color = Color(slot.statusColor)
|
||||
val totalCount = day.slots.sumOf { it.count }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(28.dp)
|
||||
@@ -351,8 +355,8 @@ private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (slot.count > 0) {
|
||||
Text(text = slot.count.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
if (totalCount > 0) {
|
||||
Text(text = totalCount.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "470"
|
||||
appVersionCode = "477"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.7-ba7opf.0"
|
||||
appVersionName = "4.4.7-ba7opf.7"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user