diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml
index 925837955..b470d684e 100644
--- a/core/strings/src/commonMain/composeResources/values/strings.xml
+++ b/core/strings/src/commonMain/composeResources/values/strings.xml
@@ -1016,7 +1016,6 @@
Update via %1$s
Select DFU USB Drive
Your device has rebooted into DFU mode and should appear as a USB drive (e.g., RAK4631).\n\nWhen the file picker opens, please select the root of that drive to save the firmware file.
-
Unset
Always On
@@ -1031,4 +1030,17 @@
- 1 hour
- %1$d hours
+
+
+ Compass
+ Open Compass
+ Distance: %1$s
+ Bearing: %1$s
+ Bearing: N/A
+ This device does not have a compass sensor. Heading is unavailable.
+ Location permission is required to show distance and bearing.
+ Location provider is disabled. Turn on location services.
+ Waiting for a GPS fix to calculate distance and bearing.
+ Estimated area: \u00b1%1$s (\u00b1%2$s)
+ Estimated area: unknown accuracy
\ No newline at end of file
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassHeadingProvider.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassHeadingProvider.kt
new file mode 100644
index 000000000..5bbda223a
--- /dev/null
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassHeadingProvider.kt
@@ -0,0 +1,116 @@
+/*
+ * Copyright (c) 2025 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.meshtastic.feature.node.compass
+
+import android.content.Context
+import android.hardware.Sensor
+import android.hardware.SensorEvent
+import android.hardware.SensorEventListener
+import android.hardware.SensorManager
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import javax.inject.Inject
+
+private const val ROTATION_MATRIX_SIZE = 9
+private const val ORIENTATION_SIZE = 3
+private const val FULL_CIRCLE_DEGREES = 360f
+
+data class HeadingState(
+ val heading: Float? = null, // 0..360 degrees
+ val hasSensor: Boolean = true,
+ val accuracy: Int = SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM,
+)
+
+class CompassHeadingProvider @Inject constructor(@ApplicationContext private val context: Context) {
+
+ /**
+ * Emits compass heading in degrees (magnetic). Callers can correct for true north using the latest location data
+ * when available.
+ */
+ fun headingUpdates(): Flow = callbackFlow {
+ val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
+ if (sensorManager == null) {
+ trySend(HeadingState(hasSensor = false))
+ close()
+ return@callbackFlow
+ }
+
+ val rotationSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
+ val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
+ val magnetometer = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
+
+ if (rotationSensor == null && (accelerometer == null || magnetometer == null)) {
+ trySend(HeadingState(hasSensor = false))
+ awaitClose {}
+ return@callbackFlow
+ }
+
+ val rotationMatrix = FloatArray(ROTATION_MATRIX_SIZE)
+ val orientation = FloatArray(ORIENTATION_SIZE)
+ val accelValues = FloatArray(ORIENTATION_SIZE)
+ val magnetValues = FloatArray(ORIENTATION_SIZE)
+ var hasAccel = false
+ var hasMagnet = false
+
+ val listener =
+ object : SensorEventListener {
+ override fun onSensorChanged(event: SensorEvent) {
+ when (event.sensor.type) {
+ Sensor.TYPE_ROTATION_VECTOR -> {
+ SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
+ }
+ Sensor.TYPE_ACCELEROMETER -> {
+ System.arraycopy(event.values, 0, accelValues, 0, accelValues.size)
+ hasAccel = true
+ }
+ Sensor.TYPE_MAGNETIC_FIELD -> {
+ System.arraycopy(event.values, 0, magnetValues, 0, magnetValues.size)
+ hasMagnet = true
+ }
+ }
+
+ val ready = rotationSensor != null || (hasAccel && hasMagnet)
+ if (ready) {
+ if (rotationSensor == null) {
+ SensorManager.getRotationMatrix(rotationMatrix, null, accelValues, magnetValues)
+ }
+
+ SensorManager.getOrientation(rotationMatrix, orientation)
+ var azimuth = Math.toDegrees(orientation[0].toDouble()).toFloat()
+ val heading = (azimuth + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES
+
+ trySend(HeadingState(heading = heading, hasSensor = true, accuracy = event.accuracy))
+ }
+ }
+
+ override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
+ // No-op
+ }
+ }
+
+ rotationSensor?.let { sensorManager.registerListener(listener, it, SensorManager.SENSOR_DELAY_UI) }
+ if (rotationSensor == null) {
+ accelerometer?.let { sensorManager.registerListener(listener, it, SensorManager.SENSOR_DELAY_UI) }
+ magnetometer?.let { sensorManager.registerListener(listener, it, SensorManager.SENSOR_DELAY_UI) }
+ }
+
+ awaitClose { sensorManager.unregisterListener(listener) }
+ }
+}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassUiState.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassUiState.kt
new file mode 100644
index 000000000..a8f44f0d6
--- /dev/null
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassUiState.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2025 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.meshtastic.feature.node.compass
+
+import androidx.compose.ui.graphics.Color
+import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
+
+private const val DEFAULT_TARGET_COLOR_HEX = 0xFFFF9800
+
+enum class CompassWarning {
+ NO_MAGNETOMETER,
+ NO_LOCATION_PERMISSION,
+ LOCATION_DISABLED,
+ NO_LOCATION_FIX,
+}
+
+/** Render-ready state for the compass sheet (heading, bearing, distances, and warnings). */
+data class CompassUiState(
+ val targetName: String = "",
+ val targetColor: Color = Color(DEFAULT_TARGET_COLOR_HEX),
+ val heading: Float? = null,
+ val bearing: Float? = null,
+ val distanceText: String? = null,
+ val bearingText: String? = null,
+ val lastUpdateText: String? = null,
+ val positionTimeSec: Long? = null, // Epoch seconds for the target position (used for elapsed display)
+ val warnings: List = emptyList(),
+ val errorRadiusText: String? = null,
+ val angularErrorDeg: Float? = null,
+ val isAligned: Boolean = false,
+ val hasTargetPosition: Boolean = true,
+ val displayUnits: DisplayUnits = DisplayUnits.METRIC,
+)
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt
new file mode 100644
index 000000000..d8a96113e
--- /dev/null
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt
@@ -0,0 +1,253 @@
+/*
+ * Copyright (c) 2025 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.meshtastic.feature.node.compass
+
+import android.hardware.GeomagneticField
+import androidx.compose.ui.graphics.Color
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.util.bearing
+import org.meshtastic.core.model.util.latLongToMeter
+import org.meshtastic.core.model.util.toDistanceString
+import org.meshtastic.core.ui.component.precisionBitsToMeters
+import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
+import javax.inject.Inject
+import kotlin.math.abs
+import kotlin.math.atan2
+import kotlin.math.min
+import kotlin.math.pow
+import kotlin.math.sqrt
+
+private const val ALIGNMENT_TOLERANCE_DEGREES = 5f
+private const val FULL_CIRCLE_DEGREES = 360f
+private const val BEARING_FORMAT = "%.0f°"
+private const val MILLIS_PER_SECOND = 1000
+private const val SECONDS_PER_HOUR = 3600
+private const val SECONDS_PER_MINUTE = 60
+private const val HUNDRED = 100f
+private const val MILLIMETERS_PER_METER = 1000f
+
+@HiltViewModel
+@Suppress("TooManyFunctions")
+class CompassViewModel
+@Inject
+constructor(
+ private val headingProvider: CompassHeadingProvider,
+ private val phoneLocationProvider: PhoneLocationProvider,
+ private val dispatchers: CoroutineDispatchers,
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(CompassUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private var updatesJob: Job? = null
+ private var targetPosition: Pair? = null
+ private var targetPositionProto: org.meshtastic.proto.MeshProtos.Position? = null
+ private var targetPositionTimeSec: Long? = null
+
+ fun start(node: Node, displayUnits: DisplayUnits) {
+ val targetPos = node.validPosition?.let { node.latitude to node.longitude }
+ targetPosition = targetPos
+ targetPositionProto = node.position
+ val targetColor = Color(node.colors.second)
+ val targetName = node.user.longName.ifBlank { node.user.shortName.ifBlank { node.num.toString() } }
+ targetPositionTimeSec =
+ node.position.timestamp.takeIf { it > 0 }?.toLong() ?: node.position.time.takeIf { it > 0 }?.toLong()
+
+ _uiState.update {
+ it.copy(
+ targetName = targetName,
+ targetColor = targetColor,
+ hasTargetPosition = targetPos != null,
+ displayUnits = displayUnits,
+ positionTimeSec = targetPositionTimeSec,
+ )
+ }
+
+ updatesJob?.cancel()
+
+ updatesJob =
+ viewModelScope.launch {
+ combine(headingProvider.headingUpdates(), phoneLocationProvider.locationUpdates()) {
+ heading,
+ location,
+ ->
+ buildState(heading, location)
+ }
+ .flowOn(dispatchers.default)
+ .collect { _uiState.value = it }
+ }
+ }
+
+ fun stop() {
+ updatesJob?.cancel()
+ updatesJob = null
+ }
+
+ private fun buildState(headingState: HeadingState, locationState: PhoneLocationState): CompassUiState {
+ val current = _uiState.value
+ val warnings = buildWarnings(headingState, locationState)
+ val target = targetPosition
+
+ val positionalAccuracyMeters = target?.let { calculatePositionalAccuracyMeters() }
+ val distanceMeters = calculateDistanceMeters(locationState, target)
+ val bearingDegrees = calculateBearing(locationState, target)
+ val trueHeading = applyTrueNorthCorrection(headingState.heading, locationState)
+ val distanceText = distanceMeters?.toDistanceString(current.displayUnits)
+ val bearingText = bearingDegrees?.let { BEARING_FORMAT.format(it) }
+ val isAligned = isAligned(trueHeading, bearingDegrees)
+ val lastUpdateText = targetPositionTimeSec?.let { formatElapsed(it) }
+ val angularErrorDeg = calculateAngularError(positionalAccuracyMeters, distanceMeters)
+ val errorRadiusText = positionalAccuracyMeters?.toInt()?.toDistanceString(current.displayUnits)
+
+ return current.copy(
+ heading = trueHeading,
+ bearing = bearingDegrees,
+ distanceText = distanceText,
+ bearingText = bearingText,
+ warnings = warnings,
+ isAligned = isAligned,
+ lastUpdateText = lastUpdateText,
+ errorRadiusText = errorRadiusText,
+ angularErrorDeg = angularErrorDeg,
+ )
+ }
+
+ private fun buildWarnings(headingState: HeadingState, locationState: PhoneLocationState): List =
+ buildList {
+ if (!headingState.hasSensor) add(CompassWarning.NO_MAGNETOMETER)
+ if (!locationState.permissionGranted) {
+ add(CompassWarning.NO_LOCATION_PERMISSION)
+ } else if (!locationState.providerEnabled) {
+ add(CompassWarning.LOCATION_DISABLED)
+ } else if (!locationState.hasFix) {
+ add(CompassWarning.NO_LOCATION_FIX)
+ }
+ }
+
+ private fun calculateBearing(locationState: PhoneLocationState, target: Pair?): Float? =
+ if (canUseLocation(locationState, target)) {
+ val location = locationState.location ?: return null
+ val activeTarget = target ?: return null
+ bearing(location.latitude, location.longitude, activeTarget.first, activeTarget.second).toFloat()
+ } else {
+ null
+ }
+
+ private fun calculateDistanceMeters(locationState: PhoneLocationState, target: Pair?): Int? =
+ if (canUseLocation(locationState, target)) {
+ val location = locationState.location ?: return null
+ val activeTarget = target ?: return null
+ latLongToMeter(location.latitude, location.longitude, activeTarget.first, activeTarget.second).toInt()
+ } else {
+ null
+ }
+
+ private fun canUseLocation(locationState: PhoneLocationState, target: Pair?): Boolean =
+ target != null &&
+ locationState.permissionGranted &&
+ locationState.providerEnabled &&
+ locationState.location != null
+
+ private fun isAligned(heading: Float?, bearingDegrees: Float?): Boolean {
+ if (heading == null || bearingDegrees == null) return false
+ return angularDifference(heading, bearingDegrees) <= ALIGNMENT_TOLERANCE_DEGREES
+ }
+
+ private fun angularDifference(heading: Float, target: Float): Float {
+ val diff = abs(heading - target) % FULL_CIRCLE_DEGREES
+ return min(diff, FULL_CIRCLE_DEGREES - diff)
+ }
+
+ @Suppress("ReturnCount")
+ private fun applyTrueNorthCorrection(heading: Float?, locationState: PhoneLocationState): Float? {
+ val loc = locationState.location ?: return heading
+ val baseHeading = heading ?: return null
+ val geomagnetic =
+ GeomagneticField(
+ loc.latitude.toFloat(),
+ loc.longitude.toFloat(),
+ loc.altitude.toFloat(),
+ System.currentTimeMillis(),
+ )
+ return (baseHeading + geomagnetic.declination + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES
+ }
+
+ private fun formatElapsed(timestampSec: Long): String {
+ val nowSec = System.currentTimeMillis() / MILLIS_PER_SECOND
+ val diff = maxOf(0, nowSec - timestampSec)
+ val hours = diff / SECONDS_PER_HOUR
+ val minutes = (diff % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE
+ val seconds = diff % SECONDS_PER_MINUTE
+ // Show a short elapsed string to match iOS behavior and avoid locale/format churn
+ return "${hours}h ${minutes}m ${seconds}s ago"
+ }
+
+ @Suppress("ReturnCount")
+ private fun calculatePositionalAccuracyMeters(): Float? {
+ val position = targetPositionProto ?: return null
+ val positionTime = targetPositionTimeSec
+ if (positionTime == null || positionTime <= 0) return null
+
+ val gpsAccuracyMm = position.gpsAccuracy.toFloat()
+ val dop: Float? =
+ when {
+ position.getPDOP() > 0 -> position.getPDOP() / HUNDRED
+ position.getHDOP() > 0 && position.getVDOP() > 0 ->
+ sqrt(
+ (position.getHDOP() / HUNDRED).toDouble().pow(2.0) +
+ (position.getVDOP() / HUNDRED).toDouble().pow(2.0),
+ )
+ .toFloat()
+ position.getHDOP() > 0 -> position.getHDOP() / HUNDRED
+ else -> null
+ }
+
+ if (gpsAccuracyMm > 0f && dop != null) {
+ return (gpsAccuracyMm / MILLIMETERS_PER_METER) * dop
+ }
+
+ // Fallback: infer radius from precision bits if provided
+ if (position.precisionBits > 0) {
+ return precisionBitsToMeters(position.precisionBits).toFloat()
+ }
+
+ return null
+ }
+
+ @Suppress("ReturnCount")
+ private fun calculateAngularError(positionalAccuracyMeters: Float?, distanceMeters: Int?): Float? {
+ val distance = distanceMeters ?: return null
+ val accuracy = positionalAccuracyMeters ?: return null
+ if (distance <= 0) return FULL_CIRCLE_DEGREES / 2
+
+ val radians = atan2(accuracy.toDouble(), distance.toDouble())
+ return Math.toDegrees(radians).toFloat().coerceIn(0f, FULL_CIRCLE_DEGREES / 2)
+ }
+}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/PhoneLocationProvider.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/PhoneLocationProvider.kt
new file mode 100644
index 000000000..6d59b90fe
--- /dev/null
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/PhoneLocationProvider.kt
@@ -0,0 +1,129 @@
+/*
+ * Copyright (c) 2025 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.meshtastic.feature.node.compass
+
+import android.Manifest
+import android.content.Context
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.Looper
+import androidx.core.content.ContextCompat
+import androidx.core.location.LocationManagerCompat
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.flowOn
+import org.meshtastic.core.di.CoroutineDispatchers
+import javax.inject.Inject
+
+data class PhoneLocationState(
+ val permissionGranted: Boolean,
+ val providerEnabled: Boolean,
+ val location: Location? = null,
+) {
+ val hasFix: Boolean
+ get() = location != null
+}
+
+class PhoneLocationProvider
+@Inject
+constructor(
+ @ApplicationContext private val context: Context,
+ private val dispatchers: CoroutineDispatchers,
+) {
+ // Streams phone location (and permission/provider state) so the compass stays gated on real fixes.
+ fun locationUpdates(): Flow = callbackFlow {
+ val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
+ if (locationManager == null) {
+ trySend(PhoneLocationState(permissionGranted = false, providerEnabled = false))
+ close()
+ return@callbackFlow
+ }
+
+ val permissionGranted = hasLocationPermission()
+ if (!permissionGranted) {
+ trySend(PhoneLocationState(permissionGranted = false, providerEnabled = false))
+ awaitClose {}
+ return@callbackFlow
+ }
+
+ val listener = LocationListener { location ->
+ trySend(
+ PhoneLocationState(
+ permissionGranted = true,
+ providerEnabled = LocationManagerCompat.isLocationEnabled(locationManager),
+ location = location,
+ ),
+ )
+ }
+
+ val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
+
+ try {
+ // Emit last known location if available
+ providers
+ .mapNotNull { provider -> locationManager.getLastKnownLocation(provider) }
+ .maxByOrNull { it.time }
+ ?.let { lastLocation ->
+ trySend(
+ PhoneLocationState(
+ permissionGranted = true,
+ providerEnabled = LocationManagerCompat.isLocationEnabled(locationManager),
+ location = lastLocation,
+ ),
+ )
+ }
+
+ providers.forEach { provider ->
+ if (locationManager.getProvider(provider) != null) {
+ locationManager.requestLocationUpdates(
+ provider,
+ MIN_UPDATE_INTERVAL_MS,
+ 0f,
+ listener,
+ Looper.getMainLooper(),
+ )
+ }
+ }
+
+ // Emit provider-disabled state if location is off
+ if (!LocationManagerCompat.isLocationEnabled(locationManager)) {
+ trySend(PhoneLocationState(permissionGranted = true, providerEnabled = false, location = null))
+ }
+ } catch (securityException: SecurityException) {
+ trySend(PhoneLocationState(permissionGranted = false, providerEnabled = false))
+ close(securityException)
+ return@callbackFlow
+ }
+
+ awaitClose { locationManager.removeUpdates(listener) }
+ }
+ .flowOn(dispatchers.io)
+
+ private fun hasLocationPermission(): Boolean =
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
+ android.content.pm.PackageManager.PERMISSION_GRANTED ||
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
+ android.content.pm.PackageManager.PERMISSION_GRANTED
+
+ companion object {
+ private const val MIN_UPDATE_INTERVAL_MS = 1_000L
+ }
+}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt
new file mode 100644
index 000000000..cadf61a7b
--- /dev/null
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt
@@ -0,0 +1,436 @@
+/*
+ * Copyright (c) 2025 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.meshtastic.feature.node.component
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ErrorOutline
+import androidx.compose.material.icons.filled.GpsFixed
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.drawscope.rotate
+import androidx.compose.ui.graphics.drawscope.withTransform
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.drawText
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.compass_bearing
+import org.meshtastic.core.strings.compass_bearing_na
+import org.meshtastic.core.strings.compass_distance
+import org.meshtastic.core.strings.compass_location_disabled
+import org.meshtastic.core.strings.compass_no_location_fix
+import org.meshtastic.core.strings.compass_no_location_permission
+import org.meshtastic.core.strings.compass_no_magnetometer
+import org.meshtastic.core.strings.compass_title
+import org.meshtastic.core.strings.compass_uncertainty
+import org.meshtastic.core.strings.compass_uncertainty_unknown
+import org.meshtastic.core.strings.exchange_position
+import org.meshtastic.core.strings.last_position_update
+import org.meshtastic.core.ui.theme.AppTheme
+import org.meshtastic.feature.node.compass.CompassUiState
+import org.meshtastic.feature.node.compass.CompassWarning
+import kotlin.math.cos
+import kotlin.math.sin
+
+private const val DIAL_WIDTH_FRACTION = 0.66f
+
+@Composable
+@Suppress("LongMethod", "MagicNumber")
+fun CompassSheetContent(
+ uiState: CompassUiState,
+ onRequestLocationPermission: () -> Unit,
+ onOpenLocationSettings: () -> Unit,
+ onRequestPosition: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val haptics = LocalHapticFeedback.current
+
+ LaunchedEffect(uiState.isAligned) {
+ if (uiState.isAligned) haptics.performHapticFeedback(HapticFeedbackType.LongPress)
+ }
+
+ Column(
+ modifier = modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(text = stringResource(Res.string.compass_title), style = MaterialTheme.typography.headlineSmall)
+ Text(text = uiState.targetName, style = MaterialTheme.typography.titleMedium, color = uiState.targetColor)
+
+ CompassDial(
+ heading = uiState.heading,
+ bearing = uiState.bearing,
+ angularErrorDeg = uiState.angularErrorDeg,
+ modifier = Modifier.fillMaxWidth(DIAL_WIDTH_FRACTION).aspectRatio(1f),
+ markerColor = uiState.targetColor,
+ )
+
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = stringResource(Res.string.compass_distance, uiState.distanceText ?: "--"),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ Text(
+ text =
+ uiState.bearingText?.let { stringResource(Res.string.compass_bearing, it) }
+ ?: stringResource(Res.string.compass_bearing_na),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ Text(
+ text =
+ uiState.errorRadiusText?.let { radius ->
+ val angle = uiState.angularErrorDeg?.let { "%.0f°".format(it) } ?: "?"
+ stringResource(Res.string.compass_uncertainty, radius, angle)
+ } ?: stringResource(Res.string.compass_uncertainty_unknown),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ }
+
+ uiState.lastUpdateText?.let {
+ Text(
+ text = stringResource(Res.string.last_position_update) + ": $it",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ // Quick way to re-request a fresh fix without leaving the compass sheet
+ Button(onClick = onRequestPosition, modifier = Modifier.fillMaxWidth()) {
+ Icon(imageVector = Icons.Default.GpsFixed, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(text = stringResource(Res.string.exchange_position))
+ }
+ }
+
+ if (uiState.warnings.isNotEmpty()) {
+ WarningList(
+ warnings = uiState.warnings,
+ onRequestPermission = onRequestLocationPermission,
+ onOpenLocationSettings = onOpenLocationSettings,
+ )
+ }
+ }
+}
+
+@Composable
+private fun WarningList(
+ warnings: List,
+ onRequestPermission: () -> Unit,
+ onOpenLocationSettings: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ warnings.forEach { warning ->
+ Surface(
+ tonalElevation = 2.dp,
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.errorContainer,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.ErrorOutline,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onErrorContainer,
+ )
+ Text(
+ text = warningText(warning),
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ }
+ }
+
+ if (warnings.contains(CompassWarning.NO_LOCATION_PERMISSION)) {
+ Button(onClick = onRequestPermission, modifier = Modifier.fillMaxWidth()) {
+ Icon(imageVector = Icons.Default.GpsFixed, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(text = stringResource(Res.string.compass_no_location_permission))
+ }
+ } else if (warnings.contains(CompassWarning.LOCATION_DISABLED)) {
+ Button(onClick = onOpenLocationSettings, modifier = Modifier.fillMaxWidth()) {
+ Icon(imageVector = Icons.Default.GpsFixed, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(text = stringResource(Res.string.compass_location_disabled))
+ }
+ }
+ }
+}
+
+@Composable
+private fun warningText(warning: CompassWarning): String = when (warning) {
+ CompassWarning.NO_MAGNETOMETER -> stringResource(Res.string.compass_no_magnetometer)
+ CompassWarning.NO_LOCATION_PERMISSION -> stringResource(Res.string.compass_no_location_permission)
+ CompassWarning.LOCATION_DISABLED -> stringResource(Res.string.compass_location_disabled)
+ CompassWarning.NO_LOCATION_FIX -> stringResource(Res.string.compass_no_location_fix)
+}
+
+@Composable
+@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
+private fun CompassDial(
+ heading: Float?,
+ bearing: Float?,
+ angularErrorDeg: Float?,
+ modifier: Modifier = Modifier,
+ markerColor: Color = Color(0xFF2196F3),
+) {
+ val compassRoseColor = MaterialTheme.colorScheme.primary
+ val tickColor = MaterialTheme.colorScheme.onSurface
+ val cardinalColor = MaterialTheme.colorScheme.primary
+ val degreeTextColor = MaterialTheme.colorScheme.onSurfaceVariant
+ val northPointerColor = Color.Red
+ val headingIndicatorColor = MaterialTheme.colorScheme.secondary
+
+ val textMeasurer = rememberTextMeasurer()
+ val cardinalStyle = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold)
+ val degreeStyle = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Medium)
+
+ Canvas(modifier = modifier) {
+ val center = Offset(size.width / 2f, size.height / 2f)
+ val radius = size.minDimension / 2f * 0.88f
+ val ringStroke = 3.dp.toPx()
+
+ val currentHeading = heading ?: 0f
+ val currentBearing = bearing
+
+ rotate(-currentHeading, center) {
+ // Compass circles
+ drawCircle(color = compassRoseColor, radius = radius, center = center, style = Stroke(width = ringStroke))
+ drawCircle(
+ color = compassRoseColor.copy(alpha = 0.35f),
+ radius = radius * 0.85f,
+ center = center,
+ style = Stroke(width = 1.dp.toPx()),
+ )
+
+ // Tick marks
+ for (deg in 0 until 360 step 5) {
+ val isCardinal = deg % 90 == 0
+ val isMajor = deg % 30 == 0
+ val tickLength =
+ when {
+ isCardinal -> radius * 0.14f
+ isMajor -> radius * 0.09f
+ else -> radius * 0.045f
+ }
+ val tickWidth =
+ when {
+ isCardinal -> 3.dp.toPx()
+ isMajor -> 2.dp.toPx()
+ else -> 1.dp.toPx()
+ }
+
+ val angle = Math.toRadians(deg.toDouble())
+ val outer = Offset(center.x + radius * sin(angle).toFloat(), center.y - radius * cos(angle).toFloat())
+ val inner =
+ Offset(
+ center.x + (radius - tickLength) * sin(angle).toFloat(),
+ center.y - (radius - tickLength) * cos(angle).toFloat(),
+ )
+
+ drawLine(
+ color = if (deg == 0) northPointerColor else tickColor,
+ start = inner,
+ end = outer,
+ strokeWidth = tickWidth,
+ cap = StrokeCap.Round,
+ )
+ }
+
+ // Compass rose center
+ drawCompassRoseCenter(center = center, size = radius * 0.13f, color = compassRoseColor)
+
+ // Cardinal labels (moved closer to center)
+ val cardinalRadius = radius * 0.48f
+ val cardinals =
+ listOf(
+ Triple("N", 0, northPointerColor),
+ Triple("E", 90, cardinalColor),
+ Triple("S", 180, cardinalColor),
+ Triple("W", 270, cardinalColor),
+ )
+
+ for ((label, deg, color) in cardinals) {
+ val angle = Math.toRadians(deg.toDouble())
+ val x = center.x + cardinalRadius * sin(angle).toFloat()
+ val y = center.y - cardinalRadius * cos(angle).toFloat()
+
+ val layout = textMeasurer.measure(label, style = cardinalStyle.copy(color = color))
+
+ withTransform({ rotate(currentHeading, Offset(x, y)) }) {
+ drawText(
+ textLayoutResult = layout,
+ topLeft = Offset(x - layout.size.width / 2f, y - layout.size.height / 2f),
+ )
+ }
+ }
+
+ // Degree labels
+ val degRadius = radius * 0.72f
+ for (d in 0 until 360 step 30) {
+ val angle = Math.toRadians(d.toDouble())
+ val x = center.x + degRadius * sin(angle).toFloat()
+ val y = center.y - degRadius * cos(angle).toFloat()
+
+ val layout = textMeasurer.measure(d.toString(), style = degreeStyle.copy(color = degreeTextColor))
+
+ withTransform({ rotate(currentHeading, Offset(x, y)) }) {
+ drawText(
+ textLayoutResult = layout,
+ topLeft = Offset(x - layout.size.width / 2f, y - layout.size.height / 2f),
+ )
+ }
+ }
+
+ // Bearing marker (adjust bearing by current heading because the canvas is rotated)
+ val bearingForDraw = currentBearing
+ if (bearingForDraw != null && angularErrorDeg != null && angularErrorDeg > 0f) {
+ val arcRadius = radius * 0.82f
+ val startAngleNorth = bearingForDraw - angularErrorDeg
+ val sweep = angularErrorDeg * 2f
+ val faint = markerColor.copy(alpha = 0.18f)
+ // Canvas drawArc: 0deg = 3 o'clock, +clockwise. Convert north=0° to that space.
+ val startAngleCanvas = (startAngleNorth - 90f).normalizeDegrees()
+
+ // Filled wedge for the cone shading.
+ drawArc(
+ color = faint,
+ startAngle = startAngleCanvas,
+ sweepAngle = sweep,
+ useCenter = true,
+ topLeft = Offset(center.x - arcRadius, center.y - arcRadius),
+ size = Size(arcRadius * 2, arcRadius * 2),
+ )
+
+ // Cone edge lines for clarity
+ val edgeRadius = arcRadius
+ val startRad = Math.toRadians(startAngleNorth.toDouble())
+ val endRad = Math.toRadians((startAngleNorth + sweep).toDouble())
+ val startEnd =
+ Offset(
+ center.x + edgeRadius * sin(startRad).toFloat(),
+ center.y - edgeRadius * cos(startRad).toFloat(),
+ )
+ val endEnd =
+ Offset(center.x + edgeRadius * sin(endRad).toFloat(), center.y - edgeRadius * cos(endRad).toFloat())
+ drawLine(color = faint, start = center, end = startEnd, strokeWidth = 6f, cap = StrokeCap.Round)
+ drawLine(color = faint, start = center, end = endEnd, strokeWidth = 6f, cap = StrokeCap.Round)
+ }
+ if (bearingForDraw != null) {
+ val angle = Math.toRadians(bearingForDraw.toDouble())
+ val dot =
+ Offset(
+ center.x + (radius * 0.95f) * sin(angle).toFloat(),
+ center.y - (radius * 0.95f) * cos(angle).toFloat(),
+ )
+ drawCircle(color = markerColor, radius = 10.dp.toPx(), center = dot)
+ }
+ }
+
+ // Heading indicator as a simple line
+ if (heading != null) {
+ val headingEnd = Offset(center.x, center.y - radius * 0.78f)
+ drawLine(
+ color = headingIndicatorColor,
+ start = center,
+ end = headingEnd,
+ strokeWidth = 6.dp.toPx(),
+ cap = StrokeCap.Round,
+ )
+ }
+ }
+}
+
+@Suppress("MagicNumber")
+private fun DrawScope.drawCompassRoseCenter(center: Offset, size: Float, color: Color) {
+ val path =
+ Path().apply {
+ moveTo(center.x, center.y - size)
+ lineTo(center.x + size * 0.35f, center.y)
+ lineTo(center.x, center.y + size * 0.35f)
+ lineTo(center.x - size * 0.35f, center.y)
+ close()
+ }
+
+ drawPath(path, color.copy(alpha = 0.5f))
+ drawCircle(color = color, radius = size * 0.25f, center = center)
+}
+
+@Suppress("MagicNumber")
+private fun Float.normalizeDegrees(): Float {
+ val normalized = this % 360f
+ return if (normalized < 0f) normalized + 360f else normalized
+}
+
+@Preview(showBackground = true)
+@Composable
+@Suppress("MagicNumber")
+private fun CompassSheetPreview() {
+ AppTheme {
+ CompassSheetContent(
+ uiState =
+ CompassUiState(
+ targetName = "Sample Node",
+ heading = 45f,
+ bearing = 90f,
+ distanceText = "1.2 km",
+ bearingText = "90°",
+ lastUpdateText = "0h 3m 10s ago",
+ errorRadiusText = "150 m",
+ angularErrorDeg = 12f,
+ isAligned = false,
+ ),
+ onRequestLocationPermission = {},
+ onOpenLocationSettings = {},
+ onRequestPosition = {},
+ )
+ }
+}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
index a3b270753..4ff9fadd8 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
@@ -20,6 +20,7 @@ package org.meshtastic.feature.node.component
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.SocialDistance
import androidx.compose.runtime.Composable
@@ -31,6 +32,7 @@ import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.exchange_position
import org.meshtastic.core.strings.node_sort_distance
+import org.meshtastic.core.strings.open_compass
import org.meshtastic.core.strings.position
import org.meshtastic.core.ui.component.InsetDivider
import org.meshtastic.core.ui.component.ListItem
@@ -85,6 +87,17 @@ fun PositionSection(
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) },
)
+ if (hasValidPosition) {
+ InsetDivider()
+
+ ListItem(
+ text = stringResource(Res.string.open_compass),
+ leadingIcon = Icons.Default.Explore,
+ trailingIcon = null,
+ onClick = { onAction(NodeDetailAction.OpenCompass(node, metricsState.displayUnits)) },
+ )
+ }
+
// Node Map log
if (availableLogs.contains(LogsType.NODE_MAP)) {
InsetDivider()
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailList.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailList.kt
index e345e0a16..60ea2d37a 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailList.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailList.kt
@@ -17,6 +17,13 @@
package org.meshtastic.feature.node.detail
+import android.Manifest
+import android.content.Intent
+import android.provider.Settings
+import androidx.activity.compose.ManagedActivityResultLauncher
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.ActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -28,25 +35,33 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
+import org.meshtastic.feature.node.compass.CompassUiState
+import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.component.AdministrationSection
+import org.meshtastic.feature.node.component.CompassSheetContent
import org.meshtastic.feature.node.component.DeviceActions
import org.meshtastic.feature.node.component.DeviceDetailsSection
import org.meshtastic.feature.node.component.FirmwareReleaseSheetContent
import org.meshtastic.feature.node.component.MetricsSection
import org.meshtastic.feature.node.component.NodeDetailsSection
+import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.component.NotesSection
import org.meshtastic.feature.node.component.PositionSection
import org.meshtastic.feature.node.model.LogsType
@@ -89,6 +104,7 @@ fun NodeDetailContent(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
+@Suppress("LongMethod")
fun NodeDetailList(
node: Node,
lastTracerouteTime: Long?,
@@ -101,13 +117,38 @@ fun NodeDetailList(
) {
var showFirmwareSheet by remember { mutableStateOf(false) }
var selectedFirmware by remember { mutableStateOf(null) }
+ var showCompassSheet by remember { mutableStateOf(false) }
- if (showFirmwareSheet) {
- val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
- ModalBottomSheet(onDismissRequest = { showFirmwareSheet = false }, sheetState = sheetState) {
- selectedFirmware?.let { FirmwareReleaseSheetContent(firmwareRelease = it) }
- }
- }
+ val inspectionMode = LocalInspectionMode.current
+ val compassViewModel = if (inspectionMode) null else hiltViewModel()
+ val compassUiState by
+ compassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
+ var compassTargetNode by remember { mutableStateOf(null) } // Cache target for sheet-side position requests
+
+ val permissionLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ -> }
+ val locationSettingsLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ -> }
+
+ FirmwareSheetHost(
+ showFirmwareSheet = showFirmwareSheet,
+ onDismiss = { showFirmwareSheet = false },
+ firmwareRelease = selectedFirmware,
+ )
+
+ CompassSheetHost(
+ showCompassSheet = showCompassSheet,
+ compassViewModel = compassViewModel,
+ compassUiState = compassUiState,
+ onDismiss = { showCompassSheet = false },
+ permissionLauncher = permissionLauncher,
+ locationSettingsLauncher = locationSettingsLauncher,
+ onRequestPosition = {
+ compassTargetNode?.let { target ->
+ onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(target)))
+ }
+ },
+ )
Column(
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp).focusable(),
@@ -133,7 +174,17 @@ fun NodeDetailList(
ourNode = ourNode,
metricsState = metricsState,
availableLogs = availableLogs,
- onAction = onAction,
+ onAction = { action ->
+ when (action) {
+ is NodeDetailAction.OpenCompass -> {
+ compassViewModel?.start(action.node, action.displayUnits)
+ compassTargetNode = action.node
+ showCompassSheet = compassViewModel != null
+ }
+
+ else -> onAction(action)
+ }
+ },
)
MetricsSection(node, metricsState, availableLogs, onAction)
@@ -152,6 +203,58 @@ fun NodeDetailList(
}
}
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun FirmwareSheetHost(showFirmwareSheet: Boolean, onDismiss: () -> Unit, firmwareRelease: FirmwareRelease?) {
+ if (showFirmwareSheet) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
+ ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
+ firmwareRelease?.let { FirmwareReleaseSheetContent(firmwareRelease = it) }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+@Suppress("LongParameterList")
+private fun CompassSheetHost(
+ showCompassSheet: Boolean,
+ compassViewModel: CompassViewModel?,
+ compassUiState: CompassUiState,
+ onDismiss: () -> Unit,
+ permissionLauncher: ManagedActivityResultLauncher, Map>,
+ locationSettingsLauncher: ManagedActivityResultLauncher,
+ onRequestPosition: () -> Unit,
+) {
+ if (showCompassSheet && compassViewModel != null) {
+ // Tie sensor lifecycle to the sheet so streams stop as soon as the sheet is dismissed.
+ DisposableEffect(Unit) { onDispose { compassViewModel.stop() } }
+
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
+ ModalBottomSheet(
+ onDismissRequest = {
+ compassViewModel.stop()
+ onDismiss()
+ },
+ sheetState = sheetState,
+ ) {
+ CompassSheetContent(
+ uiState = compassUiState,
+ onRequestLocationPermission = {
+ permissionLauncher.launch(
+ arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
+ )
+ },
+ onOpenLocationSettings = {
+ locationSettingsLauncher.launch(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS))
+ },
+ onRequestPosition = onRequestPosition,
+ modifier = Modifier.padding(bottom = 24.dp),
+ )
+ }
+ }
+}
+
@Preview(showBackground = true)
@Composable
private fun NodeDetailsPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) {
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt
index 863a2fbc8..3c593d7b6 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt
@@ -157,5 +157,9 @@ private fun handleNodeAction(
is NodeDetailAction.ShareContact -> {
/* Handled in NodeDetailContent */
}
+
+ is NodeDetailAction.OpenCompass -> {
+ /* Handled in NodeDetailList */
+ }
}
}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt
index 09829beb8..540e48190 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt
@@ -17,9 +17,11 @@
package org.meshtastic.feature.node.model
+import org.meshtastic.core.database.model.Node
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.feature.node.component.NodeMenuAction
+import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
sealed interface NodeDetailAction {
data class Navigate(val route: Route) : NodeDetailAction
@@ -29,4 +31,7 @@ sealed interface NodeDetailAction {
data class HandleNodeMenuAction(val action: NodeMenuAction) : NodeDetailAction
data object ShareContact : NodeDetailAction
+
+ // Opens the compass sheet scoped to a target node and the user’s preferred units.
+ data class OpenCompass(val node: Node, val displayUnits: DisplayUnits) : NodeDetailAction
}