mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2025-12-24 00:07:48 -05:00
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:
@@ -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>
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -157,5 +157,9 @@ private fun handleNodeAction(
|
||||
is NodeDetailAction.ShareContact -> {
|
||||
/* Handled in NodeDetailContent */
|
||||
}
|
||||
|
||||
is NodeDetailAction.OpenCompass -> {
|
||||
/* Handled in NodeDetailList */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user