mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-05 20:17:29 +00:00
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:
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,
|
||||
|
||||
Reference in new issue
Block a user