diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/WavelogUploadMessages.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/WavelogUploadMessages.kt index 40ce51f9..02e1a215 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/WavelogUploadMessages.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/WavelogUploadMessages.kt @@ -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" diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/WavelogUploadMessagesTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/WavelogUploadMessagesTest.kt index db962aa0..acdf0f99 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/WavelogUploadMessagesTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/WavelogUploadMessagesTest.kt @@ -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 = 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() + ) + } } diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/WavelogUploadPreviewDialog.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/WavelogUploadPreviewDialog.kt deleted file mode 100644 index 4715d2c6..00000000 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/WavelogUploadPreviewDialog.kt +++ /dev/null @@ -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 . - */ -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") } - } - ) -} diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 3bdb4302..dff78e8c 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -211,8 +211,7 @@ 已选 %1$d 条 长按记录行可多选重传 已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。 - 上传 Wavelog - 确认上传 + 同时同步 Wavelog:%1$d 条 上传前核对 修改台址 忽略 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 9fafd0c8..0efd9b14 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -240,8 +240,7 @@ %1$d selected Long-press a row to re-upload corrected records Existing contacts will be updated in place — a grid already used for an award may be rejected. - Upload to Wavelog - Upload + Also syncs to Wavelog: %1$d QSO(s) Upload check Fix station location Ignore diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt index 45ec6a99..cddff9ee 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt @@ -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) } }, diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt index abe7537a..7f48f2cd 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt @@ -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. */ diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt index e18cf486..f5baa4f6 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt @@ -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) } }, diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt index bcdccbcd..98053f69 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt @@ -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) { diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt index 5359bde5..7cb4d33c 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt @@ -67,8 +67,10 @@ data class SettingsState( val wavelogUploadStations: List = 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 diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt index 46e49dd1..33470d07 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt @@ -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 + ): 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) ) }