Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 6fd5eee3 authored by Mitul Sheth's avatar Mitul Sheth
Browse files

Merge branch 'bug-wrong-roundabouts-images-one' into 'main'

fix(bug): The picture illustrating the roundabouts are wrong

See merge request !70
parents d618e87b ebec1c39
Loading
Loading
Loading
Loading
Loading
+1 −1
Original line number Diff line number Diff line
@@ -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)
        }
+291 −4
Original line number Diff line number Diff line
@@ -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<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
}
+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
+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
+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