mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-03 03:17:21 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4aada90b76 | ||
|
|
8b5960282f | ||
|
|
10eb84690e | ||
|
|
bf25292bf8 | ||
|
|
8fbfcb3712 | ||
|
|
602b1553a2 | ||
|
|
bd0881fb4b | ||
|
|
a9dd3e6e55 | ||
|
|
905995ab44 |
No files matched your search
@@ -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)
|
||||||
|
|||||||
+156
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-15
@@ -34,9 +34,11 @@ 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
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class SatelliteRepo(
|
class SatelliteRepo(
|
||||||
private val dispatcher: CoroutineDispatcher,
|
private val dispatcher: CoroutineDispatcher,
|
||||||
@@ -63,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 {
|
||||||
@@ -158,7 +164,7 @@ class SatelliteRepo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
newPasses.sortBy { it.aosTime }
|
newPasses.sortBy { it.aosTime }
|
||||||
delay(1000) // Simulate loading time for better UX
|
delay(1000.milliseconds) // Simulate loading time for better UX
|
||||||
_passes.update { newPasses }
|
_passes.update { newPasses }
|
||||||
}
|
}
|
||||||
_isCalculating.value = false
|
_isCalculating.value = false
|
||||||
@@ -176,7 +182,7 @@ class SatelliteRepo(
|
|||||||
val inRange = if (aosStartMinute <= aosEndMinute) {
|
val inRange = if (aosStartMinute <= aosEndMinute) {
|
||||||
aosMinute in aosStartMinute..aosEndMinute
|
aosMinute in aosStartMinute..aosEndMinute
|
||||||
} else {
|
} else {
|
||||||
aosMinute >= aosStartMinute || aosMinute <= aosEndMinute
|
aosMinute !in (aosEndMinute + 1)..<aosStartMinute
|
||||||
}
|
}
|
||||||
return if (invertAosTimeWindow) !inRange else inRange
|
return if (invertAosTimeWindow) !inRange else inRange
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,16 +38,16 @@ class SelectionRepo(
|
|||||||
) : ISelectionRepo {
|
) : ISelectionRepo {
|
||||||
|
|
||||||
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
|
||||||
private val currentModes = MutableStateFlow(settingsRepo.selectedSatModes.value)
|
|
||||||
private val currentQuery = MutableStateFlow("")
|
private val currentQuery = MutableStateFlow("")
|
||||||
|
|
||||||
// Resolve sat IDs once when modes change, then filter items reactively.
|
// Resolve sat IDs once when modes change, then filter items reactively.
|
||||||
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
||||||
private val itemsWithModes = currentModes.flatMapLatest { modes: List<String> ->
|
// Directly observe settingsRepo.selectedSatModes to ensure real-time sync across screens.
|
||||||
val catnumSet: Set<Int>? = if (modes.isEmpty()) {
|
private val itemsWithModes = settingsRepo.selectedSatModes.flatMapLatest { list: List<String> ->
|
||||||
|
val catnumSet: Set<Int>? = if (list.isEmpty()) {
|
||||||
null // null = no filtering
|
null // null = no filtering
|
||||||
} else {
|
} else {
|
||||||
val ids = localSource.getIdsWithModes(modes)
|
val ids = localSource.getIdsWithModes(list)
|
||||||
if (ids.isEmpty()) null else ids.toHashSet()
|
if (ids.isEmpty()) null else ids.toHashSet()
|
||||||
}
|
}
|
||||||
currentItems.map { items ->
|
currentItems.map { items ->
|
||||||
@@ -65,7 +65,7 @@ class SelectionRepo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getCurrentModes() = currentModes.value
|
override fun getCurrentModes() = settingsRepo.selectedSatModes.value
|
||||||
|
|
||||||
override fun getModesList() = Sources.satelliteModes
|
override fun getModesList() = Sources.satelliteModes
|
||||||
|
|
||||||
@@ -78,7 +78,6 @@ class SelectionRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setModes(modes: List<String>) {
|
override suspend fun setModes(modes: List<String>) {
|
||||||
currentModes.value = modes
|
|
||||||
settingsRepo.setSelectedSatModes(modes)
|
settingsRepo.setSelectedSatModes(modes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -86,6 +87,8 @@ class SettingsRepo(
|
|||||||
private val keySstvMode = "sstvMode"
|
private val keySstvMode = "sstvMode"
|
||||||
private val keyLowElevation = "lowElevation"
|
private val keyLowElevation = "lowElevation"
|
||||||
private val keyHighElevation = "highElevation"
|
private val keyHighElevation = "highElevation"
|
||||||
|
private val keyRadarCompassOffset = "radarCompassOffset"
|
||||||
|
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
|
||||||
private val keyUseCustomTle = "useCustomTle"
|
private val keyUseCustomTle = "useCustomTle"
|
||||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||||
private val keyTleUrl = "tleUrl"
|
private val keyTleUrl = "tleUrl"
|
||||||
@@ -332,6 +335,8 @@ class SettingsRepo(
|
|||||||
putString(keySstvMode, new.sstvMode)
|
putString(keySstvMode, new.sstvMode)
|
||||||
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
||||||
putLong(keyHighElevation, new.highElevation.toRawBits())
|
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||||
|
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
|
||||||
|
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
|
||||||
}
|
}
|
||||||
new
|
new
|
||||||
}
|
}
|
||||||
@@ -348,7 +353,9 @@ class SettingsRepo(
|
|||||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||||
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
||||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
|
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
|
||||||
|
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
|
||||||
|
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f)
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -411,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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,6 @@ import kotlinx.coroutines.CoroutineDispatcher
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
class RemoteSource(
|
class RemoteSource(
|
||||||
@@ -46,13 +45,49 @@ class RemoteSource(
|
|||||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
||||||
try {
|
try {
|
||||||
val networkRequest = Request.Builder().url(url).build()
|
val networkRequest = Request.Builder().url(url).build()
|
||||||
httpClient.newCall(networkRequest).execute().use { response ->
|
val response = httpClient.newCall(networkRequest).execute()
|
||||||
if (!response.isSuccessful) return@withContext null
|
if (!response.isSuccessful) {
|
||||||
ByteArrayInputStream(response.body.bytes())
|
response.close()
|
||||||
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
// Return the body stream directly as the caller is responsible for closing it
|
||||||
|
// That returns the connection to OkHttp's pool
|
||||||
|
response.body.byteStream().buffered()
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
println("RemoteSource network stream exception: $exception")
|
println("RemoteSource network stream exception: $exception")
|
||||||
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 {
|
||||||
|
|||||||
+4
@@ -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 → 报告
|
||||||
|
)
|
||||||
@@ -61,7 +61,9 @@ data class OtherSettings(
|
|||||||
val shouldSeeWhatsNew: Boolean,
|
val shouldSeeWhatsNew: Boolean,
|
||||||
val sstvMode: String = "Auto",
|
val sstvMode: String = "Auto",
|
||||||
val lowElevation: Double = 15.0,
|
val lowElevation: Double = 15.0,
|
||||||
val highElevation: Double = 45.0
|
val highElevation: Double = 45.0,
|
||||||
|
val radarCompassOffset: Float = 0f,
|
||||||
|
val radarCompassOffsetElev: Float = 0f
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DataSourcesSettings(
|
data class DataSourcesSettings(
|
||||||
|
|||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
|
|
||||||
|
/** AMSAT satellite status data source */
|
||||||
|
interface IAmSatRepository {
|
||||||
|
/** Fetch and parse the AMSAT status page; null on failure */
|
||||||
|
suspend fun fetchStatus(): SatStatusPage?
|
||||||
|
}
|
||||||
+1
@@ -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
|
||||||
|
|||||||
+5
@@ -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?
|
||||||
}
|
}
|
||||||
@@ -19,10 +19,10 @@ package com.rtbishop.look4sat.core.domain.source
|
|||||||
|
|
||||||
object Sources {
|
object Sources {
|
||||||
val satelliteDataUrls = mapOf(
|
val satelliteDataUrls = mapOf(
|
||||||
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
"CelesTrak" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||||
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
|
"AMSAT" to "https://amsat.org/tle/current/nasabare.txt",
|
||||||
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
"McCants Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
||||||
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
|
"McCants Integrated" to "https://www.mmccants.org/tles/inttles.zip",
|
||||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||||
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
|
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
|
||||||
"ARISS" to "https://live.ariss.org/iss.txt",
|
"ARISS" to "https://live.ariss.org/iss.txt",
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
|||||||
val line1 = tle[1]
|
val line1 = tle[1]
|
||||||
val line2 = tle[2]
|
val line2 = tle[2]
|
||||||
OrbitalData(
|
OrbitalData(
|
||||||
name = tle[0].trim(),
|
name = tle[0].trim().removePrefix("0 "),
|
||||||
epoch = line1.substring(18, 32).toDouble(),
|
epoch = line1.substring(18, 32).toDouble(),
|
||||||
meanmo = line2.substring(52, 63).toDouble(),
|
meanmo = line2.substring(52, 63).toDouble(),
|
||||||
eccn = line2.substring(26, 33).toDouble() / 1e7,
|
eccn = line2.substring(26, 33).toDouble() / 1e7,
|
||||||
|
|||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
/*
|
||||||
|
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||||
|
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.utility
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
||||||
|
*
|
||||||
|
* For a linear (passband) transponder, uplink and downlink frequencies are
|
||||||
|
* related by a fixed passband offset. When the satellite moves, both are
|
||||||
|
* Doppler-shifted. Given one, this computes the other:
|
||||||
|
*
|
||||||
|
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler)
|
||||||
|
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
|
||||||
|
*/
|
||||||
|
object DopplerFrequencyCalculator {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a downlink frequency, compute the Doppler-corrected uplink frequency.
|
||||||
|
* Returns null if the transponder is not a linear passband type.
|
||||||
|
*/
|
||||||
|
fun computeUplinkFromDownlink(
|
||||||
|
downlinkHz: Long,
|
||||||
|
transponder: SatRadio,
|
||||||
|
orbitalPos: OrbitalPos
|
||||||
|
): Long? {
|
||||||
|
if (!isLinearTransponder(transponder)) return null
|
||||||
|
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz, transponder) ?: return null
|
||||||
|
return orbitalPos.getUplinkFreq(baseUplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
|
||||||
|
* with an offset applied to the downlink (in Hz).
|
||||||
|
* Returns null if the transponder is not a linear passband type.
|
||||||
|
*
|
||||||
|
* The user-entered downlink frequency already includes the offset, so subtract
|
||||||
|
* it before mapping the downlink passband position back to the uplink.
|
||||||
|
*/
|
||||||
|
fun computeUplinkFromDownlinkWithOffset(
|
||||||
|
downlinkHz: Long,
|
||||||
|
transponder: SatRadio,
|
||||||
|
orbitalPos: OrbitalPos,
|
||||||
|
offsetHz: Long
|
||||||
|
): Long? {
|
||||||
|
if (!isLinearTransponder(transponder)) return null
|
||||||
|
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
|
||||||
|
return orbitalPos.getUplinkFreq(baseUplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an uplink frequency, compute the Doppler-corrected downlink frequency.
|
||||||
|
* Returns null if the transponder is not a linear passband type.
|
||||||
|
*/
|
||||||
|
fun computeDownlinkFromUplink(
|
||||||
|
uplinkHz: Long,
|
||||||
|
transponder: SatRadio,
|
||||||
|
orbitalPos: OrbitalPos
|
||||||
|
): Long? {
|
||||||
|
if (!isLinearTransponder(transponder)) return null
|
||||||
|
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
|
||||||
|
return orbitalPos.getDownlinkFreq(baseDownlink)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an uplink frequency, compute the Doppler-corrected downlink frequency
|
||||||
|
* with an offset applied to the downlink (in Hz).
|
||||||
|
* Returns null if the transponder is not a linear passband type.
|
||||||
|
*/
|
||||||
|
fun computeDownlinkFromUplinkWithOffset(
|
||||||
|
uplinkHz: Long,
|
||||||
|
transponder: SatRadio,
|
||||||
|
orbitalPos: OrbitalPos,
|
||||||
|
offsetHz: Long
|
||||||
|
): Long? {
|
||||||
|
if (!isLinearTransponder(transponder)) return null
|
||||||
|
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
|
||||||
|
return orbitalPos.getDownlinkFreq(baseDownlink + offsetHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if this transponder supports linear passband mapping. */
|
||||||
|
fun isLinearTransponder(transponder: SatRadio): Boolean {
|
||||||
|
val upLow = transponder.uplinkLow
|
||||||
|
val upHigh = transponder.uplinkHigh
|
||||||
|
val downLow = transponder.downlinkLow
|
||||||
|
val downHigh = transponder.downlinkHigh
|
||||||
|
return upLow != null && upHigh != null && downLow != null && downHigh != null
|
||||||
|
&& upLow != upHigh && downLow != downHigh
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for the radio entry that should drive the standalone Calculator page.
|
||||||
|
*
|
||||||
|
* A frequency range alone is not enough: some non-user-facing or drifting data entries
|
||||||
|
* can also have low/high frequencies. The calculator is meant for named linear
|
||||||
|
* transponders, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
|
||||||
|
*/
|
||||||
|
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
|
||||||
|
if (!isLinearTransponder(transponder)) return false
|
||||||
|
|
||||||
|
val info = transponder.info.lowercase(Locale.ENGLISH)
|
||||||
|
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
||||||
|
.joinToString(separator = " ")
|
||||||
|
.lowercase(Locale.ENGLISH)
|
||||||
|
val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
|
||||||
|
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
|
||||||
|
info.contains("xponder") || info.contains("xpdr")
|
||||||
|
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
|
||||||
|
|
||||||
|
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) ||
|
||||||
|
(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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+273
@@ -0,0 +1,273 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DopplerFrequencyCalculatorTest {
|
||||||
|
|
||||||
|
private fun linearTransponder(
|
||||||
|
uuid: String = "linear",
|
||||||
|
upLow: Long = 145_000_000L,
|
||||||
|
upHigh: Long = 145_500_000L,
|
||||||
|
downLow: Long = 435_000_000L,
|
||||||
|
downHigh: Long? = 435_500_000L,
|
||||||
|
inverted: Boolean = false,
|
||||||
|
info: String = "Linear Transponder",
|
||||||
|
downlinkMode: String? = "USB",
|
||||||
|
uplinkMode: String? = "LSB"
|
||||||
|
) = SatRadio(
|
||||||
|
uuid = uuid, info = info, isAlive = true,
|
||||||
|
downlinkLow = downLow, downlinkHigh = downHigh,
|
||||||
|
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
|
||||||
|
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun fmTransponder() = SatRadio(
|
||||||
|
uuid = "fm", info = "FM Repeater", isAlive = true,
|
||||||
|
downlinkLow = 435_600_000L, downlinkHigh = null,
|
||||||
|
downlinkMode = "FM", uplinkLow = 145_900_000L, uplinkHigh = null,
|
||||||
|
uplinkMode = "FM", isInverted = false, catnum = 99999
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun pos(distanceRateKmS: Double = 0.0) = OrbitalPos().apply {
|
||||||
|
this.distanceRate = distanceRateKmS
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isLinearTransponder_returnsTrueForLinear() {
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isLinearTransponder(linearTransponder()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isLinearTransponder_returnsFalseForFM() {
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(fmTransponder()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isLinearTransponder_returnsFalseForNullDownlinkHigh() {
|
||||||
|
val xpdr = linearTransponder(downHigh = null)
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsTrueForLinearTransponderName() {
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(linearTransponder()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsTrueForSsbTransponderName() {
|
||||||
|
val xpdr = linearTransponder(info = "Mode V/U SSB Transponder", downlinkMode = "USB", uplinkMode = "LSB")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(xpdr))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsFalseForRangeEntryWithoutTransponderName() {
|
||||||
|
val driftingRangeEntry = linearTransponder(info = "Upper side band (drifting)")
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_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
|
||||||
|
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
|
||||||
|
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
|
||||||
|
fun computeUplinkFromDownlink_linear_noDoppler() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||||
|
assertNotNull(uplink)
|
||||||
|
assertEquals(145_200_000L, uplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeDownlinkFromUplink_linear_noDoppler() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_200_000L, xpdr, orbitalPos)
|
||||||
|
assertNotNull(downlink)
|
||||||
|
assertEquals(435_200_000L, downlink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() {
|
||||||
|
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate.
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(7.0)
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||||
|
assertNotNull(uplink)
|
||||||
|
assertTrue(uplink!! > 145_200_000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_fmTransponder_returnsNull() {
|
||||||
|
val orbitalPos = pos()
|
||||||
|
val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos)
|
||||||
|
assertNull(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeDownlinkFromUplink_fmTransponder_returnsNull() {
|
||||||
|
val orbitalPos = pos()
|
||||||
|
val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos)
|
||||||
|
assertNull(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeDownlinkFromUplink_withPositiveOffset_addsOffsetToDownlink() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||||
|
uplinkHz = 145_200_000L,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = 2_500L
|
||||||
|
)
|
||||||
|
assertEquals(435_202_500L, downlink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_withPositiveOffset_subtractsOffsetBeforeMapping() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||||
|
downlinkHz = 435_202_500L,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = 2_500L
|
||||||
|
)
|
||||||
|
assertEquals(145_200_000L, uplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeOffsetRoundTrip_handlesNegativeOffset() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||||
|
uplinkHz = 145_200_000L,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = -2_500L
|
||||||
|
)
|
||||||
|
assertEquals(435_197_500L, downlink)
|
||||||
|
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||||
|
downlinkHz = downlink!!,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = -2_500L
|
||||||
|
)
|
||||||
|
assertEquals(145_200_000L, uplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_invertedTransponder() {
|
||||||
|
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
|
||||||
|
assertNotNull(uplink)
|
||||||
|
assertEquals(145_300_000L, uplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_roundTrip() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(3.5)
|
||||||
|
val originalDownlink = 435_250_000L
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(originalDownlink, xpdr, orbitalPos)
|
||||||
|
assertNotNull(uplink)
|
||||||
|
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
|
||||||
|
assertNotNull(roundTripDownlink)
|
||||||
|
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
|
||||||
|
assertTrue("Round-trip error too large: $error", error < 10000)
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-4
@@ -47,6 +47,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.SheetValue
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -144,7 +145,7 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
}
|
}
|
||||||
val sdfTime = remember(isUtc) {
|
val sdfTime = remember(isUtc) {
|
||||||
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
|
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
|
||||||
}
|
}
|
||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -213,6 +214,11 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun displayLocale(): Locale {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
@@ -303,7 +309,8 @@ fun SharedDialog(
|
|||||||
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
|
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
|
||||||
content: @Composable (padding: Dp) -> Unit
|
content: @Composable (padding: Dp) -> Unit
|
||||||
) {
|
) {
|
||||||
DialogShell(onDismissRequest = onDismissRequest) { padding ->
|
val dismissible = onCancel != null
|
||||||
|
DialogShell(onDismissRequest = onDismissRequest, dismissible = dismissible) { padding ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -337,10 +344,14 @@ fun SharedDialog(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DialogShell(
|
private fun DialogShell(
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
|
dismissible: Boolean = true,
|
||||||
content: @Composable (padding: Dp) -> Unit
|
content: @Composable (padding: Dp) -> Unit
|
||||||
) {
|
) {
|
||||||
val padding = LocalSpacing.current.large
|
val padding = LocalSpacing.current.large
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(
|
||||||
|
skipPartiallyExpanded = true,
|
||||||
|
confirmValueChange = { if (dismissible) true else it != SheetValue.Hidden }
|
||||||
|
)
|
||||||
val stopSheetFling = remember {
|
val stopSheetFling = remember {
|
||||||
object : NestedScrollConnection {
|
object : NestedScrollConnection {
|
||||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||||
@@ -355,7 +366,7 @@ private fun DialogShell(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = if (dismissible) onDismissRequest else { {} },
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
dragHandle = null,
|
dragHandle = null,
|
||||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||||
|
|||||||
+8
-4
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="15"
|
||||||
|
android:viewportHeight="15">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M13.91,6.75c-1.17,2.25 -4.3,5.31 -6.07,6.94c-0.19,0.172 -0.48,0.172 -0.67,0C5.39,12.06 2.26,9 1.09,6.75C-1.48,1.8 5,-1.5 7.5,3.45C10,-1.5 16.48,1.8 13.91,6.75z" />
|
||||||
|
</vector>
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<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="@android:color/white"
|
|
||||||
android:pathData="M14,21c1.93,0 3.62,-1.17 4,-3l-1.75,-0.88C16,18.21 15.33,19 14,19l-4.9,0c0.83,-1 1.5,-2.34 1.5,-4c0,-0.35 -0.03,-0.69 -0.08,-1L14,14v-2l-4.18,0C9,10.42 8,9.6 8,8c0,-1.93 1.57,-3.5 3.5,-3.5c1.5,0 2.79,0.95 3.28,2.28L16.63,6c-0.8,-2.05 -2.79,-3.5 -5.13,-3.5C8.46,2.5 6,4.96 6,8c0,1.78 0.79,2.9 1.49,4L6,12v2l2.47,0c0.08,0.31 0.13,0.64 0.13,1c0,2.7 -2.6,4 -2.6,4v2H14z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="960"
|
|
||||||
android:viewportHeight="960">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M240,800L280,640L120,640L140,560L300,560L340,400L180,400L200,320L360,320L400,160L480,160L440,320L600,320L640,160L720,160L680,320L840,320L820,400L660,400L620,560L780,560L760,640L600,640L560,800L480,800L520,640L360,640L320,800L240,800ZM380,560L540,560L580,400L420,400L380,560Z" />
|
|
||||||
</vector>
|
|
||||||
@@ -155,11 +155,11 @@
|
|||||||
<string name="prefs_net_perm_error">Revisa los permisos de red</string>
|
<string name="prefs_net_perm_error">Revisa los permisos de red</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Otros</string>
|
<string name="prefs_other_title">Otros</string>
|
||||||
<string name="prefs_other_switch_utc">Mostrar hora UTC</string>
|
<string name="prefs_other_switch_utc">Hora UTC</string>
|
||||||
<string name="prefs_other_switch_update">Actualización automática</string>
|
<string name="prefs_other_switch_update">Actualización automática</string>
|
||||||
<string name="prefs_other_switch_sweep">Animación de radar</string>
|
<string name="prefs_other_switch_sweep">Animación radar</string>
|
||||||
<string name="prefs_other_switch_sensors">Sensores para radar</string>
|
<string name="prefs_other_switch_sensors">Sensores</string>
|
||||||
<string name="prefs_other_switch_night_mode">Filtro nocturno rojo</string>
|
<string name="prefs_other_switch_night_mode">Filtro nocturno</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Resaltado de elevación</string>
|
<string name="prefs_highlight_title">Resaltado de elevación</string>
|
||||||
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
<string name="prefs_highlight_low">Baja < %1$d°</string>
|
||||||
|
|||||||
@@ -158,8 +158,8 @@
|
|||||||
<string name="prefs_other_switch_utc">Время в UTC</string>
|
<string name="prefs_other_switch_utc">Время в UTC</string>
|
||||||
<string name="prefs_other_switch_update">Автообновление данных</string>
|
<string name="prefs_other_switch_update">Автообновление данных</string>
|
||||||
<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_highlight_title">Подсветка высоты</string>
|
<string name="prefs_highlight_title">Подсветка высоты</string>
|
||||||
<string name="prefs_highlight_low">Низко < %1$d°</string>
|
<string name="prefs_highlight_low">Низко < %1$d°</string>
|
||||||
|
|||||||
@@ -155,11 +155,11 @@
|
|||||||
<string name="prefs_net_perm_error">Network අවසර පරික්ෂා කරන්න</string>
|
<string name="prefs_net_perm_error">Network අවසර පරික්ෂා කරන්න</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">වෙනත්</string>
|
<string name="prefs_other_title">වෙනත්</string>
|
||||||
<string name="prefs_other_switch_utc">UTC වේලාව පෙන්වන්න</string>
|
<string name="prefs_other_switch_utc">UTC වේලාව</string>
|
||||||
<string name="prefs_other_switch_update">දත්ත ස්වයං යාවත්කාලීන</string>
|
<string name="prefs_other_switch_update">දත්ත ස්වයං යාවත්කාලීන</string>
|
||||||
<string name="prefs_other_switch_sweep">Radar sweep සජීවිකරණය</string>
|
<string name="prefs_other_switch_sweep">Radar sweep</string>
|
||||||
<string name="prefs_other_switch_sensors">Radar සඳහා සංවේදක භාවිතා කරන්න</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_highlight_title">උස් උද්දීපනය</string>
|
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
|
||||||
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
<string name="prefs_highlight_low">අඩු < %1$d°</string>
|
||||||
|
|||||||
@@ -187,11 +187,11 @@
|
|||||||
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
|
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Diğer</string>
|
<string name="prefs_other_title">Diğer</string>
|
||||||
<string name="prefs_other_switch_utc">UTC saatini göster</string>
|
<string name="prefs_other_switch_utc">UTC saati</string>
|
||||||
<string name="prefs_other_switch_update">Veriyi otomatik güncelle</string>
|
<string name="prefs_other_switch_update">Veriyi otomatik güncelle</string>
|
||||||
<string name="prefs_other_switch_sweep">Radar tarama animasyonu</string>
|
<string name="prefs_other_switch_sweep">Radar taraması</string>
|
||||||
<string name="prefs_other_switch_sensors">Radar için sensör kullan</string>
|
<string name="prefs_other_switch_sensors">Sensör</string>
|
||||||
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresi</string>
|
<string name="prefs_other_switch_night_mode">Gece filtresi</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
|
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
|
||||||
<string name="prefs_highlight_low">Düşük < %1$d°</string>
|
<string name="prefs_highlight_low">Düşük < %1$d°</string>
|
||||||
|
|||||||
@@ -158,8 +158,8 @@
|
|||||||
<string name="prefs_other_switch_utc">Час в UTC</string>
|
<string name="prefs_other_switch_utc">Час в UTC</string>
|
||||||
<string name="prefs_other_switch_update">Автооновлення даних</string>
|
<string name="prefs_other_switch_update">Автооновлення даних</string>
|
||||||
<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_highlight_title">Підсвічування висоти</string>
|
<string name="prefs_highlight_title">Підсвічування висоти</string>
|
||||||
<string name="prefs_highlight_low">Низько < %1$d°</string>
|
<string name="prefs_highlight_low">Низько < %1$d°</string>
|
||||||
|
|||||||
@@ -60,6 +60,9 @@
|
|||||||
<string name="radar_string_no">否</string>
|
<string name="radar_string_no">否</string>
|
||||||
<string name="radar_string_yes">是</string>
|
<string name="radar_string_yes">是</string>
|
||||||
<string name="radar_visible">日照中</string>
|
<string name="radar_visible">日照中</string>
|
||||||
|
<string name="radar_doppler_tx_hint">输入上行频率 (MHz)</string>
|
||||||
|
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
|
||||||
|
<string name="radar_doppler_offset_hint">偏移 (kHz)</string>
|
||||||
|
|
||||||
<!-- Map screen -->
|
<!-- Map screen -->
|
||||||
<string name="map_prev">上一个</string>
|
<string name="map_prev">上一个</string>
|
||||||
@@ -84,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>
|
||||||
@@ -92,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>
|
||||||
@@ -152,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">低 < %1$d°</string>
|
<string name="prefs_highlight_low">低 < %1$d°</string>
|
||||||
|
|||||||
@@ -55,10 +55,33 @@
|
|||||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
||||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* Added required tweaks to support Android 17 (API 37)
|
* Added small tweaks to Sources and strings. Added official ISS source (#225)
|
||||||
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
\n* Implemented AOS window and elevation highlight filters (#226)
|
||||||
|
\n* Added cleartext traffic support for custom TLE URLs (#227)
|
||||||
|
\n* Fixed manual OMM (.csv) data import, tweaked error messaging (#228)
|
||||||
|
\n* Added support for Icom IC-705 CAT by ruilvo (#229)
|
||||||
|
\n* Added configurable Radar offset to the sensors output by qwqtoday (#230)
|
||||||
|
\n* Localized pass date and time formats for Chinese by atsunatsu (#231)
|
||||||
|
\n* Added linear transponder Doppler calculator by atsunatsu (#232)
|
||||||
|
\n* Added a few tweaks to SSTV sensitivity and reception, similar to Robot36
|
||||||
|
\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>
|
||||||
@@ -84,6 +107,9 @@
|
|||||||
<string name="radar_string_no">No</string>
|
<string name="radar_string_no">No</string>
|
||||||
<string name="radar_string_yes">Yes</string>
|
<string name="radar_string_yes">Yes</string>
|
||||||
<string name="radar_visible">Visible</string>
|
<string name="radar_visible">Visible</string>
|
||||||
|
<string name="radar_doppler_tx_hint">Enter TX freq (MHz)</string>
|
||||||
|
<string name="radar_doppler_rx_hint">Enter RX freq (MHz)</string>
|
||||||
|
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
|
||||||
|
|
||||||
<!-- Map screen -->
|
<!-- Map screen -->
|
||||||
<string name="map_prev">Prev</string>
|
<string name="map_prev">Prev</string>
|
||||||
@@ -184,11 +210,13 @@
|
|||||||
<string name="prefs_net_perm_error">Check your network permission</string>
|
<string name="prefs_net_perm_error">Check your network permission</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Other</string>
|
<string name="prefs_other_title">Other</string>
|
||||||
<string name="prefs_other_switch_utc">Show UTC time</string>
|
<string name="prefs_other_switch_utc">UTC time</string>
|
||||||
<string name="prefs_other_switch_update">Auto-update data</string>
|
<string name="prefs_other_switch_update">Auto-update data</string>
|
||||||
<string name="prefs_other_switch_sweep">Radar sweep animation</string>
|
<string name="prefs_other_switch_sweep">Radar sweep</string>
|
||||||
<string name="prefs_other_switch_sensors">Use sensors for radar</string>
|
<string name="prefs_other_switch_sensors">Use sensors</string>
|
||||||
<string name="prefs_other_switch_night_mode">Red night filter</string>
|
<string name="prefs_other_switch_night_mode">Night filter</string>
|
||||||
|
<string name="prefs_other_compass_offset">Radar compass offset</string>
|
||||||
|
<string name="prefs_other_compass_offset_elev">Radar compass offset (elev)</string>
|
||||||
|
|
||||||
<string name="prefs_highlight_title">Elevation highlight</string>
|
<string name="prefs_highlight_title">Elevation highlight</string>
|
||||||
<string name="prefs_highlight_low">Low < %1$d°</string>
|
<string name="prefs_highlight_low">Low < %1$d°</string>
|
||||||
|
|||||||
@@ -1,2 +1,8 @@
|
|||||||
* Added required tweaks to support Android 17 (API 37)
|
* Added ARISS source, tweaked SSTV sensitivity #225
|
||||||
* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
* Implemented AOS window and elev filter #226
|
||||||
|
* Added cleartext traffic support for TLEs #227
|
||||||
|
* Fixed OMM import, tweaked error messages #228
|
||||||
|
* Added support for Icom CAT by ruilvo #229
|
||||||
|
* Added tweakable Radar offset by qwqtoday #230
|
||||||
|
* Localized date/time formats by atsunatsu #231
|
||||||
|
* Added linear radio Doppler calc by atsunatsu #232
|
||||||
@@ -156,7 +156,7 @@ private fun PassesScreen(
|
|||||||
TopBar(
|
TopBar(
|
||||||
isVerticalLayout = isVerticalLayout,
|
isVerticalLayout = isVerticalLayout,
|
||||||
startAction = {
|
startAction = {
|
||||||
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
|
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
|
||||||
},
|
},
|
||||||
topInfo = {
|
topInfo = {
|
||||||
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
|
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
|
||||||
@@ -165,7 +165,7 @@ private fun PassesScreen(
|
|||||||
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
|
||||||
},
|
},
|
||||||
endAction = {
|
endAction = {
|
||||||
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
|
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -292,6 +292,11 @@ private fun StickyDateHeader(label: String, sunriseTime: String, sunsetTime: Str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun displayLocale(): Locale {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
|
||||||
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun DeepSpacePassPreview() {
|
private fun DeepSpacePassPreview() {
|
||||||
@@ -324,7 +329,7 @@ private fun PassItem(
|
|||||||
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
}
|
}
|
||||||
val sdfTime = remember(isUtc) {
|
val sdfTime = remember(isUtc) {
|
||||||
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
|
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
|
||||||
}
|
}
|
||||||
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
|
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
|
||||||
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
|
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
|
||||||
|
|||||||
+51
-26
@@ -34,6 +34,9 @@ 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.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -41,6 +44,7 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class PassesViewModel(
|
class PassesViewModel(
|
||||||
private val satelliteRepo: ISatelliteRepo,
|
private val satelliteRepo: ISatelliteRepo,
|
||||||
@@ -91,32 +95,37 @@ class PassesViewModel(
|
|||||||
_uiState.update { it.copy(modes = modes) }
|
_uiState.update { it.copy(modes = modes) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Main tick loop: reacts to new passes, then ticks every second
|
// Tick loop: restarts on passes change, UTC/DeepSpace changes. Grouping/sun-time
|
||||||
|
// computations run once per restart, progress/countdown are calculated every second.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
satelliteRepo.passes.collectLatest { allPasses ->
|
combine(
|
||||||
while (isActive) {
|
satelliteRepo.passes,
|
||||||
val timeNow = System.currentTimeMillis()
|
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged(),
|
||||||
val isUtc = _uiState.value.isUtc
|
settingsRepo.passesSettings.map { it.showDeepSpace }.distinctUntilChanged()
|
||||||
val showDeepSpace = _uiState.value.showDeepSpace
|
) { passes, isUtc, showDeepSpace -> Triple(passes, isUtc, showDeepSpace) }
|
||||||
val filtered = allPasses
|
.collectLatest { (allPasses, isUtc, showDeepSpace) ->
|
||||||
.let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
|
val filtered = if (showDeepSpace) allPasses
|
||||||
val processed = computePassProgress(filtered, timeNow)
|
else allPasses.filter { !it.isDeepSpace }
|
||||||
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
// Expensive: recompute sun times once per items/UTC change, not every second
|
||||||
val sunTimes = computeSunTimes(processed, isUtc)
|
val sunTimes = computeSunTimes(filtered, isUtc)
|
||||||
val grouped = groupPasses(processed, isUtc)
|
_uiState.update { it.copy(sunTimes = sunTimes) }
|
||||||
_uiState.update {
|
while (isActive) {
|
||||||
it.copy(
|
val timeNow = System.currentTimeMillis()
|
||||||
itemsList = processed,
|
val processed = computePassProgress(filtered, timeNow)
|
||||||
groupedPasses = grouped,
|
val grouped = groupPasses(processed, isUtc)
|
||||||
sunTimes = sunTimes,
|
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
||||||
nextPass = nextPass,
|
_uiState.update {
|
||||||
nextTime = nextTime,
|
it.copy(
|
||||||
isNextTimeAos = isAos
|
itemsList = processed,
|
||||||
)
|
groupedPasses = grouped,
|
||||||
|
nextPass = nextPass,
|
||||||
|
nextTime = nextTime,
|
||||||
|
isNextTimeAos = isAos
|
||||||
|
)
|
||||||
|
}
|
||||||
|
delay(1000.milliseconds)
|
||||||
}
|
}
|
||||||
delay(1000)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,12 +154,28 @@ class PassesViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun displayLocale(): Locale {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the visible pass-date format. Keep non-Chinese locales identical to upstream. */
|
||||||
|
private fun dateFormat(tz: TimeZone): SimpleDateFormat {
|
||||||
|
val locale = displayLocale()
|
||||||
|
val pattern = if (locale.language == Locale.CHINESE.language) {
|
||||||
|
"yyyy'年'M'月'd'日' EEEE"
|
||||||
|
} else {
|
||||||
|
"EEE, dd MMM yyyy"
|
||||||
|
}
|
||||||
|
return SimpleDateFormat(pattern, locale).also { it.timeZone = tz }
|
||||||
|
}
|
||||||
|
|
||||||
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
|
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
|
||||||
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
||||||
val stationPos = settingsRepo.stationPosition.value
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
|
val sdfDate = dateFormat(tz)
|
||||||
val sdfTime = SimpleDateFormat("HH:mm", Locale.ENGLISH).also { it.timeZone = tz }
|
val sdfTime = SimpleDateFormat("HH:mm", displayLocale()).also { it.timeZone = tz }
|
||||||
val result = LinkedHashMap<String, Pair<String, String>>()
|
val result = LinkedHashMap<String, Pair<String, String>>()
|
||||||
// DeepSpace group always shows today's sun times
|
// DeepSpace group always shows today's sun times
|
||||||
if (passes.any { it.isDeepSpace }) {
|
if (passes.any { it.isDeepSpace }) {
|
||||||
@@ -173,7 +198,7 @@ class PassesViewModel(
|
|||||||
|
|
||||||
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
|
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
|
||||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
|
val sdfDate = dateFormat(tz)
|
||||||
val ordered = LinkedHashMap<String, List<OrbitalPass>>()
|
val ordered = LinkedHashMap<String, List<OrbitalPass>>()
|
||||||
val deepSpace = passes.filter { it.isDeepSpace }
|
val deepSpace = passes.filter { it.isDeepSpace }
|
||||||
if (deepSpace.isNotEmpty()) ordered["DeepSpace (period >225min)"] = deepSpace
|
if (deepSpace.isNotEmpty()) ordered["DeepSpace (period >225min)"] = deepSpace
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ 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.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -59,6 +60,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
@@ -74,6 +76,7 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
private enum class RadarPage(val title: String) {
|
private enum class RadarPage(val title: String) {
|
||||||
Transceivers("Transceivers"),
|
Transceivers("Transceivers"),
|
||||||
|
Calculator("Calculator"),
|
||||||
Sstv("SSTV")
|
Sstv("SSTV")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,16 +153,31 @@ private fun PagerCard(
|
|||||||
requestMicPermission: () -> Unit,
|
requestMicPermission: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val pages = RadarPage.entries
|
val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
|
||||||
|
uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
|
||||||
|
}
|
||||||
|
val pages = remember(hasCalculatorPage) {
|
||||||
|
buildList {
|
||||||
|
add(RadarPage.Transceivers)
|
||||||
|
if (hasCalculatorPage) add(RadarPage.Calculator)
|
||||||
|
add(RadarPage.Sstv)
|
||||||
|
}
|
||||||
|
}
|
||||||
val pagerState = rememberPagerState(pageCount = { pages.size })
|
val pagerState = rememberPagerState(pageCount = { pages.size })
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
LaunchedEffect(pages.size) {
|
||||||
|
val lastPage = pages.lastIndex
|
||||||
|
if (pagerState.currentPage > lastPage) pagerState.scrollToPage(lastPage)
|
||||||
|
}
|
||||||
|
|
||||||
ElevatedCard(modifier = modifier) {
|
ElevatedCard(modifier = modifier) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
PrimaryTabRow(selectedTabIndex = pagerState.currentPage) {
|
val selectedTabIndex = pagerState.currentPage.coerceIn(0, pages.lastIndex)
|
||||||
|
PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
|
||||||
pages.forEachIndexed { index, page ->
|
pages.forEachIndexed { index, page ->
|
||||||
Tab(
|
Tab(
|
||||||
selected = pagerState.currentPage == index,
|
selected = selectedTabIndex == index,
|
||||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
|
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
|
||||||
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||||
)
|
)
|
||||||
@@ -176,6 +194,13 @@ private fun PagerCard(
|
|||||||
radioControl = uiState.radioControl,
|
radioControl = uiState.radioControl,
|
||||||
onAction = onAction
|
onAction = onAction
|
||||||
)
|
)
|
||||||
|
RadarPage.Calculator -> CalculatorPage(
|
||||||
|
transceivers = uiState.transceivers.transmitters,
|
||||||
|
selectedUuid = uiState.transceivers.selectedUuid,
|
||||||
|
orbitalPos = uiState.orbitalPos,
|
||||||
|
onAction = onAction,
|
||||||
|
calculatorOffsetKHz = uiState.calculatorOffsetKHz
|
||||||
|
)
|
||||||
RadarPage.Sstv -> SstvPage(
|
RadarPage.Sstv -> SstvPage(
|
||||||
sstv = uiState.sstv,
|
sstv = uiState.sstv,
|
||||||
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
|
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
|
||||||
@@ -225,6 +250,7 @@ private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
|
|||||||
azimElev = uiState.orientationValues,
|
azimElev = uiState.orientationValues,
|
||||||
shouldShowSweep = uiState.shouldShowSweep,
|
shouldShowSweep = uiState.shouldShowSweep,
|
||||||
shouldUseCompass = uiState.shouldUseCompass,
|
shouldUseCompass = uiState.shouldUseCompass,
|
||||||
|
shouldFlipRadar = uiState.shouldFlipRadar,
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
sunPosition = uiState.sunPosition,
|
sunPosition = uiState.sunPosition,
|
||||||
moonPosition = uiState.moonPosition,
|
moonPosition = uiState.moonPosition,
|
||||||
|
|||||||
@@ -58,11 +58,13 @@ data class RadarState(
|
|||||||
val satTrack: List<OrbitalPos> = emptyList(),
|
val satTrack: List<OrbitalPos> = emptyList(),
|
||||||
val shouldShowSweep: Boolean = false,
|
val shouldShowSweep: Boolean = false,
|
||||||
val shouldUseCompass: Boolean = false,
|
val shouldUseCompass: Boolean = false,
|
||||||
|
val shouldFlipRadar: Boolean = false,
|
||||||
val sunPosition: CelestialComputer.SunPosition? = null,
|
val sunPosition: CelestialComputer.SunPosition? = null,
|
||||||
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 }
|
||||||
@@ -96,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
|
||||||
}
|
}
|
||||||
@@ -74,6 +74,7 @@ fun RadarViewCompose(
|
|||||||
azimElev: Pair<Float, Float>,
|
azimElev: Pair<Float, Float>,
|
||||||
shouldShowSweep: Boolean,
|
shouldShowSweep: Boolean,
|
||||||
shouldUseCompass: Boolean,
|
shouldUseCompass: Boolean,
|
||||||
|
shouldFlipRadar: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
sunPosition: CelestialComputer.SunPosition? = null,
|
sunPosition: CelestialComputer.SunPosition? = null,
|
||||||
moonPosition: CelestialComputer.MoonPosition? = null,
|
moonPosition: CelestialComputer.MoonPosition? = null,
|
||||||
@@ -125,7 +126,9 @@ fun RadarViewCompose(
|
|||||||
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
cachedSweepBrush = makeSweepBrush(center, primaryColor)
|
||||||
cachedSweepColor = primaryColor
|
cachedSweepColor = primaryColor
|
||||||
}
|
}
|
||||||
rotate(if (shouldUseCompass) -azimElev.first else 0f) {
|
val baseRotation = if (shouldUseCompass) -azimElev.first else 0f
|
||||||
|
val rotation = if (shouldFlipRadar) baseRotation + 180f else baseRotation
|
||||||
|
rotate(rotation) {
|
||||||
if (shouldShowSweep) cachedSweepBrush?.let { drawSweep(center, sweepDegrees, radius, it) }
|
if (shouldShowSweep) cachedSweepBrush?.let { drawSweep(center, sweepDegrees, radius, it) }
|
||||||
drawRadar(radius, radarColor)
|
drawRadar(radius, radarColor)
|
||||||
drawElevationLabels(radius, primaryColor, measurer)
|
drawElevationLabels(radius, primaryColor, measurer)
|
||||||
|
|||||||
@@ -65,11 +65,14 @@ 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 compassOffsetElev = settingsRepo.otherSettings.value.radarCompassOffsetElev
|
||||||
private var transponders: List<SatRadio> = emptyList()
|
private var transponders: List<SatRadio> = emptyList()
|
||||||
private var sstvDecoder: SstvDecoder? = null
|
private var sstvDecoder: SstvDecoder? = null
|
||||||
private var sstvRecordingJob: Job? = null
|
private var sstvRecordingJob: Job? = null
|
||||||
|
private var sensorCollectionJob: Job? = null
|
||||||
|
|
||||||
// Celestial positions change slowly, recompute at most once per minute
|
// Celestial positions change slowly, recompute at most once per minute
|
||||||
private var lastCelestialUpdateMs = 0L
|
private var lastCelestialUpdateMs = 0L
|
||||||
@@ -88,33 +91,67 @@ class RadarViewModel(
|
|||||||
val uiState: StateFlow<RadarState> = _uiState
|
val uiState: StateFlow<RadarState> = _uiState
|
||||||
|
|
||||||
init {
|
init {
|
||||||
collectCompassSensor()
|
collectSettingsChanges() // also handles initial sensor subscription
|
||||||
collectSettingsChanges()
|
collectStationPositionChanges()
|
||||||
collectPassAndStartTickLoop()
|
collectPassAndStartTickLoop()
|
||||||
collectRadioTrackingState()
|
collectRadioTrackingState()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun collectCompassSensor() {
|
// Starts sensor collection if not already running.
|
||||||
if (!settingsRepo.otherSettings.value.stateOfSensors) return
|
private fun startSensorCollection() {
|
||||||
viewModelScope.launch {
|
if (sensorCollectionJob?.isActive == true) return
|
||||||
|
sensorCollectionJob = viewModelScope.launch {
|
||||||
sensorsRepo.enableSensor()
|
sensorsRepo.enableSensor()
|
||||||
sensorsRepo.sensorData.collect { data ->
|
sensorsRepo.sensorData.collect { data ->
|
||||||
val orientationValues = (data.first + magDeclination) to data.second
|
val orientationValues =
|
||||||
|
(data.first + magDeclination + compassOffset) to (data.second + compassOffsetElev)
|
||||||
_uiState.update { it.copy(orientationValues = orientationValues) }
|
_uiState.update { it.copy(orientationValues = orientationValues) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stops sensor collection and disables the hardware sensor.
|
||||||
|
private fun stopSensorCollection() {
|
||||||
|
sensorCollectionJob?.cancel()
|
||||||
|
sensorCollectionJob = null
|
||||||
|
sensorsRepo.disableSensor()
|
||||||
|
}
|
||||||
|
|
||||||
private fun collectSettingsChanges() {
|
private fun collectSettingsChanges() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepo.otherSettings.collectLatest { settings ->
|
settingsRepo.otherSettings.collect { settings ->
|
||||||
|
compassOffset = settings.radarCompassOffset
|
||||||
|
compassOffsetElev = settings.radarCompassOffsetElev
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isUtc = settings.stateOfUtc,
|
isUtc = settings.stateOfUtc,
|
||||||
shouldShowSweep = settings.stateOfSweep,
|
shouldShowSweep = settings.stateOfSweep,
|
||||||
shouldUseCompass = settings.stateOfSensors
|
shouldUseCompass = settings.stateOfSensors,
|
||||||
|
shouldFlipRadar = settings.radarCompassOffsetElev < 0f
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Reactively wire sensor hardware to the compass setting
|
||||||
|
when {
|
||||||
|
settings.stateOfSensors -> startSensorCollection()
|
||||||
|
else -> stopSensorCollection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,7 +250,7 @@ class RadarViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
sensorsRepo.disableSensor()
|
stopSensorCollection()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onAction(action: RadarAction) {
|
fun onAction(action: RadarAction) {
|
||||||
@@ -223,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) {
|
||||||
@@ -272,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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,7 +455,7 @@ class RadarViewModel(
|
|||||||
// - 0.25: Aggressive (original default), amplifies noise; use only for clean inputs
|
// - 0.25: Aggressive (original default), amplifies noise; use only for clean inputs
|
||||||
// - 0.35-0.40: RECOMMENDED for typical phone/mic inputs
|
// - 0.35-0.40: RECOMMENDED for typical phone/mic inputs
|
||||||
// - 0.50-0.60: Conservative, best for noisy environments
|
// - 0.50-0.60: Conservative, best for noisy environments
|
||||||
private const val SSTV_TARGET_RMS = 0.40f // Changed from 0.25 to safer default
|
private const val SSTV_TARGET_RMS = 0.45f // Changed from 0.25 to safer default
|
||||||
|
|
||||||
// Enable/disable RMS normalization entirely. Set false for direct receiver outputs.
|
// Enable/disable RMS normalization entirely. Set false for direct receiver outputs.
|
||||||
private const val SSTV_ENABLE_RMS_NORMALIZATION = true
|
private const val SSTV_ENABLE_RMS_NORMALIZATION = true
|
||||||
@@ -422,7 +478,7 @@ class RadarViewModel(
|
|||||||
// - Look4SatLimited: caps predicted lines to reduce visible vertical collapse.
|
// - Look4SatLimited: caps predicted lines to reduce visible vertical collapse.
|
||||||
// - Robot36Compatible: unlimited predicted lines (legacy Robot36 behavior).
|
// - Robot36Compatible: unlimited predicted lines (legacy Robot36 behavior).
|
||||||
// Ask users to compare both if they report line-skipping/compression artifacts.
|
// Ask users to compare both if they report line-skipping/compression artifacts.
|
||||||
private val SSTV_LINE_RECOVERY_STRATEGY = LineRecoveryStrategy.Look4SatLimited
|
private val SSTV_LINE_RECOVERY_STRATEGY = LineRecoveryStrategy.Robot36Compatible
|
||||||
|
|
||||||
// Debug: Return scope visualization in frames (increases per-frame memory usage).
|
// Debug: Return scope visualization in frames (increases per-frame memory usage).
|
||||||
private const val SSTV_INCLUDE_SCOPE = false
|
private const val SSTV_INCLUDE_SCOPE = false
|
||||||
@@ -434,7 +490,6 @@ class RadarViewModel(
|
|||||||
218.1, 225.7, 233.6, 241.8, 250.3
|
218.1, 225.7, 233.6, 241.8, 250.3
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
RadarViewModel(
|
RadarViewModel(
|
||||||
|
|||||||
+449
-5
@@ -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,14 +43,23 @@ 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.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.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.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -54,12 +67,17 @@ 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.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
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.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.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
|
||||||
@@ -104,6 +122,73 @@ fun TransceiversPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CalculatorPage(
|
||||||
|
transceivers: List<SatRadio>,
|
||||||
|
selectedUuid: String?,
|
||||||
|
orbitalPos: OrbitalPos?,
|
||||||
|
onAction: (RadarAction) -> Unit,
|
||||||
|
calculatorOffsetKHz: String = "",
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val calculatorTransceivers = remember(transceivers) {
|
||||||
|
transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder)
|
||||||
|
.let(DopplerFrequencyCalculator::deduplicateTransponders)
|
||||||
|
}
|
||||||
|
val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) {
|
||||||
|
calculatorTransceivers.firstOrNull { it.uuid == selectedUuid }
|
||||||
|
?: calculatorTransceivers.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTransceiver == null) {
|
||||||
|
EmptyTransceiversContent(modifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
if (calculatorTransceivers.size > 1) {
|
||||||
|
item {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
calculatorTransceivers.forEach { radio ->
|
||||||
|
FilterChip(
|
||||||
|
selected = radio.uuid == selectedTransceiver.uuid,
|
||||||
|
onClick = {
|
||||||
|
if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid))
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = transceiverTitle(radio),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
DopplerFrequencyCalculator(
|
||||||
|
transponder = selectedTransceiver,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetKHz = calculatorOffsetKHz,
|
||||||
|
onOffsetChange = { onAction(RadarAction.ChangeCalculatorOffset(it)) },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
|
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
|
||||||
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
|
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
|
||||||
@@ -172,12 +257,8 @@ private fun TransceiverItem(
|
|||||||
.rotate(if (isExpanded) 270f else 90f)
|
.rotate(if (isExpanded) 270f else 90f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Title with mode
|
|
||||||
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
|
|
||||||
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
|
|
||||||
val fullTitle = "$title ($mode)"
|
|
||||||
Text(
|
Text(
|
||||||
text = fullTitle,
|
text = transceiverTitle(radio),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -437,6 +518,362 @@ private fun ExpandedRadioControl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum class EditedField { TX, PASSBAND, RX }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DopplerFrequencyCalculator(
|
||||||
|
transponder: SatRadio,
|
||||||
|
orbitalPos: OrbitalPos?,
|
||||||
|
offsetKHz: String = "",
|
||||||
|
onOffsetChange: (String) -> Unit = {},
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
if (orbitalPos == null || !DopplerFrequencyCalculator.isLinearTransponder(transponder)) return
|
||||||
|
|
||||||
|
var lastEditedField by remember { mutableStateOf(EditedField.TX) }
|
||||||
|
var txFrequencyHz by remember { mutableStateOf(0L) }
|
||||||
|
var rxFrequencyHz by remember { mutableStateOf(0L) }
|
||||||
|
var passbandPosition by remember { mutableStateOf(0.5f) }
|
||||||
|
var stepSizeKHz by remember { mutableIntStateOf(1) }
|
||||||
|
|
||||||
|
val offsetHz = offsetKHz.toDoubleOrNull()?.let { it * 1000 }?.toLong() ?: 0L
|
||||||
|
|
||||||
|
val txLow = transponder.uplinkLow ?: return
|
||||||
|
val txHigh = transponder.uplinkHigh ?: return
|
||||||
|
val rxLow = transponder.downlinkLow ?: return
|
||||||
|
val rxHigh = transponder.downlinkHigh ?: return
|
||||||
|
val txRange = txHigh - txLow
|
||||||
|
val rxRange = rxHigh - rxLow
|
||||||
|
if (txRange <= 0L || rxRange <= 0L) return
|
||||||
|
|
||||||
|
// 原始转发器值(逆转多普勒),用于 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实时重算:卫星移动时,锚点侧不变,重算另一侧
|
||||||
|
LaunchedEffect(orbitalPos) {
|
||||||
|
if (lastEditedField == EditedField.TX) {
|
||||||
|
// TX 是锚点,不变;重算 RX
|
||||||
|
rxFrequencyHz = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||||
|
txFrequencyHz, rawTransponder, orbitalPos, offsetHz
|
||||||
|
) ?: rxFrequencyHz
|
||||||
|
} 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val txSliderValue = if (lastEditedField == EditedField.PASSBAND) {
|
||||||
|
passbandPosition
|
||||||
|
} else {
|
||||||
|
((txFrequencyHz - txLow).toFloat() / txRange).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
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(
|
||||||
|
modifier = modifier.fillMaxWidth().padding(top = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
// 左半边: TX + RX 按钮
|
||||||
|
Row(
|
||||||
|
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 输入框(带细线边框)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.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()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TX 行
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
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) }
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value = txSliderValue,
|
||||||
|
onValueChange = { norm ->
|
||||||
|
val rawFreq = txLow + (txRange * norm).toLong()
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
|
||||||
|
// RX 行
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
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) }
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@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 {
|
||||||
@@ -452,5 +889,12 @@ private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun transceiverTitle(radio: SatRadio): String {
|
||||||
|
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
|
||||||
|
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
|
||||||
|
return "$title ($mode)"
|
||||||
|
}
|
||||||
|
|
||||||
private val FREQ_ADJUSTMENTS =
|
private val FREQ_ADJUSTMENTS =
|
||||||
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
|
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
|
||||||
|
|
||||||
+5
@@ -59,6 +59,11 @@ class SatellitesViewModel(
|
|||||||
_uiState.update { it.copy(shouldSeeWarning = settings.shouldSeeWarning) }
|
_uiState.update { it.copy(shouldSeeWarning = settings.shouldSeeWarning) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsRepo.selectedSatModes.collectLatest { modes ->
|
||||||
|
_uiState.update { it.copy(currentModes = modes) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onAction(action: SatellitesAction) {
|
fun onAction(action: SatellitesAction) {
|
||||||
|
|||||||
+97
-23
@@ -25,10 +25,12 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
@@ -38,6 +40,7 @@ import androidx.compose.material3.ElevatedCard
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -219,7 +222,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
version = uiState.appVersionName,
|
version = uiState.appVersionName,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
|
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
|
||||||
}
|
}
|
||||||
TopBar {
|
TopBar {
|
||||||
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
@@ -241,7 +244,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
TopBar {
|
TopBar {
|
||||||
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
|
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like)
|
||||||
TopCard(
|
TopCard(
|
||||||
onClick = { safeOpenUri(appUrl) },
|
onClick = { safeOpenUri(appUrl) },
|
||||||
version = uiState.appVersionName,
|
version = uiState.appVersionName,
|
||||||
@@ -299,8 +302,22 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
onRadioControlClick = { dialogs.radioControl = true }
|
onRadioControlClick = { dialogs.radioControl = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { OtherCard(uiState.otherSettings, onAction) }
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
item { CardCredits() }
|
if (isVerticalLayout) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
OtherCard(uiState.otherSettings, onAction)
|
||||||
|
CardCredits()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)
|
||||||
|
) {
|
||||||
|
OtherCard(uiState.otherSettings, onAction, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||||
|
CardCredits(modifier = Modifier.weight(1f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -479,40 +496,83 @@ private fun OtherCardPreview() = MainTheme {
|
|||||||
shouldSeeWarning = false,
|
shouldSeeWarning = false,
|
||||||
shouldSeeWhatsNew = false
|
shouldSeeWhatsNew = false
|
||||||
)
|
)
|
||||||
OtherCard(settings = values) {}
|
OtherCard(settings = values, onAction = {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
|
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit, modifier: Modifier = Modifier) {
|
||||||
ElevatedCard(
|
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(268.dp)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.prefs_other_title),
|
text = stringResource(id = R.string.prefs_other_title),
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
SwitchRow(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
onAction(SettingsAction.ToggleUtc(it))
|
// Display preferences: UTC clock + night filter
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
SwitchTile(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
|
||||||
|
onAction(SettingsAction.ToggleUtc(it))
|
||||||
|
}
|
||||||
|
SwitchTile(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
|
||||||
|
onAction(SettingsAction.ToggleNightMode(it))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
// Radar behavior: sweep animation + sensor control
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
SwitchTile(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
|
||||||
|
onAction(SettingsAction.ToggleSweep(it))
|
||||||
|
}
|
||||||
|
SwitchTile(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
|
||||||
|
onAction(SettingsAction.ToggleSensor(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
// Data management (full width)
|
||||||
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
|
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
|
||||||
onAction(SettingsAction.ToggleUpdate(it))
|
onAction(SettingsAction.ToggleUpdate(it))
|
||||||
}
|
}
|
||||||
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
onAction(SettingsAction.ToggleSweep(it))
|
// Compass calibration sliders at the bottom
|
||||||
}
|
CompassOffsetRow(
|
||||||
SwitchRow(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
|
labelResId = R.string.prefs_other_compass_offset,
|
||||||
onAction(SettingsAction.ToggleSensor(it))
|
value = settings.radarCompassOffset,
|
||||||
}
|
range = -180f..180f
|
||||||
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
|
) { onAction(SettingsAction.SetRadarCompassOffset(it)) }
|
||||||
onAction(SettingsAction.ToggleNightMode(it))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
CompassOffsetRow(
|
||||||
|
labelResId = R.string.prefs_other_compass_offset_elev,
|
||||||
|
value = settings.radarCompassOffsetElev,
|
||||||
|
range = -90f..90f
|
||||||
|
) { onAction(SettingsAction.SetRadarCompassOffsetElev(it)) }
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CompassOffsetRow(
|
||||||
|
labelResId: Int,
|
||||||
|
value: Float,
|
||||||
|
range: ClosedFloatingPointRange<Float>,
|
||||||
|
onValueChange: (Float) -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = labelResId))
|
||||||
|
Text(text = "${value.toInt()}°")
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Slider(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
valueRange = range
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
@@ -525,6 +585,21 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RowScope.SwitchTile(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = labelResId),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun formatUpdateTime(updateTime: Long): String {
|
private fun formatUpdateTime(updateTime: Long): String {
|
||||||
val timePattern = stringResource(id = R.string.prefs_updated_time)
|
val timePattern = stringResource(id = R.string.prefs_updated_time)
|
||||||
@@ -559,7 +634,6 @@ private fun CardCredits(modifier: Modifier = Modifier) {
|
|||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(268.dp)
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ sealed interface SettingsAction {
|
|||||||
data class ToggleSensor(val value: Boolean) : SettingsAction
|
data class ToggleSensor(val value: Boolean) : SettingsAction
|
||||||
data class ToggleLightTheme(val value: Boolean) : SettingsAction
|
data class ToggleLightTheme(val value: Boolean) : SettingsAction
|
||||||
data class ToggleNightMode(val value: Boolean) : SettingsAction
|
data class ToggleNightMode(val value: Boolean) : SettingsAction
|
||||||
|
data class SetRadarCompassOffset(val value: Float) : SettingsAction
|
||||||
|
data class SetRadarCompassOffsetElev(val value: Float) : SettingsAction
|
||||||
|
|
||||||
// Remote control
|
// Remote control
|
||||||
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
||||||
|
|||||||
+2
@@ -123,6 +123,8 @@ class SettingsViewModel(
|
|||||||
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
|
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
|
||||||
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
|
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
|
||||||
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
|
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
|
||||||
|
is SettingsAction.SetRadarCompassOffset -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffset = action.value) }
|
||||||
|
is SettingsAction.SetRadarCompassOffsetElev -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffsetElev = action.value) }
|
||||||
// Remote control & data sources
|
// Remote control & data sources
|
||||||
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
||||||
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
+94
@@ -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,4 +2,5 @@
|
|||||||
org.gradle.caching=true
|
org.gradle.caching=true
|
||||||
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||||
org.gradle.parallel=true
|
org.gradle.parallel=true
|
||||||
|
org.gradle.tooling.parallel=true
|
||||||
org.gradle.workers.max=4
|
org.gradle.workers.max=4
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "443"
|
appVersionCode = "445"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.4.3"
|
appVersionName = "4.4.5"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
compileSdk = "37"
|
compileSdk = "37"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
@@ -23,7 +23,7 @@ compose-activity = "1.13.0"
|
|||||||
compose-lifecycle = "2.11.0"
|
compose-lifecycle = "2.11.0"
|
||||||
compose-navigation3 = "1.1.5"
|
compose-navigation3 = "1.1.5"
|
||||||
|
|
||||||
google-ksp = "2.3.8"
|
google-ksp = "2.3.10"
|
||||||
|
|
||||||
kotlin = "2.4.10"
|
kotlin = "2.4.10"
|
||||||
kotlin-coroutines = "1.11.0"
|
kotlin-coroutines = "1.11.0"
|
||||||
|
|||||||
+2
-1
@@ -25,5 +25,6 @@ include(
|
|||||||
":feature:passes",
|
":feature:passes",
|
||||||
":feature:radar",
|
":feature:radar",
|
||||||
":feature:satellites",
|
":feature:satellites",
|
||||||
":feature:settings"
|
":feature:settings",
|
||||||
|
":feature:status"
|
||||||
)
|
)
|
||||||
Reference in new issue
Block a user