fix(wavelog,radar): 4.5.2 user-test fixes (404 upload, swipe delete, transponder picker, log page)

Background: user tested 4.5.2 and reported 4 issues. Same-version
overwrite per user (4.5.2 exists solely for the logbook system).

Fixes:
1. Upload 404 — root cause: user server URL ending in /index.php was
   concatenated again (/index.php/index.php/api/v2/...) -> 404. Now:
   - normalizeUrl strips trailing /index.php
   - every request tries the index.php path first, falls back to the
     rewritten path on 404
   - 409 conflict (duplicate QSO) counts as success (moves out of queue)
   - failure messages include the actual URL + HTTP code for debugging
2. Swipe-to-delete was dead: rowWidth was never measured (0) so the
   75% threshold was 0 and the drag was clamped to 0. Now measured via
   onSizeChanged + smooth spring/tween snap-back animation.
3. Transponder picker: long card list replaced with an
   ExposedDropdownMenuBox dropdown (scrollable menu, pick one).
4. New Log page under the More menu: table view (time / frequency /
   satellite / callsign / uploaded checkmark). WavelogQso gains
   uploaded flag; uploader marks instead of removing; queue keeps
   uploaded entries (500 cap). Old persisted subMenuOrder gets
   WavelogLog appended (migration).

Verified: check_strings.py OK (8 files, 452/4.5.2);
:app:compileDebugKotlin BUILD SUCCESSFUL.
This commit is contained in:
mckero committed 2026-08-04 11:06:47 +00:00
1 parent 916a0d0f0a
commit 5351afe508
13 files changed
+407 -113

No files matched your search

@@ -9,6 +9,10 @@
*/
package com.rtbishop.look4sat.feature.radar
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
@@ -20,6 +24,7 @@ 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.ui.layout.onSizeChanged
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -29,8 +34,11 @@ 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.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -51,6 +59,7 @@ 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.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -68,6 +77,7 @@ import kotlin.math.roundToInt
private val WaveLogYellow = Color(0xFFFFC107)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LogTab(
transceivers: List<SatRadio>,
@@ -97,46 +107,58 @@ fun LogTab(
)
}
// 转发器选择: 点开一个, 边看频率边填日志
transceivers.forEach { radio ->
val isExpanded = radio.uuid == selectedUuid
Card(
// 转发器选择(下拉选择框): 选中后底下出现输入区
var menuExpanded by remember { mutableStateOf(false) }
var selectedRadio by remember { mutableStateOf<SatRadio?>(null) }
ExposedDropdownMenuBox(
expanded = menuExpanded,
onExpandedChange = { menuExpanded = it }
) {
OutlinedTextField(
value = selectedRadio?.info ?: "",
onValueChange = {},
readOnly = true,
label = { Text(stringResource(id = R.string.wavelog_select_transponder)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = menuExpanded) },
textStyle = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
.fillMaxWidth()
.clickable { selectedUuid = if (isExpanded) null else radio.uuid },
colors = CardDefaults.cardColors(
containerColor = if (isExpanded) MaterialTheme.colorScheme.surfaceContainerHighest
else MaterialTheme.colorScheme.surfaceContainer
)
)
ExposedDropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }
) {
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++ }
)
}
transceivers.forEach { radio ->
DropdownMenuItem(
text = {
Text(
text = radio.info,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
onClick = {
selectedRadio = radio
menuExpanded = false
}
)
}
}
}
// 选中转发器后: 输入区(实时频率 + 呼号 + 模式)
selectedRadio?.let { radio ->
ExpandedLogInput(
radio = radio,
orbitalPos = orbitalPos,
satelliteName = satelliteName,
queue = queue,
showToast = showToast,
onSaved = { refreshTick++ }
)
}
// 本地记录列表(时间/频率/呼号)
Text(
text = stringResource(id = R.string.wavelog_saved),
@@ -275,7 +297,7 @@ private fun ExpandedLogInput(
* 5 秒倒计时内点撤销恢复, 不点自动删除。
*/
@Composable
private fun SwipeDeleteRow(
internal fun SwipeDeleteRow(
onDelete: () -> Unit,
content: @Composable () -> Unit
) {
@@ -285,6 +307,13 @@ private fun SwipeDeleteRow(
var rowWidth by remember { mutableIntStateOf(0) }
val threshold = rowWidth * 0.75f
// 平滑弹回/滑入(动画)
val animatedOffset by animateFloatAsState(
targetValue = offsetX,
animationSpec = if (pending) tween(200) else spring(dampingRatio = Spring.DampingRatioNoBouncy),
label = "swipeOffset"
)
// 倒计时: 进入待删除后 5 秒自动删
LaunchedEffect(pending) {
if (pending) {
@@ -301,7 +330,11 @@ private fun SwipeDeleteRow(
}
}
Box(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { rowWidth = it.width }
) {
// 背景层(右侧图标: 垃圾桶 / 撤销+倒计时)
Box(
modifier = Modifier
@@ -348,7 +381,7 @@ private fun SwipeDeleteRow(
// 内容层(跟手滑动)
Box(
modifier = Modifier
.offset { IntOffset(offsetX.roundToInt(), 0) }
.offset { IntOffset(animatedOffset.roundToInt(), 0) }
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { if (pending) { pending = false } },
@@ -0,0 +1,147 @@
/*
* WavelogLogScreen.kt — 「日志」页面(更多菜单, 4.5.2 修复)。
*
* 表格形式展示本地存储的日志: 时间 | 频率 | 卫星 | 呼号 | 已上传(✓)。
* 数据来自 WavelogQueue(与雷达页 Log 标签共用)。空态提示; 表格行
* 复用左滑删除(同 LogTab 交互)。
*/
package com.rtbishop.look4sat.feature.radar
import androidx.compose.foundation.background
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.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.formatFrequency
import java.util.Calendar
import java.util.TimeZone
private val CheckGreen = Color(0xFF4CAF50)
@Composable
fun WavelogLogScreen(
queue: WavelogQueue,
modifier: Modifier = Modifier
) {
var refreshTick by remember { mutableIntStateOf(0) }
val entries = remember(refreshTick) { queue.all() }
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// 表头
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 6.dp)
) {
HeaderCell(stringResource(id = R.string.wavelog_col_time), 52.dp)
HeaderCell(stringResource(id = R.string.wavelog_col_freq), 70.dp)
HeaderCell(stringResource(id = R.string.wavelog_col_sat), 0.dp, weight = 1.2f)
HeaderCell(stringResource(id = R.string.wavelog_col_call), 0.dp, weight = 1f)
HeaderCell(stringResource(id = R.string.wavelog_col_uploaded), 44.dp)
}
if (entries.isEmpty()) {
Text(
text = stringResource(id = R.string.wavelog_empty),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 12.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)
) {
Cell(formatLocalTime(entry.timeUtcMs), 52.dp)
Cell(formatFrequency(entry.freqTxHz), 70.dp)
Cell(entry.satName, 0.dp, weight = 1.2f)
Cell(entry.call, 0.dp, weight = 1f)
Box(modifier = Modifier.width(44.dp)) {
if (entry.uploaded) {
Text(
text = "✓",
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = CheckGreen
)
}
}
}
}
}
}
}
}
@Composable
private fun RowScope.HeaderCell(text: String, width: androidx.compose.ui.unit.Dp, weight: Float = 0f) {
val mod = if (weight > 0f) Modifier.weight(weight) else Modifier.width(width)
Text(
text = text,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
modifier = mod
)
}
@Composable
private fun RowScope.Cell(text: String, width: androidx.compose.ui.unit.Dp, weight: Float = 0f) {
val mod = if (weight > 0f) Modifier.weight(weight) else Modifier.width(width)
Text(
text = text,
fontSize = 12.sp,
maxLines = 1,
modifier = mod
)
}
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))
}