From adf50ab22e2ac726508f7eef058e39c4f85c79f3 Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Tue, 21 Apr 2026 00:07:37 -0400 Subject: [PATCH 1/3] fix: remove centering location button and add back buttons to settings screens. update contributor information --- .../transit/ui/NavigationController.kt | 12 ++- .../transit/ui/screens/HomeScreen.kt | 7 +- .../transit/ui/screens/SettingsScreen.kt | 101 ++++++++++-------- .../ui/screens/settings/AboutScreen.kt | 29 ++++- .../ui/screens/settings/SupportScreen.kt | 17 ++- 5 files changed, 112 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/transit/ui/NavigationController.kt b/app/src/main/java/com/cornellappdev/transit/ui/NavigationController.kt index e7fd22a..2d13caa 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/NavigationController.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/NavigationController.kt @@ -45,13 +45,15 @@ fun NavigationController( composable("settings") { SettingsScreen ( - onAboutClick = {navController.navigateSingleTop("about")}, - onSupportClick = {navController.navigateSingleTop("support")}, - onNotificationsAndPrivacyClick = {navController.navigateSingleTop("notifs_privacy")}) + onAboutClick = { navController.navigateSingleTop("about") }, + onSupportClick = { navController.navigateSingleTop("support") }, + onNotificationsAndPrivacyClick = { navController.navigateSingleTop("notifs_privacy") }, + onBackClick = { navController.popBackStack() } + ) } composable("about") { - AboutScreen() + AboutScreen(onBackClick = { navController.popBackStack() }) } composable("notifs_privacy") { @@ -69,7 +71,7 @@ fun NavigationController( } composable("support") { - SupportScreen() + SupportScreen(onBackClick = { navController.popBackStack() }) } } } diff --git a/app/src/main/java/com/cornellappdev/transit/ui/screens/HomeScreen.kt b/app/src/main/java/com/cornellappdev/transit/ui/screens/HomeScreen.kt index fc82a8e..53e84c0 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/screens/HomeScreen.kt @@ -237,7 +237,11 @@ fun HomeScreen( properties = MapProperties( isMyLocationEnabled = permissionState.status.isGranted ), - uiSettings = MapUiSettings(zoomControlsEnabled = false, mapToolbarEnabled = false), + uiSettings = MapUiSettings( + zoomControlsEnabled = false, + mapToolbarEnabled = false, + myLocationButtonEnabled = false + ), contentPadding = PaddingValues( bottom = filterSheetState.sheetVisibleHeightDp.orZeroIfUnspecified() ) @@ -615,4 +619,3 @@ private fun HomeScreenSearchBar( } } - diff --git a/app/src/main/java/com/cornellappdev/transit/ui/screens/SettingsScreen.kt b/app/src/main/java/com/cornellappdev/transit/ui/screens/SettingsScreen.kt index 8ead3cf..c9fb109 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/screens/SettingsScreen.kt @@ -6,23 +6,27 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowLeft import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.navigation.NavController import com.cornellappdev.transit.R import com.cornellappdev.transit.ui.components.SettingsPageItem +import com.cornellappdev.transit.ui.theme.IconGray import com.cornellappdev.transit.ui.theme.TransitBlue import com.cornellappdev.transit.ui.theme.robotoFamily import com.cornellappdev.transit.util.NOTIFICATIONS_ENABLED @@ -31,52 +35,65 @@ import com.cornellappdev.transit.util.NOTIFICATIONS_ENABLED * Composable for Settings Screen, which displays a list of settings options and app information. * **/ @Composable -fun SettingsScreen(onSupportClick: () -> Unit, - onAboutClick: () -> Unit, - onNotificationsAndPrivacyClick: () -> Unit ){ - Column( - modifier = Modifier - .fillMaxSize() - .padding(8.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) - { - Text( - text = "Settings", - fontSize = 32.sp, - modifier = Modifier.padding(top = 16.dp, start = 16.dp), - fontWeight = FontWeight.Bold, - fontFamily = robotoFamily, - color = TransitBlue, - ) - - SettingsPageItem( - name = stringResource(R.string.about_text), - description = "Learn more about the team behind the app", - icon = R.drawable.appdev_gray, - onClick = onAboutClick) - HorizontalDivider(thickness = 0.5.dp) +fun SettingsScreen( + onSupportClick: () -> Unit, + onAboutClick: () -> Unit, + onNotificationsAndPrivacyClick: () -> Unit, + onBackClick: () -> Unit +) { + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp) + .padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowLeft, + contentDescription = "Go back", + tint = IconGray, + modifier = Modifier.size(24.dp) + ) + } - // Disable Notifications until implemented + Text( + text = "Settings", + fontSize = 32.sp, + modifier = Modifier.padding(start = 16.dp), + fontWeight = FontWeight.Bold, + fontFamily = robotoFamily, + color = TransitBlue, + ) - if (NOTIFICATIONS_ENABLED) { SettingsPageItem( - name = "Notifications and Privacy", - description = "Manage permissions and analytics", - icon = R.drawable.lock, - onClick = onNotificationsAndPrivacyClick + name = stringResource(R.string.about_text), + description = "Learn more about the team behind the app", + icon = R.drawable.appdev_gray, + onClick = onAboutClick ) HorizontalDivider(thickness = 0.5.dp) + + // Disable Notifications until implemented + if (NOTIFICATIONS_ENABLED) { + SettingsPageItem( + name = "Notifications and Privacy", + description = "Manage permissions and analytics", + icon = R.drawable.lock, + onClick = onNotificationsAndPrivacyClick + ) + HorizontalDivider(thickness = 0.5.dp) + } + + SettingsPageItem( + name = "Support", + description = "Report issues and contact us", + icon = R.drawable.help_outline, + onClick = onSupportClick + ) } - SettingsPageItem( - name = "Support", - description = "Report issues and contact us", - icon = R.drawable.help_outline, - onClick = onSupportClick - ) - } - Box(modifier = Modifier.fillMaxSize()) { Image( painter = painterResource(R.drawable.clock_tower), contentDescription = "Clock Tower", @@ -94,5 +111,5 @@ fun SettingsScreen(onSupportClick: () -> Unit, @Preview(showBackground = true) @Composable private fun SettingsScreenPreview() { - SettingsScreen({}, {}, {}) + SettingsScreen({}, {}, {}, {}) } diff --git a/app/src/main/java/com/cornellappdev/transit/ui/screens/settings/AboutScreen.kt b/app/src/main/java/com/cornellappdev/transit/ui/screens/settings/AboutScreen.kt index 7cfad68..739cd14 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/screens/settings/AboutScreen.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/screens/settings/AboutScreen.kt @@ -2,6 +2,7 @@ package com.cornellappdev.transit.ui.screens.settings import android.content.Intent import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -10,9 +11,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowLeft import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -31,11 +35,14 @@ import androidx.compose.ui.unit.sp import androidx.core.net.toUri import com.cornellappdev.transit.R import com.cornellappdev.transit.ui.components.MemberList +import com.cornellappdev.transit.ui.theme.IconGray import com.cornellappdev.transit.ui.theme.TransitBlue import com.cornellappdev.transit.ui.theme.robotoFamily private val names = mapOf( "iOS" to listOf( + "Gabriel Castillo", + "Isha Nagireddy", "Angelina Chen", "Asen Ou", "Jayson Hahn", @@ -53,6 +60,8 @@ private val names = mapOf( "Monica Ong" ), "Android" to listOf( + "Ryan Cheung", + "Abigail Labanok", "Mihili Herath", "Jonathan Chen", "Veronica Starchenko", @@ -70,6 +79,7 @@ private val names = mapOf( "Abdullah Islam" ), "Design" to listOf( + "Seojin Park", "Gillian Fang", "Leah Kim", "Amy Ge", @@ -81,6 +91,8 @@ private val names = mapOf( "Mind Apivessa" ), "Marketing" to listOf( + "Maya Levine", + "Nina Zambrano", "Anvi Savant", "Christine Tao", "Luke Stewart", @@ -92,6 +104,7 @@ private val names = mapOf( "Catherine Wei" ), "Backend" to listOf( + "Wyatt Cox", "Nicole Qiu", "Daisy Chang", "Lauren Ah-Hot", @@ -110,9 +123,9 @@ private val names = mapOf( * Composable for the About Screen of the app, which displays information about team behind it. */ @Composable -fun AboutScreen() { +fun AboutScreen(onBackClick: () -> Unit) { val context = LocalContext.current - + Column( modifier = Modifier .fillMaxSize() @@ -120,6 +133,15 @@ fun AboutScreen() { ) { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowLeft, + contentDescription = "Go back", + tint = IconGray, + modifier = Modifier.size(24.dp) + ) + } + Text( text = stringResource(R.string.about_text), fontSize = 32.sp, @@ -180,6 +202,7 @@ fun AboutScreen() { ) MemberList( listOf( + "Nina Zambrano", "Anvi Savant", "Cindy Liang", "Maxwell Pang", @@ -233,5 +256,5 @@ fun AboutScreen() { @Preview(showBackground = true) @Composable private fun PreviewAboutScreen() { - AboutScreen() + AboutScreen {} } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/transit/ui/screens/settings/SupportScreen.kt b/app/src/main/java/com/cornellappdev/transit/ui/screens/settings/SupportScreen.kt index a3c1c28..dde4ec2 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/screens/settings/SupportScreen.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/screens/settings/SupportScreen.kt @@ -8,9 +8,12 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowLeft import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -25,6 +28,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.net.toUri import com.cornellappdev.transit.R +import com.cornellappdev.transit.ui.theme.IconGray import com.cornellappdev.transit.ui.theme.TransitBlue import com.cornellappdev.transit.ui.theme.robotoFamily @@ -33,7 +37,7 @@ import com.cornellappdev.transit.ui.theme.robotoFamily * report issues. */ @Composable -fun SupportScreen() { +fun SupportScreen(onBackClick: () -> Unit) { val context = LocalContext.current Column( modifier = Modifier @@ -42,6 +46,15 @@ fun SupportScreen() { verticalArrangement = Arrangement.spacedBy(8.dp) ) { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowLeft, + contentDescription = "Go back", + tint = IconGray, + modifier = Modifier.size(24.dp) + ) + } + Text( text = "Support", fontSize = 32.sp, @@ -112,5 +125,5 @@ fun SupportScreen() { @Preview(showBackground = true) @Composable fun SupportScreenPreview() { - SupportScreen() + SupportScreen {} } \ No newline at end of file From 98a7a1d2c9baca1ceceb44d5c0944f2ca1b22304 Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Thu, 23 Apr 2026 00:53:15 -0400 Subject: [PATCH 2/3] fix: process routes for arrive by / leave at to show more relevant results --- .../transit/ui/screens/RouteScreen.kt | 2 +- .../transit/ui/viewmodels/RouteViewModel.kt | 30 ++ .../util/RouteOptionsDisplayProcessor.kt | 303 ++++++++++++++++++ .../transit/util/TransitConstants.kt | 14 +- 4 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/cornellappdev/transit/util/RouteOptionsDisplayProcessor.kt diff --git a/app/src/main/java/com/cornellappdev/transit/ui/screens/RouteScreen.kt b/app/src/main/java/com/cornellappdev/transit/ui/screens/RouteScreen.kt index 2873705..07922fa 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/screens/RouteScreen.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/screens/RouteScreen.kt @@ -108,7 +108,7 @@ fun RouteScreen( val keyboardController = LocalSoftwareKeyboardController.current - val lastRoute = routeViewModel.lastRouteFlow.collectAsState().value + val lastRoute = routeViewModel.displayedRouteFlow.collectAsState().value val startSheetState = rememberModalBottomSheetState( diff --git a/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/RouteViewModel.kt b/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/RouteViewModel.kt index 3f9044d..5dc606a 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/RouteViewModel.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/RouteViewModel.kt @@ -16,7 +16,10 @@ import com.cornellappdev.transit.models.SelectedRouteRepository import com.cornellappdev.transit.models.UserPreferenceRepository import com.cornellappdev.transit.models.search.UnifiedSearchRepository import com.cornellappdev.transit.networking.ApiResponse +import com.cornellappdev.transit.util.LEAVE_AT_MAX_DISPLAYED_ROUTES import com.cornellappdev.transit.util.TimeUtils +import com.cornellappdev.transit.util.filterAndSortForArriveBy +import com.cornellappdev.transit.util.filterAndSortForLeaveCutoff import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.LatLngBounds import dagger.hilt.android.lifecycle.HiltViewModel @@ -97,6 +100,33 @@ class RouteViewModel @Inject constructor( val lastRouteFlow: StateFlow> = routeRepository.lastRouteFlow + val displayedRouteFlow: StateFlow> = + combine(lastRouteFlow, arriveByFlow) { routeResponse, arriveByState -> + if (routeResponse is ApiResponse.Success) { + val processed = when (arriveByState) { + is ArriveByUIState.ArriveBy -> routeResponse.data.filterAndSortForArriveBy( + arriveByState.date.toInstant() + ) + + is ArriveByUIState.LeaveAt -> routeResponse.data.filterAndSortForLeaveCutoff( + cutoff = arriveByState.date.toInstant(), + maxRoutes = LEAVE_AT_MAX_DISPLAYED_ROUTES + ) + + is ArriveByUIState.LeaveNow -> routeResponse.data.filterAndSortForLeaveCutoff( + cutoff = Instant.now() + ) + } + ApiResponse.Success(processed) + } else { + routeResponse + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ApiResponse.Pending + ) + /** * Default map location */ diff --git a/app/src/main/java/com/cornellappdev/transit/util/RouteOptionsDisplayProcessor.kt b/app/src/main/java/com/cornellappdev/transit/util/RouteOptionsDisplayProcessor.kt new file mode 100644 index 0000000..b54600f --- /dev/null +++ b/app/src/main/java/com/cornellappdev/transit/util/RouteOptionsDisplayProcessor.kt @@ -0,0 +1,303 @@ +package com.cornellappdev.transit.util + +import com.cornellappdev.transit.models.DirectionType +import com.cornellappdev.transit.models.Route +import com.cornellappdev.transit.models.RouteOptions +import java.time.Duration +import java.time.Instant + +/** Parse an ISO-8601 instant string and return null when parsing fails. */ +private fun String.toInstantOrNull(): Instant? = runCatching { Instant.parse(this) }.getOrNull() + +private enum class RouteSection { + FROM_STOP, + BOARDING_SOON, + WALKING, +} + +private data class SectionedRoute( + val section: RouteSection, + val route: Route, +) + +/** Flatten sectioned route options into a single list while preserving section labels. */ +private fun RouteOptions.flattenBySection(): List { + val routes = mutableListOf() + fromStop?.forEach { routes += SectionedRoute(RouteSection.FROM_STOP, it) } + boardingSoon?.forEach { routes += SectionedRoute(RouteSection.BOARDING_SOON, it) } + walking?.forEach { routes += SectionedRoute(RouteSection.WALKING, it) } + return routes +} + +/** Rebuild sectioned [RouteOptions] from a flattened list of routes tagged with section metadata. */ +private fun List.toRouteOptions(): RouteOptions { + val fromStopRoutes = filter { it.section == RouteSection.FROM_STOP }.map { it.route } + val boardingSoonRoutes = filter { it.section == RouteSection.BOARDING_SOON }.map { it.route } + val walkingRoutes = filter { it.section == RouteSection.WALKING }.map { it.route } + + return RouteOptions( + fromStop = fromStopRoutes.takeIf { it.isNotEmpty() }, + boardingSoon = boardingSoonRoutes.takeIf { it.isNotEmpty() }, + walking = walkingRoutes.takeIf { it.isNotEmpty() }, + ) +} + +/** + * Effective first bus departure for a route, including positive delay on the first DEPART segment. + * Returns null for walking-only routes or malformed timestamps. + */ +private fun Route.firstBoardingDepartureInstantOrNull(): Instant? { + val firstDepartDirection = directions.firstOrNull { it.type == DirectionType.DEPART } ?: return null + val scheduledStart = firstDepartDirection.startTime.toInstantOrNull() ?: return null + val delaySeconds = (firstDepartDirection.delay ?: 0).coerceAtLeast(0) + return scheduledStart.plusSeconds(delaySeconds.toLong()) +} + +/** + * Total duration of directions before the first DEPART segment. + * Returns [Duration.ZERO] when route starts with transit or has no DEPART segment. + */ +private fun Route.walkingDurationBeforeFirstBoardingOrNull(): Duration? { + val firstDepartIndex = directions.indexOfFirst { it.type == DirectionType.DEPART } + if (firstDepartIndex <= 0) return Duration.ZERO + + var total = Duration.ZERO + for (direction in directions.take(firstDepartIndex)) { + val start = direction.startTime.toInstantOrNull() ?: return null + val end = direction.endTime.toInstantOrNull() ?: return null + val segmentDuration = Duration.between(start, end) + if (segmentDuration.isNegative) return null + total = total.plus(segmentDuration) + } + return total +} + +/** + * Leave-mode legality check: + * - walking-only routes are always legal to start at cutoff, + * - transit routes are legal only if user can complete initial walking before first boarding. + */ +private fun Route.isLegalForLeaveCutoff(cutoff: Instant): Boolean { + val firstBoardingDeparture = firstBoardingDepartureInstantOrNull() + + // Walking-only routes can always start at the chosen leave cutoff. + if (firstBoardingDeparture == null) { + return true + } + + val initialWalkingDuration = walkingDurationBeforeFirstBoardingOrNull() ?: return false + val readyToBoardAt = cutoff.plus(initialWalkingDuration) + + // Strictly before departure to avoid showing routes where the bus leaves as walking completes. + return readyToBoardAt.isBefore(firstBoardingDeparture) +} + +/** Departure instant used to rank leave-mode routes. */ +private fun Route.leaveRankingDepartureInstantOrNull(): Instant? { + return firstBoardingDepartureInstantOrNull() ?: departureTime.toInstantOrNull() +} + +/** Effective leave instant from user perspective; falls back to cutoff for walking-only routes. */ +private fun Route.effectiveLeaveInstantOrNull(cutoff: Instant): Instant? { + return firstBoardingDepartureInstantOrNull() ?: cutoff +} + +/** + * Instant used for leave-mode horizon bounds. + * For transit, prefer route start time; for walking-only routes, use the user cutoff. + */ +private fun Route.horizonReferenceInstantOrNull(cutoff: Instant): Instant? { + // Use route start time for horizon checks so initial walking/waiting doesn't over-prune options. + return if (isTransitRoute()) { + departureTime.toInstantOrNull() ?: effectiveLeaveInstantOrNull(cutoff) + } else { + cutoff + } +} + +private data class RouteEffort( + val duration: Duration, + val distance: Double, +) + +/** Compute effort tuple used for walking-vs-transit preference decisions. */ +private fun Route.effortOrNull(): RouteEffort? { + val departureInstant = departureTime.toInstantOrNull() ?: return null + val arrivalInstant = arrivalTime.toInstantOrNull() ?: return null + val tripDuration = Duration.between(departureInstant, arrivalInstant) + if (tripDuration.isNegative) return null + return RouteEffort(duration = tripDuration, distance = travelDistance) +} + +/** True when route contains at least one DEPART segment. */ +private fun Route.isTransitRoute(): Boolean = directions.any { it.type == DirectionType.DEPART } + +/** + * Best-effort arrival instant for Arrive By checks. + * Uses top-level route arrival first, then falls back to last direction endTime (+delay). + */ +private fun Route.effectiveArrivalInstantOrNull(): Instant? { + val routeArrival = arrivalTime.toInstantOrNull() + if (routeArrival != null) return routeArrival + + val lastDirection = directions.lastOrNull() ?: return null + val endInstant = lastDirection.endTime.toInstantOrNull() ?: return null + val delaySeconds = (lastDirection.delay ?: 0).coerceAtLeast(0) + return endInstant.plusSeconds(delaySeconds.toLong()) +} + +/** True when route arrives on or before provided cutoff (including grace already applied by caller). */ +private fun Route.arrivesBy(cutoffWithGrace: Instant): Boolean { + val arrivalInstant = effectiveArrivalInstantOrNull() ?: return false + return !arrivalInstant.isAfter(cutoffWithGrace) +} + +/** Comparator for Arrive By ordering: latest departure first, then shorter distance. */ +private fun compareArriveByRoutes(left: Route, right: Route): Int { + val leftDeparture = left.departureTime.toInstantOrNull() + val rightDeparture = right.departureTime.toInstantOrNull() + + return when { + leftDeparture == null && rightDeparture == null -> 0 + leftDeparture == null -> 1 + rightDeparture == null -> -1 + else -> { + val departureCompare = rightDeparture.compareTo(leftDeparture) + if (departureCompare != 0) departureCompare else left.travelDistance.compareTo(right.travelDistance) + } + } +} + +/** Check whether a route's horizon reference departure is inside the allowed leave window. */ +private fun Route.isWithinLeaveHorizon( + cutoff: Instant, + earliestAllowed: Instant, + horizonEnd: Instant, +): Boolean { + val routeDeparture = horizonReferenceInstantOrNull(cutoff) ?: return false + return !routeDeparture.isBefore(earliestAllowed) && !routeDeparture.isAfter(horizonEnd) +} + +/** + * Transit preference predicate when walking alternatives exist. + * Transit is preferred if it is significantly faster, or not slower while being shorter distance. + */ +private fun Route.isPreferredTransitAgainstWalking( + walkingEffort: RouteEffort, + tieBuffer: Duration, +): Boolean { + val transitEffort = effortOrNull() ?: return false + + val transitSignificantlyFaster = + transitEffort.duration.plus(tieBuffer).compareTo(walkingEffort.duration) < 0 + val transitNotSlowerAndShorter = + transitEffort.duration.compareTo(walkingEffort.duration.plus(tieBuffer)) <= 0 && + transitEffort.distance < walkingEffort.distance + + // Prefer transit only when it offers better effort (time and/or distance) than walking. + return transitSignificantlyFaster || transitNotSlowerAndShorter +} + +/** Comparator for leave-mode ordering: earliest effective leave time first. */ +private fun compareByEffectiveLeaveTime( + left: SectionedRoute, + right: SectionedRoute, + cutoff: Instant, +): Int { + val leftDeparture = left.route.effectiveLeaveInstantOrNull(cutoff) + val rightDeparture = right.route.effectiveLeaveInstantOrNull(cutoff) + return when { + leftDeparture == null && rightDeparture == null -> 0 + leftDeparture == null -> 1 + rightDeparture == null -> -1 + else -> leftDeparture.compareTo(rightDeparture) + } +} + +/** + * Section-level Arrive By filtering and ordering. + * Keeps routes that arrive by cutoff (with grace), then sorts by latest departure first. + */ +private fun List?.filterAndSortRoutesForArriveBy(cutoff: Instant): List? { + if (this == null) return null + + val cutoffWithGrace = cutoff.plus(Duration.ofMinutes(ARRIVE_BY_CUTOFF_GRACE_MINUTES)) + + return this + .filter { route -> route.arrivesBy(cutoffWithGrace) } + .sortedWith(::compareArriveByRoutes) +} + +/** + * Apply Arrive By policy per section. + * Routes are filtered by arrival cutoff and sorted within each section. + */ +fun RouteOptions.filterAndSortForArriveBy(cutoff: Instant): RouteOptions { + return RouteOptions( + fromStop = fromStop.filterAndSortRoutesForArriveBy(cutoff), + boardingSoon = boardingSoon.filterAndSortRoutesForArriveBy(cutoff), + walking = walking.filterAndSortRoutesForArriveBy(cutoff) + ) +} + +/** + * Apply leave-mode display policy. + * + * Pipeline: + * 1) flatten sections, + * 2) filter to legal routes, + * 3) filter to configured horizon window, + * 4) prefer transit only when effort beats walking alternatives, + * 5) ensure at least one transit fallback when legal transit exists, + * 6) rank by effective leave instant, + * 7) optionally cap route count and rehydrate sections. + */ +fun RouteOptions.filterAndSortForLeaveCutoff( + cutoff: Instant, + maxRoutes: Int? = null, + horizonMinutes: Long = LEAVE_CUTOFF_HORIZON_MINUTES, + walkingTransitTieMinutes: Long = WALKING_TRANSIT_TIE_MINUTES, +): RouteOptions { + val horizonEnd = cutoff.plus(Duration.ofMinutes(horizonMinutes)) + val earliestAllowed = cutoff.minus(Duration.ofMinutes(LEAVE_CUTOFF_GRACE_MINUTES)) + + val eligibleRoutes = flattenBySection() + .filter { (_, route) -> route.isLegalForLeaveCutoff(cutoff) } + .filter { (_, route) -> route.isWithinLeaveHorizon(cutoff, earliestAllowed, horizonEnd) } + + val bestWalkingEffort = eligibleRoutes + .map { it.route } + .filter { !it.isTransitRoute() } + .mapNotNull { it.effortOrNull() } + .minWithOrNull(compareBy { it.duration }.thenBy { it.distance }) + + val tieBuffer = Duration.ofMinutes(walkingTransitTieMinutes) + + val preferred = eligibleRoutes + .filter { (_, route) -> + if (!route.isTransitRoute()) return@filter true + + val walkingEffort = bestWalkingEffort ?: return@filter true + route.isPreferredTransitAgainstWalking(walkingEffort, tieBuffer) + } + + val fallbackTransit = if (preferred.any { it.route.isTransitRoute() }) { + null + } else { + eligibleRoutes + .asSequence() + .filter { it.route.isTransitRoute() } + .minByOrNull { it.route.effectiveLeaveInstantOrNull(cutoff) ?: Instant.MAX } + } + + val ranked = (if (fallbackTransit != null) preferred + fallbackTransit else preferred) + .sortedWith { left, right -> compareByEffectiveLeaveTime(left, right, cutoff) } + + return (if (maxRoutes != null) ranked.take(maxRoutes) else ranked).toRouteOptions() +} + + + + + + diff --git a/app/src/main/java/com/cornellappdev/transit/util/TransitConstants.kt b/app/src/main/java/com/cornellappdev/transit/util/TransitConstants.kt index 4173664..54ba13f 100644 --- a/app/src/main/java/com/cornellappdev/transit/util/TransitConstants.kt +++ b/app/src/main/java/com/cornellappdev/transit/util/TransitConstants.kt @@ -14,4 +14,16 @@ const val HIGH_CAPACITY_THRESHOLD = 0.65f const val NOTIFICATIONS_ENABLED = false -const val METERS_TO_FEET = 3.28084 \ No newline at end of file +const val METERS_TO_FEET = 3.28084 + +const val LEAVE_AT_MAX_DISPLAYED_ROUTES = 3 + +const val LEAVE_CUTOFF_HORIZON_MINUTES = 45L + +const val LEAVE_CUTOFF_GRACE_MINUTES = 2L + +const val ARRIVE_BY_CUTOFF_GRACE_MINUTES = 2L + +// Hide transit options when walking arrives at the same time or sooner (+ tie buffer). +const val WALKING_TRANSIT_TIE_MINUTES = 1L + From f5aa428c882d49cf99c6341bd75de07a3624de6e Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Thu, 23 Apr 2026 02:32:22 -0400 Subject: [PATCH 3/3] fix: address coderabbit comments, fix fallback result handling --- .../util/RouteOptionsDisplayProcessor.kt | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/transit/util/RouteOptionsDisplayProcessor.kt b/app/src/main/java/com/cornellappdev/transit/util/RouteOptionsDisplayProcessor.kt index b54600f..7855a4a 100644 --- a/app/src/main/java/com/cornellappdev/transit/util/RouteOptionsDisplayProcessor.kt +++ b/app/src/main/java/com/cornellappdev/transit/util/RouteOptionsDisplayProcessor.kt @@ -80,9 +80,9 @@ private fun Route.walkingDurationBeforeFirstBoardingOrNull(): Duration? { private fun Route.isLegalForLeaveCutoff(cutoff: Instant): Boolean { val firstBoardingDeparture = firstBoardingDepartureInstantOrNull() - // Walking-only routes can always start at the chosen leave cutoff. + // Only walking-only routes can treat missing boarding departure as legal. if (firstBoardingDeparture == null) { - return true + return !isTransitRoute() } val initialWalkingDuration = walkingDurationBeforeFirstBoardingOrNull() ?: return false @@ -293,7 +293,19 @@ fun RouteOptions.filterAndSortForLeaveCutoff( val ranked = (if (fallbackTransit != null) preferred + fallbackTransit else preferred) .sortedWith { left, right -> compareByEffectiveLeaveTime(left, right, cutoff) } - return (if (maxRoutes != null) ranked.take(maxRoutes) else ranked).toRouteOptions() + val finalRoutes = if (maxRoutes != null) { + val initialSlice = ranked.take(maxRoutes) + if (fallbackTransit != null && maxRoutes > 0 && fallbackTransit !in initialSlice) { + (initialSlice.dropLast(1) + fallbackTransit) + .sortedWith { left, right -> compareByEffectiveLeaveTime(left, right, cutoff) } + } else { + initialSlice + } + } else { + ranked + } + + return finalRoutes.toRouteOptions() }