mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 19:17:21 +00:00
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:
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
|
||||
|
||||
+111
-59
@@ -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)
|
||||
}
|
||||
|
||||
+17
-2
@@ -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())
|
||||
|
||||
+3
-4
@@ -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)
|
||||
}
|
||||
|
||||
+4
-1
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user