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

@@ -96,6 +96,7 @@ import com.rtbishop.look4sat.feature.mutual.MutualScreen
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
import com.rtbishop.look4sat.feature.passes.PassesDestination
import com.rtbishop.look4sat.feature.radar.RadarDestination
import com.rtbishop.look4sat.feature.radar.WavelogLogScreen
import com.rtbishop.look4sat.feature.roaming.RoamingScreen
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
import com.rtbishop.look4sat.feature.settings.SettingsDestination
@@ -158,7 +159,9 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
}
.filter { it.screenId !in otherSettings.hiddenScreens || it is Screen.Settings }
// 4.5.1 折叠菜单: 主菜单(底部栏 5 槽) + 更多菜单(溢出页面)
val subOrder = otherSettings.subMenuOrder.ifEmpty { com.rtbishop.look4sat.core.presentation.defaultSubMenuOrder }
// 老用户迁移: 已持久化的 subMenuOrder 不含新页面 WavelogLog → 追加到子菜单尾部
val subOrder = (otherSettings.subMenuOrder.ifEmpty { com.rtbishop.look4sat.core.presentation.defaultSubMenuOrder })
.let { list -> if ("WavelogLog" in list) list else list + "WavelogLog" }
val mainNavItems = remember(allNavItems, subOrder) {
allNavItems.filter { it.screenId !in subOrder }.take(5)
}
@@ -189,6 +192,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
is Screen.Radar -> screen is Screen.Radar
is Screen.Mutual -> screen is Screen.Mutual
is Screen.CwDecode -> screen is Screen.CwDecode
is Screen.WavelogLog -> screen is Screen.WavelogLog
is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings
else -> false
@@ -279,6 +283,9 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
entry<Screen.CwDecode> {
CwDecodeScreen()
}
entry<Screen.WavelogLog> {
WavelogLogScreen(queue = container.wavelogQueue)
}
entry<Screen.Settings> {
SettingsDestination()
}
@@ -65,6 +65,7 @@ fun MoreMenuPopup(
is Screen.Radar -> screen is Screen.Radar
is Screen.Mutual -> screen is Screen.Mutual
is Screen.CwDecode -> screen is Screen.CwDecode
is Screen.WavelogLog -> screen is Screen.WavelogLog
is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings
else -> false
@@ -1,19 +1,22 @@
/*
* WaveLogApi.kt — WaveLog 日志服务器 API v2 客户端(4.5.2)。
* WaveLogApi.kt — WaveLog 日志服务器 API v2 客户端(4.5.2, 覆盖修复)。
*
* 端点(文档 /temporary/wavelog_docs/docs/developer/api-v2/):
* GET {url}/index.php/api/v2/token whoami(测试连接,任意有效 token)
* GET {url}/index.php/api/v2/station/{id} 站点信息(id/name/gridsquare)
* POST {url}/index.php/api/v2/qso 创建 QSO(qso:write)
* GET {base}/api/v2/token whoami(测试连接,任意有效 token)
* GET {base}/api/v2/station/{id} 站点信息(id/name/gridsquare)
* POST {base}/api/v2/qso 创建 QSO(qso:write)
* 认证: Authorization: Bearer wl2_xxx(v2 token)
*
* 轻量实现: HttpURLConnection + 协程(Dispatchers.IO),不引新依赖。
* URL 处理(用户实测 404 修复):
* 1. normalizeUrl 去掉用户输入的尾部 /index.php(我们自己拼, 避免重复)
* 2. 先试带 index.php 的路径; 404 时自动回退到不带 index.php(服务器重写配置)
* 3. 409 conflict = 重复 QSO(已存在) → 视为成功(移出队列)
* 4. 失败信息带实际 URL + HTTP 码, 便于排查
*/
package com.rtbishop.look4sat.core.domain.wavelog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
@@ -38,38 +41,37 @@ object WaveLogApi {
private const val TIMEOUT_MS = 15000
/** 规范化服务器地址: 去尾斜杠; 无协议前缀时补 https:// */
/** 规范化服务器地址: 去尾斜杠/去尾部 index.php; 无协议前缀时补 https:// */
fun normalizeUrl(raw: String): String {
var u = raw.trim().trimEnd('/')
if (u.isBlank()) return ""
if (!u.startsWith("http://") && !u.startsWith("https://")) u = "https://$u"
if (u.endsWith("/index.php")) u = u.removeSuffix("/index.php")
return u
}
/** 测试连接: GET /index.php/api/v2/token */
/** 测试连接: GET /api/v2/token */
suspend fun testToken(url: String, apiKey: String): WavelogResult = withContext(Dispatchers.IO) {
try {
val conn = openConn("${normalizeUrl(url)}/index.php/api/v2/token", "GET", apiKey)
val code = conn.responseCode
val body = readBody(conn, code)
if (code in 200..299) {
// 返回当前 token 元数据(有 type/name 等)
WavelogResult.Success("连接成功")
} else {
WavelogResult.Failure("HTTP $code: ${shortError(body)}")
execute(
url = url,
apiKey = apiKey,
relPath = "api/v2/token",
method = "GET",
onSuccess = { _ -> WavelogResult.Success("连接成功") },
onFailure = { code, body, usedUrl ->
WavelogResult.Failure("HTTP $code: ${shortError(body)} ($usedUrl)")
}
} catch (e: Exception) {
WavelogResult.Failure("网络错误: ${e.message ?: e.javaClass.simpleName}")
}
)
}
/** 站点信息: GET /index.php/api/v2/station/{id} */
/** 站点信息: GET /api/v2/station/{id} */
suspend fun getStation(url: String, apiKey: String, stationId: String): WavelogResult = withContext(Dispatchers.IO) {
try {
val conn = openConn("${normalizeUrl(url)}/index.php/api/v2/station/$stationId", "GET", apiKey)
val code = conn.responseCode
val body = readBody(conn, code)
if (code in 200..299) {
execute(
url = url,
apiKey = apiKey,
relPath = "api/v2/station/$stationId",
method = "GET",
onSuccess = { body ->
val obj = JSONObject(body)
val data = obj.optJSONObject("data") ?: obj
val station = WavelogStation(
@@ -82,15 +84,14 @@ object WaveLogApi {
put("id", station.id); put("name", station.name)
put("callsign", station.callsign); put("gridsquare", station.gridsquare)
}.toString())
} else {
WavelogResult.Failure("HTTP $code: ${shortError(body)}")
},
onFailure = { code, body, usedUrl ->
WavelogResult.Failure("HTTP $code: ${shortError(body)} ($usedUrl)")
}
} catch (e: Exception) {
WavelogResult.Failure("网络错误: ${e.message ?: e.javaClass.simpleName}")
}
)
}
/** 创建 QSO: POST /index.php/api/v2/qso(单条 JSON) */
/** 创建 QSO: POST /api/v2/qso(单条 JSON) */
suspend fun postQso(
url: String,
apiKey: String,
@@ -98,33 +99,82 @@ object WaveLogApi {
qso: WavelogQso,
gridsquare: String
): WavelogResult = withContext(Dispatchers.IO) {
try {
val body = JSONObject().apply {
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
put("call", qso.call)
put("band", "SAT")
put("mode", qso.mode)
put("qso_date", utcDate(qso.timeUtcMs))
put("time_on", utcTime(qso.timeUtcMs))
put("freq", qso.freqTxHz)
put("freq_rx", qso.freqRxHz)
put("gridsquare", gridsquare)
put("sat_name", qso.satName)
}
val conn = openConn("${normalizeUrl(url)}/index.php/api/v2/qso", "POST", apiKey)
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json")
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(body.toString()) }
val code = conn.responseCode
val resp = readBody(conn, code)
if (code in 200..299) {
WavelogResult.Success("已上传")
} else {
WavelogResult.Failure("HTTP $code: ${shortError(resp)}")
}
} catch (e: Exception) {
WavelogResult.Failure("网络错误: ${e.message ?: e.javaClass.simpleName}")
val body = JSONObject().apply {
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
put("call", qso.call)
put("band", "SAT")
put("mode", qso.mode)
put("qso_date", utcDate(qso.timeUtcMs))
put("time_on", utcTime(qso.timeUtcMs))
put("freq", qso.freqTxHz)
put("freq_rx", qso.freqRxHz)
put("gridsquare", gridsquare)
put("sat_name", qso.satName)
}
execute(
url = url,
apiKey = apiKey,
relPath = "api/v2/qso",
method = "POST",
jsonBody = body.toString(),
onSuccess = { _ -> WavelogResult.Success("已上传") },
onFailure = { code, resp, usedUrl ->
if (code == 409) {
// 重复 QSO(已存在) → 视为成功, 移出队列
WavelogResult.Success("重复(已存在)")
} else {
WavelogResult.Failure("HTTP $code: ${shortError(resp)} ($usedUrl)")
}
}
)
}
/**
* 统一请求执行: 先试带 index.php 的路径; 404 时回退到不带 index.php。
* 返回 2xx/409 以外的状态都交给 onFailure(带实际使用的 URL)。
*/
private fun execute(
url: String,
apiKey: String,
relPath: String,
method: String,
jsonBody: String? = null,
onSuccess: (String) -> WavelogResult,
onFailure: (Int, String, String) -> WavelogResult
): WavelogResult {
val base = normalizeUrl(url)
if (base.isBlank()) return WavelogResult.Failure("服务器地址为空")
// 候选路径: 带 index.php → 不带(服务器重写配置)
val candidates = listOf(
"$base/index.php/$relPath",
"$base/$relPath"
)
var lastError: WavelogResult? = null
for (candidate in candidates) {
try {
val conn = openConn(candidate, method, apiKey)
if (jsonBody != null) {
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json")
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(jsonBody) }
}
val code = conn.responseCode
val resp = readBody(conn, code)
if (code in 200..299) return onSuccess(resp)
if (code == 409) return onFailure(code, resp, candidate) // 409 特殊处理在调用方
if (code == 404) {
// 路径不存在 → 试下一个候选(去掉 index.php)
lastError = WavelogResult.Failure("HTTP 404: ${shortError(resp)} ($candidate)")
continue
}
return onFailure(code, resp, candidate)
} catch (e: Exception) {
lastError = WavelogResult.Failure("网络错误: ${e.message ?: e.javaClass.simpleName}")
return lastError // 网络错误重试没意义
}
}
return lastError ?: WavelogResult.Failure("请求失败")
}
private fun openConn(url: String, method: String, apiKey: String): HttpURLConnection {
@@ -145,7 +195,9 @@ object WaveLogApi {
private fun shortError(body: String): String {
return try {
val obj = JSONObject(body)
obj.optString("message").ifBlank { body.take(120) }
val err = obj.optJSONObject("error")
err?.optString("message")?.ifBlank { body.take(120) }
?: obj.optString("message").ifBlank { body.take(120) }
} catch (_: Exception) {
body.take(120)
}
@@ -24,7 +24,8 @@ data class WavelogQso(
val mode: String,
val freqTxHz: Long, // 上行(回车那一秒多普勒修正)
val freqRxHz: Long, // 下行
val satName: String
val satName: String,
val uploaded: Boolean = false // 是否已成功上传(4.5.2 修复: 成功后保留标记, 表格打勾)
)
class WavelogQueue(private val store: IWavelogQueueStore) {
@@ -44,7 +45,8 @@ class WavelogQueue(private val store: IWavelogQueueStore) {
mode = o.optString("mode"),
freqTxHz = o.optLong("freqTxHz"),
freqRxHz = o.optLong("freqRxHz"),
satName = o.optString("satName")
satName = o.optString("satName"),
uploaded = o.optBoolean("uploaded", false)
)
}
} catch (_: Exception) { emptyList() }
@@ -68,6 +70,18 @@ class WavelogQueue(private val store: IWavelogQueueStore) {
save(all().filter { it.id !in ids })
}
/** 标记为已上传(保留在队列, 表格打勾) */
@Synchronized
fun markUploaded(id: String) {
save(all().map { if (it.id == id) it.copy(uploaded = true) else it })
}
/** 移除所有已上传条目(可选项, 保持队列精简) */
@Synchronized
fun removeUploaded() {
save(all().filter { !it.uploaded })
}
private fun save(list: List<WavelogQso>) {
val arr = JSONArray()
list.forEach { q ->
@@ -75,6 +89,7 @@ class WavelogQueue(private val store: IWavelogQueueStore) {
put("id", q.id); put("timeUtcMs", q.timeUtcMs); put("call", q.call)
put("mode", q.mode); put("freqTxHz", q.freqTxHz)
put("freqRxHz", q.freqRxHz); put("satName", q.satName)
put("uploaded", q.uploaded)
})
}
store.save(arr.toString())
@@ -47,21 +47,20 @@ class WavelogUploader(
}
}
// 3. 逐条上传
// 3. 逐条上传(成功后标记 uploaded, 保留在本地供日志页打勾)
val entries = queue.all()
var ok = 0
var fail = 0
val uploadedIds = mutableSetOf<String>()
for (qso in entries) {
if (qso.uploaded) { ok++; continue }
val result = WaveLogApi.postQso(url, apiKey, stationId, qso, stationGrid)
if (result is WavelogResult.Success) {
ok++
uploadedIds.add(qso.id)
queue.markUploaded(qso.id)
} else {
fail++
}
}
if (uploadedIds.isNotEmpty()) queue.removeAll(uploadedIds)
val message = if (fail == 0) "成功上传 $ok 条" else "成功 $ok 条, 失败 $fail 条(保留待重试)"
return UploadOutcome.Done(ok, fail, message)
}
@@ -44,6 +44,9 @@ sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: Strin
@Serializable
data object CwDecode : Screen(R.drawable.ic_cw, R.string.nav_cw, "CwDecode")
@Serializable
data object WavelogLog : Screen(R.drawable.ic_log, R.string.nav_log, "WavelogLog")
@Serializable
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings")
}
@@ -52,7 +55,7 @@ sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: Strin
val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings")
// UI 设置: 默认更多菜单顺序(进「更多」的页面)
val defaultSubMenuOrder = listOf("Mutual", "Roaming", "CwDecode")
val defaultSubMenuOrder = listOf("Mutual", "Roaming", "CwDecode", "WavelogLog")
@Serializable
data object RadarDestination : NavKey
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M6,2h9l5,5v13c0,1.1 -0.9,2 -2,2H6c-1.1,0 -2,-0.9 -2,-2V4c0,-1.1 0.9,-2 2,-2zM14,3.5V8h4.5L14,3.5zM8,12h8v2H8v-2zM8,16h8v2H8v-2z" />
</vector>
@@ -13,6 +13,7 @@
<string name="nav_map">Peta</string>
<string name="nav_prefs">Pengaturan</string>
<string name="nav_more">Lainnya</string>
<string name="nav_log">Log</string>
<string name="sat_type_hint">Jenis: %s</string>
<string name="sat_type_title">Pilih jenis satelit</string>
<string name="sat_search_hint">Id - Nama</string>
@@ -48,8 +49,8 @@
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
<string name="pass_whatsnew_title">Apa yang baru di Look4Sat</string>
<string name="pass_whatsnew_message">
* Integrasi logbook WaveLog baru: catat kontak satelit dari halaman radar (tab Log)
* Unggah otomatis/manual ke server WaveLog Anda dengan deteksi ketidakcocokan grid</string>
* Perbaiki kesalahan 404 unggah WaveLog (penanganan URL + percobaan jalur otomatis)
* Pemilih transponder kini dropdown; swipe-to-delete diperbaiki; halaman Log baru (tampilan tabel) di menu Lainnya</string>
<string name="radar_back">Kembali</string>
<string name="radar_notify">Beri tahu</string>
<string name="radar_az_text">Azimuth</string>
@@ -185,6 +186,7 @@
<string name="prefs_wavelog_test">Uji koneksi</string>
<string name="prefs_wavelog_upload">Unggah sekarang</string>
<string name="wavelog_title">Log</string>
<string name="wavelog_select_transponder">Pilih transponder</string>
<string name="wavelog_call_hint">Tanda panggil</string>
<string name="wavelog_mode_hint">Mode</string>
<string name="wavelog_saved">Tersimpan ke log lokal</string>
@@ -194,6 +196,11 @@
<string name="wavelog_ignore">Abaikan dan unggah</string>
<string name="wavelog_cancel">Batal</string>
<string name="wavelog_delete_undo">Urungkan</string>
<string name="wavelog_col_time">Waktu</string>
<string name="wavelog_col_freq">Frek.</string>
<string name="wavelog_col_sat">Satelit</string>
<string name="wavelog_col_call">Panggilan</string>
<string name="wavelog_col_uploaded">Terkirim</string>
<string name="prefs_other_title">Pengaturan lainnya</string>
<string name="prefs_ui_title">Pengaturan UI</string>
<string name="prefs_ui_order_title">Urutan halaman</string>
@@ -14,6 +14,7 @@
<string name="nav_map">Harita</string>
<string name="nav_prefs">Ayarlar</string>
<string name="nav_more">Daha Fazla</string>
<string name="nav_log">Günlük</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Uydu türü: %s</string>
@@ -53,8 +54,8 @@
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* Yeni WaveLog günlük entegrasyonu: radar sayfasından uydu temaslarını kaydedin (Günlük sekmesi)
* Izgara uyuşmazlığı algılamayla WaveLog sunucunuza otomatik/manuel yükleme</string>
* WaveLog yükleme 404 hatası düzeltildi (URL işleme + otomatik yol denemesi)
* Transponder seçici artık açılır menü; kaydır-sil düzeltildi; Daha Fazla altında yeni Günlük sayfası (tablo görünümü)</string>
<!-- Radar screen -->
<string name="radar_back">Geri</string>
@@ -195,6 +196,7 @@
<string name="prefs_wavelog_test">Bağlantıyı test et</string>
<string name="prefs_wavelog_upload">Şimdi yükle</string>
<string name="wavelog_title">Günlük</string>
<string name="wavelog_select_transponder">Transponder seç</string>
<string name="wavelog_call_hint">Çağrı işareti</string>
<string name="wavelog_mode_hint">Mod</string>
<string name="wavelog_saved">Yerel günlüğe kaydedildi</string>
@@ -204,6 +206,11 @@
<string name="wavelog_ignore">Yoksay ve yükle</string>
<string name="wavelog_cancel">İptal</string>
<string name="wavelog_delete_undo">Geri al</string>
<string name="wavelog_col_time">Zaman</string>
<string name="wavelog_col_freq">Frekans</string>
<string name="wavelog_col_sat">Uydu</string>
<string name="wavelog_col_call">Çağrı</string>
<string name="wavelog_col_uploaded">Gönderildi</string>
<string name="prefs_other_title">Diğer ayarlar</string>
<string name="prefs_ui_title">UI Ayarları</string>
<string name="prefs_ui_order_title">Sayfa sırası</string>
@@ -15,6 +15,7 @@
<string name="nav_map">地图</string>
<string name="nav_prefs">设置</string>
<string name="nav_more">更多</string>
<string name="nav_log">日志</string>
<!-- Satellites screen -->
<string name="sat_type_hint">类型:%s</string>
@@ -43,8 +44,8 @@
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
<string name="pass_whatsnew_message" translatable="false">
* 新增 WaveLog 日志系统: 雷达页 Log 标签记录卫星通联日志
* 自动/手动上传到 WaveLog 服务器,带网格不一致检测</string>
* 修复 WaveLog 上传 404(URL 处理 + 自动路径重试)
* 转发器改为下拉选择框; 修复左滑删除; 更多菜单新增「日志」页(表格视图)</string>
<!-- Radar screen -->
<string name="radar_back">后退</string>
@@ -178,6 +179,7 @@
<string name="prefs_wavelog_test">测试连接</string>
<string name="prefs_wavelog_upload">手动上传</string>
<string name="wavelog_title">日志</string>
<string name="wavelog_select_transponder">选择转发器</string>
<string name="wavelog_call_hint">对方呼号</string>
<string name="wavelog_mode_hint">模式</string>
<string name="wavelog_saved">已存入本地日志</string>
@@ -187,6 +189,11 @@
<string name="wavelog_ignore">忽略并上传</string>
<string name="wavelog_cancel">取消</string>
<string name="wavelog_delete_undo">撤销</string>
<string name="wavelog_col_time">时间</string>
<string name="wavelog_col_freq">频率</string>
<string name="wavelog_col_sat">卫星</string>
<string name="wavelog_col_call">呼号</string>
<string name="wavelog_col_uploaded">已传</string>
<string name="prefs_other_title">其他设置</string>
<string name="prefs_ui_title">UI设置</string>
<string name="prefs_ui_order_title">页面顺序</string>
@@ -16,6 +16,7 @@
<string name="nav_map">Map</string>
<string name="nav_prefs">Settings</string>
<string name="nav_more">More</string>
<string name="nav_log">Log</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Type: %s</string>
@@ -55,8 +56,8 @@
\n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* New WaveLog logbook integration: log satellite contacts from the radar page (Log tab)
* Auto/manual upload to your WaveLog server with grid mismatch detection</string>
* Fixed WaveLog upload 404 (URL handling + automatic path retry)
* Transponder picker is now a dropdown; swipe-to-delete fixed; new Log page (table view) under More</string>
<!-- Radar screen -->
<string name="radar_back">Back</string>
@@ -209,6 +210,7 @@
<string name="prefs_wavelog_test">Test connection</string>
<string name="prefs_wavelog_upload">Upload now</string>
<string name="wavelog_title">Log</string>
<string name="wavelog_select_transponder">Select transponder</string>
<string name="wavelog_call_hint">Callsign</string>
<string name="wavelog_mode_hint">Mode</string>
<string name="wavelog_saved">Saved to local log</string>
@@ -218,6 +220,11 @@
<string name="wavelog_ignore">Ignore and upload</string>
<string name="wavelog_cancel">Cancel</string>
<string name="wavelog_delete_undo">Undo</string>
<string name="wavelog_col_time">Time</string>
<string name="wavelog_col_freq">Freq</string>
<string name="wavelog_col_sat">Satellite</string>
<string name="wavelog_col_call">Call</string>
<string name="wavelog_col_uploaded">Sent</string>
<string name="prefs_other_title">Other settings</string>
<string name="prefs_ui_title">UI Settings</string>
<string name="prefs_ui_order_title">Page order</string>
@@ -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))
}