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

Commit c993eedd authored by Ellen Poe's avatar Ellen Poe
Browse files

feat: tapping on alternate routes selects them

parent cba365e2
Loading
Loading
Loading
Loading
Loading
+108 −11
Original line number Diff line number Diff line
@@ -191,12 +191,17 @@ fun AppContent(
                port = port,
                mapViewModel = mapViewModel,
                onMapInteraction = {
                    if (navController.currentBackStackEntry?.destination?.route?.startsWith("place_card") == true) {
                    if (topOfBackStack?.destination?.route?.startsWith("place_card") == true) {
                        navController.popBackStack()
                    }
                },
                onMapPoiClick = {
                    if (topOfBackStack?.destination?.route?.startsWith("directions") != true && topOfBackStack?.destination?.route?.startsWith(
                            "transit_itinerary_detail"
                        ) != true
                    ) {
                        NavigationUtils.navigate(navController, Screen.PlaceCard(it))
                    }
                },
                onDropPin = {
                    val place = Place(
@@ -227,7 +232,26 @@ fun AppContent(
                selectedOfflineArea = state.selectedOfflineArea,
                currentRoute = state.currentRoute,
                allRoutes = state.allRoutes,
                currentTransitItinerary = state.currentTransitItinerary
                currentTransitItinerary = state.currentTransitItinerary,
                onRouteAnnotationClick = { routeIndex ->
                    // Handle route annotation click by updating the selected route index in AppContentState
                    // The DirectionsScreen will observe this change and update the DirectionsViewModel
                    if (state.allRoutes.isNotEmpty()) {
                        val actualIndex = if (routeIndex == -1) {
                            // If -1 is passed, it means the current selected route was tapped
                            // Keep the current selection
                            state.selectedRouteIndex ?: 0
                        } else {
                            // Convert the reversed index back to the correct index
                            // because routes are displayed in reverse order in the RouteLayer
                            state.allRoutes.size - 1 - routeIndex
                        }

                        if (actualIndex >= 0 && actualIndex < state.allRoutes.size) {
                            state.selectedRouteIndex = actualIndex
                        }
                    }
                }
            )
        } else {
            LaunchedEffect(key1 = port) {
@@ -249,7 +273,14 @@ fun AppContent(
            Screen.HOME_SEARCH,
            enterTransition = { slideInVertically(initialOffsetY = { it }) },
            exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry ->
            HomeRoute(state, homeViewModel, navController, topOfBackStack, appPreferenceRepository, backStackEntry)
            HomeRoute(
                state,
                homeViewModel,
                navController,
                topOfBackStack,
                appPreferenceRepository,
                backStackEntry
            )
        }

        composable(
@@ -263,21 +294,39 @@ fun AppContent(
            Screen.NEARBY_TRANSIT,
            enterTransition = { slideInVertically(initialOffsetY = { it }) },
            exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry ->
            NearbyTransitRoute(state, transitViewModel, navController, topOfBackStack, backStackEntry)
            NearbyTransitRoute(
                state,
                transitViewModel,
                navController,
                topOfBackStack,
                backStackEntry
            )
        }

        composable(
            Screen.PLACE_CARD,
            enterTransition = { slideInVertically(initialOffsetY = { it }) },
            exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry ->
            PlaceCardRoute(state, navController, topOfBackStack, appPreferenceRepository, backStackEntry)
            PlaceCardRoute(
                state,
                navController,
                topOfBackStack,
                appPreferenceRepository,
                backStackEntry
            )
        }

        composable(
            Screen.OFFLINE_AREAS,
            enterTransition = { slideInVertically(initialOffsetY = { it }) },
            exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry ->
            OfflineAreasRoute(state, navController, topOfBackStack, appPreferenceRepository, backStackEntry)
            OfflineAreasRoute(
                state,
                navController,
                topOfBackStack,
                appPreferenceRepository,
                backStackEntry
            )
        }

        composable(
@@ -354,14 +403,31 @@ fun AppContent(
            Screen.DIRECTIONS,
            enterTransition = { slideInVertically(initialOffsetY = { it }) },
            exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry ->
            DirectionsRoute(state, mapViewModel, navController, topOfBackStack, appPreferenceRepository, hasLocationPermission, onRequestLocationPermission, hasNotificationPermission, onRequestNotificationPermission, backStackEntry)
            DirectionsRoute(
                state,
                mapViewModel,
                navController,
                topOfBackStack,
                appPreferenceRepository,
                hasLocationPermission,
                onRequestLocationPermission,
                hasNotificationPermission,
                onRequestNotificationPermission,
                backStackEntry
            )
        }

        composable(
            Screen.TRANSIT_ITINERARY_DETAIL,
            enterTransition = { slideInVertically(initialOffsetY = { it }) },
            exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry ->
            TransitItineraryDetailRoute(state, navController, topOfBackStack, appPreferenceRepository, backStackEntry)
            TransitItineraryDetailRoute(
                state,
                navController,
                topOfBackStack,
                appPreferenceRepository,
                backStackEntry
            )
        }

        composable(Screen.TURN_BY_TURN) { backStackEntry ->
@@ -409,6 +475,11 @@ private fun HomeRoute(
    appPreferenceRepository: AppPreferenceRepository,
    backStackEntry: NavBackStackEntry
) {
    LaunchedEffect(Unit) {
        state.mapPins.clear()
        state.currentRoute = null
        state.allRoutes = emptyList()
    }
    state.showToolbar = true
    HomeScreenComposable(
        viewModel = homeViewModel,
@@ -542,9 +613,15 @@ private fun RoutingProfilesRoute(state: AppContentState, navController: NavHostC

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ProfileEditorRoute(state: AppContentState, navController: NavHostController, backStackEntry: NavBackStackEntry) {
private fun ProfileEditorRoute(
    state: AppContentState,
    navController: NavHostController,
    backStackEntry: NavBackStackEntry
) {
    LaunchedEffect(key1 = Unit) {
        state.mapPins.clear()
        state.currentRoute = null
        state.allRoutes = emptyList()
    }

    val snackBarHostState = remember { SnackbarHostState() }
@@ -566,7 +643,11 @@ private fun ProfileEditorRoute(state: AppContentState, navController: NavHostCon

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ManagePlacesRoute(state: AppContentState, navController: NavHostController, backStackEntry: NavBackStackEntry) {
private fun ManagePlacesRoute(
    state: AppContentState,
    navController: NavHostController,
    backStackEntry: NavBackStackEntry
) {
    state.showToolbar = true

    val listIdRaw = backStackEntry.arguments?.getString("listId")
@@ -617,6 +698,8 @@ private fun PlaceCardRoute(
            viewModel.setPlace(place)
            // Clear any existing pins and add the new one to ensure only one pin is shown at a time
            state.mapPins.clear()
            state.currentRoute = null
            state.allRoutes = emptyList()
            state.mapPins.add(place)

            val previousBackStackEntry = navController.previousBackStackEntry
@@ -690,6 +773,8 @@ private fun OfflineAreasRoute(
        rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)

    LaunchedEffect(key1 = Unit) {
        state.currentRoute = null
        state.allRoutes = emptyList()
        state.mapPins.clear()
        state.peekHeight = state.screenHeightDp / 3 // Approx, empirical
        state.coroutineScope.launch {
@@ -814,6 +899,16 @@ private fun DirectionsRoute(
            state.currentRoute = route
            state.allRoutes = allRoutes
        })

    // Observe selectedRouteIndex changes from AppContentState and update DirectionsViewModel
    LaunchedEffect(state.selectedRouteIndex) {
        state.selectedRouteIndex?.let { selectedIndex ->
            if (selectedIndex >= 0 && selectedIndex < viewModel.routeState.value.routes.size) {
                viewModel.selectRoute(selectedIndex)
            }
        }
    }

    DisposableEffect(key1 = Unit) {
        onDispose {
            state.currentRoute = null
@@ -931,6 +1026,8 @@ private fun TransitItineraryDetailRoute(
            }

            // Clear any existing pins
            state.currentRoute = null
            state.allRoutes = emptyList()
            state.mapPins.clear()
        }

+2 −0
Original line number Diff line number Diff line
@@ -54,6 +54,7 @@ class AppContentState(
    screenHeightDp: Dp = 0.dp,
    screenWidthDp: Dp = 0.dp,
    peekHeight: Dp = 0.dp,
    selectedRouteIndex: Int? = null,
) {
    var fabHeight by mutableStateOf(fabHeight)
    var selectedOfflineArea by mutableStateOf(selectedOfflineArea)
@@ -64,6 +65,7 @@ class AppContentState(
    var screenHeightDp by mutableStateOf(screenHeightDp)
    var screenWidthDp by mutableStateOf(screenWidthDp)
    var peekHeight by mutableStateOf(peekHeight)
    var selectedRouteIndex by mutableStateOf(selectedRouteIndex)
}

@Composable
+119 −95
Original line number Diff line number Diff line
@@ -19,7 +19,6 @@
package earth.maps.cardinal.ui.core

import android.content.Context
import android.util.Log
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -40,6 +39,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.dimensionResource
@@ -47,7 +47,6 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.util.fastSumBy
import androidx.compose.ui.zIndex
import androidx.core.graphics.toColorInt
import earth.maps.cardinal.R
@@ -71,7 +70,12 @@ import io.github.dellisd.spatialk.geojson.Point
import io.github.dellisd.spatialk.geojson.Polygon
import io.github.dellisd.spatialk.geojson.Position
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.encodeToJsonElement
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.expressions.dsl.Feature.get
import org.maplibre.compose.expressions.dsl.Feature.has
import org.maplibre.compose.expressions.dsl.const
import org.maplibre.compose.expressions.dsl.image
import org.maplibre.compose.expressions.dsl.offset
@@ -113,6 +117,7 @@ fun MapView(
    currentRoute: Route? = null,
    allRoutes: List<Route>,
    currentTransitItinerary: Itinerary? = null,
    onRouteAnnotationClick: ((Int) -> Unit)? = null,
) {
    val context = LocalContext.current
    val styleState = rememberStyleState()
@@ -161,7 +166,11 @@ fun MapView(
                ),
                onMapClick = { position, dpOffset ->
                    mapViewModel.handleMapTap(
                        cameraState, dpOffset, onMapPoiClick, onMapInteraction
                        cameraState,
                        dpOffset,
                        onMapPoiClick,
                        onMapInteraction,
                        onRouteAnnotationClick,
                    )
                    ClickResult.Consume
                },
@@ -269,124 +278,139 @@ private fun OfflineBoundsLayer(selectedOfflineArea: OfflineArea?) {
private fun RouteLayer(viewModel: MapViewModel, currentRoute: Route?, allRoutes: List<Route>) {
    val annotations = remember(allRoutes) { viewModel.placeRouteAnnotations(allRoutes) }

    Log.d("annotations", "${annotations.values}")

    allRoutes.reversed().forEachIndexed { index, route ->
        if (route == currentRoute) {
            return@forEachIndexed
        }
    // Create all route features in a single collection
    val routeFeatures = allRoutes.reversed().mapIndexed { index, route ->
        val routePositions = route.geometry.map { coord ->
            Position(coord.lng, coord.lat) // [longitude, latitude]
        }
        val routeLineString = LineString(routePositions)
        val routeFeature = Feature(geometry = routeLineString)
        val routeSource = rememberGeoJsonSource(
            GeoJsonData.Features(FeatureCollection(features = listOf(routeFeature)))
        )

        val desaturateAmount = 0.8f
        val polylineColor = colorResource(R.color.polyline_color).desaturate(desaturateAmount)
        val polylineCasingColor =
        val polylineCasingColor = if (route == currentRoute) {
            formatColorAsJson(colorResource(R.color.polyline_casing_color))
        } else {
            formatColorAsJson(
                colorResource(R.color.polyline_casing_color).desaturate(desaturateAmount)
            )
        }
        val polylineColor = if (route == currentRoute) {
            formatColorAsJson(colorResource(R.color.polyline_color))
        } else {
            formatColorAsJson(
                colorResource(R.color.polyline_color).desaturate(desaturateAmount)
            )
        }
        Feature(
            geometry = routeLineString,
            properties = mapOf(
                "routeIndex" to Json.encodeToJsonElement(index.toString()),
                "routeColor" to polylineColor,
                "routeColorCasing" to polylineCasingColor,
                if(route == currentRoute) {
                    "current" to Json.encodeToJsonElement(true)
                } else {
                    "notCurrent" to Json.encodeToJsonElement(true)
                }
            )
        )
    }.toMutableList()


    // Create single source for all routes
    val routeSource = rememberGeoJsonSource(
        GeoJsonData.Features(FeatureCollection(features = routeFeatures))
    )

    // Route casing layer
    LineLayer(
            id = "route_line_casing_$index", source = routeSource,
            color = rgbColor(
                const((polylineCasingColor.red * 255.0).toInt()), // Blue color
                const((polylineCasingColor.green * 255.0).toInt()),
                const((polylineCasingColor.blue * 255.0).toInt())
            ),
            width = const(9.dp),
        id = "route_lines_casing", source = routeSource,
        color = get("routeColorCasing").cast(),
        width = const(11.dp),
        opacity = const(1f),
        cap = const(LineCap.Round),
        join = const(LineJoin.Round),
    )

    // Route main line layer
    LineLayer(
            id = "route_line_$index", source = routeSource,
            color = rgbColor(
                const((polylineColor.red * 255.0).toInt()), // Blue color
                const((polylineColor.green * 255.0).toInt()),
                const((polylineColor.blue * 255.0).toInt())
            ),
            width = const(6.dp),
        id = "route_lines", source = routeSource,
        color = get("routeColor").cast(),
        width = const(8.dp),
        opacity = const(1f),
        cap = const(LineCap.Round),
        join = const(LineJoin.Round),
    )

        annotations[route]?.let { annotationLatLng ->
            RouteAnnotation(annotationLatLng, index, route)
        }
    }

    currentRoute?.let { route ->
        val routePositions = route.geometry.map { coord ->
            Position(coord.lng, coord.lat) // [longitude, latitude]
        }
        val routeLineString = LineString(routePositions)
        val routeFeature = Feature(geometry = routeLineString)
        val routeSource = rememberGeoJsonSource(
            GeoJsonData.Features(FeatureCollection(features = listOf(routeFeature)))
        )

        val polylineColor = colorResource(R.color.polyline_color)
        val polylineCasingColor = colorResource(R.color.polyline_casing_color)

    // Route casing layer
    LineLayer(
            id = "current_route_line_casing", source = routeSource,
            color = rgbColor(
                const((polylineCasingColor.red * 255.0).toInt()), // Blue color
                const((polylineCasingColor.green * 255.0).toInt()),
                const((polylineCasingColor.blue * 255.0).toInt())
            ),
        id = "route_lines_casing_selected", source = routeSource,
        color = get("routeColorCasing").cast(),
        filter = has("current"),
        width = const(9.dp),
        opacity = const(1f),
        cap = const(LineCap.Round),
        join = const(LineJoin.Round),
    )

    // Route main line layer
    LineLayer(
            id = "current_route_line", source = routeSource,
            color = rgbColor(
                const((polylineColor.red * 255.0).toInt()), // Blue color
                const((polylineColor.green * 255.0).toInt()),
                const((polylineColor.blue * 255.0).toInt())
            ),
        id = "route_lines_selected", source = routeSource,
        color = get("routeColor").cast(),
        filter = has("current"),
        width = const(6.dp),
        opacity = const(1f),
        cap = const(LineCap.Round),
        join = const(LineJoin.Round),
    )

        annotations[route]?.let { annotationLatLng ->
            RouteAnnotation(annotationLatLng, index = null, route)
        }
    RouteAnnotations(
        allRoutes.reversed().mapIndexedNotNull { index, route ->
            annotations[route]?.let { Triple(index, route, it) }
        }
    )
}

@Composable
private fun RouteAnnotation(
    annotationLatLng: LatLng,
    index: Int?,
    route: Route
private fun formatColorAsJson(polylineCasingColor: Color): JsonElement = Json.encodeToJsonElement(
    "#${
        String.format(
            "%02x%02x%02x",
            (polylineCasingColor.red * 255).toInt(),
            (polylineCasingColor.green * 255).toInt(),
            (polylineCasingColor.blue * 255).toInt()
        )
    }"
)

@Composable
private fun RouteAnnotations(
    annotations: List<Triple<Int, Route, LatLng>>
) {
    val index = index?.toString() ?: "selected"
    val annotationFeature = Feature(
    val features = annotations.map { triple ->
        val index = triple.first.toString()
        val route = triple.second
        val duration = formatDuration(route.steps.sumOf { it.duration }.toInt())
        return@map Feature(
            geometry = Point(
                coordinates = Position(
                longitude = annotationLatLng.longitude, latitude = annotationLatLng.latitude
                    longitude = triple.third.longitude, latitude = triple.third.latitude
                )
            ),
            properties = mapOf(
                "routeIndex" to Json.encodeToJsonElement(index),
                "duration" to Json.encodeToJsonElement(duration)
            )
        )
    }
    val annotationSource = rememberGeoJsonSource(
        GeoJsonData.Features(FeatureCollection(features = listOf(annotationFeature)))
        GeoJsonData.Features(FeatureCollection(features = features))
    )
    val textHaloColor = MaterialTheme.colorScheme.surface
    val textColor = MaterialTheme.colorScheme.onSurface

    SymbolLayer(
        id = "route_annotation_$index",
        id = "route_annotations",
        source = annotationSource,
        textField = const(formatDuration(route.steps.sumOf { it.duration }.toInt())),
        textField = org.maplibre.compose.expressions.dsl.Feature["duration"].cast(),
        textAnchor = const(SymbolAnchor.Bottom),
        textColor = rgbColor(
            const((textColor.red * 255.0).toInt()), // Blue color
+20 −1
Original line number Diff line number Diff line
@@ -199,8 +199,27 @@ class MapViewModel @Inject constructor(
        cameraState: CameraState,
        dpOffset: DpOffset,
        onMapPoiClick: (Place) -> Unit,
        onMapInteraction: () -> Unit
        onMapInteraction: () -> Unit,
        onRouteAnnotationClick: ((Int) -> Unit)? = null,
    ) {
        // Check for route annotation features first
        val routeAnnotationFeatures = cameraState.projection?.queryRenderedFeatures(
            dpOffset,
            layerIds = setOf("route_annotations", "route_lines_casing", "route_lines")
        )

        val routeAnnotationFeature = routeAnnotationFeatures?.firstOrNull()
        if (routeAnnotationFeature != null) {
            // Extract route index from layer ID
            val routeIndex =
                routeAnnotationFeature.properties["routeIndex"]?.jsonPrimitive?.content?.toIntOrNull()

            if (routeIndex != null && onRouteAnnotationClick != null) {
                onRouteAnnotationClick(routeIndex)
                return
            }
        }

        val features = cameraState.projection?.queryRenderedFeatures(
            dpOffset,
            layerIds = setOf(
+29 −0
Original line number Diff line number Diff line
@@ -493,6 +493,35 @@ class DirectionsViewModel @Inject constructor(
        }
    }

    /**
     * Selects a route by index from the available routes.
     * This method is called when a route annotation is tapped on the map.
     * 
     * @param routeIndex The index of the route to select. Use -1 for the current selected route.
     */
    fun selectRouteByIndex(routeIndex: Int) {
        val currentRoutes = routeState.value.routes
        if (currentRoutes.isNotEmpty()) {
            val actualIndex = if (routeIndex == -1) {
                // If -1 is passed, it means the current selected route was tapped
                // Keep the current selection
                routeState.value.selectedRouteIndex ?: 0
            } else {
                // Convert the reversed index back to the correct index
                // because routes are displayed in reverse order in the RouteLayer
                currentRoutes.size - 1 - routeIndex
            }
            
            if (actualIndex >= 0 && actualIndex < currentRoutes.size) {
                routeStateRepository.selectRoute(actualIndex)
            }
        }
    }

    fun selectRoute(selectedIndex: Int) {
        routeStateRepository.selectRoute(selectedIndex)
    }

    companion object {
        private const val TAG = "DirectionsViewModel"
    }