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/components/MemberList.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/MemberList.kt index e940f40..ba98273 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/components/MemberList.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/components/MemberList.kt @@ -8,17 +8,14 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch +import kotlinx.coroutines.isActive +import kotlin.math.abs /** * Compose function to display a list of member items in a horizontal scrolling row. @@ -26,24 +23,31 @@ import kotlinx.coroutines.launch * **/ @Composable fun MemberList(names: List) { - var scrolled by remember { mutableStateOf(false) } - val listState = rememberLazyListState() - val coroutineScope = rememberCoroutineScope() + val isPreview = LocalInspectionMode.current + val scrollStep = remember(names) { + val seed = names.joinToString(separator = "|").hashCode() + 80f + (abs(seed) % 120).toFloat() + } + val repeatCount = if (isPreview) 1 else 80 + val displayNames = if (names.isEmpty()) emptyList() else List(repeatCount) { names }.flatten() + val startIndex = if (isPreview || displayNames.isEmpty()) 0 else displayNames.size / 2 - // Get the screen density and calculate the scroll distance - val configuration = LocalConfiguration.current - val screenDensity = configuration.densityDpi / 160f - val screenWidth = configuration.screenWidthDp.toFloat() * screenDensity - val scrollDist = ((screenWidth / 2 + (Math.random() * screenWidth)) * .65).toFloat() * 50000 + LaunchedEffect(displayNames, isPreview) { + if (isPreview || displayNames.size <= 1) return@LaunchedEffect - // Calculate the total number of items to be displayed to simulate infinite scrolling - val nameRepeatCount = 1000 - val totalItems = nameRepeatCount * names.size + listState.scrollToItem(startIndex) - // Scroll to the middle of the list on first composition - LaunchedEffect(Unit) { - listState.scrollToItem(totalItems / 2) + while (isActive) { + listState.animateScrollBy( + value = scrollStep, + animationSpec = tween(durationMillis = 1600, easing = LinearEasing) + ) + + if (listState.firstVisibleItemIndex > displayNames.size - 30) { + listState.scrollToItem(startIndex) + } + } } LazyRow( @@ -51,26 +55,15 @@ fun MemberList(names: List) { verticalAlignment = Alignment.CenterVertically, state = listState, ) { - // Trigger animated scroll only once - if (!scrolled) { - coroutineScope.launch { - scrolled = true - listState.animateScrollBy( - scrollDist, - tween(250000000, 0, LinearEasing) - ) - } - } - items(totalItems) { index -> - val memberIndex = index % names.size - MemberItem(name = names[memberIndex]) + items(displayNames.size) { index -> + MemberItem(name = displayNames[index]) } } } @Preview(showBackground = true) @Composable -private fun PreviewMemberList() { +private fun MemberListPreview() { MemberList( names = listOf("Alice", "Bob", "Charlie", "David", "Eve") ) 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/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/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..e0fa4e0 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 @@ -10,9 +10,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,88 +34,19 @@ 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( - "Angelina Chen", - "Asen Ou", - "Jayson Hahn", - "Daniel Chuang", - "William Ma", - "Sergio Diaz", - "Kevin Chan", - "Omar Rasheed", - "Lucy Xu", - "Haiying Weng", - "Daniel Vebman", - "Yana Sang", - "Matt Barker", - "Austin Astorga", - "Monica Ong" - ), - "Android" to listOf( - "Mihili Herath", - "Jonathan Chen", - "Veronica Starchenko", - "Adam Kadhim", - "Lesley Huang", - "Kevin Sun", - "Chris Desir", - "Connor Reinhold", - "Aastha Shah", - "Justin Jiang", - "Haichen Wang", - "Jonvi Rollins", - "Preston Rozwood", - "Ziwei Gu", - "Abdullah Islam" - ), - "Design" to listOf( - "Gillian Fang", - "Leah Kim", - "Amy Ge", - "Lauren Jun", - "Zain Khoja", - "Maggie Ying", - "Femi Badero", - "Maya Frai", - "Mind Apivessa" - ), - "Marketing" to listOf( - "Anvi Savant", - "Christine Tao", - "Luke Stewart", - "Melika Khoshneviszadeh", - "Eddie Chi", - "Neha Malepati", - "Emily Shiang", - "Lucy Zhang", - "Catherine Wei" - ), - "Backend" to listOf( - "Nicole Qiu", - "Daisy Chang", - "Lauren Ah-Hot", - "Maxwell Pang", - "Mateo Weiner", - "Cindy Liang", - "Raahi Menon", - "Kate Liang", - "Alanna Zhou", - "Kevin Chan", - "Nate Schickler" - ) -).entries.shuffled().associate { it.toPair() } +import com.cornellappdev.transit.util.ABOUT_POD_LEADS +import com.cornellappdev.transit.util.ABOUT_TEAM_MEMBERS_BY_TEAM_SHUFFLED /** * 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 +54,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, @@ -179,20 +122,11 @@ fun AboutScreen() { .width(80.dp) ) MemberList( - listOf( - "Anvi Savant", - "Cindy Liang", - "Maxwell Pang", - "Amanda He", - "Connor Reinhold", - "Omar Rasheed", - "Maya Frai", - "Matt Barker" - ) + ABOUT_POD_LEADS ) } - for ((team, members) in names) { + for ((team, members) in ABOUT_TEAM_MEMBERS_BY_TEAM_SHUFFLED) { Row { Text( text = team, @@ -232,6 +166,6 @@ fun AboutScreen() { @Preview(showBackground = true) @Composable -private fun PreviewAboutScreen() { - AboutScreen() +private fun AboutScreenPreview() { + 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 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/ContentConstants.kt b/app/src/main/java/com/cornellappdev/transit/util/ContentConstants.kt index 9f97554..fff127d 100644 --- a/app/src/main/java/com/cornellappdev/transit/util/ContentConstants.kt +++ b/app/src/main/java/com/cornellappdev/transit/util/ContentConstants.kt @@ -1,5 +1,99 @@ package com.cornellappdev.transit.util +val ABOUT_POD_LEADS = listOf( + "Nina Zambrano", + "Anvi Savant", + "Cindy Liang", + "Maxwell Pang", + "Amanda He", + "Connor Reinhold", + "Omar Rasheed", + "Maya Frai", + "Matt Barker" +) + +val ABOUT_TEAM_MEMBERS_BY_TEAM = mapOf( + "iOS" to listOf( + "Gabriel Castillo", + "Isha Nagireddy", + "Angelina Chen", + "Asen Ou", + "Jayson Hahn", + "Daniel Chuang", + "William Ma", + "Sergio Diaz", + "Kevin Chan", + "Omar Rasheed", + "Lucy Xu", + "Haiying Weng", + "Daniel Vebman", + "Yana Sang", + "Matt Barker", + "Austin Astorga", + "Monica Ong" + ), + "Android" to listOf( + "Ryan Cheung", + "Abigail Labanok", + "Mihili Herath", + "Jonathan Chen", + "Veronica Starchenko", + "Adam Kadhim", + "Lesley Huang", + "Kevin Sun", + "Chris Desir", + "Connor Reinhold", + "Aastha Shah", + "Justin Jiang", + "Haichen Wang", + "Jonvi Rollins", + "Preston Rozwood", + "Ziwei Gu", + "Abdullah Islam" + ), + "Design" to listOf( + "Seojin Park", + "Gillian Fang", + "Leah Kim", + "Amy Ge", + "Lauren Jun", + "Zain Khoja", + "Maggie Ying", + "Femi Badero", + "Maya Frai", + "Mind Apivessa" + ), + "Marketing" to listOf( + "Maya Levine", + "Nina Zambrano", + "Anvi Savant", + "Christine Tao", + "Luke Stewart", + "Melika Khoshneviszadeh", + "Eddie Chi", + "Neha Malepati", + "Emily Shiang", + "Lucy Zhang", + "Catherine Wei" + ), + "Backend" to listOf( + "Wyatt Cox", + "Nicole Qiu", + "Daisy Chang", + "Lauren Ah-Hot", + "Maxwell Pang", + "Mateo Weiner", + "Cindy Liang", + "Raahi Menon", + "Kate Liang", + "Alanna Zhou", + "Kevin Chan", + "Nate Schickler" + ) +) + +val ABOUT_TEAM_MEMBERS_BY_TEAM_SHUFFLED = ABOUT_TEAM_MEMBERS_BY_TEAM.entries.shuffled().associate { it.toPair() } + private val ABOUT_CONTENT = mapOf( "Terrace Restaurant" to "The Terrace often features five to six made-to-order options, such as burritos, pho, gyros, and more throughout the day.", "Mac's Café" to "Mac's features grab-and-go deli sandwiches, pizza, chopped salads and healthy smoothies for those looking for a quick bite.", 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..307f33e --- /dev/null +++ b/app/src/main/java/com/cornellappdev/transit/util/RouteOptionsDisplayProcessor.kt @@ -0,0 +1,452 @@ +package com.cornellappdev.transit.util + +import android.util.Log +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 + +/** Log tag for aggregated route-processing diagnostics emitted by this file. */ +private const val ROUTE_OPTIONS_DISPLAY_TAG = "RouteOptionsDisplay" + +/** + * Normalized reasons a route can be treated as invalid/malformed during display processing. + * + * Keys are stable for log aggregation and quick telemetry filtering. + */ +private sealed class RouteProcessingFailure(val key: String) { + object InvalidDirectionStartTime : RouteProcessingFailure("invalid_direction_start_time") + object InvalidDirectionEndTime : RouteProcessingFailure("invalid_direction_end_time") + object InvalidRouteDepartureTime : RouteProcessingFailure("invalid_route_departure_time") + object InvalidRouteArrivalTime : RouteProcessingFailure("invalid_route_arrival_time") + object InvalidDirectionDuration : RouteProcessingFailure("invalid_direction_duration") + object InvalidTripDuration : RouteProcessingFailure("invalid_trip_duration") + object MissingDirectionsForArrivalFallback : RouteProcessingFailure("missing_directions_for_arrival_fallback") + object UnresolvedArrivalTime : RouteProcessingFailure("unresolved_arrival_time") +} + +/** + * Aggregates parsing/validation failures during a single processing pass and logs once at the end. + */ +private class RouteProcessingDiagnostics(private val mode: String) { + private val failureCounts = linkedMapOf() + + fun record(failure: RouteProcessingFailure) { + failureCounts[failure.key] = (failureCounts[failure.key] ?: 0) + 1 + } + + fun logIfAny() { + if (failureCounts.isEmpty()) return + val details = failureCounts.entries.joinToString { (key, count) -> "$key=$count" } + Log.w(ROUTE_OPTIONS_DISPLAY_TAG, "mode=$mode dropped routes due to malformed timing data: $details") + } +} + +/** Parse an ISO-8601 instant string and report parse failures while preserving null-on-failure behavior. */ +private fun String.toInstantOrNull( + diagnostics: RouteProcessingDiagnostics? = null, + failure: RouteProcessingFailure, +): Instant? { + return runCatching { Instant.parse(this) } + .getOrElse { + diagnostics?.record(failure) + null + } +} + +/** Logical source section used to flatten and later reconstruct [RouteOptions]. */ +private enum class RouteSection { + FROM_STOP, + BOARDING_SOON, + WALKING, +} + +/** Route paired with its originating display section while processing pipelines run. */ +private data class SectionedRoute( + val section: RouteSection, + val route: Route, +) + +/** Time-and-distance effort tuple used for walking-vs-transit preference comparisons. */ +private data class RouteEffort( + val duration: Duration, + val distance: Double, +) + +/** 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( + diagnostics: RouteProcessingDiagnostics? = null, +): Instant? { + val firstDepartDirection = directions.firstOrNull { it.type == DirectionType.DEPART } ?: return null + val scheduledStart = firstDepartDirection.startTime.toInstantOrNull( + diagnostics = diagnostics, + failure = RouteProcessingFailure.InvalidDirectionStartTime, + ) ?: 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( + diagnostics: RouteProcessingDiagnostics? = null, +): 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( + diagnostics = diagnostics, + failure = RouteProcessingFailure.InvalidDirectionStartTime, + ) ?: return null + val end = direction.endTime.toInstantOrNull( + diagnostics = diagnostics, + failure = RouteProcessingFailure.InvalidDirectionEndTime, + ) ?: return null + val segmentDuration = Duration.between(start, end) + if (segmentDuration.isNegative) { + diagnostics?.record(RouteProcessingFailure.InvalidDirectionDuration) + 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, + diagnostics: RouteProcessingDiagnostics? = null, +): Boolean { + val firstBoardingDeparture = firstBoardingDepartureInstantOrNull(diagnostics) + + // Only walking-only routes can treat missing boarding departure as legal. + if (firstBoardingDeparture == null) { + return !isTransitRoute() + } + + val initialWalkingDuration = walkingDurationBeforeFirstBoardingOrNull(diagnostics) ?: 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( + diagnostics: RouteProcessingDiagnostics? = null, +): Instant? { + return firstBoardingDepartureInstantOrNull(diagnostics) ?: departureTime.toInstantOrNull( + diagnostics = diagnostics, + failure = RouteProcessingFailure.InvalidRouteDepartureTime, + ) +} + +/** Effective leave instant from user perspective; falls back to cutoff for walking-only routes. */ +private fun Route.effectiveLeaveInstantOrNull( + cutoff: Instant, + diagnostics: RouteProcessingDiagnostics? = null, +): Instant? { + return firstBoardingDepartureInstantOrNull(diagnostics) ?: 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, + diagnostics: RouteProcessingDiagnostics? = null, +): Instant? { + // Use route start time for horizon checks so initial walking/waiting doesn't over-prune options. + return if (isTransitRoute()) { + departureTime.toInstantOrNull( + diagnostics = diagnostics, + failure = RouteProcessingFailure.InvalidRouteDepartureTime, + ) ?: effectiveLeaveInstantOrNull(cutoff, diagnostics) + } else { + cutoff + } +} + +/** Compute effort tuple used for walking-vs-transit preference decisions. */ +private fun Route.effortOrNull( + diagnostics: RouteProcessingDiagnostics? = null, +): RouteEffort? { + val departureInstant = departureTime.toInstantOrNull( + diagnostics = diagnostics, + failure = RouteProcessingFailure.InvalidRouteDepartureTime, + ) ?: return null + val arrivalInstant = arrivalTime.toInstantOrNull( + diagnostics = diagnostics, + failure = RouteProcessingFailure.InvalidRouteArrivalTime, + ) ?: return null + val tripDuration = Duration.between(departureInstant, arrivalInstant) + if (tripDuration.isNegative) { + diagnostics?.record(RouteProcessingFailure.InvalidTripDuration) + 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( + diagnostics: RouteProcessingDiagnostics? = null, +): Instant? { + val routeArrival = arrivalTime.toInstantOrNull( + diagnostics = diagnostics, + failure = RouteProcessingFailure.InvalidRouteArrivalTime, + ) + if (routeArrival != null) return routeArrival + + val lastDirection = directions.lastOrNull() + if (lastDirection == null) { + diagnostics?.record(RouteProcessingFailure.MissingDirectionsForArrivalFallback) + diagnostics?.record(RouteProcessingFailure.UnresolvedArrivalTime) + return null + } + + val endInstant = lastDirection.endTime.toInstantOrNull( + diagnostics = diagnostics, + failure = RouteProcessingFailure.InvalidDirectionEndTime, + ) + if (endInstant == null) { + diagnostics?.record(RouteProcessingFailure.UnresolvedArrivalTime) + 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, + diagnostics: RouteProcessingDiagnostics? = null, +): Boolean { + val arrivalInstant = effectiveArrivalInstantOrNull(diagnostics) ?: 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(failure = RouteProcessingFailure.InvalidRouteDepartureTime) + val rightDeparture = right.departureTime.toInstantOrNull(failure = RouteProcessingFailure.InvalidRouteDepartureTime) + + 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, + diagnostics: RouteProcessingDiagnostics? = null, +): Boolean { + val routeDeparture = horizonReferenceInstantOrNull(cutoff, diagnostics) ?: 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, + diagnostics: RouteProcessingDiagnostics? = null, +): Boolean { + val transitEffort = effortOrNull(diagnostics) ?: 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, + diagnostics: RouteProcessingDiagnostics? = null, +): List? { + if (this == null) return null + + val cutoffWithGrace = cutoff.plus(Duration.ofMinutes(ARRIVE_BY_CUTOFF_GRACE_MINUTES)) + + return this + .filter { route -> route.arrivesBy(cutoffWithGrace, diagnostics) } + .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 { + val diagnostics = RouteProcessingDiagnostics(mode = "arrive_by") + + val processed = RouteOptions( + fromStop = fromStop.filterAndSortRoutesForArriveBy(cutoff, diagnostics), + boardingSoon = boardingSoon.filterAndSortRoutesForArriveBy(cutoff, diagnostics), + walking = walking.filterAndSortRoutesForArriveBy(cutoff, diagnostics) + ) + diagnostics.logIfAny() + + return processed +} + +/** + * 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 diagnostics = RouteProcessingDiagnostics(mode = "leave_cutoff") + val horizonEnd = cutoff.plus(Duration.ofMinutes(horizonMinutes)) + val earliestAllowed = cutoff.minus(Duration.ofMinutes(LEAVE_CUTOFF_GRACE_MINUTES)) + + // Cache per-route transit status so we do not rescan directions in downstream filters. + data class LeaveCandidate( + val sectionedRoute: SectionedRoute, + val isTransit: Boolean, + ) + + val eligibleCandidates = flattenBySection() + .filter { (_, route) -> route.isLegalForLeaveCutoff(cutoff, diagnostics) } + .filter { (_, route) -> route.isWithinLeaveHorizon(cutoff, earliestAllowed, horizonEnd, diagnostics) } + .map { sectionedRoute -> + LeaveCandidate( + sectionedRoute = sectionedRoute, + isTransit = sectionedRoute.route.isTransitRoute(), + ) + } + + val bestWalkingEffort = eligibleCandidates + .filter { !it.isTransit } + .mapNotNull { it.sectionedRoute.route.effortOrNull(diagnostics) } + .minWithOrNull(compareBy { it.duration }.thenBy { it.distance }) + + val tieBuffer = Duration.ofMinutes(walkingTransitTieMinutes) + + val preferred = eligibleCandidates + .filter { candidate -> + if (!candidate.isTransit) return@filter true + + val walkingEffort = bestWalkingEffort ?: return@filter true + candidate.sectionedRoute.route.isPreferredTransitAgainstWalking(walkingEffort, tieBuffer, diagnostics) + } + + val fallbackTransit = if (preferred.any { it.isTransit }) { + null + } else { + eligibleCandidates + .asSequence() + .filter { it.isTransit } + .minByOrNull { it.sectionedRoute.route.effectiveLeaveInstantOrNull(cutoff, diagnostics) ?: Instant.MAX } + } + + val ranked = (if (fallbackTransit != null) preferred + fallbackTransit else preferred) + .sortedWith { left, right -> + compareByEffectiveLeaveTime(left.sectionedRoute, right.sectionedRoute, cutoff) + } + + 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.sectionedRoute, right.sectionedRoute, cutoff) + } + } else { + initialSlice + } + } else { + ranked + } + + diagnostics.logIfAny() + + return finalRoutes.map { it.sectionedRoute }.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 +