diff options
| author | Claude <noreply@anthropic.com> | 2026-06-03 01:54:38 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-06-03 01:54:38 +0000 |
| commit | bfeab101083970a3fd5a67aa62d09a7506027da4 (patch) | |
| tree | ce5e21a6749dc2516b72a6970bbdc2e68329781c /android-app/app/src/main/kotlin | |
| parent | 17cfd884976b4accbb2ad6af6acf3578d0c6ec53 (diff) | |
PMIM-1 hardware support: UDP, IIXDR, inclinometer
Transport:
- NmeaStreamManager supports TCP and UDP modes
- NmeaConnectionPrefs replaces hardcoded 192.168.1.1:10110; defaults
to UDP/10110 matching PMIM-1 spec (ESP32 AP at 192.168.4.1)
- LocationService reads prefs via startNmeaFromPrefs()
Parsing:
- NmeaParser.parseXdr() handles $IIXDR attitude (HEEL/PTCH in degrees)
and baro (pressure in bar → hPa, air temp °C)
- New AttitudeData and NmeaBaroData sensor types
UI:
- Heel + pitch row in instrument sheet (hidden until data arrives)
- InclinometerView: canvas circle with rotating waterline (heel) and
displaced dot (pitch), colorPrimary-tinted, respects night mode
- Inclinometer overlay top-left of map, appears on first attitude packet
- NMEA baro overrides device sensor reading when available
https://claude.ai/code/session_01KXYBuAHZkvv63DeUG6XaZD
Diffstat (limited to 'android-app/app/src/main/kotlin')
8 files changed, 347 insertions, 87 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt index 9440d49..c47c7a5 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt @@ -23,9 +23,12 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.asStateFlow import org.terst.nav.nmea.NmeaParser import org.terst.nav.nmea.NmeaStreamManager +import org.terst.nav.nmea.NmeaTransport +import org.terst.nav.sensors.AttitudeData import org.terst.nav.sensors.BoatSpeedData import org.terst.nav.sensors.DepthData import org.terst.nav.sensors.HeadingData +import org.terst.nav.sensors.NmeaBaroData import org.terst.nav.sensors.WindData import org.terst.nav.gps.GpsPosition import org.terst.nav.data.model.SensorData @@ -136,6 +139,20 @@ class LocationService : Service() { } } + // Collect NMEA Attitude Data (heel + pitch from PMIM-1 IIXDR) + serviceScope.launch { + nmeaStreamManager.nmeaAttitudeData.collectLatest { attitude -> + _nmeaAttitudeFlow.emit(attitude) + } + } + + // Collect NMEA Baro Data (pressure + air temp from PMIM-1 IIXDR) + serviceScope.launch { + nmeaStreamManager.nmeaBaroData.collectLatest { baro -> + _nmeaBaroFlow.emit(baro) + } + } + locationCallback = object : LocationCallback() { override fun onLocationResult(locationResult: LocationResult) { locationResult.lastLocation?.let { location -> @@ -240,9 +257,9 @@ class LocationService : Service() { startLocationUpdatesInternal(PowerMode.ECONOMY) } barometerSensorManager.start() - val flags = (application as NavApplication).featureFlags - if (flags.isEnabled(org.terst.nav.settings.HardwareSource.NMEA_INSTRUMENTS)) { - nmeaStreamManager.start(NMEA_GATEWAY_IP, NMEA_GATEWAY_PORT) + val featureFlags = (application as NavApplication).featureFlags + if (featureFlags.isEnabled(org.terst.nav.settings.HardwareSource.NMEA_INSTRUMENTS)) { + startNmeaFromPrefs() } } ACTION_STOP_FOREGROUND_SERVICE -> { @@ -274,7 +291,7 @@ class LocationService : Service() { } ACTION_START_NMEA -> { Log.d("LocationService", "Starting NMEA stream") - nmeaStreamManager.start(NMEA_GATEWAY_IP, NMEA_GATEWAY_PORT) + startNmeaFromPrefs() } ACTION_STOP_NMEA -> { Log.d("LocationService", "Stopping NMEA stream") @@ -317,6 +334,16 @@ class LocationService : Service() { fusedLocationClient.removeLocationUpdates(locationCallback) } + private fun startNmeaFromPrefs() { + val prefs = NmeaConnectionPrefs(this) + val transport = when (prefs.transport) { + NmeaConnectionPrefs.Transport.UDP -> NmeaTransport.UDP + NmeaConnectionPrefs.Transport.TCP -> NmeaTransport.TCP + } + nmeaStreamManager.start(prefs.tcpHost, prefs.port, transport) + Log.i("LocationService", "NMEA started: ${prefs.transport} port=${prefs.port}") + } + fun setPowerMode(powerMode: PowerMode) { serviceScope.launch { if (_currentPowerMode.value != powerMode) { @@ -395,9 +422,6 @@ class LocationService : Service() { const val EXTRA_TIDAL_VISIBILITY = "extra_tidal_visibility" const val EXTRA_WATCH_RADIUS = "extra_watch_radius" - private const val NMEA_GATEWAY_IP = "192.168.1.1" - private const val NMEA_GATEWAY_PORT = 10110 - private val _locationFlow = MutableSharedFlow<GpsPosition>(replay = 1) val locationFlow: SharedFlow<GpsPosition> get() = _locationFlow @@ -431,6 +455,12 @@ class LocationService : Service() { private val _nmeaBoatSpeedDataFlow = MutableSharedFlow<BoatSpeedData>(replay = 1) val nmeaBoatSpeedData: SharedFlow<BoatSpeedData> get() = _nmeaBoatSpeedDataFlow + private val _nmeaAttitudeFlow = MutableSharedFlow<AttitudeData>(replay = 1) + val nmeaAttitudeFlow: SharedFlow<AttitudeData> get() = _nmeaAttitudeFlow + + private val _nmeaBaroFlow = MutableSharedFlow<NmeaBaroData>(replay = 1) + val nmeaBaroFlow: SharedFlow<NmeaBaroData> get() = _nmeaBaroFlow + private val _currentSpeedKt = MutableStateFlow<Double?>(null) private val _currentDirectionDeg = MutableStateFlow<Double?>(null) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt index 69ca666..b66c20b 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt @@ -86,6 +86,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private lateinit var bottomSheet: CardView private lateinit var bottomNav: BottomNavigationView private lateinit var mapCrosshair: View + private lateinit var inclinometerView: org.terst.nav.ui.InclinometerView // HUD value TextViews private lateinit var hudSog: TextView @@ -222,6 +223,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { bottomSheet = findViewById(R.id.instrument_bottom_sheet) bottomNav = findViewById(R.id.bottom_navigation) mapCrosshair = findViewById(R.id.map_crosshair) + inclinometerView = findViewById(R.id.inclinometer_view) // HUD value views hudSog = findViewById(R.id.hud_sog) @@ -829,6 +831,28 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } } lifecycleScope.launch { + LocationService.nmeaAttitudeFlow.collect { attitude -> + val heelText = "%+.1f".format(Locale.getDefault(), attitude.heelDeg) + val pitchText = "%+.1f".format(Locale.getDefault(), attitude.pitchDeg) + findViewById<android.widget.TextView>(R.id.value_heel)?.text = heelText + findViewById<android.widget.TextView>(R.id.value_pitch)?.text = pitchText + findViewById<android.view.View>(R.id.attitude_row)?.visibility = android.view.View.VISIBLE + inclinometerView.heelDeg = attitude.heelDeg + inclinometerView.pitchDeg = attitude.pitchDeg + inclinometerView.visibility = android.view.View.VISIBLE + } + } + lifecycleScope.launch { + LocationService.nmeaBaroFlow.collect { baro -> + lastBaroHpa = baro.pressureHpa + instrumentHandler?.updateConditions(baroHpa = baro.pressureHpa) + baro.airTempC?.let { tempC -> + lastConditions = lastConditions?.copy(tempC = tempC.toDouble()) + instrumentHandler?.updateConditions(tempC = tempC.toDouble()) + } + } + } + lifecycleScope.launch { LocationService.anchorWatchState.collect { state -> safetyFragment.updateAnchorStatus(if (state.isActive) "Active: ${state.watchCircleRadiusMeters}m" else "Inactive") } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/NmeaConnectionPrefs.kt b/android-app/app/src/main/kotlin/org/terst/nav/NmeaConnectionPrefs.kt new file mode 100644 index 0000000..19ad7a2 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/NmeaConnectionPrefs.kt @@ -0,0 +1,31 @@ +package org.terst.nav + +import android.content.Context + +class NmeaConnectionPrefs(context: Context) { + + private val prefs = context.getSharedPreferences("nmea_connection", Context.MODE_PRIVATE) + + enum class Transport { TCP, UDP } + + var transport: Transport + get() = runCatching { Transport.valueOf(prefs.getString(KEY_TRANSPORT, null) ?: "") } + .getOrDefault(Transport.UDP) + set(v) { prefs.edit().putString(KEY_TRANSPORT, v.name).apply() } + + var tcpHost: String + get() = prefs.getString(KEY_HOST, DEFAULT_HOST) ?: DEFAULT_HOST + set(v) { prefs.edit().putString(KEY_HOST, v).apply() } + + var port: Int + get() = prefs.getInt(KEY_PORT, DEFAULT_PORT) + set(v) { prefs.edit().putInt(KEY_PORT, v).apply() } + + companion object { + private const val KEY_TRANSPORT = "transport" + private const val KEY_HOST = "host" + private const val KEY_PORT = "port" + const val DEFAULT_HOST = "192.168.4.1" + const val DEFAULT_PORT = 10110 + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt index 6a470b8..d634115 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt @@ -1,9 +1,11 @@ package org.terst.nav.nmea import org.terst.nav.gps.GpsPosition +import org.terst.nav.sensors.AttitudeData import org.terst.nav.sensors.BoatSpeedData import org.terst.nav.sensors.DepthData import org.terst.nav.sensors.HeadingData +import org.terst.nav.sensors.NmeaBaroData import org.terst.nav.sensors.WindData import java.util.Calendar import java.util.TimeZone @@ -213,6 +215,7 @@ class NmeaParser { "DBT" -> parseDbt(sentence) "HDG", "HDM" -> parseHdg(sentence) "VHW" -> parseVhw(sentence) + "XDR" -> parseXdr(sentence) else -> null } } @@ -251,6 +254,51 @@ class NmeaParser { } /** + * Parses $IIXDR (transducer data). Handles the two sentence types emitted by PMIM-1: + * + * Attitude: $IIXDR,A,{heel},D,HEEL,A,{pitch},D,PTCH*xx + * Baro: $IIXDR,P,{bar},B,Barometer,C,{tempC},C,TempAir*xx + * + * Returns AttitudeData, NmeaBaroData, or null. + */ + fun parseXdr(sentence: String): Any? { + if (sentence.isBlank()) return null + val body = if ('*' in sentence) sentence.substringBefore('*') else sentence + val fields = body.split(',') + if (fields.size < 5 || !fields[0].endsWith("XDR")) return null + + val now = System.currentTimeMillis() + var heelDeg: Float? = null + var pitchDeg: Float? = null + var pressureHpa: Float? = null + var airTempC: Float? = null + + var i = 1 + while (i + 3 < fields.size) { + val type = fields[i] + val value = fields[i + 1].toFloatOrNull() + val unit = fields[i + 2] + val id = fields[i + 3] + i += 4 + if (value == null) continue + when { + type == "A" && unit == "D" && id.equals("HEEL", ignoreCase = true) -> heelDeg = value + type == "A" && unit == "D" && id.equals("PTCH", ignoreCase = true) -> pitchDeg = value + type == "P" && unit == "B" && id.equals("Barometer", ignoreCase = true) -> pressureHpa = value * 1000f + type == "C" && unit == "C" && id.equals("TempAir", ignoreCase = true) -> airTempC = value + } + } + + return when { + heelDeg != null || pitchDeg != null -> + AttitudeData(heelDeg ?: 0f, pitchDeg ?: 0f, now) + pressureHpa != null -> + NmeaBaroData(pressureHpa, airTempC, now) + else -> null + } + } + + /** * Combines NMEA time (HHMMSS.ss) and date (DDMMYY) into a Unix epoch milliseconds value. * Returns 0 on any parse failure. */ diff --git a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt index 981b32e..17c6bfb 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt @@ -12,16 +12,22 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.terst.nav.gps.GpsPosition +import org.terst.nav.sensors.AttitudeData import org.terst.nav.sensors.BoatSpeedData import org.terst.nav.sensors.DepthData import org.terst.nav.sensors.HeadingData +import org.terst.nav.sensors.NmeaBaroData import org.terst.nav.sensors.WindData import java.io.BufferedReader import java.io.InputStreamReader +import java.net.DatagramPacket +import java.net.DatagramSocket import java.net.InetSocketAddress import java.net.Socket import java.util.concurrent.atomic.AtomicBoolean +enum class NmeaTransport { TCP, UDP } + class NmeaStreamManager( private val parser: NmeaParser, private val connectionScope: CoroutineScope @@ -29,101 +35,101 @@ class NmeaStreamManager( private var connectionJob: Job? = null private val isConnected = AtomicBoolean(false) - // Flows to emit parsed data - private val _nmeaGpsPosition = MutableSharedFlow<GpsPosition>( - replay = 0, - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - val nmeaGpsPosition: SharedFlow<GpsPosition> = _nmeaGpsPosition.asSharedFlow() - - private val _nmeaWindData = MutableSharedFlow<WindData>( - replay = 0, - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST + private fun <T> flow() = MutableSharedFlow<T>( + replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) - val nmeaWindData: SharedFlow<WindData> = _nmeaWindData.asSharedFlow() - private val _nmeaDepthData = MutableSharedFlow<DepthData>( - replay = 0, - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - val nmeaDepthData: SharedFlow<DepthData> = _nmeaDepthData.asSharedFlow() + private val _nmeaGpsPosition = flow<GpsPosition>() + private val _nmeaWindData = flow<WindData>() + private val _nmeaDepthData = flow<DepthData>() + private val _nmeaHeadingData = flow<HeadingData>() + private val _nmeaBoatSpeedData = flow<BoatSpeedData>() + private val _nmeaAttitudeData = flow<AttitudeData>() + private val _nmeaBaroData = flow<NmeaBaroData>() - private val _nmeaHeadingData = MutableSharedFlow<HeadingData>( - replay = 0, - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - val nmeaHeadingData: SharedFlow<HeadingData> = _nmeaHeadingData.asSharedFlow() - - private val _nmeaBoatSpeedData = MutableSharedFlow<BoatSpeedData>( - replay = 0, - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - val nmeaBoatSpeedData: SharedFlow<BoatSpeedData> = _nmeaBoatSpeedData.asSharedFlow() + val nmeaGpsPosition: SharedFlow<GpsPosition> = _nmeaGpsPosition.asSharedFlow() + val nmeaWindData: SharedFlow<WindData> = _nmeaWindData.asSharedFlow() + val nmeaDepthData: SharedFlow<DepthData> = _nmeaDepthData.asSharedFlow() + val nmeaHeadingData: SharedFlow<HeadingData> = _nmeaHeadingData.asSharedFlow() + val nmeaBoatSpeedData: SharedFlow<BoatSpeedData> = _nmeaBoatSpeedData.asSharedFlow() + val nmeaAttitudeData: SharedFlow<AttitudeData> = _nmeaAttitudeData.asSharedFlow() + val nmeaBaroData: SharedFlow<NmeaBaroData> = _nmeaBaroData.asSharedFlow() - fun start(address: String, port: Int) { - if (connectionJob?.isActive == true) { - Log.d(TAG, "NMEA stream already running.") - return + fun start(address: String, port: Int, transport: NmeaTransport = NmeaTransport.TCP) { + if (connectionJob?.isActive == true) return + connectionJob = when (transport) { + NmeaTransport.TCP -> startTcp(address, port) + NmeaTransport.UDP -> startUdp(port) } + } - connectionJob = connectionScope.launch(Dispatchers.IO) { - while (isActive) { - if (!isConnected.get()) { - Log.d(TAG, "Attempting to connect to NMEA source: $address:$port") - try { - Socket().use { socket -> - socket.connect(InetSocketAddress(address, port), CONNECTION_TIMEOUT_MS) - isConnected.set(true) - Log.i(TAG, "Connected to NMEA source: $address:$port") + fun stop() { + connectionJob?.cancel() + connectionJob = null + isConnected.set(false) + } - BufferedReader(InputStreamReader(socket.getInputStream())).use { reader -> - var line: String? - while (isActive && isConnected.get()) { - line = reader.readLine() - if (line != null) { - // Log.v(TAG, "NMEA: $line") // Too verbose for regular logging - parser.parse(line)?.let { parsedData -> - when (parsedData) { - is GpsPosition -> _nmeaGpsPosition.emit(parsedData) - is WindData -> _nmeaWindData.emit(parsedData) - is DepthData -> _nmeaDepthData.emit(parsedData) - is HeadingData -> _nmeaHeadingData.emit(parsedData) - is BoatSpeedData -> _nmeaBoatSpeedData.emit(parsedData) - else -> Log.w(TAG, "Unknown parsed NMEA data type: ${parsedData::class.simpleName}") - } - } - } else { - // End of stream, connection closed by server - Log.w(TAG, "NMEA stream ended, reconnecting...") - isConnected.set(false) - break - } - } - } + private fun startTcp(address: String, port: Int) = connectionScope.launch(Dispatchers.IO) { + while (isActive) { + Log.d(TAG, "TCP connecting to $address:$port") + try { + Socket().use { socket -> + socket.connect(InetSocketAddress(address, port), CONNECTION_TIMEOUT_MS) + isConnected.set(true) + Log.i(TAG, "TCP connected to $address:$port") + BufferedReader(InputStreamReader(socket.getInputStream())).use { reader -> + var line: String? + while (isActive) { + line = reader.readLine() ?: break + processLine(line) } - } catch (e: Exception) { - Log.e(TAG, "NMEA connection error: ${e.message}", e) - isConnected.set(false) } } - if (!isConnected.get()) { - delay(RETRY_DELAY_MS) + } catch (e: Exception) { + Log.e(TAG, "TCP error: ${e.message}") + } + isConnected.set(false) + delay(RETRY_DELAY_MS) + } + } + + private fun startUdp(port: Int) = connectionScope.launch(Dispatchers.IO) { + Log.i(TAG, "UDP listening on port $port") + try { + DatagramSocket(port).use { socket -> + isConnected.set(true) + val buffer = ByteArray(4096) + val packet = DatagramPacket(buffer, buffer.size) + while (isActive) { + socket.receive(packet) + val text = String(packet.data, 0, packet.length, Charsets.US_ASCII) + text.split('\n').forEach { raw -> + val line = raw.trimEnd('\r', ' ') + if (line.isNotBlank()) processLine(line) + } } } - Log.d(TAG, "NMEA connection job finished.") + } catch (e: Exception) { + Log.e(TAG, "UDP error: ${e.message}") + isConnected.set(false) } } - fun stop() { - connectionJob?.cancel() - connectionJob = null - isConnected.set(false) - Log.i(TAG, "NMEA stream stopped.") + private fun processLine(line: String) { + parser.parse(line)?.let { parsed -> + connectionScope.launch { + when (parsed) { + is GpsPosition -> _nmeaGpsPosition.emit(parsed) + is WindData -> _nmeaWindData.emit(parsed) + is DepthData -> _nmeaDepthData.emit(parsed) + is HeadingData -> _nmeaHeadingData.emit(parsed) + is BoatSpeedData -> _nmeaBoatSpeedData.emit(parsed) + is AttitudeData -> _nmeaAttitudeData.emit(parsed) + is NmeaBaroData -> _nmeaBaroData.emit(parsed) + else -> Log.w(TAG, "Unhandled: ${parsed::class.simpleName}") + } + } + } } companion object { diff --git a/android-app/app/src/main/kotlin/org/terst/nav/sensors/AttitudeData.kt b/android-app/app/src/main/kotlin/org/terst/nav/sensors/AttitudeData.kt new file mode 100644 index 0000000..1969c0e --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/sensors/AttitudeData.kt @@ -0,0 +1,7 @@ +package org.terst.nav.sensors + +data class AttitudeData( + val heelDeg: Float, + val pitchDeg: Float, + val timestampMs: Long +) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/sensors/NmeaBaroData.kt b/android-app/app/src/main/kotlin/org/terst/nav/sensors/NmeaBaroData.kt new file mode 100644 index 0000000..2f2bae5 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/sensors/NmeaBaroData.kt @@ -0,0 +1,7 @@ +package org.terst.nav.sensors + +data class NmeaBaroData( + val pressureHpa: Float, + val airTempC: Float?, + val timestampMs: Long +) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/InclinometerView.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/InclinometerView.kt new file mode 100644 index 0000000..c472375 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/InclinometerView.kt @@ -0,0 +1,107 @@ +package org.terst.nav.ui + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.util.AttributeSet +import android.view.View +import androidx.core.content.ContextCompat +import org.terst.nav.R +import kotlin.math.min + +/** + * Compact inclinometer showing heel (rotation) and pitch (dot displacement). + * Heel: the waterline rotates — starboard-down is positive. + * Pitch: dot displaces up (bow up = positive). + */ +class InclinometerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + var heelDeg: Float = 0f + set(value) { field = value; invalidate() } + + var pitchDeg: Float = 0f + set(value) { field = value; invalidate() } + + private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = Color.parseColor("#CC000000") + } + + private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 1.5f + color = Color.parseColor("#44FFFFFF") + } + + private val horizonPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + } + + private val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + } + + private val tickPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 1f + color = Color.parseColor("#33FFFFFF") + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + val primary = try { + val tv = android.util.TypedValue() + context.theme.resolveAttribute(com.google.android.material.R.attr.colorPrimary, tv, true) + tv.data + } catch (e: Exception) { + Color.parseColor("#2B8FC4") + } + horizonPaint.color = primary + horizonPaint.strokeWidth = density * 2.5f + dotPaint.color = primary + } + + private val density get() = resources.displayMetrics.density + + override fun onDraw(canvas: Canvas) { + val cx = width / 2f + val cy = height / 2f + val r = min(width, height) / 2f - density * 2f + + // Background + border + canvas.drawCircle(cx, cy, r, bgPaint) + canvas.drawCircle(cx, cy, r, borderPaint) + + // Clip to circle + canvas.save() + canvas.clipRect(cx - r, cy - r, cx + r, cy + r) + + // Tick marks at 10° intervals (subtle) + for (angle in listOf(-30f, -20f, -10f, 10f, 20f, 30f)) { + canvas.save() + canvas.rotate(angle, cx, cy) + canvas.drawLine(cx, cy - r, cx, cy - r + density * 5f, tickPaint) + canvas.restore() + } + + // Heel: rotate the horizon line + canvas.save() + canvas.rotate(heelDeg, cx, cy) + val lineLen = r * 0.78f + canvas.drawLine(cx - lineLen, cy, cx + lineLen, cy, horizonPaint) + canvas.restore() + + // Pitch: displace dot (up = bow up = negative Y, clamped to ±30°) + val pitchClamped = pitchDeg.coerceIn(-30f, 30f) + val pitchOffsetY = -(pitchClamped / 30f) * r * 0.35f + canvas.drawCircle(cx, cy + pitchOffsetY, density * 3.5f, dotPaint) + + canvas.restore() + } +} |
