Loading cardinal-android/app/src/main/java/earth/maps/cardinal/di/NetworkModule.kt +1 −1 Original line number Diff line number Diff line Loading @@ -41,7 +41,7 @@ object NetworkModule { .callTimeout(CALL_TIMEOUT_SEC, TimeUnit.SECONDS) if (BuildConfig.DEBUG) { val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC level = HttpLoggingInterceptor.Level.BODY } builder.addInterceptor(logging) } Loading cardinal-android/app/src/main/java/earth/maps/cardinal/ui/navigation/NavigationChrome.kt +291 −4 Original line number Diff line number Diff line Loading @@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState Loading @@ -13,6 +15,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.DpSize Loading @@ -34,14 +37,31 @@ import com.stadiamaps.ferrostar.composeui.theme.TripProgressViewTheme import com.stadiamaps.ferrostar.composeui.views.components.CurrentRoadNameView import com.stadiamaps.ferrostar.composeui.views.components.InstructionsView import com.stadiamaps.ferrostar.composeui.views.components.TripProgressView import com.stadiamaps.ferrostar.composeui.views.components.maneuver.ManeuverImage import com.stadiamaps.ferrostar.core.NavigationUiState import dagger.hilt.android.lifecycle.HiltViewModel import earth.maps.cardinal.R import earth.maps.cardinal.data.AppPreferenceRepository import earth.maps.cardinal.data.AppPreferences import earth.maps.cardinal.ui.navigation.NavigationUiConstants.FIRST_EXIT import earth.maps.cardinal.ui.navigation.NavigationUiConstants.FOURTH_EXIT import earth.maps.cardinal.ui.navigation.NavigationUiConstants.FULL_CIRCLE_DEGREES import earth.maps.cardinal.ui.navigation.NavigationUiConstants.HALF_CIRCLE_DEGREES import earth.maps.cardinal.ui.navigation.NavigationUiConstants.NORMAL_TURN_THRESHOLD import earth.maps.cardinal.ui.navigation.NavigationUiConstants.SECOND_EXIT import earth.maps.cardinal.ui.navigation.NavigationUiConstants.SLIGHT_TURN_THRESHOLD import earth.maps.cardinal.ui.navigation.NavigationUiConstants.STRAIGHT_ANGLE_THRESHOLD import earth.maps.cardinal.ui.navigation.NavigationUiConstants.THIRD_EXIT import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import uniffi.ferrostar.ManeuverModifier import uniffi.ferrostar.ManeuverType import uniffi.ferrostar.RouteStep import uniffi.ferrostar.VisualInstruction import uniffi.ferrostar.VisualInstructionContent import javax.inject.Inject import kotlin.math.abs import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime Loading Loading @@ -96,15 +116,28 @@ fun CardinalInstructionsView(modifier: Modifier, uiState: NavigationUiState) { DistanceMeasurementSystem.IMPERIAL } val formatter = remember(distanceMeasurementSystem) { LocalizedDistanceFormatter(distanceMeasurementSystemOverride = distanceMeasurementSystem) } val currentStep = remember(uiState.remainingSteps, uiState.visualInstruction) { uiState.remainingSteps?.stepForInstruction(uiState.visualInstruction) } val remainingSteps = remember(uiState.remainingSteps) { uiState.remainingSteps?.map(RouteStep::patchRoundaboutInstructionIcons) } val instructionTheme = CardinalNavigationUITheme.instructionRowTheme uiState.visualInstruction?.let { instructions -> InstructionsView( modifier = modifier, instructions = instructions, theme = CardinalNavigationUITheme.instructionRowTheme, remainingSteps = uiState.remainingSteps, instructions = instructions.patchRoundaboutInstructionIcons(currentStep), theme = instructionTheme, remainingSteps = remainingSteps, distanceFormatter = formatter, distanceToNextManeuver = uiState.progress?.distanceToNextManeuver distanceToNextManeuver = uiState.progress?.distanceToNextManeuver, contentBuilder = { instruction -> CardinalManeuverImage( content = instruction.primaryContent, tint = instructionTheme.iconTintColor, ) }, ) } } Loading Loading @@ -216,3 +249,257 @@ class NavigationChromeViewModel @Inject constructor( private val DELAY = 5.seconds } } private enum class RoundaboutCirculation { CLOCKWISE, COUNTERCLOCKWISE } private data class ManeuverIconOverride( val maneuverType: ManeuverType?, val maneuverModifier: ManeuverModifier?, ) @Composable private fun CardinalManeuverImage(content: VisualInstructionContent, tint: Color) { val resourceId = content.cardinalManeuverIcon() if (resourceId != null) { Icon( painter = painterResource(id = resourceId), contentDescription = null, tint = Color.Unspecified, modifier = Modifier.size(64.dp), ) } else { ManeuverImage(content = content, tint = tint) } } internal fun VisualInstruction.patchRoundaboutInstructionIcons(): VisualInstruction = patchRoundaboutInstructionIcons(step = null) internal fun VisualInstruction.patchRoundaboutInstructionIcons(step: RouteStep?): VisualInstruction = copy( primaryContent = primaryContent.patchRoundaboutInstructionIcon(step), secondaryContent = secondaryContent?.patchRoundaboutInstructionIcon(step), subContent = subContent?.patchRoundaboutInstructionIcon(step), ) internal fun RouteStep.patchRoundaboutInstructionIcons(): RouteStep = copy( visualInstructions = visualInstructions.map { it.patchRoundaboutInstructionIcons(this) } ) internal fun VisualInstructionContent.patchRoundaboutInstructionIcon( step: RouteStep? = null ): VisualInstructionContent { if (!maneuverType.isRoundaboutLike()) { return this } val exitNumber = exitNumbers.firstExitNumber() ?: step?.firstRoundaboutExitNumber() val resolvedExitNumbers = exitNumbers.ifEmpty { exitNumber?.let { listOf(it.toString()) } ?: emptyList() } val resolvedIcon = resolveRoundaboutIconFromExitNumber( maneuverType = maneuverType, modifier = maneuverModifier, exitNumber = exitNumber ) ?: roundaboutExitDegrees?.toInt()?.let { exitDegrees -> resolveRoundaboutModifier(maneuverModifier, exitDegrees)?.let { resolvedModifier -> ManeuverIconOverride( maneuverType = maneuverType, maneuverModifier = resolvedModifier, ) } } ?: return this if (resolvedIcon.maneuverType == maneuverType && resolvedIcon.maneuverModifier == maneuverModifier && resolvedExitNumbers == exitNumbers) { return this } return copy( text = text, maneuverType = resolvedIcon.maneuverType, maneuverModifier = resolvedIcon.maneuverModifier, roundaboutExitDegrees = roundaboutExitDegrees, laneInfo = laneInfo, exitNumbers = resolvedExitNumbers, ) } internal fun resolveRoundaboutModifier( modifier: ManeuverModifier?, roundaboutExitDegrees: Int ): ManeuverModifier? { val circulation = modifier.roundaboutCirculation() ?: return null val signedTurnAngle = circulation.toSignedTurnAngle(roundaboutExitDegrees) return when { abs(signedTurnAngle) < STRAIGHT_ANGLE_THRESHOLD -> ManeuverModifier.STRAIGHT abs(signedTurnAngle) < SLIGHT_TURN_THRESHOLD -> if (signedTurnAngle > 0) ManeuverModifier.SLIGHT_RIGHT else ManeuverModifier.SLIGHT_LEFT abs(signedTurnAngle) < NORMAL_TURN_THRESHOLD -> if (signedTurnAngle > 0) ManeuverModifier.RIGHT else ManeuverModifier.LEFT else -> if (signedTurnAngle > 0) ManeuverModifier.SHARP_RIGHT else ManeuverModifier.SHARP_LEFT } } internal fun resolveRoundaboutModifierFromExitNumber( modifier: ManeuverModifier?, exitNumber: Int? ): ManeuverModifier? { return resolveRoundaboutIconFromExitNumber( maneuverType = ManeuverType.ROTARY, modifier = modifier, exitNumber = exitNumber, )?.maneuverModifier } private fun resolveRoundaboutIconFromExitNumber( maneuverType: ManeuverType?, modifier: ManeuverModifier?, exitNumber: Int? ): ManeuverIconOverride? { val circulation = modifier.roundaboutCirculation() ?: return null return when (exitNumber) { FIRST_EXIT -> when (circulation) { RoundaboutCirculation.CLOCKWISE -> ManeuverIconOverride( maneuverType = maneuverType ?: ManeuverType.ROTARY, maneuverModifier = ManeuverModifier.SLIGHT_LEFT, ) RoundaboutCirculation.COUNTERCLOCKWISE -> ManeuverIconOverride( maneuverType = maneuverType ?: ManeuverType.ROTARY, maneuverModifier = ManeuverModifier.SLIGHT_RIGHT, ) } SECOND_EXIT -> ManeuverIconOverride( maneuverType = maneuverType ?: ManeuverType.ROTARY, maneuverModifier = modifier, ) THIRD_EXIT -> ManeuverIconOverride( maneuverType = maneuverType ?: ManeuverType.ROTARY, maneuverModifier = modifier, ) FOURTH_EXIT -> ManeuverIconOverride( maneuverType = maneuverType ?: ManeuverType.ROTARY, maneuverModifier = modifier, ) else -> null } } internal fun VisualInstructionContent.cardinalManeuverIcon(): Int? { val exitNumber = exitNumbers.firstExitNumber() if (!maneuverType.isRoundaboutLike()) { return null } return when (exitNumber) { FIRST_EXIT -> when (maneuverModifier.roundaboutCirculation()) { RoundaboutCirculation.CLOCKWISE -> R.drawable.ic_maneuver_roundabout_first_exit_left RoundaboutCirculation.COUNTERCLOCKWISE -> R.drawable.ic_maneuver_roundabout_first_exit_right null -> null } SECOND_EXIT -> when (maneuverModifier.roundaboutCirculation()) { RoundaboutCirculation.CLOCKWISE -> R.drawable.ic_maneuver_roundabout_second_exit_left RoundaboutCirculation.COUNTERCLOCKWISE -> R.drawable.ic_maneuver_roundabout_second_exit_right null -> null } THIRD_EXIT -> when (maneuverModifier.roundaboutCirculation()) { RoundaboutCirculation.CLOCKWISE -> R.drawable.ic_maneuver_roundabout_third_exit_right RoundaboutCirculation.COUNTERCLOCKWISE -> R.drawable.ic_maneuver_roundabout_third_exit_left null -> null } FOURTH_EXIT -> when (maneuverModifier.roundaboutCirculation()) { RoundaboutCirculation.CLOCKWISE -> R.drawable.ic_maneuver_roundabout_fourth_exit_right RoundaboutCirculation.COUNTERCLOCKWISE -> R.drawable.ic_maneuver_roundabout_fourth_exit_left null -> null } else -> null } } private fun ManeuverType?.isRoundaboutLike(): Boolean = this == ManeuverType.ROUNDABOUT || this == ManeuverType.ROTARY || this == ManeuverType.ROUNDABOUT_TURN || this == ManeuverType.EXIT_ROUNDABOUT || this == ManeuverType.EXIT_ROTARY private fun ManeuverModifier?.roundaboutCirculation(): RoundaboutCirculation? = when (this) { ManeuverModifier.SLIGHT_LEFT, ManeuverModifier.LEFT, ManeuverModifier.SHARP_LEFT -> RoundaboutCirculation.CLOCKWISE ManeuverModifier.SLIGHT_RIGHT, ManeuverModifier.RIGHT, ManeuverModifier.SHARP_RIGHT -> RoundaboutCirculation.COUNTERCLOCKWISE else -> null } private fun RoundaboutCirculation.toSignedTurnAngle(roundaboutExitDegrees: Int): Int { val normalizedDegrees = ((roundaboutExitDegrees % FULL_CIRCLE_DEGREES) + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES return when (this) { RoundaboutCirculation.CLOCKWISE -> normalizedDegrees - HALF_CIRCLE_DEGREES RoundaboutCirculation.COUNTERCLOCKWISE -> HALF_CIRCLE_DEGREES - normalizedDegrees } } private fun RouteStep.firstRoundaboutExitNumber(): Int? = exits.firstExitNumber() ?: spokenInstructions.asSequence().mapNotNull { it.text.firstExitNumber() }.firstOrNull() ?: instruction.firstExitNumber() internal fun List<RouteStep>.stepForInstruction(instruction: VisualInstruction?): RouteStep? { if (instruction == null) { return firstOrNull() } return firstOrNull { step -> step.visualInstructions.any { candidate -> candidate.primaryContent.matchesInstruction(instruction.primaryContent) } } ?: firstOrNull() } private fun VisualInstructionContent.matchesInstruction(other: VisualInstructionContent): Boolean = text == other.text && maneuverType == other.maneuverType && maneuverModifier == other.maneuverModifier private fun List<String>.firstExitNumber(): Int? = firstOrNull()?.firstExitNumber() private fun String.firstExitNumber(): Int? = trim().toIntOrNull() ?: FIRST_EXIT_NUMBER_REGEX.find(this)?.groupValues?.getOrNull(1)?.toIntOrNull() private val FIRST_EXIT_NUMBER_REGEX = Regex("""\b(\d+)(?:st|nd|rd|th)\s+exit\b""", RegexOption.IGNORE_CASE) private object NavigationUiConstants { const val FULL_CIRCLE_DEGREES = 360 const val HALF_CIRCLE_DEGREES = 180 const val STRAIGHT_ANGLE_THRESHOLD = 30 const val SLIGHT_TURN_THRESHOLD = 60 const val NORMAL_TURN_THRESHOLD = 135 const val FIRST_EXIT = 1 const val SECOND_EXIT = 2 const val THIRD_EXIT = 3 const val FOURTH_EXIT = 4 } cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_first_exit_left.xml 0 → 100644 +79 −0 Original line number Diff line number Diff line <!-- ~ Cardinal Maps ~ Copyright (C) 2026 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/>. --> <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="40dp" android:height="40dp" android:viewportWidth="40" android:viewportHeight="40"> <!-- Outer circle (radius = 10) --> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background_alpha" android:strokeWidth="1" android:pathData=" M25,10 a10,10 0 1,1 0,20 a10,10 0 1,1 0,-20" /> <!-- Inner circle (radius = 8) --> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background_alpha" android:strokeWidth="1" android:pathData=" M25,12 a8,8 0 1,1 0,16 a8,8 0 1,1 0,-16" /> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:pathData=" M25,29 A9,10 0 0,1 16,20" /> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:pathData="M25,30 L25,35" /> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData=" M2,20 L8,16 M2,20 L8,24" /> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:pathData="M15,20 L2,20" /> </vector> No newline at end of file cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_first_exit_right.xml 0 → 100644 +84 −0 Original line number Diff line number Diff line <!-- ~ Cardinal Maps ~ Copyright (C) 2026 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/>. --> <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="40dp" android:height="40dp" android:viewportWidth="40" android:viewportHeight="40"> <!-- Outer circle (centered) --> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background_alpha" android:strokeWidth="1.4" android:pathData=" M20,10 a10,10 0 1,1 0,20 a10,10 0 1,1 0,-20" /> <!-- Inner circle --> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background_alpha" android:strokeWidth="1.4" android:pathData=" M20,12 a8,8 0 1,1 0,16 a8,8 0 1,1 0,-16" /> <!-- Exit arc (right-hand drive) --> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:pathData=" M20,29 A9,10 0 0,0 29,20" /> <!-- Exit vertical line --> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:pathData="M20,29 L20,36" /> <!-- Horizontal connector (touching circle correctly) --> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:pathData="M29,20 L38,20" /> <!-- Arrow head (correctly placed) --> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData=" M38,20 L34,17 M38,20 L34,23" /> </vector> No newline at end of file cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_fourth_exit_left.xml 0 → 100644 +81 −0 Original line number Diff line number Diff line <!-- ~ Cardinal Maps ~ Copyright (C) 2026 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/>. --> <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="40dp" android:height="40dp" android:viewportWidth="40" android:viewportHeight="40"> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background_alpha" android:strokeWidth="1" android:pathData=" M20,10 a10,10 0 1,1 0,20 a10,10 0 1,1 0,-20" /> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background_alpha" android:strokeWidth="1" android:pathData=" M20,12 a8,8 0 1,1 0,16 a8,8 0 1,1 0,-16" /> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:pathData=" M20,29 A9,9 0 0,0 29,20 A9,9 0 0,0 20,11 A9,9 0 0,0 11,20 A9,9 0 0,0 16,28" /> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:pathData="M20,30 L20,35" /> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData=" M12,35 L10,30 M12,35 L17,34" /> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:pathData="M16,28 L12,35" /> </vector> Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/di/NetworkModule.kt +1 −1 Original line number Diff line number Diff line Loading @@ -41,7 +41,7 @@ object NetworkModule { .callTimeout(CALL_TIMEOUT_SEC, TimeUnit.SECONDS) if (BuildConfig.DEBUG) { val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC level = HttpLoggingInterceptor.Level.BODY } builder.addInterceptor(logging) } Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/ui/navigation/NavigationChrome.kt +291 −4 Original line number Diff line number Diff line Loading @@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState Loading @@ -13,6 +15,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.DpSize Loading @@ -34,14 +37,31 @@ import com.stadiamaps.ferrostar.composeui.theme.TripProgressViewTheme import com.stadiamaps.ferrostar.composeui.views.components.CurrentRoadNameView import com.stadiamaps.ferrostar.composeui.views.components.InstructionsView import com.stadiamaps.ferrostar.composeui.views.components.TripProgressView import com.stadiamaps.ferrostar.composeui.views.components.maneuver.ManeuverImage import com.stadiamaps.ferrostar.core.NavigationUiState import dagger.hilt.android.lifecycle.HiltViewModel import earth.maps.cardinal.R import earth.maps.cardinal.data.AppPreferenceRepository import earth.maps.cardinal.data.AppPreferences import earth.maps.cardinal.ui.navigation.NavigationUiConstants.FIRST_EXIT import earth.maps.cardinal.ui.navigation.NavigationUiConstants.FOURTH_EXIT import earth.maps.cardinal.ui.navigation.NavigationUiConstants.FULL_CIRCLE_DEGREES import earth.maps.cardinal.ui.navigation.NavigationUiConstants.HALF_CIRCLE_DEGREES import earth.maps.cardinal.ui.navigation.NavigationUiConstants.NORMAL_TURN_THRESHOLD import earth.maps.cardinal.ui.navigation.NavigationUiConstants.SECOND_EXIT import earth.maps.cardinal.ui.navigation.NavigationUiConstants.SLIGHT_TURN_THRESHOLD import earth.maps.cardinal.ui.navigation.NavigationUiConstants.STRAIGHT_ANGLE_THRESHOLD import earth.maps.cardinal.ui.navigation.NavigationUiConstants.THIRD_EXIT import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import uniffi.ferrostar.ManeuverModifier import uniffi.ferrostar.ManeuverType import uniffi.ferrostar.RouteStep import uniffi.ferrostar.VisualInstruction import uniffi.ferrostar.VisualInstructionContent import javax.inject.Inject import kotlin.math.abs import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime Loading Loading @@ -96,15 +116,28 @@ fun CardinalInstructionsView(modifier: Modifier, uiState: NavigationUiState) { DistanceMeasurementSystem.IMPERIAL } val formatter = remember(distanceMeasurementSystem) { LocalizedDistanceFormatter(distanceMeasurementSystemOverride = distanceMeasurementSystem) } val currentStep = remember(uiState.remainingSteps, uiState.visualInstruction) { uiState.remainingSteps?.stepForInstruction(uiState.visualInstruction) } val remainingSteps = remember(uiState.remainingSteps) { uiState.remainingSteps?.map(RouteStep::patchRoundaboutInstructionIcons) } val instructionTheme = CardinalNavigationUITheme.instructionRowTheme uiState.visualInstruction?.let { instructions -> InstructionsView( modifier = modifier, instructions = instructions, theme = CardinalNavigationUITheme.instructionRowTheme, remainingSteps = uiState.remainingSteps, instructions = instructions.patchRoundaboutInstructionIcons(currentStep), theme = instructionTheme, remainingSteps = remainingSteps, distanceFormatter = formatter, distanceToNextManeuver = uiState.progress?.distanceToNextManeuver distanceToNextManeuver = uiState.progress?.distanceToNextManeuver, contentBuilder = { instruction -> CardinalManeuverImage( content = instruction.primaryContent, tint = instructionTheme.iconTintColor, ) }, ) } } Loading Loading @@ -216,3 +249,257 @@ class NavigationChromeViewModel @Inject constructor( private val DELAY = 5.seconds } } private enum class RoundaboutCirculation { CLOCKWISE, COUNTERCLOCKWISE } private data class ManeuverIconOverride( val maneuverType: ManeuverType?, val maneuverModifier: ManeuverModifier?, ) @Composable private fun CardinalManeuverImage(content: VisualInstructionContent, tint: Color) { val resourceId = content.cardinalManeuverIcon() if (resourceId != null) { Icon( painter = painterResource(id = resourceId), contentDescription = null, tint = Color.Unspecified, modifier = Modifier.size(64.dp), ) } else { ManeuverImage(content = content, tint = tint) } } internal fun VisualInstruction.patchRoundaboutInstructionIcons(): VisualInstruction = patchRoundaboutInstructionIcons(step = null) internal fun VisualInstruction.patchRoundaboutInstructionIcons(step: RouteStep?): VisualInstruction = copy( primaryContent = primaryContent.patchRoundaboutInstructionIcon(step), secondaryContent = secondaryContent?.patchRoundaboutInstructionIcon(step), subContent = subContent?.patchRoundaboutInstructionIcon(step), ) internal fun RouteStep.patchRoundaboutInstructionIcons(): RouteStep = copy( visualInstructions = visualInstructions.map { it.patchRoundaboutInstructionIcons(this) } ) internal fun VisualInstructionContent.patchRoundaboutInstructionIcon( step: RouteStep? = null ): VisualInstructionContent { if (!maneuverType.isRoundaboutLike()) { return this } val exitNumber = exitNumbers.firstExitNumber() ?: step?.firstRoundaboutExitNumber() val resolvedExitNumbers = exitNumbers.ifEmpty { exitNumber?.let { listOf(it.toString()) } ?: emptyList() } val resolvedIcon = resolveRoundaboutIconFromExitNumber( maneuverType = maneuverType, modifier = maneuverModifier, exitNumber = exitNumber ) ?: roundaboutExitDegrees?.toInt()?.let { exitDegrees -> resolveRoundaboutModifier(maneuverModifier, exitDegrees)?.let { resolvedModifier -> ManeuverIconOverride( maneuverType = maneuverType, maneuverModifier = resolvedModifier, ) } } ?: return this if (resolvedIcon.maneuverType == maneuverType && resolvedIcon.maneuverModifier == maneuverModifier && resolvedExitNumbers == exitNumbers) { return this } return copy( text = text, maneuverType = resolvedIcon.maneuverType, maneuverModifier = resolvedIcon.maneuverModifier, roundaboutExitDegrees = roundaboutExitDegrees, laneInfo = laneInfo, exitNumbers = resolvedExitNumbers, ) } internal fun resolveRoundaboutModifier( modifier: ManeuverModifier?, roundaboutExitDegrees: Int ): ManeuverModifier? { val circulation = modifier.roundaboutCirculation() ?: return null val signedTurnAngle = circulation.toSignedTurnAngle(roundaboutExitDegrees) return when { abs(signedTurnAngle) < STRAIGHT_ANGLE_THRESHOLD -> ManeuverModifier.STRAIGHT abs(signedTurnAngle) < SLIGHT_TURN_THRESHOLD -> if (signedTurnAngle > 0) ManeuverModifier.SLIGHT_RIGHT else ManeuverModifier.SLIGHT_LEFT abs(signedTurnAngle) < NORMAL_TURN_THRESHOLD -> if (signedTurnAngle > 0) ManeuverModifier.RIGHT else ManeuverModifier.LEFT else -> if (signedTurnAngle > 0) ManeuverModifier.SHARP_RIGHT else ManeuverModifier.SHARP_LEFT } } internal fun resolveRoundaboutModifierFromExitNumber( modifier: ManeuverModifier?, exitNumber: Int? ): ManeuverModifier? { return resolveRoundaboutIconFromExitNumber( maneuverType = ManeuverType.ROTARY, modifier = modifier, exitNumber = exitNumber, )?.maneuverModifier } private fun resolveRoundaboutIconFromExitNumber( maneuverType: ManeuverType?, modifier: ManeuverModifier?, exitNumber: Int? ): ManeuverIconOverride? { val circulation = modifier.roundaboutCirculation() ?: return null return when (exitNumber) { FIRST_EXIT -> when (circulation) { RoundaboutCirculation.CLOCKWISE -> ManeuverIconOverride( maneuverType = maneuverType ?: ManeuverType.ROTARY, maneuverModifier = ManeuverModifier.SLIGHT_LEFT, ) RoundaboutCirculation.COUNTERCLOCKWISE -> ManeuverIconOverride( maneuverType = maneuverType ?: ManeuverType.ROTARY, maneuverModifier = ManeuverModifier.SLIGHT_RIGHT, ) } SECOND_EXIT -> ManeuverIconOverride( maneuverType = maneuverType ?: ManeuverType.ROTARY, maneuverModifier = modifier, ) THIRD_EXIT -> ManeuverIconOverride( maneuverType = maneuverType ?: ManeuverType.ROTARY, maneuverModifier = modifier, ) FOURTH_EXIT -> ManeuverIconOverride( maneuverType = maneuverType ?: ManeuverType.ROTARY, maneuverModifier = modifier, ) else -> null } } internal fun VisualInstructionContent.cardinalManeuverIcon(): Int? { val exitNumber = exitNumbers.firstExitNumber() if (!maneuverType.isRoundaboutLike()) { return null } return when (exitNumber) { FIRST_EXIT -> when (maneuverModifier.roundaboutCirculation()) { RoundaboutCirculation.CLOCKWISE -> R.drawable.ic_maneuver_roundabout_first_exit_left RoundaboutCirculation.COUNTERCLOCKWISE -> R.drawable.ic_maneuver_roundabout_first_exit_right null -> null } SECOND_EXIT -> when (maneuverModifier.roundaboutCirculation()) { RoundaboutCirculation.CLOCKWISE -> R.drawable.ic_maneuver_roundabout_second_exit_left RoundaboutCirculation.COUNTERCLOCKWISE -> R.drawable.ic_maneuver_roundabout_second_exit_right null -> null } THIRD_EXIT -> when (maneuverModifier.roundaboutCirculation()) { RoundaboutCirculation.CLOCKWISE -> R.drawable.ic_maneuver_roundabout_third_exit_right RoundaboutCirculation.COUNTERCLOCKWISE -> R.drawable.ic_maneuver_roundabout_third_exit_left null -> null } FOURTH_EXIT -> when (maneuverModifier.roundaboutCirculation()) { RoundaboutCirculation.CLOCKWISE -> R.drawable.ic_maneuver_roundabout_fourth_exit_right RoundaboutCirculation.COUNTERCLOCKWISE -> R.drawable.ic_maneuver_roundabout_fourth_exit_left null -> null } else -> null } } private fun ManeuverType?.isRoundaboutLike(): Boolean = this == ManeuverType.ROUNDABOUT || this == ManeuverType.ROTARY || this == ManeuverType.ROUNDABOUT_TURN || this == ManeuverType.EXIT_ROUNDABOUT || this == ManeuverType.EXIT_ROTARY private fun ManeuverModifier?.roundaboutCirculation(): RoundaboutCirculation? = when (this) { ManeuverModifier.SLIGHT_LEFT, ManeuverModifier.LEFT, ManeuverModifier.SHARP_LEFT -> RoundaboutCirculation.CLOCKWISE ManeuverModifier.SLIGHT_RIGHT, ManeuverModifier.RIGHT, ManeuverModifier.SHARP_RIGHT -> RoundaboutCirculation.COUNTERCLOCKWISE else -> null } private fun RoundaboutCirculation.toSignedTurnAngle(roundaboutExitDegrees: Int): Int { val normalizedDegrees = ((roundaboutExitDegrees % FULL_CIRCLE_DEGREES) + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES return when (this) { RoundaboutCirculation.CLOCKWISE -> normalizedDegrees - HALF_CIRCLE_DEGREES RoundaboutCirculation.COUNTERCLOCKWISE -> HALF_CIRCLE_DEGREES - normalizedDegrees } } private fun RouteStep.firstRoundaboutExitNumber(): Int? = exits.firstExitNumber() ?: spokenInstructions.asSequence().mapNotNull { it.text.firstExitNumber() }.firstOrNull() ?: instruction.firstExitNumber() internal fun List<RouteStep>.stepForInstruction(instruction: VisualInstruction?): RouteStep? { if (instruction == null) { return firstOrNull() } return firstOrNull { step -> step.visualInstructions.any { candidate -> candidate.primaryContent.matchesInstruction(instruction.primaryContent) } } ?: firstOrNull() } private fun VisualInstructionContent.matchesInstruction(other: VisualInstructionContent): Boolean = text == other.text && maneuverType == other.maneuverType && maneuverModifier == other.maneuverModifier private fun List<String>.firstExitNumber(): Int? = firstOrNull()?.firstExitNumber() private fun String.firstExitNumber(): Int? = trim().toIntOrNull() ?: FIRST_EXIT_NUMBER_REGEX.find(this)?.groupValues?.getOrNull(1)?.toIntOrNull() private val FIRST_EXIT_NUMBER_REGEX = Regex("""\b(\d+)(?:st|nd|rd|th)\s+exit\b""", RegexOption.IGNORE_CASE) private object NavigationUiConstants { const val FULL_CIRCLE_DEGREES = 360 const val HALF_CIRCLE_DEGREES = 180 const val STRAIGHT_ANGLE_THRESHOLD = 30 const val SLIGHT_TURN_THRESHOLD = 60 const val NORMAL_TURN_THRESHOLD = 135 const val FIRST_EXIT = 1 const val SECOND_EXIT = 2 const val THIRD_EXIT = 3 const val FOURTH_EXIT = 4 }
cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_first_exit_left.xml 0 → 100644 +79 −0 Original line number Diff line number Diff line <!-- ~ Cardinal Maps ~ Copyright (C) 2026 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/>. --> <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="40dp" android:height="40dp" android:viewportWidth="40" android:viewportHeight="40"> <!-- Outer circle (radius = 10) --> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background_alpha" android:strokeWidth="1" android:pathData=" M25,10 a10,10 0 1,1 0,20 a10,10 0 1,1 0,-20" /> <!-- Inner circle (radius = 8) --> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background_alpha" android:strokeWidth="1" android:pathData=" M25,12 a8,8 0 1,1 0,16 a8,8 0 1,1 0,-16" /> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:pathData=" M25,29 A9,10 0 0,1 16,20" /> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:pathData="M25,30 L25,35" /> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData=" M2,20 L8,16 M2,20 L8,24" /> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:pathData="M15,20 L2,20" /> </vector> No newline at end of file
cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_first_exit_right.xml 0 → 100644 +84 −0 Original line number Diff line number Diff line <!-- ~ Cardinal Maps ~ Copyright (C) 2026 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/>. --> <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="40dp" android:height="40dp" android:viewportWidth="40" android:viewportHeight="40"> <!-- Outer circle (centered) --> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background_alpha" android:strokeWidth="1.4" android:pathData=" M20,10 a10,10 0 1,1 0,20 a10,10 0 1,1 0,-20" /> <!-- Inner circle --> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background_alpha" android:strokeWidth="1.4" android:pathData=" M20,12 a8,8 0 1,1 0,16 a8,8 0 1,1 0,-16" /> <!-- Exit arc (right-hand drive) --> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:pathData=" M20,29 A9,10 0 0,0 29,20" /> <!-- Exit vertical line --> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:pathData="M20,29 L20,36" /> <!-- Horizontal connector (touching circle correctly) --> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:pathData="M29,20 L38,20" /> <!-- Arrow head (correctly placed) --> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData=" M38,20 L34,17 M38,20 L34,23" /> </vector> No newline at end of file
cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_fourth_exit_left.xml 0 → 100644 +81 −0 Original line number Diff line number Diff line <!-- ~ Cardinal Maps ~ Copyright (C) 2026 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/>. --> <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="40dp" android:height="40dp" android:viewportWidth="40" android:viewportHeight="40"> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background_alpha" android:strokeWidth="1" android:pathData=" M20,10 a10,10 0 1,1 0,20 a10,10 0 1,1 0,-20" /> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background_alpha" android:strokeWidth="1" android:pathData=" M20,12 a8,8 0 1,1 0,16 a8,8 0 1,1 0,-16" /> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:pathData=" M20,29 A9,9 0 0,0 29,20 A9,9 0 0,0 20,11 A9,9 0 0,0 11,20 A9,9 0 0,0 16,28" /> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:pathData="M20,30 L20,35" /> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" android:pathData=" M12,35 L10,30 M12,35 L17,34" /> <path android:fillColor="@android:color/transparent" android:strokeColor="@color/direction_icon_background" android:strokeWidth="2" android:strokeLineCap="round" android:pathData="M16,28 L12,35" /> </vector>