mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c35f2418d | ||
|
|
2aa9858660 | ||
|
|
bc6e065804 | ||
|
|
7924a6f69e | ||
|
|
9a2a715d1f | ||
|
|
ee32c7a1eb | ||
|
|
1d54ac4718 | ||
|
|
433ce85561 | ||
|
|
cebe3b7ec4 | ||
|
|
d8f60cf270 |
No files matched your search
@@ -6,10 +6,12 @@
|
||||
|
||||
## 本仓库特色功能
|
||||
|
||||
- **双站过境匹配(Mutual Pass)** — 输入友台经纬度/网格,筛选双方同时可见的卫星过境,在地图上同时显示双方仰角曲线和地面轨迹,支持一键跳转雷达页查看详情
|
||||
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
|
||||
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本
|
||||
- **Passband 模式** — 支持通过位置滑块(Passband)自动计算 TX/RX 频率,避免切换时跳变
|
||||
- **中文界面优化** — 翻译修正、UI 布局调整
|
||||
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放
|
||||
- **中文界面优化** — 翻译修正、UI 布局调整,系统语言自动切换
|
||||
|
||||
## 上游仓库
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.app.Application.ActivityLifecycleCallbacks
|
||||
import android.os.Bundle
|
||||
import com.rtbishop.look4sat.core.data.injection.MainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
@@ -35,12 +38,37 @@ class MainApplication : Application(), IContainerProvider {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = MainContainer(this)
|
||||
clearAmSatCacheOnAppBackground()
|
||||
// trigger automatic update every 48 hours
|
||||
container.appScope.launch { checkAutoUpdate() }
|
||||
// load satellite data on every app start
|
||||
container.appScope.launch { container.satelliteRepo.initRepository() }
|
||||
}
|
||||
|
||||
private fun clearAmSatCacheOnAppBackground() {
|
||||
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
|
||||
private var startedActivityCount = 0
|
||||
|
||||
override fun onActivityStarted(activity: Activity) {
|
||||
if (startedActivityCount == 0) {
|
||||
container.appScope.launch { container.amSatRepo.prefetchStatus() }
|
||||
}
|
||||
startedActivityCount += 1
|
||||
}
|
||||
|
||||
override fun onActivityStopped(activity: Activity) {
|
||||
startedActivityCount = (startedActivityCount - 1).coerceAtLeast(0)
|
||||
if (startedActivityCount == 0) container.amSatRepo.clearStatusCache()
|
||||
}
|
||||
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
|
||||
override fun onActivityResumed(activity: Activity) = Unit
|
||||
override fun onActivityPaused(activity: Activity) = Unit
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
|
||||
override fun onActivityDestroyed(activity: Activity) = Unit
|
||||
})
|
||||
}
|
||||
|
||||
private suspend fun checkAutoUpdate(timeNow: Long = System.currentTimeMillis()) {
|
||||
if (container.settingsRepo.otherSettings.value.stateOfAutoUpdate) {
|
||||
val timeDelta = timeNow - container.settingsRepo.databaseState.value.updateTimestamp
|
||||
|
||||
@@ -51,6 +51,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.draw.clip
|
||||
@@ -91,11 +94,16 @@ import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
||||
fun NavRoot(deeplink: String? = null) {
|
||||
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||
val deeplinkResolver = DeeplinkResolver()
|
||||
var openMapRequest by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(deeplink) {
|
||||
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
||||
}
|
||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
||||
val navigateToMap: () -> Unit = {
|
||||
while (rootBackStack.size > 1) rootBackStack.removeAt(rootBackStack.size - 1)
|
||||
openMapRequest += 1
|
||||
}
|
||||
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
|
||||
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
||||
slideOutHorizontally(tween(300)) { -it / 3 }
|
||||
@@ -114,13 +122,19 @@ fun NavRoot(deeplink: String? = null) {
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) }
|
||||
entry<Screen.Passes> {
|
||||
MainScreen(
|
||||
navigateToRadar = navigateToRadar,
|
||||
openMapRequest = openMapRequest,
|
||||
onOpenMapRequestHandled = { openMapRequest = 0 }
|
||||
)
|
||||
}
|
||||
entry<RadarDestination> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
RadarDestination(navigateUp = navigateBack, navigateToMap = navigateToMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,12 +142,26 @@ fun NavRoot(deeplink: String? = null) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
fun MainScreen(
|
||||
navigateToRadar: () -> Unit = {},
|
||||
openMapRequest: Int = 0,
|
||||
onOpenMapRequestHandled: () -> Unit = {}
|
||||
) {
|
||||
val backStack = rememberNavBackStack(Screen.Passes)
|
||||
val currentKey = backStack.lastOrNull()
|
||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||
val navigateToMap: () -> Unit = {
|
||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||
backStack.add(Screen.Map)
|
||||
}
|
||||
LaunchedEffect(openMapRequest) {
|
||||
if (openMapRequest > 0) {
|
||||
navigateToMap()
|
||||
onOpenMapRequestHandled()
|
||||
}
|
||||
}
|
||||
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Mutual, Screen.Map, Screen.AMSAT, Screen.Settings)
|
||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.AMSAT, Screen.Mutual, Screen.Settings)
|
||||
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
@@ -157,9 +185,9 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
val isSelected = when (currentKey) {
|
||||
is Screen.Satellites -> screen is Screen.Satellites
|
||||
is Screen.Passes -> screen is Screen.Passes
|
||||
is Screen.Mutual -> screen is Screen.Mutual
|
||||
is Screen.Map -> screen is Screen.Map
|
||||
is Screen.Map -> screen is Screen.Passes
|
||||
is Screen.AMSAT -> screen is Screen.AMSAT
|
||||
is Screen.Mutual -> screen is Screen.Mutual
|
||||
is Screen.Settings -> screen is Screen.Settings
|
||||
else -> false
|
||||
}
|
||||
@@ -168,7 +196,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
label = { Text(stringResource(screen.titleResId)) },
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
if (isSelected) return@item
|
||||
if (isSelected && !(currentKey is Screen.Map && screen is Screen.Passes)) return@item
|
||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||
if (screen !is Screen.Passes) backStack.add(screen)
|
||||
}
|
||||
@@ -203,11 +231,14 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
SatellitesDestination(navigateUp = navigateBack)
|
||||
}
|
||||
entry<Screen.Passes> {
|
||||
PassesDestination { catNum, aosTime ->
|
||||
container.setMutualPassData(MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
navigateToRadar()
|
||||
}
|
||||
PassesDestination(
|
||||
navigateToRadar = { catNum, aosTime ->
|
||||
container.setMutualPassData(MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
navigateToRadar()
|
||||
},
|
||||
navigateToMap = navigateToMap
|
||||
)
|
||||
}
|
||||
entry<Screen.Map> {
|
||||
MapDestination()
|
||||
@@ -215,7 +246,6 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
entry<Screen.Mutual> {
|
||||
MutualScreen(
|
||||
viewModel = mutualViewModel,
|
||||
navigateUp = navigateBack,
|
||||
navigateToRadar = { catNum, aosTime, pass ->
|
||||
container.setMutualPassData(pass ?: MutualPassData())
|
||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||
|
||||
@@ -78,7 +78,7 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
override val selectionRepo = provideSelectionRepo()
|
||||
override val satelliteRepo = provideSatelliteRepo()
|
||||
override val databaseRepo = provideDatabaseRepo()
|
||||
override val amSatRepo by lazy { AmSatRepository(remoteSource) }
|
||||
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
|
||||
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||
|
||||
+115
-9
@@ -1,5 +1,7 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
|
||||
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||
@@ -7,11 +9,18 @@ import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
@@ -26,28 +35,125 @@ private data class ApiReport(
|
||||
)
|
||||
|
||||
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
|
||||
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
|
||||
class AmSatRepository(
|
||||
private val remoteSource: IRemoteSource,
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
) : IAmSatRepository {
|
||||
|
||||
private val statusCacheMutex = Mutex()
|
||||
|
||||
@Volatile
|
||||
private var statusCache: SatStatusPage? = null
|
||||
|
||||
@Volatile
|
||||
private var statusFetchInFlight: Deferred<SatStatusPage?>? = null
|
||||
|
||||
@Volatile
|
||||
private var cacheGeneration = 0
|
||||
|
||||
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
|
||||
override fun getCachedStatus(): SatStatusPage? = statusCache
|
||||
|
||||
override suspend fun fetchStatus(forceRefresh: Boolean): SatStatusPage? {
|
||||
if (!forceRefresh) statusCache?.let { return it }
|
||||
|
||||
var cachedPage: SatStatusPage? = null
|
||||
val inFlightFetch = statusCacheMutex.withLock {
|
||||
if (!forceRefresh) {
|
||||
val cached = statusCache
|
||||
if (cached != null) {
|
||||
cachedPage = cached
|
||||
return@withLock null
|
||||
}
|
||||
}
|
||||
|
||||
statusFetchInFlight ?: scope.async(Dispatchers.IO) {
|
||||
fetchStatusFromRemote(cacheGeneration)
|
||||
}.also { statusFetchInFlight = it }
|
||||
}
|
||||
|
||||
cachedPage?.let { return it }
|
||||
val fetch = inFlightFetch ?: return null
|
||||
return try {
|
||||
fetch.await()
|
||||
} finally {
|
||||
statusCacheMutex.withLock {
|
||||
if (statusFetchInFlight === fetch && fetch.isCompleted) statusFetchInFlight = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchStatusFromRemote(generation: Int): SatStatusPage? {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
|
||||
val catalogJson = remoteSource.getAmSatCatalog() ?: return null
|
||||
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
|
||||
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
|
||||
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
|
||||
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return@withContext null
|
||||
|
||||
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return null
|
||||
|
||||
val names = parseCatalog(catalogJson)
|
||||
val reports = parseReports(reportsJson)
|
||||
|
||||
if (names.isEmpty() && reports.isEmpty()) return@withContext null
|
||||
|
||||
|
||||
if (names.isEmpty() && reports.isEmpty()) return null
|
||||
|
||||
val statuses = buildStatuses(names, reports, nowSec)
|
||||
val reportMap = reports.associate { it.id to toSatReport(it) }
|
||||
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
|
||||
return SatStatusPage(System.currentTimeMillis(), statuses, reportMap).also { page ->
|
||||
if (generation == cacheGeneration) statusCache = page
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun prefetchStatus() {
|
||||
fetchStatus(forceRefresh = false)
|
||||
}
|
||||
|
||||
override fun clearStatusCache() {
|
||||
cacheGeneration += 1
|
||||
statusCache = null
|
||||
statusFetchInFlight = null
|
||||
}
|
||||
|
||||
override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
|
||||
val payload = JSONObject().apply {
|
||||
put("name", submission.name)
|
||||
put("report", submission.report)
|
||||
put("callsign", submission.callsign)
|
||||
put("reported_at", isoUtcFormat.format(Date(submission.reportedAtUtcMillis)))
|
||||
if (submission.gridSquare.isNotBlank()) put("grid_square", submission.gridSquare)
|
||||
}
|
||||
val response = remoteSource.submitAmSatReport(payload.toString())
|
||||
?: return@withContext AmSatReportSubmitResult(success = false, message = "Network request failed")
|
||||
val (code, body) = response
|
||||
val errorMessage = parseSubmitError(body)
|
||||
return@withContext if (code in 200..299 && errorMessage.isBlank()) {
|
||||
AmSatReportSubmitResult(success = true, reportId = parseSubmitReportId(body))
|
||||
} else {
|
||||
AmSatReportSubmitResult(
|
||||
success = false,
|
||||
message = errorMessage.ifBlank { "HTTP $code" }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSubmitError(json: String): String {
|
||||
return try {
|
||||
JSONObject(json).optJSONObject("error")?.optString("message").orEmpty()
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSubmitReportId(json: String): String? {
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
obj.optJSONObject("data")?.optString("id")?.takeIf { it.isNotBlank() }
|
||||
?: obj.optString("id").takeIf { it.isNotBlank() }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse catalog JSON to list of satellite names */
|
||||
|
||||
@@ -37,6 +37,8 @@ import com.rtbishop.look4sat.core.domain.utility.round
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.json.JSONObject
|
||||
import java.util.Locale
|
||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
|
||||
@@ -474,4 +476,55 @@ class SettingsRepo(
|
||||
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
||||
)
|
||||
//endregion
|
||||
|
||||
//region # Per-satellite calculator offset settings
|
||||
private val keySatelliteOffsets = "satelliteOffsets"
|
||||
private val keyLegacySatelliteOffsetPrefix = "offset_khz_"
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String {
|
||||
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||
val stored = try {
|
||||
JSONObject(json).optString(catnum.toString(), "")
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
if (stored.isNotEmpty()) return stored
|
||||
|
||||
// Lazy migration from the fork's old UI-layer implementation, which stored
|
||||
// one SharedPreferences entry per satellite directly from TransceiversPage.
|
||||
val legacyKey = "$keyLegacySatelliteOffsetPrefix$catnum"
|
||||
val legacy = preferences.getString(legacyKey, "").orEmpty()
|
||||
if (legacy.isNotEmpty()) {
|
||||
setSatelliteOffset(catnum, legacy)
|
||||
}
|
||||
return legacy
|
||||
}
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) {
|
||||
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||
val updated = try {
|
||||
val obj = JSONObject(json)
|
||||
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
|
||||
obj.toString()
|
||||
} catch (_: Exception) {
|
||||
if (offset.isEmpty()) "{}" else """{"$catnum": "$offset"}"""
|
||||
}
|
||||
preferences.edit {
|
||||
putString(keySatelliteOffsets, updated)
|
||||
remove("$keyLegacySatelliteOffsetPrefix$catnum")
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # AMSAT status report settings
|
||||
private val keyAmSatCallsign = "amSatCallsign"
|
||||
|
||||
override fun getAmSatCallsign(): String {
|
||||
return preferences.getString(keyAmSatCallsign, "").orEmpty()
|
||||
}
|
||||
|
||||
override fun setAmSatCallsign(callsign: String) {
|
||||
preferences.edit { putString(keyAmSatCallsign, callsign.trim().uppercase(Locale.US)) }
|
||||
}
|
||||
//endregion
|
||||
}
|
||||
@@ -22,8 +22,10 @@ import androidx.core.net.toUri
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.InputStream
|
||||
|
||||
class RemoteSource(
|
||||
@@ -90,4 +92,21 @@ class RemoteSource(
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = withContext(dispatcher) {
|
||||
try {
|
||||
val body = payloadJson.toRequestBody("application/json; charset=utf-8".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("https://www.amsat.org/status/api/v1/reports.php")
|
||||
.header("User-Agent", "Look4Sat")
|
||||
.post(body)
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
response.code to response.body.string()
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
println("RemoteSource submitAmSatReport exception: $exception")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
import java.io.InputStream
|
||||
|
||||
class AmSatRepositoryTest {
|
||||
|
||||
@Test
|
||||
fun fetchStatusReturnsSeededCacheUntilCacheIsCleared() = runTest {
|
||||
val remoteSource = FakeAmSatRemoteSource()
|
||||
val repository = AmSatRepository(remoteSource)
|
||||
val cachedPage = SatStatusPage(
|
||||
fetchedAtUtcMs = 123L,
|
||||
statuses = listOf(SatStatus(name = "AO-7", days = emptyList())),
|
||||
reports = emptyMap()
|
||||
)
|
||||
repository.seedStatusCache(cachedPage)
|
||||
|
||||
val firstPage = repository.fetchStatus()
|
||||
val secondPage = repository.fetchStatus()
|
||||
|
||||
assertSame(cachedPage, firstPage)
|
||||
assertSame(cachedPage, secondPage)
|
||||
assertSame(cachedPage, repository.getCachedStatus())
|
||||
assertEquals(0, remoteSource.catalogRequests)
|
||||
assertEquals(0, remoteSource.reportRequests)
|
||||
|
||||
repository.clearStatusCache()
|
||||
assertNull(repository.getCachedStatus())
|
||||
repository.fetchStatus()
|
||||
|
||||
assertEquals(1, remoteSource.catalogRequests)
|
||||
assertEquals(1, remoteSource.reportRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forceRefreshBypassesCachedPage() = runTest {
|
||||
val remoteSource = FakeAmSatRemoteSource()
|
||||
val repository = AmSatRepository(remoteSource)
|
||||
repository.seedStatusCache(
|
||||
SatStatusPage(
|
||||
fetchedAtUtcMs = 123L,
|
||||
statuses = listOf(SatStatus(name = "AO-7", days = emptyList())),
|
||||
reports = emptyMap()
|
||||
)
|
||||
)
|
||||
|
||||
repository.fetchStatus(forceRefresh = true)
|
||||
|
||||
assertEquals(1, remoteSource.catalogRequests)
|
||||
assertEquals(1, remoteSource.reportRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchStatusSharesStartupPrefetchRequest() = runTest {
|
||||
val remoteSource = FakeAmSatRemoteSource(responseDelayMillis = 50)
|
||||
val repository = AmSatRepository(remoteSource)
|
||||
|
||||
val prefetch = async { repository.prefetchStatus() }
|
||||
val pageFetch = async { repository.fetchStatus() }
|
||||
prefetch.await()
|
||||
val page = pageFetch.await()
|
||||
|
||||
assertSame(page, repository.getCachedStatus())
|
||||
assertEquals(1, remoteSource.catalogRequests)
|
||||
assertEquals(1, remoteSource.reportRequests)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
|
||||
val cacheField = AmSatRepository::class.java.getDeclaredField("statusCache")
|
||||
cacheField.isAccessible = true
|
||||
cacheField.set(this, page)
|
||||
}
|
||||
|
||||
private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L) : IRemoteSource {
|
||||
var catalogRequests = 0
|
||||
var reportRequests = 0
|
||||
|
||||
override suspend fun getFileStream(uri: String): InputStream? = null
|
||||
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = null
|
||||
|
||||
override suspend fun getAmSatCatalog(): String? {
|
||||
if (responseDelayMillis > 0L) delay(responseDelayMillis)
|
||||
catalogRequests += 1
|
||||
return """{"data":[{"name":"AO-7"}]}"""
|
||||
}
|
||||
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? {
|
||||
if (responseDelayMillis > 0L) delay(responseDelayMillis)
|
||||
reportRequests += 1
|
||||
return """{"data":[]}"""
|
||||
}
|
||||
|
||||
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
||||
}
|
||||
+10
@@ -126,6 +126,8 @@ private class FakeRemoteSource : IRemoteSource {
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||
|
||||
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
||||
}
|
||||
|
||||
private class FakeLocalSource : ILocalSource {
|
||||
@@ -224,6 +226,14 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
}
|
||||
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
|
||||
override fun setAmSatCallsign(callsign: String) = Unit
|
||||
}
|
||||
|
||||
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.rtbishop.look4sat.core.domain.model
|
||||
|
||||
/** One public AMSAT satellite status report submission. */
|
||||
data class AmSatReportSubmission(
|
||||
val name: String,
|
||||
val report: String,
|
||||
val callsign: String,
|
||||
val gridSquare: String,
|
||||
val reportedAtUtcMillis: Long
|
||||
)
|
||||
|
||||
/** Result of submitting an AMSAT satellite status report. */
|
||||
data class AmSatReportSubmitResult(
|
||||
val success: Boolean,
|
||||
val message: String = "",
|
||||
val reportId: String? = null
|
||||
)
|
||||
+16
-2
@@ -1,9 +1,23 @@
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
|
||||
/** AMSAT satellite status data source */
|
||||
interface IAmSatRepository {
|
||||
/** Fetch and parse the AMSAT status page; null on failure */
|
||||
suspend fun fetchStatus(): SatStatusPage?
|
||||
/** Cached AMSAT status page for the current foreground session, if any. */
|
||||
fun getCachedStatus(): SatStatusPage?
|
||||
|
||||
/** Fetch and parse the AMSAT status page; null on failure. */
|
||||
suspend fun fetchStatus(forceRefresh: Boolean = false): SatStatusPage?
|
||||
|
||||
/** Warm the foreground-session cache without forcing a network reload. */
|
||||
suspend fun prefetchStatus()
|
||||
|
||||
/** Clear the foreground-session status cache. */
|
||||
fun clearStatusCache()
|
||||
|
||||
/** Submit a public AMSAT satellite status report. */
|
||||
suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult
|
||||
}
|
||||
+10
@@ -77,4 +77,14 @@ interface ISettingsRepo {
|
||||
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||
//endregion
|
||||
|
||||
//region # Per-satellite calculator offset settings
|
||||
fun getSatelliteOffset(catnum: Int): String
|
||||
fun setSatelliteOffset(catnum: Int, offset: String)
|
||||
//endregion
|
||||
|
||||
//region # AMSAT status report settings
|
||||
fun getAmSatCallsign(): String
|
||||
fun setAmSatCallsign(callsign: String)
|
||||
//endregion
|
||||
}
|
||||
@@ -24,4 +24,5 @@ interface IRemoteSource {
|
||||
suspend fun getNetworkStream(url: String): InputStream?
|
||||
suspend fun getAmSatCatalog(): String?
|
||||
suspend fun getAmSatReports(hours: Int, limit: Int): String?
|
||||
suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>?
|
||||
}
|
||||
@@ -285,6 +285,7 @@ fun InfoDialog(
|
||||
title: String,
|
||||
onDismiss: () -> Unit,
|
||||
onAccept: () -> Unit,
|
||||
extraAction: (@Composable () -> Unit)? = null,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
DialogShell(onDismissRequest = onDismiss) { padding ->
|
||||
@@ -303,6 +304,10 @@ fun InfoDialog(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
extraAction?.let {
|
||||
it()
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||
}
|
||||
content()
|
||||
|
||||
@@ -53,6 +53,26 @@
|
||||
<string name="amsat_load_failed">加载失败 - 检查网络后重试</string>
|
||||
<string name="amsat_no_reports">此时间段无报告</string>
|
||||
<string name="amsat_close">关闭</string>
|
||||
<string name="amsat_upload">上传</string>
|
||||
<string name="amsat_upload_back">返回</string>
|
||||
<string name="amsat_upload_report">上传报告到 AMSAT</string>
|
||||
<string name="amsat_upload_status">状态</string>
|
||||
<string name="amsat_upload_callsign">呼号</string>
|
||||
<string name="amsat_upload_grid">网格</string>
|
||||
<string name="amsat_upload_time_now">时间:当前 UTC %s</string>
|
||||
<string name="amsat_upload_submit">提交到 AMSAT</string>
|
||||
<string name="amsat_upload_confirm">确认上传</string>
|
||||
<string name="amsat_upload_confirm_title">确认上传到 AMSAT</string>
|
||||
<string name="amsat_upload_confirm_message">这条公开报告将上传到 AMSAT。\n卫星:%1$s\n状态:%2$s\n呼号:%3$s\n网格:%4$s</string>
|
||||
<string name="amsat_upload_grid_none">未填写</string>
|
||||
<string name="amsat_upload_success">报告已上传</string>
|
||||
<string name="amsat_upload_failed">上传失败:%s</string>
|
||||
<string name="amsat_upload_callsign_required">请填写呼号</string>
|
||||
<string name="amsat_upload_grid_invalid">网格应为 4 或 6 位 Maidenhead 定位符</string>
|
||||
<string name="amsat_upload_active">活跃</string>
|
||||
<string name="amsat_upload_tlm">遥测/信标</string>
|
||||
<string name="amsat_upload_not_heard">未听到</string>
|
||||
<string name="amsat_upload_crew_active">ISS 语音</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">后退</string>
|
||||
|
||||
@@ -70,6 +70,26 @@
|
||||
<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>
|
||||
<string name="amsat_upload">Upload</string>
|
||||
<string name="amsat_upload_back">Back</string>
|
||||
<string name="amsat_upload_report">Upload report to AMSAT</string>
|
||||
<string name="amsat_upload_status">Status</string>
|
||||
<string name="amsat_upload_callsign">Callsign</string>
|
||||
<string name="amsat_upload_grid">Grid</string>
|
||||
<string name="amsat_upload_time_now">Time: current UTC %s</string>
|
||||
<string name="amsat_upload_submit">Submit to AMSAT</string>
|
||||
<string name="amsat_upload_confirm">Upload</string>
|
||||
<string name="amsat_upload_confirm_title">Confirm AMSAT upload</string>
|
||||
<string name="amsat_upload_confirm_message">This public report will be uploaded to AMSAT.\nSatellite: %1$s\nStatus: %2$s\nCallsign: %3$s\nGrid: %4$s</string>
|
||||
<string name="amsat_upload_grid_none">Not provided</string>
|
||||
<string name="amsat_upload_success">Report uploaded</string>
|
||||
<string name="amsat_upload_failed">Upload failed: %s</string>
|
||||
<string name="amsat_upload_callsign_required">Callsign is required</string>
|
||||
<string name="amsat_upload_grid_invalid">Grid must be a 4 or 6 character Maidenhead locator</string>
|
||||
<string name="amsat_upload_active">Active</string>
|
||||
<string name="amsat_upload_tlm">TLM/Beacon</string>
|
||||
<string name="amsat_upload_not_heard">Not Heard</string>
|
||||
<string name="amsat_upload_crew_active">Crew Active</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">Back</string>
|
||||
|
||||
@@ -63,7 +63,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||
import com.rtbishop.look4sat.core.domain.repository.TrackSampleData
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
@@ -75,7 +74,6 @@ import java.util.Locale
|
||||
@Composable
|
||||
fun MutualScreen(
|
||||
viewModel: MutualViewModel,
|
||||
navigateUp: () -> Unit = {},
|
||||
navigateToRadar: (Int, Long, MutualPassData?) -> Unit = { _, _, _ -> },
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -85,9 +83,7 @@ fun MutualScreen(
|
||||
topBar = { isVertical ->
|
||||
TopBar(
|
||||
isVerticalLayout = isVertical,
|
||||
startAction = {
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
},
|
||||
startAction = {},
|
||||
topInfo = {
|
||||
Text(
|
||||
text = stringResource(R.string.mutual_title),
|
||||
|
||||
@@ -84,19 +84,23 @@ import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
@Composable
|
||||
fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) {
|
||||
fun PassesDestination(
|
||||
navigateToRadar: (Int, Long) -> Unit,
|
||||
navigateToMap: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
|
||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||
PassesScreen(uiState, viewModel::onAction, navigateToRadar)
|
||||
PassesScreen(uiState, viewModel::onAction, navigateToRadar, navigateToMap)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PassesScreen(
|
||||
uiState: PassesState,
|
||||
onAction: (PassesAction) -> Unit,
|
||||
navigateToRadar: (Int, Long) -> Unit
|
||||
navigateToRadar: (Int, Long) -> Unit,
|
||||
navigateToMap: () -> Unit
|
||||
) {
|
||||
if (uiState.isPassesDialogShown) {
|
||||
PassesFilterDialog(
|
||||
@@ -163,7 +167,7 @@ private fun PassesScreen(
|
||||
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
||||
},
|
||||
endAction = {
|
||||
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
|
||||
IconCard(action = navigateToMap, resId = R.drawable.ic_map)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ private enum class RadarPage(val title: String) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RadarDestination(navigateUp: () -> Unit) {
|
||||
fun RadarDestination(navigateUp: () -> Unit, navigateToMap: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
||||
@@ -96,6 +96,12 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
||||
}
|
||||
navigateUp()
|
||||
}
|
||||
val navigateToMapAndClearMutual = {
|
||||
if (container.mutualPassData.value.endTime > 0L) {
|
||||
container.setMutualPassData(MutualPassData())
|
||||
}
|
||||
navigateToMap()
|
||||
}
|
||||
LaunchedEffect(mutualData.endTime) {
|
||||
if (mutualData.endTime <= 0L) return@LaunchedEffect
|
||||
while (true) {
|
||||
@@ -121,7 +127,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
||||
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
||||
viewModel.onAction(RadarAction.CwPermissionResult(granted))
|
||||
}
|
||||
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = {
|
||||
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, navigateToMapAndClearMutual, mutualData, requestMicPermission = {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
})
|
||||
}
|
||||
@@ -131,13 +137,11 @@ private fun RadarScreen(
|
||||
uiState: RadarState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
navigateUp: () -> Unit,
|
||||
navigateToMap: () -> Unit,
|
||||
mutualData: MutualPassData,
|
||||
requestMicPermission: () -> Unit
|
||||
) {
|
||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||
val addToCalendar: () -> Unit = {
|
||||
uiState.currentPass?.let { onAction(RadarAction.AddToCalendar(it.name, it.aosTime, it.losTime)) }
|
||||
}
|
||||
// Station-B overlay: full track line (only where B's elevation > 0) + live position dot
|
||||
// at the current moment, same display mode as the local station.
|
||||
val trackB = remember(mutualData.trackSamples) {
|
||||
@@ -173,7 +177,7 @@ private fun RadarScreen(
|
||||
TopBar {
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||
IconCard(action = navigateToMap, resId = R.drawable.ic_map)
|
||||
}
|
||||
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
|
||||
} else {
|
||||
@@ -181,7 +185,7 @@ private fun RadarScreen(
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
|
||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||
IconCard(action = navigateToMap, resId = R.drawable.ic_map)
|
||||
}
|
||||
}
|
||||
if (isVertical) {
|
||||
@@ -249,6 +253,7 @@ private fun PagerCard(
|
||||
selectedUuid = uiState.transceivers.selectedUuid,
|
||||
orbitalPos = uiState.orbitalPos,
|
||||
cw = uiState.cw,
|
||||
calculatorOffsetKHz = uiState.calculatorOffsetKHz,
|
||||
onAction = onAction,
|
||||
requestMicPermission = requestMicPermission
|
||||
)
|
||||
|
||||
@@ -69,7 +69,8 @@ data class RadarState(
|
||||
val mutualEndTime: Long = 0L,
|
||||
val mutualMaxElev: Double = 10.0,
|
||||
val mutualLabelA: String = "你",
|
||||
val mutualLabelB: String = "友台"
|
||||
val mutualLabelB: String = "友台",
|
||||
val calculatorOffsetKHz: String = ""
|
||||
)
|
||||
|
||||
enum class SstvStatus { Idle, Recording }
|
||||
@@ -124,4 +125,7 @@ sealed interface RadarAction {
|
||||
data class CwSetToneFreq(val freq: Float) : RadarAction
|
||||
data class CwToggleExpanded(val expanded: Boolean) : RadarAction
|
||||
data class CwPermissionResult(val granted: Boolean) : RadarAction
|
||||
|
||||
// Calculator actions
|
||||
data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction
|
||||
}
|
||||
@@ -151,7 +151,13 @@ class RadarViewModel(
|
||||
transponders = allRadios.filter { it.downlinkLow != null }
|
||||
if (allRadios.isNotEmpty()) {
|
||||
val firstUuid = allRadios.first().uuid
|
||||
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = firstUuid)) }
|
||||
val offsetKHz = allRadios.first().catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
transceivers = it.transceivers.copy(selectedUuid = firstUuid),
|
||||
calculatorOffsetKHz = offsetKHz
|
||||
)
|
||||
}
|
||||
transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) }
|
||||
}
|
||||
if (!pass.isDeepSpace) {
|
||||
@@ -228,7 +234,16 @@ class RadarViewModel(
|
||||
// Compute toggle state before the update so we don't read post-update value
|
||||
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
|
||||
val newUuid = if (isTogglingOff) null else action.uuid
|
||||
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = newUuid)) }
|
||||
val offsetKHz = if (!isTogglingOff) {
|
||||
transponders.find { it.uuid == action.uuid }
|
||||
?.catnum?.let { settingsRepo.getSatelliteOffset(it) } ?: ""
|
||||
} else ""
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
transceivers = it.transceivers.copy(selectedUuid = newUuid),
|
||||
calculatorOffsetKHz = offsetKHz
|
||||
)
|
||||
}
|
||||
// Only update the tracking service when selecting a different transponder to
|
||||
// avoid resetting a user-adjusted TX base on re-expand
|
||||
if (!isTogglingOff) {
|
||||
@@ -296,6 +311,15 @@ class RadarViewModel(
|
||||
is RadarAction.CwToggleExpanded -> {
|
||||
_uiState.update { it.copy(cw = it.cw.copy(isExpanded = action.expanded)) }
|
||||
}
|
||||
is RadarAction.ChangeCalculatorOffset -> {
|
||||
val catnum = _uiState.value.transceivers.selectedUuid?.let { uuid ->
|
||||
transponders.find { it.uuid == uuid }?.catnum
|
||||
}
|
||||
if (catnum != null) {
|
||||
settingsRepo.setSatelliteOffset(catnum, action.offsetKHz)
|
||||
}
|
||||
_uiState.update { it.copy(calculatorOffsetKHz = action.offsetKHz) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-21
@@ -18,7 +18,6 @@
|
||||
package com.rtbishop.look4sat.feature.radar
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
@@ -145,6 +144,7 @@ fun CalculatorPage(
|
||||
selectedUuid: String?,
|
||||
orbitalPos: OrbitalPos?,
|
||||
cw: CwSubState,
|
||||
calculatorOffsetKHz: String = "",
|
||||
onAction: (RadarAction) -> Unit,
|
||||
requestMicPermission: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
@@ -196,6 +196,8 @@ fun CalculatorPage(
|
||||
DopplerFrequencyCalculator(
|
||||
transponder = selectedTransceiver,
|
||||
orbitalPos = orbitalPos,
|
||||
offsetKHz = calculatorOffsetKHz,
|
||||
onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
@@ -548,6 +550,8 @@ private enum class EditedField { TX, PASSBAND, RX }
|
||||
private fun DopplerFrequencyCalculator(
|
||||
transponder: SatRadio,
|
||||
orbitalPos: OrbitalPos?,
|
||||
offsetKHz: String = "",
|
||||
onOffsetChange: (String) -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
|
||||
@@ -556,17 +560,6 @@ private fun DopplerFrequencyCalculator(
|
||||
var txFrequencyHz by rememberSaveable(transponder.uuid) { mutableStateOf(0L) }
|
||||
var rxFrequencyHz by rememberSaveable(transponder.uuid) { mutableStateOf(0L) }
|
||||
var passbandPosition by rememberSaveable(transponder.uuid) { mutableStateOf(0.5f) }
|
||||
// 每个卫星独立记忆 offset:以 NORAD 编号为 key,存到应用 SharedPreferences
|
||||
val context = LocalContext.current
|
||||
val catnum = transponder.catnum
|
||||
var offsetKHz by remember(catnum) {
|
||||
mutableStateOf(
|
||||
catnum?.let { cn ->
|
||||
context.getSharedPreferences(context.packageName + "_preferences", Context.MODE_PRIVATE)
|
||||
.getString("offset_khz_$cn", "") ?: ""
|
||||
} ?: ""
|
||||
)
|
||||
}
|
||||
var stepSizeKHz by remember { mutableIntStateOf(1) }
|
||||
|
||||
val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
|
||||
@@ -752,15 +745,7 @@ private fun DopplerFrequencyCalculator(
|
||||
) {
|
||||
BasicTextField(
|
||||
value = offsetKHz,
|
||||
onValueChange = { newValue ->
|
||||
offsetKHz = newValue
|
||||
catnum?.let { cn ->
|
||||
context.getSharedPreferences(
|
||||
context.packageName + "_preferences",
|
||||
Context.MODE_PRIVATE
|
||||
).edit().putString("offset_khz_$cn", newValue).apply()
|
||||
}
|
||||
},
|
||||
onValueChange = onOffsetChange,
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
|
||||
+304
-7
@@ -1,5 +1,6 @@
|
||||
package com.rtbishop.look4sat.feature.status
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -20,12 +21,18 @@ 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.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -44,6 +51,9 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||
@@ -51,14 +61,32 @@ 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.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/** Fixed width per day tile — tablet-safe; name column absorbs remaining space. */
|
||||
private val TILE_WIDTH: Dp = 64.dp
|
||||
|
||||
private data class UploadOption(
|
||||
val apiValue: String,
|
||||
val labelResId: Int,
|
||||
val color: Color
|
||||
)
|
||||
|
||||
private val UPLOAD_OPTIONS = listOf(
|
||||
UploadOption(AMSAT_REPORT_HEARD, R.string.amsat_upload_active, Color(0xFF648FFF)),
|
||||
UploadOption(AMSAT_REPORT_TELEMETRY_ONLY, R.string.amsat_upload_tlm, Color(0xFFFFB000)),
|
||||
UploadOption(AMSAT_REPORT_NOT_HEARD, R.string.amsat_upload_not_heard, Color(0xFFDC267F)),
|
||||
UploadOption(AMSAT_REPORT_CREW_ACTIVE, R.string.amsat_upload_crew_active, Color(0xFFFE6100))
|
||||
)
|
||||
|
||||
/**
|
||||
* Map AMSAT status text to Material3 colorScheme colors.
|
||||
* Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants.
|
||||
@@ -82,12 +110,49 @@ fun SatStatusDestination() {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
SatStatusScreen(uiState) { viewModel.refresh() }
|
||||
DisposableEffect(lifecycleOwner, viewModel) {
|
||||
var skipInitialStart = true
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_START) {
|
||||
if (skipInitialStart) {
|
||||
skipInitialStart = false
|
||||
} else {
|
||||
viewModel.fetch()
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
SatStatusScreen(
|
||||
uiState = uiState,
|
||||
refresh = viewModel::refresh,
|
||||
onToggleUpload = viewModel::toggleUploadPanel,
|
||||
onUploadReportChange = viewModel::setUploadReport,
|
||||
onUploadCallsignChange = viewModel::setUploadCallsign,
|
||||
onUploadGridChange = viewModel::setUploadGrid,
|
||||
onRequestSubmitUpload = viewModel::requestSubmitConfirmation,
|
||||
onConfirmSubmitUpload = viewModel::confirmSubmitReport,
|
||||
onDismissSubmitConfirmation = viewModel::dismissSubmitConfirmation,
|
||||
onDismissUpload = viewModel::collapseUploadPanel
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
|
||||
private fun SatStatusScreen(
|
||||
uiState: SatStatusUiState,
|
||||
refresh: () -> Unit,
|
||||
onToggleUpload: () -> Unit,
|
||||
onUploadReportChange: (String) -> Unit,
|
||||
onUploadCallsignChange: (String) -> Unit,
|
||||
onUploadGridChange: (String) -> Unit,
|
||||
onRequestSubmitUpload: () -> Unit,
|
||||
onConfirmSubmitUpload: (String) -> Unit,
|
||||
onDismissSubmitConfirmation: () -> Unit,
|
||||
onDismissUpload: () -> Unit
|
||||
) {
|
||||
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
|
||||
Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
|
||||
StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh)
|
||||
@@ -122,7 +187,23 @@ private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
|
||||
}
|
||||
|
||||
selectedDay?.let { (status, day) ->
|
||||
ReportDialog(statusName = status.name, day = day, reports = uiState.reports, onDismiss = { selectedDay = null })
|
||||
ReportDialog(
|
||||
statusName = status.name,
|
||||
day = day,
|
||||
reports = uiState.reports,
|
||||
upload = uiState.upload,
|
||||
onToggleUpload = onToggleUpload,
|
||||
onReportChange = onUploadReportChange,
|
||||
onCallsignChange = onUploadCallsignChange,
|
||||
onGridChange = onUploadGridChange,
|
||||
onRequestSubmitReport = onRequestSubmitUpload,
|
||||
onConfirmSubmitReport = { onConfirmSubmitUpload(status.name) },
|
||||
onDismissSubmitConfirmation = onDismissSubmitConfirmation,
|
||||
onDismiss = {
|
||||
selectedDay = null
|
||||
onDismissUpload()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,11 +359,55 @@ private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
|
||||
|
||||
/** Report detail dialog (callsign / date / time / grid) */
|
||||
@Composable
|
||||
private fun ReportDialog(statusName: String, day: SatDay, reports: Map<String, SatReport>, onDismiss: () -> Unit) {
|
||||
private fun ReportDialog(
|
||||
statusName: String,
|
||||
day: SatDay,
|
||||
reports: Map<String, SatReport>,
|
||||
upload: AmSatUploadUiState,
|
||||
onToggleUpload: () -> Unit,
|
||||
onReportChange: (String) -> Unit,
|
||||
onCallsignChange: (String) -> Unit,
|
||||
onGridChange: (String) -> Unit,
|
||||
onRequestSubmitReport: () -> Unit,
|
||||
onConfirmSubmitReport: () -> Unit,
|
||||
onDismissSubmitConfirmation: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
|
||||
InfoDialog(title = "$statusName · ${day.dateLabel}", onDismiss = onDismiss, onAccept = onDismiss) {
|
||||
InfoDialog(
|
||||
title = "$statusName · ${day.dateLabel}",
|
||||
onDismiss = onDismiss,
|
||||
onAccept = onDismiss,
|
||||
extraAction = {
|
||||
CardButton(
|
||||
onClick = onToggleUpload,
|
||||
text = stringResource(if (upload.isExpanded) R.string.amsat_upload_back else R.string.amsat_upload)
|
||||
)
|
||||
}
|
||||
) {
|
||||
AnimatedVisibility(visible = upload.isExpanded) {
|
||||
AmSatUploadPanel(
|
||||
statusName = statusName,
|
||||
upload = upload,
|
||||
onReportChange = onReportChange,
|
||||
onCallsignChange = onCallsignChange,
|
||||
onGridChange = onGridChange,
|
||||
onRequestSubmitReport = onRequestSubmitReport
|
||||
)
|
||||
}
|
||||
if (upload.isConfirmingSubmit) {
|
||||
AmSatUploadConfirmDialog(
|
||||
statusName = statusName,
|
||||
upload = upload,
|
||||
onConfirm = onConfirmSubmitReport,
|
||||
onDismiss = onDismissSubmitConfirmation
|
||||
)
|
||||
}
|
||||
if (dayReports.isEmpty()) {
|
||||
Text(stringResource(id = R.string.amsat_no_reports))
|
||||
Text(
|
||||
text = stringResource(id = R.string.amsat_no_reports),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) {
|
||||
@@ -312,6 +437,171 @@ private fun ReportDialog(statusName: String, day: SatDay, reports: Map<String, S
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AmSatUploadPanel(
|
||||
statusName: String,
|
||||
upload: AmSatUploadUiState,
|
||||
onReportChange: (String) -> Unit,
|
||||
onCallsignChange: (String) -> Unit,
|
||||
onGridChange: (String) -> Unit,
|
||||
onRequestSubmitReport: () -> Unit
|
||||
) {
|
||||
val options = remember(statusName) {
|
||||
if (statusName.startsWith("ISS")) UPLOAD_OPTIONS else UPLOAD_OPTIONS.filter { it.apiValue != AMSAT_REPORT_CREW_ACTIVE }
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f))
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.amsat_upload_report),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.amsat_upload_time_now, formatUtcNowForUpload(System.currentTimeMillis())),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.amsat_upload_status),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
options.forEach { option ->
|
||||
val selected = upload.selectedReport == option.apiValue
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = { onReportChange(option.apiValue) },
|
||||
label = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (selected) Color.White else option.color)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(stringResource(option.labelResId), maxLines = 1)
|
||||
}
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
containerColor = option.color.copy(alpha = 0.10f),
|
||||
labelColor = MaterialTheme.colorScheme.onSurface,
|
||||
selectedContainerColor = option.color,
|
||||
selectedLabelColor = Color.White
|
||||
),
|
||||
border = FilterChipDefaults.filterChipBorder(
|
||||
enabled = true,
|
||||
selected = selected,
|
||||
borderColor = option.color.copy(alpha = 0.60f),
|
||||
selectedBorderColor = option.color
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = upload.callsign,
|
||||
onValueChange = onCallsignChange,
|
||||
label = { Text(stringResource(R.string.amsat_upload_callsign)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = upload.gridSquare,
|
||||
onValueChange = onGridChange,
|
||||
label = { Text(stringResource(R.string.amsat_upload_grid)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onRequestSubmitReport,
|
||||
enabled = !upload.isSubmitting && upload.callsign.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (upload.isSubmitting) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text(stringResource(R.string.amsat_upload_submit))
|
||||
}
|
||||
}
|
||||
upload.message?.let { message ->
|
||||
if (message == UPLOAD_MESSAGE_SUCCESS) {
|
||||
Text(
|
||||
text = stringResource(R.string.amsat_upload_success),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
upload.error?.let { error ->
|
||||
Text(
|
||||
text = uploadErrorText(error),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AmSatUploadConfirmDialog(
|
||||
statusName: String,
|
||||
upload: AmSatUploadUiState,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val option = UPLOAD_OPTIONS.firstOrNull { it.apiValue == upload.selectedReport } ?: UPLOAD_OPTIONS.first()
|
||||
val statusLabel = stringResource(option.labelResId)
|
||||
val grid = upload.gridSquare.ifBlank { stringResource(R.string.amsat_upload_grid_none) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.amsat_upload_confirm_title)) },
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.amsat_upload_confirm_message,
|
||||
statusName,
|
||||
statusLabel,
|
||||
upload.callsign,
|
||||
grid
|
||||
)
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm, enabled = !upload.isSubmitting) {
|
||||
Text(stringResource(R.string.amsat_upload_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss, enabled = !upload.isSubmitting) {
|
||||
Text(stringResource(R.string.btn_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun uploadErrorText(error: String): String {
|
||||
return when (error) {
|
||||
UPLOAD_ERROR_CALLSIGN_REQUIRED -> stringResource(R.string.amsat_upload_callsign_required)
|
||||
UPLOAD_ERROR_GRID_INVALID -> stringResource(R.string.amsat_upload_grid_invalid)
|
||||
else -> stringResource(R.string.amsat_upload_failed, error)
|
||||
}
|
||||
}
|
||||
|
||||
private val MONTH_ABBR = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
|
||||
private fun formatFetchedAt(utcMs: Long): String {
|
||||
@@ -324,4 +614,11 @@ private fun formatFetchedAt(utcMs: Long): String {
|
||||
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
|
||||
val ss = cal.get(Calendar.SECOND).toString().padStart(2, '0')
|
||||
return "$day$month $year - $hh:$mm:$ss"
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatUtcNowForUpload(utcMs: Long): String {
|
||||
val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
return formatter.format(Date(utcMs))
|
||||
}
|
||||
+222
-23
@@ -4,14 +4,38 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
|
||||
const val AMSAT_REPORT_HEARD = "Heard"
|
||||
const val AMSAT_REPORT_TELEMETRY_ONLY = "Telemetry Only"
|
||||
const val AMSAT_REPORT_NOT_HEARD = "Not Heard"
|
||||
const val AMSAT_REPORT_CREW_ACTIVE = "Crew Active"
|
||||
|
||||
const val UPLOAD_ERROR_CALLSIGN_REQUIRED = "callsign_required"
|
||||
const val UPLOAD_ERROR_GRID_INVALID = "grid_invalid"
|
||||
const val UPLOAD_MESSAGE_SUCCESS = "success"
|
||||
|
||||
data class AmSatUploadUiState(
|
||||
val isExpanded: Boolean = false,
|
||||
val selectedReport: String = AMSAT_REPORT_HEARD,
|
||||
val callsign: String = "",
|
||||
val gridSquare: String = "",
|
||||
val isSubmitting: Boolean = false,
|
||||
val isConfirmingSubmit: Boolean = false,
|
||||
val message: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
data class SatStatusUiState(
|
||||
val isLoading: Boolean = false,
|
||||
@@ -19,35 +43,34 @@ data class SatStatusUiState(
|
||||
val statuses: List<SatStatus> = emptyList(),
|
||||
val reports: Map<String, SatReport> = emptyMap(),
|
||||
val fetchedAtUtcMs: Long = 0L,
|
||||
val error: String? = null
|
||||
val error: String? = null,
|
||||
val upload: AmSatUploadUiState = AmSatUploadUiState()
|
||||
)
|
||||
|
||||
class SatStatusViewModel(
|
||||
private val amSatRepo: IAmSatRepository
|
||||
private val amSatRepo: IAmSatRepository,
|
||||
private val settingsRepo: ISettingsRepo
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
|
||||
private val cachedStatusPage = amSatRepo.getCachedStatus()
|
||||
private val _uiState = MutableStateFlow(cachedStatusPage?.toUiState() ?: SatStatusUiState(isLoading = true))
|
||||
val uiState: StateFlow<SatStatusUiState> = _uiState
|
||||
|
||||
init {
|
||||
fetch()
|
||||
if (cachedStatusPage == null) fetch()
|
||||
}
|
||||
|
||||
fun fetch() {
|
||||
amSatRepo.getCachedStatus()?.let { page ->
|
||||
applyStatusPage(page)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
val page = 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
|
||||
)
|
||||
}
|
||||
applyStatusPage(page)
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
|
||||
@@ -66,16 +89,9 @@ class SatStatusViewModel(
|
||||
_uiState.update { it.copy(isRefreshing = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val page = amSatRepo.fetchStatus()
|
||||
val page = amSatRepo.fetchStatus(forceRefresh = true)
|
||||
if (page != null && page.statuses.isNotEmpty()) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isRefreshing = false,
|
||||
statuses = page.statuses,
|
||||
reports = page.reports,
|
||||
fetchedAtUtcMs = page.fetchedAtUtcMs
|
||||
)
|
||||
}
|
||||
applyStatusPage(page)
|
||||
} else {
|
||||
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
|
||||
}
|
||||
@@ -85,11 +101,194 @@ class SatStatusViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyStatusPage(page: SatStatusPage) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
statuses = page.statuses,
|
||||
reports = page.reports,
|
||||
fetchedAtUtcMs = page.fetchedAtUtcMs,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleUploadPanel() {
|
||||
val storedCallsign = settingsRepo.getAmSatCallsign()
|
||||
val defaultGrid = defaultGridSquare()
|
||||
_uiState.update { state ->
|
||||
val upload = state.upload
|
||||
val expanded = !upload.isExpanded
|
||||
state.copy(
|
||||
upload = if (expanded) {
|
||||
upload.copy(
|
||||
isExpanded = true,
|
||||
callsign = upload.callsign.ifBlank { storedCallsign },
|
||||
gridSquare = upload.gridSquare.ifBlank { defaultGrid },
|
||||
message = null,
|
||||
error = null
|
||||
)
|
||||
} else {
|
||||
upload.copy(isExpanded = false, isConfirmingSubmit = false, message = null, error = null)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun collapseUploadPanel() {
|
||||
_uiState.update { it.copy(upload = it.upload.copy(isExpanded = false, isConfirmingSubmit = false, message = null, error = null)) }
|
||||
}
|
||||
|
||||
fun setUploadReport(report: String) {
|
||||
_uiState.update { it.copy(upload = it.upload.copy(selectedReport = report, isConfirmingSubmit = false, message = null, error = null)) }
|
||||
}
|
||||
|
||||
fun setUploadCallsign(callsign: String) {
|
||||
val normalized = callsign.trim().uppercase(Locale.US)
|
||||
settingsRepo.setAmSatCallsign(normalized)
|
||||
_uiState.update { it.copy(upload = it.upload.copy(callsign = normalized, isConfirmingSubmit = false, message = null, error = null)) }
|
||||
}
|
||||
|
||||
fun setUploadGrid(grid: String) {
|
||||
val normalized = grid.trim().uppercase(Locale.US)
|
||||
_uiState.update { it.copy(upload = it.upload.copy(gridSquare = normalized, isConfirmingSubmit = false, message = null, error = null)) }
|
||||
}
|
||||
|
||||
fun requestSubmitConfirmation() {
|
||||
val prepared = validateUpload(_uiState.value.upload) ?: return
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
upload = it.upload.copy(
|
||||
callsign = prepared.callsign,
|
||||
gridSquare = prepared.grid,
|
||||
isConfirmingSubmit = true,
|
||||
message = null,
|
||||
error = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissSubmitConfirmation() {
|
||||
_uiState.update { it.copy(upload = it.upload.copy(isConfirmingSubmit = false)) }
|
||||
}
|
||||
|
||||
fun confirmSubmitReport(satelliteName: String) {
|
||||
val upload = _uiState.value.upload
|
||||
if (upload.isSubmitting) return
|
||||
val prepared = validateUpload(upload) ?: return
|
||||
|
||||
settingsRepo.setAmSatCallsign(prepared.callsign)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
upload = it.upload.copy(
|
||||
callsign = prepared.callsign,
|
||||
gridSquare = prepared.grid,
|
||||
isSubmitting = true,
|
||||
isConfirmingSubmit = false,
|
||||
error = null,
|
||||
message = null
|
||||
)
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val result = amSatRepo.submitReport(
|
||||
AmSatReportSubmission(
|
||||
name = satelliteName,
|
||||
report = prepared.report,
|
||||
callsign = prepared.callsign,
|
||||
gridSquare = prepared.grid,
|
||||
reportedAtUtcMillis = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
if (result.success) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
upload = it.upload.copy(
|
||||
isSubmitting = false,
|
||||
callsign = prepared.callsign,
|
||||
gridSquare = prepared.grid,
|
||||
message = UPLOAD_MESSAGE_SUCCESS,
|
||||
error = null
|
||||
)
|
||||
)
|
||||
}
|
||||
refresh()
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
upload = it.upload.copy(
|
||||
isSubmitting = false,
|
||||
message = null,
|
||||
error = result.message.ifBlank { "Upload failed" }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateUpload(upload: AmSatUploadUiState): PreparedUpload? {
|
||||
val callsign = upload.callsign.trim().uppercase(Locale.US)
|
||||
val grid = upload.gridSquare.trim().uppercase(Locale.US)
|
||||
return when {
|
||||
callsign.isBlank() -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
upload = it.upload.copy(
|
||||
isConfirmingSubmit = false,
|
||||
error = UPLOAD_ERROR_CALLSIGN_REQUIRED,
|
||||
message = null
|
||||
)
|
||||
)
|
||||
}
|
||||
null
|
||||
}
|
||||
grid.isNotBlank() && !MAIDENHEAD_GRID.matches(grid) -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
upload = it.upload.copy(
|
||||
isConfirmingSubmit = false,
|
||||
error = UPLOAD_ERROR_GRID_INVALID,
|
||||
message = null
|
||||
)
|
||||
)
|
||||
}
|
||||
null
|
||||
}
|
||||
else -> PreparedUpload(
|
||||
report = upload.selectedReport,
|
||||
callsign = callsign,
|
||||
grid = grid
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultGridSquare(): String {
|
||||
val grid = settingsRepo.stationPosition.value.qthLocator.trim().uppercase(Locale.US)
|
||||
return grid.takeUnless { it == "JJ00AA" }.orEmpty()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(container: IMainContainer) = viewModelFactory {
|
||||
initializer {
|
||||
SatStatusViewModel(container.amSatRepo)
|
||||
SatStatusViewModel(container.amSatRepo, container.settingsRepo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SatStatusPage.toUiState() = SatStatusUiState(
|
||||
statuses = statuses,
|
||||
reports = reports,
|
||||
fetchedAtUtcMs = fetchedAtUtcMs
|
||||
)
|
||||
|
||||
private data class PreparedUpload(
|
||||
val report: String,
|
||||
val callsign: String,
|
||||
val grid: String
|
||||
)
|
||||
|
||||
private val MAIDENHEAD_GRID = Regex("^[A-R]{2}[0-9]{2}([A-X]{2})?$")
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "446"
|
||||
appVersionCode = "449"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.6-ba7opf.1"
|
||||
appVersionName = "4.4.6-ba7opf.5"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user