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
+437 -33

No files matched your search

@@ -44,4 +44,7 @@ interface QsoDao {
@Query("DELETE FROM qso_records WHERE id = :id") @Query("DELETE FROM qso_records WHERE id = :id")
suspend fun delete(id: Long) 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.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.rtbishop.look4sat.core.data.database.entity.QsoEntity 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 * upload receipts). Kept apart from [Look4SatDb] so existing installations
* never need a migration of the satellite database. * 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 class QsoDatabase : RoomDatabase() {
abstract fun qsoDao(): QsoDao 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 dedupeKey: String,
@ColumnInfo(defaultValue = "''") val propagationMode: String = "", @ColumnInfo(defaultValue = "''") val propagationMode: String = "",
@ColumnInfo(defaultValue = "0") val lotwConfirmed: Boolean = false, @ColumnInfo(defaultValue = "0") val lotwConfirmed: Boolean = false,
@ColumnInfo(defaultValue = "0") val lotwUploaded: Boolean = false,
@ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false, @ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false,
@ColumnInfo(defaultValue = "''") val lotwQslDate: String = "", @ColumnInfo(defaultValue = "''") val lotwQslDate: String = "",
@ColumnInfo(defaultValue = "''") val vuccGrids: 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.Look4SatDb
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2 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_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.database.QsoDatabase
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
import com.rtbishop.look4sat.core.data.framework.Ft817Controller 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 wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() } override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
override val qsoRepository: IQsoRepository by lazy { 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) QsoRepository(database.qsoDao(), Dispatchers.IO)
} }
override val lotwUploadRepository: ILoTWUploadRepository by lazy { LoTWUploadRepository(context) } 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 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) { override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
val records = dao.getAll() val records = dao.getAll()
.asSequence() .asSequence()
@@ -143,6 +148,7 @@ private fun QsoEntity.toDomain() = QsoRecord(
status = runCatching { QsoStatus.valueOf(status) }.getOrDefault(QsoStatus.DRAFT), status = runCatching { QsoStatus.valueOf(status) }.getOrDefault(QsoStatus.DRAFT),
propagationMode = propagationMode, propagationMode = propagationMode,
lotwConfirmed = lotwConfirmed, lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded,
lotwReceived = lotwReceived, lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate, lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank), vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
@@ -178,6 +184,7 @@ private fun QsoRecord.toEntity() = QsoEntity(
dedupeKey = stableQsoKey(this), dedupeKey = stableQsoKey(this),
propagationMode = propagationMode.ifBlank { if (satelliteName.isNotBlank()) "SAT" else "" }, propagationMode = propagationMode.ifBlank { if (satelliteName.isNotBlank()) "SAT" else "" },
lotwConfirmed = lotwConfirmed, lotwConfirmed = lotwConfirmed,
lotwUploaded = lotwUploaded,
lotwReceived = lotwReceived, lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate, lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.joinToString(","), vuccGrids = vuccGrids.joinToString(","),
@@ -17,6 +17,7 @@ interface IQsoRepository {
suspend fun find(id: Long): QsoRecord? suspend fun find(id: Long): QsoRecord?
suspend fun save(record: QsoRecord): Long suspend fun save(record: QsoRecord): Long
suspend fun delete(id: 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 exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
suspend fun importAdi(content: String): AdifImportResult suspend fun importAdi(content: String): AdifImportResult
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
@@ -35,6 +35,7 @@ data class QsoRecord(
val status: QsoStatus = QsoStatus.DRAFT, val status: QsoStatus = QsoStatus.DRAFT,
val propagationMode: String = if (satelliteName.isNotBlank()) "SAT" else "", val propagationMode: String = if (satelliteName.isNotBlank()) "SAT" else "",
val lotwConfirmed: Boolean = false, val lotwConfirmed: Boolean = false,
val lotwUploaded: Boolean = false,
val lotwReceived: Boolean = false, val lotwReceived: Boolean = false,
val lotwQslDate: String = "", val lotwQslDate: String = "",
val vuccGrids: List<String> = emptyList(), val vuccGrids: List<String> = emptyList(),
@@ -17,9 +17,15 @@
*/ */
package com.rtbishop.look4sat.core.presentation 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.MarqueeSpacing
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
@@ -49,16 +57,28 @@ import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf 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.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke 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.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll 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.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility 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 com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.util.Locale import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
@@ -447,6 +469,134 @@ fun isVerticalLayout(): Boolean = !hasEnoughWidth()
fun Modifier.infiniteMarquee(): Modifier = fun Modifier.infiniteMarquee(): Modifier =
basicMarquee(iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(16.dp)) 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 @Composable
fun Modifier.layoutPadding(): Modifier { fun Modifier.layoutPadding(): Modifier {
val spacing = LocalSpacing.current.extraSmall val spacing = LocalSpacing.current.extraSmall
@@ -706,7 +706,7 @@ private fun WorkedGridCallRow(
text = call, text = call,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
color = MaterialTheme.colorScheme.tertiary, color = ComposeColor(0xFFFFE082),
maxLines = 1, maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
@@ -34,6 +34,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@@ -44,6 +46,9 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.displayMode 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.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.presentation.EmptyListCard import com.rtbishop.look4sat.core.presentation.EmptyListCard
@@ -57,12 +62,15 @@ fun LogPage(
) { ) {
val logUiState by logViewModel.uiState.collectAsStateWithLifecycle() val logUiState by logViewModel.uiState.collectAsStateWithLifecycle()
val records by logViewModel.records.collectAsStateWithLifecycle(initialValue = emptyList()) val records by logViewModel.records.collectAsStateWithLifecycle(initialValue = emptyList())
val swipeController = rememberSwipeController()
val selectedRadio = remember(uiState.transceivers.transmitters, uiState.transceivers.selectedUuid) { val selectedRadio = remember(uiState.transceivers.transmitters, uiState.transceivers.selectedUuid) {
uiState.transceivers.transmitters.firstOrNull { it.uuid == uiState.transceivers.selectedUuid } uiState.transceivers.transmitters.firstOrNull { it.uuid == uiState.transceivers.selectedUuid }
} }
val isLinear = selectedRadio != null && // Linear if the satellite carries ANY named linear transponder — same test
DopplerFrequencyCalculator.isNamedLinearTransponder(selectedRadio) // 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 catnum = uiState.currentPass?.catNum ?: selectedRadio?.catnum ?: 0
val satName = uiState.currentPass?.name?.trim().orEmpty() val satName = uiState.currentPass?.name?.trim().orEmpty()
@@ -70,12 +78,12 @@ fun LogPage(
if (catnum != 0) logViewModel.selectSatellite(catnum) if (catnum != 0) logViewModel.selectSatellite(catnum)
} }
val txHz = remember(selectedRadio) { val txHz = uiState.calculatorTxHz ?: remember(selectedRadio) {
selectedRadio?.uplinkLow?.let { low -> selectedRadio?.uplinkLow?.let { low ->
selectedRadio.uplinkHigh?.let { high -> (low + high) / 2 } ?: low selectedRadio.uplinkHigh?.let { high -> (low + high) / 2 } ?: low
} }
} }
val rxHz = remember(selectedRadio) { val rxHz = uiState.calculatorRxHz ?: remember(selectedRadio) {
selectedRadio?.downlinkLow?.let { low -> selectedRadio?.downlinkLow?.let { low ->
selectedRadio.downlinkHigh?.let { high -> (low + high) / 2 } ?: low selectedRadio.downlinkHigh?.let { high -> (low + high) / 2 } ?: low
} }
@@ -127,7 +135,7 @@ fun LogPage(
} else { } else {
LazyColumn(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { LazyColumn(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
items(recent, key = { it.id }) { record -> 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 @Composable
private fun LogRecordRow(record: QsoRecord, onDelete: () -> Unit) { private fun LogRecordRow(record: QsoRecord, swipeController: SwipeController, onDelete: () -> Unit) {
val time = remember(record.startUtcMillis) { val time = remember(record.startUtcMillis) {
java.text.SimpleDateFormat("HH:mm'Z'", java.util.Locale.US).apply { java.text.SimpleDateFormat("HH:mm'Z'", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC") timeZone = java.util.TimeZone.getTimeZone("UTC")
}.format(java.util.Date(record.startUtcMillis)) }.format(java.util.Date(record.startUtcMillis))
} }
// 右划露出删除按钮(短信式);整行点击不再删除。
SwipeRevealRow(
key = record.id.toString(),
controller = swipeController,
revealAction = onDelete,
modifier = Modifier.fillMaxWidth()
) {
Row( Row(
modifier = Modifier.fillMaxWidth().clickable { onDelete() }.padding(horizontal = 4.dp, vertical = 2.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = "$time ", fontSize = 14.sp, maxLines = 1)
Text( Text(
text = "$time ${record.theirCallsign} ${record.displayMode}", text = record.theirCallsign,
fontSize = 14.sp, fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
color = Color(0xFFFFE082),
maxLines = 1 maxLines = 1
) )
Text(text = if (record.lotwConfirmed) "✓" else "", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary) 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()) private val _uiState = MutableStateFlow(LogUiState())
val uiState: StateFlow<LogUiState> = _uiState 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 { init {
viewModelScope.launch { viewModelScope.launch {
val cert = lotwUploadRepository.certificate() val cert = lotwUploadRepository.certificate()
@@ -160,6 +164,7 @@ class LogViewModel(
return@launch return@launch
} }
val preview = lotwUploadRepository.prepare(pending, false) val preview = lotwUploadRepository.prepare(pending, false)
lastUploadedIds = pending.map { it.id }
_uiState.update { it.copy(busy = false, preview = preview) } _uiState.update { it.copy(busy = false, preview = preview) }
} catch (e: LoTWOperationException) { } catch (e: LoTWOperationException) {
_uiState.update { it.copy(busy = false, message = "Upload unavailable: ${e.reason}") } _uiState.update { it.copy(busy = false, message = "Upload unavailable: ${e.reason}") }
@@ -174,6 +179,11 @@ class LogViewModel(
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(busy = true) } _uiState.update { it.copy(busy = true) }
val result = lotwUploadRepository.upload(preview.id) 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) { val msg = when (result) {
is LoTWUploadResult.Accepted -> "Uploaded ${result.count} QSO(s) — accepted by LoTW" is LoTWUploadResult.Accepted -> "Uploaded ${result.count} QSO(s) — accepted by LoTW"
is LoTWUploadResult.Rejected -> "Rejected: ${result.message}" is LoTWUploadResult.Rejected -> "Rejected: ${result.message}"
@@ -70,7 +70,11 @@ data class RadarState(
val mutualMaxElev: Double = 10.0, val mutualMaxElev: Double = 10.0,
val mutualLabelA: String = "你", val mutualLabelA: String = "你",
val mutualLabelB: 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 } enum class SstvStatus { Idle, Recording }
@@ -128,4 +132,5 @@ sealed interface RadarAction {
// Calculator actions // Calculator actions
data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction 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) } _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.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -215,6 +216,7 @@ fun CalculatorPage(
orbitalPos = orbitalPos, orbitalPos = orbitalPos,
offsetKHz = calculatorOffsetKHz, offsetKHz = calculatorOffsetKHz,
onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) }, onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) },
onFrequencyChange = { tx, rx -> onAction(RadarAction.UpdateCalculatorFrequency(tx, rx)) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
@@ -569,6 +571,7 @@ private fun DopplerFrequencyCalculator(
orbitalPos: OrbitalPos?, orbitalPos: OrbitalPos?,
offsetKHz: String = "", offsetKHz: String = "",
onOffsetChange: (String) -> Unit = {}, onOffsetChange: (String) -> Unit = {},
onFrequencyChange: (Long, Long) -> Unit = { _, _ -> },
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
@@ -579,6 +582,12 @@ private fun DopplerFrequencyCalculator(
var passbandPosition by rememberSaveable(transponder.uuid) { mutableStateOf(0.5f) } var passbandPosition by rememberSaveable(transponder.uuid) { mutableStateOf(0.5f) }
var stepSizeKHz by remember { mutableIntStateOf(1) } 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 offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
val txLow = transponder.uplinkLow ?: return 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.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.displayMode 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.R
import com.rtbishop.look4sat.core.presentation.SharedDialog 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.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -61,14 +70,28 @@ fun LogbookCard(recordCount: Int, showLogbookDialog: () -> Unit) {
@Composable @Composable
fun LogbookDialog( fun LogbookDialog(
records: List<QsoRecord>, records: List<QsoRecord>,
uploadBusy: Boolean,
uploadMessage: String,
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview?,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onDelete: (Long) -> Unit onDelete: (Long) -> Unit,
onUpload: () -> Unit,
onConfirmUpload: () -> Unit,
onDismissPreview: () -> Unit,
onDismissMessage: () -> Unit
) { ) {
val swipeController = rememberSwipeController()
SharedDialog( SharedDialog(
title = stringResource(R.string.prefs_logbook_title), title = stringResource(R.string.prefs_logbook_title),
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
onCancel = onDismiss, onCancel = onDismiss,
onAccept = null onAccept = onUpload,
acceptText = stringResource(R.string.prefs_logbook_upload),
acceptEnabled = !uploadBusy
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
if (records.isEmpty()) { if (records.isEmpty()) {
Text(stringResource(R.string.prefs_logbook_empty), fontSize = 14.sp) Text(stringResource(R.string.prefs_logbook_empty), fontSize = 14.sp)
@@ -78,39 +101,111 @@ fun LogbookDialog(
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
items(records, key = { it.id }) { record -> items(records, key = { it.id }) { record ->
LogbookRow(record, onDelete = { onDelete(record.id) }) 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 @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) { val time = remember(record.startUtcMillis) {
SimpleDateFormat("MM-dd HH:mm'Z'", Locale.US).apply { SimpleDateFormat("MM-dd HH:mm'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC") timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(record.startUtcMillis)) }.format(Date(record.startUtcMillis))
} }
val satShort = record.satelliteName.substringBefore('(').trim() val satShort = record.satelliteName.substringBefore('(').trim()
Row( val band = record.band.ifBlank { frequencyBand(record.txFrequencyHz) }
modifier = Modifier.fillMaxWidth().clickable { onDelete() }.padding(horizontal = 4.dp, vertical = 2.dp), // 右划露出删除按钮(短信式);整行点击不再删除。
horizontalArrangement = Arrangement.SpaceBetween, SwipeRevealRow(
verticalAlignment = Alignment.CenterVertically 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(
text = "$time ${record.theirCallsign} ${record.displayMode}", text = record.theirCallsign,
fontSize = 13.sp, style = MaterialTheme.typography.titleMedium,
fontFamily = FontFamily.Monospace,
color = Color(0xFFFFE082),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
) )
Text(text = satShort, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) 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(
text = if (record.lotwConfirmed) "✓" else "", text = listOf(satShort, record.displayMode, band, time).filter { it.isNotBlank() }.joinToString(" · "),
fontSize = 14.sp, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 3.dp)
) )
} }
}
} }
@@ -264,14 +264,22 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
if (dialogs.logbook) { if (dialogs.logbook) {
LogbookDialog( LogbookDialog(
records = uiState.logbookRecords, records = uiState.logbookRecords,
uploadBusy = uiState.logbookUploadBusy,
uploadMessage = uiState.logbookUploadMessage,
preview = uiState.logbookPreview,
onDismiss = { dialogs.logbook = false }, 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) { if (dialogs.lotwUpload) {
LoTWUploadConfigDialog( LoTWUploadConfigDialog(
certificate = uiState.lotwCertificate, certificate = uiState.lotwCertificate,
station = uiState.lotwStation, station = uiState.lotwStation,
stationMeta = uiState.lotwStationMeta,
busy = uiState.lotwUploadBusy, busy = uiState.lotwUploadBusy,
error = uiState.lotwUploadError, error = uiState.lotwUploadError,
onDismiss = { dialogs.lotwUpload = false }, onDismiss = { dialogs.lotwUpload = false },
@@ -74,9 +74,16 @@ data class SettingsState(
val lotwCertificate: com.rtbishop.look4sat.core.domain.repository.LoTWCertificate? = null, val lotwCertificate: com.rtbishop.look4sat.core.domain.repository.LoTWCertificate? = null,
/** LoTW upload station location (null when unset). */ /** LoTW upload station location (null when unset). */
val lotwStation: com.rtbishop.look4sat.core.domain.repository.LoTWStation? = null, 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, val lotwUploadBusy: Boolean = false,
/** Last certificate import outcome; shown inside the upload config dialog. */ /** Last certificate import outcome; shown inside the upload config dialog. */
val lotwUploadError: LoTWUploadError? = null, 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, val compassAccuracy: CompassAccuracy = CompassAccuracy.UNRELIABLE,
/** 校正后航向(度, 含磁偏角+手动偏置), 校准对话框实时显示. */ /** 校正后航向(度, 含磁偏角+手动偏置), 校准对话框实时显示. */
@@ -93,7 +100,7 @@ sealed interface LoTWError {
data class Network(val detail: String) : 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 { sealed interface SettingsAction {
// Position // Position
@@ -144,6 +151,10 @@ sealed interface SettingsAction {
// Logbook (QSO records + LoTW confirmations) // Logbook (QSO records + LoTW confirmations)
data object RefreshLogbook : SettingsAction data object RefreshLogbook : SettingsAction
data class DeleteLogbookRecord(val id: Long) : 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) // LoTW upload configuration (certificate + station)
data object LoadLoTWUploadStatus : SettingsAction data object LoadLoTWUploadStatus : SettingsAction
@@ -212,6 +212,10 @@ class SettingsViewModel(
// Logbook // Logbook
SettingsAction.RefreshLogbook -> refreshLogbook() SettingsAction.RefreshLogbook -> refreshLogbook()
is SettingsAction.DeleteLogbookRecord -> viewModelScope.launch { qsoRepository.delete(action.id) } is SettingsAction.DeleteLogbookRecord -> viewModelScope.launch { qsoRepository.delete(action.id) }
SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload()
SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload()
SettingsAction.DismissLogbookPreview -> dismissLogbookPreview()
SettingsAction.ClearLogbookMessage -> clearLogbookMessage()
// LoTW upload configuration // LoTW upload configuration
SettingsAction.LoadLoTWUploadStatus -> loadLoTWUploadStatus() SettingsAction.LoadLoTWUploadStatus -> loadLoTWUploadStatus()
is SettingsAction.ImportLoTWCertificate -> importLoTWCertificate(action.bytes, action.password) 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() { private fun loadLoTWUploadStatus() {
viewModelScope.launch { viewModelScope.launch {
val cert = lotwUploadRepository.certificate() val cert = lotwUploadRepository.certificate()
val station = lotwUploadRepository.station() 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) { val error = when (e.reason) {
com.rtbishop.look4sat.core.domain.repository.LoTWProblem.CERTIFICATE_PASSWORD -> LoTWUploadError.PASSWORD 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_EXPIRED -> LoTWUploadError.EXPIRED
com.rtbishop.look4sat.core.domain.repository.LoTWProblem.CERTIFICATE_FORMAT -> LoTWUploadError.FORMAT
else -> LoTWUploadError.INVALID_FILE else -> LoTWUploadError.INVALID_FILE
} }
_uiState.update { it.copy(lotwUploadBusy = false, lotwUploadError = error) } _uiState.update { it.copy(lotwUploadBusy = false, lotwUploadError = error) }
@@ -374,7 +433,7 @@ class SettingsViewModel(
private fun removeLoTWCertificate() { private fun removeLoTWCertificate() {
viewModelScope.launch { viewModelScope.launch {
lotwUploadRepository.removeCertificate() lotwUploadRepository.removeCertificate()
_uiState.update { it.copy(lotwCertificate = null, lotwStation = null) } _uiState.update { it.copy(lotwCertificate = null, lotwStation = null, lotwStationMeta = null) }
} }
} }