feat(logbook): LoTW 日志本官方名统一、上传候选集、编辑已传记录新建、记录分钟/kHz 规范化

This commit is contained in:
atsunatsu committed 2026-09-28 23:46:47 +08:00
1 parent 1871c05002
commit 5acf71b2a6
26 files changed
+2145 -98

No files matched your search

@@ -31,7 +31,9 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -46,13 +48,21 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.displayMode
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
import com.rtbishop.look4sat.core.presentation.SwipeController
import com.rtbishop.look4sat.core.presentation.SwipeRevealRow
import com.rtbishop.look4sat.core.presentation.rememberSwipeController
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.utility.downlinkHz
import com.rtbishop.look4sat.core.domain.utility.uplinkHz
import com.rtbishop.look4sat.core.domain.utility.voiceRepeater
import com.rtbishop.look4sat.core.presentation.EmptyListCard
private const val LOG_WINDOW_MS = 24 * 3_600_000L
import com.rtbishop.look4sat.core.presentation.QsoEditDialog
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
@Composable
fun LogPage(
@@ -63,6 +73,8 @@ fun LogPage(
val logUiState by logViewModel.uiState.collectAsStateWithLifecycle()
val records by logViewModel.records.collectAsStateWithLifecycle(initialValue = emptyList())
val swipeController = rememberSwipeController()
// Record currently open in the edit dialog (null when closed).
var editTarget by remember { mutableStateOf<QsoRecord?>(null) }
val selectedRadio = remember(uiState.transceivers.transmitters, uiState.transceivers.selectedUuid) {
uiState.transceivers.transmitters.firstOrNull { it.uuid == uiState.transceivers.selectedUuid }
@@ -73,25 +85,44 @@ fun LogPage(
val isLinear = uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
val catnum = uiState.currentPass?.catNum ?: selectedRadio?.catnum ?: 0
val satName = uiState.currentPass?.name?.trim().orEmpty()
// The satellite's FM voice repeater — the single-frequency transceiver the
// contact is actually made through. SatNOGS publishes its nominal pair, so a
// contact can never be logged without a frequency (which LoTW rejects as a
// missing BAND).
val voiceRepeater = remember(uiState.transceivers.transmitters) {
uiState.transceivers.transmitters.voiceRepeater()
}
// AMSAT Live FM satellites are always logged on that fixed nominal pair,
// even when the calculator happens to be tuned to something else.
val useRepeater = catnum in Sources.amSatFmCatnums
LaunchedEffect(catnum) {
if (catnum != 0) logViewModel.selectSatellite(catnum)
}
val txHz = uiState.calculatorTxHz ?: remember(selectedRadio) {
selectedRadio?.uplinkLow?.let { low ->
selectedRadio.uplinkHigh?.let { high -> (low + high) / 2 } ?: low
}
val selectedTxHz = remember(selectedRadio) { selectedRadio?.uplinkHz() }
val selectedRxHz = remember(selectedRadio) { selectedRadio?.downlinkHz() }
val repeaterTxHz = voiceRepeater?.uplinkHz()
val repeaterRxHz = voiceRepeater?.downlinkHz()
val txHz = if (useRepeater) {
repeaterTxHz ?: uiState.calculatorTxHz ?: selectedTxHz
} else {
uiState.calculatorTxHz ?: repeaterTxHz ?: selectedTxHz
}
val rxHz = uiState.calculatorRxHz ?: remember(selectedRadio) {
selectedRadio?.downlinkLow?.let { low ->
selectedRadio.downlinkHigh?.let { high -> (low + high) / 2 } ?: low
}
val rxHz = if (useRepeater) {
repeaterRxHz ?: uiState.calculatorRxHz ?: selectedRxHz
} else {
uiState.calculatorRxHz ?: repeaterRxHz ?: selectedRxHz
}
val mode = if (isLinear) logUiState.selectedMode.ifBlank { "CW" } else "FM"
val now = System.currentTimeMillis()
val satIdentity = remember(satName) { satelliteIdentity(satName) }
val passWindow = remember(uiState.currentPass) {
uiState.currentPass?.let { it.aosTime..it.losTime }
}
val recent = records
.filter { it.satelliteName.trim().equals(satName, true) && it.startUtcMillis > now - LOG_WINDOW_MS }
.filter { satelliteIdentity(it.satelliteName) == satIdentity }
// Only contacts inside the current pass window: the log page is the "this pass" sheet.
.filter { passWindow?.contains(it.startUtcMillis) == true }
.sortedByDescending { it.startUtcMillis }
val maxElev = uiState.currentPass?.maxElevation ?: 0.0
@@ -135,7 +166,12 @@ fun LogPage(
} else {
LazyColumn(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
items(recent, key = { it.id }) { record ->
LogRecordRow(record, swipeController, onDelete = { logViewModel.delete(record.id) })
LogRecordRow(
record = record,
swipeController = swipeController,
onDelete = { logViewModel.delete(record.id) },
onClick = { editTarget = record }
)
}
}
}
@@ -193,17 +229,36 @@ fun LogPage(
if (logUiState.message.isNotBlank()) {
AlertDialog(
onDismissRequest = logViewModel::clearMessage,
title = { Text("LoTW Upload") },
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle("LoTW Upload") },
text = { Text(logUiState.message) },
confirmButton = {
TextButton(onClick = logViewModel::clearMessage) { Text("OK") }
}
)
}
editTarget?.let { target ->
QsoEditDialog(
record = target,
satelliteCandidates = logUiState.satelliteCatalog,
onDismiss = { editTarget = null },
onSave = { updated ->
logViewModel.updateRecord(updated)
editTarget = null
}
)
}
}
@Composable
private fun LogRecordRow(record: QsoRecord, swipeController: SwipeController, onDelete: () -> Unit) {
private fun LogRecordRow(
record: QsoRecord,
swipeController: SwipeController,
onDelete: () -> Unit,
onClick: () -> Unit
) {
val time = remember(record.startUtcMillis) {
java.text.SimpleDateFormat("HH:mm'Z'", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
@@ -217,7 +272,10 @@ private fun LogRecordRow(record: QsoRecord, swipeController: SwipeController, on
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp),
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(horizontal = 4.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -227,14 +285,14 @@ private fun LogRecordRow(record: QsoRecord, swipeController: SwipeController, on
text = record.theirCallsign,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
color = Color(0xFFFFE082),
color = MaterialTheme.colorScheme.primary,
maxLines = 1
)
Text(text = " ${record.displayMode}", fontSize = 14.sp, maxLines = 1)
}
when {
record.lotwConfirmed -> Text("QSL", fontSize = 12.sp, color = Color(0xFFFFE082), fontFamily = FontFamily.Monospace)
record.lotwUploaded -> Text("UP", fontSize = 12.sp, color = Color(0xFFFFE082), fontFamily = FontFamily.Monospace)
record.lotwConfirmed -> Text("QSL", fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
record.lotwUploaded -> Text("UP", fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
else -> Text("", fontSize = 12.sp)
}
}
@@ -250,7 +308,9 @@ private fun PostDialog(
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("通联记录") },
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle("通联记录") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text, fontSize = 13.sp)
@@ -273,7 +333,9 @@ private fun UploadPreviewDialog(
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("上传到 LoTW") },
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle("上传到 LoTW") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp)
@@ -296,9 +358,21 @@ private fun UploadPreviewDialog(
}Z", fontSize = 13.sp)
if (preview.skipped > 0 || preview.unknownSkipped > 0 || preview.unavailableSkipped > 0) {
val parts = buildList {
if (preview.skipped > 0) add("${preview.skipped} already uploaded/duplicate")
// skipped counts every record left out of this batch: previously-uploaded
// ones, batch duplicates and unknown-outcome ones. Show them separately so
// "10 already uploaded" (historical records) is not read as this upload
// being rejected as a duplicate.
val alreadyUploaded = preview.skipped - preview.duplicateSkipped - preview.unknownSkipped
if (alreadyUploaded > 0) add("$alreadyUploaded already uploaded")
if (preview.duplicateSkipped > 0) add("${preview.duplicateSkipped} duplicate")
if (preview.unknownSkipped > 0) add("${preview.unknownSkipped} unknown result")
if (preview.unavailableSkipped > 0) add("${preview.unavailableSkipped} un-uploadable")
if (preview.unavailableSkipped > 0) add(
unavailableUploadSummary(
preview.unavailableSkipped,
preview.unavailableReasons,
duplicates = preview.duplicateSkipped
)
)
}
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
}
@@ -18,6 +18,9 @@ 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.officialSatelliteName
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
@@ -43,6 +46,8 @@ data class LogUiState(
val certificateCallsign: String = "",
/** Primary grid of the LoTW upload station location ("" when none). */
val stationGrid: String = "",
/** ARRL satellite names (config.tq6) — the only names a record may be signed with. */
val satelliteCatalog: List<String> = emptyList(),
val preview: LoTWUploadPreview? = null,
/** User-facing upload / record message ("" when none). */
val message: String = "",
@@ -56,6 +61,8 @@ class LogViewModel(
private val settingsRepo: ISettingsRepo
) : ViewModel() {
/** Records shown on the radar log page: all records of the current pass window, including
* already-uploaded (UP) and confirmed (QSL) ones. Window filtering happens in LogPage. */
val records: Flow<List<QsoRecord>> = qsoRepository.records
private val _uiState = MutableStateFlow(LogUiState())
@@ -69,10 +76,12 @@ class LogViewModel(
viewModelScope.launch {
val cert = lotwUploadRepository.certificate()
val station = lotwUploadRepository.station()
val catalog = runCatching { lotwUploadRepository.satelliteCatalog() }.getOrDefault(emptyList())
_uiState.update {
it.copy(
certificateCallsign = cert?.callsign.orEmpty(),
stationGrid = station?.grid.orEmpty()
stationGrid = station?.grid.orEmpty(),
satelliteCatalog = catalog
)
}
}
@@ -96,20 +105,27 @@ class LogViewModel(
val call = _uiState.value.callsignInput.trim()
if (call.isEmpty()) return
val normalizedMode = mode.ifBlank { "FM" }.uppercase(Locale.US)
val now = System.currentTimeMillis()
// Whole minutes (seconds zeroed): matches the edit dialog's HH:mm granularity and the
// minute-resolution of LoTW reports, so re-saving an unchanged record never looks edited.
val now = System.currentTimeMillis() / 60_000L * 60_000L
// kHz granularity (3 decimals in MHz) — enough for operating and for the ADIF export.
val tx = txHz?.let { (it + 500L) / 1000L * 1000L }
val rx = rxHz?.let { (it + 500L) / 1000L * 1000L }
val record = QsoRecord(
startUtcMillis = now,
endUtcMillis = now,
theirCallsign = call,
myCallsign = _uiState.value.certificateCallsign,
myGrid = myGrid.take(6).uppercase(Locale.US),
txFrequencyHz = txHz,
rxFrequencyHz = rxHz,
band = frequencyBand(txHz),
rxBand = frequencyBand(rxHz),
txFrequencyHz = tx,
rxFrequencyHz = rx,
band = frequencyBand(tx),
rxBand = frequencyBand(rx),
mode = if (normalizedMode == "FT4") "MFSK" else normalizedMode,
submode = normalizedMode.takeIf { it == "FT4" }.orEmpty(),
satelliteName = satName,
// Stored under the ARRL name (the tracker's "SAUDISAT 1C" is logged as "SO-50"):
// one name for the logbook, the ADIF export and the signed record.
satelliteName = officialSatelliteName(satName, _uiState.value.satelliteCatalog),
satelliteMode = normalizedMode,
status = QsoStatus.COMPLETE,
propagationMode = "SAT"
@@ -120,16 +136,25 @@ class LogViewModel(
fun delete(id: Long) = viewModelScope.launch { qsoRepository.delete(id) }
/**
* Persist an edited record (frequency/callsign/time/…). The repository keeps
* the LoTW confirmation state consistent when the contact identity changes.
*/
fun updateRecord(record: QsoRecord) = viewModelScope.launch { qsoRepository.save(record) }
/** English social post from the recorded QSOs of the current satellite (last 24h). */
fun generatePost(satName: String, maxElev: Double) {
viewModelScope.launch {
val now = System.currentTimeMillis()
// Records carry the ARRL name while the pass carries the tracker's name, so the
// operator's own contacts are collected by identity, not by the raw name.
val identity = satelliteIdentity(satName)
val list = qsoRepository.records.first()
.filter { it.satelliteName.trim().equals(satName.trim(), true) }
.filter { satelliteIdentity(it.satelliteName) == identity }
.filter { it.startUtcMillis > now - 24 * 3_600_000L }
.sortedBy { it.startUtcMillis }
if (list.isEmpty()) return@launch
val shortName = satName.substringBefore('(').trim().uppercase(Locale.US)
val shortName = officialSatelliteName(satName).substringBefore('(').trim().uppercase(Locale.US)
val utc = SimpleDateFormat("yyyyMMdd|HH:mm'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
@@ -140,7 +165,7 @@ class LogViewModel(
.joinToString("\n")
val currentGrid = settingsRepo.stationPosition.value.qthLocator.take(6)
val logLine = "📍 Log: ${_uiState.value.stationGrid.take(4)} | Current: $currentGrid"
val viaLine = "via Look4Sat — TNX de ${_uiState.value.certificateCallsign}"
val viaLine = "via Look4Sat-BA7OPF — TNX de ${_uiState.value.certificateCallsign}"
val tag = "#HamRadio #SatelliteQSO #" + shortName.filter { it.isLetterOrDigit() }
val text = listOf(firstLine, elLine, modeLines, "", logLine, viaLine, tag).joinToString("\n")
_uiState.update { it.copy(postText = text) }
@@ -157,11 +182,14 @@ class LogViewModel(
val all = qsoRepository.records.first()
// Only local (non-confirmed) records are candidates for upload;
// LoTW-imported confirmations are the feedback side.
val pending = all.filter { !it.lotwConfirmed && it.status == QsoStatus.COMPLETE }
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == QsoStatus.COMPLETE }
val audit = lotwUploadRepository.audit(pending)
if (audit.pending == 0) {
val msg = when {
audit.unavailable > 0 -> "${audit.unavailable} QSO(s) can't be uploaded (invalid call/date — check the logbook)"
audit.unavailable > 0 -> unavailableUploadSummary(
audit.unavailable, audit.reasons, audit.details, audit.duplicates, audit.incomplete
)
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
else -> "No pending QSOs to upload"
}