mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-04 03:37:21 +00:00
feat(logbook): Room persistence, UP/QSL markers, one-click upload, swipe-delete, calculator-frequency logging
This commit is contained in:
1 parent
3be2e4b9e8
commit
a769f7dea7
18 files changed
+454
-50
No files matched your search
+121
-26
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -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 },
|
||||
|
||||
+12
-1
@@ -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
|
||||
|
||||
+61
-2
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user