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
new file mode 100644
index 00000000..4715d2c6
--- /dev/null
+++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/WavelogUploadPreviewDialog.kt
@@ -0,0 +1,69 @@
+/*
+ * 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 95f56501..3bdb4302 100644
--- a/core/presentation/src/main/res/values-zh/strings.xml
+++ b/core/presentation/src/main/res/values-zh/strings.xml
@@ -166,6 +166,16 @@
已同步网格数:%1$d。格式:API key:日志ID。填好直接点同步,会自动保存并拉取。
立即同步
同步中…
+ Wavelog 日志上传
+ 未配置 — 把本机通联记录上传到 Wavelog
+ 站点 %1$s — 可以上传
+ API key(读写权限)
+ 获取站点
+ 检测中…
+ 未选择站点 — 点「获取站点」
+ 密钥:读写 — 可上传
+ 密钥只读 — 请在 Wavelog → API 生成「读写」密钥
+ 点「获取站点」选择上传使用的台站档案;密钥需要「读写」权限。
LoTW 已确认网格
LoTW 呼号
LoTW 密码
@@ -201,6 +211,8 @@
已选 %1$d 条
长按记录行可多选重传
已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。
+ 上传 Wavelog
+ 确认上传
上传前核对
修改台址
忽略
diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml
index a5e0dbbf..9fafd0c8 100644
--- a/core/presentation/src/main/res/values/strings.xml
+++ b/core/presentation/src/main/res/values/strings.xml
@@ -195,6 +195,16 @@
Worked grids synced: %1$d. Format: API key, colon, logbook ID. Just tap Sync — credentials are saved automatically.
Sync now
Syncing…
+ Wavelog log upload
+ Not configured — upload your local QSO records to Wavelog
+ Station %1$s — ready to upload
+ API key (read & write)
+ Fetch stations
+ Checking…
+ No station selected — tap Fetch stations
+ Key: read & write — uploads enabled
+ Key is read-only — create a read & write key in Wavelog → API
+ Tap Fetch stations to pick the station profile uploads go through. The key needs read & write permission.
LoTW confirmed grids
LoTW callsign
LoTW password
@@ -230,6 +240,8 @@
%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
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 20b43933..45ec6a99 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,6 +68,7 @@ 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(
@@ -214,7 +215,14 @@ fun LogPage(
modifier = Modifier.weight(1f)
) { Text("上传 LoTW", fontSize = 13.sp) }
}
- if (logUiState.busy) {
+ 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) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CircularProgressIndicator(modifier = Modifier.padding(4.dp), strokeWidth = 2.dp)
Text("Working…", fontSize = 13.sp)
@@ -266,6 +274,15 @@ fun LogPage(
}
}
+ logUiState.wavelogPreview?.let { preview ->
+ WavelogUploadPreviewDialog(
+ preview = preview,
+ busy = logUiState.wavelogBusy,
+ onConfirm = logViewModel::confirmWavelogUpload,
+ onDismiss = logViewModel::dismissWavelogPreview
+ )
+ }
+
if (logUiState.message.isNotBlank()) {
AlertDialog(
onDismissRequest = logViewModel::clearMessage,
@@ -279,6 +296,19 @@ 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).
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 5bd230a1..abe7537a 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,16 +18,23 @@ 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.wavelogUploadCandidates
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
+import com.rtbishop.look4sat.core.domain.repository.IWavelogUploadRepository
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
import com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
+import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
+import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
import com.rtbishop.look4sat.core.domain.repository.positionWarning
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -78,6 +85,10 @@ 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,
/** Out-of-window contact waiting for the operator's confirmation (null when closed). */
val outOfWindowLog: OutOfWindowLog? = null
)
@@ -85,6 +96,7 @@ data class LogUiState(
class LogViewModel(
private val qsoRepository: IQsoRepository,
private val lotwUploadRepository: ILoTWUploadRepository,
+ private val wavelogUploadRepository: IWavelogUploadRepository,
private val settingsRepo: ISettingsRepo
) : ViewModel() {
@@ -314,6 +326,52 @@ class LogViewModel(
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") }
+ }
+ }
+ }
+
+ 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 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) }
@@ -347,7 +405,10 @@ class LogViewModel(
companion object {
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
- LogViewModel(container.qsoRepository, container.lotwUploadRepository, container.settingsRepo)
+ LogViewModel(
+ container.qsoRepository, container.lotwUploadRepository,
+ container.wavelogUploadRepository, container.settingsRepo
+ )
}
}
}
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 de35353d..e18cf486 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
@@ -104,7 +104,14 @@ fun LogbookDialog(
onStartSelection: (Long) -> Unit = {},
onToggleSelection: (Long) -> Unit = {},
onExitSelection: () -> Unit = {},
- onResubmitSelected: () -> 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 = {}
) {
val swipeController = rememberSwipeController()
// Entering selection mode closes any row left swiped open — reveals are off there.
@@ -166,6 +173,14 @@ fun LogbookDialog(
}
}
}
+ Row(
+ horizontalArrangement = Arrangement.End,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ TextButton(onClick = onWavelogUpload, enabled = !wavelogBusy) {
+ Text(stringResource(R.string.prefs_logbook_wavelog_upload))
+ }
+ }
}
}
@@ -198,6 +213,27 @@ 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,
diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt
index 3d5f8d03..b75d808b 100644
--- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt
+++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt
@@ -225,6 +225,117 @@ fun WavelogDialog(
}
}
+@Composable
+fun WavelogUploadDialog(
+ initialSettings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings,
+ stations: List,
+ rights: String,
+ probeBusy: Boolean,
+ message: String?,
+ dismiss: () -> Unit,
+ onSave: (com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings) -> Unit,
+ onFetchStations: (url: String, apiKey: String) -> Unit,
+ onSelectStation: (com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo) -> Unit
+) {
+ val url = rememberSaveable { mutableStateOf(initialSettings.url) }
+ val apiKey = rememberSaveable { mutableStateOf(initialSettings.apiKey) }
+ SharedDialog(
+ title = stringResource(R.string.prefs_wavelog_upload_title),
+ onCancel = dismiss,
+ onAccept = {
+ onSave(initialSettings.copy(url = url.value, apiKey = apiKey.value))
+ dismiss()
+ }
+ ) {
+ OutlinedTextField(
+ value = url.value,
+ onValueChange = { url.value = it },
+ label = { Text(text = stringResource(id = R.string.prefs_wavelog_url)) },
+ placeholder = { Text(text = "http://100.x.x.x/wavelog") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
+ )
+ OutlinedTextField(
+ value = apiKey.value,
+ onValueChange = { apiKey.value = it },
+ label = { Text(text = stringResource(id = R.string.prefs_wavelog_upload_apikey)) },
+ placeholder = { Text(text = "wl…") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
+ )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
+ ) {
+ TextButton(onClick = { onFetchStations(url.value, apiKey.value) }, enabled = !probeBusy) {
+ Text(
+ text = if (probeBusy) stringResource(R.string.prefs_wavelog_upload_probing)
+ else stringResource(R.string.prefs_wavelog_upload_fetch)
+ )
+ }
+ }
+ if (rights.isNotBlank()) {
+ Text(
+ text = when (rights) {
+ "rw" -> stringResource(R.string.prefs_wavelog_upload_rights_rw)
+ else -> stringResource(R.string.prefs_wavelog_upload_rights_r)
+ },
+ color = if (rights == "rw") MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
+ )
+ }
+ if (stations.isEmpty() && initialSettings.stationId.isBlank()) {
+ Text(
+ text = stringResource(R.string.prefs_wavelog_upload_station_none),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
+ )
+ } else {
+ stations.forEach { station ->
+ val selected = station.id == initialSettings.stationId
+ Text(
+ text = (if (selected) "● " else "○ ") + listOf(station.name, station.callsign, station.grid)
+ .filter { it.isNotBlank() }.joinToString(" · "),
+ color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onSelectStation(station) }
+ .padding(horizontal = LocalSpacing.current.large, vertical = 2.dp)
+ )
+ }
+ }
+ if (initialSettings.stationId.isNotBlank()) {
+ Text(
+ text = stringResource(
+ R.string.prefs_wavelog_upload_configured,
+ listOf(initialSettings.stationName, initialSettings.stationCallsign, initialSettings.stationGrid)
+ .filter { it.isNotBlank() }.joinToString(" · ")
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
+ )
+ }
+ if (message != null) {
+ Text(
+ text = message,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
+ )
+ }
+ Text(
+ text = stringResource(R.string.prefs_wavelog_upload_hint),
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
+ )
+ Spacer(modifier = Modifier.height(0.dp))
+ }
+}
+
@Composable
fun LoTWDialog(
initialSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
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 5c2d2300..bcdccbcd 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
@@ -318,7 +318,14 @@ private fun SettingsScreen(
onStartSelection = { onAction(SettingsAction.StartLogbookSelection(it)) },
onToggleSelection = { onAction(SettingsAction.ToggleLogbookSelection(it)) },
onExitSelection = { onAction(SettingsAction.ExitLogbookSelection) },
- onResubmitSelected = { onAction(SettingsAction.ResubmitSelectedLogbook) }
+ 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) }
)
}
if (dialogs.lotwUpload) {
@@ -340,6 +347,20 @@ private fun SettingsScreen(
)
}
+ if (dialogs.wavelogUpload) {
+ WavelogUploadDialog(
+ initialSettings = uiState.wavelogUploadSettings,
+ stations = uiState.wavelogUploadStations,
+ rights = uiState.wavelogUploadRights,
+ probeBusy = uiState.wavelogUploadProbeBusy,
+ message = uiState.wavelogUploadMessage,
+ dismiss = { dialogs.wavelogUpload = false },
+ onSave = { onAction(SettingsAction.UpdateWavelogUpload(it)) },
+ onFetchStations = { url, apiKey -> onAction(SettingsAction.FetchWavelogUploadStations(url, apiKey)) },
+ onSelectStation = { onAction(SettingsAction.SelectWavelogUploadStation(it)) }
+ )
+ }
+
// URLs for top bar
val uriHandler = LocalUriHandler.current
val upstreamTitle = stringResource(R.string.prefs_upstream_title)
@@ -461,6 +482,12 @@ private fun SettingsScreen(
showLoTWDialog = { dialogs.lotw = true }
)
}
+ item {
+ WavelogUploadCard(
+ settings = uiState.wavelogUploadSettings,
+ showDialog = { dialogs.wavelogUpload = true }
+ )
+ }
item {
OtherCard(
settings = uiState.otherSettings,
@@ -883,6 +910,41 @@ private fun LoTWCard(
}
}
+@Composable
+private fun WavelogUploadCard(
+ settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings,
+ showDialog: () -> Unit
+) {
+ ElevatedCard(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
+ Text(
+ text = stringResource(id = R.string.prefs_wavelog_upload_title),
+ color = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = if (settings.isReady) {
+ stringResource(
+ R.string.prefs_wavelog_upload_configured,
+ listOf(settings.stationName, settings.stationCallsign, settings.stationGrid)
+ .filter { it.isNotBlank() }.joinToString(" · ")
+ )
+ } else {
+ stringResource(R.string.prefs_wavelog_upload_not_configured)
+ },
+ style = MaterialTheme.typography.bodySmall,
+ maxLines = 2
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ CardButton(
+ onClick = showDialog,
+ text = stringResource(id = R.string.prefs_wavelog_configure),
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+}
+
@Composable
private fun formatUpdateTime(updateTime: Long): String {
val timePattern = stringResource(id = R.string.prefs_updated_time)
@@ -1029,6 +1091,7 @@ private class DialogVisibility {
var compassCalibration by mutableStateOf(false)
var logbook by mutableStateOf(false)
var lotwUpload by mutableStateOf(false)
+ var wavelogUpload by mutableStateOf(false)
}
@Composable
@@ -1036,7 +1099,7 @@ private fun rememberDialogVisibility(): DialogVisibility {
return rememberSaveable(saver = run {
androidx.compose.runtime.saveable.Saver(
save = {
- listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw, it.compassCalibration, it.logbook, it.lotwUpload)
+ listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw, it.compassCalibration, it.logbook, it.lotwUpload, it.wavelogUpload)
},
restore = {
DialogVisibility().apply {
@@ -1046,6 +1109,7 @@ private fun rememberDialogVisibility(): DialogVisibility {
compassCalibration = it.getOrElse(8) { false }
logbook = it.getOrElse(9) { false }
lotwUpload = it.getOrElse(10) { false }
+ wavelogUpload = it.getOrElse(11) { false }
}
}
)
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 b206d4ba..5359bde5 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
@@ -23,6 +23,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
+import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
@@ -61,6 +62,14 @@ data class SettingsState(
val workedGridsCount: Int = 0,
val wavelogSyncing: Boolean = false,
val wavelogMessage: String? = null,
+ /** Wavelog upload: its own config block + probe/upload transient state. */
+ val wavelogUploadSettings: WavelogUploadSettings = WavelogUploadSettings(),
+ 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,
+ val wavelogUploadMessage: String? = null,
val lotwSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings = com.rtbishop.look4sat.core.domain.model.LoTWSettings(),
val lotwSyncing: Boolean = false,
val lotwSyncMode: LoTWSyncMode? = null,
@@ -150,6 +159,15 @@ sealed interface SettingsAction {
data class UpdateWavelog(val settings: WavelogSettings) : SettingsAction
data class SyncWorkedGrids(val settings: WavelogSettings) : SettingsAction
+ // Wavelog upload (separate config block + upload flow)
+ 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
data class SyncLoTWGrids(
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 035d3152..46e49dd1 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
@@ -24,6 +24,7 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
+import com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
@@ -36,11 +37,18 @@ import com.rtbishop.look4sat.core.domain.repository.lotwCursorEpochMs
import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
import com.rtbishop.look4sat.core.domain.repository.positionWarning
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.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.wavelogUploadCandidates
import com.rtbishop.look4sat.core.presentation.R
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -54,6 +62,7 @@ class SettingsViewModel(
private val settingsRepo: ISettingsRepo,
private val updateRepo: IUpdateRepository,
private val wavelogRepo: IWavelogRepository,
+ private val wavelogUploadRepository: IWavelogUploadRepository,
private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository,
private val qsoRepository: com.rtbishop.look4sat.core.domain.logbook.IQsoRepository,
private val lotwUploadRepository: com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository,
@@ -79,6 +88,7 @@ class SettingsViewModel(
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
wavelogSettings = settingsRepo.wavelogSettings.value,
+ wavelogUploadSettings = settingsRepo.wavelogUploadSettings.value,
workedGridsCount = settingsRepo.getWorkedGrids().size,
compassAccuracy = sensorsRepo.compassAccuracy.value,
compassHeadingDegrees = correctedCompassHeading(),
@@ -156,6 +166,11 @@ class SettingsViewModel(
_uiState.update { it.copy(wavelogSettings = settings) }
}
}
+ viewModelScope.launch {
+ settingsRepo.wavelogUploadSettings.collect { settings ->
+ _uiState.update { it.copy(wavelogUploadSettings = settings) }
+ }
+ }
viewModelScope.launch {
settingsRepo.lotwSettings.collect { settings ->
_uiState.update { it.copy(lotwSettings = settings) }
@@ -225,6 +240,14 @@ 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)
+ 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)
@@ -280,6 +303,102 @@ class SettingsViewModel(
}
}
+ // Wavelog upload (separate config block + upload flow)
+
+ private fun fetchWavelogUploadStations(url: String, apiKey: String) {
+ if (_uiState.value.wavelogUploadProbeBusy) return
+ _uiState.update { it.copy(wavelogUploadProbeBusy = true, wavelogUploadMessage = null) }
+ viewModelScope.launch {
+ val probe = wavelogUploadRepository.probe(url.trim(), apiKey.trim())
+ if (probe == null) {
+ _uiState.update {
+ it.copy(
+ wavelogUploadProbeBusy = false,
+ wavelogUploadMessage = "Wavelog probe failed — check URL/key/network"
+ )
+ }
+ return@launch
+ }
+ _uiState.update {
+ it.copy(
+ wavelogUploadProbeBusy = false,
+ wavelogUploadStations = probe.stations,
+ wavelogUploadRights = probe.rights
+ )
+ }
+ // Single-station setups select themselves; a saved station that the server
+ // still reports stays selected.
+ val current = settingsRepo.wavelogUploadSettings.value
+ val savedStillPresent = current.stationId.isNotBlank() && probe.stations.any { it.id == current.stationId }
+ val auto = if (probe.stations.size == 1 && !savedStillPresent) probe.stations.single() else null
+ if (auto != null) selectWavelogUploadStation(auto)
+ }
+ }
+
+ private fun selectWavelogUploadStation(station: WavelogStationInfo) {
+ val current = settingsRepo.wavelogUploadSettings.value
+ settingsRepo.updateWavelogUploadSettings(
+ current.copy(
+ stationId = station.id,
+ stationName = station.name,
+ stationCallsign = station.callsign,
+ stationGrid = station.grid
+ )
+ )
+ }
+
+ 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") }
+ }
+ }
+ }
+
+ 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")
+ }
+ }
+ }
+ }
+
+ 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
@@ -704,6 +823,7 @@ class SettingsViewModel(
settingsRepo = container.settingsRepo,
updateRepo = container.updateRepo,
wavelogRepo = container.wavelogRepo,
+ wavelogUploadRepository = container.wavelogUploadRepository,
lotwRepo = container.lotwRepo,
qsoRepository = container.qsoRepository,
lotwUploadRepository = container.lotwUploadRepository,