feat(wavelog): upload rides the LoTW flow — auto-sync + catch-up (option A)

Standalone Wavelog upload entries are gone: the batch is prepared alongside the LoTW preview (count shown in both preview dialogs), posted right after the LoTW confirm in the radar, settings-logbook and resubmit flows, and the backlog flushes when LoTW has nothing pending. Failures stay unmarked and retry on the next upload.
This commit is contained in:
atsunatsu committed 2026-10-05 02:19:11 +08:00
1 parent a459cfb046
commit 5a8c4281a3
11 files changed
+243 -268

No files matched your search

@@ -56,11 +56,35 @@ fun wavelogResultMessage(result: WavelogUploadOutcome): String = when (result) {
append("All ${result.markIds.size} record(s) already in Wavelog")
} else {
append("Uploaded ${result.imported} QSO(s) to Wavelog")
if (result.duplicates > 0) append(" — ${result.duplicates} already there")
}
if (result.duplicates > 0) append(" — ${result.duplicates} already there")
if (result.skipped > 0) append(" — ${result.skipped} skipped by Wavelog")
}
is WavelogUploadOutcome.Rejected -> result.message
is WavelogUploadOutcome.Failed -> result.message
WavelogUploadOutcome.Expired -> "Preview expired — tap upload again"
}
/**
* Message for the piggybacked Wavelog side when nothing can be submitted —
* null when there is nothing to say (fully caught up, no held-back records).
*/
fun wavelogIdleSegment(preview: WavelogUploadPreview): String? = when {
preview.blockedBy != null -> "Wavelog: ${preview.blockedBy.label()}"
preview.skipped > 0 -> "Wavelog: ${wavelogSkipSummary(preview)}"
else -> null
}
/**
* Message for a finished piggybacked Wavelog upload: the server outcome plus
* the records held back before the POST (e.g. grids outside the station profile).
*/
fun wavelogConfirmedSegment(outcome: WavelogUploadOutcome, preview: WavelogUploadPreview): String {
val base = wavelogResultMessage(outcome)
val head = if (outcome is WavelogUploadOutcome.Imported) base else "Wavelog: $base"
val skip = wavelogSkipSummary(preview)
return if (skip.isNotBlank()) "$head\n$skip" else head
}
/** Message when the piggybacked upload threw outright — records stay unmarked for retry. */
fun wavelogTransportFailureSegment(): String = "Wavelog: upload failed — it will retry with your next upload"
@@ -11,7 +11,10 @@
package com.rtbishop.look4sat.core.domain.logbook
import com.rtbishop.look4sat.core.domain.repository.WavelogProblem
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -32,6 +35,23 @@ class WavelogUploadMessagesTest {
wavelogUploaded = wavelogUploaded
)
private fun preview(
count: Int = 0,
skipped: Int = 0,
reasons: Map<WavelogProblem, Int> = emptyMap(),
blockedBy: WavelogProblem? = null
) = WavelogUploadPreview(
count = count,
skipped = skipped,
reasons = reasons,
stationLabel = "OL62TI · BA7OPF",
firstUtc = "",
lastUtc = "",
contacts = emptyList(),
submittedIds = List(count) { it.toLong() },
blockedBy = blockedBy
)
@Test
fun freshLocalRecordIsEligible() {
assertTrue(wavelogUploadCandidates(listOf(record())).isNotEmpty())
@@ -62,4 +82,60 @@ class WavelogUploadMessagesTest {
assertEquals("no station profile selected", WavelogProblem.NO_STATION.label())
assertEquals("logged grid outside the Wavelog station grid", WavelogProblem.GRID_MISMATCH.label())
}
// --- Piggyback segments (Wavelog rides the LoTW upload flow) ---
@Test
fun `idle segment stays silent when nothing was held back`() {
assertNull(wavelogIdleSegment(preview()))
assertNull(wavelogIdleSegment(preview(count = 0, skipped = 0)))
}
@Test
fun `idle segment explains a blocked profile`() {
assertEquals(
"Wavelog: no station profile selected",
wavelogIdleSegment(preview(blockedBy = WavelogProblem.NO_STATION))
)
}
@Test
fun `idle segment lists held-back records with reasons`() {
assertEquals(
"Wavelog: 3 QSO(s) skipped — 3× logged grid outside the Wavelog station grid",
wavelogIdleSegment(preview(skipped = 3, reasons = mapOf(WavelogProblem.GRID_MISMATCH to 3)))
)
}
@Test
fun `confirmed segment reports the outcome and pre-POST skips`() {
val outcome = WavelogUploadOutcome.Imported(imported = 2, duplicates = 0, skipped = 0, markIds = listOf(1, 2))
assertEquals("Uploaded 2 QSO(s) to Wavelog", wavelogConfirmedSegment(outcome, preview()))
assertEquals(
"Uploaded 2 QSO(s) to Wavelog\n1 QSO(s) skipped — 1× logged grid outside the Wavelog station grid",
wavelogConfirmedSegment(
outcome,
preview(skipped = 1, reasons = mapOf(WavelogProblem.GRID_MISMATCH to 1))
)
)
}
@Test
fun `confirmed segment keeps the all-duplicates wording unprefixed`() {
val outcome = WavelogUploadOutcome.Imported(imported = 0, duplicates = 2, skipped = 0, markIds = listOf(1, 2))
assertEquals("All 2 record(s) already in Wavelog", wavelogConfirmedSegment(outcome, preview()))
}
@Test
fun `confirmed segment prefixes failures with Wavelog`() {
assertEquals("Wavelog: boom", wavelogConfirmedSegment(WavelogUploadOutcome.Failed("boom"), preview()))
}
@Test
fun `transport failure segment asks for a retry on the next upload`() {
assertEquals(
"Wavelog: upload failed — it will retry with your next upload",
wavelogTransportFailureSegment()
)
}
}
@@ -1,69 +0,0 @@
/*
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2026 BA7OPF.
* Based on Look4Sat by 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.presentation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.logbook.wavelogSkipSummary
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
/**
* Confirmation dialog shown before a Wavelog upload goes out: what will be submitted,
* and which records were held back (e.g. grids the Wavelog station profile would skip).
*/
@Composable
fun WavelogUploadPreviewDialog(
preview: WavelogUploadPreview,
busy: Boolean,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle("上传到 Wavelog") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (preview.stationLabel.isNotBlank()) {
Text(preview.stationLabel, fontSize = 13.sp)
}
Text("${preview.count} QSO(s) · ${preview.firstUtc} – ${preview.lastUtc}", fontSize = 13.sp)
val skipText = wavelogSkipSummary(preview)
if (skipText.isNotBlank()) {
Text(skipText, fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
}
Text(preview.contacts.joinToString("\n"), fontSize = 12.sp, maxLines = 8)
}
},
confirmButton = {
TextButton(onClick = onConfirm, enabled = !busy) { Text("确认上传") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
@@ -211,8 +211,7 @@
<string name="prefs_logbook_selected">已选 %1$d 条</string>
<string name="prefs_logbook_select_hint">长按记录行可多选重传</string>
<string name="prefs_logbook_resubmit_note">已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。</string>
<string name="prefs_logbook_wavelog_upload">上传 Wavelog</string>
<string name="prefs_logbook_wavelog_upload_confirm">确认上传</string>
<string name="prefs_logbook_wavelog_sync">同时同步 Wavelog:%1$d 条</string>
<string name="lotw_upload_grid_title">上传前核对</string>
<string name="lotw_upload_grid_fix">修改台址</string>
<string name="lotw_upload_grid_ignore">忽略</string>
@@ -240,8 +240,7 @@
<string name="prefs_logbook_selected">%1$d selected</string>
<string name="prefs_logbook_select_hint">Long-press a row to re-upload corrected records</string>
<string name="prefs_logbook_resubmit_note">Existing contacts will be updated in place — a grid already used for an award may be rejected.</string>
<string name="prefs_logbook_wavelog_upload">Upload to Wavelog</string>
<string name="prefs_logbook_wavelog_upload_confirm">Upload</string>
<string name="prefs_logbook_wavelog_sync">Also syncs to Wavelog: %1$d QSO(s)</string>
<string name="lotw_upload_grid_title">Upload check</string>
<string name="lotw_upload_grid_fix">Fix station location</string>
<string name="lotw_upload_grid_ignore">Ignore</string>
@@ -68,7 +68,6 @@ import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
import com.rtbishop.look4sat.core.presentation.gridsLabel
import com.rtbishop.look4sat.core.presentation.LoTWPositionWarningDialog
import com.rtbishop.look4sat.core.presentation.WavelogUploadPreviewDialog
@Composable
fun LogPage(
@@ -215,14 +214,7 @@ fun LogPage(
modifier = Modifier.weight(1f)
) { Text("上传 LoTW", fontSize = 13.sp) }
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = logViewModel::prepareWavelogUpload,
enabled = !logUiState.busy && !logUiState.wavelogBusy,
modifier = Modifier.weight(1f)
) { Text("上传 Wavelog", fontSize = 13.sp) }
}
if (logUiState.busy || logUiState.wavelogBusy) {
if (logUiState.busy) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CircularProgressIndicator(modifier = Modifier.padding(4.dp), strokeWidth = 2.dp)
Text("Working…", fontSize = 13.sp)
@@ -268,21 +260,13 @@ fun LogPage(
UploadPreviewDialog(
preview = preview,
busy = logUiState.busy,
wavelogCount = logUiState.wavelogPending?.count ?: 0,
onConfirm = logViewModel::confirmUpload,
onDismiss = logViewModel::dismissPreview
)
}
}
logUiState.wavelogPreview?.let { preview ->
WavelogUploadPreviewDialog(
preview = preview,
busy = logUiState.wavelogBusy,
onConfirm = logViewModel::confirmWavelogUpload,
onDismiss = logViewModel::dismissWavelogPreview
)
}
if (logUiState.message.isNotBlank()) {
AlertDialog(
onDismissRequest = logViewModel::clearMessage,
@@ -296,19 +280,6 @@ fun LogPage(
)
}
if (logUiState.wavelogMessage.isNotBlank()) {
AlertDialog(
onDismissRequest = logViewModel::clearWavelogMessage,
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle("Wavelog Upload") },
text = { Text(logUiState.wavelogMessage) },
confirmButton = {
TextButton(onClick = logViewModel::clearWavelogMessage) { Text("OK") }
}
)
}
// Out-of-window notice: the clock is outside the current pass [aos, los], so the record
// would never show in the window-filtered list. Confirm first, then store it at the pass
// midpoint (there is always an edit dialog afterwards for the exact time).
@@ -436,6 +407,7 @@ private fun PostDialog(
private fun UploadPreviewDialog(
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
busy: Boolean,
wavelogCount: Int = 0,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
@@ -484,6 +456,13 @@ private fun UploadPreviewDialog(
}
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
}
if (wavelogCount > 0) {
Text(
"同时同步 Wavelog:$wavelogCount 条",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
}
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
}
},
@@ -18,13 +18,14 @@ import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.logbook.displayMode
import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
import com.rtbishop.look4sat.core.domain.logbook.label
import com.rtbishop.look4sat.core.domain.logbook.officialSatelliteName
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
import com.rtbishop.look4sat.core.domain.logbook.wavelogResultMessage
import com.rtbishop.look4sat.core.domain.logbook.wavelogSkipSummary
import com.rtbishop.look4sat.core.domain.logbook.wavelogConfirmedSegment
import com.rtbishop.look4sat.core.domain.logbook.wavelogIdleSegment
import com.rtbishop.look4sat.core.domain.logbook.wavelogTransportFailureSegment
import com.rtbishop.look4sat.core.domain.logbook.wavelogUploadCandidates
import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
@@ -85,10 +86,9 @@ data class LogUiState(
val message: String = "",
val postText: String? = null,
val busy: Boolean = false,
/** Wavelog upload: prepared preview awaiting confirmation / user-facing message / busy flag. */
val wavelogPreview: WavelogUploadPreview? = null,
val wavelogMessage: String = "",
val wavelogBusy: Boolean = false,
/** Wavelog piggyback: batch prepared alongside the LoTW preview (see prepareUpload);
* uploaded right after a confirmed LoTW upload. Null when Wavelog is not ready. */
val wavelogPending: WavelogUploadPreview? = null,
/** Out-of-window contact waiting for the operator's confirmation (null when closed). */
val outOfWindowLog: OutOfWindowLog? = null
)
@@ -272,6 +272,16 @@ class LogViewModel(
// LoTW-imported confirmations are the feedback side.
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == QsoStatus.COMPLETE }
val audit = lotwUploadRepository.audit(pending)
// Wavelog piggyback (option A): prepare its batch while we are here, so
// the LoTW preview can show the count and confirm can post it directly.
val wavelogSettings = settingsRepo.wavelogUploadSettings.value
val wavelogPreview = if (wavelogSettings.isReady) {
runCatching {
wavelogUploadRepository.prepare(wavelogUploadCandidates(all), wavelogSettings)
}.getOrNull()
} else {
null
}
if (audit.pending == 0) {
val msg = when {
audit.unavailable > 0 -> unavailableUploadSummary(
@@ -281,7 +291,14 @@ class LogViewModel(
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
else -> "No pending QSOs to upload"
}
_uiState.update { it.copy(busy = false, message = msg) }
// No LoTW work to do — still flush the Wavelog backlog (catch-up): a
// freshly configured Wavelog syncs without waiting for new QSOs.
val wavelogMsg = wavelogPreview?.let {
if (it.count > 0) uploadWavelogNow(it, wavelogSettings) else wavelogIdleSegment(it)
}
_uiState.update {
it.copy(busy = false, message = listOfNotNull(msg, wavelogMsg).joinToString("\n"))
}
return@launch
}
val preview = lotwUploadRepository.prepare(pending, false)
@@ -292,6 +309,7 @@ class LogViewModel(
it.copy(
busy = false,
preview = preview,
wavelogPending = wavelogPreview,
uploadPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
)
}
@@ -320,58 +338,41 @@ class LogViewModel(
LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry"
LoTWUploadResult.ExpiredPreview -> "Preview expired — tap upload again"
}
_uiState.update { it.copy(busy = false, preview = null, message = msg) }
}
}
fun dismissPreview() = _uiState.update { it.copy(preview = null) }
fun prepareWavelogUpload() {
if (_uiState.value.wavelogBusy) return
viewModelScope.launch {
_uiState.update { it.copy(wavelogBusy = true, wavelogMessage = "") }
try {
val pending = wavelogUploadCandidates(qsoRepository.records.first())
val preview = wavelogUploadRepository.prepare(pending, settingsRepo.wavelogUploadSettings.value)
if (preview.count == 0) {
val blocked = preview.blockedBy
val message = when {
blocked != null -> blocked.label()
preview.skipped > 0 -> wavelogSkipSummary(preview)
else -> "No pending QSOs to upload"
}
_uiState.update { it.copy(wavelogBusy = false, wavelogMessage = message) }
return@launch
}
_uiState.update { it.copy(wavelogBusy = false, wavelogPreview = preview) }
} catch (_: Exception) {
_uiState.update { it.copy(wavelogBusy = false, wavelogMessage = "Upload failed") }
// Wavelog piggyback: rides the same confirmation, independent of the LoTW
// outcome — a Wavelog failure leaves records unmarked for the next upload.
val wavelogPending = _uiState.value.wavelogPending
val wavelogMsg = wavelogPending?.let { wl ->
if (wl.count > 0) uploadWavelogNow(wl, settingsRepo.wavelogUploadSettings.value)
else wavelogIdleSegment(wl)
}
_uiState.update {
it.copy(
busy = false, preview = null, wavelogPending = null,
message = listOfNotNull(msg, wavelogMsg).joinToString("\n")
)
}
}
}
fun confirmWavelogUpload() {
val preview = _uiState.value.wavelogPreview ?: return
viewModelScope.launch {
_uiState.update { it.copy(wavelogBusy = true) }
try {
val result = wavelogUploadRepository.upload(preview, settingsRepo.wavelogUploadSettings.value)
if (result is WavelogUploadOutcome.Imported && result.markIds.isNotEmpty()) {
qsoRepository.markWavelogUploaded(result.markIds)
}
_uiState.update {
it.copy(wavelogBusy = false, wavelogPreview = null, wavelogMessage = wavelogResultMessage(result))
}
} catch (_: Exception) {
_uiState.update { it.copy(wavelogBusy = false, wavelogPreview = null, wavelogMessage = "Upload failed") }
}
fun dismissPreview() = _uiState.update { it.copy(preview = null, wavelogPending = null) }
/**
* Uploads one prepared Wavelog batch (piggyback) and marks what the server
* accepted. Returns the operator-facing message segment for this upload.
*/
private suspend fun uploadWavelogNow(
preview: WavelogUploadPreview,
settings: WavelogUploadSettings
): String = try {
val outcome = wavelogUploadRepository.upload(preview, settings)
if (outcome is WavelogUploadOutcome.Imported && outcome.markIds.isNotEmpty()) {
qsoRepository.markWavelogUploaded(outcome.markIds)
}
wavelogConfirmedSegment(outcome, preview)
} catch (_: Exception) {
wavelogTransportFailureSegment()
}
fun dismissWavelogPreview() = _uiState.update { it.copy(wavelogPreview = null) }
fun clearWavelogMessage() = _uiState.update { it.copy(wavelogMessage = "") }
/** Operator chose "ignore" on the position check: keep the prepared preview. */
fun ignorePositionWarning() = _uiState.update { it.copy(uploadPositionWarning = null) }
@@ -379,7 +380,7 @@ class LogViewModel(
fun abandonForGridFix() {
lotwUploadRepository.discardPreview()
lastUploadedIds = emptyList()
_uiState.update { it.copy(uploadPositionWarning = null, preview = null) }
_uiState.update { it.copy(uploadPositionWarning = null, preview = null, wavelogPending = null) }
}
/** Recompute the log-page roaming hint from the freshest station/position data. */
@@ -105,13 +105,8 @@ fun LogbookDialog(
onToggleSelection: (Long) -> Unit = {},
onExitSelection: () -> Unit = {},
onResubmitSelected: () -> Unit = {},
wavelogBusy: Boolean = false,
wavelogPreview: com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview? = null,
wavelogMessage: String = "",
onWavelogUpload: () -> Unit = {},
onConfirmWavelogUpload: () -> Unit = {},
onDismissWavelogPreview: () -> Unit = {},
onDismissWavelogMessage: () -> Unit = {}
/** Submit count of the Wavelog batch riding this upload (0 hides the note). */
wavelogCount: Int = 0
) {
val swipeController = rememberSwipeController()
// Entering selection mode closes any row left swiped open — reveals are off there.
@@ -173,14 +168,6 @@ fun LogbookDialog(
}
}
}
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth()
) {
TextButton(onClick = onWavelogUpload, enabled = !wavelogBusy) {
Text(stringResource(R.string.prefs_logbook_wavelog_upload))
}
}
}
}
@@ -196,6 +183,7 @@ fun LogbookDialog(
LogbookUploadPreviewDialog(
preview = preview,
busy = uploadBusy,
wavelogCount = wavelogCount,
onConfirm = onConfirmUpload,
onDismiss = onDismissPreview
)
@@ -213,27 +201,6 @@ fun LogbookDialog(
)
}
wavelogPreview?.let { preview ->
com.rtbishop.look4sat.core.presentation.WavelogUploadPreviewDialog(
preview = preview,
busy = wavelogBusy,
onConfirm = onConfirmWavelogUpload,
onDismiss = onDismissWavelogPreview
)
}
if (wavelogMessage.isNotBlank()) {
AlertDialog(
onDismissRequest = onDismissWavelogMessage,
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle("Wavelog Upload") },
text = { Text(wavelogMessage) },
confirmButton = {
TextButton(onClick = onDismissWavelogMessage) { Text("OK") }
}
)
}
editTarget?.let { target ->
QsoEditDialog(
record = target,
@@ -251,6 +218,7 @@ fun LogbookDialog(
private fun LogbookUploadPreviewDialog(
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
busy: Boolean,
wavelogCount: Int,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
@@ -291,6 +259,13 @@ private fun LogbookUploadPreviewDialog(
color = MaterialTheme.colorScheme.primary
)
}
if (wavelogCount > 0) {
Text(
stringResource(R.string.prefs_logbook_wavelog_sync, wavelogCount),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
}
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
}
},
@@ -319,13 +319,7 @@ private fun SettingsScreen(
onToggleSelection = { onAction(SettingsAction.ToggleLogbookSelection(it)) },
onExitSelection = { onAction(SettingsAction.ExitLogbookSelection) },
onResubmitSelected = { onAction(SettingsAction.ResubmitSelectedLogbook) },
wavelogBusy = uiState.wavelogUploadBusy,
wavelogPreview = uiState.wavelogPreview,
wavelogMessage = uiState.wavelogUploadMessage.orEmpty(),
onWavelogUpload = { onAction(SettingsAction.PrepareWavelogUpload) },
onConfirmWavelogUpload = { onAction(SettingsAction.ConfirmWavelogUpload) },
onDismissWavelogPreview = { onAction(SettingsAction.DismissWavelogPreview) },
onDismissWavelogMessage = { onAction(SettingsAction.ClearWavelogUploadMessage) }
wavelogCount = uiState.wavelogPending?.count ?: 0
)
}
if (dialogs.lotwUpload) {
@@ -67,8 +67,10 @@ data class SettingsState(
val wavelogUploadStations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList(),
val wavelogUploadRights: String = "",
val wavelogUploadProbeBusy: Boolean = false,
val wavelogPreview: com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview? = null,
val wavelogUploadBusy: Boolean = false,
/** Wavelog batch prepared alongside the logbook LoTW preview; uploaded when the
* LoTW upload is confirmed (null when Wavelog is not ready). */
val wavelogPending: com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview? = null,
/** Config-block feedback (probe failures etc.); also shown inside the config dialog. */
val wavelogUploadMessage: String? = null,
val lotwSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings = com.rtbishop.look4sat.core.domain.model.LoTWSettings(),
val lotwSyncing: Boolean = false,
@@ -163,10 +165,6 @@ sealed interface SettingsAction {
data class UpdateWavelogUpload(val settings: WavelogUploadSettings) : SettingsAction
data class FetchWavelogUploadStations(val url: String, val apiKey: String) : SettingsAction
data class SelectWavelogUploadStation(val station: com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo) : SettingsAction
data object PrepareWavelogUpload : SettingsAction
data object ConfirmWavelogUpload : SettingsAction
data object DismissWavelogPreview : SettingsAction
data object ClearWavelogUploadMessage : SettingsAction
// LoTW confirmed grids
data class UpdateLoTW(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction
@@ -40,14 +40,15 @@ import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
import com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
import com.rtbishop.look4sat.core.domain.logbook.label
import com.rtbishop.look4sat.core.domain.logbook.resubmitCandidates
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
import com.rtbishop.look4sat.core.domain.logbook.wavelogResultMessage
import com.rtbishop.look4sat.core.domain.logbook.wavelogSkipSummary
import com.rtbishop.look4sat.core.domain.logbook.wavelogConfirmedSegment
import com.rtbishop.look4sat.core.domain.logbook.wavelogIdleSegment
import com.rtbishop.look4sat.core.domain.logbook.wavelogTransportFailureSegment
import com.rtbishop.look4sat.core.domain.logbook.wavelogUploadCandidates
import com.rtbishop.look4sat.core.presentation.R
import kotlinx.coroutines.flow.MutableStateFlow
@@ -240,14 +241,10 @@ class SettingsViewModel(
// Wavelog worked grids
is SettingsAction.UpdateWavelog -> settingsRepo.updateWavelogSettings(action.settings)
is SettingsAction.SyncWorkedGrids -> syncWorkedGrids(action.settings)
// Wavelog upload (separate config block + upload flow)
// Wavelog upload config (the upload itself rides the LoTW logbook flow)
is SettingsAction.UpdateWavelogUpload -> settingsRepo.updateWavelogUploadSettings(action.settings)
is SettingsAction.FetchWavelogUploadStations -> fetchWavelogUploadStations(action.url, action.apiKey)
is SettingsAction.SelectWavelogUploadStation -> selectWavelogUploadStation(action.station)
SettingsAction.PrepareWavelogUpload -> prepareWavelogUpload()
SettingsAction.ConfirmWavelogUpload -> confirmWavelogUpload()
SettingsAction.DismissWavelogPreview -> dismissWavelogPreview()
SettingsAction.ClearWavelogUploadMessage -> clearWavelogUploadMessage()
// LoTW confirmed grids
is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode)
@@ -347,58 +344,34 @@ class SettingsViewModel(
)
}
private fun prepareWavelogUpload() {
if (_uiState.value.wavelogUploadBusy) return
viewModelScope.launch {
_uiState.update { it.copy(wavelogUploadBusy = true, wavelogUploadMessage = null) }
try {
val pending = wavelogUploadCandidates(qsoRepository.records.first())
val preview = wavelogUploadRepository.prepare(pending, settingsRepo.wavelogUploadSettings.value)
if (preview.count == 0) {
val blocked = preview.blockedBy
val message = when {
blocked != null -> blocked.label()
preview.skipped > 0 -> wavelogSkipSummary(preview)
else -> "No pending QSOs to upload"
}
_uiState.update { it.copy(wavelogUploadBusy = false, wavelogUploadMessage = message) }
return@launch
}
_uiState.update { it.copy(wavelogUploadBusy = false, wavelogPreview = preview) }
} catch (_: Exception) {
_uiState.update { it.copy(wavelogUploadBusy = false, wavelogUploadMessage = "Upload failed") }
}
}
/** Prepares the Wavelog batch riding the LoTW upload (null when Wavelog is not ready). */
private suspend fun prepareWavelogPiggyback(
records: List<com.rtbishop.look4sat.core.domain.logbook.QsoRecord>
): WavelogUploadPreview? {
val settings = settingsRepo.wavelogUploadSettings.value
if (!settings.isReady) return null
return runCatching {
wavelogUploadRepository.prepare(wavelogUploadCandidates(records), settings)
}.getOrNull()
}
private fun confirmWavelogUpload() {
val preview = _uiState.value.wavelogPreview ?: return
viewModelScope.launch {
_uiState.update { it.copy(wavelogUploadBusy = true) }
try {
val result = wavelogUploadRepository.upload(preview, settingsRepo.wavelogUploadSettings.value)
if (result is WavelogUploadOutcome.Imported && result.markIds.isNotEmpty()) {
qsoRepository.markWavelogUploaded(result.markIds)
}
_uiState.update {
it.copy(
wavelogUploadBusy = false,
wavelogPreview = null,
wavelogUploadMessage = wavelogResultMessage(result)
)
}
} catch (_: Exception) {
_uiState.update {
it.copy(wavelogUploadBusy = false, wavelogPreview = null, wavelogUploadMessage = "Upload failed")
}
}
/**
* Uploads one prepared Wavelog batch (piggyback) and marks what the server
* accepted. Returns the operator-facing message segment for this upload.
*/
private suspend fun uploadWavelogNow(
preview: WavelogUploadPreview,
settings: WavelogUploadSettings
): String = try {
val outcome = wavelogUploadRepository.upload(preview, settings)
if (outcome is WavelogUploadOutcome.Imported && outcome.markIds.isNotEmpty()) {
qsoRepository.markWavelogUploaded(outcome.markIds)
}
wavelogConfirmedSegment(outcome, preview)
} catch (_: Exception) {
wavelogTransportFailureSegment()
}
private fun dismissWavelogPreview() = _uiState.update { it.copy(wavelogPreview = null) }
private fun clearWavelogUploadMessage() = _uiState.update { it.copy(wavelogUploadMessage = null) }
private fun syncLoTWGrids(
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
mode: LoTWSyncMode
@@ -508,6 +481,9 @@ class SettingsViewModel(
// LoTW-imported confirmations are the feedback side.
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE }
val audit = lotwUploadRepository.audit(pending)
// Wavelog piggyback (option A): prepare its batch while we are here, so
// the logbook preview can show the count and confirm can post it directly.
val wavelogPreview = prepareWavelogPiggyback(all)
if (audit.pending == 0) {
val msg = when {
audit.unavailable > 0 -> com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary(
@@ -517,7 +493,17 @@ class SettingsViewModel(
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
else -> "No pending QSOs to upload"
}
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = msg) }
// No LoTW work to do — still flush the Wavelog backlog (catch-up): a
// freshly configured Wavelog syncs without waiting for new QSOs.
val wavelogMsg = wavelogPreview?.let {
if (it.count > 0) uploadWavelogNow(it, settingsRepo.wavelogUploadSettings.value) else wavelogIdleSegment(it)
}
_uiState.update {
it.copy(
logbookUploadBusy = false,
logbookUploadMessage = listOfNotNull(msg, wavelogMsg).joinToString("\n")
)
}
return@launch
}
val preview = lotwUploadRepository.prepare(pending, false)
@@ -528,6 +514,7 @@ class SettingsViewModel(
it.copy(
logbookUploadBusy = false,
logbookPreview = preview,
wavelogPending = wavelogPreview,
logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
)
}
@@ -555,11 +542,19 @@ class SettingsViewModel(
com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry"
com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.ExpiredPreview -> "Preview expired — tap upload again"
}
// Wavelog piggyback: rides the same confirmation, independent of the LoTW
// outcome — a Wavelog failure leaves records unmarked for the next upload.
val wavelogPending = _uiState.value.wavelogPending
val wavelogMsg = wavelogPending?.let { wl ->
if (wl.count > 0) uploadWavelogNow(wl, settingsRepo.wavelogUploadSettings.value)
else wavelogIdleSegment(wl)
}
_uiState.update {
it.copy(
logbookUploadBusy = false,
logbookPreview = null,
logbookUploadMessage = msg,
wavelogPending = null,
logbookUploadMessage = listOfNotNull(msg, wavelogMsg).joinToString("\n"),
// An accepted resubmit is done — drop the checks; a failed one keeps
// them so retrying stays one tap away.
logbookSelectionMode = if (accepted) false else it.logbookSelectionMode,
@@ -569,7 +564,7 @@ class SettingsViewModel(
}
}
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null) }
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null, wavelogPending = null) }
/** Operator chose "ignore" on the position check: keep the prepared preview. */
private fun ignoreLogbookPositionWarning() = _uiState.update { it.copy(logbookPositionWarning = null) }
@@ -578,7 +573,7 @@ class SettingsViewModel(
private fun abandonLogbookForGridFix() {
lotwUploadRepository.discardPreview()
lastLogbookUploadIds = emptyList()
_uiState.update { it.copy(logbookPositionWarning = null, logbookPreview = null) }
_uiState.update { it.copy(logbookPositionWarning = null, logbookPreview = null, wavelogPending = null) }
}
/** Long-press entry: selection mode with the pressed record checked. */
@@ -605,7 +600,8 @@ class SettingsViewModel(
viewModelScope.launch {
_uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") }
try {
val selected = resubmitCandidates(qsoRepository.records.first(), _uiState.value.logbookSelectedIds)
val all = qsoRepository.records.first()
val selected = resubmitCandidates(all, _uiState.value.logbookSelectedIds)
if (selected.isEmpty()) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No complete QSOs in the selection") }
return@launch
@@ -621,10 +617,13 @@ class SettingsViewModel(
return@launch
}
lastLogbookUploadIds = preview.submittedIds
// Wavelog piggyback: the resubmit confirmation syncs the backlog too.
val wavelogPending = prepareWavelogPiggyback(all)
_uiState.update {
it.copy(
logbookUploadBusy = false,
logbookPreview = preview,
wavelogPending = wavelogPending,
logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
)
}