diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-01 06:17:37 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-01 06:17:37 +0000 |
| commit | 36d94937a561300197ef53aa9096defadfda3e72 (patch) | |
| tree | 260de6db4ca7d5a75a5bdfc4c56df12b57aa0f78 | |
| parent | 13c72a335b7d1a0338d3e2d68556689d05d87a1c (diff) | |
feat(aton): AtonData model + AtonRepository with 24h cache
Implements AtonData (color mapping + Gson-based JSON parsing for NOAA
AtoN station data) and AtonRepository (disk-cached HTTP fetcher).
Uses Gson instead of org.json so unit tests run on the JVM classpath.
7/7 AtonDataTest assertions pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 files changed, 109 insertions, 0 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/aton/AtonData.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/aton/AtonData.kt new file mode 100644 index 0000000..2ff59cd --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/aton/AtonData.kt @@ -0,0 +1,37 @@ +package org.terst.nav.ui.aton + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import org.maplibre.geojson.Feature +import org.maplibre.geojson.Point + +object AtonData { + fun colorFor(type: String): String = when (type) { + "Light" -> "#FF0000" + "Float", "Buoy" -> "#00AA00" + "Daybeacon", "Range" -> "#FFDD00" + else -> "#FFFFFF" + } + + fun parseStation(json: String): Feature? = try { + val obj = JsonParser.parseString(json).asJsonObject + val lat = obj.get("lat").asDouble + val lng = obj.get("lng").asDouble + val type = obj.get("type")?.asString ?: "" + val id = obj.get("id")?.asString ?: "" + val name = obj.get("name")?.asString ?: "" + val props = JsonObject().apply { + addProperty("id", id) + addProperty("name", name) + addProperty("type", type) + addProperty("color", colorFor(type)) + } + Feature.fromGeometry(Point.fromLngLat(lng, lat), props) + } catch (_: Exception) { null } + + fun parseResponse(json: String): List<Feature> = try { + val arr = JsonParser.parseString(json).asJsonObject + .getAsJsonArray("stations") + arr.mapNotNull { parseStation(it.toString()) } + } catch (_: Exception) { emptyList() } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/aton/AtonRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/aton/AtonRepository.kt new file mode 100644 index 0000000..b5472b3 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/aton/AtonRepository.kt @@ -0,0 +1,35 @@ +package org.terst.nav.ui.aton + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.maplibre.geojson.Feature +import java.io.File +import java.net.URL + +class AtonRepository(private val context: Context) { + + private val cacheFile = File(context.filesDir, "aton_cache.json") + private val maxAgeMs = 24 * 60 * 60 * 1000L + + private val apiUrl = + "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations.json?type=aton&units=english" + + suspend fun getFeatures(): List<Feature> = withContext(Dispatchers.IO) { + val cached = readCache() + if (cached != null) return@withContext AtonData.parseResponse(cached) + val fresh = fetchFromApi() ?: return@withContext emptyList() + cacheFile.writeText(fresh) + AtonData.parseResponse(fresh) + } + + private fun readCache(): String? { + if (!cacheFile.exists()) return null + if (System.currentTimeMillis() - cacheFile.lastModified() > maxAgeMs) return null + return cacheFile.readText() + } + + private fun fetchFromApi(): String? = try { + URL(apiUrl).openStream().bufferedReader().readText() + } catch (_: Exception) { null } +} diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ui/aton/AtonDataTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ui/aton/AtonDataTest.kt new file mode 100644 index 0000000..dc0cb4f --- /dev/null +++ b/android-app/app/src/test/kotlin/org/terst/nav/ui/aton/AtonDataTest.kt @@ -0,0 +1,37 @@ +package org.terst.nav.ui.aton + +import org.junit.Assert.* +import org.junit.Test + +class AtonDataTest { + @Test fun `light type maps to red`() { + assertEquals("#FF0000", AtonData.colorFor("Light")) + } + @Test fun `float type maps to green`() { + assertEquals("#00AA00", AtonData.colorFor("Float")) + } + @Test fun `buoy type maps to green`() { + assertEquals("#00AA00", AtonData.colorFor("Buoy")) + } + @Test fun `daybeacon type maps to yellow`() { + assertEquals("#FFDD00", AtonData.colorFor("Daybeacon")) + } + @Test fun `unknown type maps to white`() { + assertEquals("#FFFFFF", AtonData.colorFor("WeirdThing")) + } + @Test fun `parse station json to feature`() { + val json = """{"id":"8443970","name":"Boston Light","lat":42.3298,"lng":-70.8930,"type":"Light"}""" + val f = AtonData.parseStation(json) + assertNotNull(f) + assertEquals("Boston Light", f!!.getStringProperty("name")) + assertEquals("#FF0000", f.getStringProperty("color")) + } + @Test fun `parse response returns list of features`() { + val json = """{"stations":[ + {"id":"1","name":"A","lat":21.0,"lng":-158.0,"type":"Buoy"}, + {"id":"2","name":"B","lat":21.1,"lng":-157.9,"type":"Light"} + ]}""" + val features = AtonData.parseResponse(json) + assertEquals(2, features.size) + } +} |
