feat(logbook): 窗口外录入 + 网格显示链 + 双倍修复 + 上传前核对 + 重传选择 + 报错拆分

.2.5–.3 调试系列收口:

- 雷达窗口外录入:不在过境窗口内按 Log 时按当前过境中点落点(弹窗确认,可事后编辑改时间)
- 网格显示链:上传盖章(markUploaded 记录台址网格集)+ 日志本行尾 / 雷达页「自己的格子」+
  QSL 槽对方网格(绿 #4CD964)+ 缩写规则统一(gridsLabel 上移 core:presentation);
  QsoDatabase v2→v3 增列 theirVuccGrids
- 全量同步「双倍」三层修复:键无关身份±60s 镜像折叠、matchableMode 归一(MFSK→FT4)、
  双确认对按 contentScore 折叠
- 上传前核对:批记录网格与台址完全不交集 → 弹窗(修改台址带预填跳转 / 忽略),预览被闸住
- 重传选中:日志本长按多选(含已上传/已确认)→ prepare(resubmit=true) → 成功后网格盖章更新;
  退出日志本重置选择(需重新长按)
- 报错拆分:QSO_FUTURE(记录时间在未来)与 QSO_DATE(超出证书范围)分开提示
- 全 App 弹窗样式统一(深色圆角 sheet)

测试:domain 172 / data 116 全绿。
This commit is contained in:
atsunatsu committed 2026-10-02 12:01:57 +08:00
1 parent d13af978af
commit 5b7c7d6df5
36 files changed
+1145 -99

No files matched your search

@@ -40,6 +40,7 @@ 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.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -60,14 +61,18 @@ import com.rtbishop.look4sat.core.domain.utility.uplinkHz
import com.rtbishop.look4sat.core.domain.utility.voiceRepeater
import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.QsoEditDialog
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
import com.rtbishop.look4sat.core.presentation.gridsLabel
import com.rtbishop.look4sat.core.presentation.LoTWGridWarningDialog
@Composable
fun LogPage(
uiState: RadarState,
logViewModel: LogViewModel,
onFixGrid: (List<String>) -> Unit = {},
modifier: Modifier = Modifier
) {
val logUiState by logViewModel.uiState.collectAsStateWithLifecycle()
@@ -139,11 +144,11 @@ fun LogPage(
imeAction = ImeAction.Done
),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = {
logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid)
logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid, passWindow)
})
)
Button(
onClick = { logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid) },
onClick = { logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid, passWindow) },
enabled = logUiState.callsignInput.isNotBlank() && satName.isNotBlank()
) { Text("Log") }
}
@@ -217,15 +222,29 @@ fun LogPage(
)
}
logUiState.preview?.let { preview ->
UploadPreviewDialog(
preview = preview,
busy = logUiState.busy,
onConfirm = logViewModel::confirmUpload,
onDismiss = logViewModel::dismissPreview
logUiState.uploadGridWarning?.let { warning ->
LoTWGridWarningDialog(
warning = warning,
onFixStation = {
logViewModel.abandonForGridFix()
onFixGrid(warning.recordGrids)
},
onIgnore = logViewModel::ignoreGridWarning
)
}
// The preview only opens once the grid check is out of the way.
if (logUiState.uploadGridWarning == null) {
logUiState.preview?.let { preview ->
UploadPreviewDialog(
preview = preview,
busy = logUiState.busy,
onConfirm = logViewModel::confirmUpload,
onDismiss = logViewModel::dismissPreview
)
}
}
if (logUiState.message.isNotBlank()) {
AlertDialog(
onDismissRequest = logViewModel::clearMessage,
@@ -239,6 +258,34 @@ fun LogPage(
)
}
// Out-of-window notice: the clock is outside the current pass [aos, los], so the record
// would never show in the window-filtered list. Confirm first, then store it at the pass
// midpoint (there is always an edit dialog afterwards for the exact time).
logUiState.outOfWindowLog?.let { pending ->
val midpointText = remember(pending.midpointUtcMillis) {
java.text.SimpleDateFormat("HH:mm'Z'", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}.format(java.util.Date(pending.midpointUtcMillis))
}
AlertDialog(
onDismissRequest = logViewModel::dismissOutOfWindowLog,
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle(stringResource(R.string.log_out_window_title)) },
text = { Text(stringResource(R.string.log_out_window_message, midpointText)) },
confirmButton = {
TextButton(onClick = logViewModel::confirmOutOfWindowLog) {
Text(stringResource(R.string.log_out_window_confirm))
}
},
dismissButton = {
TextButton(onClick = logViewModel::dismissOutOfWindowLog) {
Text(stringResource(R.string.btn_cancel))
}
}
)
}
editTarget?.let { target ->
QsoEditDialog(
record = target,
@@ -264,6 +311,8 @@ private fun LogRecordRow(
timeZone = java.util.TimeZone.getTimeZone("UTC")
}.format(java.util.Date(record.startUtcMillis))
}
// Own station grid: the upload-stamped set, else the grid the QSO was logged under.
val ownGrids = gridsLabel(record.vuccGrids.ifEmpty { listOf(record.myGrid) })
// 右划露出删除按钮(短信式);整行点击不再删除。
SwipeRevealRow(
key = record.id.toString(),
@@ -289,6 +338,14 @@ private fun LogRecordRow(
maxLines = 1
)
Text(text = " ${record.displayMode}", fontSize = 14.sp, maxLines = 1)
if (ownGrids.isNotBlank()) {
Text(
text = " $ownGrids",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
}
when {
record.lotwConfirmed -> Text("QSL", fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
@@ -26,6 +26,7 @@ import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
import com.rtbishop.look4sat.core.domain.repository.LoTWGridWarning
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -38,6 +39,23 @@ import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* A contact waiting for the operator to confirm logging while the clock is outside the
* current pass window (after LOS, before AOS, or against a pass card that is not the one
* being worked). Confirmed entries are stored at the pass midpoint — inside [aos, los] —
* so the window-filtered log page can show them; the exact time can be re-dated from the
* edit dialog afterwards.
*/
data class OutOfWindowLog(
val callsign: String,
val satName: String,
val mode: String,
val txHz: Long?,
val rxHz: Long?,
val myGrid: String,
val midpointUtcMillis: Long
)
data class LogUiState(
val callsignInput: String = "",
/** Current logbook mode for the selected satellite (FM/CW/SSB/FT4). */
@@ -49,10 +67,15 @@ data class LogUiState(
/** ARRL satellite names (config.tq6) — the only names a record may be signed with. */
val satelliteCatalog: List<String> = emptyList(),
val preview: LoTWUploadPreview? = null,
/** Roaming guard: the prepared batch holds records whose own grids fall outside the
* station-location grids — shown as a dialog before the preview opens. */
val uploadGridWarning: LoTWGridWarning? = null,
/** User-facing upload / record message ("" when none). */
val message: String = "",
val postText: String? = null,
val busy: Boolean = false
val busy: Boolean = false,
/** Out-of-window contact waiting for the operator's confirmation (null when closed). */
val outOfWindowLog: OutOfWindowLog? = null
)
class LogViewModel(
@@ -101,19 +124,64 @@ class LogViewModel(
settingsRepo.setSatelliteMode(catnum, mode)
}
fun record(satName: String, mode: String, txHz: Long?, rxHz: Long?, myGrid: String) {
fun record(
satName: String,
mode: String,
txHz: Long?,
rxHz: Long?,
myGrid: String,
passWindow: ClosedRange<Long>?
) {
val call = _uiState.value.callsignInput.trim()
if (call.isEmpty()) return
val normalizedMode = mode.ifBlank { "FM" }.uppercase(Locale.US)
// Whole minutes (seconds zeroed): matches the edit dialog's HH:mm granularity and the
// minute-resolution of LoTW reports, so re-saving an unchanged record never looks edited.
val now = System.currentTimeMillis() / 60_000L * 60_000L
// Logging while the clock sits outside the current pass window (after LOS, before AOS,
// or against a pass card that is not the one being worked): the log page only shows
// records inside [aos, los], so a "now" timestamp could never appear there. Ask the
// operator first; the confirmed contact is stored at the pass midpoint (always inside
// the window) and can be re-dated later from the edit dialog.
if (passWindow != null && now !in passWindow) {
val midpoint = ((passWindow.start + passWindow.endInclusive) / 2L)
.let { it / 60_000L * 60_000L }
.coerceIn(passWindow.start, passWindow.endInclusive)
_uiState.update {
it.copy(outOfWindowLog = OutOfWindowLog(call, satName, mode, txHz, rxHz, myGrid, midpoint))
}
return
}
storeRecord(call, satName, mode, txHz, rxHz, myGrid, now)
}
/** The operator confirmed the out-of-window notice: store the contact at the pass midpoint. */
fun confirmOutOfWindowLog() {
val pending = _uiState.value.outOfWindowLog ?: return
storeRecord(
pending.callsign, pending.satName, pending.mode,
pending.txHz, pending.rxHz, pending.myGrid, pending.midpointUtcMillis
)
_uiState.update { it.copy(outOfWindowLog = null) }
}
fun dismissOutOfWindowLog() = _uiState.update { it.copy(outOfWindowLog = null) }
private fun storeRecord(
call: String,
satName: String,
mode: String,
txHz: Long?,
rxHz: Long?,
myGrid: String,
timeMillis: Long
) {
val normalizedMode = mode.ifBlank { "FM" }.uppercase(Locale.US)
// kHz granularity (3 decimals in MHz) — enough for operating and for the ADIF export.
val tx = txHz?.let { (it + 500L) / 1000L * 1000L }
val rx = rxHz?.let { (it + 500L) / 1000L * 1000L }
val record = QsoRecord(
startUtcMillis = now,
endUtcMillis = now,
startUtcMillis = timeMillis,
endUtcMillis = timeMillis,
theirCallsign = call,
myCallsign = _uiState.value.certificateCallsign,
myGrid = myGrid.take(6).uppercase(Locale.US),
@@ -200,7 +268,7 @@ class LogViewModel(
// Only the records that actually made it into the TQ8 may be
// marked uploaded later — never the whole candidate list.
lastUploadedIds = preview.submittedIds
_uiState.update { it.copy(busy = false, preview = preview) }
_uiState.update { it.copy(busy = false, preview = preview, uploadGridWarning = preview.gridWarning) }
} catch (e: LoTWOperationException) {
_uiState.update { it.copy(busy = false, message = "Upload unavailable: ${e.reason}") }
} catch (_: Exception) {
@@ -215,8 +283,9 @@ class LogViewModel(
_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)
// Mark the submitted QSOs as uploaded (distinct from confirmed) and stamp
// the station grids this batch went out under.
qsoRepository.markUploaded(lastUploadedIds, preview.grids)
lastUploadedIds = emptyList()
}
val msg = when (result) {
@@ -231,6 +300,16 @@ class LogViewModel(
fun dismissPreview() = _uiState.update { it.copy(preview = null) }
/** Operator chose "ignore" on the grid check: keep the prepared preview. */
fun ignoreGridWarning() = _uiState.update { it.copy(uploadGridWarning = null) }
/** Operator chose to fix the station grid first: drop the prepared preview and leave. */
fun abandonForGridFix() {
lotwUploadRepository.discardPreview()
lastUploadedIds = emptyList()
_uiState.update { it.copy(uploadGridWarning = null, preview = null) }
}
fun clearMessage() = _uiState.update { it.copy(message = "") }
companion object {
@@ -175,7 +175,7 @@ internal fun radarFillSizes(maxWidth: Dp, maxHeight: Dp): RadarFillSizes {
}
@Composable
fun RadarDestination(navigateUp: () -> Unit) {
fun RadarDestination(navigateUp: () -> Unit, onOpenLoTWStation: () -> Unit = {}) {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
@@ -216,9 +216,15 @@ fun RadarDestination(navigateUp: () -> Unit) {
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
viewModel.onAction(RadarAction.CwPermissionResult(granted))
}
// Grid-check "fix it" jump from the log page: hand the affected records' grids to the
// station-location page as its prefill (mirrors the Grid Finder's hand-off).
val openLoTWStationForGridFix: (List<String>) -> Unit = { grids ->
if (grids.isNotEmpty()) container.setPendingLoTWStationGrid(grids.joinToString(","))
onOpenLoTWStation()
}
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, logViewModel, requestMicPermission = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
})
}, onFixGrid = openLoTWStationForGridFix)
}
@Composable
@@ -228,7 +234,8 @@ private fun RadarScreen(
navigateUp: () -> Unit,
mutualData: MutualPassData,
logViewModel: LogViewModel,
requestMicPermission: () -> Unit
requestMicPermission: () -> Unit,
onFixGrid: (List<String>) -> Unit
) {
val upcomingPass = uiState.currentPass ?: getDefaultPass()
// 日程功能: 把当前过境写入系统日历(原仓库的 addToCalendar, ic_calendar 按钮)
@@ -308,16 +315,17 @@ private fun RadarScreen(
onAction = onAction,
logViewModel = logViewModel,
requestMicPermission = requestMicPermission,
onFixGrid = onFixGrid,
modifier = Modifier.weight(1f)
)
} else {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f), onFixGrid = onFixGrid)
}
} else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f), onFixGrid = onFixGrid)
}
}
}
@@ -338,6 +346,7 @@ private fun RadarFillArea(
onAction: (RadarAction) -> Unit,
logViewModel: LogViewModel,
requestMicPermission: () -> Unit,
onFixGrid: (List<String>) -> Unit,
modifier: Modifier = Modifier
) {
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
@@ -351,7 +360,7 @@ private fun RadarFillArea(
verticalArrangement = Arrangement.spacedBy(ROW_GAP)
) {
RadarCard(uiState, trackB, trackBPosition, Modifier.height(sizes.radarSide).fillMaxWidth())
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.height(sizes.pagerSpace))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.height(sizes.pagerSpace), onFixGrid = onFixGrid)
}
} else {
Box(modifier = Modifier.fillMaxSize()) {
@@ -361,6 +370,7 @@ private fun RadarFillArea(
onAction = onAction,
logViewModel = logViewModel,
requestMicPermission = requestMicPermission,
onFixGrid = onFixGrid,
panelHeight = pagerPanelHeight,
modifier = Modifier.align(Alignment.BottomCenter)
)
@@ -380,6 +390,7 @@ private fun CompactPagerOverlay(
onAction: (RadarAction) -> Unit,
logViewModel: LogViewModel,
requestMicPermission: () -> Unit,
onFixGrid: (List<String>) -> Unit,
panelHeight: Dp,
modifier: Modifier = Modifier
) {
@@ -420,6 +431,7 @@ private fun CompactPagerOverlay(
onAction = onAction,
logViewModel = logViewModel,
requestMicPermission = requestMicPermission,
onFixGrid = onFixGrid,
modifier = modifier
.fillMaxWidth()
.height(panelHeight),
@@ -453,7 +465,8 @@ private fun PagerCard(
requestMicPermission: () -> Unit,
modifier: Modifier = Modifier,
startPage: RadarPage? = null,
onCollapse: (() -> Unit)? = null
onCollapse: (() -> Unit)? = null,
onFixGrid: (List<String>) -> Unit = {}
) {
val pages = rememberRadarPages(uiState)
val pagerState = rememberPagerState(
@@ -515,7 +528,8 @@ private fun PagerCard(
)
RadarPage.Log -> LogPage(
uiState = uiState,
logViewModel = logViewModel
logViewModel = logViewModel,
onFixGrid = onFixGrid
)
RadarPage.Sstv -> SstvPage(
sstv = uiState.sstv,