Loading cardinal-android/app/src/main/java/earth/maps/cardinal/data/LocationRepository.kt +32 −3 Original line number Diff line number Diff line Loading @@ -46,7 +46,8 @@ import javax.inject.Singleton @Singleton class LocationRepository @Inject constructor( @param:ApplicationContext private val context: Context, private val appPreferenceRepository: AppPreferenceRepository private val appPreferenceRepository: AppPreferenceRepository, private val orientationRepository: OrientationRepository ) { private companion object { Loading Loading @@ -98,6 +99,8 @@ class LocationRepository @Inject constructor( // Try to get last known location from any provider val lastKnownLocation = getLastKnownLocation(locationManager) lastKnownLocation?.let { orientationRepository.setDeclination(it) } // If we have a recent last known location, use it lastKnownLocation?.let { location -> if (isLocationRecent(location, currentTime)) { Loading Loading @@ -127,7 +130,7 @@ class LocationRepository @Inject constructor( } /** * Starts continuous location updates. * Starts continuous location updates and orientation tracking. * This should be called from the UI when the map is ready. */ @SuppressLint("MissingPermission") Loading @@ -137,6 +140,10 @@ class LocationRepository @Inject constructor( return } // Start orientation tracking orientationRepository.start() Log.d("LocationRepository", "Orientation sensor started") try { val locationManager = getLocationManager(context) Loading @@ -151,6 +158,8 @@ class LocationRepository @Inject constructor( override fun onLocationChanged(location: Location) { // Update the location flow with the new location _locationFlow.value = location orientationRepository.setDeclination(location) synchronized(locationLock) { lastRequestedLocation = location } Loading Loading @@ -192,9 +201,13 @@ class LocationRepository @Inject constructor( } /** * Stops location updates. * Stops location updates and orientation tracking. */ fun stopLocationUpdates(context: Context) { // Stop orientation tracking orientationRepository.stop() Log.d("LocationRepository", "Orientation sensor stopped") locationListener?.let { listener -> try { val locationManager = getLocationManager(context) Loading Loading @@ -803,4 +816,20 @@ class LocationRepository @Inject constructor( createMyLocationPlace(LatLng(location.latitude, location.longitude)) } } /** * Resets the orientation filter. * Useful after device calibration or when the heading seems incorrect. */ fun resetOrientationFilter() { orientationRepository.reset() Log.d("LocationRepository", "Orientation filter reset") } /** * Check if orientation sensors are available on this device. */ fun areOrientationSensorsAvailable(): Boolean { return orientationRepository.areSensorsAvailable() } } cardinal-android/app/src/main/java/earth/maps/cardinal/data/OrientationRepository.kt 0 → 100644 +267 −0 Original line number Diff line number Diff line /* * Cardinal Maps * Copyright (C) 2025 Cardinal Maps Authors * * 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 earth.maps.cardinal.data import android.content.Context import android.hardware.GeomagneticField import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener import android.hardware.SensorManager import android.location.Location import android.util.Log import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import javax.inject.Inject import javax.inject.Singleton import kotlin.math.abs /** * Repository for handling device orientation using sensor data. * Uses ROTATION_VECTOR sensor which provides fused sensor data for better accuracy, * especially on devices with noisy magnetometers. */ @Singleton class OrientationRepository @Inject constructor( @param:ApplicationContext private val context: Context ) { private companion object { private const val TAG = "OrientationRepository" // Sensor update rate - SENSOR_DELAY_UI is ~60ms, good balance between accuracy and battery private const val SENSOR_DELAY = SensorManager.SENSOR_DELAY_UI // Low-pass filter alpha value (0.0-1.0) // Lower values = more smoothing but more lag // Higher values = less smoothing but more responsive private const val SMOOTHING_FACTOR = 0.15f // Threshold in degrees to ignore small changes (reduces jitter) private const val CHANGE_THRESHOLD_DEGREES = 2.0f } private val sensorManager: SensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager private val rotationVectorSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) // Current azimuth in degrees (0-360, where 0/360 is North, 90 is East, etc.) private val _azimuth = MutableStateFlow(0f) val azimuth: StateFlow<Float> = _azimuth.asStateFlow() // Indicates if the sensor is currently active private val _isActive = MutableStateFlow(false) val isActive: StateFlow<Boolean> = _isActive.asStateFlow() // Smoothed azimuth value for filtering out noise private var smoothedAzimuth: Float = 0f // Rotation matrix and orientation arrays for sensor calculations private val rotationMatrix = FloatArray(9) private val orientationAngles = FloatArray(3) private var declination = 0f private val sensorEventListener = object : SensorEventListener { override fun onSensorChanged(event: SensorEvent?) { event?.let { handleSensorEvent(it) } } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) { // Log accuracy changes for debugging when (accuracy) { SensorManager.SENSOR_STATUS_UNRELIABLE -> Log.d(TAG, "Sensor accuracy: UNRELIABLE") SensorManager.SENSOR_STATUS_ACCURACY_LOW -> Log.d(TAG, "Sensor accuracy: LOW") SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM -> Log.d(TAG, "Sensor accuracy: MEDIUM") SensorManager.SENSOR_STATUS_ACCURACY_HIGH -> Log.d(TAG, "Sensor accuracy: HIGH") } } } /** * Starts listening to orientation sensor updates. */ fun start() { if (_isActive.value) { Log.d(TAG, "Orientation sensor already active") return } if (!areSensorsAvailable()) { Log.w(TAG, "ROTATION_VECTOR sensor not available on this device") return } val registered = sensorManager.registerListener( sensorEventListener, rotationVectorSensor, SENSOR_DELAY ) if (registered) { _isActive.value = true Log.d(TAG, "Orientation sensor started") } else { Log.e(TAG, "Failed to register orientation sensor listener") } } /** * Stops listening to orientation sensor updates. */ fun stop() { if (!_isActive.value) { return } sensorManager.unregisterListener(sensorEventListener) _isActive.value = false Log.d(TAG, "Orientation sensor stopped") } fun setDeclination(location: Location) { declination = GeomagneticField( location.latitude.toFloat(), location.longitude.toFloat(), 0.0f, System.currentTimeMillis() ).declination } /** * Resets the orientation filter. * Useful after device calibration or when heading seems incorrect. */ fun reset() { smoothedAzimuth = _azimuth.value Log.d(TAG, "Orientation filter reset to current azimuth: $smoothedAzimuth") } /** * Checks if orientation sensors are available on this device. */ fun areSensorsAvailable(): Boolean { return rotationVectorSensor != null } /** * Handles sensor events and updates the azimuth value. */ private fun handleSensorEvent(event: SensorEvent) { if (event.sensor.type != Sensor.TYPE_ROTATION_VECTOR) { return } // Convert rotation vector to rotation matrix SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values) // Get orientation angles from rotation matrix // orientationAngles[0] = azimuth (rotation around Z axis) // orientationAngles[1] = pitch (rotation around X axis) // orientationAngles[2] = roll (rotation around Y axis) SensorManager.getOrientation(rotationMatrix, orientationAngles) // Convert azimuth from radians to degrees // Result is in range [-180, 180], convert to [0, 360] var azimuthDegrees = Math.toDegrees(orientationAngles[0].toDouble()).toFloat() if (azimuthDegrees < 0) { azimuthDegrees += 360f } // Apply declination correction to convert from magnetic north to true north // Formula: True Heading = Magnetic Heading + Declination azimuthDegrees += declination // Normalize to [0, 360] after adding declination if (azimuthDegrees < 0f) { azimuthDegrees += 360f } else if (azimuthDegrees >= 360f) { azimuthDegrees -= 360f } // Apply low-pass filter for smoothing smoothedAzimuth = applyLowPassFilter(azimuthDegrees, smoothedAzimuth) // Only update if change is significant (reduces jitter) if (shouldUpdateAzimuth(smoothedAzimuth, _azimuth.value)) { _azimuth.value = smoothedAzimuth } } /** * Applies a low-pass filter to smooth out sensor noise. * * @param newValue The new sensor reading * @param oldValue The previous filtered value * @return The filtered value */ private fun applyLowPassFilter(newValue: Float, oldValue: Float): Float { // Handle wrap-around at 0/360 degrees var delta = newValue - oldValue // If the difference is greater than 180 degrees, we've wrapped around if (delta > 180f) { delta -= 360f } else if (delta < -180f) { delta += 360f } // Apply the filter var result = oldValue + (delta * SMOOTHING_FACTOR) // Normalize to [0, 360] if (result < 0f) { result += 360f } else if (result >= 360f) { result -= 360f } return result } /** * Determines if the azimuth should be updated based on the change threshold. * * @param newValue The new azimuth value * @param currentValue The current azimuth value * @return true if the change exceeds the threshold */ private fun shouldUpdateAzimuth(newValue: Float, currentValue: Float): Boolean { val delta = abs(newValue - currentValue) // Handle wrap-around case val normalizedDelta = if (delta > 180f) { 360f - delta } else { delta } return normalizedDelta >= CHANGE_THRESHOLD_DEGREES } } cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapView.kt +2 −1 Original line number Diff line number Diff line Loading @@ -164,8 +164,9 @@ fun MapView( ClickResult.Consume }) { val location by mapViewModel.locationFlow.collectAsState() val sensorHeading by mapViewModel.heading.collectAsState() val savedPlaces by mapViewModel.savedPlacesFlow.collectAsState(FeatureCollection()) location?.let { LocationPuck(it) } location?.let { LocationPuck(it, sensorHeading) } FavoritesLayer(savedPlaces, isSystemInDarkTheme()) Loading cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapViewModel.kt +5 −1 Original line number Diff line number Diff line Loading @@ -32,6 +32,7 @@ import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import earth.maps.cardinal.data.LocationRepository import earth.maps.cardinal.data.OrientationRepository import earth.maps.cardinal.data.Place import earth.maps.cardinal.data.ViewportPreferences import earth.maps.cardinal.data.ViewportRepository Loading Loading @@ -64,6 +65,7 @@ class MapViewModel @Inject constructor( private val viewportPreferences: ViewportPreferences, private val viewportRepository: ViewportRepository, private val locationRepository: LocationRepository, private val orientationRepository: OrientationRepository, private val offlineGeocodingService: OfflineGeocodingService, private val placeDao: SavedPlaceDao, ) : ViewModel() { Loading @@ -87,6 +89,8 @@ class MapViewModel @Inject constructor( val locationFlow: StateFlow<Location?> = locationRepository.locationFlow val heading: StateFlow<Float?> = orientationRepository.azimuth // Location listener for continuous updates private var locationListener: LocationListener? = null Loading cardinal-android/app/src/main/java/earth/maps/cardinal/ui/map/LocationPuck.kt +26 −38 Original line number Diff line number Diff line Loading @@ -20,69 +20,57 @@ package earth.maps.cardinal.ui.map import android.location.Location import android.util.Log import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import earth.maps.cardinal.ui.theme.onPuckColorDark import earth.maps.cardinal.ui.theme.onPuckColorLight import earth.maps.cardinal.ui.theme.puckColorDarkHighContrast import earth.maps.cardinal.ui.theme.puckColorLightHighContrast import androidx.compose.ui.res.painterResource import earth.maps.cardinal.R.drawable import io.github.dellisd.spatialk.geojson.Point import io.github.dellisd.spatialk.geojson.Position import org.maplibre.compose.expressions.dsl.const import org.maplibre.compose.expressions.dsl.offset import org.maplibre.compose.layers.CircleLayer import org.maplibre.compose.expressions.dsl.image import org.maplibre.compose.layers.SymbolLayer import org.maplibre.compose.sources.GeoJsonData import org.maplibre.compose.sources.Source import org.maplibre.compose.sources.rememberGeoJsonSource @Composable fun LocationPuckLayers(idPrefix: String, locationSource: Source) { val puckColor = if (isSystemInDarkTheme()) { puckColorDarkHighContrast fun LocationPuckLayers(idPrefix: String, locationSource: Source, headingDegrees: Float?) { val puckDrawable = if (headingDegrees == null) { painterResource(drawable.location_puck) } else { puckColorLightHighContrast painterResource(drawable.location_puck_with_arrow) } val puckShadowColor = if (isSystemInDarkTheme()) { onPuckColorDark } else { onPuckColorLight } CircleLayer( id = "${idPrefix}-shadow", SymbolLayer( id = "${idPrefix}-puck", source = locationSource, radius = const(13.dp), color = const(puckShadowColor), blur = const(1f), translate = offset(0.dp, 1.dp), ) CircleLayer( id = "${idPrefix}-circle", source = locationSource, radius = const(7.dp), color = const(puckColor), strokeColor = const(Color.White), strokeWidth = const(3.dp), iconImage = image(puckDrawable), iconRotate = const(headingDegrees ?: 0f), ) } @Composable fun LocationPuck(location: Location) { fun LocationPuck(location: Location, sensorHeading: Float? = null) { Log.d("Location", "$location") val locationSource = rememberGeoJsonSource( data = GeoJsonData.Features( Point( Position( coordinates = Position( location.longitude, location.latitude ), ) ) ) // Prefer sensor-based heading over GPS bearing // Sensor heading is more accurate and updates faster, especially when stationary val headingDegrees = sensorHeading ?: if (location.hasBearing()) location.bearing else null LocationPuckLayers( idPrefix = "user-location", locationSource = locationSource, headingDegrees = headingDegrees ) LocationPuckLayers(idPrefix = "user-location", locationSource = locationSource) } Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/data/LocationRepository.kt +32 −3 Original line number Diff line number Diff line Loading @@ -46,7 +46,8 @@ import javax.inject.Singleton @Singleton class LocationRepository @Inject constructor( @param:ApplicationContext private val context: Context, private val appPreferenceRepository: AppPreferenceRepository private val appPreferenceRepository: AppPreferenceRepository, private val orientationRepository: OrientationRepository ) { private companion object { Loading Loading @@ -98,6 +99,8 @@ class LocationRepository @Inject constructor( // Try to get last known location from any provider val lastKnownLocation = getLastKnownLocation(locationManager) lastKnownLocation?.let { orientationRepository.setDeclination(it) } // If we have a recent last known location, use it lastKnownLocation?.let { location -> if (isLocationRecent(location, currentTime)) { Loading Loading @@ -127,7 +130,7 @@ class LocationRepository @Inject constructor( } /** * Starts continuous location updates. * Starts continuous location updates and orientation tracking. * This should be called from the UI when the map is ready. */ @SuppressLint("MissingPermission") Loading @@ -137,6 +140,10 @@ class LocationRepository @Inject constructor( return } // Start orientation tracking orientationRepository.start() Log.d("LocationRepository", "Orientation sensor started") try { val locationManager = getLocationManager(context) Loading @@ -151,6 +158,8 @@ class LocationRepository @Inject constructor( override fun onLocationChanged(location: Location) { // Update the location flow with the new location _locationFlow.value = location orientationRepository.setDeclination(location) synchronized(locationLock) { lastRequestedLocation = location } Loading Loading @@ -192,9 +201,13 @@ class LocationRepository @Inject constructor( } /** * Stops location updates. * Stops location updates and orientation tracking. */ fun stopLocationUpdates(context: Context) { // Stop orientation tracking orientationRepository.stop() Log.d("LocationRepository", "Orientation sensor stopped") locationListener?.let { listener -> try { val locationManager = getLocationManager(context) Loading Loading @@ -803,4 +816,20 @@ class LocationRepository @Inject constructor( createMyLocationPlace(LatLng(location.latitude, location.longitude)) } } /** * Resets the orientation filter. * Useful after device calibration or when the heading seems incorrect. */ fun resetOrientationFilter() { orientationRepository.reset() Log.d("LocationRepository", "Orientation filter reset") } /** * Check if orientation sensors are available on this device. */ fun areOrientationSensorsAvailable(): Boolean { return orientationRepository.areSensorsAvailable() } }
cardinal-android/app/src/main/java/earth/maps/cardinal/data/OrientationRepository.kt 0 → 100644 +267 −0 Original line number Diff line number Diff line /* * Cardinal Maps * Copyright (C) 2025 Cardinal Maps Authors * * 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 earth.maps.cardinal.data import android.content.Context import android.hardware.GeomagneticField import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener import android.hardware.SensorManager import android.location.Location import android.util.Log import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import javax.inject.Inject import javax.inject.Singleton import kotlin.math.abs /** * Repository for handling device orientation using sensor data. * Uses ROTATION_VECTOR sensor which provides fused sensor data for better accuracy, * especially on devices with noisy magnetometers. */ @Singleton class OrientationRepository @Inject constructor( @param:ApplicationContext private val context: Context ) { private companion object { private const val TAG = "OrientationRepository" // Sensor update rate - SENSOR_DELAY_UI is ~60ms, good balance between accuracy and battery private const val SENSOR_DELAY = SensorManager.SENSOR_DELAY_UI // Low-pass filter alpha value (0.0-1.0) // Lower values = more smoothing but more lag // Higher values = less smoothing but more responsive private const val SMOOTHING_FACTOR = 0.15f // Threshold in degrees to ignore small changes (reduces jitter) private const val CHANGE_THRESHOLD_DEGREES = 2.0f } private val sensorManager: SensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager private val rotationVectorSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) // Current azimuth in degrees (0-360, where 0/360 is North, 90 is East, etc.) private val _azimuth = MutableStateFlow(0f) val azimuth: StateFlow<Float> = _azimuth.asStateFlow() // Indicates if the sensor is currently active private val _isActive = MutableStateFlow(false) val isActive: StateFlow<Boolean> = _isActive.asStateFlow() // Smoothed azimuth value for filtering out noise private var smoothedAzimuth: Float = 0f // Rotation matrix and orientation arrays for sensor calculations private val rotationMatrix = FloatArray(9) private val orientationAngles = FloatArray(3) private var declination = 0f private val sensorEventListener = object : SensorEventListener { override fun onSensorChanged(event: SensorEvent?) { event?.let { handleSensorEvent(it) } } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) { // Log accuracy changes for debugging when (accuracy) { SensorManager.SENSOR_STATUS_UNRELIABLE -> Log.d(TAG, "Sensor accuracy: UNRELIABLE") SensorManager.SENSOR_STATUS_ACCURACY_LOW -> Log.d(TAG, "Sensor accuracy: LOW") SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM -> Log.d(TAG, "Sensor accuracy: MEDIUM") SensorManager.SENSOR_STATUS_ACCURACY_HIGH -> Log.d(TAG, "Sensor accuracy: HIGH") } } } /** * Starts listening to orientation sensor updates. */ fun start() { if (_isActive.value) { Log.d(TAG, "Orientation sensor already active") return } if (!areSensorsAvailable()) { Log.w(TAG, "ROTATION_VECTOR sensor not available on this device") return } val registered = sensorManager.registerListener( sensorEventListener, rotationVectorSensor, SENSOR_DELAY ) if (registered) { _isActive.value = true Log.d(TAG, "Orientation sensor started") } else { Log.e(TAG, "Failed to register orientation sensor listener") } } /** * Stops listening to orientation sensor updates. */ fun stop() { if (!_isActive.value) { return } sensorManager.unregisterListener(sensorEventListener) _isActive.value = false Log.d(TAG, "Orientation sensor stopped") } fun setDeclination(location: Location) { declination = GeomagneticField( location.latitude.toFloat(), location.longitude.toFloat(), 0.0f, System.currentTimeMillis() ).declination } /** * Resets the orientation filter. * Useful after device calibration or when heading seems incorrect. */ fun reset() { smoothedAzimuth = _azimuth.value Log.d(TAG, "Orientation filter reset to current azimuth: $smoothedAzimuth") } /** * Checks if orientation sensors are available on this device. */ fun areSensorsAvailable(): Boolean { return rotationVectorSensor != null } /** * Handles sensor events and updates the azimuth value. */ private fun handleSensorEvent(event: SensorEvent) { if (event.sensor.type != Sensor.TYPE_ROTATION_VECTOR) { return } // Convert rotation vector to rotation matrix SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values) // Get orientation angles from rotation matrix // orientationAngles[0] = azimuth (rotation around Z axis) // orientationAngles[1] = pitch (rotation around X axis) // orientationAngles[2] = roll (rotation around Y axis) SensorManager.getOrientation(rotationMatrix, orientationAngles) // Convert azimuth from radians to degrees // Result is in range [-180, 180], convert to [0, 360] var azimuthDegrees = Math.toDegrees(orientationAngles[0].toDouble()).toFloat() if (azimuthDegrees < 0) { azimuthDegrees += 360f } // Apply declination correction to convert from magnetic north to true north // Formula: True Heading = Magnetic Heading + Declination azimuthDegrees += declination // Normalize to [0, 360] after adding declination if (azimuthDegrees < 0f) { azimuthDegrees += 360f } else if (azimuthDegrees >= 360f) { azimuthDegrees -= 360f } // Apply low-pass filter for smoothing smoothedAzimuth = applyLowPassFilter(azimuthDegrees, smoothedAzimuth) // Only update if change is significant (reduces jitter) if (shouldUpdateAzimuth(smoothedAzimuth, _azimuth.value)) { _azimuth.value = smoothedAzimuth } } /** * Applies a low-pass filter to smooth out sensor noise. * * @param newValue The new sensor reading * @param oldValue The previous filtered value * @return The filtered value */ private fun applyLowPassFilter(newValue: Float, oldValue: Float): Float { // Handle wrap-around at 0/360 degrees var delta = newValue - oldValue // If the difference is greater than 180 degrees, we've wrapped around if (delta > 180f) { delta -= 360f } else if (delta < -180f) { delta += 360f } // Apply the filter var result = oldValue + (delta * SMOOTHING_FACTOR) // Normalize to [0, 360] if (result < 0f) { result += 360f } else if (result >= 360f) { result -= 360f } return result } /** * Determines if the azimuth should be updated based on the change threshold. * * @param newValue The new azimuth value * @param currentValue The current azimuth value * @return true if the change exceeds the threshold */ private fun shouldUpdateAzimuth(newValue: Float, currentValue: Float): Boolean { val delta = abs(newValue - currentValue) // Handle wrap-around case val normalizedDelta = if (delta > 180f) { 360f - delta } else { delta } return normalizedDelta >= CHANGE_THRESHOLD_DEGREES } }
cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapView.kt +2 −1 Original line number Diff line number Diff line Loading @@ -164,8 +164,9 @@ fun MapView( ClickResult.Consume }) { val location by mapViewModel.locationFlow.collectAsState() val sensorHeading by mapViewModel.heading.collectAsState() val savedPlaces by mapViewModel.savedPlacesFlow.collectAsState(FeatureCollection()) location?.let { LocationPuck(it) } location?.let { LocationPuck(it, sensorHeading) } FavoritesLayer(savedPlaces, isSystemInDarkTheme()) Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapViewModel.kt +5 −1 Original line number Diff line number Diff line Loading @@ -32,6 +32,7 @@ import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import earth.maps.cardinal.data.LocationRepository import earth.maps.cardinal.data.OrientationRepository import earth.maps.cardinal.data.Place import earth.maps.cardinal.data.ViewportPreferences import earth.maps.cardinal.data.ViewportRepository Loading Loading @@ -64,6 +65,7 @@ class MapViewModel @Inject constructor( private val viewportPreferences: ViewportPreferences, private val viewportRepository: ViewportRepository, private val locationRepository: LocationRepository, private val orientationRepository: OrientationRepository, private val offlineGeocodingService: OfflineGeocodingService, private val placeDao: SavedPlaceDao, ) : ViewModel() { Loading @@ -87,6 +89,8 @@ class MapViewModel @Inject constructor( val locationFlow: StateFlow<Location?> = locationRepository.locationFlow val heading: StateFlow<Float?> = orientationRepository.azimuth // Location listener for continuous updates private var locationListener: LocationListener? = null Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/ui/map/LocationPuck.kt +26 −38 Original line number Diff line number Diff line Loading @@ -20,69 +20,57 @@ package earth.maps.cardinal.ui.map import android.location.Location import android.util.Log import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import earth.maps.cardinal.ui.theme.onPuckColorDark import earth.maps.cardinal.ui.theme.onPuckColorLight import earth.maps.cardinal.ui.theme.puckColorDarkHighContrast import earth.maps.cardinal.ui.theme.puckColorLightHighContrast import androidx.compose.ui.res.painterResource import earth.maps.cardinal.R.drawable import io.github.dellisd.spatialk.geojson.Point import io.github.dellisd.spatialk.geojson.Position import org.maplibre.compose.expressions.dsl.const import org.maplibre.compose.expressions.dsl.offset import org.maplibre.compose.layers.CircleLayer import org.maplibre.compose.expressions.dsl.image import org.maplibre.compose.layers.SymbolLayer import org.maplibre.compose.sources.GeoJsonData import org.maplibre.compose.sources.Source import org.maplibre.compose.sources.rememberGeoJsonSource @Composable fun LocationPuckLayers(idPrefix: String, locationSource: Source) { val puckColor = if (isSystemInDarkTheme()) { puckColorDarkHighContrast fun LocationPuckLayers(idPrefix: String, locationSource: Source, headingDegrees: Float?) { val puckDrawable = if (headingDegrees == null) { painterResource(drawable.location_puck) } else { puckColorLightHighContrast painterResource(drawable.location_puck_with_arrow) } val puckShadowColor = if (isSystemInDarkTheme()) { onPuckColorDark } else { onPuckColorLight } CircleLayer( id = "${idPrefix}-shadow", SymbolLayer( id = "${idPrefix}-puck", source = locationSource, radius = const(13.dp), color = const(puckShadowColor), blur = const(1f), translate = offset(0.dp, 1.dp), ) CircleLayer( id = "${idPrefix}-circle", source = locationSource, radius = const(7.dp), color = const(puckColor), strokeColor = const(Color.White), strokeWidth = const(3.dp), iconImage = image(puckDrawable), iconRotate = const(headingDegrees ?: 0f), ) } @Composable fun LocationPuck(location: Location) { fun LocationPuck(location: Location, sensorHeading: Float? = null) { Log.d("Location", "$location") val locationSource = rememberGeoJsonSource( data = GeoJsonData.Features( Point( Position( coordinates = Position( location.longitude, location.latitude ), ) ) ) // Prefer sensor-based heading over GPS bearing // Sensor heading is more accurate and updates faster, especially when stationary val headingDegrees = sensorHeading ?: if (location.hasBearing()) location.bearing else null LocationPuckLayers( idPrefix = "user-location", locationSource = locationSource, headingDegrees = headingDegrees ) LocationPuckLayers(idPrefix = "user-location", locationSource = locationSource) }