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
)
}
}
}
@@ -46,14 +46,18 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
@@ -80,6 +84,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -111,6 +116,34 @@ fun SettingsDestination() {
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SettingsScreen(uiState, viewModel::onAction)
// WaveLog 网格不一致确认弹窗(4.5.2)
val gridConfirm = viewModel.gridConfirm
if (gridConfirm != null) {
AlertDialog(
onDismissRequest = { viewModel.resolveGridConfirm(false) },
title = { Text(text = stringResource(id = R.string.wavelog_title)) },
text = {
Text(
text = stringResource(
id = R.string.wavelog_grid_mismatch,
gridConfirm.userGrid.take(4),
gridConfirm.stationGrid.take(4)
)
)
},
confirmButton = {
TextButton(onClick = { viewModel.resolveGridConfirm(true) }) {
Text(text = stringResource(id = R.string.wavelog_ignore))
}
},
dismissButton = {
TextButton(onClick = { viewModel.resolveGridConfirm(false) }) {
Text(text = stringResource(id = R.string.wavelog_cancel))
}
}
)
}
}
@Composable
@@ -326,6 +359,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
)
}
item { OtherCard(uiState.otherSettings, onAction) }
item { WavelogCard(uiState.otherSettings, onAction) }
item {
UiSettingsCard(
hiddenScreens = uiState.otherSettings.hiddenScreens,
@@ -558,6 +592,71 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
* 顺序固定(卫星/过境/雷达/匹配/漫游/地图/设置), 关闭后其余自动靠拢;
* "设置"页固定显示(防止失去设置入口)。
*/
/** WaveLog 日志服务器设置卡片(4.5.2): 照用户截图布局(深色卡片/标签左输入右/按钮行) */
@Composable
private fun WavelogCard(
settings: OtherSettings,
onAction: (SettingsAction) -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = stringResource(id = R.string.prefs_wavelog_title),
color = MaterialTheme.colorScheme.primary
)
OutlinedTextField(
value = settings.wavelogUrl,
onValueChange = {
onAction(SettingsAction.UpdateWavelogSettings(it, settings.wavelogApiKey, settings.wavelogStationId, settings.wavelogAutoUpload))
},
label = { Text(stringResource(id = R.string.prefs_wavelog_url_hint)) },
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 14.sp),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = settings.wavelogApiKey,
onValueChange = {
onAction(SettingsAction.UpdateWavelogSettings(settings.wavelogUrl, it, settings.wavelogStationId, settings.wavelogAutoUpload))
},
label = { Text(stringResource(id = R.string.prefs_wavelog_key_hint)) },
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 14.sp),
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = settings.wavelogStationId,
onValueChange = {
onAction(SettingsAction.UpdateWavelogSettings(settings.wavelogUrl, settings.wavelogApiKey, it, settings.wavelogAutoUpload))
},
label = { Text(stringResource(id = R.string.prefs_wavelog_station_hint)) },
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 14.sp),
modifier = Modifier.fillMaxWidth()
)
SwitchRow(R.string.prefs_wavelog_auto, settings.wavelogAutoUpload) {
onAction(SettingsAction.UpdateWavelogSettings(settings.wavelogUrl, settings.wavelogApiKey, settings.wavelogStationId, it))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CardButton(
onClick = { onAction(SettingsAction.TestWavelogConnection) },
text = stringResource(id = R.string.prefs_wavelog_test),
modifier = Modifier.weight(1f)
)
CardButton(
onClick = { onAction(SettingsAction.UploadWavelogQueue) },
text = stringResource(id = R.string.prefs_wavelog_upload),
modifier = Modifier.weight(1f)
)
}
}
}
}
@Composable
private fun UiSettingsCard(
hiddenScreens: List<String>,
@@ -72,8 +72,16 @@ sealed interface SettingsAction {
data object ResetScreenOrder : SettingsAction
// UI 设置: 更新主菜单 + 更多菜单顺序(4.5.1 折叠菜单)
data class UpdateMenuOrder(val mainOrder: List<String>, val subOrder: List<String>) : SettingsAction
// UI 设置: 重置主/子菜单为默认
// UI 设置: 重置主菜单 + 更多菜单顺序
data object ResetMenuOrder : SettingsAction
// WaveLog 日志(4.5.2): 更新服务器配置
data class UpdateWavelogSettings(
val url: String, val apiKey: String, val stationId: String, val autoUpload: Boolean
) : SettingsAction
// WaveLog: 测试连接
data object TestWavelogConnection : SettingsAction
// WaveLog: 手动上传队列
data object UploadWavelogQueue : SettingsAction
// Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction
@@ -25,11 +25,18 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.wavelog.UploadOutcome
import com.rtbishop.look4sat.core.domain.wavelog.WaveLogApi
import com.rtbishop.look4sat.core.domain.wavelog.WavelogResult
import com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
import com.rtbishop.look4sat.core.presentation.R
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
class SettingsViewModel(
private val databaseRepo: IDatabaseRepo,
@@ -138,6 +145,17 @@ class SettingsViewModel(
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
// WaveLog 日志(4.5.2)
is SettingsAction.UpdateWavelogSettings -> settingsRepo.updateOtherSettings {
it.copy(
wavelogUrl = action.url,
wavelogApiKey = action.apiKey,
wavelogStationId = action.stationId,
wavelogAutoUpload = action.autoUpload
)
}
SettingsAction.TestWavelogConnection -> testWavelogConnection()
SettingsAction.UploadWavelogQueue -> uploadWavelogQueue()
// System
is SettingsAction.ShowToast -> showToast(action.message)
}
@@ -210,6 +228,58 @@ class SettingsViewModel(
}
// endregion
// region WaveLog(4.5.2)
private fun testWavelogConnection() {
viewModelScope.launch {
val s = settingsRepo.otherSettings.value
val result = WaveLogApi.testToken(s.wavelogUrl, s.wavelogApiKey)
showToast(
when (result) {
is WavelogResult.Success -> "WaveLog: 连接成功"
is WavelogResult.Failure -> "WaveLog: ${result.message}"
}
)
}
}
// 由 SettingsScreen 注入的共享 uploader(容器级单例)
var pendingUploader: WavelogUploader? = null
// 网格不一致确认弹窗状态(UI 观察后弹 AlertDialog)
var gridConfirm by mutableStateOf<GridConfirmData?>(null)
data class GridConfirmData(val stationGrid: String, val userGrid: String)
private fun uploadWavelogQueue() {
viewModelScope.launch {
val result = pendingUploader?.uploadQueue()
when (result) {
is UploadOutcome.Done -> showToast("WaveLog: ${result.message}")
is UploadOutcome.NeedConfirm -> {
gridConfirm = GridConfirmData(result.stationGrid, result.userGrid)
}
null -> showToast("WaveLog: 上传失败")
}
}
}
/** 网格确认结果: 忽略并上传 / 取消 */
fun resolveGridConfirm(ignoreAndUpload: Boolean) {
val confirm = gridConfirm ?: return
gridConfirm = null
if (!ignoreAndUpload) return
viewModelScope.launch {
val result = pendingUploader?.uploadQueue(force = true)
when (result) {
is UploadOutcome.Done -> showToast("WaveLog: ${result.message}")
else -> showToast("WaveLog: 上传失败")
}
}
}
// endregion
companion object {
@@ -220,7 +290,7 @@ class SettingsViewModel(
databaseRepo = container.databaseRepo,
settingsRepo = container.settingsRepo,
showToast = container.provideShowToast()
)
).apply { pendingUploader = container.provideWavelogUploader() }
}
}
}