diff --git a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt index fd95bb63..ddeb20aa 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt @@ -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 { CwDecodeScreen() } + entry { + WavelogLogScreen(queue = container.wavelogQueue) + } entry { SettingsDestination() } diff --git a/app/src/main/java/com/rtbishop/look4sat/MoreMenuPopup.kt b/app/src/main/java/com/rtbishop/look4sat/MoreMenuPopup.kt index 0e3aa175..8b90d581 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MoreMenuPopup.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MoreMenuPopup.kt @@ -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 diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt index 3af7b5b2..7c62be8d 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt @@ -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) } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogQueue.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogQueue.kt index f4561a43..0fd829bf 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogQueue.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogQueue.kt @@ -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) { 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()) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogUploader.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogUploader.kt index c7ae4c99..ac6939db 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogUploader.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogUploader.kt @@ -47,21 +47,20 @@ class WavelogUploader( } } - // 3. 逐条上传 + // 3. 逐条上传(成功后标记 uploaded, 保留在本地供日志页打勾) val entries = queue.all() var ok = 0 var fail = 0 - val uploadedIds = mutableSetOf() 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) } diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt index 50ce065e..912e85cc 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt @@ -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 diff --git a/core/presentation/src/main/res/drawable/ic_log.xml b/core/presentation/src/main/res/drawable/ic_log.xml new file mode 100644 index 00000000..74746c0a --- /dev/null +++ b/core/presentation/src/main/res/drawable/ic_log.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/presentation/src/main/res/values-in/strings.xml b/core/presentation/src/main/res/values-in/strings.xml index 33d4de7f..e0444208 100644 --- a/core/presentation/src/main/res/values-in/strings.xml +++ b/core/presentation/src/main/res/values-in/strings.xml @@ -13,6 +13,7 @@ Peta Pengaturan Lainnya + Log Jenis: %s Pilih jenis satelit Id - Nama @@ -48,8 +49,8 @@ \n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat. Apa yang baru di Look4Sat - * Integrasi logbook WaveLog baru: catat kontak satelit dari halaman radar (tab Log) - * Unggah otomatis/manual ke server WaveLog Anda dengan deteksi ketidakcocokan grid + * 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 Kembali Beri tahu Azimuth @@ -185,6 +186,7 @@ Uji koneksi Unggah sekarang Log + Pilih transponder Tanda panggil Mode Tersimpan ke log lokal @@ -194,6 +196,11 @@ Abaikan dan unggah Batal Urungkan + Waktu + Frek. + Satelit + Panggilan + Terkirim Pengaturan lainnya Pengaturan UI Urutan halaman diff --git a/core/presentation/src/main/res/values-tr/strings.xml b/core/presentation/src/main/res/values-tr/strings.xml index de37ad69..81d53708 100644 --- a/core/presentation/src/main/res/values-tr/strings.xml +++ b/core/presentation/src/main/res/values-tr/strings.xml @@ -14,6 +14,7 @@ Harita Ayarlar Daha Fazla + Günlük Uydu türü: %s @@ -53,8 +54,8 @@ \n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin. What\'s new in Look4Sat - * 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 + * 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ü) Geri @@ -195,6 +196,7 @@ Bağlantıyı test et Şimdi yükle Günlük + Transponder seç Çağrı işareti Mod Yerel günlüğe kaydedildi @@ -204,6 +206,11 @@ Yoksay ve yükle İptal Geri al + Zaman + Frekans + Uydu + Çağrı + Gönderildi Diğer ayarlar UI Ayarları Sayfa sırası diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index d0ff7abc..7e2b1616 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -15,6 +15,7 @@ 地图 设置 更多 + 日志 类型:%s @@ -43,8 +44,8 @@ 请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性 Look4Sat Pro 更新内容 - * 新增 WaveLog 日志系统: 雷达页 Log 标签记录卫星通联日志 - * 自动/手动上传到 WaveLog 服务器,带网格不一致检测 + * 修复 WaveLog 上传 404(URL 处理 + 自动路径重试) + * 转发器改为下拉选择框; 修复左滑删除; 更多菜单新增「日志」页(表格视图) 后退 @@ -178,6 +179,7 @@ 测试连接 手动上传 日志 + 选择转发器 对方呼号 模式 已存入本地日志 @@ -187,6 +189,11 @@ 忽略并上传 取消 撤销 + 时间 + 频率 + 卫星 + 呼号 + 已传 其他设置 UI设置 页面顺序 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 07199fe7..d2258aec 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -16,6 +16,7 @@ Map Settings More + Log Type: %s @@ -55,8 +56,8 @@ \n\nPlease update the database at least weekly to get accurate predictions. What\'s new in Look4Sat - * New WaveLog logbook integration: log satellite contacts from the radar page (Log tab) - * Auto/manual upload to your WaveLog server with grid mismatch detection + * 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 Back @@ -209,6 +210,7 @@ Test connection Upload now Log + Select transponder Callsign Mode Saved to local log @@ -218,6 +220,11 @@ Ignore and upload Cancel Undo + Time + Freq + Satellite + Call + Sent Other settings UI Settings Page order diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt index 626827cd..d2e25327 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt @@ -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, @@ -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(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 } }, diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/WavelogLogScreen.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/WavelogLogScreen.kt new file mode 100644 index 00000000..d34bbfdf --- /dev/null +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/WavelogLogScreen.kt @@ -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)) +}