feat(wavelog,radar,settings): WaveLog logbook integration + Log tab

Background: satellite operators want to log QSOs during a pass while
watching live frequencies. 4.5.2 adds WaveLog (logbook server) API v2
integration: log from the radar page, upload to a self-hosted WaveLog
instance with grid-mismatch protection.

Changes:
- Radar page: new third tab "Log" between Transceivers and SSTV
  - pick a transponder, watch live TX/RX Doppler-corrected frequencies
  - enter callsign, Enter stores locally (UTC time + that second's
    frequencies sampled together)
  - local entry list shows time/frequency/callsign only (no upload
    status, per user: proves the entry was saved)
  - swipe-to-delete: yellow trash while swiping, turns into Undo at
    75%, 5s countdown before auto-delete (custom gesture, no
    SwipeToDismissBox)
- Settings: new WaveLog card (server URL / API key / station ID /
  auto-upload switch / test connection / upload now buttons) matching
  the user's reference screenshot layout
- Upload pipeline: POST /index.php/api/v2/qso with required fields
  (station_profile_id, call, band=SAT, mode, qso_date, time_on UTC)
  plus freq/freq_rx (Hz), gridsquare (from station profile via
  GET /api/v2/station/{id}), sat_name; RST omitted per user
- Grid check: station gridsquare (first 4) vs user QTH (first 4);
  mismatch shows a confirm dialog (ignore & upload / cancel)
- Auto upload: 10-minute in-app retry loop (only when switch on);
  manual upload button; local queue capped at 500, all entries stored
  locally regardless of switch
- Fixed: subMenuOrder was never persisted (4.5.1 regression)
- core/domain: compileOnly org.json (runtime uses Android's)
- Version 4.5.2 (452)

Verified: check_strings.py OK (8 files, 452/4.5.2);
:app:compileDebugKotlin BUILD SUCCESSFUL locally.
This commit is contained in:
mckero committed 2026-08-04 10:29:40 +00:00
1 parent 38cf7a3569
commit 916a0d0f0a
18 files changed
+1064 -20

No files matched your search

@@ -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.AudioCapture
import com.rtbishop.look4sat.core.data.usecase.SaveImage import com.rtbishop.look4sat.core.data.usecase.SaveImage
import com.rtbishop.look4sat.core.data.usecase.ShowToast 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.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.IMainContainer 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) 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 { override fun provideBluetoothReporter(): IReporter {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val rc = settingsRepo.rcSettings.value val rc = settingsRepo.rcSettings.value
@@ -94,6 +94,11 @@ class SettingsRepo(
private val keyUseCustomTransceivers = "useCustomTransceivers" private val keyUseCustomTransceivers = "useCustomTransceivers"
private val keyTleUrl = "tleUrl" private val keyTleUrl = "tleUrl"
private val keyTransceiversUrl = "transceiversUrl" 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 = "," private val separatorComma = ","
//region # Satellites selection settings //region # Satellites selection settings
@@ -361,6 +366,11 @@ class SettingsRepo(
putLong(keyHighElevation, new.highElevation.toRawBits()) putLong(keyHighElevation, new.highElevation.toRawBits())
putStringSet(keyHiddenScreens, new.hiddenScreens.toSet()) putStringSet(keyHiddenScreens, new.hiddenScreens.toSet())
putString(keyScreenOrder, new.screenOrder.joinToString(",")) 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 new
} }
@@ -378,8 +388,13 @@ class SettingsRepo(
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto", sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
hiddenScreens = preferences.getStringSet(keyHiddenScreens, emptySet())?.toList() ?: emptyList(), hiddenScreens = preferences.getStringSet(keyHiddenScreens, emptySet())?.toList() ?: emptyList(),
screenOrder = preferences.getString(keyScreenOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: 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())), 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 //endregion
+5
View File
@@ -1,3 +1,8 @@
plugins { plugins {
alias(libs.plugins.convention.coreDomainPlugin) alias(libs.plugins.convention.coreDomainPlugin)
} }
dependencies {
// 编译期使用 org.json(构造/解析 WaveLog API 请求体); 运行时用 Android 系统自带的 org.json
compileOnly("org.json:json:20240303")
}
@@ -68,7 +68,12 @@ data class OtherSettings(
// UI 设置: 页面顺序(空 = 默认顺序: 卫星/过境/雷达/地图/设置) // UI 设置: 页面顺序(空 = 默认顺序: 卫星/过境/雷达/地图/设置)
val screenOrder: List<String> = emptyList(), val screenOrder: List<String> = emptyList(),
// UI 设置: 更多菜单顺序(空 = 默认: 匹配/漫游/CW解码) // UI 设置: 更多菜单顺序(空 = 默认: 匹配/漫游/CW解码)
val subMenuOrder: List<String> = emptyList() val subMenuOrder: List<String> = emptyList(),
// WaveLog 日志系统(4.5.2): 服务器配置
val wavelogUrl: String = "",
val wavelogApiKey: String = "",
val wavelogStationId: String = "",
val wavelogAutoUpload: Boolean = false
) )
data class DataSourcesSettings( data class DataSourcesSettings(
@@ -42,6 +42,9 @@ interface IMainContainer {
fun provideRxRadioController(): IRadioController fun provideRxRadioController(): IRadioController
fun provideAudioCapture(): IAudioCapture fun provideAudioCapture(): IAudioCapture
fun provideSaveImage(): ISaveImage 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( data class MutualPassData(
@@ -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)
)
}
}
@@ -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<WavelogQso> {
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<String>) {
save(all().filter { it.id !in ids })
}
private fun save(list: List<WavelogQso>) {
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())
}
}
@@ -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<String>()
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 }
}
}
@@ -48,8 +48,8 @@
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string> \n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
<string name="pass_whatsnew_title">Apa yang baru di Look4Sat</string> <string name="pass_whatsnew_title">Apa yang baru di Look4Sat</string>
<string name="pass_whatsnew_message"> <string name="pass_whatsnew_message">
* Navigasi bawah kini menggunakan 5 tab utama plus menu Lainnya (menu tingkat kedua yang dapat dilipat) * Integrasi logbook WaveLog baru: catat kontak satelit dari halaman radar (tab Log)
* Pengaturan UI: urutan halaman dipisah menjadi menu utama dan menu lainnya; pindahkan halaman di antara keduanya</string> * Unggah otomatis/manual ke server WaveLog Anda dengan deteksi ketidakcocokan grid</string>
<string name="radar_back">Kembali</string> <string name="radar_back">Kembali</string>
<string name="radar_notify">Beri tahu</string> <string name="radar_notify">Beri tahu</string>
<string name="radar_az_text">Azimuth</string> <string name="radar_az_text">Azimuth</string>
@@ -177,6 +177,23 @@
<string name="disconnect_radio">Putuskan</string> <string name="disconnect_radio">Putuskan</string>
<string name="track_radio">Lacak</string> <string name="track_radio">Lacak</string>
<string name="stop_radio">Berhenti</string> <string name="stop_radio">Berhenti</string>
<string name="prefs_wavelog_title">Server log WaveLog</string>
<string name="prefs_wavelog_url_hint">Alamat server</string>
<string name="prefs_wavelog_key_hint">Kunci API</string>
<string name="prefs_wavelog_station_hint">ID stasiun</string>
<string name="prefs_wavelog_auto">Unggah otomatis</string>
<string name="prefs_wavelog_test">Uji koneksi</string>
<string name="prefs_wavelog_upload">Unggah sekarang</string>
<string name="wavelog_title">Log</string>
<string name="wavelog_call_hint">Tanda panggil</string>
<string name="wavelog_mode_hint">Mode</string>
<string name="wavelog_saved">Tersimpan ke log lokal</string>
<string name="wavelog_need_config">Konfigurasikan WaveLog di Pengaturan terlebih dahulu</string>
<string name="wavelog_empty">Belum ada entri log</string>
<string name="wavelog_grid_mismatch">Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah?</string>
<string name="wavelog_ignore">Abaikan dan unggah</string>
<string name="wavelog_cancel">Batal</string>
<string name="wavelog_delete_undo">Urungkan</string>
<string name="prefs_other_title">Pengaturan lainnya</string> <string name="prefs_other_title">Pengaturan lainnya</string>
<string name="prefs_ui_title">Pengaturan UI</string> <string name="prefs_ui_title">Pengaturan UI</string>
<string name="prefs_ui_order_title">Urutan halaman</string> <string name="prefs_ui_order_title">Urutan halaman</string>
@@ -53,8 +53,8 @@
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string> \n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string> <string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false"> <string name="pass_whatsnew_message" translatable="false">
* Alt gezinme artık 5 ana sekme artı Daha Fazla menüsü kullanıyor (katlanabilir ikinci düzey menü) * Yeni WaveLog günlük entegrasyonu: radar sayfasından uydu temaslarını kaydedin (Günlük sekmesi)
* UI Ayarları: sayfa sırası ana menü ve daha fazla menü olarak ayrıldı; sayfalar arasında taşıma yapılabilir</string> * Izgara uyuşmazlığı algılamayla WaveLog sunucunuza otomatik/manuel yükleme</string>
<!-- Radar screen --> <!-- Radar screen -->
<string name="radar_back">Geri</string> <string name="radar_back">Geri</string>
@@ -187,6 +187,23 @@
<string name="disconnect_radio">Bağlantıyı Kes</string> <string name="disconnect_radio">Bağlantıyı Kes</string>
<string name="track_radio">Takip</string> <string name="track_radio">Takip</string>
<string name="stop_radio">Dur</string> <string name="stop_radio">Dur</string>
<string name="prefs_wavelog_title">WaveLog günlük sunucusu</string>
<string name="prefs_wavelog_url_hint">Sunucu adresi</string>
<string name="prefs_wavelog_key_hint">API anahtarı</string>
<string name="prefs_wavelog_station_hint">İstasyon kimliği</string>
<string name="prefs_wavelog_auto">Otomatik yükleme</string>
<string name="prefs_wavelog_test">Bağlantıyı test et</string>
<string name="prefs_wavelog_upload">Şimdi yükle</string>
<string name="wavelog_title">Günlük</string>
<string name="wavelog_call_hint">Çağrı işareti</string>
<string name="wavelog_mode_hint">Mod</string>
<string name="wavelog_saved">Yerel günlüğe kaydedildi</string>
<string name="wavelog_need_config">Önce Ayarlar\'da WaveLog\'u yapılandırın</string>
<string name="wavelog_empty">Henüz günlük kaydı yok</string>
<string name="wavelog_grid_mismatch">QTH ızgarası (%1$s) istasyon ızgarasıyla (%2$s) eşleşmiyor. Yine de yüklensin mi?</string>
<string name="wavelog_ignore">Yoksay ve yükle</string>
<string name="wavelog_cancel">İptal</string>
<string name="wavelog_delete_undo">Geri al</string>
<string name="prefs_other_title">Diğer ayarlar</string> <string name="prefs_other_title">Diğer ayarlar</string>
<string name="prefs_ui_title">UI Ayarları</string> <string name="prefs_ui_title">UI Ayarları</string>
<string name="prefs_ui_order_title">Sayfa sırası</string> <string name="prefs_ui_order_title">Sayfa sırası</string>
@@ -43,8 +43,8 @@
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string> <string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string> <string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
<string name="pass_whatsnew_message" translatable="false"> <string name="pass_whatsnew_message" translatable="false">
* 底部导航改为 5 个主标签 + 更多菜单(可折叠二级菜单) * 新增 WaveLog 日志系统: 雷达页 Log 标签记录卫星通联日志
* UI 设置: 页面顺序分为主菜单与更多菜单,可拖拽或移动页面</string> * 自动/手动上传到 WaveLog 服务器,带网格不一致检测</string>
<!-- Radar screen --> <!-- Radar screen -->
<string name="radar_back">后退</string> <string name="radar_back">后退</string>
@@ -170,6 +170,23 @@
<string name="disconnect_radio">断开</string> <string name="disconnect_radio">断开</string>
<string name="track_radio">跟踪</string> <string name="track_radio">跟踪</string>
<string name="stop_radio">停止</string> <string name="stop_radio">停止</string>
<string name="prefs_wavelog_title">WaveLog 日志服务器</string>
<string name="prefs_wavelog_url_hint">服务器地址</string>
<string name="prefs_wavelog_key_hint">API 密钥</string>
<string name="prefs_wavelog_station_hint">站点 ID</string>
<string name="prefs_wavelog_auto">自动上传</string>
<string name="prefs_wavelog_test">测试连接</string>
<string name="prefs_wavelog_upload">手动上传</string>
<string name="wavelog_title">日志</string>
<string name="wavelog_call_hint">对方呼号</string>
<string name="wavelog_mode_hint">模式</string>
<string name="wavelog_saved">已存入本地日志</string>
<string name="wavelog_need_config">请先在设置中配置 WaveLog</string>
<string name="wavelog_empty">暂无日志记录</string>
<string name="wavelog_grid_mismatch">当前 QTH 网格(%1$s)与站点网格(%2$s)不一致,仍要上传?</string>
<string name="wavelog_ignore">忽略并上传</string>
<string name="wavelog_cancel">取消</string>
<string name="wavelog_delete_undo">撤销</string>
<string name="prefs_other_title">其他设置</string> <string name="prefs_other_title">其他设置</string>
<string name="prefs_ui_title">UI设置</string> <string name="prefs_ui_title">UI设置</string>
<string name="prefs_ui_order_title">页面顺序</string> <string name="prefs_ui_order_title">页面顺序</string>
@@ -55,8 +55,8 @@
\n\nPlease update the database at least weekly to get accurate predictions.</string> \n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string> <string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false"> <string name="pass_whatsnew_message" translatable="false">
* Bottom navigation now uses 5 main tabs plus a More menu (collapsible second-level menu) * New WaveLog logbook integration: log satellite contacts from the radar page (Log tab)
* UI Settings: page order split into main menu and more menu; drag or move pages between them</string> * Auto/manual upload to your WaveLog server with grid mismatch detection</string>
<!-- Radar screen --> <!-- Radar screen -->
<string name="radar_back">Back</string> <string name="radar_back">Back</string>
@@ -201,6 +201,23 @@
<string name="disconnect_radio">Disconnect</string> <string name="disconnect_radio">Disconnect</string>
<string name="track_radio">Track</string> <string name="track_radio">Track</string>
<string name="stop_radio">Stop</string> <string name="stop_radio">Stop</string>
<string name="prefs_wavelog_title">WaveLog log server</string>
<string name="prefs_wavelog_url_hint">Server address</string>
<string name="prefs_wavelog_key_hint">API key</string>
<string name="prefs_wavelog_station_hint">Station ID</string>
<string name="prefs_wavelog_auto">Auto upload</string>
<string name="prefs_wavelog_test">Test connection</string>
<string name="prefs_wavelog_upload">Upload now</string>
<string name="wavelog_title">Log</string>
<string name="wavelog_call_hint">Callsign</string>
<string name="wavelog_mode_hint">Mode</string>
<string name="wavelog_saved">Saved to local log</string>
<string name="wavelog_need_config">Configure WaveLog in Settings first</string>
<string name="wavelog_empty">No log entries yet</string>
<string name="wavelog_grid_mismatch">QTH grid (%1$s) does not match station grid (%2$s). Upload anyway?</string>
<string name="wavelog_ignore">Ignore and upload</string>
<string name="wavelog_cancel">Cancel</string>
<string name="wavelog_delete_undo">Undo</string>
<string name="prefs_other_title">Other settings</string> <string name="prefs_other_title">Other settings</string>
<string name="prefs_ui_title">UI Settings</string> <string name="prefs_ui_title">UI Settings</string>
<string name="prefs_ui_order_title">Page order</string> <string name="prefs_ui_order_title">Page order</string>
@@ -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<SatRadio>,
orbitalPos: OrbitalPos?,
satelliteName: String,
queue: WavelogQueue,
wavelogConfigured: Boolean,
showToast: (String) -> Unit,
modifier: Modifier = Modifier
) {
var selectedUuid by remember { mutableStateOf<String?>(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<Long, Long> {
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))
}
@@ -61,6 +61,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.MutualPassData 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.domain.utility.toDegrees
import com.rtbishop.look4sat.core.presentation.EmptyListCard import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.IconCard
@@ -78,6 +80,7 @@ import kotlin.math.PI
private enum class RadarPage(val title: String) { private enum class RadarPage(val title: String) {
Transceivers("Transceivers"), Transceivers("Transceivers"),
Log("Log"),
Sstv("SSTV") Sstv("SSTV")
} }
@@ -119,9 +122,33 @@ fun RadarDestination(navigateUp: () -> Unit) {
viewModel.onAction(RadarAction.SstvPermissionResult(granted)) viewModel.onAction(RadarAction.SstvPermissionResult(granted))
viewModel.onAction(RadarAction.CwPermissionResult(granted)) viewModel.onAction(RadarAction.CwPermissionResult(granted))
} }
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = { // WaveLog(4.5.2): 自动上传 — 每 10 分钟重试本地队列(开关开启时)
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) 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 @Composable
@@ -130,7 +157,10 @@ private fun RadarScreen(
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
navigateUp: () -> Unit, navigateUp: () -> Unit,
mutualData: MutualPassData, mutualData: MutualPassData,
requestMicPermission: () -> Unit requestMicPermission: () -> Unit,
wavelogQueue: WavelogQueue,
wavelogConfigured: Boolean,
showToast: (String) -> Unit
) { ) {
val upcomingPass = uiState.currentPass ?: getDefaultPass() val upcomingPass = uiState.currentPass ?: getDefaultPass()
val addToCalendar: () -> Unit = { val addToCalendar: () -> Unit = {
@@ -184,11 +214,11 @@ private fun RadarScreen(
} }
if (isVertical) { if (isVertical) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f)) 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 { } else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f)) 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, uiState: RadarState,
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit, requestMicPermission: () -> Unit,
wavelogQueue: WavelogQueue,
wavelogConfigured: Boolean,
showToast: (String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val pages = RadarPage.entries val pages = RadarPage.entries
@@ -236,6 +269,14 @@ private fun PagerCard(
onAction = onAction, onAction = onAction,
requestMicPermission = requestMicPermission requestMicPermission = requestMicPermission
) )
RadarPage.Log -> LogTab(
transceivers = uiState.transceivers.transmitters,
orbitalPos = uiState.orbitalPos,
satelliteName = uiState.currentPass?.name ?: "",
queue = wavelogQueue,
wavelogConfigured = wavelogConfigured,
showToast = showToast
)
} }
} }
} }
@@ -46,14 +46,18 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf 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.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -111,6 +116,34 @@ fun SettingsDestination() {
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container)) val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SettingsScreen(uiState, viewModel::onAction) 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 @Composable
@@ -326,6 +359,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
) )
} }
item { OtherCard(uiState.otherSettings, onAction) } item { OtherCard(uiState.otherSettings, onAction) }
item { WavelogCard(uiState.otherSettings, onAction) }
item { item {
UiSettingsCard( UiSettingsCard(
hiddenScreens = uiState.otherSettings.hiddenScreens, 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 @Composable
private fun UiSettingsCard( private fun UiSettingsCard(
hiddenScreens: List<String>, hiddenScreens: List<String>,
@@ -72,8 +72,16 @@ sealed interface SettingsAction {
data object ResetScreenOrder : SettingsAction data object ResetScreenOrder : SettingsAction
// UI 设置: 更新主菜单 + 更多菜单顺序(4.5.1 折叠菜单) // UI 设置: 更新主菜单 + 更多菜单顺序(4.5.1 折叠菜单)
data class UpdateMenuOrder(val mainOrder: List<String>, val subOrder: List<String>) : SettingsAction data class UpdateMenuOrder(val mainOrder: List<String>, val subOrder: List<String>) : SettingsAction
// UI 设置: 重置主/子菜单为默认 // UI 设置: 重置主菜单 + 更多菜单顺序
data object ResetMenuOrder : SettingsAction 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 // Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction data class UpdateRC(val settings: RCSettings) : SettingsAction
@@ -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.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.usecase.IShowToast 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 com.rtbishop.look4sat.core.presentation.R
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
class SettingsViewModel( class SettingsViewModel(
private val databaseRepo: IDatabaseRepo, private val databaseRepo: IDatabaseRepo,
@@ -138,6 +145,17 @@ class SettingsViewModel(
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings) is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings) is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(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 // System
is SettingsAction.ShowToast -> showToast(action.message) 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<GridConfirmData?>(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 // endregion
companion object { companion object {
@@ -220,7 +290,7 @@ class SettingsViewModel(
databaseRepo = container.databaseRepo, databaseRepo = container.databaseRepo,
settingsRepo = container.settingsRepo, settingsRepo = container.settingsRepo,
showToast = container.provideShowToast() showToast = container.provideShowToast()
) ).apply { pendingUploader = container.provideWavelogUploader() }
} }
} }
} }
+2 -2
View File
@@ -1,8 +1,8 @@
[versions] [versions]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionCode = "451" appVersionCode = "452"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionName = "4.5.1" appVersionName = "4.5.2"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
compileSdk = "37" compileSdk = "37"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry