mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
feat(logbook): LoTW 日志本官方名统一、上传候选集、编辑已传记录新建、记录分钟/kHz 规范化
This commit is contained in:
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"
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user