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:
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.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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
plugins {
|
||||
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 设置: 页面顺序(空 = 默认顺序: 卫星/过境/雷达/地图/设置)
|
||||
val screenOrder: List<String> = emptyList(),
|
||||
// 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(
|
||||
|
||||
+3
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
+84
@@ -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>
|
||||
<string name="pass_whatsnew_title">Apa yang baru di Look4Sat</string>
|
||||
<string name="pass_whatsnew_message">
|
||||
* 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</string>
|
||||
* Integrasi logbook WaveLog baru: catat kontak satelit dari halaman radar (tab Log)
|
||||
* Unggah otomatis/manual ke server WaveLog Anda dengan deteksi ketidakcocokan grid</string>
|
||||
<string name="radar_back">Kembali</string>
|
||||
<string name="radar_notify">Beri tahu</string>
|
||||
<string name="radar_az_text">Azimuth</string>
|
||||
@@ -177,6 +177,23 @@
|
||||
<string name="disconnect_radio">Putuskan</string>
|
||||
<string name="track_radio">Lacak</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_ui_title">Pengaturan UI</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>
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||
<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ü)
|
||||
* UI Ayarları: sayfa sırası ana menü ve daha fazla menü olarak ayrıldı; sayfalar arasında taşıma yapılabilir</string>
|
||||
* Yeni WaveLog günlük entegrasyonu: radar sayfasından uydu temaslarını kaydedin (Günlük sekmesi)
|
||||
* Izgara uyuşmazlığı algılamayla WaveLog sunucunuza otomatik/manuel yükleme</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">Geri</string>
|
||||
@@ -187,6 +187,23 @@
|
||||
<string name="disconnect_radio">Bağlantıyı Kes</string>
|
||||
<string name="track_radio">Takip</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_ui_title">UI Ayarları</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_whatsnew_title">Look4Sat Pro 更新内容</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* 底部导航改为 5 个主标签 + 更多菜单(可折叠二级菜单)
|
||||
* UI 设置: 页面顺序分为主菜单与更多菜单,可拖拽或移动页面</string>
|
||||
* 新增 WaveLog 日志系统: 雷达页 Log 标签记录卫星通联日志
|
||||
* 自动/手动上传到 WaveLog 服务器,带网格不一致检测</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">后退</string>
|
||||
@@ -170,6 +170,23 @@
|
||||
<string name="disconnect_radio">断开</string>
|
||||
<string name="track_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_ui_title">UI设置</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>
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* 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</string>
|
||||
* New WaveLog logbook integration: log satellite contacts from the radar page (Log tab)
|
||||
* Auto/manual upload to your WaveLog server with grid mismatch detection</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">Back</string>
|
||||
@@ -201,6 +201,23 @@
|
||||
<string name="disconnect_radio">Disconnect</string>
|
||||
<string name="track_radio">Track</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_ui_title">UI Settings</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.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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+99
@@ -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<String>,
|
||||
|
||||
+9
-1
@@ -72,8 +72,16 @@ sealed interface SettingsAction {
|
||||
data object ResetScreenOrder : SettingsAction
|
||||
// UI 设置: 更新主菜单 + 更多菜单顺序(4.5.1 折叠菜单)
|
||||
data class UpdateMenuOrder(val mainOrder: List<String>, val subOrder: List<String>) : 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
|
||||
|
||||
+71
-1
@@ -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<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
|
||||
|
||||
companion object {
|
||||
@@ -220,7 +290,7 @@ class SettingsViewModel(
|
||||
databaseRepo = container.databaseRepo,
|
||||
settingsRepo = container.settingsRepo,
|
||||
showToast = container.provideShowToast()
|
||||
)
|
||||
).apply { pendingUploader = container.provideWavelogUploader() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user