From 15589194826203cd7c52d1ec24290f7bd874b025 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Mon, 5 Oct 2026 01:37:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(wavelog):=20upload=20UI=20=E2=80=94=20sett?= =?UTF-8?q?ings=20block,=20radar=20log=20button,=20logbook=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WavelogUploadPreviewDialog.kt | 69 ++++++++++ .../src/main/res/values-zh/strings.xml | 12 ++ .../src/main/res/values/strings.xml | 12 ++ .../look4sat/feature/radar/LogPage.kt | 32 ++++- .../look4sat/feature/radar/LogViewModel.kt | 63 ++++++++- .../feature/settings/LogbookDialog.kt | 38 +++++- .../feature/settings/SettingsDialog.kt | 111 ++++++++++++++++ .../feature/settings/SettingsScreen.kt | 68 +++++++++- .../feature/settings/SettingsState.kt | 18 +++ .../feature/settings/SettingsViewModel.kt | 120 ++++++++++++++++++ 10 files changed, 538 insertions(+), 5 deletions(-) create mode 100644 core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/WavelogUploadPreviewDialog.kt 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,