mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-04 19:47:22 +00:00
feat(logbook): Room persistence, UP/QSL markers, one-click upload, swipe-delete, calculator-frequency logging
This commit is contained in:
1 parent
3be2e4b9e8
commit
a769f7dea7
18 files changed
+454
-50
No files matched your search
@@ -34,6 +34,8 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
@@ -44,6 +46,9 @@ 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.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.presentation.EmptyListCard
|
||||
|
||||
@@ -57,12 +62,15 @@ fun LogPage(
|
||||
) {
|
||||
val logUiState by logViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val records by logViewModel.records.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val swipeController = rememberSwipeController()
|
||||
|
||||
val selectedRadio = remember(uiState.transceivers.transmitters, uiState.transceivers.selectedUuid) {
|
||||
uiState.transceivers.transmitters.firstOrNull { it.uuid == uiState.transceivers.selectedUuid }
|
||||
}
|
||||
val isLinear = selectedRadio != null &&
|
||||
DopplerFrequencyCalculator.isNamedLinearTransponder(selectedRadio)
|
||||
// Linear if the satellite carries ANY named linear transponder — same test
|
||||
// that gates the Calculator tab (RadarScreen), so the mode selector and the
|
||||
// calculator always appear together regardless of which entry is selected.
|
||||
val isLinear = uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
|
||||
val catnum = uiState.currentPass?.catNum ?: selectedRadio?.catnum ?: 0
|
||||
val satName = uiState.currentPass?.name?.trim().orEmpty()
|
||||
|
||||
@@ -70,12 +78,12 @@ fun LogPage(
|
||||
if (catnum != 0) logViewModel.selectSatellite(catnum)
|
||||
}
|
||||
|
||||
val txHz = remember(selectedRadio) {
|
||||
val txHz = uiState.calculatorTxHz ?: remember(selectedRadio) {
|
||||
selectedRadio?.uplinkLow?.let { low ->
|
||||
selectedRadio.uplinkHigh?.let { high -> (low + high) / 2 } ?: low
|
||||
}
|
||||
}
|
||||
val rxHz = remember(selectedRadio) {
|
||||
val rxHz = uiState.calculatorRxHz ?: remember(selectedRadio) {
|
||||
selectedRadio?.downlinkLow?.let { low ->
|
||||
selectedRadio.downlinkHigh?.let { high -> (low + high) / 2 } ?: low
|
||||
}
|
||||
@@ -127,7 +135,7 @@ fun LogPage(
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
items(recent, key = { it.id }) { record ->
|
||||
LogRecordRow(record, onDelete = { logViewModel.delete(record.id) })
|
||||
LogRecordRow(record, swipeController, onDelete = { logViewModel.delete(record.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,23 +203,41 @@ fun LogPage(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogRecordRow(record: QsoRecord, onDelete: () -> Unit) {
|
||||
private fun LogRecordRow(record: QsoRecord, swipeController: SwipeController, onDelete: () -> Unit) {
|
||||
val time = remember(record.startUtcMillis) {
|
||||
java.text.SimpleDateFormat("HH:mm'Z'", java.util.Locale.US).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}.format(java.util.Date(record.startUtcMillis))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable { onDelete() }.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
// 右划露出删除按钮(短信式);整行点击不再删除。
|
||||
SwipeRevealRow(
|
||||
key = record.id.toString(),
|
||||
controller = swipeController,
|
||||
revealAction = onDelete,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "$time ${record.theirCallsign} ${record.displayMode}",
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
Text(text = if (record.lotwConfirmed) "✓" else "", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = "$time ", fontSize = 14.sp, maxLines = 1)
|
||||
Text(
|
||||
text = record.theirCallsign,
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = Color(0xFFFFE082),
|
||||
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)
|
||||
else -> Text("", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,10 @@ class LogViewModel(
|
||||
private val _uiState = MutableStateFlow(LogUiState())
|
||||
val uiState: StateFlow<LogUiState> = _uiState
|
||||
|
||||
// Record ids submitted in the most recent prepare → upload cycle, so a
|
||||
// successful POST can mark them "uploaded" (distinct from "confirmed").
|
||||
private var lastUploadedIds: List<Long> = emptyList()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val cert = lotwUploadRepository.certificate()
|
||||
@@ -160,6 +164,7 @@ class LogViewModel(
|
||||
return@launch
|
||||
}
|
||||
val preview = lotwUploadRepository.prepare(pending, false)
|
||||
lastUploadedIds = pending.map { it.id }
|
||||
_uiState.update { it.copy(busy = false, preview = preview) }
|
||||
} catch (e: LoTWOperationException) {
|
||||
_uiState.update { it.copy(busy = false, message = "Upload unavailable: ${e.reason}") }
|
||||
@@ -174,6 +179,11 @@ class LogViewModel(
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(busy = true) }
|
||||
val result = lotwUploadRepository.upload(preview.id)
|
||||
if (result is LoTWUploadResult.Accepted && lastUploadedIds.isNotEmpty()) {
|
||||
// Mark the submitted QSOs as uploaded (distinct from confirmed).
|
||||
qsoRepository.markUploaded(lastUploadedIds)
|
||||
lastUploadedIds = emptyList()
|
||||
}
|
||||
val msg = when (result) {
|
||||
is LoTWUploadResult.Accepted -> "Uploaded ${result.count} QSO(s) — accepted by LoTW"
|
||||
is LoTWUploadResult.Rejected -> "Rejected: ${result.message}"
|
||||
|
||||
@@ -70,7 +70,11 @@ data class RadarState(
|
||||
val mutualMaxElev: Double = 10.0,
|
||||
val mutualLabelA: String = "你",
|
||||
val mutualLabelB: String = "友台",
|
||||
val calculatorOffsetKHz: String = ""
|
||||
val calculatorOffsetKHz: String = "",
|
||||
/** Current calculator TX/RX frequencies (linear transponder), synced from the
|
||||
* Calculator tab so the Log tab records at the same frequencies. */
|
||||
val calculatorTxHz: Long? = null,
|
||||
val calculatorRxHz: Long? = null
|
||||
)
|
||||
|
||||
enum class SstvStatus { Idle, Recording }
|
||||
@@ -128,4 +132,5 @@ sealed interface RadarAction {
|
||||
|
||||
// Calculator actions
|
||||
data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction
|
||||
data class UpdateCalculatorFrequency(val txHz: Long?, val rxHz: Long?) : RadarAction
|
||||
}
|
||||
@@ -327,6 +327,12 @@ class RadarViewModel(
|
||||
}
|
||||
_uiState.update { it.copy(calculatorOffsetKHz = action.offsetKHz) }
|
||||
}
|
||||
is RadarAction.UpdateCalculatorFrequency -> {
|
||||
_uiState.update {
|
||||
if (it.calculatorTxHz == action.txHz && it.calculatorRxHz == action.rxHz) it
|
||||
else it.copy(calculatorTxHz = action.txHz, calculatorRxHz = action.rxHz)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -215,6 +216,7 @@ fun CalculatorPage(
|
||||
orbitalPos = orbitalPos,
|
||||
offsetKHz = calculatorOffsetKHz,
|
||||
onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) },
|
||||
onFrequencyChange = { tx, rx -> onAction(RadarAction.UpdateCalculatorFrequency(tx, rx)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
@@ -569,6 +571,7 @@ private fun DopplerFrequencyCalculator(
|
||||
orbitalPos: OrbitalPos?,
|
||||
offsetKHz: String = "",
|
||||
onOffsetChange: (String) -> Unit = {},
|
||||
onFrequencyChange: (Long, Long) -> Unit = { _, _ -> },
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
|
||||
@@ -579,6 +582,12 @@ private fun DopplerFrequencyCalculator(
|
||||
var passbandPosition by rememberSaveable(transponder.uuid) { mutableStateOf(0.5f) }
|
||||
var stepSizeKHz by remember { mutableIntStateOf(1) }
|
||||
|
||||
// Sync the calculator's current frequencies upward after every recomposition,
|
||||
// so the Log tab records at the same TX/RX the operator is listening on.
|
||||
SideEffect {
|
||||
if (txFrequencyHz != 0L && rxFrequencyHz != 0L) onFrequencyChange(txFrequencyHz, rxFrequencyHz)
|
||||
}
|
||||
|
||||
val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
|
||||
|
||||
val txLow = transponder.uplinkLow ?: return
|
||||
|
||||
Reference in new issue
Block a user