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