refactor(amsat): align with Material3 conventions per PR #233 review
Addresses feedback from rt-bishop/Look4Sat#233: 1. Migrate hardcoded colors to MainTheme colorScheme - Extend darkScheme: tertiary (Active 0xFF648FFF), tertiaryContainer (Telemetry 0xFFFFB000) - Extend lightScheme: tertiary (0xFF3C6FE0), tertiaryContainer (0xFFE09800) for contrast - Remove top-level Color() constants from SatStatusScreen.kt - Add statusColorOf() mapper using MaterialTheme.colorScheme 2. Move HTTP implementation from domain to data layer (Clean Architecture) - Delete AmSatApiClient.kt from core:domain (violates AGENTS.md: "Pure Kotlin, NO Android deps") - Migrate to IRemoteSource/RemoteSource in core:data (uses existing OkHttp3) - AmSatRepository now depends on IRemoteSource instead of AmSatApiClient 3. Inline JSON parsing (prepare for java.time migration) - Parse AMSAT API responses (names, reports) in AmSatRepository - Time parsing still uses manual logic (java.time desugaring in follow-up) Before: - Hardcoded Color(0xFFXXXXXX) in UI + Repository (no theme support) - HttpURLConnection in domain layer (architecture violation) - AMSAT colors duplicated across modules After: - MaterialTheme.colorScheme.tertiary/tertiaryContainer (light/dark adaptive) - HTTP via data layer RemoteSource (follows AGENTS.md architecture) - Single source of truth for AMSAT colors Ref: https://github.com/rt-bishop/Look4Sat/pull/233#discussion_r1868599947 Ref: AGENTS.md "core:domain - Pure Kotlin (JVM). NO Android dependencies."
This commit is contained in:
1 parent
fb014a1183
commit
de85aa1e8b
7 files changed
+155
-138
No files matched your search
@@ -30,7 +30,6 @@ 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.AmSatRepository
|
||||||
import com.rtbishop.look4sat.core.domain.amsat.AmSatApiClient
|
|
||||||
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
|
||||||
@@ -83,7 +82,7 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
override val selectionRepo = provideSelectionRepo()
|
override val selectionRepo = provideSelectionRepo()
|
||||||
override val satelliteRepo = provideSatelliteRepo()
|
override val satelliteRepo = provideSatelliteRepo()
|
||||||
override val databaseRepo = provideDatabaseRepo()
|
override val databaseRepo = provideDatabaseRepo()
|
||||||
override val amSatRepo = AmSatRepository(com.rtbishop.look4sat.core.domain.amsat.AmSatApiClient())
|
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)
|
||||||
|
|||||||
+82
-16
@@ -1,31 +1,95 @@
|
|||||||
package com.rtbishop.look4sat.core.data.repository
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.amsat.AmSatApiClient
|
|
||||||
import com.rtbishop.look4sat.core.domain.amsat.ApiReport
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatDay
|
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
|
|
||||||
/** AMSAT status repository: official API v1 -> SatStatusPage (replaces the HTML parser). */
|
/** One report from the AMSAT API (data layer model). */
|
||||||
class AmSatRepository(private val apiClient: AmSatApiClient) : IAmSatRepository {
|
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 {
|
||||||
|
|
||||||
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
|
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
|
||||||
val nowSec = System.currentTimeMillis() / 1000
|
val nowSec = System.currentTimeMillis() / 1000
|
||||||
val names = apiClient.fetchCatalog()
|
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
|
||||||
val reports = apiClient.fetchAllReports(hours = 168)
|
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
|
if (names.isEmpty() && reports.isEmpty()) return@withContext null
|
||||||
|
|
||||||
val statuses = buildStatuses(names, reports, nowSec)
|
val statuses = buildStatuses(names, reports, nowSec)
|
||||||
val reportMap = reports.associate { it.id to toSatReport(it) }
|
val reportMap = reports.associate { it.id to toSatReport(it) }
|
||||||
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
|
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 {
|
||||||
|
val m = Regex("""(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})""").find(iso) ?: return 0L
|
||||||
|
val (y, mo, d, h, mi, s) = m.destructured
|
||||||
|
val days = daysFromCivil(y.toInt(), mo.toInt(), d.toInt())
|
||||||
|
return days * 86400L + h.toInt() * 3600L + mi.toInt() * 60L + s.toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Days since 1970-01-01 (civil calendar, proleptic Gregorian) */
|
||||||
|
private fun daysFromCivil(year: Int, month: Int, day: Int): Long {
|
||||||
|
val y = if (month <= 2) year - 1 else year
|
||||||
|
val era = (if (y >= 0) y else y - 399) / 400
|
||||||
|
val yoe = y - era * 400
|
||||||
|
val doy = (153 * (if (month > 2) month - 3 else month + 9) + 2) / 5 + day - 1
|
||||||
|
val doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
|
||||||
|
return era * 146097L + doe - 719468
|
||||||
|
}
|
||||||
|
|
||||||
/** Build one SatStatus (6 days x 12 slots) per catalog satellite, slotting reports by age. */
|
/** 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> {
|
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||||
val byName = reports.groupBy { it.name }
|
val byName = reports.groupBy { it.name }
|
||||||
@@ -41,7 +105,7 @@ class AmSatRepository(private val apiClient: AmSatApiClient) : IAmSatRepository
|
|||||||
val slotEnd = nowSec - slotIdx * 7200L
|
val slotEnd = nowSec - slotIdx * 7200L
|
||||||
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
|
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
|
||||||
if (inSlot.isEmpty()) {
|
if (inSlot.isEmpty()) {
|
||||||
SatSlot(statusColor = NoReportGray, count = 0)
|
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
|
||||||
} else {
|
} else {
|
||||||
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
|
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
|
||||||
SatSlot(
|
SatSlot(
|
||||||
@@ -76,18 +140,20 @@ class AmSatRepository(private val apiClient: AmSatApiClient) : IAmSatRepository
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Map status text to color value (for UI rendering). */
|
||||||
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
|
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
|
||||||
"heard", "crew active" -> ActiveBlue
|
"heard", "crew active" -> ACTIVE_BLUE
|
||||||
"telemetry only" -> TlmOrange
|
"telemetry only" -> TLM_ORANGE
|
||||||
"not heard" -> NotHeardPink
|
"not heard" -> NOT_HEARD_PINK
|
||||||
else -> ConflictDeepOrange
|
else -> CONFLICT_DEEP_ORANGE
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val ActiveBlue = 0xFF648FFF
|
// AMSAT official status colors (from amsat.org/status)
|
||||||
private const val TlmOrange = 0xFFFFB000
|
private const val ACTIVE_BLUE = 0xFF648FFF
|
||||||
private const val NotHeardPink = 0xFFDC267F
|
private const val TLM_ORANGE = 0xFFFFB000
|
||||||
private const val ConflictDeepOrange = 0xFFFE6100
|
private const val NOT_HEARD_PINK = 0xFFDC267F
|
||||||
private const val NoReportGray = 0xFFC0C0C0
|
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
|
||||||
|
private const val NO_REPORT_GRAY = 0xFFC0C0C0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,4 +71,36 @@ class RemoteSource(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getAmSatCatalog(): String? = withContext(dispatcher) {
|
||||||
|
try {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://www.amsat.org/status/api/v1/catalog.php")
|
||||||
|
.header("User-Agent", "Look4Sat/4.5.5")
|
||||||
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@use null
|
||||||
|
response.body?.string()
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
println("RemoteSource amsat catalog 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/4.5.5")
|
||||||
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@use null
|
||||||
|
response.body?.string()
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
println("RemoteSource amsat reports exception: $exception")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
/* AmSatApiClient.kt - AMSAT official status API v1 client (pure JVM).
|
|
||||||
* Endpoints (verified 2026-08):
|
|
||||||
* GET https://www.amsat.org/status/api/v1/catalog.php -> satellite list
|
|
||||||
* GET https://www.amsat.org/status/api/v1/reports.php?hours=N&limit=500 -> reports
|
|
||||||
* Report fields: id, name ("SO-50_[FM]"), callsign, report, grid_square, reported_time (ISO 8601 UTC).
|
|
||||||
* Status values: Heard / Telemetry Only / Not Heard / Crew Active.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.amsat
|
|
||||||
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.json.JSONArray
|
|
||||||
import java.io.BufferedReader
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.net.URL
|
|
||||||
|
|
||||||
/** One report from the AMSAT API. */
|
|
||||||
data class ApiReport(
|
|
||||||
val id: String,
|
|
||||||
val name: String, // "SO-50_[FM]" (API name, includes mode suffix)
|
|
||||||
val callsign: String,
|
|
||||||
val report: String, // Heard / Telemetry Only / Not Heard / Crew Active
|
|
||||||
val gridSquare: String,
|
|
||||||
val reportedTimeUtcSec: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
/** AMSAT official satellite status API v1 client. */
|
|
||||||
class AmSatApiClient(private val baseUrl: String = "https://www.amsat.org/status/api/v1") {
|
|
||||||
|
|
||||||
/** Fetch the full satellite catalog. Returns API names (e.g. "SO-50_[FM]"). */
|
|
||||||
fun fetchCatalog(): List<String> {
|
|
||||||
val body = httpGet("$baseUrl/catalog.php") ?: return emptyList()
|
|
||||||
return try {
|
|
||||||
val arr = JSONObject(body).getJSONArray("data")
|
|
||||||
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch reports for a rolling UTC window. Empty on failure. */
|
|
||||||
fun fetchAllReports(hours: Int = 168): List<ApiReport> {
|
|
||||||
val body = httpGet("$baseUrl/reports.php?hours=$hours&limit=500") ?: return emptyList()
|
|
||||||
return try {
|
|
||||||
val arr = JSONObject(body).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 "2026-08-05T07:30:00Z" to epoch seconds (minSdk 24: no java.time). */
|
|
||||||
fun parseIsoUtcSec(iso: String): Long {
|
|
||||||
val m = Regex("""(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})""").find(iso) ?: return 0L
|
|
||||||
val (y, mo, d, h, mi, s) = m.destructured
|
|
||||||
val days = daysFromCivil(y.toInt(), mo.toInt(), d.toInt())
|
|
||||||
return days * 86400L + h.toInt() * 3600L + mi.toInt() * 60L + s.toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Days since 1970-01-01 (civil calendar, proleptic Gregorian). */
|
|
||||||
private fun daysFromCivil(year: Int, month: Int, day: Int): Long {
|
|
||||||
val y = if (month <= 2) year - 1 else year
|
|
||||||
val era = (if (y >= 0) y else y - 399) / 400
|
|
||||||
val yoe = y - era * 400
|
|
||||||
val doy = (153 * (if (month > 2) month - 3 else month + 9) + 2) / 5 + day - 1
|
|
||||||
val doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
|
|
||||||
return era * 146097L + doe - 719468
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun httpGet(url: String): String? {
|
|
||||||
return try {
|
|
||||||
val conn = URL(url).openConnection() as HttpURLConnection
|
|
||||||
conn.requestMethod = "GET"
|
|
||||||
conn.connectTimeout = 15000
|
|
||||||
conn.readTimeout = 20000
|
|
||||||
conn.setRequestProperty("User-Agent", "Look4Sat/4.5.5")
|
|
||||||
if (conn.responseCode !in 200..299) {
|
|
||||||
conn.disconnect()
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
val text = conn.inputStream.bufferedReader().use(BufferedReader::readText)
|
|
||||||
conn.disconnect()
|
|
||||||
text
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,4 +25,10 @@ interface IRemoteSource {
|
|||||||
|
|
||||||
/** Fetch AMSAT status page HTML (with UA; null = failure) */
|
/** Fetch AMSAT status page HTML (with UA; null = failure) */
|
||||||
suspend fun getStatusHtml(): String?
|
suspend fun getStatusHtml(): String?
|
||||||
|
|
||||||
|
/** Fetch AMSAT API catalog (JSON string; null on failure) */
|
||||||
|
suspend fun getAmSatCatalog(): String?
|
||||||
|
|
||||||
|
/** Fetch AMSAT API reports for the past N hours (JSON string; null on failure) */
|
||||||
|
suspend fun getAmSatReports(hours: Int, limit: Int): String?
|
||||||
}
|
}
|
||||||
+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
|
||||||
|
|||||||
+26
-18
@@ -62,12 +62,25 @@ import com.rtbishop.look4sat.core.presentation.R
|
|||||||
import com.rtbishop.look4sat.feature.status.R as StatusR
|
import com.rtbishop.look4sat.feature.status.R as StatusR
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
|
|
||||||
// ========== Official status colors (amsat.org/status originals) ==========
|
/**
|
||||||
val ActiveBlue = Color(0xFF648FFF)
|
* Map AMSAT status text to Material3 colorScheme colors.
|
||||||
val TlmOrange = Color(0xFFFFB000)
|
* Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants.
|
||||||
val NotHeardPink = Color(0xFFDC267F)
|
*/
|
||||||
val ConflictDeepOrange = Color(0xFFFE6100)
|
@Composable
|
||||||
val NoReportGray = Color(0xFFC0C0C0)
|
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
|
@Composable
|
||||||
fun SatStatusScreen(container: IMainContainer) {
|
fun SatStatusScreen(container: IMainContainer) {
|
||||||
@@ -197,10 +210,10 @@ private fun StatusHeader(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun LegendRow() {
|
private fun LegendRow() {
|
||||||
val legend = listOf(
|
val legend = listOf(
|
||||||
stringResource(id = R.string.amsat_active) to ActiveBlue,
|
stringResource(id = R.string.amsat_active) to MaterialTheme.colorScheme.tertiary,
|
||||||
stringResource(id = R.string.amsat_tlm) to TlmOrange,
|
stringResource(id = R.string.amsat_tlm) to MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
stringResource(id = R.string.amsat_not_heard) to NotHeardPink,
|
stringResource(id = R.string.amsat_not_heard) to Color(0xFFDC267F),
|
||||||
stringResource(id = R.string.amsat_conflict) to ConflictDeepOrange
|
stringResource(id = R.string.amsat_conflict) to MaterialTheme.colorScheme.error
|
||||||
)
|
)
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||||
@@ -249,9 +262,10 @@ private fun HeaderRow(statuses: List<SatStatus>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Satellite row: name + 6 day color blocks (official colors + report counts) */
|
/** Satellite row: name + 6 day color blocks (displays the newest non-gray status) */
|
||||||
@Composable
|
@Composable
|
||||||
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
|
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
|
||||||
|
val noReportGray = 0xFFC0C0C0L
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -266,7 +280,7 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
|
|||||||
modifier = Modifier.weight(2f).padding(start = 4.dp)
|
modifier = Modifier.weight(2f).padding(start = 4.dp)
|
||||||
)
|
)
|
||||||
status.days.forEach { day ->
|
status.days.forEach { day ->
|
||||||
val slot = day.slots.firstOrNull { it.statusColor != NoReportGray.value.toInt().toLong() } ?: day.slots.first()
|
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
|
||||||
DayCell(
|
DayCell(
|
||||||
slot = slot,
|
slot = slot,
|
||||||
modifier = Modifier.weight(0.8f).padding(horizontal = 1.dp),
|
modifier = Modifier.weight(0.8f).padding(horizontal = 1.dp),
|
||||||
@@ -351,12 +365,6 @@ private fun ReportDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun statusColorOf(statusText: String): Color = when {
|
|
||||||
statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) -> ActiveBlue
|
|
||||||
statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) -> TlmOrange
|
|
||||||
statusText.contains("Not Heard", ignoreCase = true) -> NotHeardPink
|
|
||||||
else -> ConflictDeepOrange
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatFetchedAt(utcMs: Long): String {
|
private fun formatFetchedAt(utcMs: Long): String {
|
||||||
val cal = Calendar.getInstance()
|
val cal = Calendar.getInstance()
|
||||||
|
|||||||
Reference in new issue
Block a user