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

@@ -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) */
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?
}