feat(wavelog,radar,settings): WaveLog logbook integration + Log tab

Background: satellite operators want to log QSOs during a pass while
watching live frequencies. 4.5.2 adds WaveLog (logbook server) API v2
integration: log from the radar page, upload to a self-hosted WaveLog
instance with grid-mismatch protection.

Changes:
- Radar page: new third tab "Log" between Transceivers and SSTV
  - pick a transponder, watch live TX/RX Doppler-corrected frequencies
  - enter callsign, Enter stores locally (UTC time + that second's
    frequencies sampled together)
  - local entry list shows time/frequency/callsign only (no upload
    status, per user: proves the entry was saved)
  - swipe-to-delete: yellow trash while swiping, turns into Undo at
    75%, 5s countdown before auto-delete (custom gesture, no
    SwipeToDismissBox)
- Settings: new WaveLog card (server URL / API key / station ID /
  auto-upload switch / test connection / upload now buttons) matching
  the user's reference screenshot layout
- Upload pipeline: POST /index.php/api/v2/qso with required fields
  (station_profile_id, call, band=SAT, mode, qso_date, time_on UTC)
  plus freq/freq_rx (Hz), gridsquare (from station profile via
  GET /api/v2/station/{id}), sat_name; RST omitted per user
- Grid check: station gridsquare (first 4) vs user QTH (first 4);
  mismatch shows a confirm dialog (ignore & upload / cancel)
- Auto upload: 10-minute in-app retry loop (only when switch on);
  manual upload button; local queue capped at 500, all entries stored
  locally regardless of switch
- Fixed: subMenuOrder was never persisted (4.5.1 regression)
- core/domain: compileOnly org.json (runtime uses Android's)
- Version 4.5.2 (452)

Verified: check_strings.py OK (8 files, 452/4.5.2);
:app:compileDebugKotlin BUILD SUCCESSFUL locally.
This commit is contained in:
mckero committed 2026-08-04 10:29:40 +00:00
1 parent 38cf7a3569
commit 916a0d0f0a
18 files changed
+1064 -20

No files matched your search

@@ -0,0 +1,380 @@
/*
* LogTab.kt — 雷达页「Log」标签(4.5.2 WaveLog 日志)。
*
* 布局: 转发器列表(点开选一个, 边看频率边填) → 呼号输入回车存本地 →
* 下方本地记录列表(时间/频率/呼号)。记录支持自绘左滑删除:
* 滑动中显示黄色垃圾桶, 滑过 75% 变撤销键, 5 秒内不点撤销则删除。
*
* 回车只存本地(WavelogQueue), 上传由 10 分钟周期/手动触发(防抄收错误)。
*/
package com.rtbishop.look4sat.feature.radar
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQso
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.formatFrequency
import kotlinx.coroutines.delay
import java.util.Calendar
import java.util.TimeZone
import java.util.UUID
import kotlin.math.roundToInt
private val WaveLogYellow = Color(0xFFFFC107)
@Composable
fun LogTab(
transceivers: List<SatRadio>,
orbitalPos: OrbitalPos?,
satelliteName: String,
queue: WavelogQueue,
wavelogConfigured: Boolean,
showToast: (String) -> Unit,
modifier: Modifier = Modifier
) {
var selectedUuid by remember { mutableStateOf<String?>(null) }
var refreshTick by remember { mutableIntStateOf(0) }
val entries = remember(refreshTick) { queue.all() }
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
if (!wavelogConfigured) {
Text(
text = stringResource(id = R.string.wavelog_need_config),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// 转发器选择: 点开一个, 边看频率边填日志
transceivers.forEach { radio ->
val isExpanded = radio.uuid == selectedUuid
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { selectedUuid = if (isExpanded) null else radio.uuid },
colors = CardDefaults.cardColors(
containerColor = if (isExpanded) MaterialTheme.colorScheme.surfaceContainerHighest
else MaterialTheme.colorScheme.surfaceContainer
)
) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = radio.info,
fontSize = 13.sp,
fontWeight = if (isExpanded) FontWeight.Bold else FontWeight.Normal,
modifier = Modifier.weight(1f)
)
Text(
text = if (isExpanded) "▾" else "▸",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (isExpanded) {
ExpandedLogInput(
radio = radio,
orbitalPos = orbitalPos,
satelliteName = satelliteName,
queue = queue,
showToast = showToast,
onSaved = { refreshTick++ }
)
}
}
}
}
// 本地记录列表(时间/频率/呼号)
Text(
text = stringResource(id = R.string.wavelog_saved),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (entries.isEmpty()) {
Text(
text = stringResource(id = R.string.wavelog_empty),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp)
)
} else {
entries.forEach { entry ->
SwipeDeleteRow(
onDelete = {
queue.remove(entry.id)
refreshTick++
}
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 10.dp, vertical = 8.dp)
) {
Text(
text = formatLocalTime(entry.timeUtcMs),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(64.dp)
)
Text(
text = formatFrequency(entry.freqTxHz),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(76.dp)
)
Text(
text = entry.call,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
}
}
}
}
}
}
/** 转发器展开输入区: 实时频率 + 呼号(回车存本地) + 模式修正 */
@Composable
private fun ExpandedLogInput(
radio: SatRadio,
orbitalPos: OrbitalPos?,
satelliteName: String,
queue: WavelogQueue,
showToast: (String) -> Unit,
onSaved: () -> Unit
) {
var callsign by remember { mutableStateOf("") }
var mode by remember { mutableStateOf(radio.uplinkMode ?: "FM") }
// 回车那一刻的多普勒修正频率(UTC 时间一并采样)
fun currentFreqs(): Pair<Long, Long> {
val uplinkBase = radio.uplinkLow ?: radio.downlinkLow ?: 0L
val downlinkBase = radio.downlinkLow ?: radio.uplinkLow ?: 0L
val tx = if (orbitalPos != null) orbitalPos.getUplinkFreq(uplinkBase) else uplinkBase
val rx = if (orbitalPos != null) orbitalPos.getDownlinkFreq(downlinkBase) else downlinkBase
return tx to rx
}
val savedMsg = stringResource(id = R.string.wavelog_saved)
fun submit() {
val call = callsign.trim().uppercase()
if (call.length < 3) return
val (tx, rx) = currentFreqs()
queue.add(
WavelogQso(
id = UUID.randomUUID().toString(),
timeUtcMs = System.currentTimeMillis(),
call = call,
mode = mode.trim().ifBlank { "FM" }.uppercase(),
freqTxHz = tx,
freqRxHz = rx,
satName = satelliteName
)
)
callsign = ""
onSaved()
showToast(savedMsg)
}
val (txHz, rxHz) = currentFreqs()
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = "TX ${formatFrequency(txHz)}",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "RX ${formatFrequency(rxHz)}",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
}
OutlinedTextField(
value = callsign,
onValueChange = { callsign = it.take(12) },
label = { Text(stringResource(id = R.string.wavelog_call_hint)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { submit() }),
textStyle = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = mode,
onValueChange = { mode = it.take(8) },
label = { Text(stringResource(id = R.string.wavelog_mode_hint)) },
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth()
)
}
}
/**
* 自绘左滑删除行(用户定制交互):
* 左滑 → 右侧黄色垃圾桶; 滑过 75% → 变撤销键(待删除, 不立即删);
* 5 秒倒计时内点撤销恢复, 不点自动删除。
*/
@Composable
private fun SwipeDeleteRow(
onDelete: () -> Unit,
content: @Composable () -> Unit
) {
var offsetX by remember { mutableStateOf(0f) }
var pending by remember { mutableStateOf(false) }
var countdown by remember { mutableIntStateOf(5) }
var rowWidth by remember { mutableIntStateOf(0) }
val threshold = rowWidth * 0.75f
// 倒计时: 进入待删除后 5 秒自动删
LaunchedEffect(pending) {
if (pending) {
countdown = 5
while (countdown > 0) {
delay(1000)
countdown--
}
if (pending) {
onDelete()
pending = false
offsetX = 0f
}
}
}
Box(modifier = Modifier.fillMaxWidth()) {
// 背景层(右侧图标: 垃圾桶 / 撤销+倒计时)
Box(
modifier = Modifier
.matchParentSize()
.background(WaveLogYellow.copy(alpha = 0.25f)),
contentAlignment = Alignment.CenterEnd
) {
if (pending) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(end = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "$countdown",
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = WaveLogYellow
)
Text(
text = stringResource(id = R.string.wavelog_delete_undo),
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = WaveLogYellow,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(WaveLogYellow.copy(alpha = 0.15f))
.clickable {
pending = false
offsetX = 0f
}
.padding(horizontal = 10.dp, vertical = 6.dp)
)
}
} else {
// 垃圾桶(滑动过程中一直显示, 黄色)
Text(
text = "🗑",
fontSize = 20.sp,
modifier = Modifier.padding(end = 16.dp)
)
}
}
// 内容层(跟手滑动)
Box(
modifier = Modifier
.offset { IntOffset(offsetX.roundToInt(), 0) }
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { if (pending) { pending = false } },
onHorizontalDrag = { change, dragAmount ->
change.consume()
offsetX = (offsetX + dragAmount).coerceIn(-rowWidth.toFloat(), 0f)
},
onDragEnd = {
if (offsetX <= -threshold) {
pending = true
countdown = 5
} else {
offsetX = 0f
}
},
onDragCancel = { offsetX = 0f }
)
}
) {
content()
}
}
}
private fun formatLocalTime(utcMs: Long): String {
val cal = Calendar.getInstance(TimeZone.getDefault())
cal.timeInMillis = utcMs
return "%02d:%02d".format(cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE))
}
@@ -61,6 +61,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.wavelog.UploadOutcome
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard
@@ -78,6 +80,7 @@ import kotlin.math.PI
private enum class RadarPage(val title: String) {
Transceivers("Transceivers"),
Log("Log"),
Sstv("SSTV")
}
@@ -119,9 +122,33 @@ fun RadarDestination(navigateUp: () -> Unit) {
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
viewModel.onAction(RadarAction.CwPermissionResult(granted))
}
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
})
// WaveLog(4.5.2): 自动上传 — 每 10 分钟重试本地队列(开关开启时)
val wavelogUploader = remember { container.provideWavelogUploader() }
LaunchedEffect(Unit) {
while (true) {
delay(10 * 60 * 1000L)
val s = container.settingsRepo.otherSettings.value
if (s.wavelogAutoUpload && s.wavelogUrl.isNotBlank()) {
// 网格不一致时静默跳过(留给手动上传确认), 其余失败自动下次重试
val outcome = wavelogUploader.uploadQueue()
if (outcome is UploadOutcome.NeedConfirm) {
// 跳过本次; 用户可在设置页手动上传并确认
}
}
}
}
RadarScreen(
uiState,
viewModel::onAction,
navigateUpAndClearMutual,
mutualData,
{ permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) },
wavelogQueue = container.wavelogQueue,
wavelogConfigured = container.settingsRepo.otherSettings.value.let {
it.wavelogUrl.isNotBlank() && it.wavelogApiKey.isNotBlank() && it.wavelogStationId.isNotBlank()
},
showToast = { msg -> container.provideShowToast()(msg) }
)
}
@Composable
@@ -130,7 +157,10 @@ private fun RadarScreen(
onAction: (RadarAction) -> Unit,
navigateUp: () -> Unit,
mutualData: MutualPassData,
requestMicPermission: () -> Unit
requestMicPermission: () -> Unit,
wavelogQueue: WavelogQueue,
wavelogConfigured: Boolean,
showToast: (String) -> Unit
) {
val upcomingPass = uiState.currentPass ?: getDefaultPass()
val addToCalendar: () -> Unit = {
@@ -184,11 +214,11 @@ private fun RadarScreen(
}
if (isVertical) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, Modifier.weight(1f))
} else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, Modifier.weight(1f))
}
}
}
@@ -199,6 +229,9 @@ private fun PagerCard(
uiState: RadarState,
onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit,
wavelogQueue: WavelogQueue,
wavelogConfigured: Boolean,
showToast: (String) -> Unit,
modifier: Modifier = Modifier
) {
val pages = RadarPage.entries
@@ -236,6 +269,14 @@ private fun PagerCard(
onAction = onAction,
requestMicPermission = requestMicPermission
)
RadarPage.Log -> LogTab(
transceivers = uiState.transceivers.transmitters,
orbitalPos = uiState.orbitalPos,
satelliteName = uiState.currentPass?.name ?: "",
queue = wavelogQueue,
wavelogConfigured = wavelogConfigured,
showToast = showToast
)
}
}
}