mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-05 20:17:29 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f1eb239ed | ||
|
|
01ca06b43f | ||
|
|
22363c2549 | ||
|
|
45fe755470 | ||
|
|
0ce177cfbf |
No files matched your search
+17
-5
@@ -22,7 +22,9 @@ import com.rtbishop.look4sat.core.domain.repository.LoTWPhase
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProgress
|
import com.rtbishop.look4sat.core.domain.repository.LoTWProgress
|
||||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.SocketTimeoutException
|
import java.net.SocketTimeoutException
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
@@ -91,7 +93,7 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
return Triple(grids, qsos, roamed)
|
return Triple(grids, qsos, roamed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchReportBody(
|
private suspend fun fetchReportBody(
|
||||||
callsign: String,
|
callsign: String,
|
||||||
password: String,
|
password: String,
|
||||||
since: String,
|
since: String,
|
||||||
@@ -151,6 +153,9 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
!body.contains("<eoh>", ignoreCase = true) -> Result.failure(RateLimitException())
|
!body.contains("<eoh>", ignoreCase = true) -> Result.failure(RateLimitException())
|
||||||
else -> Result.success(body)
|
else -> Result.success(body)
|
||||||
}
|
}
|
||||||
|
} catch (e: java.util.concurrent.CancellationException) {
|
||||||
|
// Cancellation must propagate — it is not a network failure.
|
||||||
|
throw e
|
||||||
} catch (e: SocketTimeoutException) {
|
} catch (e: SocketTimeoutException) {
|
||||||
Result.failure(TimeoutException(e.message ?: "timed out"))
|
Result.failure(TimeoutException(e.message ?: "timed out"))
|
||||||
} catch (e: SSLException) {
|
} catch (e: SSLException) {
|
||||||
@@ -167,7 +172,7 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
* body size is estimated as NUMREC * AVG_RECORD_BYTES so the UI can show a
|
* body size is estimated as NUMREC * AVG_RECORD_BYTES so the UI can show a
|
||||||
* meaningful progress bar and remaining-time estimate.
|
* meaningful progress bar and remaining-time estimate.
|
||||||
*/
|
*/
|
||||||
private fun readBodyWithProgress(
|
private suspend fun readBodyWithProgress(
|
||||||
stream: java.io.InputStream,
|
stream: java.io.InputStream,
|
||||||
isGzip: Boolean,
|
isGzip: Boolean,
|
||||||
onProgress: (LoTWProgress) -> Unit
|
onProgress: (LoTWProgress) -> Unit
|
||||||
@@ -185,6 +190,11 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
var lastSampleBytes = 0L
|
var lastSampleBytes = 0L
|
||||||
var lastEmitMs = 0L
|
var lastEmitMs = 0L
|
||||||
while (true) {
|
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)
|
val n = reader.read(buf)
|
||||||
if (n <= 0) break
|
if (n <= 0) break
|
||||||
sb.append(buf, 0, n)
|
sb.append(buf, 0, n)
|
||||||
@@ -440,9 +450,11 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
val NUMREC_REGEX = Regex("<APP_LoTW_NUMREC:\\d+>(\\d+)")
|
val NUMREC_REGEX = Regex("<APP_LoTW_NUMREC:\\d+>(\\d+)")
|
||||||
/**
|
/**
|
||||||
* Measured 2026-09-16 (BH6RJD, 2367 QSLs): 1.7 MB body streamed at
|
* 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. Used to predict
|
* ~9.5 KB/s ≈ 180 s, i.e. ≈ 720 bytes per ADIF record. Use a
|
||||||
* the total body size from the header record count.
|
* 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 = 720L
|
const val AVG_RECORD_BYTES = 900L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+4
-1
@@ -375,7 +375,10 @@ fun SharedDialog(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.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) {
|
if (onAccept != null) {
|
||||||
CardButton(
|
CardButton(
|
||||||
|
|||||||
@@ -160,7 +160,6 @@
|
|||||||
<string name="prefs_lotw_callsign">LoTW 呼号</string>
|
<string name="prefs_lotw_callsign">LoTW 呼号</string>
|
||||||
<string name="prefs_lotw_password">LoTW 密码</string>
|
<string name="prefs_lotw_password">LoTW 密码</string>
|
||||||
<string name="prefs_lotw_hint">直接从 ARRL LoTW 拉取该呼号已确认 QSO 的网格并覆盖本地已存集合(当前 %1$d 个)。密码仅保存在手机本地。点"同步"会自动保存凭据并拉取。</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_sync">同步</string>
|
||||||
<string name="prefs_lotw_syncing">同步中…</string>
|
<string name="prefs_lotw_syncing">同步中…</string>
|
||||||
<string name="prefs_lotw_configured">已从 LoTW 同步 — 共 %1$d 个网格</string>
|
<string name="prefs_lotw_configured">已从 LoTW 同步 — 共 %1$d 个网格</string>
|
||||||
@@ -175,6 +174,7 @@
|
|||||||
<string name="lotw_sync_rate_hint">下载速度约每 100 条 QSO 需 8 秒(另有 5–40 秒服务器生成时间)</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_connecting">正在连接 LoTW 服务器…</string>
|
||||||
<string name="lotw_sync_progress_downloading">正在下载报告…</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_count">本次同步 %1$d 条 QSO…</string>
|
||||||
<string name="lotw_sync_progress_qso">本次同步 %1$d 条 QSO,预计还需 %2$d 秒</string>
|
<string name="lotw_sync_progress_qso">本次同步 %1$d 条 QSO,预计还需 %2$d 秒</string>
|
||||||
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
|
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
|
||||||
|
|||||||
@@ -195,7 +195,6 @@
|
|||||||
<string name="prefs_lotw_callsign">LoTW callsign</string>
|
<string name="prefs_lotw_callsign">LoTW callsign</string>
|
||||||
<string name="prefs_lotw_password">LoTW password</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_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_sync">Sync</string>
|
||||||
<string name="prefs_lotw_syncing">Syncing…</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_configured">Synced from LoTW — %1$d grids total</string>
|
||||||
@@ -210,6 +209,7 @@
|
|||||||
<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_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_connecting">Connecting to LoTW…</string>
|
||||||
<string name="lotw_sync_progress_downloading">Downloading report…</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_count">Syncing %1$d QSOs…</string>
|
||||||
<string name="lotw_sync_progress_qso">Syncing %1$d QSOs, ~%2$d s remaining</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_locator_text">Set station\'s position using locator</string>
|
||||||
|
|||||||
+15
-4
@@ -226,7 +226,7 @@ class MaidenheadGridOverlay : Overlay() {
|
|||||||
if (xRight < 0f || xLeft > canvas.width) continue
|
if (xRight < 0f || xLeft > canvas.width) continue
|
||||||
val label = cellLabel(lat, lon, zoom)
|
val label = cellLabel(lat, lon, zoom)
|
||||||
if (label in roamedGrids && label != ownGrid) {
|
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()
|
val xRight = xRightBase + turn * worldWidthPx.toFloat()
|
||||||
if (xRight < 0f || xLeft > canvas.width) continue
|
if (xRight < 0f || xLeft > canvas.width) continue
|
||||||
if (yBottom < 0f || yTop > canvas.height) 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
|
* rectangle, from top-left to bottom-right. The paint is nearly opaque so
|
||||||
* stripes stay blue even over a green worked fill underneath.
|
* 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
|
if (xRight <= xLeft || yBottom <= yTop) return
|
||||||
canvas.save()
|
canvas.save()
|
||||||
canvas.clipRect(xLeft, yTop, xRight, yBottom)
|
canvas.clipRect(xLeft, yTop, xRight, yBottom)
|
||||||
val height = yBottom - yTop
|
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
|
// 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).
|
// always covered; each stripe runs from (x, top) to (x+height, bottom).
|
||||||
var x = xLeft - height
|
var x = xLeft - height
|
||||||
while (x < xRight) {
|
while (x < xRight) {
|
||||||
canvas.drawLine(x, yTop, x + height, yBottom, roamStripePaint)
|
canvas.drawLine(x, yTop, x + height, yBottom, roamStripePaint)
|
||||||
x += STRIPE_SPACING_PX
|
x += spacing
|
||||||
}
|
}
|
||||||
canvas.restore()
|
canvas.restore()
|
||||||
}
|
}
|
||||||
@@ -509,5 +513,12 @@ class MaidenheadGridOverlay : Overlay() {
|
|||||||
// 44f spacing (was 16f — too dense per user) with 14f-wide stripes;
|
// 44f spacing (was 16f — too dense per user) with 14f-wide stripes;
|
||||||
// sparse GridMaster-style zebra.
|
// sparse GridMaster-style zebra.
|
||||||
const val STRIPE_SPACING_PX = 44f
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -567,50 +567,63 @@ private fun MutualPassCard(
|
|||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(12.dp)) {
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
Row(
|
// Header: pass info (name/time + elevations) on the left, and a
|
||||||
modifier = Modifier.fillMaxWidth(),
|
// larger radar shortcut on the right that spans both rows vertically.
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
// The pass info column narrows, so the "Opposite" elevation shifts
|
||||||
verticalAlignment = Alignment.CenterVertically
|
// left to make room for the taller button.
|
||||||
) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
text = pass.name,
|
Row(
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
fontWeight = FontWeight.Medium,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
verticalAlignment = Alignment.CenterVertically
|
||||||
modifier = Modifier.weight(1f)
|
) {
|
||||||
)
|
Text(
|
||||||
Text(
|
text = pass.name,
|
||||||
text = "${timeFormat.format(Date(pass.startTime))} - ${timeFormat.format(Date(pass.endTime))}",
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
maxLines = 1,
|
||||||
// Radar shortcut, visible even on the collapsed card: icon only,
|
overflow = TextOverflow.Ellipsis,
|
||||||
// rightmost position so the pass info shifts left. The card's own
|
modifier = Modifier.weight(1f)
|
||||||
// click (expand) is not triggered by the IconButton's tap.
|
)
|
||||||
IconButton(onClick = onNavigateToRadar) {
|
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(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_radar),
|
painter = painterResource(id = R.drawable.ic_radar),
|
||||||
contentDescription = stringResource(R.string.mutual_open_radar),
|
contentDescription = stringResource(R.string.mutual_open_radar),
|
||||||
modifier = Modifier.size(18.dp)
|
modifier = Modifier.size(24.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = isExpanded,
|
visible = isExpanded,
|
||||||
|
|||||||
@@ -116,4 +116,8 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti
|
|||||||
override fun getRoamedGrids(): Set<String> = TODO()
|
override fun getRoamedGrids(): Set<String> = TODO()
|
||||||
override fun setRoamedGrids(grids: Set<String>) = TODO()
|
override fun setRoamedGrids(grids: Set<String>) = TODO()
|
||||||
override fun updateLoTWSettings(settings: LoTWSettings) = 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) {
|
LaunchedEffect(mutualData.endTime) {
|
||||||
if (mutualData.endTime <= 0L) return@LaunchedEffect
|
if (mutualData.endTime <= 0L) return@LaunchedEffect
|
||||||
while (true) {
|
// Auto-return only while the mutual pass is actually in progress. An
|
||||||
val remainingMs = mutualData.endTime - System.currentTimeMillis()
|
// already-finished pass must NOT bounce the radar page back instantly
|
||||||
if (remainingMs <= 0L) {
|
// (that made the pass-card radar shortcut look broken: tapping it while
|
||||||
navigateUpAndClearMutual()
|
// the computed pass had ended returned to Mutual immediately).
|
||||||
return@LaunchedEffect
|
var remainingMs = mutualData.endTime - System.currentTimeMillis()
|
||||||
}
|
if (remainingMs <= 0L) return@LaunchedEffect
|
||||||
|
while (remainingMs > 0L) {
|
||||||
delay(remainingMs.coerceAtMost(1000L))
|
delay(remainingMs.coerceAtMost(1000L))
|
||||||
|
remainingMs = mutualData.endTime - System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
|
navigateUpAndClearMutual()
|
||||||
}
|
}
|
||||||
// Sync actual permission state on every recomposition so it survives screen re-entry
|
// Sync actual permission state on every recomposition so it survives screen re-entry
|
||||||
val hasPermission = ContextCompat.checkSelfPermission(
|
val hasPermission = ContextCompat.checkSelfPermission(
|
||||||
|
|||||||
+13
-9
@@ -233,6 +233,8 @@ fun LoTWDialog(
|
|||||||
progress: com.rtbishop.look4sat.core.domain.repository.LoTWProgress?,
|
progress: com.rtbishop.look4sat.core.domain.repository.LoTWProgress?,
|
||||||
message: String?,
|
message: String?,
|
||||||
dismiss: () -> Unit,
|
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,
|
onSave: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit,
|
||||||
onSyncFull: (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
|
onSyncIncremental: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit
|
||||||
@@ -241,8 +243,10 @@ fun LoTWDialog(
|
|||||||
val pass = rememberSaveable { mutableStateOf(initialSettings.password) }
|
val pass = rememberSaveable { mutableStateOf(initialSettings.password) }
|
||||||
SharedDialog(
|
SharedDialog(
|
||||||
title = stringResource(R.string.prefs_lotw_title),
|
title = stringResource(R.string.prefs_lotw_title),
|
||||||
onDismissRequest = dismiss,
|
// While syncing, cancel/back aborts the download instead of merely
|
||||||
onCancel = dismiss,
|
// 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
|
// The two sync buttons below are the primary actions: saving
|
||||||
// credentials and fetching confirmed grids happen in one step.
|
// credentials and fetching confirmed grids happen in one step.
|
||||||
onAccept = null
|
onAccept = null
|
||||||
@@ -276,6 +280,11 @@ fun LoTWDialog(
|
|||||||
progress == null ||
|
progress == null ||
|
||||||
progress.phase == com.rtbishop.look4sat.core.domain.repository.LoTWPhase.Connecting ->
|
progress.phase == com.rtbishop.look4sat.core.domain.repository.LoTWPhase.Connecting ->
|
||||||
stringResource(R.string.lotw_sync_progress_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 ->
|
progress.qsoCount > 0 && progress.remainingSeconds > 0 ->
|
||||||
stringResource(R.string.lotw_sync_progress_qso, progress.qsoCount, progress.remainingSeconds)
|
stringResource(R.string.lotw_sync_progress_qso, progress.qsoCount, progress.remainingSeconds)
|
||||||
progress.qsoCount > 0 ->
|
progress.qsoCount > 0 ->
|
||||||
@@ -307,12 +316,6 @@ fun LoTWDialog(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
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 (message != null) {
|
if (message != null) {
|
||||||
Text(
|
Text(
|
||||||
@@ -323,9 +326,10 @@ fun LoTWDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
|
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
|
||||||
) {
|
) {
|
||||||
|
// Full sync bottom-left, incremental merge bottom-right.
|
||||||
CardButton(
|
CardButton(
|
||||||
onClick = { onSyncFull(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value)) },
|
onClick = { onSyncFull(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value)) },
|
||||||
text = stringResource(R.string.lotw_sync_full),
|
text = stringResource(R.string.lotw_sync_full),
|
||||||
|
|||||||
+1
@@ -223,6 +223,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
progress = uiState.lotwProgress,
|
progress = uiState.lotwProgress,
|
||||||
message = lotwErrorMessage,
|
message = lotwErrorMessage,
|
||||||
dismiss = { dialogs.lotw = false },
|
dismiss = { dialogs.lotw = false },
|
||||||
|
onCancelSync = { onAction(SettingsAction.CancelLoTWSync); dialogs.lotw = false },
|
||||||
onSave = { onAction(SettingsAction.UpdateLoTW(it)) },
|
onSave = { onAction(SettingsAction.UpdateLoTW(it)) },
|
||||||
onSyncFull = { onAction(SettingsAction.SyncLoTWGrids(it, LoTWSyncMode.Full)) },
|
onSyncFull = { onAction(SettingsAction.SyncLoTWGrids(it, LoTWSyncMode.Full)) },
|
||||||
onSyncIncremental = { onAction(SettingsAction.SyncLoTWGrids(it, LoTWSyncMode.Incremental)) }
|
onSyncIncremental = { onAction(SettingsAction.SyncLoTWGrids(it, LoTWSyncMode.Incremental)) }
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ sealed interface SettingsAction {
|
|||||||
val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
||||||
val mode: LoTWSyncMode
|
val mode: LoTWSyncMode
|
||||||
) : SettingsAction
|
) : SettingsAction
|
||||||
|
/** Abort an in-flight LoTW sync (wrong button / changed mind). */
|
||||||
|
data object CancelLoTWSync : SettingsAction
|
||||||
|
|
||||||
// Update checker
|
// Update checker
|
||||||
data object CheckForUpdate : SettingsAction
|
data object CheckForUpdate : SettingsAction
|
||||||
|
|||||||
+22
-4
@@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class SettingsViewModel(
|
class SettingsViewModel(
|
||||||
@@ -49,6 +50,8 @@ class SettingsViewModel(
|
|||||||
|
|
||||||
private val defaultPosSettings = PositionSettings(false, settingsRepo.stationPosition.value, 0)
|
private val defaultPosSettings = PositionSettings(false, settingsRepo.stationPosition.value, 0)
|
||||||
private val defaultDataSettings = DataSettings(false, 0, 0, 0L)
|
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(
|
private val _uiState = MutableStateFlow(
|
||||||
SettingsState(
|
SettingsState(
|
||||||
appVersionName = settingsRepo.appVersionName,
|
appVersionName = settingsRepo.appVersionName,
|
||||||
@@ -159,6 +162,7 @@ class SettingsViewModel(
|
|||||||
// LoTW confirmed grids
|
// LoTW confirmed grids
|
||||||
is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
|
is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
|
||||||
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode)
|
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode)
|
||||||
|
SettingsAction.CancelLoTWSync -> cancelLoTWSync()
|
||||||
// Update checker
|
// Update checker
|
||||||
SettingsAction.CheckForUpdate -> checkForUpdate()
|
SettingsAction.CheckForUpdate -> checkForUpdate()
|
||||||
SettingsAction.DownloadUpdate -> downloadUpdate()
|
SettingsAction.DownloadUpdate -> downloadUpdate()
|
||||||
@@ -215,7 +219,7 @@ class SettingsViewModel(
|
|||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(lotwSyncing = true, lotwSyncMode = effectiveMode, lotwProgress = null, lotwError = null)
|
it.copy(lotwSyncing = true, lotwSyncMode = effectiveMode, lotwProgress = null, lotwError = null)
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
lotwSyncJob = viewModelScope.launch {
|
||||||
val result = lotwRepo.fetchConfirmedGridQsos(callsign, settings.password, since) { progress ->
|
val result = lotwRepo.fetchConfirmedGridQsos(callsign, settings.password, since) { progress ->
|
||||||
_uiState.update { it.copy(lotwProgress = progress) }
|
_uiState.update { it.copy(lotwProgress = progress) }
|
||||||
}
|
}
|
||||||
@@ -235,9 +239,6 @@ class SettingsViewModel(
|
|||||||
val mergedRoamed = if (effectiveMode == LoTWSyncMode.Incremental) {
|
val mergedRoamed = if (effectiveMode == LoTWSyncMode.Incremental) {
|
||||||
existingRoamed + result.roamedGrids
|
existingRoamed + result.roamedGrids
|
||||||
} else result.roamedGrids
|
} else result.roamedGrids
|
||||||
settingsRepo.setWorkedGrids(mergedGrids)
|
|
||||||
settingsRepo.setWorkedGridQsos(mergedQsos)
|
|
||||||
settingsRepo.setRoamedGrids(mergedRoamed)
|
|
||||||
// Remember when/what we synced, so the next incremental pull
|
// Remember when/what we synced, so the next incremental pull
|
||||||
// asks LoTW for only the confirmations since today.
|
// asks LoTW for only the confirmations since today.
|
||||||
val today = java.text.SimpleDateFormat("yyyyMMdd", java.util.Locale.US)
|
val today = java.text.SimpleDateFormat("yyyyMMdd", java.util.Locale.US)
|
||||||
@@ -245,6 +246,16 @@ class SettingsViewModel(
|
|||||||
.format(java.util.Date())
|
.format(java.util.Date())
|
||||||
settingsRepo.setLastLotwSyncDate(today)
|
settingsRepo.setLastLotwSyncDate(today)
|
||||||
settingsRepo.setLastLotwSyncCallsign(callsign)
|
settingsRepo.setLastLotwSyncCallsign(callsign)
|
||||||
|
// Persist on the IO dispatcher: building the per-grid QSO JSON
|
||||||
|
// and writing SharedPreferences synchronously blocks the
|
||||||
|
// calling thread, and for large accounts (tens of thousands
|
||||||
|
// of QSOs → multi-MB JSON) that froze the main thread after
|
||||||
|
// the download finished — 'bar done but app stuck'.
|
||||||
|
withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||||
|
settingsRepo.setWorkedGrids(mergedGrids)
|
||||||
|
settingsRepo.setWorkedGridQsos(mergedQsos)
|
||||||
|
settingsRepo.setRoamedGrids(mergedRoamed)
|
||||||
|
}
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
state.copy(
|
state.copy(
|
||||||
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
|
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
|
||||||
@@ -284,6 +295,13 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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) }
|
||||||
|
}
|
||||||
|
|
||||||
/** Merge fresh QSO detail into existing per-grid lists, dedup by call + QSO time. */
|
/** Merge fresh QSO detail into existing per-grid lists, dedup by call + QSO time. */
|
||||||
private fun mergeGridQsos(
|
private fun mergeGridQsos(
|
||||||
existing: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>,
|
existing: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "475"
|
appVersionCode = "476"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.4.7-ba7opf.5"
|
appVersionName = "4.4.7-ba7opf.6"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
compileSdk = "37"
|
compileSdk = "37"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
|
|||||||
Reference in new issue
Block a user