diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDao.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDao.kt index 4e833eea..d747afe5 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDao.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDao.kt @@ -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) } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDatabase.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDatabase.kt index 7708e6f6..6ef15938 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDatabase.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDatabase.kt @@ -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") + } +} diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/QsoEntity.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/QsoEntity.kt index f534d094..be6140a2 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/QsoEntity.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/QsoEntity.kt @@ -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 = "", diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt index 343e917c..c94c73c5 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt @@ -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) } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/QsoRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/QsoRepository.kt index 0dd869c8..170f414f 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/QsoRepository.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/QsoRepository.kt @@ -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) = withContext(dispatcher) { + if (ids.isEmpty()) return@withContext + dao.markUploaded(ids) + } + override suspend fun exportAdi(ids: Set?, 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(","), diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/IQsoRepository.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/IQsoRepository.kt index e6904205..44dec151 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/IQsoRepository.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/IQsoRepository.kt @@ -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) suspend fun exportAdi(ids: Set? = null, includeIncomplete: Boolean = false): String suspend fun importAdi(content: String): AdifImportResult suspend fun mergeConfirmed(records: List): AdifImportResult diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt index 797b3f5c..7472229d 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt @@ -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 = emptyList(), diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt index 998af9aa..106c71ca 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt @@ -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(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(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 diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt index 9c675537..a66f2f47 100644 --- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt +++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt @@ -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) diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt index 2926360e..6f43adb7 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt @@ -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) + } + } } } diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt index 2f30e039..e4ee9442 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt @@ -61,6 +61,10 @@ class LogViewModel( private val _uiState = MutableStateFlow(LogUiState()) val uiState: StateFlow = _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 = 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}" diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt index 48a87ad0..7ab37096 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt @@ -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 } diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt index 0e2354e6..642acc00 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt @@ -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) + } + } } } diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt index a3be71ea..c5f9cc85 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt @@ -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 diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt index 5d96a899..be13c7c9 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt @@ -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, + 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 - ) } } diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt index 1a813bb2..6bd412a8 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt @@ -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 }, diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt index 1b3f947b..5cf419f6 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt @@ -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 diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt index 23ac6105..4830ea81 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt @@ -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 = 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) } } }