Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9d87be289 | ||
|
|
012ea1eeb6 | ||
|
|
920af7bde2 | ||
|
|
88a8e795d1 | ||
|
|
44ae5cef5d | ||
|
|
518d9a4d22 | ||
|
|
72f507163a | ||
|
|
f7a3dbaa2d | ||
|
|
c525f3a11f | ||
|
|
2777607ab5 | ||
|
|
a222cbb839 | ||
|
|
0ef8f05dc4 | ||
|
|
f7402a3531 | ||
|
|
dd378990fe | ||
|
|
a23bf34ccf | ||
|
|
a724c0823d | ||
|
|
cda6cf8583 | ||
|
|
c09e87fa4e | ||
|
|
418a05e343 | ||
|
|
0bcf73e1f9 | ||
|
|
ab1a0c0891 | ||
|
|
1a55241791 | ||
|
|
9d437a90c9 | ||
|
|
5351afe508 | ||
|
|
c80e351212 | ||
|
|
916a0d0f0a | ||
|
|
a333192df5 |
No files matched your search
@@ -18,6 +18,12 @@ android {
|
|||||||
abiFilters += listOf("armeabi-v7a")
|
abiFilters += listOf("armeabi-v7a")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
androidResources {
|
||||||
|
// 显式保留全部语言(防 shrinkResources 丢弃 in/id 印尼语配置); AGP 9 用 localeFilters
|
||||||
|
localeFilters += listOf(
|
||||||
|
"en", "zh", "tr", "in", "id", "es", "ru", "si", "uk"
|
||||||
|
)
|
||||||
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
if (keystoreProperties["storeFile"] != null) {
|
if (keystoreProperties["storeFile"] != null) {
|
||||||
create("release") {
|
create("release") {
|
||||||
|
|||||||
@@ -14,7 +14,10 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
<application
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<application
|
||||||
android:name=".MainApplication"
|
android:name=".MainApplication"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
@@ -45,5 +48,9 @@
|
|||||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||||
android:value="com.rtbishop.look4sat.bg7nta" />
|
android:value="com.rtbishop.look4sat.bg7nta" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name="com.rtbishop.look4sat.app.AprsForegroundService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package com.rtbishop.look4sat.app
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.widget.Toast
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import com.rtbishop.look4sat.MainApplication
|
||||||
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
|
||||||
|
import com.rtbishop.look4sat.core.data.aprs.AprsStore
|
||||||
|
import com.rtbishop.look4sat.core.data.aprs.AprsReporter
|
||||||
|
import com.rtbishop.look4sat.core.data.aprs.AprsState
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APRS 前台服务:系统通知保活,切换页面不断线。
|
||||||
|
* START_STICKY:被系统杀掉后自动重启(APRSdroid 同款策略)。
|
||||||
|
*/
|
||||||
|
class AprsForegroundService : Service() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_START = AprsStore.ACTION_START
|
||||||
|
const val ACTION_STOP = AprsStore.ACTION_STOP
|
||||||
|
const val ACTION_REPORT_NOW = AprsStore.ACTION_REPORT_NOW
|
||||||
|
const val CHANNEL_ID = "aprs_service"
|
||||||
|
const val NOTIF_ID = 101
|
||||||
|
}
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var reporter: AprsReporter? = null
|
||||||
|
private var lastState: AprsState = AprsState.Idle
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_STOP -> stopReporting()
|
||||||
|
ACTION_REPORT_NOW -> {
|
||||||
|
if (reporter == null) {
|
||||||
|
// 服务没在跑:先启动(未配置会 Toast 提示)
|
||||||
|
startReporting()
|
||||||
|
}
|
||||||
|
reporter?.reportNow()
|
||||||
|
}
|
||||||
|
else -> startReporting()
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startReporting() {
|
||||||
|
val cfg = AprsStore.loadConfig(this)
|
||||||
|
if (!cfg.enabled || cfg.callsign.isBlank()) {
|
||||||
|
runCatching {
|
||||||
|
Toast.makeText(this, getString(R.string.aprs_toast_not_configured), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
stopSelf()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startForegroundWithNotification(cfg)
|
||||||
|
val rep = AprsReporter(
|
||||||
|
configProvider = { AprsStore.loadConfig(this) },
|
||||||
|
positionProvider = { stationPosition() },
|
||||||
|
onState = { lastState = it },
|
||||||
|
onReport = { report ->
|
||||||
|
AprsStore.saveLastReport(this, report.ok, report.detail)
|
||||||
|
updateNotification(cfg)
|
||||||
|
// 上报结果必达:成功 Toast 短显,失败 Toast 长显+原因
|
||||||
|
val msg = if (report.ok) {
|
||||||
|
getString(R.string.aprs_toast_ok)
|
||||||
|
} else {
|
||||||
|
getString(R.string.aprs_toast_fail, report.detail)
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
Toast.makeText(this, msg,
|
||||||
|
if (report.ok) Toast.LENGTH_SHORT else Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
reporter = rep
|
||||||
|
rep.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopReporting() {
|
||||||
|
reporter?.stop()
|
||||||
|
reporter = null
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startForegroundWithNotification(cfg: AprsConfig) {
|
||||||
|
try {
|
||||||
|
val notif = buildNotification(cfg)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIF_ID, notif)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// 厂商 ROM / 旧系统兼容兜底:启动前台失败只停服务,不崩进程
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(cfg: AprsConfig): Notification {
|
||||||
|
val pi = PendingIntent.getActivity(
|
||||||
|
this, 0, packageManager.getLaunchIntentForPackage(packageName),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val stopPi = PendingIntent.getService(
|
||||||
|
this, 1, Intent(this, AprsForegroundService::class.java).setAction(ACTION_STOP),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val stateText = when (lastState) {
|
||||||
|
AprsState.Connected -> getString(R.string.aprs_notif_connected)
|
||||||
|
AprsState.Error -> getString(R.string.aprs_notif_error)
|
||||||
|
else -> getString(R.string.aprs_notif_running)
|
||||||
|
}
|
||||||
|
return Notification.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_radios)
|
||||||
|
.setContentTitle(getString(R.string.aprs_notif_title, cfg.callsign))
|
||||||
|
.setContentText(stateText)
|
||||||
|
.setContentIntent(pi)
|
||||||
|
.setOngoing(true)
|
||||||
|
.addAction(0, getString(R.string.aprs_notif_stop), stopPi)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification(cfg: AprsConfig) {
|
||||||
|
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
nm.notify(NOTIF_ID, buildNotification(cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上报位置:优先用设置里的站位(用户拍板);站位无效时实时 GPS 兜底 */
|
||||||
|
private fun stationPosition(): Pair<Double, Double>? {
|
||||||
|
// ① 站位(设置页设置的位置)
|
||||||
|
val station = runCatching {
|
||||||
|
val container = (application as MainApplication).getMainContainer()
|
||||||
|
container.settingsRepo.stationPosition.value
|
||||||
|
}.getOrNull()
|
||||||
|
if (station != null && (station.latitude != 0.0 || station.longitude != 0.0)) {
|
||||||
|
return Pair(station.latitude, station.longitude)
|
||||||
|
}
|
||||||
|
// ② 兜底:实时 GPS 最后位置
|
||||||
|
return runCatching {
|
||||||
|
val lm = getSystemService(Context.LOCATION_SERVICE) as android.location.LocationManager
|
||||||
|
val providers = listOf(
|
||||||
|
android.location.LocationManager.GPS_PROVIDER,
|
||||||
|
android.location.LocationManager.NETWORK_PROVIDER
|
||||||
|
)
|
||||||
|
for (p in providers) {
|
||||||
|
val loc = lm.getLastKnownLocation(p) ?: continue
|
||||||
|
if (loc.latitude != 0.0 || loc.longitude != 0.0) {
|
||||||
|
return Pair(loc.latitude, loc.longitude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createChannel() {
|
||||||
|
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID, getString(R.string.aprs_notif_channel),
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
)
|
||||||
|
nm.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
reporter?.stop()
|
||||||
|
reporter = null
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,10 +30,29 @@ class MainApplication : Application(), IContainerProvider {
|
|||||||
|
|
||||||
private lateinit var container: IMainContainer
|
private lateinit var container: IMainContainer
|
||||||
|
|
||||||
|
/** 全局崩溃捕获:堆栈写入 files/crash_log.txt,重启后可查看(用户要求错误报告) */
|
||||||
|
private fun installCrashHandler() {
|
||||||
|
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
|
runCatching {
|
||||||
|
val log = StringBuilder()
|
||||||
|
log.append("=== Crash ${System.currentTimeMillis()} ===\n")
|
||||||
|
log.append("Thread: ").append(thread.name).append("\n")
|
||||||
|
val sw = java.io.StringWriter()
|
||||||
|
throwable.printStackTrace(java.io.PrintWriter(sw))
|
||||||
|
log.append(sw.toString()).append("\n")
|
||||||
|
val file = java.io.File(filesDir, "crash_log.txt")
|
||||||
|
file.appendText(log.toString())
|
||||||
|
}
|
||||||
|
defaultHandler?.uncaughtException(thread, throwable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun getMainContainer(): IMainContainer = container
|
override fun getMainContainer(): IMainContainer = container
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
installCrashHandler()
|
||||||
container = MainContainer(this)
|
container = MainContainer(this)
|
||||||
// trigger automatic update every 48 hours
|
// trigger automatic update every 48 hours
|
||||||
container.appScope.launch { checkAutoUpdate() }
|
container.appScope.launch { checkAutoUpdate() }
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
|||||||
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
||||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||||
|
import com.rtbishop.look4sat.feature.radar.WavelogLogScreen
|
||||||
|
import com.rtbishop.look4sat.feature.status.SatStatusScreen
|
||||||
import com.rtbishop.look4sat.feature.roaming.RoamingScreen
|
import com.rtbishop.look4sat.feature.roaming.RoamingScreen
|
||||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||||
@@ -147,7 +149,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||||
// UI 设置: 按 screenOrder 排序(空 = 默认顺序), 再按 hiddenScreens 过滤(设置页固定保留)
|
// UI 设置: 按 screenOrder 排序(空 = 默认顺序), 再按 hiddenScreens 过滤(设置页固定保留)
|
||||||
val allNavItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.CwDecode, Screen.Map, Screen.Settings)
|
val allNavItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.CwDecode, Screen.WavelogLog, Screen.AmSat, Screen.Map, Screen.Settings)
|
||||||
.sortedBy { screen ->
|
.sortedBy { screen ->
|
||||||
// 未知(新页面如 CwDecode 不在旧持久化顺序里): 用默认顺序位置(漫游↔地图), 再兜底最后
|
// 未知(新页面如 CwDecode 不在旧持久化顺序里): 用默认顺序位置(漫游↔地图), 再兜底最后
|
||||||
val idx = otherSettings.screenOrder.indexOf(screen.screenId)
|
val idx = otherSettings.screenOrder.indexOf(screen.screenId)
|
||||||
@@ -158,7 +160,10 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
}
|
}
|
||||||
.filter { it.screenId !in otherSettings.hiddenScreens || it is Screen.Settings }
|
.filter { it.screenId !in otherSettings.hiddenScreens || it is Screen.Settings }
|
||||||
// 4.5.1 折叠菜单: 主菜单(底部栏 5 槽) + 更多菜单(溢出页面)
|
// 4.5.1 折叠菜单: 主菜单(底部栏 5 槽) + 更多菜单(溢出页面)
|
||||||
val subOrder = otherSettings.subMenuOrder.ifEmpty { com.rtbishop.look4sat.core.presentation.defaultSubMenuOrder }
|
// 老用户迁移: 已持久化的 subMenuOrder 不含新页面 WavelogLog → 追加到子菜单尾部
|
||||||
|
val subOrder = (otherSettings.subMenuOrder.ifEmpty { com.rtbishop.look4sat.core.presentation.defaultSubMenuOrder })
|
||||||
|
.let { list -> if ("WavelogLog" in list) list else list + "WavelogLog" }
|
||||||
|
.let { list -> if ("AMSAT" in list) list else list + "AMSAT" }
|
||||||
val mainNavItems = remember(allNavItems, subOrder) {
|
val mainNavItems = remember(allNavItems, subOrder) {
|
||||||
allNavItems.filter { it.screenId !in subOrder }.take(5)
|
allNavItems.filter { it.screenId !in subOrder }.take(5)
|
||||||
}
|
}
|
||||||
@@ -189,6 +194,8 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
is Screen.Radar -> screen is Screen.Radar
|
is Screen.Radar -> screen is Screen.Radar
|
||||||
is Screen.Mutual -> screen is Screen.Mutual
|
is Screen.Mutual -> screen is Screen.Mutual
|
||||||
is Screen.CwDecode -> screen is Screen.CwDecode
|
is Screen.CwDecode -> screen is Screen.CwDecode
|
||||||
|
is Screen.WavelogLog -> screen is Screen.WavelogLog
|
||||||
|
is Screen.AmSat -> screen is Screen.AmSat
|
||||||
is Screen.Map -> screen is Screen.Map
|
is Screen.Map -> screen is Screen.Map
|
||||||
is Screen.Settings -> screen is Screen.Settings
|
is Screen.Settings -> screen is Screen.Settings
|
||||||
else -> false
|
else -> false
|
||||||
@@ -279,6 +286,12 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
entry<Screen.CwDecode> {
|
entry<Screen.CwDecode> {
|
||||||
CwDecodeScreen()
|
CwDecodeScreen()
|
||||||
}
|
}
|
||||||
|
entry<Screen.AmSat> {
|
||||||
|
SatStatusScreen(container = container)
|
||||||
|
}
|
||||||
|
entry<Screen.WavelogLog> {
|
||||||
|
WavelogLogScreen(queue = container.wavelogQueue)
|
||||||
|
}
|
||||||
entry<Screen.Settings> {
|
entry<Screen.Settings> {
|
||||||
SettingsDestination()
|
SettingsDestination()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ fun MoreMenuPopup(
|
|||||||
is Screen.Radar -> screen is Screen.Radar
|
is Screen.Radar -> screen is Screen.Radar
|
||||||
is Screen.Mutual -> screen is Screen.Mutual
|
is Screen.Mutual -> screen is Screen.Mutual
|
||||||
is Screen.CwDecode -> screen is Screen.CwDecode
|
is Screen.CwDecode -> screen is Screen.CwDecode
|
||||||
|
is Screen.WavelogLog -> screen is Screen.WavelogLog
|
||||||
is Screen.Map -> screen is Screen.Map
|
is Screen.Map -> screen is Screen.Map
|
||||||
is Screen.Settings -> screen is Screen.Settings
|
is Screen.Settings -> screen is Screen.Settings
|
||||||
else -> false
|
else -> false
|
||||||
|
|||||||
+1
@@ -39,6 +39,7 @@ internal class ApplicationPlugin : Plugin<Project> {
|
|||||||
implementation(project(":feature:roaming"))
|
implementation(project(":feature:roaming"))
|
||||||
implementation(project(":feature:satellites"))
|
implementation(project(":feature:satellites"))
|
||||||
implementation(project(":feature:settings"))
|
implementation(project(":feature:settings"))
|
||||||
|
implementation(project(":feature:status"))
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
implementation(libs.compose.material3.adaptive)
|
implementation(libs.compose.material3.adaptive)
|
||||||
implementation(libs.compose.navigation3)
|
implementation(libs.compose.navigation3)
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.aprs
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.OutputStreamWriter
|
||||||
|
import java.io.PrintWriter
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APRS-IS TCP 客户端(从 APRSdroid TcpUploader.scala 逆向移植)。
|
||||||
|
* 纯文本协议:登录一行 + 每行一个包;断线 30 秒重连。
|
||||||
|
*/
|
||||||
|
class AprsIsClient(
|
||||||
|
private val host: String,
|
||||||
|
private val port: Int,
|
||||||
|
private val callsign: String,
|
||||||
|
private val ssid: String,
|
||||||
|
private val passcode: Int,
|
||||||
|
private val version: String,
|
||||||
|
private val filter: String = "",
|
||||||
|
private val timeoutSec: Int = 120
|
||||||
|
) {
|
||||||
|
private var socket: Socket? = null
|
||||||
|
private var writer: PrintWriter? = null
|
||||||
|
private var reader: BufferedReader? = null
|
||||||
|
private val lock = Any()
|
||||||
|
|
||||||
|
val isConnected: Boolean
|
||||||
|
get() = synchronized(lock) { socket?.isConnected == true && !socket!!.isClosed }
|
||||||
|
|
||||||
|
/** 建立连接 + 登录(同步阻塞,调用方放后台线程) */
|
||||||
|
@Throws(Exception::class)
|
||||||
|
fun connect() {
|
||||||
|
disconnect()
|
||||||
|
val s = Socket()
|
||||||
|
s.connect(InetSocketAddress(host, port), 30_000)
|
||||||
|
s.soTimeout = timeoutSec * 1000
|
||||||
|
s.tcpNoDelay = true
|
||||||
|
synchronized(lock) {
|
||||||
|
socket = s
|
||||||
|
writer = PrintWriter(OutputStreamWriter(s.getOutputStream(), Charsets.ISO_8859_1), true)
|
||||||
|
reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.ISO_8859_1), 256)
|
||||||
|
}
|
||||||
|
// 登录行
|
||||||
|
val login = AprsPacket.formatLogin(callsign, ssid, passcode, version) + filter
|
||||||
|
writer?.println(login)
|
||||||
|
// 读登录响应(aprsc 返回 # logresp ... verified/unverified)
|
||||||
|
runCatching {
|
||||||
|
s.soTimeout = 8000
|
||||||
|
val resp = reader?.readLine()
|
||||||
|
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
|
||||||
|
resp.contains("unverified", ignoreCase = true))) {
|
||||||
|
throw IllegalArgumentException(resp.trim())
|
||||||
|
}
|
||||||
|
// 恢复超时
|
||||||
|
s.soTimeout = timeoutSec * 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送一个 APRS 包(一行)并尝试读服务器确认。
|
||||||
|
* 返回 null=发送失败;Pair(ok, detail)=发送结果(服务器错误原文在 detail)
|
||||||
|
*/
|
||||||
|
fun sendPacket(packetLine: String): Pair<Boolean, String>? {
|
||||||
|
synchronized(lock) {
|
||||||
|
val w = writer ?: return null
|
||||||
|
w.println(packetLine)
|
||||||
|
if (w.checkError()) return Pair(false, "write failed")
|
||||||
|
}
|
||||||
|
// 尝试读服务器响应(短超时 3s;APRS-IS 对格式错误会回错误行)
|
||||||
|
return runCatching {
|
||||||
|
val s = socket ?: return@runCatching Pair(true, "OK")
|
||||||
|
val oldTimeout = s.soTimeout
|
||||||
|
s.soTimeout = 3000
|
||||||
|
try {
|
||||||
|
val resp = reader?.readLine()
|
||||||
|
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
|
||||||
|
resp.contains("error", ignoreCase = true))) {
|
||||||
|
Pair(false, resp.trim())
|
||||||
|
} else {
|
||||||
|
Pair(true, if (resp.isNullOrBlank()) "OK" else resp.trim())
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
s.soTimeout = oldTimeout
|
||||||
|
}
|
||||||
|
}.getOrElse { Pair(true, "OK") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读一行(服务器响应,超时抛异常) */
|
||||||
|
fun readLine(): String? {
|
||||||
|
return reader?.readLine()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
synchronized(lock) {
|
||||||
|
runCatching { writer?.close() }
|
||||||
|
runCatching { reader?.close() }
|
||||||
|
runCatching { socket?.close() }
|
||||||
|
writer = null
|
||||||
|
reader = null
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.aprs
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
|
||||||
|
import com.rtbishop.look4sat.core.domain.aprs.AprsPosition
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/** APRS 连接状态 */
|
||||||
|
enum class AprsState { Idle, Connecting, Connected, Disconnected, Error }
|
||||||
|
|
||||||
|
/** APRS 配置(SharedPreferences 持久化,填入即保存) */
|
||||||
|
data class AprsConfig(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val server: String = "euro.aprs2.net",
|
||||||
|
val port: Int = 14580,
|
||||||
|
val callsign: String = "",
|
||||||
|
val ssid: String = "",
|
||||||
|
val passcode: String = "",
|
||||||
|
val intervalMin: Int = 5,
|
||||||
|
val statusText: String = "Look4Sat APRS",
|
||||||
|
val symbolTable: String = "/",
|
||||||
|
val symbolCode: String = ">",
|
||||||
|
val includeCourseSpeed: Boolean = true,
|
||||||
|
val includeAltitude: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
/** APRS 上报结果 */
|
||||||
|
data class AprsReport(
|
||||||
|
val timestamp: Long,
|
||||||
|
val packet: String,
|
||||||
|
val ok: Boolean,
|
||||||
|
val detail: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 上报调度器(周期上报 + 手动触发),连接管理放在前台服务 */
|
||||||
|
class AprsReporter(
|
||||||
|
private val configProvider: () -> AprsConfig,
|
||||||
|
private val positionProvider: () -> Pair<Double, Double>? = { null },
|
||||||
|
private val onState: (AprsState) -> Unit = {},
|
||||||
|
private val onReport: (AprsReport) -> Unit = {}
|
||||||
|
) {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var client: AprsIsClient? = null
|
||||||
|
private var job: Job? = null
|
||||||
|
private var manualJob: Job? = null
|
||||||
|
|
||||||
|
val isRunning: Boolean get() = job?.isActive == true
|
||||||
|
|
||||||
|
/** 启动周期上报(前台服务调用) */
|
||||||
|
fun start() {
|
||||||
|
stop()
|
||||||
|
val cfg = configProvider()
|
||||||
|
if (!cfg.enabled || cfg.callsign.isBlank()) {
|
||||||
|
onState(AprsState.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
job = scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
reportOnce()
|
||||||
|
delay(cfg.intervalMin.coerceAtLeast(1) * 60_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
job?.cancel()
|
||||||
|
job = null
|
||||||
|
runCatching { client?.disconnect() }
|
||||||
|
client = null
|
||||||
|
onState(AprsState.Idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动触发一次(立即上报,不等待周期) */
|
||||||
|
fun reportNow() {
|
||||||
|
manualJob?.cancel()
|
||||||
|
manualJob = scope.launch { reportOnce() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun reportOnce() {
|
||||||
|
val cfg = configProvider()
|
||||||
|
if (!cfg.enabled || cfg.callsign.isBlank()) return
|
||||||
|
onState(AprsState.Connecting)
|
||||||
|
try {
|
||||||
|
val c = client ?: AprsIsClient(
|
||||||
|
host = cfg.server,
|
||||||
|
port = cfg.port,
|
||||||
|
callsign = cfg.callsign,
|
||||||
|
ssid = cfg.ssid,
|
||||||
|
passcode = cfg.passcode.toIntOrNull()?.takeIf { it >= 0 } ?: AprsPacket.passcode(cfg.callsign),
|
||||||
|
version = "Look4Sat 4.5.4"
|
||||||
|
).also { client = it }
|
||||||
|
if (!c.isConnected) c.connect()
|
||||||
|
onState(AprsState.Connected)
|
||||||
|
|
||||||
|
val pos = positionProvider()
|
||||||
|
val packetLine = buildPositionPacket(cfg, pos?.first, pos?.second)
|
||||||
|
val result = c.sendPacket(packetLine)
|
||||||
|
val ok = result?.first == true
|
||||||
|
val detail = result?.second ?: "no connection"
|
||||||
|
onReport(AprsReport(System.currentTimeMillis(), packetLine, ok, detail))
|
||||||
|
if (ok) onState(AprsState.Connected) else onState(AprsState.Error)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
runCatching { client?.disconnect() }
|
||||||
|
client = null
|
||||||
|
onState(AprsState.Error)
|
||||||
|
onReport(AprsReport(System.currentTimeMillis(), "", false, e.message ?: "error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造位置包:BG7NTA-5>APRS:=DDMM.MMN/DDDMM.MME<状态文本 */
|
||||||
|
private fun buildPositionPacket(cfg: AprsConfig, lat: Double? = null, lon: Double? = null): String {
|
||||||
|
val source = AprsPacket.formatCallSsid(cfg.callsign, cfg.ssid)
|
||||||
|
val pos = AprsPosition(
|
||||||
|
latitude = lat ?: 0.0,
|
||||||
|
longitude = lon ?: 0.0,
|
||||||
|
symbolTable = cfg.symbolTable.firstOrNull() ?: '/',
|
||||||
|
symbolCode = cfg.symbolCode.firstOrNull() ?: '>'
|
||||||
|
)
|
||||||
|
return "$source>APRS:=${pos.toUncompressedString()}${cfg.statusText}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.aprs
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APRS 配置存取 + 服务 action 常量(被 feature/settings 和 app 服务共用,
|
||||||
|
* 避免 feature 反向依赖 app)。
|
||||||
|
*/
|
||||||
|
object AprsStore {
|
||||||
|
|
||||||
|
const val ACTION_START = "com.rtbishop.look4sat.aprs.START"
|
||||||
|
const val ACTION_STOP = "com.rtbishop.look4sat.aprs.STOP"
|
||||||
|
const val ACTION_REPORT_NOW = "com.rtbishop.look4sat.aprs.REPORT_NOW"
|
||||||
|
const val SERVICE_CLASS = "com.rtbishop.look4sat.app.AprsForegroundService"
|
||||||
|
|
||||||
|
private const val PREFS = "aprs_config"
|
||||||
|
private const val KEY_ENABLED = "enabled"
|
||||||
|
private const val KEY_SERVER = "server"
|
||||||
|
private const val KEY_PORT = "port"
|
||||||
|
private const val KEY_CALLSIGN = "callsign"
|
||||||
|
private const val KEY_SSID = "ssid"
|
||||||
|
private const val KEY_PASSCODE = "passcode"
|
||||||
|
private const val KEY_INTERVAL = "interval"
|
||||||
|
private const val KEY_STATUS = "status"
|
||||||
|
private const val KEY_SYMBOL_TABLE = "symbol_table"
|
||||||
|
private const val KEY_SYMBOL_CODE = "symbol_code"
|
||||||
|
private const val KEY_LAST_TIME = "last_report_time"
|
||||||
|
private const val KEY_LAST_OK = "last_report_ok"
|
||||||
|
private const val KEY_LAST_DETAIL = "last_report_detail"
|
||||||
|
|
||||||
|
/** 读取配置(填入即保存,无需每次打开重填) */
|
||||||
|
fun loadConfig(context: Context): AprsConfig {
|
||||||
|
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
return AprsConfig(
|
||||||
|
enabled = p.getBoolean(KEY_ENABLED, false),
|
||||||
|
server = p.getString(KEY_SERVER, "euro.aprs2.net") ?: "euro.aprs2.net",
|
||||||
|
port = p.getInt(KEY_PORT, 14580),
|
||||||
|
callsign = p.getString(KEY_CALLSIGN, "") ?: "",
|
||||||
|
ssid = p.getString(KEY_SSID, "") ?: "",
|
||||||
|
passcode = p.getString(KEY_PASSCODE, "") ?: "",
|
||||||
|
intervalMin = p.getInt(KEY_INTERVAL, 5),
|
||||||
|
statusText = p.getString(KEY_STATUS, "Look4Sat APRS") ?: "Look4Sat APRS",
|
||||||
|
symbolTable = p.getString(KEY_SYMBOL_TABLE, "/") ?: "/",
|
||||||
|
symbolCode = p.getString(KEY_SYMBOL_CODE, ">") ?: ">"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上次上报结果(供设置卡片显示) */
|
||||||
|
data class LastReport(val time: Long = 0L, val ok: Boolean = false, val detail: String = "")
|
||||||
|
|
||||||
|
fun loadLastReport(context: Context): LastReport {
|
||||||
|
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
return LastReport(
|
||||||
|
time = p.getLong(KEY_LAST_TIME, 0L),
|
||||||
|
ok = p.getBoolean(KEY_LAST_OK, false),
|
||||||
|
detail = p.getString(KEY_LAST_DETAIL, "") ?: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveLastReport(context: Context, ok: Boolean, detail: String) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||||
|
.putLong(KEY_LAST_TIME, System.currentTimeMillis())
|
||||||
|
.putBoolean(KEY_LAST_OK, ok)
|
||||||
|
.putString(KEY_LAST_DETAIL, detail)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存配置 */
|
||||||
|
fun saveConfig(context: Context, cfg: AprsConfig) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||||
|
.putBoolean(KEY_ENABLED, cfg.enabled)
|
||||||
|
.putString(KEY_SERVER, cfg.server)
|
||||||
|
.putInt(KEY_PORT, cfg.port)
|
||||||
|
.putString(KEY_CALLSIGN, cfg.callsign)
|
||||||
|
.putString(KEY_SSID, cfg.ssid)
|
||||||
|
.putString(KEY_PASSCODE, cfg.passcode)
|
||||||
|
.putInt(KEY_INTERVAL, cfg.intervalMin)
|
||||||
|
.putString(KEY_STATUS, cfg.statusText)
|
||||||
|
.putString(KEY_SYMBOL_TABLE, cfg.symbolTable)
|
||||||
|
.putString(KEY_SYMBOL_CODE, cfg.symbolCode)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
|||||||
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
||||||
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
||||||
|
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
|
||||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
||||||
@@ -40,6 +41,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
|
||||||
@@ -70,12 +74,14 @@ import okhttp3.OkHttpClient
|
|||||||
class MainContainer(private val context: Context) : IMainContainer {
|
class MainContainer(private val context: Context) : IMainContainer {
|
||||||
|
|
||||||
private val localSource = provideLocalSource()
|
private val localSource = provideLocalSource()
|
||||||
|
private val remoteSource by lazy { provideRemoteSource() }
|
||||||
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
|
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
|
||||||
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
|
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
|
||||||
override val settingsRepo = provideSettingsRepo()
|
override val settingsRepo = provideSettingsRepo()
|
||||||
override val selectionRepo = provideSelectionRepo()
|
override val selectionRepo = provideSelectionRepo()
|
||||||
override val satelliteRepo = provideSatelliteRepo()
|
override val satelliteRepo = provideSatelliteRepo()
|
||||||
override val databaseRepo = provideDatabaseRepo()
|
override val databaseRepo = provideDatabaseRepo()
|
||||||
|
override val amSatRepo = AmSatRepository(remoteSource)
|
||||||
override val radioTrackingService: IRadioTrackingService by lazy {
|
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||||
@@ -96,6 +102,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
|
||||||
@@ -149,7 +165,6 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
private fun provideDatabaseRepo(): IDatabaseRepo {
|
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||||
val dbDispatcher = Dispatchers.Default
|
val dbDispatcher = Dispatchers.Default
|
||||||
val dataParser = DataParser(dbDispatcher)
|
val dataParser = DataParser(dbDispatcher)
|
||||||
val remoteSource = provideRemoteSource()
|
|
||||||
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +175,14 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun provideRemoteSource(): IRemoteSource {
|
private fun provideRemoteSource(): IRemoteSource {
|
||||||
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
|
return RemoteSource(
|
||||||
|
Dispatchers.IO, context.contentResolver,
|
||||||
|
OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
.readTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideSatelliteRepo(): ISatelliteRepo {
|
private fun provideSatelliteRepo(): ISatelliteRepo {
|
||||||
@@ -176,6 +198,6 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
val appPrefsFileName = "${context.packageName}_preferences"
|
val appPrefsFileName = "${context.packageName}_preferences"
|
||||||
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
||||||
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
||||||
return SettingsRepo(manager, appPreferences, appVersionName)
|
return SettingsRepo(context, manager, appPreferences, appVersionName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.data.source.AmSatParser
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
|
||||||
|
/** AMSAT 状态仓库: 抓 HTML → 解析 → SatStatusPage */
|
||||||
|
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
|
||||||
|
|
||||||
|
override suspend fun fetchStatus(): SatStatusPage? {
|
||||||
|
val html = remoteSource.getStatusHtml() ?: return null
|
||||||
|
if (html.isBlank()) return null
|
||||||
|
return try {
|
||||||
|
AmSatParser.parse(html, System.currentTimeMillis())
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
println("AmSatRepository parse exception: $exception")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,14 +81,21 @@ class DatabaseRepo(
|
|||||||
// launch all network requests concurrently
|
// launch all network requests concurrently
|
||||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||||
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||||
|
// 统计成功源数: 0 成功视为更新失败(不刷时间戳, 抛异常让 UI 提示)
|
||||||
|
val tleResults = tleJobs.awaitAll()
|
||||||
|
val radioResults = radioJobs.awaitAll()
|
||||||
|
val successCount = tleResults.count { it.second != null } + radioResults.count { it.second != null }
|
||||||
|
if (successCount == 0) {
|
||||||
|
throw java.io.IOException("All data sources failed to download")
|
||||||
|
}
|
||||||
// parse fetched data concurrently and associate with types
|
// parse fetched data concurrently and associate with types
|
||||||
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
val importedEntries = tleResults.flatMap { (url, stream) ->
|
||||||
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
||||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
||||||
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
val importedRadios = radioResults.flatMap { (url, stream) ->
|
||||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
||||||
}
|
}
|
||||||
// insert parsed data into the database
|
// insert parsed data into the database
|
||||||
|
|||||||
+51
-17
@@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
class SettingsRepo(
|
class SettingsRepo(
|
||||||
|
private val context: android.content.Context,
|
||||||
private val locationManager: LocationManager,
|
private val locationManager: LocationManager,
|
||||||
private val preferences: SharedPreferences,
|
private val preferences: SharedPreferences,
|
||||||
override val appVersionName: String
|
override val appVersionName: String
|
||||||
@@ -94,6 +95,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
|
||||||
@@ -181,23 +187,41 @@ class SettingsRepo(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setStationPosition(): Boolean {
|
/** GPS 定位: 一次性 getCurrentLocation(GPS 优先, 15 秒超时), 拿到位置才返回 true */
|
||||||
if (!LocationManagerCompat.isLocationEnabled(locationManager)) return false
|
override suspend fun setStationPosition(): Boolean {
|
||||||
try {
|
// 权限前置: 无定位权限直接失败(不吞异常)
|
||||||
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
|
if (androidx.core.content.ContextCompat.checkSelfPermission(
|
||||||
val hasNet = LocationManagerCompat.hasProvider(locationManager, providerNet)
|
context, android.Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
val provider = if (hasGps) providerGps else if (hasNet) providerNet else providerDef
|
) != android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
val location = locationManager.getLastKnownLocation(providerDef)
|
) {
|
||||||
if (location == null || System.currentTimeMillis() - location.time > 600_000L) {
|
println("GPS: no fine location permission")
|
||||||
println("Requesting location for $provider provider")
|
return false
|
||||||
locationManager.requestLocationUpdates(provider, 0L, 0f, this)
|
}
|
||||||
} else {
|
if (!LocationManagerCompat.isLocationEnabled(locationManager)) return false
|
||||||
setStationPosition(location.latitude, location.longitude, location.altitude)
|
return kotlinx.coroutines.suspendCancellableCoroutine { cont ->
|
||||||
}
|
val signal = android.os.CancellationSignal()
|
||||||
} catch (exception: SecurityException) {
|
val handler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||||
println("No permissions were given - $exception")
|
val executor = java.util.concurrent.Executor { handler.post(it) }
|
||||||
|
// 15 秒超时
|
||||||
|
val timeout = handler.postDelayed({
|
||||||
|
signal.cancel()
|
||||||
|
if (cont.isActive) cont.resume(false) { }
|
||||||
|
}, 15_000L)
|
||||||
|
val listener = androidx.core.util.Consumer<Location> { location ->
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
setStationPosition(location.latitude, location.longitude, location.altitude)
|
||||||
|
if (cont.isActive) cont.resume(true) { }
|
||||||
|
}
|
||||||
|
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
|
||||||
|
val provider = if (hasGps) providerGps else providerNet
|
||||||
|
try {
|
||||||
|
LocationManagerCompat.getCurrentLocation(locationManager, provider, signal, executor, listener)
|
||||||
|
} catch (exception: SecurityException) {
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
if (cont.isActive) cont.resume(false) { }
|
||||||
|
}
|
||||||
|
cont.invokeOnCancellation { signal.cancel(); handler.removeCallbacksAndMessages(null) }
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setStationPosition(locator: String): Boolean {
|
override fun setStationPosition(locator: String): Boolean {
|
||||||
@@ -361,6 +385,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 +407,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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.source
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AMSAT 卫星状态页解析器(https://amsat.org/status/)
|
||||||
|
*
|
||||||
|
* 页面结构(静态 HTML, 2026-08 实测):
|
||||||
|
* - 状态表: <table> 内 48 行, 表头 = Name + 6 天(每个 colspan=12)
|
||||||
|
* 数据行 73 格: [0]=卫星名, [1..72] = 6 天 × 12 个 2 小时槽(新→旧)
|
||||||
|
* 每格 <td width=9 bgcolor="颜色">数字</td>, 有报告时数字带
|
||||||
|
* docTips.show('id') 链接
|
||||||
|
* - 报告详情在页面内嵌 JS:
|
||||||
|
* tips.a885153 = new Array(5,5,120,'状态<br>呼号<br>网格<br>日期<br>时间段 UTC')
|
||||||
|
*
|
||||||
|
* 状态色: #648fff=活跃, #ffb000=仅遥测, #dc267f=未听到, #fe6100=矛盾
|
||||||
|
*/
|
||||||
|
object AmSatParser {
|
||||||
|
|
||||||
|
private val STATUS_COLORS = mapOf(
|
||||||
|
"#648fff" to 0xFF648FFF.toLong(),
|
||||||
|
"#ffb000" to 0xFFFFB000.toLong(),
|
||||||
|
"#dc267f" to 0xFFDC267F.toLong(),
|
||||||
|
"#fe6100" to 0xFFFE6100.toLong()
|
||||||
|
)
|
||||||
|
private val GRAY = 0xFFC0C0C0.toLong()
|
||||||
|
|
||||||
|
private val rowRe = Pattern.compile("<tr>(.*?)</tr>", Pattern.DOTALL)
|
||||||
|
private val cellRe = Pattern.compile("(<t[dh][^>]*>.*?</t[dh]>)", Pattern.DOTALL)
|
||||||
|
private val bgRe = Pattern.compile("bgcolor=\"?(#[0-9a-fA-F]{6}|C0C0C0)\"?")
|
||||||
|
private val linkRe = Pattern.compile("docTips\\.show\\('(a\\d+)'\\)")
|
||||||
|
private val tipRe = Pattern.compile(
|
||||||
|
"tips\\.(a\\d+)\\s*=\\s*new\\s+Array\\(\\s*\\d+,\\s*\\d+,\\s*\\d+,\\s*'([^']*)'"
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 解析完整页面 */
|
||||||
|
fun parse(html: String, fetchedAtUtcMs: Long): SatStatusPage {
|
||||||
|
val reports = parseReports(html)
|
||||||
|
val statuses = parseStatusTable(html, reports)
|
||||||
|
return SatStatusPage(fetchedAtUtcMs, statuses, reports)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取全部报告(tips.* JS 数组) */
|
||||||
|
fun parseReports(html: String): Map<String, SatReport> {
|
||||||
|
val map = mutableMapOf<String, SatReport>()
|
||||||
|
val m = tipRe.matcher(html)
|
||||||
|
while (m.find()) {
|
||||||
|
val id = m.group(1)
|
||||||
|
val parts = m.group(2).split("<br>")
|
||||||
|
map[id] = SatReport(
|
||||||
|
id = id,
|
||||||
|
statusText = parts.getOrElse(0) { "" }.trim(),
|
||||||
|
call = parts.getOrElse(1) { "" }.trim(),
|
||||||
|
grid = parts.getOrElse(2) { "" }.trim(),
|
||||||
|
dateUtc = parts.getOrElse(3) { "" }.trim(),
|
||||||
|
timeUtc = parts.getOrElse(4) { "" }.trim()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析状态表(48 行卫星) */
|
||||||
|
fun parseStatusTable(html: String, reports: Map<String, SatReport>): List<SatStatus> {
|
||||||
|
val result = mutableListOf<SatStatus>()
|
||||||
|
val tables = extractTables(html)
|
||||||
|
for (table in tables) {
|
||||||
|
val rows = rowRe.matcher(table)
|
||||||
|
val parsed = mutableListOf<SatStatus>()
|
||||||
|
var rowIndex = 0
|
||||||
|
var dayHeaders: List<String> = emptyList()
|
||||||
|
while (rows.find()) {
|
||||||
|
val rowHtml = rows.group(1)
|
||||||
|
val cells = cellRe.matcher(rowHtml)
|
||||||
|
val cellList = mutableListOf<String>()
|
||||||
|
while (cells.find()) cellList.add(cells.group(1))
|
||||||
|
if (rowIndex == 0) {
|
||||||
|
// 表头: Name + 6 天(每个 colspan=12)
|
||||||
|
dayHeaders = cellList.drop(1).take(6).map { stripHtml(it) }
|
||||||
|
rowIndex++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (cellList.size < 7) { rowIndex++; continue }
|
||||||
|
val name = stripHtml(cellList[0])
|
||||||
|
if (name.isBlank()) { rowIndex++; continue }
|
||||||
|
val days = mutableListOf<SatDay>()
|
||||||
|
for (d in 0 until 6) {
|
||||||
|
val start = 1 + d * 12
|
||||||
|
val end = start + 12
|
||||||
|
val slots = (start until end).mapNotNull { i ->
|
||||||
|
cellList.getOrNull(i)?.let { cell ->
|
||||||
|
val bg = bgRe.matcher(cell)
|
||||||
|
val color = if (bg.find()) {
|
||||||
|
STATUS_COLORS[bg.group(1).lowercase()] ?: GRAY
|
||||||
|
} else GRAY
|
||||||
|
val link = linkRe.matcher(cell)
|
||||||
|
val ids = mutableListOf<String>()
|
||||||
|
while (link.find()) ids.add(link.group(1))
|
||||||
|
val hasReport = ids.isNotEmpty()
|
||||||
|
val count = if (hasReport) {
|
||||||
|
stripHtml(cell).trim().toIntOrNull() ?: ids.size
|
||||||
|
} else 0
|
||||||
|
SatSlot(
|
||||||
|
statusColor = if (hasReport) color else GRAY,
|
||||||
|
count = count,
|
||||||
|
reportIds = ids
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
days.add(SatDay(dayHeaders.getOrElse(d) { "" }, slots))
|
||||||
|
}
|
||||||
|
parsed.add(SatStatus(name, days))
|
||||||
|
rowIndex++
|
||||||
|
}
|
||||||
|
if (parsed.isNotEmpty()) {
|
||||||
|
result.addAll(parsed)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractTables(html: String): List<String> {
|
||||||
|
val result = mutableListOf<String>()
|
||||||
|
val m = Pattern.compile("<table.*?</table>", Pattern.DOTALL).matcher(html)
|
||||||
|
while (m.find()) result.add(m.group())
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stripHtml(s: String): String =
|
||||||
|
s.replace(Regex("<[^>]+>"), "").trim()
|
||||||
|
}
|
||||||
@@ -43,6 +43,22 @@ class RemoteSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getStatusHtml(): String? = withContext(dispatcher) {
|
||||||
|
try {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://amsat.org/status/")
|
||||||
|
.header("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat/4.5")
|
||||||
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@use null
|
||||||
|
response.body?.string()
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
println("RemoteSource amsat status exception: $exception")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
||||||
try {
|
try {
|
||||||
val networkRequest = Request.Builder().url(url).build()
|
val networkRequest = Request.Builder().url(url).build()
|
||||||
|
|||||||
@@ -25,4 +25,8 @@ class ShowToast(private val context: Context) : IShowToast {
|
|||||||
override fun invoke(message: String) {
|
override fun invoke(message: String) {
|
||||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun invoke(resId: Int) {
|
||||||
|
invoke(context.getString(resId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.aprs
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.round
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APRS-IS 协议核心(纯 Kotlin,无 Android 依赖)。
|
||||||
|
* 从 APRSdroid 1.6.3d 逆向移植:AprsPacket$.scala + ab0oo Position.java。
|
||||||
|
*/
|
||||||
|
object AprsPacket {
|
||||||
|
|
||||||
|
/** APRS-IS passcode 算法(标准):0x73E2 初值,呼号大写+\0,每对字符 XOR */
|
||||||
|
fun passcode(callsign: String): Int {
|
||||||
|
val s = callsign.split("-")[0].uppercase() + "\u0000"
|
||||||
|
var hash = 29666 // 0x73E2
|
||||||
|
var i = 0
|
||||||
|
while (i <= s.length - 2) {
|
||||||
|
hash = hash xor (s[i].code * 256 + s[i + 1].code)
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
return hash and 0x7FFF
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录行:user CALL-SSID pass XXXX vers XXXX */
|
||||||
|
fun formatLogin(callsign: String, ssid: String, passcode: Int, version: String): String {
|
||||||
|
val callSsid = formatCallSsid(callsign, ssid)
|
||||||
|
return "user $callSsid pass $passcode vers $version"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 呼号-SSID 拼接(BG7NTA + 5 → BG7NTA-5) */
|
||||||
|
fun formatCallSsid(callsign: String, ssid: String): String {
|
||||||
|
if (ssid.isNullOrEmpty()) return callsign
|
||||||
|
return "$callsign-$ssid"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 可选距离过滤: filter r/lat/lon/dist */
|
||||||
|
fun formatRangeFilter(latitude: Double, longitude: Double, distKm: Int): String {
|
||||||
|
return String.format("r/%.3f/%.3f/%d", latitude, longitude, distKm)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 高度扩展 /A=00000(英尺) */
|
||||||
|
fun formatAltitude(altitudeMeters: Double?): String {
|
||||||
|
if (altitudeMeters == null) return ""
|
||||||
|
return String.format("/A=%06d", (altitudeMeters * 3.2808399).toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 航速航向扩展(节/度) */
|
||||||
|
fun formatCourseSpeed(speedMps: Double?, bearing: Float?): String {
|
||||||
|
if (speedMps == null || bearing == null) return ""
|
||||||
|
val knots = (speedMps * 1.94384449).toInt()
|
||||||
|
return String.format("/%03d/%03d", bearing.toInt(), knots)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APRS 位置编码(ab0oo Position.java 逆向移植)。
|
||||||
|
* 非压缩:DDMM.MMN/DDDMM.MME;压缩:base91。
|
||||||
|
*/
|
||||||
|
class AprsPosition(
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val symbolTable: Char,
|
||||||
|
val symbolCode: Char,
|
||||||
|
val positionAmbiguity: Int = 0
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** 非压缩格式(APRS-IS 默认上报格式) */
|
||||||
|
fun toUncompressedString(): String {
|
||||||
|
val lat = getDMS(latitude, true)
|
||||||
|
val lon = getDMS(longitude, false)
|
||||||
|
return "$lat$symbolTable$lon$symbolCode"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 压缩格式(base91,标准 APRS 算法) */
|
||||||
|
fun toCompressedString(): String {
|
||||||
|
val jRound = round((90.0 - latitude) * 380926.0).toLong()
|
||||||
|
val j = jRound / 753571 + 33
|
||||||
|
val j2 = jRound % 753571
|
||||||
|
val j3 = j2 / 8281 + 33
|
||||||
|
val j4 = j2 % 8281
|
||||||
|
val i = (j4 % 91).toInt() + 33
|
||||||
|
val jRound2 = round((longitude + 180.0) * 190463.0).toLong()
|
||||||
|
val j5 = jRound2 / 753571 + 33
|
||||||
|
val j6 = jRound2 % 753571
|
||||||
|
val j7 = 33 + j6 / 8281
|
||||||
|
val j8 = j6 % 8281
|
||||||
|
return "" + symbolTable + j.toInt().toChar() + j3.toInt().toChar() +
|
||||||
|
((j4 / 91).toInt() + 33).toChar() + i.toChar() +
|
||||||
|
j5.toInt().toChar() + j7.toInt().toChar() +
|
||||||
|
((j8 / 91).toInt() + 33).toChar() +
|
||||||
|
((j8 % 91).toInt() + 33).toChar() + symbolCode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单轴 DMS 编码(百分位) */
|
||||||
|
private fun getDMS(value: Double, isLat: Boolean): String {
|
||||||
|
var iRound = round(value * 6000.0).toInt()
|
||||||
|
if (iRound < 0) iRound = -iRound
|
||||||
|
val degrees = iRound / 6000
|
||||||
|
val minutes = (iRound / 100) % 60
|
||||||
|
val hundredths = iRound % 100
|
||||||
|
val frac = when (positionAmbiguity) {
|
||||||
|
1 -> " . "
|
||||||
|
2 -> String.format("%d . ", minutes / 10)
|
||||||
|
3 -> String.format("%02d. ", minutes)
|
||||||
|
4 -> String.format("%02d.%d ", minutes, hundredths / 10)
|
||||||
|
else -> String.format("%02d.%02d", minutes, hundredths)
|
||||||
|
}
|
||||||
|
return if (isLat) {
|
||||||
|
val ns = if (value >= 0) 'N' else 'S'
|
||||||
|
String.format("%02d%s%c", degrees, frac, ns)
|
||||||
|
} else {
|
||||||
|
val ew = if (value >= 0) 'E' else 'W'
|
||||||
|
String.format("%03d%s%c", degrees, frac, ew)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.model
|
||||||
|
|
||||||
|
/** 单条卫星状态报告(AMSAT 网站 tooltip 数据) */
|
||||||
|
data class SatReport(
|
||||||
|
val id: String, // 报告 ID(a885153)
|
||||||
|
val statusText: String, // Heard / Telemetry Only / Not Heard ...
|
||||||
|
val call: String, // 呼号
|
||||||
|
val grid: String, // 网格坐标(可为空)
|
||||||
|
val dateUtc: String, // 2026-08-04
|
||||||
|
val timeUtc: String // 2:46-:59 UTC
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 单个 2 小时槽的状态 */
|
||||||
|
data class SatSlot(
|
||||||
|
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
|
||||||
|
val count: Int, // 报告数量(0 = 无)
|
||||||
|
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 卫星一天的状态(12 个 2 小时槽) */
|
||||||
|
data class SatDay(
|
||||||
|
val dateLabel: String, // "Aug 4"
|
||||||
|
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 单个卫星 6 天状态 */
|
||||||
|
data class SatStatus(
|
||||||
|
val name: String, // "AO-123_[FM]"
|
||||||
|
val days: List<SatDay> // 6 天(新→旧)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 页面整体解析结果 */
|
||||||
|
data class SatStatusPage(
|
||||||
|
val fetchedAtUtcMs: Long,
|
||||||
|
val statuses: List<SatStatus>,
|
||||||
|
val reports: Map<String, SatReport> // id → 报告
|
||||||
|
)
|
||||||
@@ -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(
|
||||||
|
|||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
|
|
||||||
|
/** AMSAT 卫星状态数据源 */
|
||||||
|
interface IAmSatRepository {
|
||||||
|
/** 抓取并解析 AMSAT 状态页; 失败返回 null */
|
||||||
|
suspend fun fetchStatus(): SatStatusPage?
|
||||||
|
}
|
||||||
+4
@@ -30,6 +30,7 @@ interface IMainContainer {
|
|||||||
val selectionRepo: ISelectionRepo
|
val selectionRepo: ISelectionRepo
|
||||||
val satelliteRepo: ISatelliteRepo
|
val satelliteRepo: ISatelliteRepo
|
||||||
val databaseRepo: IDatabaseRepo
|
val databaseRepo: IDatabaseRepo
|
||||||
|
val amSatRepo: IAmSatRepository
|
||||||
val radioTrackingService: IRadioTrackingService
|
val radioTrackingService: IRadioTrackingService
|
||||||
val mutualPassData: StateFlow<MutualPassData>
|
val mutualPassData: StateFlow<MutualPassData>
|
||||||
fun setMutualPassData(data: MutualPassData)
|
fun setMutualPassData(data: MutualPassData)
|
||||||
@@ -42,6 +43,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(
|
||||||
|
|||||||
+2
-1
@@ -45,7 +45,8 @@ interface ISettingsRepo {
|
|||||||
//region # Station position settings
|
//region # Station position settings
|
||||||
val stationPosition: StateFlow<GeoPos>
|
val stationPosition: StateFlow<GeoPos>
|
||||||
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
|
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
|
||||||
fun setStationPosition(): Boolean
|
/** GPS 定位(挂起): 拿到位置才返回 true; 权限缺失/超时/无信号返回 false */
|
||||||
|
suspend fun setStationPosition(): Boolean
|
||||||
fun setStationPosition(locator: String): Boolean
|
fun setStationPosition(locator: String): Boolean
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
|||||||
@@ -22,4 +22,7 @@ import java.io.InputStream
|
|||||||
interface IRemoteSource {
|
interface IRemoteSource {
|
||||||
suspend fun getFileStream(uri: String): InputStream?
|
suspend fun getFileStream(uri: String): InputStream?
|
||||||
suspend fun getNetworkStream(url: String): InputStream?
|
suspend fun getNetworkStream(url: String): InputStream?
|
||||||
|
|
||||||
|
/** 抓取 AMSAT 状态页 HTML(带 UA, 返回 null = 失败) */
|
||||||
|
suspend fun getStatusHtml(): String?
|
||||||
}
|
}
|
||||||
@@ -19,4 +19,7 @@ package com.rtbishop.look4sat.core.domain.usecase
|
|||||||
|
|
||||||
interface IShowToast {
|
interface IShowToast {
|
||||||
operator fun invoke(message: String)
|
operator fun invoke(message: String)
|
||||||
|
|
||||||
|
/** 按资源 ID 显示(四语文案) */
|
||||||
|
operator fun invoke(resId: Int)
|
||||||
}
|
}
|
||||||
+28
-2
@@ -1,4 +1,4 @@
|
|||||||
/*
|
/*
|
||||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
*
|
*
|
||||||
@@ -11,6 +11,7 @@ package com.rtbishop.look4sat.core.domain.utility
|
|||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
||||||
@@ -44,6 +45,9 @@ object DopplerFrequencyCalculator {
|
|||||||
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
|
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
|
||||||
* with an offset applied to the downlink (in Hz).
|
* with an offset applied to the downlink (in Hz).
|
||||||
* Returns null if the transponder is not a linear passband type.
|
* Returns null if the transponder is not a linear passband type.
|
||||||
|
*
|
||||||
|
* The user-entered downlink frequency already includes the offset, so subtract
|
||||||
|
* it before mapping the downlink passband position back to the uplink.
|
||||||
*/
|
*/
|
||||||
fun computeUplinkFromDownlinkWithOffset(
|
fun computeUplinkFromDownlinkWithOffset(
|
||||||
downlinkHz: Long,
|
downlinkHz: Long,
|
||||||
@@ -52,7 +56,7 @@ object DopplerFrequencyCalculator {
|
|||||||
offsetHz: Long
|
offsetHz: Long
|
||||||
): Long? {
|
): Long? {
|
||||||
if (!isLinearTransponder(transponder)) return null
|
if (!isLinearTransponder(transponder)) return null
|
||||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz + offsetHz, transponder) ?: return null
|
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
|
||||||
return orbitalPos.getUplinkFreq(baseUplink)
|
return orbitalPos.getUplinkFreq(baseUplink)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,4 +99,26 @@ object DopplerFrequencyCalculator {
|
|||||||
return upLow != null && upHigh != null && downLow != null && downHigh != null
|
return upLow != null && upHigh != null && downLow != null && downHigh != null
|
||||||
&& upLow != upHigh && downLow != downHigh
|
&& upLow != upHigh && downLow != downHigh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for the radio entry that should drive the standalone Calculator page.
|
||||||
|
*
|
||||||
|
* A frequency range alone is not enough: some non-user-facing or drifting data entries
|
||||||
|
* can also have low/high frequencies. The calculator is meant for the named linear
|
||||||
|
* transponder entry, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
|
||||||
|
*/
|
||||||
|
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
|
||||||
|
if (!isLinearTransponder(transponder)) return false
|
||||||
|
|
||||||
|
val info = transponder.info.lowercase(Locale.ENGLISH)
|
||||||
|
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
||||||
|
.joinToString(separator = " ")
|
||||||
|
.lowercase(Locale.ENGLISH)
|
||||||
|
val hasLinearName = info.contains("linear")
|
||||||
|
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
|
||||||
|
info.contains("xponder") || info.contains("xpdr")
|
||||||
|
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
|
||||||
|
|
||||||
|
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
/*
|
||||||
|
* WaveLogApi.kt — WaveLog 日志服务器 API 客户端(4.5.2 覆盖修复 2)。
|
||||||
|
*
|
||||||
|
* 同时支持 v1 与 v2(用户服务器实测只有 v1, v2 返回 404):
|
||||||
|
* v2: POST {base}/api/v2/qso (Authorization: Bearer + JSON 字段)
|
||||||
|
* v1: POST {base}/index.php/api/qso (key 在 JSON body + ADIF 字符串)
|
||||||
|
* 策略: 优先 v2, 404 自动降级 v1。
|
||||||
|
* 测试连接: v2 GET api/v2/token → 404 时 v1 POST api/get_contacts_adif。
|
||||||
|
* 站点网格: 仅 v2 有 GET api/v2/station/{id}; v1 无此端点 → 降级用用户 QTH。
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.wavelog
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.OutputStreamWriter
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** 站点信息(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
|
||||||
|
|
||||||
|
/** 规范化服务器地址: 去尾斜杠/去尾部 index.php; 无协议前缀时补 https:// */
|
||||||
|
fun normalizeUrl(raw: String): String {
|
||||||
|
var u = raw.trim().trimEnd('/')
|
||||||
|
if (u.isBlank()) return ""
|
||||||
|
if (!u.startsWith("http://") && !u.startsWith("https://")) u = "https://$u"
|
||||||
|
if (u.endsWith("/index.php")) u = u.removeSuffix("/index.php")
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 测试连接: v2 GET api/v2/token → 404 时 v1 POST api/get_contacts_adif */
|
||||||
|
suspend fun testToken(url: String, apiKey: String, stationId: String = ""): WavelogResult = withContext(Dispatchers.IO) {
|
||||||
|
val base = normalizeUrl(url)
|
||||||
|
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
||||||
|
|
||||||
|
// v2: GET /index.php/api/v2/token
|
||||||
|
val v2 = httpRequest("$base/index.php/api/v2/token", "GET", apiKey, null)
|
||||||
|
if (v2.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v2)")
|
||||||
|
|
||||||
|
// v1: POST /index.php/api/get_contacts_adif(key 在 body)
|
||||||
|
if (stationId.isNotBlank()) {
|
||||||
|
val body = JSONObject().apply {
|
||||||
|
put("key", apiKey)
|
||||||
|
put("station_id", stationId)
|
||||||
|
put("fetchfromid", 0)
|
||||||
|
}.toString()
|
||||||
|
val v1 = httpRequest("$base/index.php/api/get_contacts_adif", "POST", apiKey, body)
|
||||||
|
if (v1.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
|
||||||
|
if (v1.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
|
||||||
|
}
|
||||||
|
// 无 index.php 的 v1 尝试
|
||||||
|
val body = JSONObject().apply {
|
||||||
|
put("key", apiKey)
|
||||||
|
put("station_id", stationId)
|
||||||
|
put("fetchfromid", 0)
|
||||||
|
}.toString()
|
||||||
|
val v1b = httpRequest("$base/api/get_contacts_adif", "POST", apiKey, body)
|
||||||
|
if (v1b.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
|
||||||
|
if (v1b.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
|
||||||
|
|
||||||
|
WavelogResult.Failure("连接失败: v2 HTTP ${v2.first}, v1 HTTP ${v1b.first} — 请确认服务器地址/密钥正确")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 站点信息: 仅 v2; v1 无此端点返回 null 标记(网格检测降级用用户 QTH) */
|
||||||
|
suspend fun getStation(url: String, apiKey: String, stationId: String): WavelogResult = withContext(Dispatchers.IO) {
|
||||||
|
val base = normalizeUrl(url)
|
||||||
|
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
||||||
|
val (code, resp) = httpRequest("$base/index.php/api/v2/station/$stationId", "GET", apiKey, null)
|
||||||
|
if (code in 200..299) {
|
||||||
|
return@withContext try {
|
||||||
|
val obj = JSONObject(resp)
|
||||||
|
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())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
WavelogResult.Failure("解析失败: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// v1 无 station 端点 → 返回 Success 空(调用方降级用用户 QTH)
|
||||||
|
WavelogResult.Success("")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LoTW 认可的卫星名: 取括号前主名 + 大写(ISS 特判) */
|
||||||
|
fun normalizeSatName(raw: String): String {
|
||||||
|
val main = raw.substringBefore('(').trim()
|
||||||
|
.ifBlank { raw.trim() }
|
||||||
|
.uppercase(Locale.ENGLISH)
|
||||||
|
// ISS 特判: 主名是 ISS/ZARYA/ARISS 变体 → ISS(LoTW 认可名)
|
||||||
|
return when {
|
||||||
|
main == "ZARYA" || main.startsWith("ISS") -> "ISS"
|
||||||
|
main == "ARISS" -> "ISS"
|
||||||
|
else -> main
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建 QSO: 优先 v2, 404 降级 v1(ADIF) */
|
||||||
|
suspend fun postQso(
|
||||||
|
url: String,
|
||||||
|
apiKey: String,
|
||||||
|
stationProfileId: String,
|
||||||
|
qso: WavelogQso,
|
||||||
|
gridsquare: String
|
||||||
|
): WavelogResult = withContext(Dispatchers.IO) {
|
||||||
|
val base = normalizeUrl(url)
|
||||||
|
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
||||||
|
|
||||||
|
val satName = normalizeSatName(qso.satName)
|
||||||
|
|
||||||
|
// v2: POST /index.php/api/v2/qso(JSON 字段)
|
||||||
|
val v2Body = 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", satName)
|
||||||
|
}
|
||||||
|
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
|
||||||
|
if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)")
|
||||||
|
if (code == 409) return@withContext WavelogResult.Success("重复(已存在)")
|
||||||
|
|
||||||
|
// v1: POST /index.php/api/qso(key 在 body + ADIF)
|
||||||
|
val v1Body = JSONObject().apply {
|
||||||
|
put("key", apiKey)
|
||||||
|
put("station_profile_id", stationProfileId)
|
||||||
|
put("type", "adif")
|
||||||
|
put("string", toAdif(qso, gridsquare, satName))
|
||||||
|
}
|
||||||
|
val (code1, resp1) = httpRequest("$base/index.php/api/qso", "POST", apiKey, v1Body.toString())
|
||||||
|
if (code1 in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
|
||||||
|
|
||||||
|
// 无 index.php 的 v1
|
||||||
|
val (code1b, resp1b) = httpRequest("$base/api/qso", "POST", apiKey, v1Body.toString())
|
||||||
|
if (code1b in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
|
||||||
|
|
||||||
|
WavelogResult.Failure("上传失败: v2 HTTP $code, v1 HTTP $code1 — ${shortError(resp1.ifBlank { resp1b })}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** v1 ADIF 字符串(频率 MHz, 长度=UTF-8 字节数, sat_name 已规范化) */
|
||||||
|
private fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String {
|
||||||
|
fun field(name: String, value: String): String {
|
||||||
|
val bytes = value.toByteArray(Charsets.UTF_8).size
|
||||||
|
return "<$name:$bytes>$value"
|
||||||
|
}
|
||||||
|
return buildString {
|
||||||
|
append(field("call", qso.call))
|
||||||
|
append(field("band", "SAT"))
|
||||||
|
append(field("mode", qso.mode))
|
||||||
|
append(field("freq", String.format(Locale.ENGLISH, "%.6f", qso.freqTxHz / 1_000_000.0)))
|
||||||
|
if (qso.freqRxHz > 0) {
|
||||||
|
append(field("freq_rx", String.format(Locale.ENGLISH, "%.6f", qso.freqRxHz / 1_000_000.0)))
|
||||||
|
}
|
||||||
|
append(field("qso_date", utcDateCompact(qso.timeUtcMs)))
|
||||||
|
append(field("time_on", utcTimeCompact(qso.timeUtcMs)))
|
||||||
|
if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare.take(4)))
|
||||||
|
if (satName.isNotBlank()) {
|
||||||
|
append(field("sat_name", satName))
|
||||||
|
append(field("prop_mode", "SAT"))
|
||||||
|
}
|
||||||
|
append("<eor>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用 HTTP 请求(返回 code + body) */
|
||||||
|
private fun httpRequest(url: String, method: String, apiKey: String, jsonBody: String?): Pair<Int, String> {
|
||||||
|
return try {
|
||||||
|
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")
|
||||||
|
if (jsonBody != null) {
|
||||||
|
conn.doOutput = true
|
||||||
|
conn.setRequestProperty("Content-Type", "application/json")
|
||||||
|
conn.setRequestProperty("Accept", "application/json")
|
||||||
|
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(jsonBody) }
|
||||||
|
}
|
||||||
|
val code = conn.responseCode
|
||||||
|
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
|
||||||
|
val body = if (stream != null) {
|
||||||
|
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).use { it.readText() }
|
||||||
|
} else ""
|
||||||
|
code to body
|
||||||
|
} catch (e: Exception) {
|
||||||
|
-1 to (e.message ?: e.javaClass.simpleName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shortError(body: String): String {
|
||||||
|
if (body.startsWith("<")) return body.take(80) // HTML 错误页
|
||||||
|
return try {
|
||||||
|
val obj = JSONObject(body)
|
||||||
|
val err = obj.optJSONObject("error")
|
||||||
|
err?.optString("message")?.ifBlank { body.take(120) }
|
||||||
|
?: obj.optString("reason").ifBlank { 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun utcDateCompact(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 utcTimeCompact(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,100 @@
|
|||||||
|
/*
|
||||||
|
* 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,
|
||||||
|
val sessionId: String = "", // 场次 ID: 卫星名-AOS 时间戳(过境仰角 0 秒), 空=未分组(旧数据)
|
||||||
|
val uploaded: Boolean = false // 是否已成功上传(4.5.2 修复: 成功后保留标记, 表格打勾)
|
||||||
|
)
|
||||||
|
|
||||||
|
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"),
|
||||||
|
sessionId = o.optString("sessionId"),
|
||||||
|
uploaded = o.optBoolean("uploaded", false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} 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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记为已上传(保留在队列, 表格打勾) */
|
||||||
|
@Synchronized
|
||||||
|
fun markUploaded(id: String) {
|
||||||
|
save(all().map { if (it.id == id) it.copy(uploaded = true) else it })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移除所有已上传条目(可选项, 保持队列精简) */
|
||||||
|
@Synchronized
|
||||||
|
fun removeUploaded() {
|
||||||
|
save(all().filter { !it.uploaded })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun save(list: List<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)
|
||||||
|
put("sessionId", q.sessionId)
|
||||||
|
put("uploaded", q.uploaded)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
store.save(arr.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
/*
|
||||||
|
* 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,
|
||||||
|
val firstError: 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. 拉站点信息(拿台站网格); v1 无此端点时降级用用户 QTH
|
||||||
|
val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid()
|
||||||
|
if (stationGrid.isNullOrBlank()) {
|
||||||
|
return UploadOutcome.Done(0, queue.all().size, "无法获取站点信息(检查站点 ID/密钥权限)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 网格检测: 用户当前 QTH 前 4 位 vs 台站网格前 4 位(v1 降级时网格相同, 跳过)
|
||||||
|
if (!force) {
|
||||||
|
val userGrid = userQthGrid()
|
||||||
|
if (userGrid != null && stationGrid.take(4).lowercase() != userGrid.take(4).lowercase()) {
|
||||||
|
return UploadOutcome.NeedConfirm(stationGrid, userGrid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 逐条上传(成功后标记 uploaded, 保留在本地供日志页打勾)
|
||||||
|
val entries = queue.all()
|
||||||
|
var ok = 0
|
||||||
|
var fail = 0
|
||||||
|
var firstError = ""
|
||||||
|
for (qso in entries) {
|
||||||
|
if (qso.uploaded) { ok++; continue }
|
||||||
|
val result = WaveLogApi.postQso(url, apiKey, stationId, qso, stationGrid)
|
||||||
|
if (result is WavelogResult.Success) {
|
||||||
|
ok++
|
||||||
|
queue.markUploaded(qso.id)
|
||||||
|
} else {
|
||||||
|
fail++
|
||||||
|
if (firstError.isBlank()) firstError = (result as? WavelogResult.Failure)?.message ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val message = if (fail == 0) "成功上传 $ok 条" else "成功 $ok 条, 失败 $fail 条(保留待重试)"
|
||||||
|
return UploadOutcome.Done(ok, fail, message, firstError)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
-4
@@ -13,12 +13,15 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
upHigh: Long = 145_500_000L,
|
upHigh: Long = 145_500_000L,
|
||||||
downLow: Long = 435_000_000L,
|
downLow: Long = 435_000_000L,
|
||||||
downHigh: Long? = 435_500_000L,
|
downHigh: Long? = 435_500_000L,
|
||||||
inverted: Boolean = false
|
inverted: Boolean = false,
|
||||||
|
info: String = "Linear Transponder",
|
||||||
|
downlinkMode: String? = "USB",
|
||||||
|
uplinkMode: String? = "LSB"
|
||||||
) = SatRadio(
|
) = SatRadio(
|
||||||
uuid = "linear", info = "Linear Transponder", isAlive = true,
|
uuid = "linear", info = info, isAlive = true,
|
||||||
downlinkLow = downLow, downlinkHigh = downHigh,
|
downlinkLow = downLow, downlinkHigh = downHigh,
|
||||||
downlinkMode = "USB", uplinkLow = upLow, uplinkHigh = upHigh,
|
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
|
||||||
uplinkMode = "LSB", isInverted = inverted, catnum = 12345
|
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun fmTransponder() = SatRadio(
|
private fun fmTransponder() = SatRadio(
|
||||||
@@ -48,6 +51,28 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
|
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsTrueForLinearTransponderName() {
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(linearTransponder()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsTrueForSsbTransponderName() {
|
||||||
|
val xpdr = linearTransponder(info = "Mode V/U SSB Transponder", downlinkMode = "USB", uplinkMode = "LSB")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(xpdr))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsFalseForRangeEntryWithoutTransponderName() {
|
||||||
|
val driftingRangeEntry = linearTransponder(info = "Upper side band (drifting)")
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun computeUplinkFromDownlink_linear_noDoppler() {
|
fun computeUplinkFromDownlink_linear_noDoppler() {
|
||||||
val xpdr = linearTransponder()
|
val xpdr = linearTransponder()
|
||||||
@@ -93,6 +118,53 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
assertNull(result)
|
assertNull(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeDownlinkFromUplink_withPositiveOffset_addsOffsetToDownlink() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||||
|
uplinkHz = 145_200_000L,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = 2_500L
|
||||||
|
)
|
||||||
|
assertEquals(435_202_500L, downlink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_withPositiveOffset_subtractsOffsetBeforeMapping() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||||
|
downlinkHz = 435_202_500L,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = 2_500L
|
||||||
|
)
|
||||||
|
assertEquals(145_200_000L, uplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeOffsetRoundTrip_handlesNegativeOffset() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||||
|
uplinkHz = 145_200_000L,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = -2_500L
|
||||||
|
)
|
||||||
|
assertEquals(435_197_500L, downlink)
|
||||||
|
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||||
|
downlinkHz = downlink!!,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = -2_500L
|
||||||
|
)
|
||||||
|
assertEquals(145_200_000L, uplink)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun computeUplinkFromDownlink_invertedTransponder() {
|
fun computeUplinkFromDownlink_invertedTransponder() {
|
||||||
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
|
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
|
||||||
|
|||||||
+1
-2
@@ -518,6 +518,5 @@ fun formatFrequency(frequencyHz: Long): String {
|
|||||||
if (frequencyHz <= 0) return "---"
|
if (frequencyHz <= 0) return "---"
|
||||||
val mhz = frequencyHz / 1_000_000
|
val mhz = frequencyHz / 1_000_000
|
||||||
val khz = (frequencyHz % 1_000_000) / 1_000
|
val khz = (frequencyHz % 1_000_000) / 1_000
|
||||||
val hz = frequencyHz % 1_000
|
return String.format(Locale.ENGLISH, "%d.%03d", mhz, khz)
|
||||||
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
|
|
||||||
}
|
}
|
||||||
+7
-1
@@ -44,6 +44,12 @@ sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: Strin
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object CwDecode : Screen(R.drawable.ic_cw, R.string.nav_cw, "CwDecode")
|
data object CwDecode : Screen(R.drawable.ic_cw, R.string.nav_cw, "CwDecode")
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object WavelogLog : Screen(R.drawable.ic_log, R.string.nav_log, "WavelogLog")
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object AmSat : Screen(R.drawable.ic_satellites, R.string.nav_amsat, "AMSAT")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings")
|
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings")
|
||||||
}
|
}
|
||||||
@@ -52,7 +58,7 @@ sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: Strin
|
|||||||
val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings")
|
val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings")
|
||||||
|
|
||||||
// UI 设置: 默认更多菜单顺序(进「更多」的页面)
|
// UI 设置: 默认更多菜单顺序(进「更多」的页面)
|
||||||
val defaultSubMenuOrder = listOf("Mutual", "Roaming", "CwDecode")
|
val defaultSubMenuOrder = listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object RadarDestination : NavKey
|
data object RadarDestination : NavKey
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M6,2h9l5,5v13c0,1.1 -0.9,2 -2,2H6c-1.1,0 -2,-0.9 -2,-2V4c0,-1.1 0.9,-2 2,-2zM14,3.5V8h4.5L14,3.5zM8,12h8v2H8v-2zM8,16h8v2H8v-2z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name" translatable="false">Look4Sat Pro</string>
|
||||||
|
<string name="btn_accept">Terima</string>
|
||||||
|
<string name="btn_cancel">Batal</string>
|
||||||
|
<string name="empty_list_message">Tidak ada data untuk ditampilkan</string>
|
||||||
|
<string name="nav_sat">Satelit</string>
|
||||||
|
<string name="nav_pass">Lintasan</string>
|
||||||
|
<string name="nav_radar">Radar</string>
|
||||||
|
<string name="nav_mutual">Pasangan</string>
|
||||||
|
<string name="nav_roaming">Jelajah</string>
|
||||||
|
<string name="nav_cw">Dekoder CW</string>
|
||||||
|
<string name="nav_map">Peta</string>
|
||||||
|
<string name="nav_prefs">Pengaturan</string>
|
||||||
|
<string name="prefs_aprs_title">Pelaporan APRS</string>
|
||||||
|
<string name="prefs_aprs_enable">Aktifkan pelaporan APRS</string>
|
||||||
|
<string name="prefs_aprs_summary">Server: %1$s:%2$d Panggilan: %3$s</string>
|
||||||
|
<string name="prefs_aprs_report_now">Lapor Sekarang</string>
|
||||||
|
<string name="prefs_aprs_settings_title">Pengaturan APRS</string>
|
||||||
|
<string name="prefs_aprs_server">Server</string>
|
||||||
|
<string name="prefs_aprs_port">Port</string>
|
||||||
|
<string name="prefs_aprs_callsign">Tanda panggilan</string>
|
||||||
|
<string name="prefs_aprs_ssid">SSID</string>
|
||||||
|
<string name="prefs_aprs_passcode">Kode sandi</string>
|
||||||
|
<string name="prefs_aprs_passcode_hint">Kosong = otomatis dari panggilan</string>
|
||||||
|
<string name="prefs_aprs_interval">Interval (menit)</string>
|
||||||
|
<string name="prefs_aprs_status">Teks status</string>
|
||||||
|
<string name="prefs_aprs_symbol_table">Tabel simbol</string>
|
||||||
|
<string name="prefs_aprs_symbol_code">Kode simbol</string>
|
||||||
|
<string name="prefs_aprs_save">Simpan</string>
|
||||||
|
<string name="prefs_aprs_cancel">Batal</string>
|
||||||
|
<string name="aprs_notif_channel">Layanan APRS</string>
|
||||||
|
<string name="aprs_notif_title">APRS: %1$s</string>
|
||||||
|
<string name="aprs_notif_running">Pelaporan berjalan</string>
|
||||||
|
<string name="aprs_notif_connected">Terhubung ke APRS-IS</string>
|
||||||
|
<string name="aprs_notif_error">Kesalahan koneksi</string>
|
||||||
|
<string name="aprs_notif_stop">Berhenti</string>
|
||||||
|
<string name="prefs_aprs_calc_passcode">Hitung kode sandi</string>
|
||||||
|
<string name="aprs_toast_ok">APRS: laporan terkirim</string>
|
||||||
|
<string name="aprs_toast_fail">APRS: laporan gagal - %1$s</string>
|
||||||
|
<string name="aprs_toast_not_configured">APRS belum dikonfigurasi - isi panggilan dulu</string>
|
||||||
|
<string name="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string>
|
||||||
|
<string name="prefs_aprs_last_ok">OK</string>
|
||||||
|
<string name="prefs_aprs_last_fail">gagal - %1$s</string>
|
||||||
|
<string name="nav_more">Lainnya</string>
|
||||||
|
<string name="nav_log">Log</string>
|
||||||
|
<string name="nav_amsat">AMSAT</string>
|
||||||
|
<string name="amsat_title">Status Satelit AMSAT</string>
|
||||||
|
<string name="amsat_updated">Diperbarui:</string>
|
||||||
|
<string name="amsat_active">Aktif</string>
|
||||||
|
<string name="amsat_tlm">TLM/beacon</string>
|
||||||
|
<string name="amsat_not_heard">Tidak terdengar</string>
|
||||||
|
<string name="amsat_conflict">Konflik</string>
|
||||||
|
<string name="amsat_name">Name</string>
|
||||||
|
<string name="amsat_load_failed">Gagal memuat - periksa jaringan dan coba lagi</string>
|
||||||
|
<string name="amsat_no_reports">Tidak ada laporan untuk periode ini</string>
|
||||||
|
<string name="amsat_close">Tutup</string>
|
||||||
|
|
||||||
|
<string name="sat_type_hint">Jenis: %s</string>
|
||||||
|
<string name="sat_type_title">Pilih jenis satelit</string>
|
||||||
|
<string name="sat_search_hint">Id - Nama</string>
|
||||||
|
<string name="sat_search_clear">Bersihkan</string>
|
||||||
|
<string name="sat_clear_all">Bersihkan semua</string>
|
||||||
|
<string name="sat_select_all">Pilih semua</string>
|
||||||
|
<string name="sat_empty_list_message">Pastikan kata kunci pencarian Anda benar dan DB sudah diperbarui</string>
|
||||||
|
<string name="sat_warning_title">Peringatan\!</string>
|
||||||
|
<string name="sat_warning_message">
|
||||||
|
Ada lebih dari 9000 satelit yang terdaftar di aplikasi ini.
|
||||||
|
Tidak masuk akal untuk melacak semuanya sekaligus.
|
||||||
|
\n\nSelalu coba persempit daftar hanya ke satelit yang
|
||||||
|
Anda minati melalui pencarian dan pemilih jenis.</string>
|
||||||
|
<string name="pass_filter_title">Filter lintasan</string>
|
||||||
|
<string name="pass_filter_elev">Elevasi minimal</string>
|
||||||
|
<string name="pass_filter_hours">Waktu ke depan</string>
|
||||||
|
<string name="pass_filter_aos_time">Jendela AOS</string>
|
||||||
|
<string name="pass_filter_invert_time">Balik jendela AOS</string>
|
||||||
|
<string name="pass_filter_deep_space">DeepSpace (periode >225 mnt)</string>
|
||||||
|
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||||
|
<string name="pass_modes_title">Pilih jenis modulasi</string>
|
||||||
|
<string name="pass_satId" translatable="false">%05d</string>
|
||||||
|
<string name="pass_elevation">Elevasi: %.1f°</string>
|
||||||
|
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
||||||
|
<string name="pass_altitude">Ketinggian: %d km</string>
|
||||||
|
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
|
||||||
|
<string name="pass_empty_list_message">
|
||||||
|
Pastikan filter Anda benar dan Anda sudah memilih satelit</string>
|
||||||
|
<string name="pass_error_message">Tidak ada lintasan satelit mendatang untuk ditampilkan</string>
|
||||||
|
<string name="pass_welcome_title">Selamat datang di Look4Sat\!</string>
|
||||||
|
<string name="pass_welcome_message">
|
||||||
|
Pastikan Anda mengatur posisi melalui GPS, Lat/Lon atau QTH di Pengaturan.
|
||||||
|
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
|
||||||
|
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
|
||||||
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
|
* BARU: Halaman status satelit AMSAT - tabel status langsung (warna resmi), jumlah laporan, laporan detail per satelit/hari
|
||||||
|
* Log dikelompokkan per lintasan satelit (sesi = satelit + waktu AOS), garis pemisah antar sesi
|
||||||
|
* GPS kini hanya menampilkan sukses saat posisi benar-benar didapat (timeout 15 dtk, cek izin)
|
||||||
|
* Pesan kegagalan jelas saat database satelit gagal diunduh
|
||||||
|
* Frekuensi log mengikuti panel Doppler secara real-time; visual hapus geser dipulihkan
|
||||||
|
* Perbaikan bahasa Indonesia (values-id)
|
||||||
|
* BARU: Pelaporan APRS (jaringan APRS-IS, layanan latar depan dengan notifikasi, interval dapat diatur)</string>
|
||||||
|
<string name="radar_back">Kembali</string>
|
||||||
|
<string name="radar_notify">Beri tahu</string>
|
||||||
|
<string name="radar_az_text">Azimuth</string>
|
||||||
|
<string name="radar_az_value" translatable="false">%.1f°</string>
|
||||||
|
<string name="radar_el_text">Elevasi</string>
|
||||||
|
<string name="radar_el_value" translatable="false">%.1f°</string>
|
||||||
|
<string name="radar_alt_text">Ketinggian</string>
|
||||||
|
<string name="radar_alt_value" translatable="false">%.0f km</string>
|
||||||
|
<string name="radar_dist_text">Jarak</string>
|
||||||
|
<string name="radar_dist_value" translatable="false">%.0f km</string>
|
||||||
|
<string name="radar_eclipsed">Gerhana</string>
|
||||||
|
<string name="radar_downlink">Downlink</string>
|
||||||
|
<string name="radar_uplink">Uplink</string>
|
||||||
|
<string name="radar_link_low" translatable="false">%.4f</string>
|
||||||
|
<string name="radar_link_lowHigh" translatable="false">%.4f - %.4f</string>
|
||||||
|
<string name="radar_no_link" translatable="false">- - . - -</string>
|
||||||
|
<string name="radar_no_data">Satelit ini tidak memiliki transceiver</string>
|
||||||
|
<string name="radar_add">Tambah</string>
|
||||||
|
<string name="radar_data">Transceiver satelit:</string>
|
||||||
|
<string name="radar_inverted">Terbalik: %s</string>
|
||||||
|
<string name="radar_mode">Mode: %s</string>
|
||||||
|
<string name="radar_string_no">Tidak</string>
|
||||||
|
<string name="radar_string_yes">Ya</string>
|
||||||
|
<string name="radar_visible">Terlihat</string>
|
||||||
|
<string name="radar_doppler_calc">Kalkulator Frekuensi Doppler</string>
|
||||||
|
<string name="radar_doppler_tx_hint">Masukkan frekuensi TX (MHz)</string>
|
||||||
|
<string name="radar_doppler_rx_hint">Masukkan frekuensi RX (MHz)</string>
|
||||||
|
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
|
||||||
|
<string name="radar_doppler_info">Untuk transponder linier, ketik satu frekuensi untuk melihat frekuensi lainnya</string>
|
||||||
|
<string name="radar_cw_decoder">Dekoder CW</string>
|
||||||
|
<string name="radar_cw_start">Mulai</string>
|
||||||
|
<string name="radar_cw_stop">Berhenti</string>
|
||||||
|
<string name="radar_cw_reset">Bersihkan</string>
|
||||||
|
<string name="map_prev">Sebelumnya</string>
|
||||||
|
<string name="map_next">Berikutnya</string>
|
||||||
|
<string name="map_copyright" translatable="false">© Kontributor OpenStreetMap</string>
|
||||||
|
<string name="map_azimuth">Azimuth: %.1f°</string>
|
||||||
|
<string name="map_elevation">Elevasi: %.1f°</string>
|
||||||
|
<string name="map_altitude">Ketinggian: %.0f km</string>
|
||||||
|
<string name="map_distance">Jarak: %.0f km</string>
|
||||||
|
<string name="map_latitude">Lintang: %.1f°</string>
|
||||||
|
<string name="map_longitude">Bujur: %.1f°</string>
|
||||||
|
<string name="map_qth" translatable="false">QTH: %s</string>
|
||||||
|
<string name="map_phase">Fase: %.1f°</string>
|
||||||
|
<string name="map_eclipsed">Gerhana</string>
|
||||||
|
<string name="map_period">Periode: %.0f mnt</string>
|
||||||
|
<string name="map_velocity">Kecepatan: %.2f km/d</string>
|
||||||
|
<string name="map_visibility">Visibilitas: %s</string>
|
||||||
|
<string name="map_visible">Terlihat</string>
|
||||||
|
<string name="prefs_app_title" translatable="false">Look4Sat Pro v%s</string>
|
||||||
|
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
|
||||||
|
<string name="prefs_donate_title">Donasi</string>
|
||||||
|
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
||||||
|
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
|
||||||
|
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
|
||||||
|
<string name="prefs_github_title" translatable="false">GitHub</string>
|
||||||
|
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
||||||
|
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
||||||
|
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
|
||||||
|
<string name="prefs_privacy_title">Kebijakan Privasi</string>
|
||||||
|
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
|
||||||
|
<string name="prefs_updated_title">Diperbarui: %s</string>
|
||||||
|
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
||||||
|
<string name="prefs_loc_title">Posisi stasiun</string>
|
||||||
|
<string name="prefs_loc_gps_title">GPS</string>
|
||||||
|
<string name="prefs_loc_gps_error">Tidak dapat mengambil lokasi - periksa izin dan sinyal GPS/jaringan</string>
|
||||||
|
<string name="prefs_loc_input_title">Input</string>
|
||||||
|
<string name="prefs_loc_input_error">Lokasi yang dimasukkan tidak valid</string>
|
||||||
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
|
<string name="prefs_loc_qth_error">Lokator QTH tidak valid</string>
|
||||||
|
<string name="prefs_loc_success">Posisi berhasil diperbarui</string>
|
||||||
|
<string name="prefs_station_title">Pengaturan posisi stasiun</string>
|
||||||
|
<string name="prefs_station_lat_text">Atur garis lintang stasiun Anda</string>
|
||||||
|
<string name="prefs_station_lon_text">Atur garis bujur stasiun Anda</string>
|
||||||
|
<string name="prefs_locator_title">Pengaturan lokator QTH</string>
|
||||||
|
<string name="prefs_locator_text">Atur posisi stasiun menggunakan lokator</string>
|
||||||
|
<string name="prefs_data_title">Data satelit</string>
|
||||||
|
<string name="prefs_data_entries">Satelit: %s</string>
|
||||||
|
<string name="prefs_data_radios">Transceiver: %s</string>
|
||||||
|
<string name="prefs_data_update">Perbarui</string>
|
||||||
|
<string name="prefs_data_import">URL Kustom</string>
|
||||||
|
<string name="prefs_data_clear">Bersihkan</string>
|
||||||
|
<string name="prefs_data_clear_success">Data berhasil dibersihkan</string>
|
||||||
|
<string name="prefs_data_update_success">Pembaruan berhasil diselesaikan</string>
|
||||||
|
<string name="prefs_data_update_failed">Pembaruan gagal - periksa jaringan Anda</string>
|
||||||
|
<string name="prefs_data_import_satellites_error">Tidak ada satelit yang diimpor. Pilih file TLE/3LE (.txt) atau OMM (.csv) yang valid.</string>
|
||||||
|
<string name="prefs_data_import_transceivers_error">Tidak ada transceiver yang diimpor. Pilih file SatNOGS (.json) yang valid.</string>
|
||||||
|
<string name="prefs_data_sources_title">Sumber data kustom</string>
|
||||||
|
<string name="prefs_data_sources_tle_switch">URL TLE Kustom</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_switch">URL transceiver kustom</string>
|
||||||
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
|
<string name="prefs_data_reset_url">Setel ulang ke URL bawaan</string>
|
||||||
|
<string name="prefs_data_output_title">Keluaran data</string>
|
||||||
|
<string name="prefs_net_output">Jaringan</string>
|
||||||
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
|
<string name="prefs_cat_output">CAT</string>
|
||||||
|
<string name="nav_radiocontrol">Kontrol Radio</string>
|
||||||
|
<string name="rc_settings_title">Kontrol Radio CAT</string>
|
||||||
|
<string name="rc_radio_model">Model Radio</string>
|
||||||
|
<string name="rc_tx_device_hint">Alamat BT Radio TX</string>
|
||||||
|
<string name="rc_rx_device_hint">Alamat BT Radio RX</string>
|
||||||
|
<string name="rc_tx_name_hint">Nama Radio TX</string>
|
||||||
|
<string name="rc_rx_name_hint">Nama Radio RX</string>
|
||||||
|
<string name="rc_enable_switch">Aktifkan Kontrol CAT</string>
|
||||||
|
<string name="prefs_net_title">Keluaran data jaringan</string>
|
||||||
|
<string name="prefs_net_rotator_switch">Aktifkan keluaran rotator</string>
|
||||||
|
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||||
|
<string name="prefs_net_rotator_format_hint">Format</string>
|
||||||
|
<string name="prefs_net_frequency_switch">Aktifkan keluaran frekuensi</string>
|
||||||
|
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||||
|
<string name="prefs_net_frequency_format_hint">Format</string>
|
||||||
|
<string name="prefs_bt_title">Keluaran data Bluetooth</string>
|
||||||
|
<string name="prefs_bt_rotator_switch">Aktifkan keluaran rotator</string>
|
||||||
|
<string name="prefs_bt_rotator_device_hint">Id perangkat</string>
|
||||||
|
<string name="prefs_bt_rotator_output_hint">Format data</string>
|
||||||
|
<string name="prefs_bt_frequency_switch">Aktifkan keluaran frekuensi</string>
|
||||||
|
<string name="prefs_bt_frequency_device_hint">Id perangkat</string>
|
||||||
|
<string name="prefs_bt_frequency_output_hint">Format data</string>
|
||||||
|
<string name="prefs_bt_perm_error">Periksa izin bluetooth Anda</string>
|
||||||
|
<string name="prefs_net_perm_error">Periksa izin jaringan Anda</string>
|
||||||
|
<string name="tracking_status">Melacak: %s</string>
|
||||||
|
<string name="loc_lat_text">Lintang: %s°</string>
|
||||||
|
<string name="loc_lon_text">Bujur: %s°</string>
|
||||||
|
<string name="loc_qth_text">QTH: %s</string>
|
||||||
|
<string name="connect_radio">Hubungkan</string>
|
||||||
|
<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_select_transponder">Pilih transponder</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_copy">Salin</string>
|
||||||
|
<string name="wavelog_delete_undo">Urungkan</string>
|
||||||
|
<string name="wavelog_col_time">Waktu</string>
|
||||||
|
<string name="wavelog_col_freq">Frek.</string>
|
||||||
|
<string name="wavelog_col_sat">Satelit</string>
|
||||||
|
<string name="wavelog_col_call">Panggilan</string>
|
||||||
|
<string name="wavelog_col_uploaded">Terkirim</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>
|
||||||
|
<string name="prefs_ui_main_title">Menu utama (bilah bawah)</string>
|
||||||
|
<string name="prefs_ui_sub_title">Menu lainnya</string>
|
||||||
|
<string name="prefs_ui_move_out">Pindah ke menu lainnya</string>
|
||||||
|
<string name="prefs_ui_move_back">Kembalikan ke menu utama</string>
|
||||||
|
<string name="prefs_ui_order_locked">Tetap (tidak dapat dipindahkan)</string>
|
||||||
|
<string name="prefs_ui_order_reset">Setel ulang urutan</string>
|
||||||
|
<string name="prefs_other_switch_utc">Tampilkan waktu lintasan dalam UTC</string>
|
||||||
|
<string name="prefs_other_switch_update">Aktifkan pembaruan data otomatis</string>
|
||||||
|
<string name="prefs_other_switch_sweep">Aktifkan animasi sapuan radar</string>
|
||||||
|
<string name="prefs_other_switch_sensors">Gunakan sensor untuk memutar tampilan radar</string>
|
||||||
|
<string name="prefs_other_switch_night_mode">Aktifkan filter mode malam merah</string>
|
||||||
|
<string name="prefs_highlight_title">Sorotan elevasi</string>
|
||||||
|
<string name="prefs_highlight_low">Rendah < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">Sedang %1$d–%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">Tinggi > %1$d°</string>
|
||||||
|
<string name="prefs_outro_title">BG7NTA & penulis asli mengucapkan terima kasih</string>
|
||||||
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
|
* Pengguna dan kontributor Look4Sat!
|
||||||
|
\n* David A. B. Johnson (predict4java)
|
||||||
|
\n* Dave Moten (predict4java)
|
||||||
|
\n* Alexandru Csete (Gpredict)
|
||||||
|
\n* Dr T.S. Kelso (Celestrak)
|
||||||
|
\n* Libre Space Foundation (SatNOGS)
|
||||||
|
\n* xdsopl dan kontributor Robot36!
|
||||||
|
\n* BA7OPF (fitur pencocokan lintasan)
|
||||||
|
\n* BG7NTA</string>
|
||||||
|
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
|
||||||
|
</resources>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Look4Sat Pro</string>
|
<string name="app_name" translatable="false">Look4Sat Pro</string>
|
||||||
<string name="btn_accept">Terima</string>
|
<string name="btn_accept">Terima</string>
|
||||||
<string name="btn_cancel">Batal</string>
|
<string name="btn_cancel">Batal</string>
|
||||||
<string name="empty_list_message">Tidak ada data untuk ditampilkan</string>
|
<string name="empty_list_message">Tidak ada data untuk ditampilkan</string>
|
||||||
@@ -12,7 +12,49 @@
|
|||||||
<string name="nav_cw">Dekoder CW</string>
|
<string name="nav_cw">Dekoder CW</string>
|
||||||
<string name="nav_map">Peta</string>
|
<string name="nav_map">Peta</string>
|
||||||
<string name="nav_prefs">Pengaturan</string>
|
<string name="nav_prefs">Pengaturan</string>
|
||||||
|
<string name="prefs_aprs_title">Pelaporan APRS</string>
|
||||||
|
<string name="prefs_aprs_enable">Aktifkan pelaporan APRS</string>
|
||||||
|
<string name="prefs_aprs_summary">Server: %1$s:%2$d Panggilan: %3$s</string>
|
||||||
|
<string name="prefs_aprs_report_now">Lapor Sekarang</string>
|
||||||
|
<string name="prefs_aprs_settings_title">Pengaturan APRS</string>
|
||||||
|
<string name="prefs_aprs_server">Server</string>
|
||||||
|
<string name="prefs_aprs_port">Port</string>
|
||||||
|
<string name="prefs_aprs_callsign">Tanda panggilan</string>
|
||||||
|
<string name="prefs_aprs_ssid">SSID</string>
|
||||||
|
<string name="prefs_aprs_passcode">Kode sandi</string>
|
||||||
|
<string name="prefs_aprs_passcode_hint">Kosong = otomatis dari panggilan</string>
|
||||||
|
<string name="prefs_aprs_interval">Interval (menit)</string>
|
||||||
|
<string name="prefs_aprs_status">Teks status</string>
|
||||||
|
<string name="prefs_aprs_symbol_table">Tabel simbol</string>
|
||||||
|
<string name="prefs_aprs_symbol_code">Kode simbol</string>
|
||||||
|
<string name="prefs_aprs_save">Simpan</string>
|
||||||
|
<string name="prefs_aprs_cancel">Batal</string>
|
||||||
|
<string name="aprs_notif_channel">Layanan APRS</string>
|
||||||
|
<string name="aprs_notif_title">APRS: %1$s</string>
|
||||||
|
<string name="aprs_notif_running">Pelaporan berjalan</string>
|
||||||
|
<string name="aprs_notif_connected">Terhubung ke APRS-IS</string>
|
||||||
|
<string name="aprs_notif_error">Kesalahan koneksi</string>
|
||||||
|
<string name="aprs_notif_stop">Berhenti</string>
|
||||||
|
<string name="prefs_aprs_calc_passcode">Hitung kode sandi</string>
|
||||||
|
<string name="aprs_toast_ok">APRS: laporan terkirim</string>
|
||||||
|
<string name="aprs_toast_fail">APRS: laporan gagal - %1$s</string>
|
||||||
|
<string name="aprs_toast_not_configured">APRS belum dikonfigurasi - isi panggilan dulu</string>
|
||||||
|
<string name="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string>
|
||||||
|
<string name="prefs_aprs_last_ok">OK</string>
|
||||||
|
<string name="prefs_aprs_last_fail">gagal - %1$s</string>
|
||||||
<string name="nav_more">Lainnya</string>
|
<string name="nav_more">Lainnya</string>
|
||||||
|
<string name="nav_log">Log</string>
|
||||||
|
<string name="nav_amsat">AMSAT</string>
|
||||||
|
<string name="amsat_title">Status Satelit AMSAT</string>
|
||||||
|
<string name="amsat_updated">Diperbarui:</string>
|
||||||
|
<string name="amsat_active">Aktif</string>
|
||||||
|
<string name="amsat_tlm">TLM/beacon</string>
|
||||||
|
<string name="amsat_not_heard">Tidak terdengar</string>
|
||||||
|
<string name="amsat_conflict">Konflik</string>
|
||||||
|
<string name="amsat_name">Name</string>
|
||||||
|
<string name="amsat_load_failed">Gagal memuat - periksa jaringan dan coba lagi</string>
|
||||||
|
<string name="amsat_no_reports">Tidak ada laporan untuk periode ini</string>
|
||||||
|
<string name="amsat_close">Tutup</string>
|
||||||
<string name="sat_type_hint">Jenis: %s</string>
|
<string name="sat_type_hint">Jenis: %s</string>
|
||||||
<string name="sat_type_title">Pilih jenis satelit</string>
|
<string name="sat_type_title">Pilih jenis satelit</string>
|
||||||
<string name="sat_search_hint">Id - Nama</string>
|
<string name="sat_search_hint">Id - Nama</string>
|
||||||
@@ -32,13 +74,13 @@
|
|||||||
<string name="pass_filter_aos_time">Jendela AOS</string>
|
<string name="pass_filter_aos_time">Jendela AOS</string>
|
||||||
<string name="pass_filter_invert_time">Balik jendela AOS</string>
|
<string name="pass_filter_invert_time">Balik jendela AOS</string>
|
||||||
<string name="pass_filter_deep_space">DeepSpace (periode >225 mnt)</string>
|
<string name="pass_filter_deep_space">DeepSpace (periode >225 mnt)</string>
|
||||||
<string name="pass_time_placeholder"> -- : -- : -- </string>
|
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||||
<string name="pass_modes_title">Pilih jenis modulasi</string>
|
<string name="pass_modes_title">Pilih jenis modulasi</string>
|
||||||
<string name="pass_satId">%05d</string>
|
<string name="pass_satId" translatable="false">%05d</string>
|
||||||
<string name="pass_elevation">Elevasi: %.1f°</string>
|
<string name="pass_elevation">Elevasi: %.1f°</string>
|
||||||
<string name="pass_deep_space">DeepSpace</string>
|
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
||||||
<string name="pass_altitude">Ketinggian: %d km</string>
|
<string name="pass_altitude">Ketinggian: %d km</string>
|
||||||
<string name="pass_aosLos">%03d° - %03d°</string>
|
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
|
||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
Pastikan filter Anda benar dan Anda sudah memilih satelit</string>
|
Pastikan filter Anda benar dan Anda sudah memilih satelit</string>
|
||||||
<string name="pass_error_message">Tidak ada lintasan satelit mendatang untuk ditampilkan</string>
|
<string name="pass_error_message">Tidak ada lintasan satelit mendatang untuk ditampilkan</string>
|
||||||
@@ -46,26 +88,31 @@
|
|||||||
<string name="pass_welcome_message">
|
<string name="pass_welcome_message">
|
||||||
Pastikan Anda mengatur posisi melalui GPS, Lat/Lon atau QTH di Pengaturan.
|
Pastikan Anda mengatur posisi melalui GPS, Lat/Lon atau QTH di Pengaturan.
|
||||||
\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" translatable="false">Apa yang baru di Look4Sat</string>
|
||||||
<string name="pass_whatsnew_message">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* Navigasi bawah kini menggunakan 5 tab utama plus menu Lainnya (menu tingkat kedua yang dapat dilipat)
|
* BARU: Halaman status satelit AMSAT - tabel status langsung (warna resmi), jumlah laporan, laporan detail per satelit/hari
|
||||||
* Pengaturan UI: urutan halaman dipisah menjadi menu utama dan menu lainnya; pindahkan halaman di antara keduanya</string>
|
* Log dikelompokkan per lintasan satelit (sesi = satelit + waktu AOS), garis pemisah antar sesi
|
||||||
|
* GPS kini hanya menampilkan sukses saat posisi benar-benar didapat (timeout 15 dtk, cek izin)
|
||||||
|
* Pesan kegagalan jelas saat database satelit gagal diunduh
|
||||||
|
* Frekuensi log mengikuti panel Doppler secara real-time; visual hapus geser dipulihkan
|
||||||
|
* Perbaikan bahasa Indonesia (values-id)
|
||||||
|
* BARU: Pelaporan APRS (jaringan APRS-IS, layanan latar depan dengan notifikasi, interval dapat diatur)</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>
|
||||||
<string name="radar_az_value">%.1f°</string>
|
<string name="radar_az_value" translatable="false">%.1f°</string>
|
||||||
<string name="radar_el_text">Elevasi</string>
|
<string name="radar_el_text">Elevasi</string>
|
||||||
<string name="radar_el_value">%.1f°</string>
|
<string name="radar_el_value" translatable="false">%.1f°</string>
|
||||||
<string name="radar_alt_text">Ketinggian</string>
|
<string name="radar_alt_text">Ketinggian</string>
|
||||||
<string name="radar_alt_value">%.0f km</string>
|
<string name="radar_alt_value" translatable="false">%.0f km</string>
|
||||||
<string name="radar_dist_text">Jarak</string>
|
<string name="radar_dist_text">Jarak</string>
|
||||||
<string name="radar_dist_value">%.0f km</string>
|
<string name="radar_dist_value" translatable="false">%.0f km</string>
|
||||||
<string name="radar_eclipsed">Gerhana</string>
|
<string name="radar_eclipsed">Gerhana</string>
|
||||||
<string name="radar_downlink">Downlink</string>
|
<string name="radar_downlink">Downlink</string>
|
||||||
<string name="radar_uplink">Uplink</string>
|
<string name="radar_uplink">Uplink</string>
|
||||||
<string name="radar_link_low">%.4f</string>
|
<string name="radar_link_low" translatable="false">%.4f</string>
|
||||||
<string name="radar_link_lowHigh">%.4f - %.4f</string>
|
<string name="radar_link_lowHigh" translatable="false">%.4f - %.4f</string>
|
||||||
<string name="radar_no_link">- - . - -</string>
|
<string name="radar_no_link" translatable="false">- - . - -</string>
|
||||||
<string name="radar_no_data">Satelit ini tidak memiliki transceiver</string>
|
<string name="radar_no_data">Satelit ini tidak memiliki transceiver</string>
|
||||||
<string name="radar_add">Tambah</string>
|
<string name="radar_add">Tambah</string>
|
||||||
<string name="radar_data">Transceiver satelit:</string>
|
<string name="radar_data">Transceiver satelit:</string>
|
||||||
@@ -85,37 +132,37 @@
|
|||||||
<string name="radar_cw_reset">Bersihkan</string>
|
<string name="radar_cw_reset">Bersihkan</string>
|
||||||
<string name="map_prev">Sebelumnya</string>
|
<string name="map_prev">Sebelumnya</string>
|
||||||
<string name="map_next">Berikutnya</string>
|
<string name="map_next">Berikutnya</string>
|
||||||
<string name="map_copyright">© Kontributor OpenStreetMap</string>
|
<string name="map_copyright" translatable="false">© Kontributor OpenStreetMap</string>
|
||||||
<string name="map_azimuth">Azimuth: %.1f°</string>
|
<string name="map_azimuth">Azimuth: %.1f°</string>
|
||||||
<string name="map_elevation">Elevasi: %.1f°</string>
|
<string name="map_elevation">Elevasi: %.1f°</string>
|
||||||
<string name="map_altitude">Ketinggian: %.0f km</string>
|
<string name="map_altitude">Ketinggian: %.0f km</string>
|
||||||
<string name="map_distance">Jarak: %.0f km</string>
|
<string name="map_distance">Jarak: %.0f km</string>
|
||||||
<string name="map_latitude">Lintang: %.1f°</string>
|
<string name="map_latitude">Lintang: %.1f°</string>
|
||||||
<string name="map_longitude">Bujur: %.1f°</string>
|
<string name="map_longitude">Bujur: %.1f°</string>
|
||||||
<string name="map_qth">QTH: %s</string>
|
<string name="map_qth" translatable="false">QTH: %s</string>
|
||||||
<string name="map_phase">Fase: %.1f°</string>
|
<string name="map_phase">Fase: %.1f°</string>
|
||||||
<string name="map_eclipsed">Gerhana</string>
|
<string name="map_eclipsed">Gerhana</string>
|
||||||
<string name="map_period">Periode: %.0f mnt</string>
|
<string name="map_period">Periode: %.0f mnt</string>
|
||||||
<string name="map_velocity">Kecepatan: %.2f km/d</string>
|
<string name="map_velocity">Kecepatan: %.2f km/d</string>
|
||||||
<string name="map_visibility">Visibilitas: %s</string>
|
<string name="map_visibility">Visibilitas: %s</string>
|
||||||
<string name="map_visible">Terlihat</string>
|
<string name="map_visible">Terlihat</string>
|
||||||
<string name="prefs_app_title">Look4Sat Pro v%s</string>
|
<string name="prefs_app_title" translatable="false">Look4Sat Pro v%s</string>
|
||||||
<string name="prefs_app_url">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
|
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
|
||||||
<string name="prefs_donate_title">Donasi</string>
|
<string name="prefs_donate_title">Donasi</string>
|
||||||
<string name="prefs_donate_url">https://ko-fi.com/rt_bishop</string>
|
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
||||||
<string name="prefs_fdroid_title">F-Droid</string>
|
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
|
||||||
<string name="prefs_fdroid_url">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
|
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
|
||||||
<string name="prefs_github_title">GitHub</string>
|
<string name="prefs_github_title" translatable="false">GitHub</string>
|
||||||
<string name="prefs_github_url">https://github.com/rt-bishop/Look4Sat/</string>
|
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
||||||
<string name="prefs_license_title">GPLv3</string>
|
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
||||||
<string name="prefs_license_url">https://www.gnu.org/licenses/gpl-3.0.html</string>
|
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
|
||||||
<string name="prefs_privacy_title">Kebijakan Privasi</string>
|
<string name="prefs_privacy_title">Kebijakan Privasi</string>
|
||||||
<string name="prefs_privacy_url">https://sites.google.com/view/look4sat-privacy-policy/home</string>
|
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
|
||||||
<string name="prefs_updated_title">Diperbarui: %s</string>
|
<string name="prefs_updated_title">Diperbarui: %s</string>
|
||||||
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
||||||
<string name="prefs_loc_title">Posisi stasiun</string>
|
<string name="prefs_loc_title">Posisi stasiun</string>
|
||||||
<string name="prefs_loc_gps_title">GPS</string>
|
<string name="prefs_loc_gps_title">GPS</string>
|
||||||
<string name="prefs_loc_gps_error">Periksa izin lokasi Anda</string>
|
<string name="prefs_loc_gps_error">Tidak dapat mengambil lokasi - periksa izin dan sinyal GPS/jaringan</string>
|
||||||
<string name="prefs_loc_input_title">Input</string>
|
<string name="prefs_loc_input_title">Input</string>
|
||||||
<string name="prefs_loc_input_error">Lokasi yang dimasukkan tidak valid</string>
|
<string name="prefs_loc_input_error">Lokasi yang dimasukkan tidak valid</string>
|
||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
@@ -134,12 +181,13 @@
|
|||||||
<string name="prefs_data_clear">Bersihkan</string>
|
<string name="prefs_data_clear">Bersihkan</string>
|
||||||
<string name="prefs_data_clear_success">Data berhasil dibersihkan</string>
|
<string name="prefs_data_clear_success">Data berhasil dibersihkan</string>
|
||||||
<string name="prefs_data_update_success">Pembaruan berhasil diselesaikan</string>
|
<string name="prefs_data_update_success">Pembaruan berhasil diselesaikan</string>
|
||||||
|
<string name="prefs_data_update_failed">Pembaruan gagal - periksa jaringan Anda</string>
|
||||||
<string name="prefs_data_import_satellites_error">Tidak ada satelit yang diimpor. Pilih file TLE/3LE (.txt) atau OMM (.csv) yang valid.</string>
|
<string name="prefs_data_import_satellites_error">Tidak ada satelit yang diimpor. Pilih file TLE/3LE (.txt) atau OMM (.csv) yang valid.</string>
|
||||||
<string name="prefs_data_import_transceivers_error">Tidak ada transceiver yang diimpor. Pilih file SatNOGS (.json) yang valid.</string>
|
<string name="prefs_data_import_transceivers_error">Tidak ada transceiver yang diimpor. Pilih file SatNOGS (.json) yang valid.</string>
|
||||||
<string name="prefs_data_sources_title">Sumber data kustom</string>
|
<string name="prefs_data_sources_title">Sumber data kustom</string>
|
||||||
<string name="prefs_data_sources_tle_switch">URL TLE Kustom</string>
|
<string name="prefs_data_sources_tle_switch">URL TLE Kustom</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">URL transceiver kustom</string>
|
<string name="prefs_data_sources_transceivers_switch">URL transceiver kustom</string>
|
||||||
<string name="prefs_data_sources_url_title">URL (HTTPS)</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
<string name="prefs_data_reset_url">Setel ulang ke URL bawaan</string>
|
<string name="prefs_data_reset_url">Setel ulang ke URL bawaan</string>
|
||||||
<string name="prefs_data_output_title">Keluaran data</string>
|
<string name="prefs_data_output_title">Keluaran data</string>
|
||||||
<string name="prefs_net_output">Jaringan</string>
|
<string name="prefs_net_output">Jaringan</string>
|
||||||
@@ -177,6 +225,31 @@
|
|||||||
<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_select_transponder">Pilih transponder</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_copy">Salin</string>
|
||||||
|
<string name="wavelog_delete_undo">Urungkan</string>
|
||||||
|
<string name="wavelog_col_time">Waktu</string>
|
||||||
|
<string name="wavelog_col_freq">Frek.</string>
|
||||||
|
<string name="wavelog_col_sat">Satelit</string>
|
||||||
|
<string name="wavelog_col_call">Panggilan</string>
|
||||||
|
<string name="wavelog_col_uploaded">Terkirim</string>
|
||||||
|
<string name="wavelog_ungrouped">Tidak dikelompokkan</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>
|
||||||
@@ -196,7 +269,7 @@
|
|||||||
<string name="prefs_highlight_mid">Sedang %1$d–%2$d°</string>
|
<string name="prefs_highlight_mid">Sedang %1$d–%2$d°</string>
|
||||||
<string name="prefs_highlight_high">Tinggi > %1$d°</string>
|
<string name="prefs_highlight_high">Tinggi > %1$d°</string>
|
||||||
<string name="prefs_outro_title">BG7NTA & penulis asli mengucapkan terima kasih</string>
|
<string name="prefs_outro_title">BG7NTA & penulis asli mengucapkan terima kasih</string>
|
||||||
<string name="prefs_outro_thanks">
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
* Pengguna dan kontributor Look4Sat!
|
* Pengguna dan kontributor Look4Sat!
|
||||||
\n* David A. B. Johnson (predict4java)
|
\n* David A. B. Johnson (predict4java)
|
||||||
\n* Dave Moten (predict4java)
|
\n* Dave Moten (predict4java)
|
||||||
|
|||||||
@@ -13,7 +13,49 @@
|
|||||||
<string name="nav_cw">CW Dekoder</string>
|
<string name="nav_cw">CW Dekoder</string>
|
||||||
<string name="nav_map">Harita</string>
|
<string name="nav_map">Harita</string>
|
||||||
<string name="nav_prefs">Ayarlar</string>
|
<string name="nav_prefs">Ayarlar</string>
|
||||||
|
<string name="prefs_aprs_title">APRS Raporlama</string>
|
||||||
|
<string name="prefs_aprs_enable">APRS raporlamayı etkinleştir</string>
|
||||||
|
<string name="prefs_aprs_summary">Sunucu: %1$s:%2$d Çağrı: %3$s</string>
|
||||||
|
<string name="prefs_aprs_report_now">Şimdi Raporla</string>
|
||||||
|
<string name="prefs_aprs_settings_title">APRS Ayarları</string>
|
||||||
|
<string name="prefs_aprs_server">Sunucu</string>
|
||||||
|
<string name="prefs_aprs_port">Port</string>
|
||||||
|
<string name="prefs_aprs_callsign">Çağrı işareti</string>
|
||||||
|
<string name="prefs_aprs_ssid">SSID</string>
|
||||||
|
<string name="prefs_aprs_passcode">Parola</string>
|
||||||
|
<string name="prefs_aprs_passcode_hint">Parola boş = çağrıdan otomatik hesaplanır</string>
|
||||||
|
<string name="prefs_aprs_interval">Aralık (dakika)</string>
|
||||||
|
<string name="prefs_aprs_status">Durum metni</string>
|
||||||
|
<string name="prefs_aprs_symbol_table">Sembol tablosu</string>
|
||||||
|
<string name="prefs_aprs_symbol_code">Sembol kodu</string>
|
||||||
|
<string name="prefs_aprs_save">Kaydet</string>
|
||||||
|
<string name="prefs_aprs_cancel">İptal</string>
|
||||||
|
<string name="aprs_notif_channel">APRS hizmeti</string>
|
||||||
|
<string name="aprs_notif_title">APRS: %1$s</string>
|
||||||
|
<string name="aprs_notif_running">Raporlama çalışıyor</string>
|
||||||
|
<string name="aprs_notif_connected">APRS-IS bağlı</string>
|
||||||
|
<string name="aprs_notif_error">Bağlantı hatası</string>
|
||||||
|
<string name="aprs_notif_stop">Durdur</string>
|
||||||
|
<string name="prefs_aprs_calc_passcode">Parolayı hesapla</string>
|
||||||
|
<string name="aprs_toast_ok">APRS: rapor gönderildi</string>
|
||||||
|
<string name="aprs_toast_fail">APRS: rapor başarısız - %1$s</string>
|
||||||
|
<string name="aprs_toast_not_configured">APRS yapılandırılmadı - önce çağrı girin</string>
|
||||||
|
<string name="prefs_aprs_last_report">Son rapor: %1$s %2$s</string>
|
||||||
|
<string name="prefs_aprs_last_ok">OK</string>
|
||||||
|
<string name="prefs_aprs_last_fail">başarısız - %1$s</string>
|
||||||
<string name="nav_more">Daha Fazla</string>
|
<string name="nav_more">Daha Fazla</string>
|
||||||
|
<string name="nav_log">Günlük</string>
|
||||||
|
<string name="nav_amsat">AMSAT</string>
|
||||||
|
<string name="amsat_title">AMSAT Uydu Durumu</string>
|
||||||
|
<string name="amsat_updated">Güncellendi:</string>
|
||||||
|
<string name="amsat_active">Aktif</string>
|
||||||
|
<string name="amsat_tlm">TLM/işaret</string>
|
||||||
|
<string name="amsat_not_heard">Duyulmadı</string>
|
||||||
|
<string name="amsat_conflict">Çelişkili</string>
|
||||||
|
<string name="amsat_name">Name</string>
|
||||||
|
<string name="amsat_load_failed">Yükleme başarısız - ağı kontrol edip tekrar deneyin</string>
|
||||||
|
<string name="amsat_no_reports">Bu dönem için rapor yok</string>
|
||||||
|
<string name="amsat_close">Kapat</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Uydu türü: %s</string>
|
<string name="sat_type_hint">Uydu türü: %s</string>
|
||||||
@@ -53,8 +95,13 @@
|
|||||||
\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ü)
|
* YENİ: AMSAT uydu durum sayfası - canlı durum tablosu (resmi renkler), rapor sayıları, uydu/gün bazında detaylı raporlar
|
||||||
* UI Ayarları: sayfa sırası ana menü ve daha fazla menü olarak ayrıldı; sayfalar arasında taşıma yapılabilir</string>
|
* Günlükler uydu geçişine göre gruplanır (oturum = uydu + AOS zamanı), oturumlar arasında ayırıcı çizgi
|
||||||
|
* GPS artık yalnızca gerçek konum alındığında başarı gösterir (15 sn zaman aşımı, izin kontrolü)
|
||||||
|
* Uydu veritabanı indirilemediğinde net hata mesajı gösterilir
|
||||||
|
* Günlük frekansları Doppler panelini gerçek zamanlı takip eder; kaydırarak silme görselleri düzeltildi
|
||||||
|
* Endonezya dili düzeltmesi (values-id)
|
||||||
|
* YENİ: APRS raporlama (APRS-IS ağı, bildirimli ön plan hizmeti, ayarlanabilir aralık)</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
<string name="radar_back">Geri</string>
|
<string name="radar_back">Geri</string>
|
||||||
@@ -119,7 +166,7 @@
|
|||||||
|
|
||||||
<string name="prefs_loc_title">İstasyon konumu</string>
|
<string name="prefs_loc_title">İstasyon konumu</string>
|
||||||
<string name="prefs_loc_gps_title">GPS</string>
|
<string name="prefs_loc_gps_title">GPS</string>
|
||||||
<string name="prefs_loc_gps_error">Konum izinlerinizi kontrol edin</string>
|
<string name="prefs_loc_gps_error">Konum alınamıyor - izinleri ve GPS/ağ sinyalini kontrol edin</string>
|
||||||
<string name="prefs_loc_input_title">Manuel giriş</string>
|
<string name="prefs_loc_input_title">Manuel giriş</string>
|
||||||
<string name="prefs_loc_input_error">Geçersiz konum girildi</string>
|
<string name="prefs_loc_input_error">Geçersiz konum girildi</string>
|
||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
@@ -139,6 +186,7 @@
|
|||||||
<string name="prefs_data_clear">Temizle</string>
|
<string name="prefs_data_clear">Temizle</string>
|
||||||
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
|
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
|
||||||
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
|
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
|
||||||
|
<string name="prefs_data_update_failed">Güncelleme başarısız - ağınızı kontrol edin</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Özel veri kaynakları</string>
|
<string name="prefs_data_sources_title">Özel veri kaynakları</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
|
||||||
@@ -187,6 +235,31 @@
|
|||||||
<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_select_transponder">Transponder seç</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_copy">Kopyala</string>
|
||||||
|
<string name="wavelog_delete_undo">Geri al</string>
|
||||||
|
<string name="wavelog_col_time">Zaman</string>
|
||||||
|
<string name="wavelog_col_freq">Frekans</string>
|
||||||
|
<string name="wavelog_col_sat">Uydu</string>
|
||||||
|
<string name="wavelog_col_call">Çağrı</string>
|
||||||
|
<string name="wavelog_col_uploaded">Gönderildi</string>
|
||||||
|
<string name="wavelog_ungrouped">Gruplanmamış</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>
|
||||||
|
|||||||
@@ -14,7 +14,49 @@
|
|||||||
<string name="nav_cw">CW解码</string>
|
<string name="nav_cw">CW解码</string>
|
||||||
<string name="nav_map">地图</string>
|
<string name="nav_map">地图</string>
|
||||||
<string name="nav_prefs">设置</string>
|
<string name="nav_prefs">设置</string>
|
||||||
|
<string name="prefs_aprs_title">APRS 上报</string>
|
||||||
|
<string name="prefs_aprs_enable">启用 APRS 上报</string>
|
||||||
|
<string name="prefs_aprs_summary">服务器: %1$s:%2$d 呼号: %3$s</string>
|
||||||
|
<string name="prefs_aprs_report_now">立即上报</string>
|
||||||
|
<string name="prefs_aprs_settings_title">APRS 设置</string>
|
||||||
|
<string name="prefs_aprs_server">服务器</string>
|
||||||
|
<string name="prefs_aprs_port">端口</string>
|
||||||
|
<string name="prefs_aprs_callsign">呼号</string>
|
||||||
|
<string name="prefs_aprs_ssid">SSID</string>
|
||||||
|
<string name="prefs_aprs_passcode">验证码</string>
|
||||||
|
<string name="prefs_aprs_passcode_hint">验证码留空 = 按呼号自动计算</string>
|
||||||
|
<string name="prefs_aprs_interval">周期(分钟)</string>
|
||||||
|
<string name="prefs_aprs_status">状态文本</string>
|
||||||
|
<string name="prefs_aprs_symbol_table">符号表</string>
|
||||||
|
<string name="prefs_aprs_symbol_code">符号码</string>
|
||||||
|
<string name="prefs_aprs_save">保存</string>
|
||||||
|
<string name="prefs_aprs_cancel">取消</string>
|
||||||
|
<string name="aprs_notif_channel">APRS 服务</string>
|
||||||
|
<string name="aprs_notif_title">APRS: %1$s</string>
|
||||||
|
<string name="aprs_notif_running">上报运行中</string>
|
||||||
|
<string name="aprs_notif_connected">已连接 APRS-IS</string>
|
||||||
|
<string name="aprs_notif_error">连接错误</string>
|
||||||
|
<string name="aprs_notif_stop">停止</string>
|
||||||
|
<string name="prefs_aprs_calc_passcode">计算验证码</string>
|
||||||
|
<string name="aprs_toast_ok">APRS: 上报成功</string>
|
||||||
|
<string name="aprs_toast_fail">APRS: 上报失败 - %1$s</string>
|
||||||
|
<string name="aprs_toast_not_configured">APRS 未配置,请先填呼号</string>
|
||||||
|
<string name="prefs_aprs_last_report">上次上报: %1$s %2$s</string>
|
||||||
|
<string name="prefs_aprs_last_ok">成功</string>
|
||||||
|
<string name="prefs_aprs_last_fail">失败 - %1$s</string>
|
||||||
<string name="nav_more">更多</string>
|
<string name="nav_more">更多</string>
|
||||||
|
<string name="nav_log">日志</string>
|
||||||
|
<string name="nav_amsat">AMSAT</string>
|
||||||
|
<string name="amsat_title">AMSAT 卫星状态</string>
|
||||||
|
<string name="amsat_updated">更新:</string>
|
||||||
|
<string name="amsat_active">活跃</string>
|
||||||
|
<string name="amsat_tlm">仅遥测</string>
|
||||||
|
<string name="amsat_not_heard">未听到</string>
|
||||||
|
<string name="amsat_conflict">矛盾</string>
|
||||||
|
<string name="amsat_name">Name</string>
|
||||||
|
<string name="amsat_load_failed">加载失败,请检查网络后重试</string>
|
||||||
|
<string name="amsat_no_reports">该时段无报告</string>
|
||||||
|
<string name="amsat_close">关闭</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">类型:%s</string>
|
<string name="sat_type_hint">类型:%s</string>
|
||||||
@@ -43,8 +85,13 @@
|
|||||||
<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 个主标签 + 更多菜单(可折叠二级菜单)
|
* 新增: AMSAT 卫星状态页 - 实时状态表格(官网配色)、报告数量、逐卫星/逐日详细报告
|
||||||
* UI 设置: 页面顺序分为主菜单与更多菜单,可拖拽或移动页面</string>
|
* 日志按场次分组(场次 = 卫星 + AOS 时间), 组间横线分隔
|
||||||
|
* GPS 真正定位成功才会提示成功(15 秒超时, 权限检查)
|
||||||
|
* 卫星数据库下载失败时有明确失败提示
|
||||||
|
* 日志频率实时跟随多普勒面板; 左滑删除视觉恢复
|
||||||
|
* 印尼语识别修复(values-id)
|
||||||
|
* 新增: APRS 网络上报(APRS-IS,前台服务+通知保活,周期可调)</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
<string name="radar_back">后退</string>
|
<string name="radar_back">后退</string>
|
||||||
@@ -99,19 +146,19 @@
|
|||||||
<string name="prefs_updated_title">更新:%s</string>
|
<string name="prefs_updated_title">更新:%s</string>
|
||||||
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
|
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
|
||||||
|
|
||||||
<string name="prefs_loc_title">站位</string>
|
<string name="prefs_loc_title">站点位置</string>
|
||||||
<string name="prefs_loc_gps_title">GPS 定位</string>
|
<string name="prefs_loc_gps_title">GPS 定位</string>
|
||||||
<string name="prefs_loc_gps_error">检查您的位置权限</string>
|
<string name="prefs_loc_gps_error">无法获取位置,请检查定位权限和GPS/网络信号</string>
|
||||||
<string name="prefs_loc_input_title">输入位置</string>
|
<string name="prefs_loc_input_title">输入位置</string>
|
||||||
<string name="prefs_loc_input_error">输入的位置无效</string>
|
<string name="prefs_loc_input_error">输入的位置无效</string>
|
||||||
<string name="prefs_loc_qth_title">QTH 网格</string>
|
<string name="prefs_loc_qth_title">QTH 网格</string>
|
||||||
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
|
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
|
||||||
<string name="prefs_loc_success">位置更新成功</string>
|
<string name="prefs_loc_success">位置更新成功</string>
|
||||||
<string name="prefs_station_title">站位设置</string>
|
<string name="prefs_station_title">站点位置设置</string>
|
||||||
<string name="prefs_station_lat_text">输入站位的纬度</string>
|
<string name="prefs_station_lat_text">输入站点的纬度</string>
|
||||||
<string name="prefs_station_lon_text">输入站位的经度</string>
|
<string name="prefs_station_lon_text">输入站点的经度</string>
|
||||||
<string name="prefs_locator_title">QTH 网格</string>
|
<string name="prefs_locator_title">QTH 网格</string>
|
||||||
<string name="prefs_locator_text">使用梅登黑德网格设置站位</string>
|
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">卫星数据更新</string>
|
<string name="prefs_data_title">卫星数据更新</string>
|
||||||
<string name="prefs_data_entries">卫星:%s</string>
|
<string name="prefs_data_entries">卫星:%s</string>
|
||||||
@@ -121,6 +168,7 @@
|
|||||||
<string name="prefs_data_clear">清空</string>
|
<string name="prefs_data_clear">清空</string>
|
||||||
<string name="prefs_data_clear_success">数据清空成功</string>
|
<string name="prefs_data_clear_success">数据清空成功</string>
|
||||||
<string name="prefs_data_update_success">更新成功</string>
|
<string name="prefs_data_update_success">更新成功</string>
|
||||||
|
<string name="prefs_data_update_failed">更新失败,请检查网络</string>
|
||||||
|
|
||||||
<string name="prefs_data_import_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</string>
|
<string name="prefs_data_import_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</string>
|
||||||
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
|
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
|
||||||
@@ -170,6 +218,31 @@
|
|||||||
<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_select_transponder">选择转发器</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_copy">复制</string>
|
||||||
|
<string name="wavelog_delete_undo">撤销</string>
|
||||||
|
<string name="wavelog_col_time">时间</string>
|
||||||
|
<string name="wavelog_col_freq">频率</string>
|
||||||
|
<string name="wavelog_col_sat">卫星</string>
|
||||||
|
<string name="wavelog_col_call">呼号</string>
|
||||||
|
<string name="wavelog_col_uploaded">已传</string>
|
||||||
|
<string name="wavelog_ungrouped">未分组</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>
|
||||||
|
|||||||
@@ -15,7 +15,49 @@
|
|||||||
<string name="nav_cw">CW Decoder</string>
|
<string name="nav_cw">CW Decoder</string>
|
||||||
<string name="nav_map">Map</string>
|
<string name="nav_map">Map</string>
|
||||||
<string name="nav_prefs">Settings</string>
|
<string name="nav_prefs">Settings</string>
|
||||||
|
<string name="prefs_aprs_title">APRS Reporting</string>
|
||||||
|
<string name="prefs_aprs_enable">Enable APRS reporting</string>
|
||||||
|
<string name="prefs_aprs_summary">Server: %1$s:%2$d Callsign: %3$s</string>
|
||||||
|
<string name="prefs_aprs_report_now">Report Now</string>
|
||||||
|
<string name="prefs_aprs_settings_title">APRS Settings</string>
|
||||||
|
<string name="prefs_aprs_server">Server</string>
|
||||||
|
<string name="prefs_aprs_port">Port</string>
|
||||||
|
<string name="prefs_aprs_callsign">Callsign</string>
|
||||||
|
<string name="prefs_aprs_ssid">SSID</string>
|
||||||
|
<string name="prefs_aprs_passcode">Passcode</string>
|
||||||
|
<string name="prefs_aprs_passcode_hint">Passcode empty = auto-computed from callsign</string>
|
||||||
|
<string name="prefs_aprs_interval">Interval (min)</string>
|
||||||
|
<string name="prefs_aprs_status">Status text</string>
|
||||||
|
<string name="prefs_aprs_symbol_table">Symbol table</string>
|
||||||
|
<string name="prefs_aprs_symbol_code">Symbol code</string>
|
||||||
|
<string name="prefs_aprs_save">Save</string>
|
||||||
|
<string name="prefs_aprs_cancel">Cancel</string>
|
||||||
|
<string name="aprs_notif_channel">APRS service</string>
|
||||||
|
<string name="aprs_notif_title">APRS: %1$s</string>
|
||||||
|
<string name="aprs_notif_running">Reporting running</string>
|
||||||
|
<string name="aprs_notif_connected">Connected to APRS-IS</string>
|
||||||
|
<string name="aprs_notif_error">Connection error</string>
|
||||||
|
<string name="aprs_notif_stop">Stop</string>
|
||||||
|
<string name="prefs_aprs_calc_passcode">Compute passcode</string>
|
||||||
|
<string name="aprs_toast_ok">APRS: report sent OK</string>
|
||||||
|
<string name="aprs_toast_fail">APRS: report failed - %1$s</string>
|
||||||
|
<string name="aprs_toast_not_configured">APRS not configured - set callsign first</string>
|
||||||
|
<string name="prefs_aprs_last_report">Last report: %1$s %2$s</string>
|
||||||
|
<string name="prefs_aprs_last_ok">OK</string>
|
||||||
|
<string name="prefs_aprs_last_fail">failed - %1$s</string>
|
||||||
<string name="nav_more">More</string>
|
<string name="nav_more">More</string>
|
||||||
|
<string name="nav_log">Log</string>
|
||||||
|
<string name="nav_amsat">AMSAT</string>
|
||||||
|
<string name="amsat_title">AMSAT Satellite Status</string>
|
||||||
|
<string name="amsat_updated">Updated:</string>
|
||||||
|
<string name="amsat_active">Active</string>
|
||||||
|
<string name="amsat_tlm">TLM/Beacon</string>
|
||||||
|
<string name="amsat_not_heard">Not Heard</string>
|
||||||
|
<string name="amsat_conflict">Conflicting</string>
|
||||||
|
<string name="amsat_name">Name</string>
|
||||||
|
<string name="amsat_load_failed">Load failed - check network and retry</string>
|
||||||
|
<string name="amsat_no_reports">No reports for this period</string>
|
||||||
|
<string name="amsat_close">Close</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Type: %s</string>
|
<string name="sat_type_hint">Type: %s</string>
|
||||||
@@ -55,8 +97,13 @@
|
|||||||
\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: AMSAT satellite status page - live status grid (official colors), report counts, detailed reports per satellite/day
|
||||||
* UI Settings: page order split into main menu and more menu; drag or move pages between them</string>
|
* Log entries grouped by satellite pass (session ID = satellite + AOS time), divider lines between sessions
|
||||||
|
* GPS now only reports success when a real fix is acquired (15s timeout, permission check)
|
||||||
|
* Data update shows a clear failure message when the satellite database cannot be downloaded
|
||||||
|
* Log frequencies follow the Doppler panel in real time; swipe-to-delete visuals restored
|
||||||
|
* Indonesian locale fix (values-id)
|
||||||
|
* NEW: APRS reporting (APRS-IS network, foreground service with notification, configurable interval)</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
<string name="radar_back">Back</string>
|
<string name="radar_back">Back</string>
|
||||||
@@ -131,7 +178,7 @@
|
|||||||
|
|
||||||
<string name="prefs_loc_title">Station position</string>
|
<string name="prefs_loc_title">Station position</string>
|
||||||
<string name="prefs_loc_gps_title">GPS</string>
|
<string name="prefs_loc_gps_title">GPS</string>
|
||||||
<string name="prefs_loc_gps_error">Check your location permissions</string>
|
<string name="prefs_loc_gps_error">Unable to get location - check permission and GPS/network signal</string>
|
||||||
<string name="prefs_loc_input_title">Input</string>
|
<string name="prefs_loc_input_title">Input</string>
|
||||||
<string name="prefs_loc_input_error">Invalid location entered</string>
|
<string name="prefs_loc_input_error">Invalid location entered</string>
|
||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
@@ -151,6 +198,7 @@
|
|||||||
<string name="prefs_data_clear">Clear</string>
|
<string name="prefs_data_clear">Clear</string>
|
||||||
<string name="prefs_data_clear_success">Data was cleared successfully</string>
|
<string name="prefs_data_clear_success">Data was cleared successfully</string>
|
||||||
<string name="prefs_data_update_success">Update completed successfully</string>
|
<string name="prefs_data_update_success">Update completed successfully</string>
|
||||||
|
<string name="prefs_data_update_failed">Update failed - check your network</string>
|
||||||
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
|
|
||||||
@@ -201,6 +249,31 @@
|
|||||||
<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_select_transponder">Select transponder</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_copy">Copy</string>
|
||||||
|
<string name="wavelog_delete_undo">Undo</string>
|
||||||
|
<string name="wavelog_col_time">Time</string>
|
||||||
|
<string name="wavelog_col_freq">Freq</string>
|
||||||
|
<string name="wavelog_col_sat">Satellite</string>
|
||||||
|
<string name="wavelog_col_call">Call</string>
|
||||||
|
<string name="wavelog_col_uploaded">Sent</string>
|
||||||
|
<string name="wavelog_ungrouped">Ungrouped</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,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_500">#ff6200ee</color>
|
||||||
|
<integer name="default_font_size">18</integer>
|
||||||
|
<string name="idle">Diam</string>
|
||||||
|
<string name="waterfall_display">Tampilan waterfall</string>
|
||||||
|
<string name="frequency_scale">Skala frekuensi</string>
|
||||||
|
<string name="decoded_text">Teks terdekode</string>
|
||||||
|
<string name="clear">Bersihkan</string>
|
||||||
|
<string name="pause">Jeda Dekode</string>
|
||||||
|
<string name="save_text">Simpan Teks</string>
|
||||||
|
<string name="record_signals">Rekam Sinyal</string>
|
||||||
|
<string name="settings">Pengaturan</string>
|
||||||
|
<string name="help">Bantuan Daring</string>
|
||||||
|
<string name="rate">Nilai Aplikasi Ini</string>
|
||||||
|
<string name="premium">Dapatkan Versi Premium</string>
|
||||||
|
<string name="tap_back_again_to_close">Ketuk Kembali lagi untuk menutup aplikasi</string>
|
||||||
|
<string name="message_type">Tipe Pesan</string>
|
||||||
|
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk dekode CW</string>
|
||||||
|
<string name="cw_grant_permission">Beri izin</string>
|
||||||
|
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
/*
|
||||||
|
* LogTab.kt — 雷达页「Log」标签(4.5.2 WaveLog 日志)。
|
||||||
|
*
|
||||||
|
* 布局: 转发器列表(点开选一个, 边看频率边填) → 呼号输入回车存本地 →
|
||||||
|
* 下方本地记录列表(时间/频率/呼号)。记录支持自绘左滑删除:
|
||||||
|
* 滑动中显示黄色垃圾桶, 滑过 75% 变撤销键, 5 秒内不点撤销则删除。
|
||||||
|
*
|
||||||
|
* 回车只存本地(WavelogQueue), 上传由 10 分钟周期/手动触发(防抄收错误)。
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.feature.radar
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
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.ui.layout.onSizeChanged
|
||||||
|
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.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
|
import androidx.compose.material3.MenuAnchorType
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
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.text.style.TextOverflow
|
||||||
|
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.utility.DopplerFrequencyCalculator
|
||||||
|
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)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun LogTab(
|
||||||
|
transceivers: List<SatRadio>,
|
||||||
|
orbitalPos: OrbitalPos?,
|
||||||
|
satelliteName: String,
|
||||||
|
queue: WavelogQueue,
|
||||||
|
wavelogConfigured: Boolean,
|
||||||
|
showToast: (String) -> Unit,
|
||||||
|
txBaseFrequencyHz: Long? = null,
|
||||||
|
aosTimeMs: Long = 0L,
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转发器选择(下拉选择框): 选中后底下出现输入区
|
||||||
|
// 选中项按 uuid 从 transceivers 实时派生(多普勒频率每秒更新, 频率显示随之刷新)
|
||||||
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
val selectedRadio = transceivers.firstOrNull { it.uuid == selectedUuid }
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = menuExpanded,
|
||||||
|
onExpandedChange = { menuExpanded = it }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selectedRadio?.info ?: "",
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text(stringResource(id = R.string.wavelog_select_transponder)) },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = menuExpanded) },
|
||||||
|
textStyle = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier
|
||||||
|
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = menuExpanded,
|
||||||
|
onDismissRequest = { menuExpanded = false }
|
||||||
|
) {
|
||||||
|
transceivers.forEach { radio ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = radio.info,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
selectedUuid = radio.uuid
|
||||||
|
menuExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中转发器后: 输入区(实时频率 + 呼号 + 模式)
|
||||||
|
selectedRadio?.let { radio ->
|
||||||
|
ExpandedLogInput(
|
||||||
|
radio = radio,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
satelliteName = satelliteName,
|
||||||
|
queue = queue,
|
||||||
|
showToast = showToast,
|
||||||
|
txBaseFrequencyHz = txBaseFrequencyHz,
|
||||||
|
aosTimeMs = aosTimeMs,
|
||||||
|
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 {
|
||||||
|
// 按场次分组(sessionId = 卫星名-AOS 时间戳), 组间粗线分隔
|
||||||
|
entries.groupBy { it.sessionId.ifBlank { "un" } }.forEach { (sessionId, groupEntries) ->
|
||||||
|
// 组标题 + 分隔线
|
||||||
|
HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.primary)
|
||||||
|
Text(
|
||||||
|
text = if (sessionId == "un") stringResource(id = R.string.wavelog_ungrouped)
|
||||||
|
else sessionId.substringBeforeLast('-'),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
groupEntries.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,
|
||||||
|
txBaseFrequencyHz: Long? = null,
|
||||||
|
aosTimeMs: Long = 0L,
|
||||||
|
onSaved: () -> Unit
|
||||||
|
) {
|
||||||
|
var callsign by remember { mutableStateOf("") }
|
||||||
|
var mode by remember { mutableStateOf(radio.uplinkMode ?: "FM") }
|
||||||
|
|
||||||
|
val savedMsg = stringResource(id = R.string.wavelog_saved)
|
||||||
|
|
||||||
|
fun submit() {
|
||||||
|
val call = callsign.trim().uppercase()
|
||||||
|
if (call.length < 3) return
|
||||||
|
// 频率直接取转发器栏的数字(radio 每秒多普勒修正, 回车那一秒的值)
|
||||||
|
val tx = radio.uplinkLow ?: radio.downlinkLow ?: 0L
|
||||||
|
val rx = radio.downlinkLow ?: radio.uplinkLow ?: 0L
|
||||||
|
queue.add(
|
||||||
|
WavelogQso(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
timeUtcMs = System.currentTimeMillis(),
|
||||||
|
call = call,
|
||||||
|
mode = mode.trim().ifBlank { "FM" }.uppercase(),
|
||||||
|
freqTxHz = tx,
|
||||||
|
freqRxHz = rx,
|
||||||
|
satName = satelliteName,
|
||||||
|
sessionId = buildSessionId(satelliteName, aosTimeMs)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
callsign = ""
|
||||||
|
onSaved()
|
||||||
|
showToast(savedMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 频率显示与转发器栏完全一致: TX 行 + RX 行(radio 来自每秒多普勒重算, 实时刷新)
|
||||||
|
val txForDisplay = radio.uplinkLow ?: radio.downlinkLow ?: 0L
|
||||||
|
val rxForDisplay = radio.downlinkLow ?: radio.uplinkLow ?: 0L
|
||||||
|
val upLow = radio.uplinkLow
|
||||||
|
val upHigh = radio.uplinkHigh
|
||||||
|
val downLow = radio.downlinkLow
|
||||||
|
val downHigh = radio.downlinkHigh
|
||||||
|
val isLinearRange = upLow != null && upHigh != null && upLow != upHigh
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
// TX 行
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = "TX: ",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${formatFrequency(txForDisplay)} MHz",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
if (isLinearRange) {
|
||||||
|
Text(
|
||||||
|
text = " (${formatFrequency(upLow)} – ${formatFrequency(upHigh)})",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// RX 行(与转发器栏 RX 一致)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = "RX: ",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${formatFrequency(rxForDisplay)} MHz",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
if (downLow != null && downHigh != null && downLow != downHigh) {
|
||||||
|
Text(
|
||||||
|
text = " (${formatFrequency(downLow)} – ${formatFrequency(downHigh)})",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
internal 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
|
||||||
|
|
||||||
|
// 平滑弹回/滑入(动画)
|
||||||
|
val animatedOffset by animateFloatAsState(
|
||||||
|
targetValue = offsetX,
|
||||||
|
animationSpec = if (pending) tween(200) else spring(dampingRatio = Spring.DampingRatioNoBouncy),
|
||||||
|
label = "swipeOffset"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 倒计时: 进入待删除后 5 秒自动删
|
||||||
|
LaunchedEffect(pending) {
|
||||||
|
if (pending) {
|
||||||
|
countdown = 5
|
||||||
|
while (countdown > 0) {
|
||||||
|
delay(1000)
|
||||||
|
countdown--
|
||||||
|
}
|
||||||
|
if (pending) {
|
||||||
|
onDelete()
|
||||||
|
pending = false
|
||||||
|
offsetX = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.onSizeChanged { rowWidth = it.width }
|
||||||
|
) {
|
||||||
|
// 背景层(右侧图标: 垃圾桶 / 撤销+倒计时)
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 内容层(跟手滑动): 背景在 offset 内侧(随内容移动)!
|
||||||
|
// 平时盖住垃圾桶, 左滑时内容带背景移开 → 右侧露出黄色垃圾桶/撤销倒计时
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.offset { IntOffset(animatedOffset.roundToInt(), 0) }
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
.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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 场次 ID: 卫星名-AOS 时间戳(UTC yyyyMMdd-HHmm); 无 AOS 时用回车时刻 */
|
||||||
|
private fun buildSessionId(satName: String, aosTimeMs: Long): String {
|
||||||
|
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
|
cal.timeInMillis = if (aosTimeMs > 0) aosTimeMs else System.currentTimeMillis()
|
||||||
|
val stamp = "%04d%02d%02d-%02d%02d".format(
|
||||||
|
cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
|
||||||
|
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
|
||||||
|
)
|
||||||
|
return "$satName-$stamp"
|
||||||
|
}
|
||||||
@@ -61,6 +61,9 @@ 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.DopplerFrequencyCalculator
|
||||||
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 +81,8 @@ import kotlin.math.PI
|
|||||||
|
|
||||||
private enum class RadarPage(val title: String) {
|
private enum class RadarPage(val title: String) {
|
||||||
Transceivers("Transceivers"),
|
Transceivers("Transceivers"),
|
||||||
|
Log("Log"),
|
||||||
|
Calculator("Calculator"),
|
||||||
Sstv("SSTV")
|
Sstv("SSTV")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,9 +124,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 +159,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 +216,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,18 +231,37 @@ 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 hasCalculatorPage = remember(uiState.transceivers.transmitters) {
|
||||||
|
uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
|
||||||
|
}
|
||||||
|
val pages = remember(hasCalculatorPage) {
|
||||||
|
buildList {
|
||||||
|
add(RadarPage.Transceivers)
|
||||||
|
add(RadarPage.Log)
|
||||||
|
if (hasCalculatorPage) add(RadarPage.Calculator)
|
||||||
|
add(RadarPage.Sstv)
|
||||||
|
}
|
||||||
|
}
|
||||||
val pagerState = rememberPagerState(pageCount = { pages.size })
|
val pagerState = rememberPagerState(pageCount = { pages.size })
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
LaunchedEffect(pages.size) {
|
||||||
|
val lastPage = pages.lastIndex
|
||||||
|
if (pagerState.currentPage > lastPage) pagerState.scrollToPage(lastPage)
|
||||||
|
}
|
||||||
|
|
||||||
ElevatedCard(modifier = modifier) {
|
ElevatedCard(modifier = modifier) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
PrimaryTabRow(selectedTabIndex = pagerState.currentPage) {
|
val selectedTabIndex = pagerState.currentPage.coerceIn(0, pages.lastIndex)
|
||||||
|
PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
|
||||||
pages.forEachIndexed { index, page ->
|
pages.forEachIndexed { index, page ->
|
||||||
Tab(
|
Tab(
|
||||||
selected = pagerState.currentPage == index,
|
selected = selectedTabIndex == index,
|
||||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
|
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
|
||||||
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||||
)
|
)
|
||||||
@@ -222,20 +273,35 @@ private fun PagerCard(
|
|||||||
) { pageIndex ->
|
) { pageIndex ->
|
||||||
when (pages[pageIndex]) {
|
when (pages[pageIndex]) {
|
||||||
RadarPage.Transceivers -> TransceiversPage(
|
RadarPage.Transceivers -> TransceiversPage(
|
||||||
transceivers = uiState.transceivers.transmitters,
|
transceivers = uiState.transceivers.transmitters,
|
||||||
selectedUuid = uiState.transceivers.selectedUuid,
|
selectedUuid = uiState.transceivers.selectedUuid,
|
||||||
orbitalPos = uiState.orbitalPos,
|
radioControl = uiState.radioControl,
|
||||||
cw = uiState.cw,
|
onAction = onAction
|
||||||
radioControl = uiState.radioControl,
|
)
|
||||||
onAction = onAction,
|
RadarPage.Calculator -> CalculatorPage(
|
||||||
requestMicPermission = requestMicPermission
|
transceivers = uiState.transceivers.transmitters,
|
||||||
)
|
selectedUuid = uiState.transceivers.selectedUuid,
|
||||||
|
orbitalPos = uiState.orbitalPos,
|
||||||
|
cw = uiState.cw,
|
||||||
|
onAction = onAction,
|
||||||
|
requestMicPermission = requestMicPermission
|
||||||
|
)
|
||||||
RadarPage.Sstv -> SstvPage(
|
RadarPage.Sstv -> SstvPage(
|
||||||
sstv = uiState.sstv,
|
sstv = uiState.sstv,
|
||||||
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
|
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
|
||||||
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,
|
||||||
|
txBaseFrequencyHz = uiState.radioControl.txBaseFrequencyHz,
|
||||||
|
aosTimeMs = uiState.currentPass?.aosTime ?: 0L
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+98
-50
@@ -43,7 +43,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -90,11 +89,8 @@ import kotlin.time.Duration.Companion.milliseconds
|
|||||||
fun TransceiversPage(
|
fun TransceiversPage(
|
||||||
transceivers: List<SatRadio>,
|
transceivers: List<SatRadio>,
|
||||||
selectedUuid: String?,
|
selectedUuid: String?,
|
||||||
orbitalPos: OrbitalPos?,
|
|
||||||
cw: CwSubState,
|
|
||||||
radioControl: RadioControlSubState,
|
radioControl: RadioControlSubState,
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
requestMicPermission: () -> Unit = {},
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
if (transceivers.isEmpty()) {
|
if (transceivers.isEmpty()) {
|
||||||
@@ -117,11 +113,8 @@ fun TransceiversPage(
|
|||||||
TransceiverItem(
|
TransceiverItem(
|
||||||
radio = radio,
|
radio = radio,
|
||||||
isExpanded = isExpanded,
|
isExpanded = isExpanded,
|
||||||
orbitalPos = orbitalPos,
|
|
||||||
cw = cw,
|
|
||||||
radioControl = radioControl,
|
radioControl = radioControl,
|
||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
requestMicPermission = requestMicPermission,
|
|
||||||
onToggle = { onAction(RadarAction.SelectTransmitter(radio.uuid)) }
|
onToggle = { onAction(RadarAction.SelectTransmitter(radio.uuid)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -129,6 +122,87 @@ fun TransceiversPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CalculatorPage(
|
||||||
|
transceivers: List<SatRadio>,
|
||||||
|
selectedUuid: String?,
|
||||||
|
orbitalPos: OrbitalPos?,
|
||||||
|
cw: CwSubState,
|
||||||
|
onAction: (RadarAction) -> Unit,
|
||||||
|
requestMicPermission: () -> Unit = {},
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val calculatorTransceivers = remember(transceivers) {
|
||||||
|
transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder)
|
||||||
|
}
|
||||||
|
val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) {
|
||||||
|
calculatorTransceivers.firstOrNull { it.uuid == selectedUuid }
|
||||||
|
?: calculatorTransceivers.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTransceiver == null) {
|
||||||
|
EmptyTransceiversContent(modifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
if (calculatorTransceivers.size > 1) {
|
||||||
|
item {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
calculatorTransceivers.forEach { radio ->
|
||||||
|
FilterChip(
|
||||||
|
selected = radio.uuid == selectedTransceiver.uuid,
|
||||||
|
onClick = {
|
||||||
|
if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid))
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = transceiverTitle(radio),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
DopplerFrequencyCalculator(
|
||||||
|
transponder = selectedTransceiver,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
CwDecoderPanel(
|
||||||
|
cw = cw,
|
||||||
|
onAction = onAction,
|
||||||
|
requestMicPermission = requestMicPermission,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
|
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
|
||||||
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
|
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
|
||||||
@@ -156,11 +230,8 @@ private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
|
|||||||
private fun TransceiverItem(
|
private fun TransceiverItem(
|
||||||
radio: SatRadio,
|
radio: SatRadio,
|
||||||
isExpanded: Boolean,
|
isExpanded: Boolean,
|
||||||
orbitalPos: OrbitalPos?,
|
|
||||||
cw: CwSubState,
|
|
||||||
radioControl: RadioControlSubState,
|
radioControl: RadioControlSubState,
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
requestMicPermission: () -> Unit,
|
|
||||||
onToggle: () -> Unit
|
onToggle: () -> Unit
|
||||||
) {
|
) {
|
||||||
val bgColor = if (isExpanded) MaterialTheme.colorScheme.surfaceContainerHighest
|
val bgColor = if (isExpanded) MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
@@ -201,11 +272,8 @@ private fun TransceiverItem(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Title with mode
|
// Title with mode
|
||||||
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
|
|
||||||
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
|
|
||||||
val fullTitle = "$title ($mode)"
|
|
||||||
Text(
|
Text(
|
||||||
text = fullTitle,
|
text = transceiverTitle(radio),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -253,11 +321,8 @@ private fun TransceiverItem(
|
|||||||
) {
|
) {
|
||||||
ExpandedRadioControl(
|
ExpandedRadioControl(
|
||||||
radio = radio,
|
radio = radio,
|
||||||
orbitalPos = orbitalPos,
|
|
||||||
cw = cw,
|
|
||||||
radioControl = radioControl,
|
radioControl = radioControl,
|
||||||
onAction = onAction,
|
onAction = onAction
|
||||||
requestMicPermission = requestMicPermission
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,11 +391,8 @@ private fun UnifiedFrequencyRow(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ExpandedRadioControl(
|
private fun ExpandedRadioControl(
|
||||||
radio: SatRadio,
|
radio: SatRadio,
|
||||||
orbitalPos: OrbitalPos?,
|
|
||||||
cw: CwSubState,
|
|
||||||
radioControl: RadioControlSubState,
|
radioControl: RadioControlSubState,
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
requestMicPermission: () -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -439,41 +501,24 @@ private fun ExpandedRadioControl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Doppler frequency calculator (linear transponders only)
|
|
||||||
DopplerFrequencyCalculator(
|
|
||||||
transponder = radio,
|
|
||||||
orbitalPos = orbitalPos,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
// CW decoder panel (linear transponders only)
|
|
||||||
if (DopplerFrequencyCalculator.isLinearTransponder(radio)) {
|
|
||||||
CwDecoderPanel(
|
|
||||||
cw = cw,
|
|
||||||
onAction = onAction,
|
|
||||||
requestMicPermission = requestMicPermission,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Control buttons
|
// Control buttons
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
|
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
|
||||||
CardButton(
|
CardButton(
|
||||||
onClick = { onAction(RadarAction.ConnectRadios) },
|
onClick = { onAction(RadarAction.ConnectRadios) },
|
||||||
text = stringResource(R.string.connect_radio),
|
text = "Connect",
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
CardButton(
|
CardButton(
|
||||||
onClick = { onAction(RadarAction.DisconnectRadios) },
|
onClick = { onAction(RadarAction.DisconnectRadios) },
|
||||||
text = stringResource(R.string.disconnect_radio),
|
text = "Disconnect",
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
CardButton(
|
CardButton(
|
||||||
onClick = { onAction(RadarAction.ToggleTracking) },
|
onClick = { onAction(RadarAction.ToggleTracking) },
|
||||||
text = if (radioControl.isTracking) stringResource(R.string.stop_radio) else stringResource(R.string.track_radio),
|
text = if (radioControl.isTracking) "Stop" else "Track",
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -573,7 +618,7 @@ private fun DopplerFrequencyCalculator(
|
|||||||
},
|
},
|
||||||
label = { Text(stringResource(R.string.radar_doppler_offset_hint)) },
|
label = { Text(stringResource(R.string.radar_doppler_offset_hint)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -669,8 +714,8 @@ private fun CwDecoderPanel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(visible = cw.isExpanded) {
|
AnimatedVisibility(visible = cw.isExpanded) {
|
||||||
// Morse Expert 引擎(4.5.0 移植): 小布局 cw_panel_main(瀑布图 + 解码文本)
|
// PR #1 Morse Expert engine: mini layout with waterfall + decoded text.
|
||||||
// 自带 Kotlin 贝叶斯引擎(CwDecoder)不再用于此面板(代码保留作回退)
|
// Keep the old Kotlin decoder state/actions as fallback code, but this panel no longer feeds it.
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = remember { context as? Activity }
|
val activity = remember { context as? Activity }
|
||||||
val controller = remember { MainActivity() }
|
val controller = remember { MainActivity() }
|
||||||
@@ -680,7 +725,6 @@ private fun CwDecoderPanel(
|
|||||||
var initialized by remember { mutableStateOf(false) }
|
var initialized by remember { mutableStateOf(false) }
|
||||||
var listening by remember { mutableStateOf(false) }
|
var listening by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// 生命周期: 展开即绑定视图(不改宿主窗口); 收起/离开自动释放
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
if (activity != null) {
|
if (activity != null) {
|
||||||
controller.onCreate(activity, rootView, false)
|
controller.onCreate(activity, rootView, false)
|
||||||
@@ -690,18 +734,17 @@ private fun CwDecoderPanel(
|
|||||||
controller.onDestroy()
|
controller.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 权限已授予(首次进入/授权回调统一入口): 初始化引擎 + 启动录音
|
|
||||||
LaunchedEffect(cw.hasPermission) {
|
LaunchedEffect(cw.hasPermission) {
|
||||||
if (cw.hasPermission && !initialized) {
|
if (cw.hasPermission && !initialized) {
|
||||||
controller.onPermissionGranted() // = v(): 创建音频采集 + 解码核心
|
controller.onPermissionGranted()
|
||||||
controller.onResume() // 立即启动录音
|
controller.onResume()
|
||||||
initialized = true
|
initialized = true
|
||||||
listening = true
|
listening = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
// Control buttons row(映射到控制器)
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
@@ -744,7 +787,6 @@ private fun CwDecoderPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 瀑布图 + 解码文本(原版布局迷你版, 适配容器)
|
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { rootView },
|
factory = { rootView },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -771,5 +813,11 @@ private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun transceiverTitle(radio: SatRadio): String {
|
||||||
|
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
|
||||||
|
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
|
||||||
|
return "$title ($mode)"
|
||||||
|
}
|
||||||
|
|
||||||
private val FREQ_ADJUSTMENTS =
|
private val FREQ_ADJUSTMENTS =
|
||||||
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
|
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
/*
|
||||||
|
* WavelogLogScreen.kt — 「日志」页面(更多菜单, 4.5.2 修复)。
|
||||||
|
*
|
||||||
|
* 表格形式展示本地存储的日志: 时间 | 频率 | 卫星 | 呼号 | 已上传(✓)。
|
||||||
|
* 完整格子线(表头横线 + 行横线 + 列竖线 + 外边框); 行复用左滑删除。
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.feature.radar
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
||||||
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
private val CheckGreen = Color(0xFF4CAF50)
|
||||||
|
private val GridLineColor = Color(0xFF3A3A3A)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WavelogLogScreen(
|
||||||
|
queue: WavelogQueue,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
var refreshTick by remember { mutableIntStateOf(0) }
|
||||||
|
val entries = remember(refreshTick) { queue.all() }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
// 表格外边框(左右 + 上下由表头/行底线构成)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
) {
|
||||||
|
// 表头
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(38.dp)
|
||||||
|
) {
|
||||||
|
HeaderCell(stringResource(id = R.string.wavelog_col_time), 72.dp)
|
||||||
|
GridVLine()
|
||||||
|
HeaderCell(stringResource(id = R.string.wavelog_col_freq), 64.dp)
|
||||||
|
GridVLine()
|
||||||
|
HeaderCell(stringResource(id = R.string.wavelog_col_sat), 0.dp, weight = 1.2f)
|
||||||
|
GridVLine()
|
||||||
|
HeaderCell(stringResource(id = R.string.wavelog_col_call), 0.dp, weight = 1f)
|
||||||
|
GridVLine()
|
||||||
|
HeaderCell(stringResource(id = R.string.wavelog_col_uploaded), 44.dp)
|
||||||
|
}
|
||||||
|
// 表头横线
|
||||||
|
HorizontalDivider(thickness = 1.5.dp, color = GridLineColor)
|
||||||
|
// 表头与数据区留白
|
||||||
|
Box(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.wavelog_empty),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 12.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// 按场次分组(sessionId = 卫星名-AOS 时间戳); 空 sessionId 归"未分组"放最后
|
||||||
|
val groups = entries.groupBy { it.sessionId.ifBlank { UNGROUPED } }
|
||||||
|
.toSortedMap(compareByDescending<String> { id ->
|
||||||
|
entries.filter { it.sessionId.ifBlank { UNGROUPED } == id }.minOfOrNull { it.timeUtcMs } ?: 0L
|
||||||
|
}.thenByDescending { it })
|
||||||
|
groups.forEach { (sessionId, groupEntries) ->
|
||||||
|
// 组标题(场次名 + 本地时间)
|
||||||
|
val (satPart, timePart) = parseSession(sessionId)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = if (sessionId == UNGROUPED) stringResource(id = R.string.wavelog_ungrouped)
|
||||||
|
else "$satPart - ${formatSessionTime(timePart)}",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 组间横线(md --- 效果)
|
||||||
|
HorizontalDivider(thickness = 1.5.dp, color = GridLineColor)
|
||||||
|
groupEntries.forEach { entry ->
|
||||||
|
SwipeDeleteRow(
|
||||||
|
onDelete = {
|
||||||
|
queue.remove(entry.id)
|
||||||
|
refreshTick++
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(36.dp)
|
||||||
|
) {
|
||||||
|
Cell(formatLocalTime(entry.timeUtcMs), 72.dp)
|
||||||
|
GridVLine()
|
||||||
|
Cell(formatFrequency(entry.freqTxHz), 64.dp)
|
||||||
|
GridVLine()
|
||||||
|
Cell(entry.satName, 0.dp, weight = 1.2f)
|
||||||
|
GridVLine()
|
||||||
|
Cell(entry.call, 0.dp, weight = 1f)
|
||||||
|
GridVLine()
|
||||||
|
Box(modifier = Modifier.width(44.dp)) {
|
||||||
|
if (entry.uploaded) {
|
||||||
|
Text(
|
||||||
|
text = "✓",
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = CheckGreen
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 行分隔线
|
||||||
|
HorizontalDivider(thickness = 1.dp, color = GridLineColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列竖线 */
|
||||||
|
@Composable
|
||||||
|
private fun GridVLine() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(1.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(GridLineColor)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RowScope.HeaderCell(text: String, width: Dp, weight: Float = 0f) {
|
||||||
|
val mod = if (weight > 0f) Modifier.weight(weight) else Modifier.width(width)
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = mod.padding(horizontal = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RowScope.Cell(text: String, width: Dp, weight: Float = 0f) {
|
||||||
|
val mod = if (weight > 0f) Modifier.weight(weight) else Modifier.width(width)
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = mod.padding(horizontal = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val UNGROUPED = "__ungrouped__"
|
||||||
|
|
||||||
|
/** 解析场次 ID: "ASRTU-1-20260804-2014" → (卫星名, "20260804-2014") */
|
||||||
|
private fun parseSession(sessionId: String): Pair<String, String> {
|
||||||
|
val parts = sessionId.split('-')
|
||||||
|
if (parts.size < 3) return sessionId to ""
|
||||||
|
val timePart = parts.takeLast(2).joinToString("-")
|
||||||
|
val satPart = parts.dropLast(2).joinToString("-")
|
||||||
|
return satPart to timePart
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 场次时间(UTC "20260804-2014") → 本地 "MM-dd HH:mm" */
|
||||||
|
private fun formatSessionTime(timePart: String): String {
|
||||||
|
if (timePart.length != 13 || timePart[8] != '-') return timePart
|
||||||
|
return try {
|
||||||
|
val year = timePart.substring(0, 4).toInt()
|
||||||
|
val month = timePart.substring(4, 6).toInt()
|
||||||
|
val day = timePart.substring(6, 8).toInt()
|
||||||
|
val hour = timePart.substring(9, 11).toInt()
|
||||||
|
val minute = timePart.substring(11, 13).toInt()
|
||||||
|
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
|
cal.set(year, month - 1, day, hour, minute, 0)
|
||||||
|
cal.timeInMillis = cal.timeInMillis + TimeZone.getDefault().getOffset(cal.timeInMillis)
|
||||||
|
"%02d-%02d %02d:%02d".format(
|
||||||
|
cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
|
||||||
|
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
timePart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatLocalTime(utcMs: Long): String {
|
||||||
|
val cal = Calendar.getInstance(TimeZone.getDefault())
|
||||||
|
cal.timeInMillis = utcMs
|
||||||
|
return "%02d-%02d %02d:%02d".format(
|
||||||
|
cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
|
||||||
|
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,3 +5,7 @@ plugins {
|
|||||||
android {
|
android {
|
||||||
namespace = "com.rtbishop.look4sat.feature.settings"
|
namespace = "com.rtbishop.look4sat.feature.settings"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core:data"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
package com.rtbishop.look4sat.feature.settings
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
|
||||||
|
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
|
||||||
|
import com.rtbishop.look4sat.core.data.aprs.AprsStore
|
||||||
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
|
||||||
|
/** APRS 卡片:卫星数据容器与数据输出容器之间;设置键弹窗收纳,不挤 */
|
||||||
|
@Composable
|
||||||
|
fun AprsCard() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var config by remember { mutableStateOf(AprsStore.loadConfig(context)) }
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
var lastReport by remember { mutableStateOf(AprsStore.loadLastReport(context)) }
|
||||||
|
var reportPing by remember { mutableStateOf(0) }
|
||||||
|
val notifPermissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { }
|
||||||
|
fun requestNotifPermissionIfNeeded() {
|
||||||
|
if (Build.VERSION.SDK_INT >= 33 &&
|
||||||
|
context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) !=
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
notifPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
// 标题行:标题 + 设置键(弹窗,省空间)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.prefs_aprs_title),
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
IconButton(onClick = { showDialog = true }) {
|
||||||
|
Text(
|
||||||
|
text = "\u2699", // ⚙
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontSize = 18.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 启用开关
|
||||||
|
AprsSwitchRow(R.string.prefs_aprs_enable, config.enabled) { enabled ->
|
||||||
|
config = config.copy(enabled = enabled)
|
||||||
|
AprsStore.saveConfig(context, config)
|
||||||
|
if (enabled) requestNotifPermissionIfNeeded()
|
||||||
|
val intent = Intent()
|
||||||
|
.setClassName(context.packageName, AprsStore.SERVICE_CLASS)
|
||||||
|
.setAction(if (enabled) AprsStore.ACTION_START else AprsStore.ACTION_STOP)
|
||||||
|
if (enabled) context.startForegroundService(intent) else context.startService(intent)
|
||||||
|
}
|
||||||
|
// 简洁信息行(不挤)
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
id = R.string.prefs_aprs_summary,
|
||||||
|
config.server, config.port,
|
||||||
|
if (config.callsign.isBlank()) "-" else config.callsign
|
||||||
|
),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
// 上次上报状态行
|
||||||
|
val lr = lastReport
|
||||||
|
if (lr.time > 0L) {
|
||||||
|
val timeStr = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault())
|
||||||
|
.format(java.util.Date(lr.time))
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
id = R.string.prefs_aprs_last_report,
|
||||||
|
timeStr,
|
||||||
|
if (lr.ok) stringResource(R.string.prefs_aprs_last_ok)
|
||||||
|
else stringResource(R.string.prefs_aprs_last_fail, lr.detail)
|
||||||
|
),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = if (lr.ok) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 手动上报按钮
|
||||||
|
CardButton(
|
||||||
|
onClick = {
|
||||||
|
val intent = Intent()
|
||||||
|
.setClassName(context.packageName, AprsStore.SERVICE_CLASS)
|
||||||
|
.setAction(AprsStore.ACTION_REPORT_NOW)
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
reportPing++
|
||||||
|
},
|
||||||
|
text = stringResource(id = R.string.prefs_aprs_report_now),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 上报后延迟刷新状态行(等结果落盘)
|
||||||
|
androidx.compose.runtime.LaunchedEffect(reportPing) {
|
||||||
|
if (reportPing > 0) {
|
||||||
|
kotlinx.coroutines.delay(6000)
|
||||||
|
lastReport = AprsStore.loadLastReport(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showDialog) {
|
||||||
|
AprsSettingsDialog(
|
||||||
|
config = config,
|
||||||
|
onDismiss = { showDialog = false },
|
||||||
|
onSave = { newConfig ->
|
||||||
|
config = newConfig
|
||||||
|
AprsStore.saveConfig(context, newConfig)
|
||||||
|
// 已启用时应用新配置(重启服务)
|
||||||
|
if (newConfig.enabled) {
|
||||||
|
context.startService(
|
||||||
|
Intent().setClassName(context.packageName, AprsStore.SERVICE_CLASS)
|
||||||
|
.setAction(AprsStore.ACTION_STOP)
|
||||||
|
)
|
||||||
|
context.startForegroundService(
|
||||||
|
Intent().setClassName(context.packageName, AprsStore.SERVICE_CLASS)
|
||||||
|
.setAction(AprsStore.ACTION_START)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
showDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** APRS 设置弹窗(空间充裕,配置项齐全) */
|
||||||
|
@Composable
|
||||||
|
private fun AprsSettingsDialog(
|
||||||
|
config: AprsConfig,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (AprsConfig) -> Unit
|
||||||
|
) {
|
||||||
|
var server by remember { mutableStateOf(config.server) }
|
||||||
|
var port by remember { mutableStateOf(config.port.toString()) }
|
||||||
|
var callsign by remember { mutableStateOf(config.callsign) }
|
||||||
|
var ssid by remember { mutableStateOf(config.ssid) }
|
||||||
|
var passcode by remember { mutableStateOf(config.passcode) }
|
||||||
|
var interval by remember { mutableStateOf(config.intervalMin.toString()) }
|
||||||
|
var status by remember { mutableStateOf(config.statusText) }
|
||||||
|
var symbolTable by remember { mutableStateOf(config.symbolTable) }
|
||||||
|
var symbolCode by remember { mutableStateOf(config.symbolCode) }
|
||||||
|
val textStyle = LocalTextStyle.current.copy(fontSize = 14.sp)
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(id = R.string.prefs_aprs_settings_title)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = server,
|
||||||
|
onValueChange = { server = it },
|
||||||
|
label = { Text(stringResource(id = R.string.prefs_aprs_server)) },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = textStyle
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = port,
|
||||||
|
onValueChange = { port = it },
|
||||||
|
label = { Text(stringResource(id = R.string.prefs_aprs_port)) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
textStyle = textStyle
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = callsign,
|
||||||
|
onValueChange = { callsign = it },
|
||||||
|
label = { Text(stringResource(id = R.string.prefs_aprs_callsign)) },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = textStyle
|
||||||
|
)
|
||||||
|
// 计算验证码按钮(用户要求:让用户算出结果)
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val call = callsign.trim().uppercase()
|
||||||
|
if (call.isNotBlank()) {
|
||||||
|
passcode = AprsPacket.passcode(call).toString()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = callsign.trim().isNotBlank()
|
||||||
|
) { Text(stringResource(id = R.string.prefs_aprs_calc_passcode)) }
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = ssid,
|
||||||
|
onValueChange = { ssid = it },
|
||||||
|
label = { Text(stringResource(id = R.string.prefs_aprs_ssid)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textStyle = textStyle
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = passcode,
|
||||||
|
onValueChange = { passcode = it },
|
||||||
|
label = { Text(stringResource(id = R.string.prefs_aprs_passcode)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textStyle = textStyle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = interval,
|
||||||
|
onValueChange = { interval = it },
|
||||||
|
label = { Text(stringResource(id = R.string.prefs_aprs_interval)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
textStyle = textStyle
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = status,
|
||||||
|
onValueChange = { status = it },
|
||||||
|
label = { Text(stringResource(id = R.string.prefs_aprs_status)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textStyle = textStyle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = symbolTable,
|
||||||
|
onValueChange = { symbolTable = it },
|
||||||
|
label = { Text(stringResource(id = R.string.prefs_aprs_symbol_table)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textStyle = textStyle
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = symbolCode,
|
||||||
|
onValueChange = { symbolCode = it },
|
||||||
|
label = { Text(stringResource(id = R.string.prefs_aprs_symbol_code)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textStyle = textStyle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.prefs_aprs_passcode_hint),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
onSave(
|
||||||
|
config.copy(
|
||||||
|
server = server.ifBlank { "euro.aprs2.net" },
|
||||||
|
port = port.toIntOrNull() ?: 14580,
|
||||||
|
callsign = callsign.trim().uppercase(),
|
||||||
|
ssid = ssid.trim(),
|
||||||
|
passcode = passcode.trim(),
|
||||||
|
intervalMin = interval.toIntOrNull() ?: 5,
|
||||||
|
statusText = status,
|
||||||
|
symbolTable = symbolTable.ifBlank { "/" },
|
||||||
|
symbolCode = symbolCode.ifBlank { ">" }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}) { Text(stringResource(id = R.string.prefs_aprs_save)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(id = R.string.prefs_aprs_cancel)) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本文件内开关行(与 SettingsScreen 的私有 SwitchRow 一致) */
|
||||||
|
@Composable
|
||||||
|
private fun AprsSwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = labelResId))
|
||||||
|
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
-5
@@ -44,16 +44,21 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import android.content.Context
|
||||||
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 +85,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 +117,63 @@ 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaveLog 错误详情弹窗(可一键复制)
|
||||||
|
val wavelogError = viewModel.wavelogError
|
||||||
|
if (wavelogError != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { viewModel.wavelogError = null },
|
||||||
|
title = { Text(text = stringResource(id = R.string.wavelog_title)) },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = wavelogError,
|
||||||
|
fontSize = 13.sp
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||||
|
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("WaveLog error", wavelogError))
|
||||||
|
viewModel.wavelogError = null
|
||||||
|
}) {
|
||||||
|
Text(text = stringResource(id = R.string.wavelog_copy))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { viewModel.wavelogError = null }) {
|
||||||
|
Text(text = stringResource(id = R.string.btn_cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -318,6 +381,9 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
showDataSourcesDialog = { dialogs.dataSources = true }
|
showDataSourcesDialog = { dialogs.dataSources = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
AprsCard()
|
||||||
|
}
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
OutputCard(
|
OutputCard(
|
||||||
onNetworkClick = permissions.launchNetwork,
|
onNetworkClick = permissions.launchNetwork,
|
||||||
@@ -326,6 +392,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 +625,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>,
|
||||||
@@ -575,6 +707,8 @@ private fun UiSettingsCard(
|
|||||||
R.string.nav_mutual to "Mutual",
|
R.string.nav_mutual to "Mutual",
|
||||||
R.string.nav_roaming to "Roaming",
|
R.string.nav_roaming to "Roaming",
|
||||||
R.string.nav_cw to "CwDecode",
|
R.string.nav_cw to "CwDecode",
|
||||||
|
R.string.nav_log to "WavelogLog",
|
||||||
|
R.string.nav_amsat to "AMSAT",
|
||||||
R.string.nav_map to "Map",
|
R.string.nav_map to "Map",
|
||||||
R.string.nav_prefs to "Settings"
|
R.string.nav_prefs to "Settings"
|
||||||
) // name 必须与 Screen.screenId 一致 (R8 安全)
|
) // name 必须与 Screen.screenId 一致 (R8 安全)
|
||||||
@@ -650,12 +784,20 @@ private fun UiSettingsCard(
|
|||||||
items = subItems,
|
items = subItems,
|
||||||
labels = screens,
|
labels = screens,
|
||||||
moveLabel = stringResource(id = R.string.prefs_ui_move_back),
|
moveLabel = stringResource(id = R.string.prefs_ui_move_back),
|
||||||
moveEnabled = { mainItems.size < 5 },
|
// 按钮常显; 主菜单满 5 时自动让位(最后一个非"设置"项移入更多, 交换)
|
||||||
|
moveEnabled = { true },
|
||||||
onMove = { name ->
|
onMove = { name ->
|
||||||
onUpdateMenu(
|
val mainWithoutSettings = mainItems.filter { it != "Settings" }
|
||||||
(screenOrder.ifEmpty { defaultScreenOrder } + name).distinct(),
|
val needSwap = mainWithoutSettings.size >= 5
|
||||||
subItems.filter { it != name }
|
val evicted = if (needSwap) mainWithoutSettings.last() else null
|
||||||
)
|
val newMain = if (needSwap) {
|
||||||
|
(mainWithoutSettings.dropLast(1) + name).distinct()
|
||||||
|
} else {
|
||||||
|
(mainWithoutSettings + name).distinct()
|
||||||
|
}
|
||||||
|
val newSub = (subItems.filter { it != name } +
|
||||||
|
if (evicted != null) listOf(evicted) else emptyList()).distinct()
|
||||||
|
onUpdateMenu(newMain, newSub)
|
||||||
},
|
},
|
||||||
onReorder = { sub -> onUpdateMenu(screenOrder, sub) }
|
onReorder = { sub -> onUpdateMenu(screenOrder, sub) }
|
||||||
)
|
)
|
||||||
|
|||||||
+9
-1
@@ -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
|
||||||
|
|||||||
+89
-2
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -146,7 +164,16 @@ class SettingsViewModel(
|
|||||||
// region Position helpers — consolidated from 3 near-identical functions
|
// region Position helpers — consolidated from 3 near-identical functions
|
||||||
|
|
||||||
private fun setGpsPosition() {
|
private fun setGpsPosition() {
|
||||||
updatePosition(R.string.prefs_loc_gps_error) { settingsRepo.setStationPosition() }
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(positionSettings = it.positionSettings.copy(isUpdating = true)) }
|
||||||
|
val success = settingsRepo.setStationPosition()
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(positionSettings = it.positionSettings.copy(
|
||||||
|
isUpdating = false,
|
||||||
|
messageResId = if (success) R.string.prefs_loc_success else R.string.prefs_loc_gps_error
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setGeoPosition(latitude: Double, longitude: Double) {
|
private fun setGeoPosition(latitude: Double, longitude: Double) {
|
||||||
@@ -194,6 +221,7 @@ class SettingsViewModel(
|
|||||||
it.copy(dataSettings = it.dataSettings.copy(isUpdating = false))
|
it.copy(dataSettings = it.dataSettings.copy(isUpdating = false))
|
||||||
}
|
}
|
||||||
println(exception)
|
println(exception)
|
||||||
|
showToast(R.string.prefs_data_update_failed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,6 +238,65 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region WaveLog(4.5.2)
|
||||||
|
|
||||||
|
// 由 SettingsScreen 注入的共享 uploader(容器级单例)
|
||||||
|
var pendingUploader: WavelogUploader? = null
|
||||||
|
|
||||||
|
// 网格不一致确认弹窗状态(UI 观察后弹 AlertDialog)
|
||||||
|
var gridConfirm by mutableStateOf<GridConfirmData?>(null)
|
||||||
|
|
||||||
|
// WaveLog 错误详情弹窗(UI 观察后弹 AlertDialog, 可一键复制)
|
||||||
|
var wavelogError by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
data class GridConfirmData(val stationGrid: String, val userGrid: String)
|
||||||
|
|
||||||
|
private fun testWavelogConnection() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val s = settingsRepo.otherSettings.value
|
||||||
|
val result = WaveLogApi.testToken(s.wavelogUrl, s.wavelogApiKey, s.wavelogStationId)
|
||||||
|
when (result) {
|
||||||
|
is WavelogResult.Success -> showToast("WaveLog: ${result.message}")
|
||||||
|
is WavelogResult.Failure -> wavelogError = result.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun uploadWavelogQueue() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = pendingUploader?.uploadQueue()
|
||||||
|
when (result) {
|
||||||
|
is UploadOutcome.Done -> {
|
||||||
|
if (result.failedCount > 0 && result.firstError.isNotBlank()) {
|
||||||
|
wavelogError = result.firstError
|
||||||
|
} else {
|
||||||
|
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 +307,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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import com.android.build.api.dsl.LibraryExtension
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.convention.featurePlugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.rtbishop.look4sat.feature.status"
|
||||||
|
compileOptions {
|
||||||
|
encoding = "UTF-8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core:domain"))
|
||||||
|
implementation(project(":core:presentation"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package com.rtbishop.look4sat.feature.status
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
|
// ========== 官网状态色(amsat.org/status 原色) ==========
|
||||||
|
val ActiveBlue = Color(0xFF648FFF)
|
||||||
|
val TlmOrange = Color(0xFFFFB000)
|
||||||
|
val NotHeardPink = Color(0xFFDC267F)
|
||||||
|
val ConflictDeepOrange = Color(0xFFFE6100)
|
||||||
|
val NoReportGray = Color(0xFFC0C0C0)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SatStatusScreen(container: IMainContainer) {
|
||||||
|
val viewModel: SatStatusViewModel = viewModel(
|
||||||
|
factory = viewModelFactory {
|
||||||
|
initializer { SatStatusViewModel(container) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp)) {
|
||||||
|
// 顶部: 图例 + 刷新
|
||||||
|
StatusHeader(
|
||||||
|
fetchedAtUtcMs = uiState.fetchedAtUtcMs,
|
||||||
|
isRefreshing = uiState.isRefreshing,
|
||||||
|
onRefresh = { viewModel.refresh() }
|
||||||
|
)
|
||||||
|
LegendRow()
|
||||||
|
|
||||||
|
when {
|
||||||
|
uiState.isLoading -> {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uiState.error != null && uiState.statuses.isEmpty() -> {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.amsat_load_failed),
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// 表头
|
||||||
|
HeaderRow(statuses = uiState.statuses)
|
||||||
|
HorizontalDivider(thickness = 1.dp)
|
||||||
|
// 行
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(uiState.statuses, key = { it.name }) { status ->
|
||||||
|
StatusRow(
|
||||||
|
status = status,
|
||||||
|
onClickDay = { day -> selectedDay = status to day }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedDay?.let { (status, day) ->
|
||||||
|
ReportDialog(
|
||||||
|
statusName = status.name,
|
||||||
|
day = day,
|
||||||
|
reports = uiState.reports,
|
||||||
|
onDismiss = { selectedDay = null }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 顶部: 更新时间 + 刷新按钮(加载中旋转动画) */
|
||||||
|
@Composable
|
||||||
|
private fun StatusHeader(
|
||||||
|
fetchedAtUtcMs: Long,
|
||||||
|
isRefreshing: Boolean,
|
||||||
|
onRefresh: () -> Unit
|
||||||
|
) {
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "refresh")
|
||||||
|
val angle by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 360f,
|
||||||
|
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Restart),
|
||||||
|
label = "angle"
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (fetchedAtUtcMs > 0) stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs) else stringResource(id = R.string.amsat_title),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(34.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.clickable(onClick = onRefresh),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (isRefreshing) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "↻",
|
||||||
|
fontSize = 20.sp,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.rotate(angle)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 图例(官网四色) */
|
||||||
|
@Composable
|
||||||
|
private fun LegendRow() {
|
||||||
|
val legend = listOf(
|
||||||
|
stringResource(id = R.string.amsat_active) to ActiveBlue,
|
||||||
|
stringResource(id = R.string.amsat_tlm) to TlmOrange,
|
||||||
|
stringResource(id = R.string.amsat_not_heard) to NotHeardPink,
|
||||||
|
stringResource(id = R.string.amsat_conflict) to ConflictDeepOrange
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
legend.forEach { (label, color) ->
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(10.dp)
|
||||||
|
.clip(RoundedCornerShape(2.dp))
|
||||||
|
.background(color)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(3.dp))
|
||||||
|
Text(text = label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表头: 卫星名 + 6 天日期 */
|
||||||
|
@Composable
|
||||||
|
private fun HeaderRow(statuses: List<SatStatus>) {
|
||||||
|
val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList()
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.amsat_name),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.weight(2f).padding(start = 4.dp)
|
||||||
|
)
|
||||||
|
dates.forEach { date ->
|
||||||
|
Text(
|
||||||
|
text = date,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.weight(0.8f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卫星行: 名称 + 6 天色块(官网色 + 报告数) */
|
||||||
|
@Composable
|
||||||
|
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 3.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = status.name,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(2f).padding(start = 4.dp)
|
||||||
|
)
|
||||||
|
status.days.forEach { day ->
|
||||||
|
val slot = day.slots.firstOrNull { it.statusColor != NoReportGray.value.toInt().toLong() } ?: day.slots.first()
|
||||||
|
DayCell(
|
||||||
|
slot = slot,
|
||||||
|
modifier = Modifier.weight(0.8f).padding(horizontal = 1.dp),
|
||||||
|
onClick = { onClickDay(day) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单天色块: 取当天 12 槽中最新的有报告状态; 无报告则灰 */
|
||||||
|
@Composable
|
||||||
|
private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
|
||||||
|
val color = Color(slot.statusColor)
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.height(24.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(color)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (slot.count > 0) {
|
||||||
|
Text(
|
||||||
|
text = slot.count.toString(),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 报告详情弹窗(3 级: 呼号/日期/时间/网格) */
|
||||||
|
@Composable
|
||||||
|
private fun ReportDialog(
|
||||||
|
statusName: String,
|
||||||
|
day: SatDay,
|
||||||
|
reports: Map<String, SatReport>,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val dayReports = day.slots.flatMap { it.reportIds }
|
||||||
|
.mapNotNull { reports[it] }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(text = "$statusName · ${day.dateLabel}") },
|
||||||
|
text = {
|
||||||
|
if (dayReports.isEmpty()) {
|
||||||
|
Text(stringResource(id = R.string.amsat_no_reports))
|
||||||
|
} else {
|
||||||
|
LazyColumn(modifier = Modifier.height(320.dp)) {
|
||||||
|
items(dayReports) { report ->
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(statusColorOf(report.statusText))
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = report.statusText,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "${report.call} ${report.dateUtc} ${report.timeUtc}" +
|
||||||
|
if (report.grid.isNotBlank() && report.grid != "-") " ${report.grid}" else "",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(thickness = 0.5.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(id = R.string.amsat_close)) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun statusColorOf(statusText: String): Color = when {
|
||||||
|
statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) -> ActiveBlue
|
||||||
|
statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) -> TlmOrange
|
||||||
|
statusText.contains("Not Heard", ignoreCase = true) -> NotHeardPink
|
||||||
|
else -> ConflictDeepOrange
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatFetchedAt(utcMs: Long): String {
|
||||||
|
val cal = Calendar.getInstance()
|
||||||
|
cal.timeInMillis = utcMs
|
||||||
|
return "%02d-%02d %02d:%02d".format(
|
||||||
|
cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
|
||||||
|
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
|
||||||
|
)
|
||||||
|
}
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
package com.rtbishop.look4sat.feature.status
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class SatStatusUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val isRefreshing: Boolean = false,
|
||||||
|
val statuses: List<SatStatus> = emptyList(),
|
||||||
|
val reports: Map<String, SatReport> = emptyMap(),
|
||||||
|
val fetchedAtUtcMs: Long = 0L,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
class SatStatusViewModel(
|
||||||
|
private val container: IMainContainer
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
|
||||||
|
val uiState: StateFlow<SatStatusUiState> = _uiState
|
||||||
|
|
||||||
|
init {
|
||||||
|
fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetch() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
|
try {
|
||||||
|
val page = container.amSatRepo.fetchStatus()
|
||||||
|
if (page != null && page.statuses.isNotEmpty()) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isRefreshing = false,
|
||||||
|
statuses = page.statuses,
|
||||||
|
reports = page.reports,
|
||||||
|
fetchedAtUtcMs = page.fetchedAtUtcMs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(isLoading = false, isRefreshing = false, error = exception.message ?: "fetch_failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
if (_uiState.value.isRefreshing) return
|
||||||
|
_uiState.update { it.copy(isRefreshing = true, error = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val page = container.amSatRepo.fetchStatus()
|
||||||
|
if (page != null && page.statuses.isNotEmpty()) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isRefreshing = false,
|
||||||
|
statuses = page.statuses,
|
||||||
|
reports = page.reports,
|
||||||
|
fetchedAtUtcMs = page.fetchedAtUtcMs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
_uiState.update { it.copy(isRefreshing = false, error = exception.message ?: "fetch_failed") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "451"
|
appVersionCode = "454"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.5.1"
|
appVersionName = "4.5.4"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
compileSdk = "37"
|
compileSdk = "37"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
|
|||||||
+2
-1
@@ -28,5 +28,6 @@ include(
|
|||||||
":feature:radar",
|
":feature:radar",
|
||||||
":feature:roaming",
|
":feature:roaming",
|
||||||
":feature:satellites",
|
":feature:satellites",
|
||||||
":feature:settings"
|
":feature:settings",
|
||||||
|
":feature:status"
|
||||||
)
|
)
|
||||||
Reference in new issue
Block a user