feat(logbook): Room persistence, UP/QSL markers, one-click upload, swipe-delete, calculator-frequency logging

This commit is contained in:
atsunatsu committed 2026-09-26 18:33:38 +08:00
1 parent 3be2e4b9e8
commit a769f7dea7
18 files changed
+454 -50

No files matched your search

@@ -44,4 +44,7 @@ interface QsoDao {
@Query("DELETE FROM qso_records WHERE id = :id")
suspend fun delete(id: Long)
@Query("UPDATE qso_records SET lotwUploaded = 1 WHERE id IN (:ids)")
suspend fun markUploaded(ids: List<Long>)
}
@@ -11,6 +11,8 @@ package com.rtbishop.look4sat.core.data.database
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
/**
@@ -18,7 +20,14 @@ import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
* upload receipts). Kept apart from [Look4SatDb] so existing installations
* never need a migration of the satellite database.
*/
@Database(entities = [QsoEntity::class], version = 1, exportSchema = false)
@Database(entities = [QsoEntity::class], version = 2, exportSchema = false)
abstract class QsoDatabase : RoomDatabase() {
abstract fun qsoDao(): QsoDao
}
/** v1 → v2: track whether a QSO was uploaded to LoTW (distinct from confirmed). */
val MIGRATION_QSO_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE qso_records ADD COLUMN lotwUploaded INTEGER NOT NULL DEFAULT 0")
}
}
@@ -43,6 +43,7 @@ data class QsoEntity(
@ColumnInfo(defaultValue = "''") val dedupeKey: String,
@ColumnInfo(defaultValue = "''") val propagationMode: String = "",
@ColumnInfo(defaultValue = "0") val lotwConfirmed: Boolean = false,
@ColumnInfo(defaultValue = "0") val lotwUploaded: Boolean = false,
@ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false,
@ColumnInfo(defaultValue = "''") val lotwQslDate: String = "",
@ColumnInfo(defaultValue = "''") val vuccGrids: String = "",
@@ -26,6 +26,7 @@ import androidx.room.Room
import com.rtbishop.look4sat.core.data.database.Look4SatDb
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_1_2
import com.rtbishop.look4sat.core.data.database.QsoDatabase
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
@@ -96,7 +97,9 @@ class MainContainer(private val context: Context) : IMainContainer {
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
override val qsoRepository: IQsoRepository by lazy {
val database = Room.databaseBuilder(context, QsoDatabase::class.java, "Look4SatQsoDB").build()
val database = Room.databaseBuilder(context, QsoDatabase::class.java, "Look4SatQsoDB")
.addMigrations(MIGRATION_QSO_1_2)
.build()
QsoRepository(database.qsoDao(), Dispatchers.IO)
}
override val lotwUploadRepository: ILoTWUploadRepository by lazy { LoTWUploadRepository(context) }
@@ -57,6 +57,11 @@ class QsoRepository(
override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } }
override suspend fun markUploaded(ids: List<Long>) = withContext(dispatcher) {
if (ids.isEmpty()) return@withContext
dao.markUploaded(ids)
}
override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
val records = dao.getAll()
.asSequence()
@@ -143,6 +148,7 @@ private fun QsoEntity.toDomain() = QsoRecord(
status = runCatching { QsoStatus.valueOf(status) }.getOrDefault(QsoStatus.DRAFT),
propagationMode = propagationMode,
lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded,
lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
@@ -178,6 +184,7 @@ private fun QsoRecord.toEntity() = QsoEntity(
dedupeKey = stableQsoKey(this),
propagationMode = propagationMode.ifBlank { if (satelliteName.isNotBlank()) "SAT" else "" },
lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded,
lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.joinToString(","),
@@ -17,6 +17,7 @@ interface IQsoRepository {
suspend fun find(id: Long): QsoRecord?
suspend fun save(record: QsoRecord): Long
suspend fun delete(id: Long)
suspend fun markUploaded(ids: List<Long>)
suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
suspend fun importAdi(content: String): AdifImportResult
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
@@ -35,6 +35,7 @@ data class QsoRecord(
val status: QsoStatus = QsoStatus.DRAFT,
val propagationMode: String = if (satelliteName.isNotBlank()) "SAT" else "",
val lotwConfirmed: Boolean = false,
val lotwUploaded: Boolean = false,
val lotwReceived: Boolean = false,
val lotwQslDate: String = "",
val vuccGrids: List<String> = emptyList(),
@@ -17,9 +17,15 @@
*/
package com.rtbishop.look4sat.core.presentation
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.foundation.MarqueeSpacing
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -29,7 +35,9 @@ import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
@@ -49,16 +57,28 @@ import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility
@@ -82,6 +102,8 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import java.text.SimpleDateFormat
import java.util.Date
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.util.Locale
import java.util.TimeZone
@@ -447,6 +469,134 @@ fun isVerticalLayout(): Boolean = !hasEnoughWidth()
fun Modifier.infiniteMarquee(): Modifier =
basicMarquee(iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(16.dp))
/** 共享的滑动行展开状态:同一列表内同时只允许一行展开(短信式联动)。 */
class SwipeController {
var openKey by mutableStateOf<String?>(null)
private set
fun open(key: String) {
openKey = key
}
fun close() {
openKey = null
}
}
@Composable
fun rememberSwipeController(): SwipeController = remember { SwipeController() }
/** 短信式滑动行:右划露出 [revealButton](默认红底垃圾桶+文字),点击按钮触发 [revealAction]。
* 通过 [controller] 联动:滑开新行时其他行自动收回;点按已展开的行内容也可收回(无涟漪)。 */
@Composable
fun SwipeRevealRow(
key: String,
controller: SwipeController,
modifier: Modifier = Modifier,
revealWidth: Dp = 64.dp,
revealAction: () -> Unit,
revealButton: @Composable () -> Unit = {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.error),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
},
content: @Composable () -> Unit
) {
val density = LocalDensity.current
val revealPx = with(density) { revealWidth.toPx() }
var offsetX by remember { mutableFloatStateOf(0f) }
var settleJob by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
val isOpen = controller.openKey == key
fun settle(target: Float) {
settleJob?.cancel()
settleJob = scope.launch {
animate(offsetX, target, animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing)) { value, _ ->
offsetX = value
}
}
}
// 另一行展开(或全部收回)时,本行随之收回。
LaunchedEffect(controller.openKey) {
if (!isOpen && offsetX < 0f) settle(0f)
}
Box(modifier.fillMaxWidth().clipToBounds()) {
// 背景删除按钮:初始位于内容右侧屏幕外,与内容同步平移(跟手滑入)。
// 父 Box clipToBounds 裁剪容器外部分——即便外层 item 带 padding、按钮
// 左缘落在容器外的屏幕内,也因裁剪而不可见、不可点。
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
.width(revealWidth)
.offset(x = revealWidth)
.graphicsLayer { translationX = offsetX }
.clickable {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
controller.close()
settle(0f)
revealAction()
}
) { revealButton() }
// 前景内容:右划平移露出按钮
Box(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { translationX = offsetX }
.pointerInput(revealPx) {
detectHorizontalDragGestures(
onDragStart = { settleJob?.cancel() },
onHorizontalDrag = { change, dragAmount ->
change.consume()
offsetX = (offsetX + dragAmount).coerceIn(-revealPx, 0f)
},
onDragEnd = {
if (offsetX < -revealPx / 2) {
controller.open(key)
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
settle(-revealPx)
} else {
controller.close()
settle(0f)
}
},
onDragCancel = {
if (offsetX < -revealPx / 2) {
controller.open(key)
settle(-revealPx)
} else {
controller.close()
settle(0f)
}
}
)
}
// 已展开的行,点按内容直接收回(无涟漪,避免整行出现点击反馈)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
if (isOpen) {
controller.close()
settle(0f)
}
}
) { content() }
}
}
@Composable
fun Modifier.layoutPadding(): Modifier {
val spacing = LocalSpacing.current.extraSmall
@@ -706,7 +706,7 @@ private fun WorkedGridCallRow(
text = call,
style = MaterialTheme.typography.titleMedium,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
color = MaterialTheme.colorScheme.tertiary,
color = ComposeColor(0xFFFFE082),
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
@@ -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
@@ -20,21 +20,30 @@ import androidx.compose.foundation.layout.heightIn
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.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.displayMode
import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.SwipeController
import com.rtbishop.look4sat.core.presentation.SwipeRevealRow
import com.rtbishop.look4sat.core.presentation.rememberSwipeController
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -61,56 +70,142 @@ fun LogbookCard(recordCount: Int, showLogbookDialog: () -> Unit) {
@Composable
fun LogbookDialog(
records: List<QsoRecord>,
uploadBusy: Boolean,
uploadMessage: String,
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview?,
onDismiss: () -> Unit,
onDelete: (Long) -> Unit
onDelete: (Long) -> Unit,
onUpload: () -> Unit,
onConfirmUpload: () -> Unit,
onDismissPreview: () -> Unit,
onDismissMessage: () -> Unit
) {
val swipeController = rememberSwipeController()
SharedDialog(
title = stringResource(R.string.prefs_logbook_title),
onDismissRequest = onDismiss,
onCancel = onDismiss,
onAccept = null
onAccept = onUpload,
acceptText = stringResource(R.string.prefs_logbook_upload),
acceptEnabled = !uploadBusy
) {
if (records.isEmpty()) {
Text(stringResource(R.string.prefs_logbook_empty), fontSize = 14.sp)
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
items(records, key = { it.id }) { record ->
LogbookRow(record, onDelete = { onDelete(record.id) })
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
if (records.isEmpty()) {
Text(stringResource(R.string.prefs_logbook_empty), fontSize = 14.sp)
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
items(records, key = { it.id }) { record ->
LogbookRow(record, swipeController, onDelete = { onDelete(record.id) })
}
}
}
}
}
if (preview != null) {
LogbookUploadPreviewDialog(
preview = preview,
busy = uploadBusy,
onConfirm = onConfirmUpload,
onDismiss = onDismissPreview
)
}
if (uploadMessage.isNotBlank()) {
AlertDialog(
onDismissRequest = onDismissMessage,
title = { Text("LoTW Upload") },
text = { Text(uploadMessage) },
confirmButton = {
TextButton(onClick = onDismissMessage) { Text("OK") }
}
)
}
}
@Composable
private fun LogbookRow(record: QsoRecord, onDelete: () -> Unit) {
private fun LogbookUploadPreviewDialog(
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview,
busy: Boolean,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.prefs_logbook_upload_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp)
Text(
"${preview.count} QSO(s) · ${preview.firstUtc} – ${preview.lastUtc}",
fontSize = 13.sp
)
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
}
},
confirmButton = {
TextButton(onClick = onConfirm, enabled = !busy) {
Text(stringResource(R.string.prefs_logbook_upload_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_cancel)) }
}
)
}
@Composable
private fun LogbookRow(record: QsoRecord, swipeController: SwipeController, onDelete: () -> Unit) {
val time = remember(record.startUtcMillis) {
SimpleDateFormat("MM-dd HH:mm'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(record.startUtcMillis))
}
val satShort = record.satelliteName.substringBefore('(').trim()
Row(
modifier = Modifier.fillMaxWidth().clickable { onDelete() }.padding(horizontal = 4.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
val band = record.band.ifBlank { frequencyBand(record.txFrequencyHz) }
// 右划露出删除按钮(短信式);整行点击不再删除。
SwipeRevealRow(
key = record.id.toString(),
controller = swipeController,
revealAction = onDelete,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
// Row style mirrors the worked-grid QSO details on the map: callsign in
// monospace titleMedium, summary line in bodySmall with " · " separators.
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 8.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = record.theirCallsign,
style = MaterialTheme.typography.titleMedium,
fontFamily = FontFamily.Monospace,
color = Color(0xFFFFE082),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (record.lotwConfirmed) {
Text(text = "QSL", fontSize = 13.sp, color = Color(0xFFFFE082), fontFamily = FontFamily.Monospace)
} else if (record.lotwUploaded) {
Text(text = "UP", fontSize = 13.sp, color = Color(0xFFFFE082), fontFamily = FontFamily.Monospace)
}
}
Text(
text = "$time ${record.theirCallsign} ${record.displayMode}",
fontSize = 13.sp,
text = listOf(satShort, record.displayMode, band, time).filter { it.isNotBlank() }.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 3.dp)
)
Text(text = satShort, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
Text(
text = if (record.lotwConfirmed) "✓" else "",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
@@ -264,14 +264,22 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
if (dialogs.logbook) {
LogbookDialog(
records = uiState.logbookRecords,
uploadBusy = uiState.logbookUploadBusy,
uploadMessage = uiState.logbookUploadMessage,
preview = uiState.logbookPreview,
onDismiss = { dialogs.logbook = false },
onDelete = { onAction(SettingsAction.DeleteLogbookRecord(it)) }
onDelete = { onAction(SettingsAction.DeleteLogbookRecord(it)) },
onUpload = { onAction(SettingsAction.PrepareLogbookUpload) },
onConfirmUpload = { onAction(SettingsAction.ConfirmLogbookUpload) },
onDismissPreview = { onAction(SettingsAction.DismissLogbookPreview) },
onDismissMessage = { onAction(SettingsAction.ClearLogbookMessage) }
)
}
if (dialogs.lotwUpload) {
LoTWUploadConfigDialog(
certificate = uiState.lotwCertificate,
station = uiState.lotwStation,
stationMeta = uiState.lotwStationMeta,
busy = uiState.lotwUploadBusy,
error = uiState.lotwUploadError,
onDismiss = { dialogs.lotwUpload = false },
@@ -74,9 +74,16 @@ data class SettingsState(
val lotwCertificate: com.rtbishop.look4sat.core.domain.repository.LoTWCertificate? = null,
/** LoTW upload station location (null when unset). */
val lotwStation: com.rtbishop.look4sat.core.domain.repository.LoTWStation? = null,
/** Region-field options and national zonemap for the certificate's DXCC entity. */
val lotwStationMeta: com.rtbishop.look4sat.core.domain.repository.LoTWStationMeta? = null,
val lotwUploadBusy: Boolean = false,
/** Last certificate import outcome; shown inside the upload config dialog. */
val lotwUploadError: LoTWUploadError? = null,
/** One-click logbook upload: prepared preview awaiting confirmation. */
val logbookPreview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview? = null,
val logbookUploadBusy: Boolean = false,
/** User-facing upload message shown inside the logbook dialog ("" = none). */
val logbookUploadMessage: String = "",
/** 指南针校准精度等级 (校准对话框进度条). */
val compassAccuracy: CompassAccuracy = CompassAccuracy.UNRELIABLE,
/** 校正后航向(度, 含磁偏角+手动偏置), 校准对话框实时显示. */
@@ -93,7 +100,7 @@ sealed interface LoTWError {
data class Network(val detail: String) : LoTWError
}
enum class LoTWUploadError { PASSWORD, INVALID_FILE, EXPIRED, UNKNOWN }
enum class LoTWUploadError { PASSWORD, INVALID_FILE, EXPIRED, FORMAT, UNKNOWN }
sealed interface SettingsAction {
// Position
@@ -144,6 +151,10 @@ sealed interface SettingsAction {
// Logbook (QSO records + LoTW confirmations)
data object RefreshLogbook : SettingsAction
data class DeleteLogbookRecord(val id: Long) : SettingsAction
data object PrepareLogbookUpload : SettingsAction
data object ConfirmLogbookUpload : SettingsAction
data object DismissLogbookPreview : SettingsAction
data object ClearLogbookMessage : SettingsAction
// LoTW upload configuration (certificate + station)
data object LoadLoTWUploadStatus : SettingsAction
@@ -212,6 +212,10 @@ class SettingsViewModel(
// Logbook
SettingsAction.RefreshLogbook -> refreshLogbook()
is SettingsAction.DeleteLogbookRecord -> viewModelScope.launch { qsoRepository.delete(action.id) }
SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload()
SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload()
SettingsAction.DismissLogbookPreview -> dismissLogbookPreview()
SettingsAction.ClearLogbookMessage -> clearLogbookMessage()
// LoTW upload configuration
SettingsAction.LoadLoTWUploadStatus -> loadLoTWUploadStatus()
is SettingsAction.ImportLoTWCertificate -> importLoTWCertificate(action.bytes, action.password)
@@ -344,11 +348,65 @@ class SettingsViewModel(
}
}
// Record ids submitted in the most recent logbook prepare → upload cycle, so a
// successful POST can mark them "uploaded" (distinct from "confirmed").
private var lastLogbookUploadIds: List<Long> = emptyList()
private fun prepareLogbookUpload() {
if (_uiState.value.logbookUploadBusy) return
viewModelScope.launch {
_uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") }
try {
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 == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE }
val audit = lotwUploadRepository.audit(pending)
if (audit.pending == 0) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No pending QSOs to upload") }
return@launch
}
val preview = lotwUploadRepository.prepare(pending, false)
lastLogbookUploadIds = pending.map { it.id }
_uiState.update { it.copy(logbookUploadBusy = false, logbookPreview = preview) }
} catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload unavailable: ${e.reason}") }
} catch (_: Exception) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload failed") }
}
}
}
private fun confirmLogbookUpload() {
val preview = _uiState.value.logbookPreview ?: return
viewModelScope.launch {
_uiState.update { it.copy(logbookUploadBusy = true) }
val result = lotwUploadRepository.upload(preview.id)
if (result is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted && lastLogbookUploadIds.isNotEmpty()) {
qsoRepository.markUploaded(lastLogbookUploadIds)
lastLogbookUploadIds = emptyList()
}
val msg = when (result) {
is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted -> "Uploaded ${result.count} QSO(s) — accepted by LoTW"
is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Rejected -> "Rejected: ${result.message}"
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"
}
_uiState.update { it.copy(logbookUploadBusy = false, logbookPreview = null, logbookUploadMessage = msg) }
}
}
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null) }
private fun clearLogbookMessage() = _uiState.update { it.copy(logbookUploadMessage = "") }
private fun loadLoTWUploadStatus() {
viewModelScope.launch {
val cert = lotwUploadRepository.certificate()
val station = lotwUploadRepository.station()
_uiState.update { it.copy(lotwCertificate = cert, lotwStation = station, lotwUploadBusy = false) }
// Config parsing failure must never crash the dialog — degrade to no meta.
val meta = cert?.let { runCatching { lotwUploadRepository.stationMeta(it.dxcc) }.getOrNull() }
_uiState.update { it.copy(lotwCertificate = cert, lotwStation = station, lotwStationMeta = meta, lotwUploadBusy = false) }
}
}
@@ -362,6 +420,7 @@ class SettingsViewModel(
val error = when (e.reason) {
com.rtbishop.look4sat.core.domain.repository.LoTWProblem.CERTIFICATE_PASSWORD -> LoTWUploadError.PASSWORD
com.rtbishop.look4sat.core.domain.repository.LoTWProblem.CERTIFICATE_EXPIRED -> LoTWUploadError.EXPIRED
com.rtbishop.look4sat.core.domain.repository.LoTWProblem.CERTIFICATE_FORMAT -> LoTWUploadError.FORMAT
else -> LoTWUploadError.INVALID_FILE
}
_uiState.update { it.copy(lotwUploadBusy = false, lotwUploadError = error) }
@@ -374,7 +433,7 @@ class SettingsViewModel(
private fun removeLoTWCertificate() {
viewModelScope.launch {
lotwUploadRepository.removeCertificate()
_uiState.update { it.copy(lotwCertificate = null, lotwStation = null) }
_uiState.update { it.copy(lotwCertificate = null, lotwStation = null, lotwStationMeta = null) }
}
}