Compare commits

..
Author SHA1 Message Date
atsunatsu 4aada90b76 feat: remember per-satellite doppler offset in calculator
Store the last entered offset (kHz) per satellite in SharedPreferences
via SettingsRepo, and restore it when the linear transponder calculator
is shown for that satellite.

Follows the project's MVI/clean architecture:
- ISettingsRepo: getSatelliteOffset / setSatelliteOffset
- SettingsRepo: backed by SharedPreferences
- RadarState: calculatorOffsetKHz field
- RadarAction: ChangeCalculatorOffset
- RadarViewModel: loads offset on SelectTransmitter, saves on change
- CalculatorPage/DopplerFrequencyCalculator: receive offset via params
instead of accessing SharedPreferences directly from the UI layer.
2026-08-10 17:03:45 +08:00
atsunatsuandatsunatsu 8b5960282f Broaden linear transponder detection, logic fixes (#236)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-08 21:31:44 +02:00
mckero 10eb84690e Added AMSAT satellite status tracking page (#234) 2026-08-08 14:26:36 +02:00
bf25292bf8 Fixed recalculating Radar track on station position change (#235)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
Co-authored-by: wty2019wty <74123961+wty2019wty@users.noreply.github.com>
2026-08-08 14:10:55 +02:00
27 changed files with 1320 additions and 110 deletions

No files matched your search

+1
View File
@@ -64,3 +64,4 @@ fastlane/readme.md
/app/release/output-metadata.json /app/release/output-metadata.json
/app/release/ /app/release/
/.kotlin/sessions/ /.kotlin/sessions/
.hermes/
@@ -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
@@ -66,12 +67,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 = 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 by lazy { 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)
@@ -0,0 +1,156 @@
package com.rtbishop.look4sat.core.data.repository
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 com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.TimeZone
/** One report from the AMSAT API (data layer model). */
private data class ApiReport(
val id: String,
val name: String,
val callsign: String,
val report: String,
val gridSquare: String,
val reportedTimeUtcSec: Long
)
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
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) {
val nowSec = System.currentTimeMillis() / 1000
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
val reportsJson = remoteSource.getAmSatReports(hours = 168, limit = 500) ?: return@withContext null
val names = parseCatalog(catalogJson)
val reports = parseReports(reportsJson)
if (names.isEmpty() && reports.isEmpty()) return@withContext null
val statuses = buildStatuses(names, reports, nowSec)
val reportMap = reports.associate { it.id to toSatReport(it) }
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
}
/** Parse catalog JSON to list of satellite names */
private fun parseCatalog(json: String): List<String> {
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
} catch (e: Exception) {
emptyList()
}
}
/** Parse reports JSON to list of ApiReport domain objects */
private fun parseReports(json: String): List<ApiReport> {
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).mapNotNull { i ->
val o = arr.getJSONObject(i)
val iso = o.optString("reported_time", "")
if (iso.isEmpty()) null else ApiReport(
id = o.optString("id", ""),
name = o.optString("name", ""),
callsign = o.optString("callsign", ""),
report = o.optString("report", ""),
gridSquare = o.optString("grid_square", ""),
reportedTimeUtcSec = parseIsoUtcSec(iso)
)
}
} catch (e: Exception) {
emptyList()
}
}
/** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */
private fun parseIsoUtcSec(iso: String): Long {
return try {
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
} catch (e: Exception) {
0L
}
}
/** Build one SatStatus (6 days x 12 slots) per catalog satellite, slotting reports by age. */
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
val byName = reports.groupBy { it.name }
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
val labels = (0 until 6).map { d ->
utc.timeInMillis = (nowSec - d * 86400L) * 1000
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
}
return names.map { name ->
val slots = (0 until 72).map { slotIdx ->
val slotStart = nowSec - (slotIdx + 1) * 7200L
val slotEnd = nowSec - slotIdx * 7200L
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
if (inSlot.isEmpty()) {
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
} else {
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
SatSlot(
statusColor = statusColorOf(newest.report),
count = inSlot.size,
reportIds = inSlot.map { it.id }
)
}
}
val days = (0 until 6).map { d ->
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
}
SatStatus(name = name, days = days)
}
}
private fun toSatReport(r: ApiReport): SatReport {
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.timeInMillis = r.reportedTimeUtcSec * 1000
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
val y = cal.get(Calendar.YEAR)
val mo = (cal.get(Calendar.MONTH) + 1).toString().padStart(2, '0')
val d = cal.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0')
return SatReport(
id = r.id,
statusText = r.report,
call = r.callsign,
grid = r.gridSquare,
dateUtc = "$y-$mo-$d",
timeUtc = "$hh:$mm UTC"
)
}
/** Map status text to color value (for UI rendering). */
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
"heard", "crew active" -> ACTIVE_BLUE
"telemetry only" -> TLM_ORANGE
"not heard" -> NOT_HEARD_PINK
else -> CONFLICT_DEEP_ORANGE
}
companion object {
// AMSAT official status colors (from amsat.org/status)
private const val ACTIVE_BLUE = 0xFF648FFF
private const val TLM_ORANGE = 0xFFFFB000
private const val NOT_HEARD_PINK = 0xFFDC267F
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
private const val NO_REPORT_GRAY = 0xFFC0C0C0
}
}
@@ -34,6 +34,7 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.TimeZone import java.util.TimeZone
@@ -64,19 +65,23 @@ class SatelliteRepo(
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id) override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
override suspend fun initRepository() = withContext(dispatcher) { override suspend fun initRepository() = withContext(dispatcher) {
settingsRepo.selectedIds.collect { selectedIds -> combine(
_satellites.update { localStorage.getEntriesWithIds(selectedIds) } settingsRepo.selectedIds,
val settings = settingsRepo.passesSettings.value settingsRepo.stationPosition
calculatePasses( ) { selectedIds, _ -> selectedIds }
time = System.currentTimeMillis(), .collect { selectedIds ->
hoursAhead = settings.hoursAhead, _satellites.update { localStorage.getEntriesWithIds(selectedIds) }
minElevation = settings.minElevation, val settings = settingsRepo.passesSettings.value
aosStartMinute = settings.aosStartMinute, calculatePasses(
aosEndMinute = settings.aosEndMinute, time = System.currentTimeMillis(),
invertAosTimeWindow = settings.invertAosTimeWindow, hoursAhead = settings.hoursAhead,
modes = settingsRepo.selectedSatModes.value minElevation = settings.minElevation,
) aosStartMinute = settings.aosStartMinute,
} aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settingsRepo.selectedSatModes.value
)
}
} }
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos { override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
@@ -37,6 +37,7 @@ import com.rtbishop.look4sat.core.domain.utility.round
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 org.json.JSONObject
class SettingsRepo( class SettingsRepo(
private val locationManager: LocationManager, private val locationManager: LocationManager,
@@ -417,5 +418,27 @@ class SettingsRepo(
baudRate = preferences.getInt(keyRadioBaudRate, 4800), baudRate = preferences.getInt(keyRadioBaudRate, 4800),
splitMode = preferences.getBoolean(keyRadioSplitMode, false) splitMode = preferences.getBoolean(keyRadioSplitMode, false)
) )
//endregion
private val keySatelliteOffsets = "satelliteOffsets"
override fun getSatelliteOffset(catnum: Int): String {
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
return try {
JSONObject(json).optString(catnum.toString(), "")
} catch (e: Exception) {
""
}
}
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 (e: Exception) {
"""{"$catnum": "$offset"}"""
}
preferences.edit { putString(keySatelliteOffsets, updated) }
}
} }
@@ -58,4 +58,36 @@ class RemoteSource(
null null
} }
} }
override suspend fun getAmSatCatalog(): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/catalog.php")
.header("User-Agent", "Look4Sat")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource getAmSatCatalog exception: $exception")
null
}
}
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit")
.header("User-Agent", "Look4Sat")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource getAmSatReports exception: $exception")
null
}
}
} }
@@ -213,6 +213,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
} }
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
} }
private fun defaultDataSourcesSettings(): DataSourcesSettings { private fun defaultDataSourcesSettings(): DataSourcesSettings {
@@ -167,6 +167,10 @@ class SelectionRepoTest {
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
} }
} }
@@ -0,0 +1,37 @@
package com.rtbishop.look4sat.core.domain.model
/** One satellite status report (AMSAT site tooltip data) */
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
)
/** State of one 2-hour slot */
data class SatSlot(
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
val count: Int, // 报告数量(0 = 无)
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
)
/** One satellite day (12 two-hour slots) */
data class SatDay(
val dateLabel: String, // "Aug 4"
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
)
/** One satellite, 6 days of state */
data class SatStatus(
val name: String, // "AO-123_[FM]"
val days: List<SatDay> // 6 天(新→旧)
)
/** Overall page parse result */
data class SatStatusPage(
val fetchedAtUtcMs: Long,
val statuses: List<SatStatus>,
val reports: Map<String, SatReport> // id → 报告
)
@@ -0,0 +1,9 @@
package com.rtbishop.look4sat.core.domain.repository
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?
}
@@ -29,6 +29,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
fun provideAddToCalendar(): IAddToCalendar fun provideAddToCalendar(): IAddToCalendar
fun provideShowToast(): IShowToast fun provideShowToast(): IShowToast
@@ -75,4 +75,9 @@ interface ISettingsRepo {
val radioControlSettings: StateFlow<RadioControlSettings> val radioControlSettings: StateFlow<RadioControlSettings>
fun updateRadioControlSettings(settings: RadioControlSettings) fun updateRadioControlSettings(settings: RadioControlSettings)
//endregion //endregion
//region # Per-satellite calculator offset settings
fun getSatelliteOffset(catnum: Int): String
fun setSatelliteOffset(catnum: Int, offset: String)
//endregion
} }
@@ -22,4 +22,6 @@ 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?
suspend fun getAmSatCatalog(): String?
suspend fun getAmSatReports(hours: Int, limit: Int): String?
} }
@@ -112,11 +112,30 @@ object DopplerFrequencyCalculator {
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode) val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
.joinToString(separator = " ") .joinToString(separator = " ")
.lowercase(Locale.ENGLISH) .lowercase(Locale.ENGLISH)
val hasLinearName = info.contains("linear") val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
val hasTransponderName = info.contains("transponder") || info.contains("transp") || val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
info.contains("xponder") || info.contains("xpdr") info.contains("xponder") || info.contains("xpdr")
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) } val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) ||
(hasLinearName && hasLinearMode)
}
/**
* Removes duplicate transponder entries that describe the same physical
* transponder with different mode labels (e.g. SatNOGS lists AO-7's Mode A
* as both "Lin SSB" and "Lin CW", and JO-97's U/V transponder as both
* "CW Transponder" and "SSB Transponder").
*
* Entries sharing the same uplink/downlink frequency range are considered
* the same transponder. The non-CW entry is preferred because its invert
* flag is more reliable (e.g. JO-97's CW entry wrongly has invert=false).
*/
fun deduplicateTransponders(radios: List<SatRadio>): List<SatRadio> {
return radios.groupBy { radio ->
listOf(radio.uplinkLow, radio.uplinkHigh, radio.downlinkLow, radio.downlinkHigh)
}.values.map { group ->
group.firstOrNull { it.downlinkMode?.equals("CW", ignoreCase = true) != true } ?: group.first()
}
} }
} }
@@ -13,6 +13,7 @@ import org.junit.Test
class DopplerFrequencyCalculatorTest { class DopplerFrequencyCalculatorTest {
private fun linearTransponder( private fun linearTransponder(
uuid: String = "linear",
upLow: Long = 145_000_000L, upLow: Long = 145_000_000L,
upHigh: Long = 145_500_000L, upHigh: Long = 145_500_000L,
downLow: Long = 435_000_000L, downLow: Long = 435_000_000L,
@@ -22,7 +23,7 @@ class DopplerFrequencyCalculatorTest {
downlinkMode: String? = "USB", downlinkMode: String? = "USB",
uplinkMode: String? = "LSB" uplinkMode: String? = "LSB"
) = SatRadio( ) = SatRadio(
uuid = "linear", info = info, isAlive = true, uuid = uuid, info = info, isAlive = true,
downlinkLow = downLow, downlinkHigh = downHigh, downlinkLow = downLow, downlinkHigh = downHigh,
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh, downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345 uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
@@ -72,11 +73,93 @@ class DopplerFrequencyCalculatorTest {
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry)) assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
} }
@Test
fun isNamedLinearTransponder_returnsTrueForAbbreviatedLinName() {
// AO-7 style: "Mode V/A (A) Lin SSB" — "Lin" abbreviation, no "transponder" word
val ao7Entry = linearTransponder(info = "Mode V/A (A) Lin SSB", downlinkMode = "USB", uplinkMode = "USB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7Entry))
val ao7CwEntry = linearTransponder(info = "Mode V/A (A) Lin CW", downlinkMode = "CW", uplinkMode = "CW")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7CwEntry))
val ao7ModeBEntry = linearTransponder(info = "Mode U/V (B) Lin", downlinkMode = "USB", uplinkMode = "LSB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7ModeBEntry))
}
@Test
fun isNamedLinearTransponder_returnsTrueForLinearWithoutTransponderWord() {
// AO-73 style: "Mode U/V Linear" — has "Linear" but no "transponder"
val ao73Entry = linearTransponder(info = "Mode U/V Linear", downlinkMode = "USB", uplinkMode = "LSB")
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao73Entry))
}
@Test
fun isNamedLinearTransponder_returnsFalseForDownlinkContainingLinInsideWord() {
// "Downlink" contains "lin" but is not a linear-transponder name
val downlinkEntry = linearTransponder(info = "Mode U Downlink", downlinkMode = "FM", uplinkMode = "FM")
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(downlinkEntry))
}
@Test @Test
fun isNamedLinearTransponder_returnsFalseForFmRepeater() { fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder())) assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
} }
@Test
fun deduplicateTransponders_mergesSameFrequencyRange() {
// AO-7's Mode A: same range, SSB and CW entries
val ssb = linearTransponder(
uuid = "ssb-uuid", info = "Mode V/A (A) Lin SSB",
downlinkMode = "USB", uplinkMode = "USB"
)
val cw = linearTransponder(
uuid = "cw-uuid", info = "Mode V/A (A) Lin CW",
downlinkMode = "CW", uplinkMode = "CW"
)
val modeB = linearTransponder(
uuid = "modeb-uuid", info = "Mode U/V (B) Lin",
upLow = 432_125_000L, upHigh = 432_175_000L,
downLow = 145_925_000L, downHigh = 145_975_000L,
downlinkMode = "USB", uplinkMode = "LSB"
)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(ssb, cw, modeB))
assertEquals(2, result.size)
// SSB entry should be preferred over CW (same range)
assertEquals("ssb-uuid", result[0].uuid)
assertEquals("modeb-uuid", result[1].uuid)
}
@Test
fun deduplicateTransponders_prefersNonCwEntry() {
// JO-97: CW entry has invert=false (wrong), SSB has invert=true (correct)
val cw = linearTransponder(
uuid = "cw-uuid", info = "U/V CW Transponder",
downlinkMode = "CW", uplinkMode = "CW",
upLow = 435_100_000L, upHigh = 435_120_000L,
downLow = 145_855_000L, downHigh = 145_875_000L
)
val ssb = linearTransponder(
uuid = "ssb-uuid", info = "U/V SSB Transponder",
downlinkMode = "USB", uplinkMode = "LSB",
upLow = 435_100_000L, upHigh = 435_120_000L,
downLow = 145_855_000L, downHigh = 145_875_000L,
inverted = true
)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(cw, ssb))
assertEquals(1, result.size)
assertEquals("ssb-uuid", result[0].uuid)
// Verify the correct invert flag is preserved
assertTrue(result[0].isInverted)
}
@Test
fun deduplicateTransponders_preservesUniqueEntries() {
val t1 = linearTransponder(uuid = "t1", upLow = 145_000_000L, upHigh = 145_500_000L,
downLow = 435_000_000L, downHigh = 435_500_000L)
val t2 = linearTransponder(uuid = "t2", upLow = 435_000_000L, upHigh = 435_500_000L,
downLow = 145_000_000L, downHigh = 145_500_000L)
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(t1, t2))
assertEquals(2, result.size)
}
@Test @Test
fun computeUplinkFromDownlink_linear_noDoppler() { fun computeUplinkFromDownlink_linear_noDoppler() {
val xpdr = linearTransponder() val xpdr = linearTransponder()
@@ -79,6 +79,10 @@ private val lightScheme = lightColorScheme(
onSecondary = Color(0xFFFFFFFF), onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFF1E1BB), secondaryContainer = Color(0xFFF1E1BB),
onSecondaryContainer = Color(0xFF221B04), onSecondaryContainer = Color(0xFF221B04),
tertiary = Color(0xFF3C6FE0), // AMSAT Active (darker for light theme)
onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFE09800), // AMSAT Telemetry (darker)
onTertiaryContainer = Color(0xFF000000),
background = Color(0xFFFFF8F0), background = Color(0xFFFFF8F0),
onBackground = Color(0xFF1E1B13), onBackground = Color(0xFF1E1B13),
surface = Color(0xFFFFF8F0), surface = Color(0xFFFFF8F0),
@@ -101,10 +105,10 @@ private val darkScheme = darkColorScheme(
onSecondary = Color(0xFF000000), onSecondary = Color(0xFF000000),
secondaryContainer = Color(0xFF404040), // navBar indicator, secondaryContainer = Color(0xFF404040), // navBar indicator,
onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon
// tertiary = Color(0xFF121212), tertiary = Color(0xFF648FFF), // AMSAT Active (from amsat.org/status)
// onTertiary = Color(0xFF121212), onTertiary = Color(0xFF000000),
// tertiaryContainer = Color(0xFF121212), tertiaryContainer = Color(0xFFFFB000), // AMSAT Telemetry
// onTertiaryContainer = Color(0xFF121212), onTertiaryContainer = Color(0xFF000000),
background = Color(0xFF121212), background = Color(0xFF121212),
onBackground = Color(0xFFE0E0E0), onBackground = Color(0xFFE0E0E0),
surface = Color(0xFF202020), // card background surface = Color(0xFF202020), // card background
@@ -87,7 +87,7 @@
<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">检查您的位置权限</string>
<string name="prefs_loc_input_title">输入位置</string> <string name="prefs_loc_input_title">输入位置</string>
@@ -95,11 +95,11 @@
<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">通过 QTH 设置站位</string> <string name="prefs_locator_text">通过 QTH 设置站点位置</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>
@@ -155,6 +155,8 @@
<string name="prefs_other_switch_sweep">雷达扫描动画</string> <string name="prefs_other_switch_sweep">雷达扫描动画</string>
<string name="prefs_other_switch_sensors">传感器控制雷达</string> <string name="prefs_other_switch_sensors">传感器控制雷达</string>
<string name="prefs_other_switch_night_mode">红色夜间模式</string> <string name="prefs_other_switch_night_mode">红色夜间模式</string>
<string name="prefs_other_compass_offset">雷达罗盘水平偏移量</string>
<string name="prefs_other_compass_offset_elev">雷达罗盘俯仰偏移量</string>
<string name="prefs_highlight_title">仰角高亮</string> <string name="prefs_highlight_title">仰角高亮</string>
<string name="prefs_highlight_low">低 &lt; %1$d°</string> <string name="prefs_highlight_low">低 &lt; %1$d°</string>
@@ -67,6 +67,21 @@
\n* Replaced the types selection dialog with modes selection to avoid confusion \n* Replaced the types selection dialog with modes selection to avoid confusion
</string> </string>
<!-- AMSAT Status screen -->
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT Satellite Status</string>
<string name="amsat_refresh">Refresh</string>
<string name="amsat_retry">Retry</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>
<!-- Radar screen --> <!-- Radar screen -->
<string name="radar_back">Back</string> <string name="radar_back">Back</string>
<string name="radar_notify">Notify</string> <string name="radar_notify">Notify</string>
@@ -198,7 +198,8 @@ private fun PagerCard(
transceivers = uiState.transceivers.transmitters, transceivers = uiState.transceivers.transmitters,
selectedUuid = uiState.transceivers.selectedUuid, selectedUuid = uiState.transceivers.selectedUuid,
orbitalPos = uiState.orbitalPos, orbitalPos = uiState.orbitalPos,
onAction = onAction onAction = onAction,
calculatorOffsetKHz = uiState.calculatorOffsetKHz
) )
RadarPage.Sstv -> SstvPage( RadarPage.Sstv -> SstvPage(
sstv = uiState.sstv, sstv = uiState.sstv,
@@ -63,7 +63,8 @@ data class RadarState(
val moonPosition: CelestialComputer.MoonPosition? = null, val moonPosition: CelestialComputer.MoonPosition? = null,
val transceivers: TransceiverSubState = TransceiverSubState(), val transceivers: TransceiverSubState = TransceiverSubState(),
val radioControl: RadioControlSubState = RadioControlSubState(), val radioControl: RadioControlSubState = RadioControlSubState(),
val sstv: SstvSubState = SstvSubState() val sstv: SstvSubState = SstvSubState(),
val calculatorOffsetKHz: String = ""
) )
enum class SstvStatus { Idle, Recording } enum class SstvStatus { Idle, Recording }
@@ -97,4 +98,7 @@ sealed interface RadarAction {
data object SstvReset : RadarAction data object SstvReset : RadarAction
data class SstvSelectMode(val modeName: String) : RadarAction data class SstvSelectMode(val modeName: String) : RadarAction
data class SstvPermissionResult(val granted: Boolean) : RadarAction data class SstvPermissionResult(val granted: Boolean) : RadarAction
// Calculator actions
data class ChangeCalculatorOffset(val offsetKHz: String) : RadarAction
} }
@@ -65,8 +65,8 @@ class RadarViewModel(
private val showToast: IShowToast private val showToast: IShowToast
) : ViewModel() { ) : ViewModel() {
private val stationPos = settingsRepo.stationPosition.value private var stationPos = settingsRepo.stationPosition.value
private val magDeclination = sensorsRepo.getMagDeclination(stationPos) private var magDeclination = sensorsRepo.getMagDeclination(stationPos)
private var compassOffset = settingsRepo.otherSettings.value.radarCompassOffset private var compassOffset = settingsRepo.otherSettings.value.radarCompassOffset
private var compassOffsetElev = settingsRepo.otherSettings.value.radarCompassOffsetElev private var compassOffsetElev = settingsRepo.otherSettings.value.radarCompassOffsetElev
private var transponders: List<SatRadio> = emptyList() private var transponders: List<SatRadio> = emptyList()
@@ -92,6 +92,7 @@ class RadarViewModel(
init { init {
collectSettingsChanges() // also handles initial sensor subscription collectSettingsChanges() // also handles initial sensor subscription
collectStationPositionChanges()
collectPassAndStartTickLoop() collectPassAndStartTickLoop()
collectRadioTrackingState() collectRadioTrackingState()
} }
@@ -138,6 +139,23 @@ class RadarViewModel(
} }
} }
private fun collectStationPositionChanges() {
viewModelScope.launch {
settingsRepo.stationPosition.collect { newPos ->
if (stationPos != newPos) {
stationPos = newPos
magDeclination = sensorsRepo.getMagDeclination(stationPos)
lastCelestialUpdateMs = 0L // force celestial recompute on next tick
val pass = _uiState.value.currentPass ?: return@collect
if (!pass.isDeepSpace) {
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
_uiState.update { it.copy(satTrack = track) }
}
}
}
}
}
// --- Pass loading split into focused functions --- // --- Pass loading split into focused functions ---
private fun collectPassAndStartTickLoop() { private fun collectPassAndStartTickLoop() {
@@ -242,7 +260,17 @@ class RadarViewModel(
// Compute toggle state before the update so we don't read post-update value // Compute toggle state before the update so we don't read post-update value
val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
val newUuid = if (isTogglingOff) null else action.uuid val newUuid = if (isTogglingOff) null else action.uuid
_uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = newUuid)) } // Load the saved offset for the newly selected satellite
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 // Only update the tracking service when selecting a different transponder to
// avoid resetting a user-adjusted TX base on re-expand // avoid resetting a user-adjusted TX base on re-expand
if (!isTogglingOff) { if (!isTogglingOff) {
@@ -291,6 +319,15 @@ class RadarViewModel(
sstvDecoder?.clearPixels() sstvDecoder?.clearPixels()
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) } _uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
} }
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) }
}
} }
} }
@@ -21,14 +21,18 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -39,16 +43,20 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
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
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -59,6 +67,7 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
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.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -68,6 +77,7 @@ import androidx.compose.ui.unit.sp
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 com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.formatFrequency import com.rtbishop.look4sat.core.presentation.formatFrequency
@@ -118,10 +128,12 @@ fun CalculatorPage(
selectedUuid: String?, selectedUuid: String?,
orbitalPos: OrbitalPos?, orbitalPos: OrbitalPos?,
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
calculatorOffsetKHz: String = "",
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val calculatorTransceivers = remember(transceivers) { val calculatorTransceivers = remember(transceivers) {
transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder) transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder)
.let(DopplerFrequencyCalculator::deduplicateTransponders)
} }
val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) { val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) {
calculatorTransceivers.firstOrNull { it.uuid == selectedUuid } calculatorTransceivers.firstOrNull { it.uuid == selectedUuid }
@@ -166,9 +178,11 @@ fun CalculatorPage(
} }
item { item {
DopplerCalculatorPanel( DopplerFrequencyCalculator(
transponder = selectedTransceiver, transponder = selectedTransceiver,
orbitalPos = orbitalPos, orbitalPos = orbitalPos,
offsetKHz = calculatorOffsetKHz,
onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} }
@@ -504,108 +518,362 @@ private fun ExpandedRadioControl(
} }
} }
private enum class EditedField { TX, PASSBAND, RX }
@Composable @Composable
private fun DopplerCalculatorPanel( private fun DopplerFrequencyCalculator(
transponder: SatRadio, transponder: SatRadio,
orbitalPos: OrbitalPos?, orbitalPos: OrbitalPos?,
offsetKHz: String = "",
onOffsetChange: (String) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
var txInputMHz by remember(transponder.uuid) { mutableStateOf("") } var lastEditedField by remember { mutableStateOf(EditedField.TX) }
var rxInputMHz by remember(transponder.uuid) { mutableStateOf("") } var txFrequencyHz by remember { mutableStateOf(0L) }
var offsetKHz by remember(transponder.uuid) { mutableStateOf("") } var rxFrequencyHz by remember { mutableStateOf(0L) }
var lastEditedBy by remember(transponder.uuid) { mutableStateOf(EditedField.TX) } var passbandPosition by remember { mutableStateOf(0.5f) }
var stepSizeKHz by remember { mutableIntStateOf(1) }
fun offsetHz(text: String): Long = text.toDoubleOrNull()?.let { (it * 1000).toLong() } ?: 0L val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
fun formatHz(hz: Long): String = String.format(Locale.ENGLISH, "%.6f", hz / 1_000_000.0)
fun mhzToHz(text: String): Long? = text.toDoubleOrNull()
?.takeIf { it > 0.0 }
?.let { (it * 1_000_000).toLong() }
fun calculateDownlink(txText: String, offsetText: String): String? { val txLow = transponder.uplinkLow ?: return
val txHz = mhzToHz(txText) ?: return null val txHigh = transponder.uplinkHigh ?: return
return DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset( val rxLow = transponder.downlinkLow ?: return
uplinkHz = txHz, val rxHigh = transponder.downlinkHigh ?: return
transponder = transponder, val txRange = txHigh - txLow
orbitalPos = orbitalPos, val rxRange = rxHigh - rxLow
offsetHz = offsetHz(offsetText) if (txRange <= 0L || rxRange <= 0L) return
)?.let(::formatHz)
// 原始转发器值(逆转多普勒),用于 passband 映射计算
val rawTxLow = orbitalPos.getDownlinkFreq(txLow)
val rawTxHigh = orbitalPos.getDownlinkFreq(txHigh)
val rawRxLow = orbitalPos.getUplinkFreq(rxLow)
val rawRxHigh = orbitalPos.getUplinkFreq(rxHigh)
val rawTxRange = rawTxHigh - rawTxLow
val rawRxRange = rawRxHigh - rawRxLow
val rawTransponder = transponder.copy(
uplinkLow = rawTxLow, uplinkHigh = rawTxHigh,
downlinkLow = rawRxLow, downlinkHigh = rawRxHigh
)
// 初始化
LaunchedEffect(Unit) {
if (txFrequencyHz == 0L) {
txFrequencyHz = txLow + txRange / 2
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: (rxLow + rxRange / 2)
}
} }
fun calculateUplink(rxText: String, offsetText: String): String? { // 实时重算:卫星移动时,锚点侧不变,重算另一侧
val rxHz = mhzToHz(rxText) ?: return null LaunchedEffect(orbitalPos) {
return DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset( if (lastEditedField == EditedField.TX) {
downlinkHz = rxHz, // TX 是锚点,不变;重算 RX
transponder = transponder, rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
orbitalPos = orbitalPos, txFrequencyHz, rawTransponder, orbitalPos, offsetHz
offsetHz = offsetHz(offsetText) ) ?: rxFrequencyHz
)?.let(::formatHz) } else if (lastEditedField == EditedField.RX) {
// RX 是锚点,不变;重算 TX
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
} else if (lastEditedField == EditedField.PASSBAND) {
// 相对位置不变(passbandPosition),TX 用地面频率,RX 经 Transceiver 完整路径计算
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
} }
LaunchedEffect(orbitalPos, transponder.uuid) { val txSliderValue = if (lastEditedField == EditedField.PASSBAND) {
if (lastEditedBy == EditedField.TX) { passbandPosition
calculateDownlink(txInputMHz, offsetKHz)?.let { rxInputMHz = it } } else {
} else { ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
calculateUplink(rxInputMHz, offsetKHz)?.let { txInputMHz = it } }
val rxSliderValue = if (lastEditedField == EditedField.PASSBAND) {
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
} else {
((rxFrequencyHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
}
fun updateTx(newTxHz: Long) {
when (lastEditedField) {
EditedField.PASSBAND -> {
// Passband 模式:滑块位置 → passbandPosition → 地面频率,RX 经 Transceiver 计算
val pos = ((newTxHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
passbandPosition = pos
txFrequencyHz = txLow + (pos * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
else -> {
txFrequencyHz = newTxHz.coerceIn(txLow, txHigh)
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
}
}
fun updateRx(newRxHz: Long) {
when (lastEditedField) {
EditedField.PASSBAND -> {
// Passband 模式:从 RX 反推 TX 位置,TX 用地面频率
val pos = ((newRxHz - rxLow).toFloat() / rxRange).coerceIn(0f, 1f)
val txFromRx = TransponderMapper.mapDownlinkToUplink(newRxHz, rawTransponder)
passbandPosition = if (txFromRx != null) {
((txFromRx - rawTxLow).toFloat() / rawTxRange).coerceIn(0f, 1f)
} else pos
txFrequencyHz = txLow + (passbandPosition * txRange).toLong()
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
else -> {
rxFrequencyHz = newRxHz.coerceIn(rxLow, rxHigh)
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
}
} }
} }
Column( Column(
modifier = modifier, modifier = modifier.fillMaxWidth().padding(top = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
OutlinedTextField( Row(
value = offsetKHz, modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
onValueChange = { newValue -> verticalAlignment = Alignment.CenterVertically,
offsetKHz = newValue horizontalArrangement = Arrangement.spacedBy(6.dp)
if (lastEditedBy == EditedField.TX) { ) {
calculateDownlink(txInputMHz, newValue)?.let { rxInputMHz = it } // 左半边: TX + RX 按钮
} else { Row(
calculateUplink(rxInputMHz, newValue)?.let { txInputMHz = it } modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
FilterChip(
selected = lastEditedField == EditedField.TX,
onClick = {
when (lastEditedField) {
EditedField.TX -> {
passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
lastEditedField = EditedField.PASSBAND
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: rxFrequencyHz
}
EditedField.PASSBAND -> { lastEditedField = EditedField.TX }
else -> { lastEditedField = EditedField.TX }
}
},
label = {
Text("TX", fontSize = 13.sp, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth())
},
modifier = Modifier.weight(1f).fillMaxHeight()
)
FilterChip(
selected = lastEditedField == EditedField.RX,
onClick = {
when (lastEditedField) {
EditedField.RX -> {
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
passbandPosition = ((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
lastEditedField = EditedField.PASSBAND
}
EditedField.PASSBAND -> {
lastEditedField = EditedField.RX
txFrequencyHz = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
rxFrequencyHz, rawTransponder, orbitalPos, offsetHz
) ?: txFrequencyHz
}
else -> { lastEditedField = EditedField.RX }
}
},
label = {
Text("RX", fontSize = 13.sp, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth())
},
modifier = Modifier.weight(1f).fillMaxHeight()
)
} }
}, // 右半边: Offset 输入框(带细线边框)
label = { Text(stringResource(R.string.radar_doppler_offset_hint)) }, Box(
singleLine = true, modifier = Modifier
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), .weight(1f)
modifier = Modifier.fillMaxWidth() .height(48.dp)
) .border(1.dp, MaterialTheme.colorScheme.outline, MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
BasicTextField(
value = offsetKHz,
onValueChange = onOffsetChange,
singleLine = true,
textStyle = TextStyle(
fontSize = 16.sp,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
),
decorationBox = { innerTextField ->
if (offsetKHz.isEmpty()) {
Text(
text = "Offset (kHz)",
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
innerTextField()
}
)
}
}
Row( // TX 行
horizontalArrangement = Arrangement.spacedBy(8.dp), Column(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth() Row(verticalAlignment = Alignment.CenterVertically) {
) { OutlinedButton(
OutlinedTextField( onClick = { updateTx(txFrequencyHz - stepSizeKHz * 1000L) },
value = txInputMHz, modifier = Modifier.width(32.dp).height(28.dp),
onValueChange = { newValue -> contentPadding = PaddingValues(0.dp),
txInputMHz = newValue shape = MaterialTheme.shapes.extraSmall
lastEditedBy = EditedField.TX ) { Text("−", fontSize = 14.sp) }
rxInputMHz = calculateDownlink(newValue, offsetKHz) ?: if (newValue.isEmpty()) "" else rxInputMHz
}, Slider(
label = { Text(stringResource(R.string.radar_doppler_tx_hint)) }, value = txSliderValue,
singleLine = true, onValueChange = { norm ->
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), val rawFreq = txLow + (txRange * norm).toLong()
modifier = Modifier.weight(1f) val snapped = ((rawFreq + 500) / 1000L) * 1000L
updateTx(snapped.coerceIn(txLow, txHigh))
},
valueRange = 0f..1f,
modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp)
)
OutlinedButton(
onClick = { updateTx(txFrequencyHz + stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("+", fontSize = 14.sp) }
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = String.format(Locale.ENGLISH, "%.4f", txLow / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = String.format(Locale.ENGLISH, "%.6f", txFrequencyHz / 1_000_000.0),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = " MHz",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Text(
text = String.format(Locale.ENGLISH, "%.4f", txHigh / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End
)
}
}
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
) )
OutlinedTextField( // RX 行
value = rxInputMHz, Column(modifier = Modifier.fillMaxWidth()) {
onValueChange = { newValue -> Row(verticalAlignment = Alignment.CenterVertically) {
rxInputMHz = newValue OutlinedButton(
lastEditedBy = EditedField.RX onClick = { updateRx(rxFrequencyHz - stepSizeKHz * 1000L) },
txInputMHz = calculateUplink(newValue, offsetKHz) ?: if (newValue.isEmpty()) "" else txInputMHz modifier = Modifier.width(32.dp).height(28.dp),
}, contentPadding = PaddingValues(0.dp),
label = { Text(stringResource(R.string.radar_doppler_rx_hint)) }, shape = MaterialTheme.shapes.extraSmall
singleLine = true, ) { Text("−", fontSize = 14.sp) }
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.weight(1f) Slider(
) value = rxSliderValue,
onValueChange = { norm ->
val rawFreq = rxLow + (rxRange * norm).toLong()
val snapped = ((rawFreq + 500) / 1000L) * 1000L
updateRx(snapped.coerceIn(rxLow, rxHigh))
},
valueRange = 0f..1f,
modifier = Modifier.weight(1f).height(20.dp).padding(horizontal = 4.dp)
)
OutlinedButton(
onClick = { updateRx(rxFrequencyHz + stepSizeKHz * 1000L) },
modifier = Modifier.width(32.dp).height(28.dp),
contentPadding = PaddingValues(0.dp),
shape = MaterialTheme.shapes.extraSmall
) { Text("+", fontSize = 14.sp) }
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = String.format(Locale.ENGLISH, "%.4f", rxLow / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = String.format(Locale.ENGLISH, "%.6f", rxFrequencyHz / 1_000_000.0),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = " MHz",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Text(
text = String.format(Locale.ENGLISH, "%.4f", rxHigh / 1_000_000.0),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End
)
}
}
} }
}
} }
private enum class EditedField { TX, RX }
@Composable @Composable
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) { private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
val text = frequency?.let { val text = frequency?.let {
@@ -629,3 +897,4 @@ private fun transceiverTitle(radio: SatRadio): String {
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")
+17
View File
@@ -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,370 @@
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.Image
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.PaddingValues
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
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.compose.viewModel
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 com.rtbishop.look4sat.feature.status.R as StatusR
import java.util.Calendar
/**
* Map AMSAT status text to Material3 colorScheme colors.
* Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants.
*/
@Composable
private fun statusColorOf(statusText: String): Color {
return when {
statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) ->
MaterialTheme.colorScheme.tertiary // Active
statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) ->
MaterialTheme.colorScheme.tertiaryContainer // Telemetry
statusText.contains("Not Heard", ignoreCase = true) ->
Color(0xFFDC267F) // NotHeard pink (no semantic slot)
else ->
MaterialTheme.colorScheme.error // Conflict
}
}
private val NoReportGray = Color(0xFFC0C0C0) // Neutral state
@Composable
fun SatStatusScreen(container: IMainContainer) {
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.navigationBarsPadding()
.padding(horizontal = 8.dp)
) {
// Top: legend + refresh
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() -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(id = R.string.amsat_load_failed),
color = MaterialTheme.colorScheme.error
)
TextButton(onClick = { viewModel.refresh() }) {
Text(text = stringResource(id = R.string.amsat_retry))
}
}
}
else -> {
// Header
HeaderRow(statuses = uiState.statuses)
HorizontalDivider(thickness = 1.dp)
// Row
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp)
) {
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 }
)
}
}
/** Top: update time + refresh button (spinner while loading) */
@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 {
Image(
painter = painterResource(id = StatusR.drawable.ic_refresh),
contentDescription = stringResource(id = R.string.amsat_refresh),
modifier = Modifier
.size(18.dp)
.rotate(angle),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary)
)
}
}
}
}
/** Legend (official four colors) */
@Composable
private fun LegendRow() {
val legend = listOf(
stringResource(id = R.string.amsat_active) to MaterialTheme.colorScheme.tertiary,
stringResource(id = R.string.amsat_tlm) to MaterialTheme.colorScheme.tertiaryContainer,
stringResource(id = R.string.amsat_not_heard) to Color(0xFFDC267F),
stringResource(id = R.string.amsat_conflict) to MaterialTheme.colorScheme.error
)
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)
}
}
}
}
/** Header: satellite name + 6 day dates */
@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)
)
}
}
}
/** Satellite row: name + 6 day color blocks (displays the newest non-gray status) */
@Composable
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
val noReportGray = 0xFFC0C0C0L
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 } ?: day.slots.first()
DayCell(
slot = slot,
modifier = Modifier.weight(0.8f).padding(horizontal = 1.dp),
onClick = { onClickDay(day) }
)
}
}
}
/** Day block: newest reported status among the day's 12 slots; gray when none */
@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
)
}
}
}
/** Report detail dialog (3 levels: callsign/date/time/grid) */
@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 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)
)
}
@@ -0,0 +1,94 @@
package com.rtbishop.look4sat.feature.status
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.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") }
}
}
}
companion object {
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
SatStatusViewModel(container)
}
}
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Standard Material refresh icon (24dp viewport), centered geometry -->
<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="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
</vector>
+2 -1
View File
@@ -25,5 +25,6 @@ include(
":feature:passes", ":feature:passes",
":feature:radar", ":feature:radar",
":feature:satellites", ":feature:satellites",
":feature:settings" ":feature:settings",
":feature:status"
) )