Loading cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContent.kt +108 −11 Original line number Diff line number Diff line Loading @@ -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( Loading Loading @@ -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) { Loading @@ -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( Loading @@ -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( Loading Loading @@ -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 -> Loading Loading @@ -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, Loading Loading @@ -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() } Loading @@ -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") Loading Loading @@ -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 Loading Loading @@ -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 { Loading Loading @@ -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 Loading Loading @@ -931,6 +1026,8 @@ private fun TransitItineraryDetailRoute( } // Clear any existing pins state.currentRoute = null state.allRoutes = emptyList() state.mapPins.clear() } Loading cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContentState.kt +2 −0 Original line number Diff line number Diff line Loading @@ -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) Loading @@ -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 Loading cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapView.kt +119 −95 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading Loading @@ -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() Loading Loading @@ -161,7 +166,11 @@ fun MapView( ), onMapClick = { position, dpOffset -> mapViewModel.handleMapTap( cameraState, dpOffset, onMapPoiClick, onMapInteraction cameraState, dpOffset, onMapPoiClick, onMapInteraction, onRouteAnnotationClick, ) ClickResult.Consume }, Loading Loading @@ -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 Loading cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapViewModel.kt +20 −1 Original line number Diff line number Diff line Loading @@ -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( Loading cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsViewModel.kt +29 −0 Original line number Diff line number Diff line Loading @@ -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" } Loading Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContent.kt +108 −11 Original line number Diff line number Diff line Loading @@ -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( Loading Loading @@ -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) { Loading @@ -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( Loading @@ -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( Loading Loading @@ -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 -> Loading Loading @@ -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, Loading Loading @@ -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() } Loading @@ -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") Loading Loading @@ -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 Loading Loading @@ -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 { Loading Loading @@ -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 Loading Loading @@ -931,6 +1026,8 @@ private fun TransitItineraryDetailRoute( } // Clear any existing pins state.currentRoute = null state.allRoutes = emptyList() state.mapPins.clear() } Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContentState.kt +2 −0 Original line number Diff line number Diff line Loading @@ -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) Loading @@ -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 Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapView.kt +119 −95 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading Loading @@ -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() Loading Loading @@ -161,7 +166,11 @@ fun MapView( ), onMapClick = { position, dpOffset -> mapViewModel.handleMapTap( cameraState, dpOffset, onMapPoiClick, onMapInteraction cameraState, dpOffset, onMapPoiClick, onMapInteraction, onRouteAnnotationClick, ) ClickResult.Consume }, Loading Loading @@ -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 Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapViewModel.kt +20 −1 Original line number Diff line number Diff line Loading @@ -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( Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsViewModel.kt +29 −0 Original line number Diff line number Diff line Loading @@ -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" } Loading