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

@@ -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() }
}
}
}