diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/theme/InstructionRowTheme.kt b/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/theme/InstructionRowTheme.kt index c9b04263..43d0ca1d 100644 --- a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/theme/InstructionRowTheme.kt +++ b/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/theme/InstructionRowTheme.kt @@ -33,5 +33,5 @@ object DefaultInstructionRowTheme : InstructionRowTheme { @Composable get() = MaterialTheme.colorScheme.onSurface override val backgroundColor: Color - @Composable get() = MaterialTheme.colorScheme.surface + @Composable get() = MaterialTheme.colorScheme.surfaceContainerLow } diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/InstructionsView.kt b/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/InstructionsView.kt index a609f903..c6c747cb 100644 --- a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/InstructionsView.kt +++ b/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/InstructionsView.kt @@ -1,25 +1,44 @@ package com.stadiamaps.ferrostar.composeui.views import android.icu.util.ULocale +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.stadiamaps.ferrostar.composeui.formatting.DistanceFormatter import com.stadiamaps.ferrostar.composeui.formatting.LocalizedDistanceFormatter import com.stadiamaps.ferrostar.composeui.theme.DefaultInstructionRowTheme import com.stadiamaps.ferrostar.composeui.theme.InstructionRowTheme +import com.stadiamaps.ferrostar.composeui.views.controls.PillDragHandle import com.stadiamaps.ferrostar.composeui.views.maneuver.ManeuverImage import com.stadiamaps.ferrostar.composeui.views.maneuver.ManeuverInstructionView import uniffi.ferrostar.ManeuverModifier import uniffi.ferrostar.ManeuverType +import uniffi.ferrostar.RouteStep import uniffi.ferrostar.VisualInstruction import uniffi.ferrostar.VisualInstructionContent @@ -36,23 +55,72 @@ fun InstructionsView( distanceToNextManeuver: Double?, distanceFormatter: DistanceFormatter = LocalizedDistanceFormatter(), theme: InstructionRowTheme = DefaultInstructionRowTheme, - content: @Composable () -> Unit = { - ManeuverImage(instructions.primaryContent, tint = MaterialTheme.colorScheme.primary) + remainingSteps: List? = null, + initExpanded: Boolean = false, + contentBuilder: @Composable (VisualInstruction) -> Unit = { + ManeuverImage(it.primaryContent, tint = MaterialTheme.colorScheme.primary) } ) { + var isExpanded by remember { mutableStateOf(initExpanded) } + val screenHeight = LocalConfiguration.current.screenHeightDp.dp + Column( modifier = Modifier.fillMaxWidth() - .shadow(elevation = 5.dp, RoundedCornerShape(10.dp)) + .heightIn(max = screenHeight) + .animateContentSize(animationSpec = spring(stiffness = Spring.StiffnessHigh)) .background(theme.backgroundColor, RoundedCornerShape(10.dp)) - .padding(8.dp)) { - ManeuverInstructionView( - text = instructions.primaryContent.text, - distanceFormatter = distanceFormatter, - distanceToNextManeuver = distanceToNextManeuver, - theme = theme, - content = content) - // TODO: Secondary instructions + .padding(16.dp) + .clickable { + // This makes the entire view a click target for expansion. + // If only the pill is a click target, you need to be a ninja to tap it. + isExpanded = true + }) { + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + // Primary content + item { + ManeuverInstructionView( + text = instructions.primaryContent.text, + distanceFormatter = distanceFormatter, + distanceToNextManeuver = distanceToNextManeuver, + theme = theme) { + contentBuilder(instructions) + } + } + + // TODO: Secondary content + + // Expanded content + if (isExpanded && remainingSteps != null && remainingSteps.count() > 1) { + item { HorizontalDivider(thickness = 1.dp) } + items(remainingSteps.drop(1)) { step -> + step.visualInstructions.firstOrNull()?.let { upcomingInstruction -> + Spacer(modifier = Modifier.height(8.dp)) + ManeuverInstructionView( + text = upcomingInstruction.primaryContent.text, + distanceFormatter = distanceFormatter, + distanceToNextManeuver = step.distance, + theme = theme) { + contentBuilder(upcomingInstruction) + } + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider(thickness = 1.dp) + } + } + } + } + + if (isExpanded) { + Spacer(modifier = Modifier.weight(1.0f)) + } + + PillDragHandle( + isExpanded, + // The modifier here lets us keep the container as slim as possible + modifier = Modifier.offset(y = 4.dp).align(Alignment.CenterHorizontally), + iconTintColor = theme.iconTintColor) { + isExpanded = !isExpanded + } } } diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/controls/PillDragHandle.kt b/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/controls/PillDragHandle.kt new file mode 100644 index 00000000..1eb7af00 --- /dev/null +++ b/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/controls/PillDragHandle.kt @@ -0,0 +1,68 @@ +package com.stadiamaps.ferrostar.composeui.views.controls + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun PillDragHandle( + isExpanded: Boolean, + modifier: Modifier = Modifier.fillMaxWidth(), + iconTintColor: Color = MaterialTheme.colorScheme.onSurface, + toggle: () -> Unit = {} +) { + val handleHeight = if (isExpanded) 36.dp else 4.dp + Box(modifier = modifier.height(handleHeight).clickable(onClick = toggle)) { + if (isExpanded) { + Icon( + Icons.Rounded.KeyboardArrowUp, + modifier = Modifier.align(Alignment.Center), + contentDescription = "Show upcoming maneuvers", + tint = iconTintColor) + } else { + Box( + modifier = + Modifier.align(Alignment.Center) + .height(handleHeight) + .width(24.dp) + .background(iconTintColor, RoundedCornerShape(6.dp)) + .semantics { + role = Role.Button + onClick(label = "Hide upcoming maneuvers") { + toggle() + true + } + }) + } + } +} + +@Preview +@Composable +fun PreviewPillDragHandleCollapsed() { + PillDragHandle(isExpanded = false, iconTintColor = Color.White) +} + +@Preview +@Composable +fun PreviewPillDragHandleExpanded() { + PillDragHandle(isExpanded = true, iconTintColor = Color.White) +} diff --git a/android/composeui/src/test/java/com/stadiamaps/ferrostar/views/InstructionViewTest.kt b/android/composeui/src/test/java/com/stadiamaps/ferrostar/views/InstructionViewTest.kt index 3850680d..4f356d16 100644 --- a/android/composeui/src/test/java/com/stadiamaps/ferrostar/views/InstructionViewTest.kt +++ b/android/composeui/src/test/java/com/stadiamaps/ferrostar/views/InstructionViewTest.kt @@ -1,6 +1,8 @@ package com.stadiamaps.ferrostar.views import com.stadiamaps.ferrostar.composeui.views.InstructionsView +import com.stadiamaps.ferrostar.core.NavigationUiState +import com.stadiamaps.ferrostar.core.mock.pedestrianExample import com.stadiamaps.ferrostar.support.paparazziDefault import com.stadiamaps.ferrostar.support.withSnapshotBackground import org.junit.Rule @@ -35,4 +37,19 @@ class InstructionViewTest { } } } + + @Test + fun testInstructionViewExpanded() { + val state = NavigationUiState.pedestrianExample() + + paparazzi.snapshot { + withSnapshotBackground { + InstructionsView( + instructions = state.visualInstruction!!, + remainingSteps = state.remainingSteps, + distanceToNextManeuver = 42.0, + initExpanded = true) + } + } + } } diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InstructionViewTest_testInstructionView.png b/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InstructionViewTest_testInstructionView.png index 5ba73ea3..e563f03b 100644 Binary files a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InstructionViewTest_testInstructionView.png and b/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InstructionViewTest_testInstructionView.png differ diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InstructionViewTest_testInstructionViewExpanded.png b/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InstructionViewTest_testInstructionViewExpanded.png new file mode 100644 index 00000000..d6b9d9a5 Binary files /dev/null and b/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InstructionViewTest_testInstructionViewExpanded.png differ diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_RTLInstructionViewTests_testRTLInstructionView.png b/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_RTLInstructionViewTests_testRTLInstructionView.png index 01b134a0..282ad4a1 100644 Binary files a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_RTLInstructionViewTests_testRTLInstructionView.png and b/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_RTLInstructionViewTests_testRTLInstructionView.png differ diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/NavigationViewModel.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/NavigationViewModel.kt index 9ffa43b4..0c2a3ed8 100644 --- a/android/core/src/main/java/com/stadiamaps/ferrostar/core/NavigationViewModel.kt +++ b/android/core/src/main/java/com/stadiamaps/ferrostar/core/NavigationViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.stadiamaps.ferrostar.core.extensions.currentRoadName import com.stadiamaps.ferrostar.core.extensions.deviation import com.stadiamaps.ferrostar.core.extensions.progress +import com.stadiamaps.ferrostar.core.extensions.remainingSteps import com.stadiamaps.ferrostar.core.extensions.visualInstruction import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -13,6 +14,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import uniffi.ferrostar.GeographicCoordinate import uniffi.ferrostar.RouteDeviation +import uniffi.ferrostar.RouteStep import uniffi.ferrostar.SpokenInstruction import uniffi.ferrostar.TripProgress import uniffi.ferrostar.TripState @@ -49,7 +51,9 @@ data class NavigationUiState( /** If true, spoken instructions will not be synthesized. */ val isMuted: Boolean?, /** The name of the road which the current route step is traversing. */ - val currentStepRoadName: String? + val currentStepRoadName: String?, + /** The remaining steps in the trip (including the current step). */ + val remainingSteps: List? ) { companion object { fun fromFerrostar( @@ -70,7 +74,8 @@ data class NavigationUiState( isCalculatingNewRoute = coreState.isCalculatingNewRoute, routeDeviation = coreState.tripState.deviation(), isMuted = isMuted, - currentStepRoadName = coreState.tripState.currentRoadName()) + currentStepRoadName = coreState.tripState.currentRoadName(), + remainingSteps = coreState.tripState.remainingSteps()) } } diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/extensions/TripStateExtensions.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/extensions/TripStateExtensions.kt index f045cb91..b5ca06a0 100644 --- a/android/core/src/main/java/com/stadiamaps/ferrostar/core/extensions/TripStateExtensions.kt +++ b/android/core/src/main/java/com/stadiamaps/ferrostar/core/extensions/TripStateExtensions.kt @@ -60,3 +60,15 @@ fun TripState.currentRoadName() = is TripState.Complete, TripState.Idle -> null } + +/** + * Get the remaining steps (including the current) in the current trip. + * + * @return The list of remaining steps (if any). + */ +fun TripState.remainingSteps() = + when (this) { + is TripState.Navigating -> this.remainingSteps + is TripState.Complete, + TripState.Idle -> null + } diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt index 16c7c6cf..40d59d91 100644 --- a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt +++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt @@ -42,7 +42,7 @@ class DemoNavigationViewModel : ViewModel(), NavigationViewModel { .map { userLocation -> // TODO: Heading NavigationUiState( - userLocation, null, null, null, null, null, null, false, null, null, null) + userLocation, null, null, null, null, null, null, false, null, null, null, null) } .stateIn( scope = viewModelScope, @@ -50,7 +50,7 @@ class DemoNavigationViewModel : ViewModel(), NavigationViewModel { // TODO: Heading initialValue = NavigationUiState( - null, null, null, null, null, null, null, false, null, null, null)) + null, null, null, null, null, null, null, false, null, null, null, null)) override fun toggleMute() { // Do nothing diff --git a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/overlays/LandscapeNavigationOverlayView.kt b/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/overlays/LandscapeNavigationOverlayView.kt index f3855f4a..790f144e 100644 --- a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/overlays/LandscapeNavigationOverlayView.kt +++ b/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/overlays/LandscapeNavigationOverlayView.kt @@ -54,7 +54,9 @@ fun LandscapeNavigationOverlayView( Column(modifier = Modifier.fillMaxHeight().fillMaxWidth(0.5f)) { uiState.visualInstruction?.let { instructions -> InstructionsView( - instructions, distanceToNextManeuver = uiState.progress?.distanceToNextManeuver) + instructions, + remainingSteps = uiState.remainingSteps, + distanceToNextManeuver = uiState.progress?.distanceToNextManeuver) } Spacer(modifier = Modifier.weight(1f)) diff --git a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/overlays/PortraitNavigationOverlayView.kt b/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/overlays/PortraitNavigationOverlayView.kt index f378a657..c2bf2fd8 100644 --- a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/overlays/PortraitNavigationOverlayView.kt +++ b/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/overlays/PortraitNavigationOverlayView.kt @@ -57,7 +57,9 @@ fun PortraitNavigationOverlayView( Column(modifier) { uiState.visualInstruction?.let { instructions -> InstructionsView( - instructions, distanceToNextManeuver = uiState.progress?.distanceToNextManeuver) + instructions, + remainingSteps = uiState.remainingSteps, + distanceToNextManeuver = uiState.progress?.distanceToNextManeuver) } val cameraIsTrackingLocation = camera.value.state is CameraState.TrackingUserLocationWithBearing