Port “Compass view” bottom sheet from Meshtastic-Apple PR #1504 (#3896)

Signed-off-by: Jake Vis <github@jv.ag>
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
Jake Vis
2025-12-12 10:51:34 -08:00
committed by GitHub
parent 1a78745e6e
commit d3cd9674c9
10 changed files with 1127 additions and 8 deletions

View File

@@ -1016,7 +1016,6 @@
<string name="firmware_update_method_detail">Update via %1$s</string>
<string name="firmware_update_usb_instruction_title">Select DFU USB Drive</string>
<string name="firmware_update_usb_instruction_text">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.</string>
<string name="interval_unset">Unset</string>
<string name="interval_always_on">Always On</string>
<plurals name="plurals_seconds">
@@ -1031,4 +1030,17 @@
<item quantity="one">1 hour</item>
<item quantity="other">%1$d hours</item>
</plurals>
<!-- Compass -->
<string name="compass_title">Compass</string>
<string name="open_compass">Open Compass</string>
<string name="compass_distance">Distance: %1$s</string>
<string name="compass_bearing">Bearing: %1$s</string>
<string name="compass_bearing_na">Bearing: N/A</string>
<string name="compass_no_magnetometer">This device does not have a compass sensor. Heading is unavailable.</string>
<string name="compass_no_location_permission">Location permission is required to show distance and bearing.</string>
<string name="compass_location_disabled">Location provider is disabled. Turn on location services.</string>
<string name="compass_no_location_fix">Waiting for a GPS fix to calculate distance and bearing.</string>
<string name="compass_uncertainty">Estimated area: \u00b1%1$s (\u00b1%2$s)</string>
<string name="compass_uncertainty_unknown">Estimated area: unknown accuracy</string>
</resources>

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<HeadingState> = 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) }
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<CompassWarning> = emptyList(),
val errorRadiusText: String? = null,
val angularErrorDeg: Float? = null,
val isAligned: Boolean = false,
val hasTargetPosition: Boolean = true,
val displayUnits: DisplayUnits = DisplayUnits.METRIC,
)

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<CompassUiState> = _uiState.asStateFlow()
private var updatesJob: Job? = null
private var targetPosition: Pair<Double, Double>? = 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<CompassWarning> =
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<Double, Double>?): 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<Double, Double>?): 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<Double, Double>?): 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)
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<PhoneLocationState> = 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
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<CompassWarning>,
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 = {},
)
}
}

View File

@@ -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()

View File

@@ -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<FirmwareRelease?>(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<CompassViewModel>()
val compassUiState by
compassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
var compassTargetNode by remember { mutableStateOf<Node?>(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<Array<String>, Map<String, Boolean>>,
locationSettingsLauncher: ManagedActivityResultLauncher<Intent, ActivityResult>,
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) {

View File

@@ -157,5 +157,9 @@ private fun handleNodeAction(
is NodeDetailAction.ShareContact -> {
/* Handled in NodeDetailContent */
}
is NodeDetailAction.OpenCompass -> {
/* Handled in NodeDetailList */
}
}
}

View File

@@ -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 users preferred units.
data class OpenCompass(val node: Node, val displayUnits: DisplayUnits) : NodeDetailAction
}