feat(wavelog): 二选一互斥模式 + p12 空密码导入修复(4.4.8-ba7opf.9.4, 569)

- LoTW/Wavelog 互斥:isReady 即 Wavelog 模式,LoTW 卡置灰+说明,上传按模式分流(雷达页/日志本),自动 LoTW 同步门控;配置切换弹警告、可清除配置
- 同步重做:get_contacts_adif 每台址游标,增量/全量双按钮,只增不删,URL 变更增量降级全量;同步刷新日志本+地图 workedGrids/gridQsos/roamedGrids
- 日志本台址选择器(全部/各台址)+ Wavelog 上传预览弹窗;Qso 增 wavelogStation,DB v4→v5
- 删旧 WavelogRepository/wavelogSettings 链路,换 WavelogSyncRepository + WavelogSync 纯函数
- p12 修复:导入按钮放开空密码(isEnabled=!busy)+ 密码框留空提示;Pkcs12ReaderTest +4 用例
- 单测六模块全绿;版本 4.4.8-ba7opf.9.4 (569)
This commit is contained in:
atsunatsu committed 2026-10-05 14:57:46 +08:00
1 parent 1fa7dd2d88
commit 09539ff13a
40 files changed
+1715 -470

No files matched your search

@@ -94,22 +94,11 @@ class MapViewModel(
}
}
viewModelScope.launch {
settingsRepo.wavelogSettings.collectLatest { _ ->
val workedGrids = settingsRepo.getWorkedGrids()
val markedGrids = settingsRepo.getMarkedGridStations()
// Marked stations auto-clear once their grid becomes worked:
// the reminder has served its purpose (user req: 网格变绿后标记自动清除).
val stale = markedGrids.keys.filter { it in workedGrids }
val activeMarks = if (stale.isEmpty()) markedGrids else markedGrids - stale.toSet()
if (stale.isNotEmpty()) settingsRepo.setMarkedGridStations(activeMarks)
_uiState.update {
it.copy(
workedGrids = workedGrids,
workedGridQsos = settingsRepo.getWorkedGridQsos(),
roamedGrids = settingsRepo.getRoamedGrids(),
markedGrids = activeMarks
)
}
// Grid + mark data reloads whenever the screen becomes visible (and once at
// init): the LoTW/Wavelog syncs run from Settings or the app start, so
// re-entering the map picks their results up.
isScreenVisible.collect { visible ->
if (visible) refreshMapGridData()
}
}
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
@@ -117,6 +106,25 @@ class MapViewModel(
getStationPosition()
}
/** Loads the worked-grid / mark data into the map state (see the visibility collector). */
private fun refreshMapGridData() {
val workedGrids = settingsRepo.getWorkedGrids()
val markedGrids = settingsRepo.getMarkedGridStations()
// Marked stations auto-clear once their grid becomes worked:
// the reminder has served its purpose (user req: 网格变绿后标记自动清除).
val stale = markedGrids.keys.filter { it in workedGrids }
val activeMarks = if (stale.isEmpty()) markedGrids else markedGrids - stale.toSet()
if (stale.isNotEmpty()) settingsRepo.setMarkedGridStations(activeMarks)
_uiState.update {
it.copy(
workedGrids = workedGrids,
workedGridQsos = settingsRepo.getWorkedGridQsos(),
roamedGrids = settingsRepo.getRoamedGrids(),
markedGrids = activeMarks
)
}
}
fun onAction(action: MapAction) {
when (action) {
MapAction.SelectPrev -> scrollSelection(true)
@@ -27,7 +27,7 @@ import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.SatRadio
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.predict.OrbitalObject
@@ -107,7 +107,7 @@ class FakeSettingsRepo(
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
override val wavelogUploadSettings: StateFlow<WavelogUploadSettings> = MutableStateFlow(WavelogUploadSettings())
override fun updateWavelogUploadSettings(settings: WavelogUploadSettings) = Unit
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
@@ -137,7 +137,7 @@ class FakeSettingsRepo(
override fun setSatelliteMode(catnum: Int, mode: String) = TODO()
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = TODO()
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
override fun getWorkedGrids(): Set<String> = TODO()
override fun setWorkedGrids(grids: Set<String>) = TODO()
override fun getWorkedGridQsos(): Map<String, List<GridQso>> = TODO()
@@ -151,4 +151,12 @@ class FakeSettingsRepo(
override fun setLastLotwSyncDate(date: String) = Unit
override fun getLastLotwSyncCallsign(): String = ""
override fun setLastLotwSyncCallsign(callsign: String) = Unit
override fun getWavelogStations(): List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList()
override fun setWavelogStations(stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>) = Unit
override fun getWavelogSyncCursors(): Map<String, Long> = emptyMap()
override fun setWavelogSyncCursors(cursors: Map<String, Long>) = Unit
override fun getWavelogSyncUrl(): String = ""
override fun setWavelogSyncUrl(url: String) = Unit
override fun getLastWavelogSyncEpochMs(): Long = 0L
override fun setLastWavelogSyncEpochMs(value: Long) = Unit
}
@@ -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(
@@ -212,7 +213,7 @@ fun LogPage(
onClick = logViewModel::prepareUpload,
enabled = !logUiState.busy,
modifier = Modifier.weight(1f)
) { Text("上传 LoTW", fontSize = 13.sp) }
) { Text(if (logUiState.wavelogMode) "上传 Wavelog" else "上传 LoTW", fontSize = 13.sp) }
}
if (logUiState.busy) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -260,19 +261,27 @@ 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.busy,
onConfirm = logViewModel::confirmWavelogUpload,
onDismiss = logViewModel::dismissWavelogPreview
)
}
if (logUiState.message.isNotBlank()) {
AlertDialog(
onDismissRequest = logViewModel::clearMessage,
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle("LoTW Upload") },
title = { SheetDialogTitle(if (logUiState.wavelogMode) "Wavelog Upload" else "LoTW Upload") },
text = { Text(logUiState.message) },
confirmButton = {
TextButton(onClick = logViewModel::clearMessage) { Text("OK") }
@@ -407,7 +416,6 @@ private fun PostDialog(
private fun UploadPreviewDialog(
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
busy: Boolean,
wavelogCount: Int = 0,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
@@ -456,13 +464,6 @@ 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)
}
},
@@ -86,9 +86,10 @@ data class LogUiState(
val message: String = "",
val postText: String? = null,
val busy: 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,
/** True when uploads route through Wavelog (Wavelog configured — LoTW paused). */
val wavelogMode: Boolean = false,
/** Wavelog mode: the prepared batch awaiting the operator's confirmation. */
val wavelogPreview: WavelogUploadPreview? = null,
/** Out-of-window contact waiting for the operator's confirmation (null when closed). */
val outOfWindowLog: OutOfWindowLog? = null
)
@@ -124,6 +125,13 @@ class LogViewModel(
)
}
}
// Upload routing follows the Wavelog configuration: once it is ready the log
// page uploads through Wavelog and skips LoTW entirely (the one-of-two rule).
viewModelScope.launch {
settingsRepo.wavelogUploadSettings.collect { settings ->
_uiState.update { it.copy(wavelogMode = settings.isReady) }
}
}
// The roaming hint tracks the freshest position data: refresh on init and every fix.
viewModelScope.launch {
settingsRepo.stationPosition.collect { refreshPositionHintNow() }
@@ -268,20 +276,16 @@ class LogViewModel(
_uiState.update { it.copy(busy = true, message = "") }
try {
val all = qsoRepository.records.first()
val wavelogSettings = settingsRepo.wavelogUploadSettings.value
if (wavelogSettings.isReady) {
// Wavelog mode: the upload goes to Wavelog only (LoTW stays out).
prepareWavelogUpload(all, wavelogSettings)
return@launch
}
// Only local (non-confirmed) records are candidates for upload;
// 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(
@@ -291,14 +295,7 @@ class LogViewModel(
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
else -> "No pending QSOs to upload"
}
// 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"))
}
_uiState.update { it.copy(busy = false, message = msg) }
return@launch
}
val preview = lotwUploadRepository.prepare(pending, false)
@@ -309,7 +306,6 @@ class LogViewModel(
it.copy(
busy = false,
preview = preview,
wavelogPending = wavelogPreview,
uploadPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
)
}
@@ -321,6 +317,24 @@ class LogViewModel(
}
}
/** Wavelog-mode branch of [prepareUpload]: prepare the batch and ask for confirmation. */
private suspend fun prepareWavelogUpload(all: List<QsoRecord>, settings: WavelogUploadSettings) {
val preview = runCatching {
wavelogUploadRepository.prepare(wavelogUploadCandidates(all), settings)
}.getOrNull()
if (preview == null) {
_uiState.update { it.copy(busy = false, message = "Wavelog upload failed to prepare — check the configuration") }
return
}
if (preview.count == 0) {
_uiState.update {
it.copy(busy = false, message = wavelogIdleSegment(preview) ?: "No pending QSOs to upload to Wavelog")
}
return
}
_uiState.update { it.copy(busy = false, wavelogPreview = preview) }
}
fun confirmUpload() {
val preview = _uiState.value.preview ?: return
viewModelScope.launch {
@@ -338,27 +352,28 @@ class LogViewModel(
LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry"
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(
busy = false, preview = null, wavelogPending = null,
message = listOfNotNull(msg, wavelogMsg).joinToString("\n")
)
}
_uiState.update { it.copy(busy = false, preview = null, message = msg) }
}
}
fun dismissPreview() = _uiState.update { it.copy(preview = null, wavelogPending = null) }
/** Confirms the Wavelog-mode batch prepared by [prepareUpload]. */
fun confirmWavelogUpload() {
val preview = _uiState.value.wavelogPreview ?: return
viewModelScope.launch {
_uiState.update { it.copy(busy = true) }
val msg = uploadWavelogNow(preview, settingsRepo.wavelogUploadSettings.value)
_uiState.update { it.copy(busy = false, wavelogPreview = null, message = msg) }
}
}
fun dismissPreview() = _uiState.update { it.copy(preview = null) }
fun dismissWavelogPreview() = _uiState.update { it.copy(wavelogPreview = null) }
/**
* Uploads one prepared Wavelog batch (piggyback) and marks what the server
* accepted. Returns the operator-facing message segment for this upload.
* Uploads one prepared Wavelog batch and marks what the server accepted (stamped
* with the station profile the batch went out through). Returns the operator-facing
* message for this upload.
*/
private suspend fun uploadWavelogNow(
preview: WavelogUploadPreview,
@@ -366,7 +381,7 @@ class LogViewModel(
): String = try {
val outcome = wavelogUploadRepository.upload(preview, settings)
if (outcome is WavelogUploadOutcome.Imported && outcome.markIds.isNotEmpty()) {
qsoRepository.markWavelogUploaded(outcome.markIds)
qsoRepository.markWavelogUploaded(outcome.markIds, settings.stationId)
}
wavelogConfirmedSegment(outcome, preview)
} catch (_: Exception) {
@@ -380,7 +395,7 @@ class LogViewModel(
fun abandonForGridFix() {
lotwUploadRepository.discardPreview()
lastUploadedIds = emptyList()
_uiState.update { it.copy(uploadPositionWarning = null, preview = null, wavelogPending = null) }
_uiState.update { it.copy(uploadPositionWarning = null, preview = null) }
}
/** Recompute the log-page roaming hint from the freshest station/position data. */
@@ -44,6 +44,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -63,10 +64,15 @@ import com.rtbishop.look4sat.core.presentation.SharedDialog
fun LoTWUploadCard(
hasCertificate: Boolean,
stationGrid: String,
enabled: Boolean = true,
showUploadConfigDialog: () -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth().clickable { showUploadConfigDialog() }) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
ElevatedCard(modifier = Modifier.fillMaxWidth().clickable(enabled = enabled) { showUploadConfigDialog() }) {
Column(
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.alpha(if (enabled) 1f else 0.5f)
) {
Text(
text = stringResource(R.string.prefs_lotw_upload_title),
color = MaterialTheme.colorScheme.primary
@@ -81,6 +87,15 @@ fun LoTWUploadCard(
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)
if (!enabled) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.prefs_lotw_disabled_wavelog),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
}
}
}
}
@@ -200,6 +215,11 @@ fun LoTWUploadConfigDialog(
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Text(
text = stringResource(R.string.prefs_lotw_upload_password_hint),
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
CardButton(
onClick = {
val bytes = runCatching {
@@ -212,7 +232,9 @@ fun LoTWUploadConfigDialog(
}
},
text = stringResource(R.string.prefs_lotw_upload_import_confirm),
isEnabled = password.isNotBlank() && !busy,
// An empty field is a legitimate attempt: certificates exported
// without a password import with a blank password.
isEnabled = !busy,
modifier = Modifier.fillMaxWidth()
)
}
@@ -13,6 +13,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -23,6 +24,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -54,6 +57,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
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.SwipeController
@@ -105,8 +109,14 @@ fun LogbookDialog(
onToggleSelection: (Long) -> Unit = {},
onExitSelection: () -> Unit = {},
onResubmitSelected: () -> Unit = {},
/** Submit count of the Wavelog batch riding this upload (0 hides the note). */
wavelogCount: Int = 0
/** Wavelog mode: the prepared batch awaiting confirmation (null otherwise). */
wavelogPreview: com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview? = null,
/** True when uploads route through Wavelog — titles and previews follow the mode. */
wavelogMode: Boolean = false,
/** Wavelog station profiles for the 台址 selector (empty hides the selector). */
stationOptions: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList(),
stationFilter: String? = null,
onStationFilterChange: (String?) -> Unit = {}
) {
val swipeController = rememberSwipeController()
// Entering selection mode closes any row left swiped open — reveals are off there.
@@ -147,6 +157,51 @@ fun LogbookDialog(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (stationOptions.isNotEmpty()) {
// 台址 selector: filters the list to one Wavelog station profile
// (its QSOs) — "All" keeps everything. The menu scrolls natively.
var stationMenuOpen by remember { mutableStateOf(false) }
val currentLabel = stationOptions.firstOrNull { it.id == stationFilter }?.let { station ->
listOf(station.name, station.callsign).filter { it.isNotBlank() }.joinToString(" · ")
} ?: stringResource(R.string.prefs_logbook_station_all)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(stringResource(R.string.prefs_logbook_station_label), fontSize = 12.sp)
Box {
TextButton(onClick = { stationMenuOpen = true }) {
Text(currentLabel, fontSize = 12.sp)
}
DropdownMenu(
expanded = stationMenuOpen,
onDismissRequest = { stationMenuOpen = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.prefs_logbook_station_all)) },
onClick = {
onStationFilterChange(null)
stationMenuOpen = false
}
)
stationOptions.forEach { station ->
DropdownMenuItem(
text = {
Text(
listOf(station.name, station.callsign, station.grid)
.filter { it.isNotBlank() }.joinToString(" · ")
)
},
onClick = {
onStationFilterChange(station.id)
stationMenuOpen = false
}
)
}
}
}
}
}
LazyColumn(
modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
@@ -179,11 +234,17 @@ fun LogbookDialog(
onFixStation = { onFixGrid(listOf(positionWarning.currentGrid)) },
onIgnore = onIgnorePositionWarning
)
} else if (wavelogPreview != null) {
WavelogUploadPreviewDialog(
preview = wavelogPreview,
busy = uploadBusy,
onConfirm = onConfirmUpload,
onDismiss = onDismissPreview
)
} else if (preview != null) {
LogbookUploadPreviewDialog(
preview = preview,
busy = uploadBusy,
wavelogCount = wavelogCount,
onConfirm = onConfirmUpload,
onDismiss = onDismissPreview
)
@@ -193,7 +254,7 @@ fun LogbookDialog(
onDismissRequest = onDismissMessage,
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle("LoTW Upload") },
title = { SheetDialogTitle(if (wavelogMode) "Wavelog Upload" else "LoTW Upload") },
text = { Text(uploadMessage) },
confirmButton = {
TextButton(onClick = onDismissMessage) { Text("OK") }
@@ -218,7 +279,6 @@ fun LogbookDialog(
private fun LogbookUploadPreviewDialog(
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
busy: Boolean,
wavelogCount: Int,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
@@ -259,13 +319,6 @@ 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)
}
},
@@ -28,6 +28,8 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -39,6 +41,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
@@ -46,6 +49,7 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
@@ -69,6 +73,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -158,72 +163,6 @@ fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Uni
}
}
@Composable
fun WavelogDialog(
initialSettings: com.rtbishop.look4sat.core.domain.model.WavelogSettings,
workedGridsCount: Int,
isSyncing: Boolean,
message: String?,
dismiss: () -> Unit,
onSave: (com.rtbishop.look4sat.core.domain.model.WavelogSettings) -> Unit,
onSync: (com.rtbishop.look4sat.core.domain.model.WavelogSettings) -> Unit
) {
val url = rememberSaveable { mutableStateOf(initialSettings.url) }
val token = rememberSaveable { mutableStateOf(initialSettings.token) }
SharedDialog(
title = stringResource(R.string.prefs_wavelog_title),
onCancel = dismiss,
onAccept = {
onSave(com.rtbishop.look4sat.core.domain.model.WavelogSettings(url.value, token.value))
dismiss()
}
) {
OutlinedTextField(
value = url.value,
onValueChange = { url.value = it },
label = { Text(text = stringResource(id = R.string.prefs_wavelog_url)) },
placeholder = { Text(text = "http://192.168.1.10") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
OutlinedTextField(
value = token.value,
onValueChange = { token.value = it },
label = { Text(text = stringResource(id = R.string.prefs_wavelog_token)) },
placeholder = { Text(text = "abcdef123456:1") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
Text(
text = stringResource(R.string.prefs_wavelog_hint, workedGridsCount),
style = MaterialTheme.typography.bodySmall,
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)
)
}
// Sync uses the values typed in the fields directly — saving and syncing
// happen in one step, no need to close and reopen the dialog.
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
) {
TextButton(
onClick = { onSync(com.rtbishop.look4sat.core.domain.model.WavelogSettings(url.value, token.value)) },
enabled = !isSyncing
) {
Text(text = if (isSyncing) stringResource(R.string.prefs_wavelog_syncing)
else stringResource(R.string.prefs_wavelog_sync))
}
}
Spacer(modifier = Modifier.height(0.dp))
}
}
@Composable
fun WavelogUploadDialog(
@@ -231,14 +170,20 @@ fun WavelogUploadDialog(
stations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo>,
rights: String,
probeBusy: Boolean,
isSyncing: Boolean,
lastSyncEpochMs: Long,
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
onSelectStation: (com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo) -> Unit,
onSyncIncremental: () -> Unit,
onSyncFull: () -> Unit,
onClear: () -> Unit
) {
val url = rememberSaveable { mutableStateOf(initialSettings.url) }
val apiKey = rememberSaveable { mutableStateOf(initialSettings.apiKey) }
var showClearConfirm by remember { mutableStateOf(false) }
SharedDialog(
title = stringResource(R.string.prefs_wavelog_upload_title),
onCancel = dismiss,
@@ -293,18 +238,27 @@ fun WavelogUploadDialog(
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)
)
// Many station profiles must stay reachable: the list scrolls inside a
// bounded height instead of stretching (or clipping) the dialog.
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState())
) {
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()) {
@@ -319,6 +273,43 @@ fun WavelogUploadDialog(
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
if (lastSyncEpochMs != 0L) {
val timePattern = stringResource(R.string.prefs_updated_time)
val lastSyncText = remember(lastSyncEpochMs) {
java.text.SimpleDateFormat(timePattern, java.util.Locale.getDefault())
.format(java.util.Date(lastSyncEpochMs))
}
Text(
text = stringResource(R.string.prefs_updated_title, lastSyncText),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
// Sync pulls the server's QSOs (and the grid data the map highlights) — the
// incremental button resumes from the stored cursor, the full one re-pulls.
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
) {
CardButton(
onClick = onSyncFull,
text = stringResource(R.string.prefs_wavelog_sync_full),
isEnabled = !isSyncing && !probeBusy
)
CardButton(
onClick = onSyncIncremental,
text = stringResource(R.string.prefs_wavelog_sync_incremental),
isEnabled = !isSyncing && !probeBusy
)
}
if (isSyncing) {
Text(
text = stringResource(R.string.prefs_wavelog_syncing),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
}
if (message != null) {
Text(
text = message,
@@ -332,8 +323,49 @@ fun WavelogUploadDialog(
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
)
TextButton(
onClick = { showClearConfirm = true },
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
) {
Text(text = stringResource(R.string.prefs_wavelog_clear), color = MaterialTheme.colorScheme.error)
}
Spacer(modifier = Modifier.height(0.dp))
}
if (showClearConfirm) {
AlertDialog(
onDismissRequest = { showClearConfirm = false },
title = { Text(stringResource(R.string.prefs_wavelog_clear_title)) },
text = { Text(stringResource(R.string.prefs_wavelog_clear_message)) },
confirmButton = {
TextButton(onClick = {
showClearConfirm = false
onClear()
}) { Text(stringResource(R.string.btn_accept)) }
},
dismissButton = {
TextButton(onClick = { showClearConfirm = false }) { Text(stringResource(R.string.btn_cancel)) }
}
)
}
}
/** LoTW→Wavelog hand-over guard: shown when completing the Wavelog config would switch the mode. */
@Composable
fun WavelogSwitchWarningDialog(
onConfirm: () -> Unit,
onCancel: () -> Unit
) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text(stringResource(R.string.prefs_wavelog_switch_title)) },
text = { Text(stringResource(R.string.prefs_wavelog_switch_message)) },
confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.prefs_wavelog_switch_continue)) }
},
dismissButton = {
TextButton(onClick = onCancel) { Text(stringResource(R.string.btn_cancel)) }
}
)
}
@Composable
@@ -54,6 +54,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
@@ -69,11 +70,12 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
import com.rtbishop.look4sat.core.domain.repository.WavelogSyncMode
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.IconCard
@@ -286,8 +288,9 @@ private fun SettingsScreen(
)
}
if (dialogs.logbook) {
val stationFilter = uiState.logbookStationFilter
LogbookDialog(
records = uiState.logbookRecords,
records = uiState.logbookRecords.filter { stationFilter == null || it.wavelogStation == stationFilter },
satelliteCandidates = uiState.satelliteCatalog,
uploadBusy = uiState.logbookUploadBusy,
uploadMessage = uiState.logbookUploadMessage,
@@ -319,7 +322,11 @@ private fun SettingsScreen(
onToggleSelection = { onAction(SettingsAction.ToggleLogbookSelection(it)) },
onExitSelection = { onAction(SettingsAction.ExitLogbookSelection) },
onResubmitSelected = { onAction(SettingsAction.ResubmitSelectedLogbook) },
wavelogCount = uiState.wavelogPending?.count ?: 0
wavelogPreview = uiState.logbookWavelogPreview,
wavelogMode = uiState.wavelogUploadSettings.isReady,
stationOptions = if (uiState.wavelogUploadSettings.isReady) uiState.wavelogUploadStations else emptyList(),
stationFilter = uiState.logbookStationFilter,
onStationFilterChange = { onAction(SettingsAction.SetLogbookStationFilter(it)) }
)
}
if (dialogs.lotwUpload) {
@@ -347,11 +354,22 @@ private fun SettingsScreen(
stations = uiState.wavelogUploadStations,
rights = uiState.wavelogUploadRights,
probeBusy = uiState.wavelogUploadProbeBusy,
isSyncing = uiState.wavelogSyncing,
lastSyncEpochMs = uiState.wavelogLastSyncEpochMs,
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)) }
onSelectStation = { onAction(SettingsAction.SelectWavelogUploadStation(it)) },
onSyncIncremental = { onAction(SettingsAction.SyncWavelog(WavelogSyncMode.Incremental)) },
onSyncFull = { onAction(SettingsAction.SyncWavelog(WavelogSyncMode.Full)) },
onClear = { onAction(SettingsAction.ClearWavelogConfig) }
)
}
if (uiState.wavelogSwitchWarning) {
WavelogSwitchWarningDialog(
onConfirm = { onAction(SettingsAction.ConfirmWavelogSwitch) },
onCancel = { onAction(SettingsAction.CancelWavelogSwitch) }
)
}
@@ -423,6 +441,7 @@ private fun SettingsScreen(
}
) { _ ->
val isVerticalLayout = isVerticalLayout()
val wavelogMode = uiState.wavelogUploadSettings.isReady
LazyVerticalGrid(
columns = GridCells.Fixed(if (isVerticalLayout) 1 else 2),
horizontalArrangement = Arrangement.spacedBy(6.dp),
@@ -465,6 +484,7 @@ private fun SettingsScreen(
LoTWUploadCard(
hasCertificate = uiState.lotwCertificate != null,
stationGrid = uiState.lotwStation?.grid.orEmpty(),
enabled = !wavelogMode,
showUploadConfigDialog = { onAction(SettingsAction.LoadLoTWUploadStatus); dialogs.lotwUpload = true }
)
}
@@ -473,12 +493,14 @@ private fun SettingsScreen(
settings = uiState.lotwSettings,
workedGridsCount = uiState.workedGridsCount,
lastSyncEpochMs = uiState.lotwLastSyncEpochMs,
enabled = !wavelogMode,
showLoTWDialog = { dialogs.lotw = true }
)
}
item {
WavelogUploadCard(
settings = uiState.wavelogUploadSettings,
lastSyncEpochMs = uiState.wavelogLastSyncEpochMs,
showDialog = { dialogs.wavelogUpload = true }
)
}
@@ -486,6 +508,7 @@ private fun SettingsScreen(
OtherCard(
settings = uiState.otherSettings,
onCompassCalibration = { dialogs.compassCalibration = true },
loTWEnabled = !wavelogMode,
onAction = onAction
)
}
@@ -710,6 +733,7 @@ private fun MapSettingsCard(onClick: () -> Unit) {
private fun OtherCard(
settings: OtherSettings,
onCompassCalibration: () -> Unit,
loTWEnabled: Boolean = true,
onAction: (SettingsAction) -> Unit
) {
ElevatedCard(
@@ -728,7 +752,11 @@ private fun OtherCard(
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
onAction(SettingsAction.ToggleUpdate(it))
}
SwitchRow(R.string.prefs_other_switch_lotw_sync, settings.stateOfAutoLotwSync) {
SwitchRow(
R.string.prefs_other_switch_lotw_sync,
settings.stateOfAutoLotwSync,
enabled = loTWEnabled
) {
onAction(SettingsAction.ToggleAutoLotwSync(it))
}
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
@@ -868,10 +896,15 @@ private fun LoTWCard(
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
workedGridsCount: Int,
lastSyncEpochMs: Long,
enabled: Boolean = true,
showLoTWDialog: () -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Column(
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.alpha(if (enabled) 1f else 0.5f)
) {
Text(
text = stringResource(id = R.string.prefs_lotw_title),
color = MaterialTheme.colorScheme.primary
@@ -894,10 +927,20 @@ private fun LoTWCard(
style = MaterialTheme.typography.bodySmall
)
}
if (!enabled) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.prefs_lotw_disabled_wavelog),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
}
Spacer(modifier = Modifier.height(4.dp))
CardButton(
onClick = showLoTWDialog,
text = stringResource(id = R.string.prefs_wavelog_configure),
isEnabled = enabled,
modifier = Modifier.fillMaxWidth()
)
}
@@ -907,6 +950,7 @@ private fun LoTWCard(
@Composable
private fun WavelogUploadCard(
settings: com.rtbishop.look4sat.core.domain.model.WavelogUploadSettings,
lastSyncEpochMs: Long,
showDialog: () -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
@@ -916,6 +960,13 @@ private fun WavelogUploadCard(
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.prefs_wavelog_exclusive),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = if (settings.isReady) {
stringResource(
@@ -929,6 +980,13 @@ private fun WavelogUploadCard(
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)
if (lastSyncEpochMs != 0L) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = formatUpdateTime(updateTime = lastSyncEpochMs),
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(4.dp))
CardButton(
onClick = showDialog,
@@ -22,7 +22,6 @@ import com.rtbishop.look4sat.core.domain.model.LatestRelease
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
@@ -58,20 +57,25 @@ data class SettingsState(
val radioControlSettings: RadioControlSettings,
val dataSourcesSettings: DataSourcesSettings,
val dataSourcesStatus: Map<String, Int> = emptyMap(),
val wavelogSettings: WavelogSettings = WavelogSettings(),
val workedGridsCount: Int = 0,
val wavelogSyncing: Boolean = false,
val wavelogMessage: String? = null,
/** Wavelog upload: its own config block + probe/upload transient state. */
/** Wavelog configuration (one block; LoTW and Wavelog are mutually exclusive). */
val wavelogUploadSettings: WavelogUploadSettings = WavelogUploadSettings(),
val wavelogUploadStations: List<com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo> = emptyList(),
val wavelogUploadRights: String = "",
val wavelogUploadProbeBusy: 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. */
/** Config-block feedback (probe failures, sync results); also shown inside the config dialog. */
val wavelogUploadMessage: String? = null,
/** Wavelog sync (download of QSOs + grids). */
val wavelogSyncing: Boolean = false,
/** Epoch ms of the last successful Wavelog sync (0 = never). */
val wavelogLastSyncEpochMs: Long = 0L,
/** Mode-switch guard: a change that would hand the logbook over to Wavelog is
* waiting for the operator's confirmation (LoTW is configured). */
val wavelogSwitchWarning: Boolean = false,
/** Logbook 台址 selector: selected Wavelog station id (null = 全部). */
val logbookStationFilter: String? = null,
/** Wavelog-mode logbook upload: prepared batch awaiting confirmation. */
val logbookWavelogPreview: com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview? = 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,
@@ -157,14 +161,20 @@ sealed interface SettingsAction {
// Data sources
data class UpdateDataSources(val settings: DataSourcesSettings) : SettingsAction
// Wavelog worked grids
data class UpdateWavelog(val settings: WavelogSettings) : SettingsAction
data class SyncWorkedGrids(val settings: WavelogSettings) : SettingsAction
// Wavelog upload (separate config block + upload flow)
// Wavelog configuration (single block; LoTW and Wavelog are mutually exclusive)
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
/** Download QSOs (+ grids) from Wavelog. */
data class SyncWavelog(val mode: com.rtbishop.look4sat.core.domain.repository.WavelogSyncMode) : SettingsAction
/** Confirmed the LoTW→Wavelog mode switch warning. */
data object ConfirmWavelogSwitch : SettingsAction
/** Declined the switch warning — nothing changes. */
data object CancelWavelogSwitch : SettingsAction
/** Wipe the Wavelog config (back to LoTW mode). */
data object ClearWavelogConfig : SettingsAction
/** Logbook 台址 selector choice (null = 全部). */
data class SetLogbookStationFilter(val stationId: String?) : SettingsAction
// LoTW confirmed grids
data class UpdateLoTW(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction
@@ -23,7 +23,6 @@ import androidx.lifecycle.viewModelScope
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
@@ -36,8 +35,12 @@ import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
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.IWavelogSyncRepository
import com.rtbishop.look4sat.core.domain.repository.WavelogSyncFetch
import com.rtbishop.look4sat.core.domain.repository.WavelogSyncMode
import com.rtbishop.look4sat.core.domain.repository.applyWavelogSyncResult
import com.rtbishop.look4sat.core.domain.repository.resolveWavelogSyncMode
import com.rtbishop.look4sat.core.domain.repository.WavelogStationInfo
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadOutcome
import com.rtbishop.look4sat.core.domain.repository.WavelogUploadPreview
@@ -62,7 +65,7 @@ class SettingsViewModel(
private val databaseRepo: IDatabaseRepo,
private val settingsRepo: ISettingsRepo,
private val updateRepo: IUpdateRepository,
private val wavelogRepo: IWavelogRepository,
private val wavelogSyncRepository: IWavelogSyncRepository,
private val wavelogUploadRepository: IWavelogUploadRepository,
private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository,
private val qsoRepository: com.rtbishop.look4sat.core.domain.logbook.IQsoRepository,
@@ -88,8 +91,9 @@ class SettingsViewModel(
radioControlSettings = settingsRepo.radioControlSettings.value,
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
wavelogSettings = settingsRepo.wavelogSettings.value,
wavelogUploadSettings = settingsRepo.wavelogUploadSettings.value,
wavelogUploadStations = settingsRepo.getWavelogStations(),
wavelogLastSyncEpochMs = settingsRepo.getLastWavelogSyncEpochMs(),
workedGridsCount = settingsRepo.getWorkedGrids().size,
compassAccuracy = sensorsRepo.compassAccuracy.value,
compassHeadingDegrees = correctedCompassHeading(),
@@ -162,11 +166,6 @@ class SettingsViewModel(
_uiState.update { it.copy(radioControlSettings = settings) }
}
}
viewModelScope.launch {
settingsRepo.wavelogSettings.collect { settings ->
_uiState.update { it.copy(wavelogSettings = settings) }
}
}
viewModelScope.launch {
settingsRepo.wavelogUploadSettings.collect { settings ->
_uiState.update { it.copy(wavelogUploadSettings = settings) }
@@ -238,13 +237,15 @@ class SettingsViewModel(
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
// Wavelog worked grids
is SettingsAction.UpdateWavelog -> settingsRepo.updateWavelogSettings(action.settings)
is SettingsAction.SyncWorkedGrids -> syncWorkedGrids(action.settings)
// Wavelog upload config (the upload itself rides the LoTW logbook flow)
is SettingsAction.UpdateWavelogUpload -> settingsRepo.updateWavelogUploadSettings(action.settings)
// Wavelog configuration (single block; LoTW and Wavelog are mutually exclusive)
is SettingsAction.UpdateWavelogUpload -> applyWavelogUploadSettings(action.settings)
is SettingsAction.FetchWavelogUploadStations -> fetchWavelogUploadStations(action.url, action.apiKey)
is SettingsAction.SelectWavelogUploadStation -> selectWavelogUploadStation(action.station)
is SettingsAction.SelectWavelogUploadStation -> applyWavelogUploadStation(action.station)
is SettingsAction.SyncWavelog -> syncWavelog(action.mode)
SettingsAction.ConfirmWavelogSwitch -> confirmWavelogSwitch()
SettingsAction.CancelWavelogSwitch -> cancelWavelogSwitch()
SettingsAction.ClearWavelogConfig -> clearWavelogConfig()
is SettingsAction.SetLogbookStationFilter -> _uiState.update { it.copy(logbookStationFilter = action.stationId) }
// LoTW confirmed grids
is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode)
@@ -277,30 +278,62 @@ class SettingsViewModel(
}
}
// region Wavelog worked grids
// Wavelog configuration + sync
private fun syncWorkedGrids(settings: WavelogSettings) {
if (!settings.isConfigured) {
_uiState.update { it.copy(wavelogMessage = "Wavelog URL/token not configured") }
/** A switch that needs the LoTW→Wavelog hand-over confirmation before it applies. */
private var pendingWavelogSwitch: (() -> Unit)? = null
private fun currentLoTWConfigured(): Boolean =
settingsRepo.lotwSettings.value.isConfigured || _uiState.value.lotwCertificate != null
/**
* Applies a Wavelog config change. Completing the configuration (the block turning
* ready) while LoTW is configured pauses for the operator's confirmation: from then
* on uploads and syncs go through Wavelog only and the LoTW sides stay unavailable
* (the one-of-two rule). A half-filled config changes nothing about the routing.
*/
private fun applyWavelogUploadSettings(settings: WavelogUploadSettings) {
val current = settingsRepo.wavelogUploadSettings.value
if (!current.isReady && settings.isReady && currentLoTWConfigured()) {
pendingWavelogSwitch = { settingsRepo.updateWavelogUploadSettings(settings) }
_uiState.update { it.copy(wavelogSwitchWarning = true) }
return
}
// Persist the credentials first, then sync with the freshly-typed values.
settingsRepo.updateWavelogSettings(settings)
_uiState.update { it.copy(wavelogSyncing = true, wavelogMessage = null) }
viewModelScope.launch {
val grids = wavelogRepo.fetchWorkedGrids(settings.url, settings.token)
_uiState.update {
if (grids != null) {
settingsRepo.setWorkedGrids(grids)
it.copy(wavelogSyncing = false, workedGridsCount = grids.size, wavelogMessage = null)
} else {
it.copy(wavelogSyncing = false, wavelogMessage = "Sync failed — check URL/token/network")
}
}
}
settingsRepo.updateWavelogUploadSettings(settings)
}
// Wavelog upload (separate config block + upload flow)
private fun applyWavelogUploadStation(station: WavelogStationInfo) {
val current = settingsRepo.wavelogUploadSettings.value
val updated = current.copy(
stationId = station.id,
stationName = station.name,
stationCallsign = station.callsign,
stationGrid = station.grid
)
if (!current.isReady && updated.isReady && currentLoTWConfigured()) {
pendingWavelogSwitch = { settingsRepo.updateWavelogUploadSettings(updated) }
_uiState.update { it.copy(wavelogSwitchWarning = true) }
return
}
settingsRepo.updateWavelogUploadSettings(updated)
}
private fun confirmWavelogSwitch() {
pendingWavelogSwitch?.invoke()
pendingWavelogSwitch = null
_uiState.update { it.copy(wavelogSwitchWarning = false) }
}
private fun cancelWavelogSwitch() {
pendingWavelogSwitch = null
_uiState.update { it.copy(wavelogSwitchWarning = false) }
}
/** Wipe the configuration — routing falls back to LoTW on the next check. */
private fun clearWavelogConfig() {
settingsRepo.updateWavelogUploadSettings(WavelogUploadSettings())
_uiState.update { it.copy(logbookStationFilter = null, logbookWavelogPreview = null) }
}
private fun fetchWavelogUploadStations(url: String, apiKey: String) {
if (_uiState.value.wavelogUploadProbeBusy) return
@@ -316,6 +349,9 @@ class SettingsViewModel(
}
return@launch
}
// Persist the full list: the logbook's 台址 selector and the sync use it,
// not just the dialog that fetched it.
settingsRepo.setWavelogStations(probe.stations)
_uiState.update {
it.copy(
wavelogUploadProbeBusy = false,
@@ -328,36 +364,87 @@ class SettingsViewModel(
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)
if (auto != null) applyWavelogUploadStation(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
)
)
/**
* Downloads QSOs from Wavelog and folds them into the logbook + map grid data.
* Incremental resumes from the per-station cursors; the full mode re-pulls each
* station from the start (healing anything a cursor missed) — neither ever deletes
* data the app already has.
*/
private fun syncWavelog(requested: WavelogSyncMode) {
if (_uiState.value.wavelogSyncing) return
val settings = settingsRepo.wavelogUploadSettings.value
if (!settings.isConfigured) {
_uiState.update { it.copy(wavelogUploadMessage = "Wavelog URL/key not configured") }
return
}
val stations = settingsRepo.getWavelogStations()
if (stations.isEmpty()) {
_uiState.update { it.copy(wavelogUploadMessage = "Fetch stations first — no station profile known") }
return
}
val mode = resolveWavelogSyncMode(settingsRepo.getWavelogSyncUrl(), settings.url, requested)
_uiState.update { it.copy(wavelogSyncing = true, wavelogUploadMessage = null) }
viewModelScope.launch {
try {
val fetch = wavelogSyncRepository.fetchNewRecords(
settings.url,
settings.apiKey,
stations,
settingsRepo.getWavelogSyncCursors(),
full = mode == WavelogSyncMode.Full
)
if (fetch == null) {
_uiState.update {
it.copy(wavelogSyncing = false, wavelogUploadMessage = "Wavelog sync failed — check URL/key/network")
}
return@launch
}
val merge = if (fetch.records.isEmpty()) {
com.rtbishop.look4sat.core.domain.logbook.AdifImportResult(0, 0, 0)
} else {
qsoRepository.mergeWavelog(fetch.records)
}
// Stations that failed keep their previous cursor; fetched ones advance.
val cursors = settingsRepo.getWavelogSyncCursors() + fetch.cursors
applyWavelogSyncResult(settingsRepo, fetch.records, cursors, settings.url)
_uiState.update {
it.copy(
wavelogSyncing = false,
workedGridsCount = settingsRepo.getWorkedGrids().size,
wavelogLastSyncEpochMs = settingsRepo.getLastWavelogSyncEpochMs(),
wavelogUploadMessage = wavelogSyncSummary(mode, fetch, merge, stations.size)
)
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (_: Exception) {
_uiState.update {
it.copy(wavelogSyncing = false, wavelogUploadMessage = "Wavelog sync failed — check URL/key/network")
}
}
}
}
/** Prepares the Wavelog batch riding the LoTW upload (null when Wavelog is not ready). */
private suspend fun prepareWavelogPiggyback(
records: List<com.rtbishop.look4sat.core.domain.logbook.QsoRecord>
): WavelogUploadPreview? {
val settings = settingsRepo.wavelogUploadSettings.value
if (!settings.isReady) return null
return runCatching {
wavelogUploadRepository.prepare(wavelogUploadCandidates(records), settings)
}.getOrNull()
private fun wavelogSyncSummary(
mode: WavelogSyncMode,
fetch: WavelogSyncFetch,
merge: com.rtbishop.look4sat.core.domain.logbook.AdifImportResult,
totalStations: Int
): String {
val head = if (mode == WavelogSyncMode.Full) "Full Wavelog sync" else "Incremental Wavelog sync"
val base = "$head — ${merge.imported} new, ${merge.skipped} already there"
return if (fetch.failedStations.isEmpty()) base
else "$base · ${fetch.failedStations.size}/$totalStations station(s) failed"
}
/**
* Uploads one prepared Wavelog batch (piggyback) and marks what the server
* accepted. Returns the operator-facing message segment for this upload.
* Uploads one prepared Wavelog batch and marks what the server accepted (stamped
* with the station profile the batch went out through). Returns the operator-facing
* message for this upload.
*/
private suspend fun uploadWavelogNow(
preview: WavelogUploadPreview,
@@ -365,7 +452,7 @@ class SettingsViewModel(
): String = try {
val outcome = wavelogUploadRepository.upload(preview, settings)
if (outcome is WavelogUploadOutcome.Imported && outcome.markIds.isNotEmpty()) {
qsoRepository.markWavelogUploaded(outcome.markIds)
qsoRepository.markWavelogUploaded(outcome.markIds, settings.stationId)
}
wavelogConfirmedSegment(outcome, preview)
} catch (_: Exception) {
@@ -477,13 +564,16 @@ class SettingsViewModel(
_uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") }
try {
val all = qsoRepository.records.first()
val wavelogSettings = settingsRepo.wavelogUploadSettings.value
if (wavelogSettings.isReady) {
// Wavelog mode: the logbook upload goes to Wavelog only.
prepareLogbookWavelogUpload(all, wavelogSettings)
return@launch
}
// Only local (non-confirmed) records are candidates for upload;
// 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(
@@ -493,17 +583,7 @@ class SettingsViewModel(
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
else -> "No pending QSOs to upload"
}
// 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")
)
}
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = msg) }
return@launch
}
val preview = lotwUploadRepository.prepare(pending, false)
@@ -514,7 +594,6 @@ class SettingsViewModel(
it.copy(
logbookUploadBusy = false,
logbookPreview = preview,
wavelogPending = wavelogPreview,
logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
)
}
@@ -526,7 +605,42 @@ class SettingsViewModel(
}
}
/** Wavelog-mode branch of [prepareLogbookUpload]: prepare the batch, then confirm. */
private suspend fun prepareLogbookWavelogUpload(
all: List<com.rtbishop.look4sat.core.domain.logbook.QsoRecord>,
settings: WavelogUploadSettings
) {
val preview = runCatching {
wavelogUploadRepository.prepare(wavelogUploadCandidates(all), settings)
}.getOrNull()
if (preview == null) {
_uiState.update {
it.copy(logbookUploadBusy = false, logbookUploadMessage = "Wavelog upload failed to prepare — check the configuration")
}
return
}
if (preview.count == 0) {
_uiState.update {
it.copy(
logbookUploadBusy = false,
logbookUploadMessage = wavelogIdleSegment(preview) ?: "No pending QSOs to upload to Wavelog"
)
}
return
}
_uiState.update { it.copy(logbookUploadBusy = false, logbookWavelogPreview = preview) }
}
private fun confirmLogbookUpload() {
val wavelogPreview = _uiState.value.logbookWavelogPreview
if (wavelogPreview != null) {
viewModelScope.launch {
_uiState.update { it.copy(logbookUploadBusy = true) }
val msg = uploadWavelogNow(wavelogPreview, settingsRepo.wavelogUploadSettings.value)
_uiState.update { it.copy(logbookUploadBusy = false, logbookWavelogPreview = null, logbookUploadMessage = msg) }
}
return
}
val preview = _uiState.value.logbookPreview ?: return
viewModelScope.launch {
_uiState.update { it.copy(logbookUploadBusy = true) }
@@ -542,19 +656,11 @@ 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,
wavelogPending = null,
logbookUploadMessage = listOfNotNull(msg, wavelogMsg).joinToString("\n"),
logbookUploadMessage = msg,
// 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,
@@ -564,7 +670,7 @@ class SettingsViewModel(
}
}
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null, wavelogPending = null) }
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null, logbookWavelogPreview = null) }
/** Operator chose "ignore" on the position check: keep the prepared preview. */
private fun ignoreLogbookPositionWarning() = _uiState.update { it.copy(logbookPositionWarning = null) }
@@ -573,7 +679,7 @@ class SettingsViewModel(
private fun abandonLogbookForGridFix() {
lotwUploadRepository.discardPreview()
lastLogbookUploadIds = emptyList()
_uiState.update { it.copy(logbookPositionWarning = null, logbookPreview = null, wavelogPending = null) }
_uiState.update { it.copy(logbookPositionWarning = null, logbookPreview = null) }
}
/** Long-press entry: selection mode with the pressed record checked. */
@@ -601,6 +707,39 @@ class SettingsViewModel(
_uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") }
try {
val all = qsoRepository.records.first()
val wavelogSettings = settingsRepo.wavelogUploadSettings.value
if (wavelogSettings.isReady) {
// Wavelog mode: re-send the checked records through Wavelog (the
// server dedupes what is already there).
val selected = all.filter {
it.id in _uiState.value.logbookSelectedIds &&
it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE
}
if (selected.isEmpty()) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No complete QSOs in the selection") }
return@launch
}
val preview = runCatching {
wavelogUploadRepository.prepare(selected, wavelogSettings)
}.getOrNull()
if (preview == null) {
_uiState.update {
it.copy(logbookUploadBusy = false, logbookUploadMessage = "Wavelog upload failed to prepare — check the configuration")
}
return@launch
}
if (preview.count == 0) {
_uiState.update {
it.copy(
logbookUploadBusy = false,
logbookUploadMessage = wavelogIdleSegment(preview) ?: "No QSOs to resubmit"
)
}
return@launch
}
_uiState.update { it.copy(logbookUploadBusy = false, logbookWavelogPreview = preview) }
return@launch
}
val selected = resubmitCandidates(all, _uiState.value.logbookSelectedIds)
if (selected.isEmpty()) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No complete QSOs in the selection") }
@@ -617,13 +756,10 @@ 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)
)
}
@@ -821,7 +957,7 @@ class SettingsViewModel(
databaseRepo = container.databaseRepo,
settingsRepo = container.settingsRepo,
updateRepo = container.updateRepo,
wavelogRepo = container.wavelogRepo,
wavelogSyncRepository = container.wavelogSyncRepository,
wavelogUploadRepository = container.wavelogUploadRepository,
lotwRepo = container.lotwRepo,
qsoRepository = container.qsoRepository,