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 }