diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/di/NetworkModule.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/di/NetworkModule.kt index 638ca4136483e9dd46ba6c02136691286c67d82a..d0619e38cac30c47dd885f3367cee6db6e55a9ad 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/di/NetworkModule.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/di/NetworkModule.kt @@ -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) } diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/navigation/NavigationChrome.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/navigation/NavigationChrome.kt index ac41b22290bb600f9f180e952a69340ac76fb4d1..c13fc0e39b62ace65164ed255783742872f5291c 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/navigation/NavigationChrome.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/navigation/NavigationChrome.kt @@ -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 @@ -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 @@ -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 @@ -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, + ) + }, ) } } @@ -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.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.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 +} diff --git a/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_first_exit_left.xml b/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_first_exit_left.xml new file mode 100644 index 0000000000000000000000000000000000000000..c18e4134e4426537b0ecfcba0280cf3b75f2bcc7 --- /dev/null +++ b/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_first_exit_left.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_first_exit_right.xml b/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_first_exit_right.xml new file mode 100644 index 0000000000000000000000000000000000000000..9ad5330cfcbfeb0387486d4e1073490230a33331 --- /dev/null +++ b/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_first_exit_right.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_fourth_exit_left.xml b/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_fourth_exit_left.xml new file mode 100644 index 0000000000000000000000000000000000000000..9d2be723b48eeeeaf94a21134ae531a25dec2d24 --- /dev/null +++ b/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_fourth_exit_left.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + diff --git a/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_fourth_exit_right.xml b/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_fourth_exit_right.xml new file mode 100644 index 0000000000000000000000000000000000000000..b37aed003b494c8e8c26e2bdf97a0f60fbb36828 --- /dev/null +++ b/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_fourth_exit_right.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_second_exit_left.xml b/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_second_exit_left.xml new file mode 100644 index 0000000000000000000000000000000000000000..7022d08214a7b85e3823a120e2799d76454b6753 --- /dev/null +++ b/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_second_exit_left.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_second_exit_right.xml b/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_second_exit_right.xml new file mode 100644 index 0000000000000000000000000000000000000000..5a0aef0b1d65b7bcf41a4dfb8cb95ee0a616b319 --- /dev/null +++ b/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_second_exit_right.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_third_exit_left.xml b/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_third_exit_left.xml new file mode 100644 index 0000000000000000000000000000000000000000..465962bd57dac1e437960c0741e1676053f72a7b --- /dev/null +++ b/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_third_exit_left.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + diff --git a/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_third_exit_right.xml b/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_third_exit_right.xml new file mode 100644 index 0000000000000000000000000000000000000000..9630e754ab6d4f999455362d7691e0d0230d9b5f --- /dev/null +++ b/cardinal-android/app/src/main/res/drawable/ic_maneuver_roundabout_third_exit_right.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/cardinal-android/app/src/main/res/values-night/colors.xml b/cardinal-android/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000000000000000000000000000000000000..250461461d9110eac32dad70beaede4f02c61410 --- /dev/null +++ b/cardinal-android/app/src/main/res/values-night/colors.xml @@ -0,0 +1,22 @@ + + + + #FFFFFF + #70FFFFFF + \ No newline at end of file diff --git a/cardinal-android/app/src/main/res/values/colors.xml b/cardinal-android/app/src/main/res/values/colors.xml index 087e3d0cd2ee0dd292a60a6753fe77217670a747..26f1ce9fcdfb6015dfe3ee845b865d42a5b73cb8 100644 --- a/cardinal-android/app/src/main/res/values/colors.xml +++ b/cardinal-android/app/src/main/res/values/colors.xml @@ -31,5 +31,7 @@ #FF2222DD #FFDDDDDD + #000000 + #70000000 \ No newline at end of file diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/navigation/NavigationChromeTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/navigation/NavigationChromeTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..083dcb63b6e9592063f3fec881c44924f55d47ab --- /dev/null +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/navigation/NavigationChromeTest.kt @@ -0,0 +1,358 @@ +/* + * 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 . + */ +package earth.maps.cardinal.ui.navigation + +import earth.maps.cardinal.R +import org.junit.Assert.assertEquals +import org.junit.Test +import uniffi.ferrostar.GeographicCoordinate +import uniffi.ferrostar.ManeuverModifier +import uniffi.ferrostar.ManeuverType +import uniffi.ferrostar.RouteStep +import uniffi.ferrostar.SpokenInstruction +import uniffi.ferrostar.VisualInstruction +import uniffi.ferrostar.VisualInstructionContent +import java.util.UUID + +class NavigationChromeTest { + + @Test + fun `left-driving first exit roundabout resolves to left icon`() { + val modifier = resolveRoundaboutModifier( + modifier = ManeuverModifier.SLIGHT_LEFT, + roundaboutExitDegrees = 91, + ) + + assertEquals(ManeuverModifier.LEFT, modifier) + } + + @Test + fun `left-driving second exit roundabout resolves to straight icon`() { + val modifier = resolveRoundaboutModifier( + modifier = ManeuverModifier.SLIGHT_LEFT, + roundaboutExitDegrees = 180, + ) + + assertEquals(ManeuverModifier.STRAIGHT, modifier) + } + + @Test + fun `left-driving third exit roundabout resolves to right icon`() { + val modifier = resolveRoundaboutModifier( + modifier = ManeuverModifier.SLIGHT_LEFT, + roundaboutExitDegrees = 271, + ) + + assertEquals(ManeuverModifier.RIGHT, modifier) + } + + @Test + fun `right-driving first exit roundabout resolves to right icon`() { + val modifier = resolveRoundaboutModifier( + modifier = ManeuverModifier.SLIGHT_RIGHT, + roundaboutExitDegrees = 91, + ) + + assertEquals(ManeuverModifier.RIGHT, modifier) + } + + @Test + fun `first exit fallback resolves to slight left icon when degrees are missing`() { + val roundaboutInstruction = + VisualInstruction( + primaryContent = + VisualInstructionContent( + text = "Forest Avenue", + maneuverType = uniffi.ferrostar.ManeuverType.ROTARY, + maneuverModifier = ManeuverModifier.SLIGHT_LEFT, + roundaboutExitDegrees = null, + laneInfo = null, + exitNumbers = emptyList(), + ), + secondaryContent = null, + subContent = null, + triggerDistanceBeforeManeuver = 0.0, + ) + val departInstruction = + VisualInstruction( + primaryContent = + VisualInstructionContent( + text = "Central Avenue", + maneuverType = uniffi.ferrostar.ManeuverType.DEPART, + maneuverModifier = null, + roundaboutExitDegrees = null, + laneInfo = null, + exitNumbers = emptyList(), + ), + secondaryContent = null, + subContent = null, + triggerDistanceBeforeManeuver = 0.0, + ) + val departStep = + RouteStep( + geometry = listOf(GeographicCoordinate(0.0, 0.0)), + distance = 1.0, + duration = 1.0, + roadName = "Central Avenue", + exits = emptyList(), + instruction = "Drive southwest on Central Avenue.", + visualInstructions = listOf(departInstruction), + spokenInstructions = emptyList(), + annotations = emptyList(), + incidents = emptyList(), + ) + val step = + RouteStep( + geometry = listOf(GeographicCoordinate(0.0, 0.0)), + distance = 1.0, + duration = 1.0, + roadName = "Central Avenue", + exits = emptyList(), + instruction = "Drive southwest on Central Avenue.", + visualInstructions = listOf(roundaboutInstruction), + spokenInstructions = + listOf( + SpokenInstruction( + text = "Enter Hiranandani Circle and take the 1st exit onto Forest Avenue.", + ssml = "", + triggerDistanceBeforeManeuver = 0.0, + utteranceId = UUID.randomUUID(), + ) + ), + annotations = emptyList(), + incidents = emptyList(), + ) + + val matchedStep = listOf(departStep, step).stepForInstruction(roundaboutInstruction) + val patched = roundaboutInstruction.patchRoundaboutInstructionIcons(matchedStep).primaryContent + + assertEquals(step, matchedStep) + assertEquals(ManeuverType.ROTARY, patched.maneuverType) + assertEquals(ManeuverModifier.SLIGHT_LEFT, patched.maneuverModifier) + assertEquals(listOf("1"), patched.exitNumbers) + assertEquals(R.drawable.ic_maneuver_roundabout_first_exit_left, patched.cardinalManeuverIcon()) + } + + @Test + fun `first exit number fallback resolves to slight right icon for counterclockwise roundabout`() { + val modifier = resolveRoundaboutModifierFromExitNumber( + modifier = ManeuverModifier.SLIGHT_RIGHT, + exitNumber = 1, + ) + + assertEquals(ManeuverModifier.SLIGHT_RIGHT, modifier) + } + + @Test + fun `first exit roundabout uses Cardinal right-side banner icon`() { + val instruction = + VisualInstructionContent( + text = "Main Street", + maneuverType = ManeuverType.ROTARY, + maneuverModifier = ManeuverModifier.SLIGHT_RIGHT, + roundaboutExitDegrees = null, + laneInfo = null, + exitNumbers = listOf("1"), + ) + + assertEquals(R.drawable.ic_maneuver_roundabout_first_exit_right, instruction.cardinalManeuverIcon()) + } + + @Test + fun `second exit roundabout uses Cardinal left-side banner icon`() { + val instruction = + VisualInstructionContent( + text = "Main Street", + maneuverType = ManeuverType.ROTARY, + maneuverModifier = ManeuverModifier.SLIGHT_LEFT, + roundaboutExitDegrees = 180.toUShort(), + laneInfo = null, + exitNumbers = listOf("2"), + ) + + assertEquals(R.drawable.ic_maneuver_roundabout_second_exit_left, instruction.cardinalManeuverIcon()) + } + + @Test + fun `second exit fallback keeps circulation and uses Cardinal icon`() { + val patched = + VisualInstruction( + primaryContent = + VisualInstructionContent( + text = "Main Street", + maneuverType = ManeuverType.ROTARY, + maneuverModifier = ManeuverModifier.SLIGHT_LEFT, + roundaboutExitDegrees = null, + laneInfo = null, + exitNumbers = emptyList(), + ), + secondaryContent = null, + subContent = null, + triggerDistanceBeforeManeuver = 0.0, + ) + .patchRoundaboutInstructionIcons( + RouteStep( + geometry = listOf(GeographicCoordinate(0.0, 0.0)), + distance = 1.0, + duration = 1.0, + roadName = "Circle Road", + exits = emptyList(), + instruction = "Take the 2nd exit.", + visualInstructions = emptyList(), + spokenInstructions = + listOf( + SpokenInstruction( + text = "At the roundabout, take the 2nd exit onto Main Street.", + ssml = "", + triggerDistanceBeforeManeuver = 0.0, + utteranceId = UUID.randomUUID(), + ) + ), + annotations = emptyList(), + incidents = emptyList(), + ) + ) + .primaryContent + + assertEquals(ManeuverModifier.SLIGHT_LEFT, patched.maneuverModifier) + assertEquals(listOf("2"), patched.exitNumbers) + assertEquals(R.drawable.ic_maneuver_roundabout_second_exit_left, patched.cardinalManeuverIcon()) + } + + @Test + fun `third exit roundabout uses Cardinal right-side banner icon`() { + val instruction = + VisualInstructionContent( + text = "Main Street", + maneuverType = ManeuverType.ROTARY, + maneuverModifier = ManeuverModifier.SLIGHT_LEFT, + roundaboutExitDegrees = 271.toUShort(), + laneInfo = null, + exitNumbers = listOf("3"), + ) + + assertEquals(R.drawable.ic_maneuver_roundabout_third_exit_right, instruction.cardinalManeuverIcon()) + } + + @Test + fun `third exit fallback keeps circulation and uses Cardinal icon`() { + val patched = + VisualInstruction( + primaryContent = + VisualInstructionContent( + text = "Main Street", + maneuverType = ManeuverType.ROTARY, + maneuverModifier = ManeuverModifier.SLIGHT_LEFT, + roundaboutExitDegrees = null, + laneInfo = null, + exitNumbers = emptyList(), + ), + secondaryContent = null, + subContent = null, + triggerDistanceBeforeManeuver = 0.0, + ) + .patchRoundaboutInstructionIcons( + RouteStep( + geometry = listOf(GeographicCoordinate(0.0, 0.0)), + distance = 1.0, + duration = 1.0, + roadName = "Circle Road", + exits = emptyList(), + instruction = "Take the 3rd exit.", + visualInstructions = emptyList(), + spokenInstructions = + listOf( + SpokenInstruction( + text = "At the roundabout, take the 3rd exit onto Main Street.", + ssml = "", + triggerDistanceBeforeManeuver = 0.0, + utteranceId = UUID.randomUUID(), + ) + ), + annotations = emptyList(), + incidents = emptyList(), + ) + ) + .primaryContent + + assertEquals(ManeuverModifier.SLIGHT_LEFT, patched.maneuverModifier) + assertEquals(listOf("3"), patched.exitNumbers) + assertEquals(R.drawable.ic_maneuver_roundabout_third_exit_right, patched.cardinalManeuverIcon()) + } + + @Test + fun `fourth exit roundabout uses Cardinal right-side banner icon`() { + val instruction = + VisualInstructionContent( + text = "Main Street", + maneuverType = ManeuverType.ROTARY, + maneuverModifier = ManeuverModifier.SLIGHT_LEFT, + roundaboutExitDegrees = 359.toUShort(), + laneInfo = null, + exitNumbers = listOf("4"), + ) + + assertEquals(R.drawable.ic_maneuver_roundabout_fourth_exit_right, instruction.cardinalManeuverIcon()) + } + + @Test + fun `fourth exit fallback keeps circulation and uses Cardinal icon`() { + val patched = + VisualInstruction( + primaryContent = + VisualInstructionContent( + text = "Main Street", + maneuverType = ManeuverType.ROTARY, + maneuverModifier = ManeuverModifier.SLIGHT_LEFT, + roundaboutExitDegrees = null, + laneInfo = null, + exitNumbers = emptyList(), + ), + secondaryContent = null, + subContent = null, + triggerDistanceBeforeManeuver = 0.0, + ) + .patchRoundaboutInstructionIcons( + RouteStep( + geometry = listOf(GeographicCoordinate(0.0, 0.0)), + distance = 1.0, + duration = 1.0, + roadName = "Circle Road", + exits = emptyList(), + instruction = "Take the 4th exit.", + visualInstructions = emptyList(), + spokenInstructions = + listOf( + SpokenInstruction( + text = "At the roundabout, take the 4th exit onto Main Street.", + ssml = "", + triggerDistanceBeforeManeuver = 0.0, + utteranceId = UUID.randomUUID(), + ) + ), + annotations = emptyList(), + incidents = emptyList(), + ) + ) + .primaryContent + + assertEquals(ManeuverModifier.SLIGHT_LEFT, patched.maneuverModifier) + assertEquals(listOf("4"), patched.exitNumbers) + assertEquals(R.drawable.ic_maneuver_roundabout_fourth_exit_right, patched.cardinalManeuverIcon()) + } +}