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:
mckero committed 2026-08-05 15:26:42 +00:00
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)
@@ -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?
} }
@@ -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
@@ -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()