diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt
index ab4e95cf..879e3f73 100644
--- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt
@@ -40,6 +40,9 @@ import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
import com.rtbishop.look4sat.core.data.usecase.SaveImage
import com.rtbishop.look4sat.core.data.usecase.ShowToast
+import com.rtbishop.look4sat.core.domain.wavelog.IWavelogQueueStore
+import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
+import com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
@@ -96,6 +99,16 @@ class MainContainer(private val context: Context) : IMainContainer {
override fun provideSaveImage(): ISaveImage = SaveImage(context)
+ // WaveLog 日志(4.5.2): 本地队列 + 上传器(共享实例)
+ override val wavelogQueue: WavelogQueue by lazy {
+ val prefs = context.getSharedPreferences("wavelog", Context.MODE_PRIVATE)
+ WavelogQueue(object : IWavelogQueueStore {
+ override fun load(): String = prefs.getString("wavelog_queue", "[]") ?: "[]"
+ override fun save(json: String) = prefs.edit().putString("wavelog_queue", json).apply()
+ })
+ }
+ override fun provideWavelogUploader(): WavelogUploader = WavelogUploader(settingsRepo, wavelogQueue)
+
override fun provideBluetoothReporter(): IReporter {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val rc = settingsRepo.rcSettings.value
diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt
index 2a3f0e74..8d567a98 100644
--- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt
@@ -94,6 +94,11 @@ class SettingsRepo(
private val keyUseCustomTransceivers = "useCustomTransceivers"
private val keyTleUrl = "tleUrl"
private val keyTransceiversUrl = "transceiversUrl"
+ private val keySubMenuOrder = "subMenuOrder"
+ private val keyWavelogUrl = "wavelogUrl"
+ private val keyWavelogApiKey = "wavelogApiKey"
+ private val keyWavelogStationId = "wavelogStationId"
+ private val keyWavelogAutoUpload = "wavelogAutoUpload"
private val separatorComma = ","
//region # Satellites selection settings
@@ -361,6 +366,11 @@ class SettingsRepo(
putLong(keyHighElevation, new.highElevation.toRawBits())
putStringSet(keyHiddenScreens, new.hiddenScreens.toSet())
putString(keyScreenOrder, new.screenOrder.joinToString(","))
+ putString(keySubMenuOrder, new.subMenuOrder.joinToString(","))
+ putString(keyWavelogUrl, new.wavelogUrl)
+ putString(keyWavelogApiKey, new.wavelogApiKey)
+ putString(keyWavelogStationId, new.wavelogStationId)
+ putBoolean(keyWavelogAutoUpload, new.wavelogAutoUpload)
}
new
}
@@ -378,8 +388,13 @@ class SettingsRepo(
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
hiddenScreens = preferences.getStringSet(keyHiddenScreens, emptySet())?.toList() ?: emptyList(),
screenOrder = preferences.getString(keyScreenOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
+ subMenuOrder = preferences.getString(keySubMenuOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
- highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
+ highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
+ wavelogUrl = preferences.getString(keyWavelogUrl, null) ?: "",
+ wavelogApiKey = preferences.getString(keyWavelogApiKey, null) ?: "",
+ wavelogStationId = preferences.getString(keyWavelogStationId, null) ?: "",
+ wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false)
)
//endregion
diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts
index 841c9857..dbc922ee 100644
--- a/core/domain/build.gradle.kts
+++ b/core/domain/build.gradle.kts
@@ -1,3 +1,8 @@
plugins {
alias(libs.plugins.convention.coreDomainPlugin)
}
+
+dependencies {
+ // 编译期使用 org.json(构造/解析 WaveLog API 请求体); 运行时用 Android 系统自带的 org.json
+ compileOnly("org.json:json:20240303")
+}
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt
index 78d02c7a..d8cbed65 100644
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt
@@ -68,7 +68,12 @@ data class OtherSettings(
// UI 设置: 页面顺序(空 = 默认顺序: 卫星/过境/雷达/地图/设置)
val screenOrder: List = emptyList(),
// UI 设置: 更多菜单顺序(空 = 默认: 匹配/漫游/CW解码)
- val subMenuOrder: List = emptyList()
+ val subMenuOrder: List = emptyList(),
+ // WaveLog 日志系统(4.5.2): 服务器配置
+ val wavelogUrl: String = "",
+ val wavelogApiKey: String = "",
+ val wavelogStationId: String = "",
+ val wavelogAutoUpload: Boolean = false
)
data class DataSourcesSettings(
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt
index 70de0f81..ff0bfd00 100644
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt
@@ -42,6 +42,9 @@ interface IMainContainer {
fun provideRxRadioController(): IRadioController
fun provideAudioCapture(): IAudioCapture
fun provideSaveImage(): ISaveImage
+ // WaveLog 日志(4.5.2)
+ val wavelogQueue: com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
+ fun provideWavelogUploader(): com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
}
data class MutualPassData(
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
new file mode 100644
index 00000000..3af7b5b2
--- /dev/null
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt
@@ -0,0 +1,171 @@
+/*
+ * 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)
+ * 认证: Authorization: Bearer wl2_xxx(v2 token)
+ *
+ * 轻量实现: HttpURLConnection + 协程(Dispatchers.IO),不引新依赖。
+ */
+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
+import java.io.OutputStreamWriter
+import java.net.HttpURLConnection
+import java.net.URL
+
+/** 站点信息(GET /api/v2/station/{id} 结果) */
+data class WavelogStation(
+ val id: Int,
+ val name: String,
+ val callsign: String,
+ val gridsquare: String
+)
+
+sealed class WavelogResult {
+ data class Success(val message: String) : WavelogResult()
+ data class Failure(val message: String) : WavelogResult()
+}
+
+object WaveLogApi {
+
+ private const val TIMEOUT_MS = 15000
+
+ /** 规范化服务器地址: 去尾斜杠; 无协议前缀时补 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"
+ return u
+ }
+
+ /** 测试连接: GET /index.php/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)}")
+ }
+ } catch (e: Exception) {
+ WavelogResult.Failure("网络错误: ${e.message ?: e.javaClass.simpleName}")
+ }
+ }
+
+ /** 站点信息: GET /index.php/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) {
+ val obj = JSONObject(body)
+ val data = obj.optJSONObject("data") ?: obj
+ val station = WavelogStation(
+ id = data.optInt("id"),
+ name = data.optString("name"),
+ callsign = data.optString("callsign"),
+ gridsquare = data.optString("gridsquare")
+ )
+ WavelogResult.Success(JSONObject().apply {
+ put("id", station.id); put("name", station.name)
+ put("callsign", station.callsign); put("gridsquare", station.gridsquare)
+ }.toString())
+ } else {
+ WavelogResult.Failure("HTTP $code: ${shortError(body)}")
+ }
+ } catch (e: Exception) {
+ WavelogResult.Failure("网络错误: ${e.message ?: e.javaClass.simpleName}")
+ }
+ }
+
+ /** 创建 QSO: POST /index.php/api/v2/qso(单条 JSON) */
+ suspend fun postQso(
+ url: String,
+ apiKey: String,
+ stationProfileId: String,
+ 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}")
+ }
+ }
+
+ private fun openConn(url: String, method: String, apiKey: String): HttpURLConnection {
+ val conn = URL(url).openConnection() as HttpURLConnection
+ conn.requestMethod = method
+ conn.connectTimeout = TIMEOUT_MS
+ conn.readTimeout = TIMEOUT_MS
+ if (apiKey.isNotBlank()) conn.setRequestProperty("Authorization", "Bearer $apiKey")
+ return conn
+ }
+
+ private fun readBody(conn: HttpURLConnection, code: Int): String {
+ val stream = if (code in 200..299) conn.inputStream else conn.errorStream
+ ?: return ""
+ return BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).use { it.readText() }
+ }
+
+ private fun shortError(body: String): String {
+ return try {
+ val obj = JSONObject(body)
+ obj.optString("message").ifBlank { body.take(120) }
+ } catch (_: Exception) {
+ body.take(120)
+ }
+ }
+
+ private fun utcDate(ms: Long): String {
+ val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
+ cal.timeInMillis = ms
+ return "%04d-%02d-%02d".format(
+ cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
+ cal.get(java.util.Calendar.DAY_OF_MONTH)
+ )
+ }
+
+ private fun utcTime(ms: Long): String {
+ val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
+ cal.timeInMillis = ms
+ return "%02d:%02d:%02d".format(
+ cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
+ cal.get(java.util.Calendar.SECOND)
+ )
+ }
+}
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
new file mode 100644
index 00000000..f4561a43
--- /dev/null
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogQueue.kt
@@ -0,0 +1,82 @@
+/*
+ * WavelogQueue.kt — WaveLog 本地日志队列(4.5.2)。
+ *
+ * 纯 Kotlin(不依赖 Android): 存储走 IWavelogQueueStore 接口,
+ * 由 core/data 用 SharedPreferences 实现。
+ * 队列上限 500 条(超出丢最旧)。
+ */
+package com.rtbishop.look4sat.core.domain.wavelog
+
+import org.json.JSONArray
+import org.json.JSONObject
+
+/** 存储抽象(SharedPreferences 实现见 core/data) */
+interface IWavelogQueueStore {
+ fun load(): String
+ fun save(json: String)
+}
+
+/** 待上传的 QSO 条目(本地队列元素,与 POST /api/v2/qso 字段对应) */
+data class WavelogQso(
+ val id: String, // 本地唯一 id(UUID)
+ val timeUtcMs: Long, // 回车时刻 UTC 毫秒(本地显示 + 组装 qso_date/time_on)
+ val call: String,
+ val mode: String,
+ val freqTxHz: Long, // 上行(回车那一秒多普勒修正)
+ val freqRxHz: Long, // 下行
+ val satName: String
+)
+
+class WavelogQueue(private val store: IWavelogQueueStore) {
+
+ private val key = "wavelog_queue"
+
+ fun all(): List {
+ val raw = store.load()
+ return try {
+ val arr = JSONArray(raw)
+ (0 until arr.length()).map { i ->
+ val o = arr.getJSONObject(i)
+ WavelogQso(
+ id = o.getString("id"),
+ timeUtcMs = o.getLong("timeUtcMs"),
+ call = o.optString("call"),
+ mode = o.optString("mode"),
+ freqTxHz = o.optLong("freqTxHz"),
+ freqRxHz = o.optLong("freqRxHz"),
+ satName = o.optString("satName")
+ )
+ }
+ } catch (_: Exception) { emptyList() }
+ }
+
+ @Synchronized
+ fun add(qso: WavelogQso) {
+ val list = all().toMutableList()
+ list.add(0, qso) // 最新在前
+ if (list.size > 500) list.removeAt(list.size - 1)
+ save(list)
+ }
+
+ @Synchronized
+ fun remove(id: String) {
+ save(all().filter { it.id != id })
+ }
+
+ @Synchronized
+ fun removeAll(ids: Set) {
+ save(all().filter { it.id !in ids })
+ }
+
+ private fun save(list: List) {
+ val arr = JSONArray()
+ list.forEach { q ->
+ arr.put(JSONObject().apply {
+ 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)
+ })
+ }
+ 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
new file mode 100644
index 00000000..c7ae4c99
--- /dev/null
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogUploader.kt
@@ -0,0 +1,84 @@
+/*
+ * WavelogUploader.kt — WaveLog 队列上传调度(4.5.2)。
+ *
+ * 手动/周期上传共用: 逐条网格检测(用户 QTH 前 4 位 vs 台站网格前 4 位)
+ * → POST /api/v2/qso → 成功移出队列。
+ * 网格不一致: 返回 NeedConfirm(由 UI 弹窗「忽略并上传/取消」),
+ * 确认后带 force=true 重试本批。
+ */
+package com.rtbishop.look4sat.core.domain.wavelog
+
+import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
+import org.json.JSONObject
+
+sealed class UploadOutcome {
+ data class NeedConfirm(val stationGrid: String, val userGrid: String) : UploadOutcome()
+ data class Done(val successCount: Int, val failedCount: Int, val message: String) : UploadOutcome()
+}
+
+class WavelogUploader(
+ private val settingsRepo: ISettingsRepo,
+ private val queue: WavelogQueue
+) {
+
+ // 台站网格缓存(每次上传前刷新; 失败用旧值)
+ private var cachedStationGrid: String? = null
+
+ /** 上传整个队列。force=true 跳过网格确认(用户已选「忽略并上传」) */
+ suspend fun uploadQueue(force: Boolean = false): UploadOutcome {
+ val settings = settingsRepo.otherSettings.value
+ val url = settings.wavelogUrl
+ val apiKey = settings.wavelogApiKey
+ val stationId = settings.wavelogStationId
+ if (url.isBlank() || apiKey.isBlank() || stationId.isBlank()) {
+ return UploadOutcome.Done(0, queue.all().size, "未配置 WaveLog 服务器")
+ }
+
+ // 1. 拉站点信息(拿台站网格)
+ val stationGrid = getStationGrid(url, apiKey, stationId) ?: return UploadOutcome.Done(
+ 0, queue.all().size, "无法获取站点信息(检查站点 ID/密钥权限)"
+ )
+
+ // 2. 网格检测: 用户当前 QTH 前 4 位 vs 台站网格前 4 位
+ if (!force) {
+ val userGrid = userQthGrid()
+ if (userGrid != null && stationGrid.take(4).lowercase() != userGrid.take(4).lowercase()) {
+ return UploadOutcome.NeedConfirm(stationGrid, userGrid)
+ }
+ }
+
+ // 3. 逐条上传
+ val entries = queue.all()
+ var ok = 0
+ var fail = 0
+ val uploadedIds = mutableSetOf()
+ for (qso in entries) {
+ val result = WaveLogApi.postQso(url, apiKey, stationId, qso, stationGrid)
+ if (result is WavelogResult.Success) {
+ ok++
+ uploadedIds.add(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)
+ }
+
+ private suspend fun getStationGrid(url: String, apiKey: String, stationId: String): String? {
+ val result = WaveLogApi.getStation(url, apiKey, stationId)
+ if (result is WavelogResult.Success) {
+ return try {
+ JSONObject(result.message).optString("gridsquare").takeIf { it.isNotBlank() }
+ ?: cachedStationGrid
+ } catch (_: Exception) { cachedStationGrid }
+ }
+ return cachedStationGrid
+ }
+
+ /** 用户当前 QTH 网格(前 4 位; 无 QTH 返回 null = 跳过检测) */
+ private fun userQthGrid(): String? {
+ return settingsRepo.stationPosition.value.qthLocator?.takeIf { it.length >= 4 }
+ }
+}
diff --git a/core/presentation/src/main/res/values-in/strings.xml b/core/presentation/src/main/res/values-in/strings.xml
index 2a83450c..33d4de7f 100644
--- a/core/presentation/src/main/res/values-in/strings.xml
+++ b/core/presentation/src/main/res/values-in/strings.xml
@@ -48,8 +48,8 @@
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.
Apa yang baru di Look4Sat
- * Navigasi bawah kini menggunakan 5 tab utama plus menu Lainnya (menu tingkat kedua yang dapat dilipat)
- * Pengaturan UI: urutan halaman dipisah menjadi menu utama dan menu lainnya; pindahkan halaman di antara keduanya
+ * Integrasi logbook WaveLog baru: catat kontak satelit dari halaman radar (tab Log)
+ * Unggah otomatis/manual ke server WaveLog Anda dengan deteksi ketidakcocokan grid
Kembali
Beri tahu
Azimuth
@@ -177,6 +177,23 @@
Putuskan
Lacak
Berhenti
+ Server log WaveLog
+ Alamat server
+ Kunci API
+ ID stasiun
+ Unggah otomatis
+ Uji koneksi
+ Unggah sekarang
+ Log
+ Tanda panggil
+ Mode
+ Tersimpan ke log lokal
+ Konfigurasikan WaveLog di Pengaturan terlebih dahulu
+ Belum ada entri log
+ Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah?
+ Abaikan dan unggah
+ Batal
+ Urungkan
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 d5558bc4..de37ad69 100644
--- a/core/presentation/src/main/res/values-tr/strings.xml
+++ b/core/presentation/src/main/res/values-tr/strings.xml
@@ -53,8 +53,8 @@
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.
What\'s new in Look4Sat
- * Alt gezinme artık 5 ana sekme artı Daha Fazla menüsü kullanıyor (katlanabilir ikinci düzey menü)
- * UI Ayarları: sayfa sırası ana menü ve daha fazla menü olarak ayrıldı; sayfalar arasında taşıma yapılabilir
+ * 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
Geri
@@ -187,6 +187,23 @@
Bağlantıyı Kes
Takip
Dur
+ WaveLog günlük sunucusu
+ Sunucu adresi
+ API anahtarı
+ İstasyon kimliği
+ Otomatik yükleme
+ Bağlantıyı test et
+ Şimdi yükle
+ Günlük
+ Çağrı işareti
+ Mod
+ Yerel günlüğe kaydedildi
+ Önce Ayarlar\'da WaveLog\'u yapılandırın
+ Henüz günlük kaydı yok
+ QTH ızgarası (%1$s) istasyon ızgarasıyla (%2$s) eşleşmiyor. Yine de yüklensin mi?
+ Yoksay ve yükle
+ İptal
+ Geri al
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 977b1865..d0ff7abc 100644
--- a/core/presentation/src/main/res/values-zh/strings.xml
+++ b/core/presentation/src/main/res/values-zh/strings.xml
@@ -43,8 +43,8 @@
请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性
Look4Sat Pro 更新内容
- * 底部导航改为 5 个主标签 + 更多菜单(可折叠二级菜单)
- * UI 设置: 页面顺序分为主菜单与更多菜单,可拖拽或移动页面
+ * 新增 WaveLog 日志系统: 雷达页 Log 标签记录卫星通联日志
+ * 自动/手动上传到 WaveLog 服务器,带网格不一致检测
后退
@@ -170,6 +170,23 @@
断开
跟踪
停止
+ WaveLog 日志服务器
+ 服务器地址
+ API 密钥
+ 站点 ID
+ 自动上传
+ 测试连接
+ 手动上传
+ 日志
+ 对方呼号
+ 模式
+ 已存入本地日志
+ 请先在设置中配置 WaveLog
+ 暂无日志记录
+ 当前 QTH 网格(%1$s)与站点网格(%2$s)不一致,仍要上传?
+ 忽略并上传
+ 取消
+ 撤销
其他设置
UI设置
页面顺序
diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml
index f97b41c8..07199fe7 100644
--- a/core/presentation/src/main/res/values/strings.xml
+++ b/core/presentation/src/main/res/values/strings.xml
@@ -55,8 +55,8 @@
\n\nPlease update the database at least weekly to get accurate predictions.
What\'s new in Look4Sat
- * Bottom navigation now uses 5 main tabs plus a More menu (collapsible second-level menu)
- * UI Settings: page order split into main menu and more menu; drag or move pages between them
+ * New WaveLog logbook integration: log satellite contacts from the radar page (Log tab)
+ * Auto/manual upload to your WaveLog server with grid mismatch detection
Back
@@ -201,6 +201,23 @@
Disconnect
Track
Stop
+ WaveLog log server
+ Server address
+ API key
+ Station ID
+ Auto upload
+ Test connection
+ Upload now
+ Log
+ Callsign
+ Mode
+ Saved to local log
+ Configure WaveLog in Settings first
+ No log entries yet
+ QTH grid (%1$s) does not match station grid (%2$s). Upload anyway?
+ Ignore and upload
+ Cancel
+ Undo
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
new file mode 100644
index 00000000..626827cd
--- /dev/null
+++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt
@@ -0,0 +1,380 @@
+/*
+ * LogTab.kt — 雷达页「Log」标签(4.5.2 WaveLog 日志)。
+ *
+ * 布局: 转发器列表(点开选一个, 边看频率边填) → 呼号输入回车存本地 →
+ * 下方本地记录列表(时间/频率/呼号)。记录支持自绘左滑删除:
+ * 滑动中显示黄色垃圾桶, 滑过 75% 变撤销键, 5 秒内不点撤销则删除。
+ *
+ * 回车只存本地(WavelogQueue), 上传由 10 分钟周期/手动触发(防抄收错误)。
+ */
+package com.rtbishop.look4sat.feature.radar
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectHorizontalDragGestures
+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.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+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.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+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.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.rtbishop.look4sat.core.domain.model.SatRadio
+import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
+import com.rtbishop.look4sat.core.domain.wavelog.WavelogQso
+import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
+import com.rtbishop.look4sat.core.presentation.R
+import com.rtbishop.look4sat.core.presentation.formatFrequency
+import kotlinx.coroutines.delay
+import java.util.Calendar
+import java.util.TimeZone
+import java.util.UUID
+import kotlin.math.roundToInt
+
+private val WaveLogYellow = Color(0xFFFFC107)
+
+@Composable
+fun LogTab(
+ transceivers: List,
+ orbitalPos: OrbitalPos?,
+ satelliteName: String,
+ queue: WavelogQueue,
+ wavelogConfigured: Boolean,
+ showToast: (String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ var selectedUuid by remember { mutableStateOf(null) }
+ var refreshTick by remember { mutableIntStateOf(0) }
+ val entries = remember(refreshTick) { queue.all() }
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(8.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ if (!wavelogConfigured) {
+ Text(
+ text = stringResource(id = R.string.wavelog_need_config),
+ fontSize = 13.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ // 转发器选择: 点开一个, 边看频率边填日志
+ transceivers.forEach { radio ->
+ val isExpanded = radio.uuid == selectedUuid
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { selectedUuid = if (isExpanded) null else radio.uuid },
+ colors = CardDefaults.cardColors(
+ containerColor = if (isExpanded) MaterialTheme.colorScheme.surfaceContainerHighest
+ else MaterialTheme.colorScheme.surfaceContainer
+ )
+ ) {
+ 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++ }
+ )
+ }
+ }
+ }
+ }
+
+ // 本地记录列表(时间/频率/呼号)
+ Text(
+ text = stringResource(id = R.string.wavelog_saved),
+ fontSize = 12.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ if (entries.isEmpty()) {
+ Text(
+ text = stringResource(id = R.string.wavelog_empty),
+ fontSize = 13.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(vertical = 8.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)
+ ) {
+ Text(
+ text = formatLocalTime(entry.timeUtcMs),
+ fontSize = 12.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.width(64.dp)
+ )
+ Text(
+ text = formatFrequency(entry.freqTxHz),
+ fontSize = 12.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.width(76.dp)
+ )
+ Text(
+ text = entry.call,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+/** 转发器展开输入区: 实时频率 + 呼号(回车存本地) + 模式修正 */
+@Composable
+private fun ExpandedLogInput(
+ radio: SatRadio,
+ orbitalPos: OrbitalPos?,
+ satelliteName: String,
+ queue: WavelogQueue,
+ showToast: (String) -> Unit,
+ onSaved: () -> Unit
+) {
+ var callsign by remember { mutableStateOf("") }
+ var mode by remember { mutableStateOf(radio.uplinkMode ?: "FM") }
+
+ // 回车那一刻的多普勒修正频率(UTC 时间一并采样)
+ fun currentFreqs(): Pair {
+ val uplinkBase = radio.uplinkLow ?: radio.downlinkLow ?: 0L
+ val downlinkBase = radio.downlinkLow ?: radio.uplinkLow ?: 0L
+ val tx = if (orbitalPos != null) orbitalPos.getUplinkFreq(uplinkBase) else uplinkBase
+ val rx = if (orbitalPos != null) orbitalPos.getDownlinkFreq(downlinkBase) else downlinkBase
+ return tx to rx
+ }
+
+ val savedMsg = stringResource(id = R.string.wavelog_saved)
+
+ fun submit() {
+ val call = callsign.trim().uppercase()
+ if (call.length < 3) return
+ val (tx, rx) = currentFreqs()
+ queue.add(
+ WavelogQso(
+ id = UUID.randomUUID().toString(),
+ timeUtcMs = System.currentTimeMillis(),
+ call = call,
+ mode = mode.trim().ifBlank { "FM" }.uppercase(),
+ freqTxHz = tx,
+ freqRxHz = rx,
+ satName = satelliteName
+ )
+ )
+ callsign = ""
+ onSaved()
+ showToast(savedMsg)
+ }
+
+ val (txHz, rxHz) = currentFreqs()
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text(
+ text = "TX ${formatFrequency(txHz)}",
+ fontSize = 12.sp,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = "RX ${formatFrequency(rxHz)}",
+ fontSize = 12.sp,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ OutlinedTextField(
+ value = callsign,
+ onValueChange = { callsign = it.take(12) },
+ label = { Text(stringResource(id = R.string.wavelog_call_hint)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(onDone = { submit() }),
+ textStyle = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.fillMaxWidth()
+ )
+ OutlinedTextField(
+ value = mode,
+ onValueChange = { mode = it.take(8) },
+ label = { Text(stringResource(id = R.string.wavelog_mode_hint)) },
+ singleLine = true,
+ textStyle = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+}
+
+/**
+ * 自绘左滑删除行(用户定制交互):
+ * 左滑 → 右侧黄色垃圾桶; 滑过 75% → 变撤销键(待删除, 不立即删);
+ * 5 秒倒计时内点撤销恢复, 不点自动删除。
+ */
+@Composable
+private fun SwipeDeleteRow(
+ onDelete: () -> Unit,
+ content: @Composable () -> Unit
+) {
+ var offsetX by remember { mutableStateOf(0f) }
+ var pending by remember { mutableStateOf(false) }
+ var countdown by remember { mutableIntStateOf(5) }
+ var rowWidth by remember { mutableIntStateOf(0) }
+ val threshold = rowWidth * 0.75f
+
+ // 倒计时: 进入待删除后 5 秒自动删
+ LaunchedEffect(pending) {
+ if (pending) {
+ countdown = 5
+ while (countdown > 0) {
+ delay(1000)
+ countdown--
+ }
+ if (pending) {
+ onDelete()
+ pending = false
+ offsetX = 0f
+ }
+ }
+ }
+
+ Box(modifier = Modifier.fillMaxWidth()) {
+ // 背景层(右侧图标: 垃圾桶 / 撤销+倒计时)
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .background(WaveLogYellow.copy(alpha = 0.25f)),
+ contentAlignment = Alignment.CenterEnd
+ ) {
+ if (pending) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(end = 12.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Text(
+ text = "$countdown",
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold,
+ color = WaveLogYellow
+ )
+ Text(
+ text = stringResource(id = R.string.wavelog_delete_undo),
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Bold,
+ color = WaveLogYellow,
+ modifier = Modifier
+ .clip(RoundedCornerShape(8.dp))
+ .background(WaveLogYellow.copy(alpha = 0.15f))
+ .clickable {
+ pending = false
+ offsetX = 0f
+ }
+ .padding(horizontal = 10.dp, vertical = 6.dp)
+ )
+ }
+ } else {
+ // 垃圾桶(滑动过程中一直显示, 黄色)
+ Text(
+ text = "🗑",
+ fontSize = 20.sp,
+ modifier = Modifier.padding(end = 16.dp)
+ )
+ }
+ }
+ // 内容层(跟手滑动)
+ Box(
+ modifier = Modifier
+ .offset { IntOffset(offsetX.roundToInt(), 0) }
+ .pointerInput(Unit) {
+ detectHorizontalDragGestures(
+ onDragStart = { if (pending) { pending = false } },
+ onHorizontalDrag = { change, dragAmount ->
+ change.consume()
+ offsetX = (offsetX + dragAmount).coerceIn(-rowWidth.toFloat(), 0f)
+ },
+ onDragEnd = {
+ if (offsetX <= -threshold) {
+ pending = true
+ countdown = 5
+ } else {
+ offsetX = 0f
+ }
+ },
+ onDragCancel = { offsetX = 0f }
+ )
+ }
+ ) {
+ content()
+ }
+ }
+}
+
+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))
+}
diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt
index 617412b0..652d58ce 100644
--- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt
+++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt
@@ -61,6 +61,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
+import com.rtbishop.look4sat.core.domain.wavelog.UploadOutcome
+import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard
@@ -78,6 +80,7 @@ import kotlin.math.PI
private enum class RadarPage(val title: String) {
Transceivers("Transceivers"),
+ Log("Log"),
Sstv("SSTV")
}
@@ -119,9 +122,33 @@ fun RadarDestination(navigateUp: () -> Unit) {
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
viewModel.onAction(RadarAction.CwPermissionResult(granted))
}
- RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = {
- permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
- })
+ // WaveLog(4.5.2): 自动上传 — 每 10 分钟重试本地队列(开关开启时)
+ val wavelogUploader = remember { container.provideWavelogUploader() }
+ LaunchedEffect(Unit) {
+ while (true) {
+ delay(10 * 60 * 1000L)
+ val s = container.settingsRepo.otherSettings.value
+ if (s.wavelogAutoUpload && s.wavelogUrl.isNotBlank()) {
+ // 网格不一致时静默跳过(留给手动上传确认), 其余失败自动下次重试
+ val outcome = wavelogUploader.uploadQueue()
+ if (outcome is UploadOutcome.NeedConfirm) {
+ // 跳过本次; 用户可在设置页手动上传并确认
+ }
+ }
+ }
+ }
+ RadarScreen(
+ uiState,
+ viewModel::onAction,
+ navigateUpAndClearMutual,
+ mutualData,
+ { permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) },
+ wavelogQueue = container.wavelogQueue,
+ wavelogConfigured = container.settingsRepo.otherSettings.value.let {
+ it.wavelogUrl.isNotBlank() && it.wavelogApiKey.isNotBlank() && it.wavelogStationId.isNotBlank()
+ },
+ showToast = { msg -> container.provideShowToast()(msg) }
+ )
}
@Composable
@@ -130,7 +157,10 @@ private fun RadarScreen(
onAction: (RadarAction) -> Unit,
navigateUp: () -> Unit,
mutualData: MutualPassData,
- requestMicPermission: () -> Unit
+ requestMicPermission: () -> Unit,
+ wavelogQueue: WavelogQueue,
+ wavelogConfigured: Boolean,
+ showToast: (String) -> Unit
) {
val upcomingPass = uiState.currentPass ?: getDefaultPass()
val addToCalendar: () -> Unit = {
@@ -184,11 +214,11 @@ private fun RadarScreen(
}
if (isVertical) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
- PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
+ PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, Modifier.weight(1f))
} else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
- PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
+ PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, Modifier.weight(1f))
}
}
}
@@ -199,6 +229,9 @@ private fun PagerCard(
uiState: RadarState,
onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit,
+ wavelogQueue: WavelogQueue,
+ wavelogConfigured: Boolean,
+ showToast: (String) -> Unit,
modifier: Modifier = Modifier
) {
val pages = RadarPage.entries
@@ -236,6 +269,14 @@ private fun PagerCard(
onAction = onAction,
requestMicPermission = requestMicPermission
)
+ RadarPage.Log -> LogTab(
+ transceivers = uiState.transceivers.transmitters,
+ orbitalPos = uiState.orbitalPos,
+ satelliteName = uiState.currentPass?.name ?: "",
+ queue = wavelogQueue,
+ wavelogConfigured = wavelogConfigured,
+ showToast = showToast
+ )
}
}
}
diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt
index 9c7a6ce9..28ce4f32 100644
--- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt
@@ -46,14 +46,18 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
+import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
@@ -80,6 +84,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -111,6 +116,34 @@ fun SettingsDestination() {
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SettingsScreen(uiState, viewModel::onAction)
+
+ // WaveLog 网格不一致确认弹窗(4.5.2)
+ val gridConfirm = viewModel.gridConfirm
+ if (gridConfirm != null) {
+ AlertDialog(
+ onDismissRequest = { viewModel.resolveGridConfirm(false) },
+ title = { Text(text = stringResource(id = R.string.wavelog_title)) },
+ text = {
+ Text(
+ text = stringResource(
+ id = R.string.wavelog_grid_mismatch,
+ gridConfirm.userGrid.take(4),
+ gridConfirm.stationGrid.take(4)
+ )
+ )
+ },
+ confirmButton = {
+ TextButton(onClick = { viewModel.resolveGridConfirm(true) }) {
+ Text(text = stringResource(id = R.string.wavelog_ignore))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { viewModel.resolveGridConfirm(false) }) {
+ Text(text = stringResource(id = R.string.wavelog_cancel))
+ }
+ }
+ )
+ }
}
@Composable
@@ -326,6 +359,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
)
}
item { OtherCard(uiState.otherSettings, onAction) }
+ item { WavelogCard(uiState.otherSettings, onAction) }
item {
UiSettingsCard(
hiddenScreens = uiState.otherSettings.hiddenScreens,
@@ -558,6 +592,71 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
* 顺序固定(卫星/过境/雷达/匹配/漫游/地图/设置), 关闭后其余自动靠拢;
* "设置"页固定显示(防止失去设置入口)。
*/
+/** WaveLog 日志服务器设置卡片(4.5.2): 照用户截图布局(深色卡片/标签左输入右/按钮行) */
+@Composable
+private fun WavelogCard(
+ settings: OtherSettings,
+ onAction: (SettingsAction) -> Unit
+) {
+ ElevatedCard(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Text(
+ text = stringResource(id = R.string.prefs_wavelog_title),
+ color = MaterialTheme.colorScheme.primary
+ )
+ OutlinedTextField(
+ value = settings.wavelogUrl,
+ onValueChange = {
+ onAction(SettingsAction.UpdateWavelogSettings(it, settings.wavelogApiKey, settings.wavelogStationId, settings.wavelogAutoUpload))
+ },
+ label = { Text(stringResource(id = R.string.prefs_wavelog_url_hint)) },
+ singleLine = true,
+ textStyle = LocalTextStyle.current.copy(fontSize = 14.sp),
+ modifier = Modifier.fillMaxWidth()
+ )
+ OutlinedTextField(
+ value = settings.wavelogApiKey,
+ onValueChange = {
+ onAction(SettingsAction.UpdateWavelogSettings(settings.wavelogUrl, it, settings.wavelogStationId, settings.wavelogAutoUpload))
+ },
+ label = { Text(stringResource(id = R.string.prefs_wavelog_key_hint)) },
+ singleLine = true,
+ textStyle = LocalTextStyle.current.copy(fontSize = 14.sp),
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth()
+ )
+ OutlinedTextField(
+ value = settings.wavelogStationId,
+ onValueChange = {
+ onAction(SettingsAction.UpdateWavelogSettings(settings.wavelogUrl, settings.wavelogApiKey, it, settings.wavelogAutoUpload))
+ },
+ label = { Text(stringResource(id = R.string.prefs_wavelog_station_hint)) },
+ singleLine = true,
+ textStyle = LocalTextStyle.current.copy(fontSize = 14.sp),
+ modifier = Modifier.fillMaxWidth()
+ )
+ SwitchRow(R.string.prefs_wavelog_auto, settings.wavelogAutoUpload) {
+ onAction(SettingsAction.UpdateWavelogSettings(settings.wavelogUrl, settings.wavelogApiKey, settings.wavelogStationId, it))
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ CardButton(
+ onClick = { onAction(SettingsAction.TestWavelogConnection) },
+ text = stringResource(id = R.string.prefs_wavelog_test),
+ modifier = Modifier.weight(1f)
+ )
+ CardButton(
+ onClick = { onAction(SettingsAction.UploadWavelogQueue) },
+ text = stringResource(id = R.string.prefs_wavelog_upload),
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+ }
+}
+
@Composable
private fun UiSettingsCard(
hiddenScreens: List,
diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt
index 3239649d..d29626a7 100644
--- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt
+++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt
@@ -72,8 +72,16 @@ sealed interface SettingsAction {
data object ResetScreenOrder : SettingsAction
// UI 设置: 更新主菜单 + 更多菜单顺序(4.5.1 折叠菜单)
data class UpdateMenuOrder(val mainOrder: List, val subOrder: List) : SettingsAction
- // UI 设置: 重置主/子菜单为默认
+ // UI 设置: 重置主菜单 + 更多菜单顺序
data object ResetMenuOrder : SettingsAction
+ // WaveLog 日志(4.5.2): 更新服务器配置
+ data class UpdateWavelogSettings(
+ val url: String, val apiKey: String, val stationId: String, val autoUpload: Boolean
+ ) : SettingsAction
+ // WaveLog: 测试连接
+ data object TestWavelogConnection : SettingsAction
+ // WaveLog: 手动上传队列
+ data object UploadWavelogQueue : SettingsAction
// Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction
diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt
index e39280ee..b3bec5bc 100644
--- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt
+++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt
@@ -25,11 +25,18 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
+import com.rtbishop.look4sat.core.domain.wavelog.UploadOutcome
+import com.rtbishop.look4sat.core.domain.wavelog.WaveLogApi
+import com.rtbishop.look4sat.core.domain.wavelog.WavelogResult
+import com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
import com.rtbishop.look4sat.core.presentation.R
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
class SettingsViewModel(
private val databaseRepo: IDatabaseRepo,
@@ -138,6 +145,17 @@ class SettingsViewModel(
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
+ // WaveLog 日志(4.5.2)
+ is SettingsAction.UpdateWavelogSettings -> settingsRepo.updateOtherSettings {
+ it.copy(
+ wavelogUrl = action.url,
+ wavelogApiKey = action.apiKey,
+ wavelogStationId = action.stationId,
+ wavelogAutoUpload = action.autoUpload
+ )
+ }
+ SettingsAction.TestWavelogConnection -> testWavelogConnection()
+ SettingsAction.UploadWavelogQueue -> uploadWavelogQueue()
// System
is SettingsAction.ShowToast -> showToast(action.message)
}
@@ -210,6 +228,58 @@ class SettingsViewModel(
}
+ // endregion
+
+ // region WaveLog(4.5.2)
+
+ private fun testWavelogConnection() {
+ viewModelScope.launch {
+ val s = settingsRepo.otherSettings.value
+ val result = WaveLogApi.testToken(s.wavelogUrl, s.wavelogApiKey)
+ showToast(
+ when (result) {
+ is WavelogResult.Success -> "WaveLog: 连接成功"
+ is WavelogResult.Failure -> "WaveLog: ${result.message}"
+ }
+ )
+ }
+ }
+
+ // 由 SettingsScreen 注入的共享 uploader(容器级单例)
+ var pendingUploader: WavelogUploader? = null
+
+ // 网格不一致确认弹窗状态(UI 观察后弹 AlertDialog)
+ var gridConfirm by mutableStateOf(null)
+
+ data class GridConfirmData(val stationGrid: String, val userGrid: String)
+
+ private fun uploadWavelogQueue() {
+ viewModelScope.launch {
+ val result = pendingUploader?.uploadQueue()
+ when (result) {
+ is UploadOutcome.Done -> showToast("WaveLog: ${result.message}")
+ is UploadOutcome.NeedConfirm -> {
+ gridConfirm = GridConfirmData(result.stationGrid, result.userGrid)
+ }
+ null -> showToast("WaveLog: 上传失败")
+ }
+ }
+ }
+
+ /** 网格确认结果: 忽略并上传 / 取消 */
+ fun resolveGridConfirm(ignoreAndUpload: Boolean) {
+ val confirm = gridConfirm ?: return
+ gridConfirm = null
+ if (!ignoreAndUpload) return
+ viewModelScope.launch {
+ val result = pendingUploader?.uploadQueue(force = true)
+ when (result) {
+ is UploadOutcome.Done -> showToast("WaveLog: ${result.message}")
+ else -> showToast("WaveLog: 上传失败")
+ }
+ }
+ }
+
// endregion
companion object {
@@ -220,7 +290,7 @@ class SettingsViewModel(
databaseRepo = container.databaseRepo,
settingsRepo = container.settingsRepo,
showToast = container.provideShowToast()
- )
+ ).apply { pendingUploader = container.provideWavelogUploader() }
}
}
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 51eebe39..3b1d35b6 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
-appVersionCode = "451"
+appVersionCode = "452"
#noinspection UnusedVersionCatalogEntry
-appVersionName = "4.5.1"
+appVersionName = "4.5.2"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry