From e7c1ecc94f66e0070ab67d02c945ef1ad4b20010 Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Sun, 15 Mar 2026 01:52:24 -0400 Subject: [PATCH 01/11] feat: add ecosystem places to search area --- .../transit/models/RouteRepository.kt | 23 +++- .../ui/components/LoadingLocationItems.kt | 12 +- .../transit/ui/components/MenuItem.kt | 65 +++++++---- .../ui/components/SearchSuggestions.kt | 5 +- .../home/EcosystemBottomSheetContent.kt | 45 +++++++- .../transit/ui/screens/HomeScreen.kt | 4 +- .../transit/ui/screens/RouteScreen.kt | 2 + .../transit/ui/viewmodels/HomeViewModel.kt | 79 +++++++++++-- .../transit/ui/viewmodels/RouteViewModel.kt | 78 +++++++++---- .../util/ecosystem/PlaceSearchMerge.kt | 104 ++++++++++++++++++ 10 files changed, 354 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/com/cornellappdev/transit/util/ecosystem/PlaceSearchMerge.kt diff --git a/app/src/main/java/com/cornellappdev/transit/models/RouteRepository.kt b/app/src/main/java/com/cornellappdev/transit/models/RouteRepository.kt index 29d2163b..3f55066b 100644 --- a/app/src/main/java/com/cornellappdev/transit/models/RouteRepository.kt +++ b/app/src/main/java/com/cornellappdev/transit/models/RouteRepository.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import javax.inject.Singleton @@ -60,6 +61,9 @@ class RouteRepository @Inject constructor( private val _libraryFlow: MutableStateFlow>> = MutableStateFlow(ApiResponse.Pending) + // Monotonic token used to ensure only the latest search request updates placeFlow. + private val latestSearchToken = AtomicLong(0L) + init { fetchAllStops() if (ECOSYSTEM_FLAG) { @@ -142,15 +146,30 @@ class RouteRepository @Inject constructor( * Makes a new call to places related to a query string. */ fun makeSearch(query: String) { + val token = latestSearchToken.incrementAndGet() + + if (query.isBlank()) { + if (token == latestSearchToken.get()) { + _placeFlow.value = ApiResponse.Success(emptyList()) + } + return + } + _placeFlow.value = ApiResponse.Pending CoroutineScope(Dispatchers.IO).launch { try { val placeResponse = appleSearch(SearchQuery(query)) val res = placeResponse.unwrap() val totalLocations = (res.places ?: emptyList()) + (res.stops ?: (emptyList())) - _placeFlow.value = ApiResponse.Success(totalLocations) + if (token == latestSearchToken.get()) { + _placeFlow.value = ApiResponse.Success(totalLocations) + } + } catch (_: kotlinx.coroutines.CancellationException) { + // Ignore cancellation; latest query owns the flow update. } catch (e: Exception) { - _placeFlow.value = ApiResponse.Error + if (token == latestSearchToken.get()) { + _placeFlow.value = ApiResponse.Error + } } } } diff --git a/app/src/main/java/com/cornellappdev/transit/ui/components/LoadingLocationItems.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/LoadingLocationItems.kt index 61deeda6..6565079a 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/components/LoadingLocationItems.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/components/LoadingLocationItems.kt @@ -2,7 +2,11 @@ package com.cornellappdev.transit.ui.components import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import com.cornellappdev.transit.models.Place import com.cornellappdev.transit.networking.ApiResponse @@ -25,7 +29,10 @@ fun LoadingLocationItems(searchResult: ApiResponse>, onClick: (Place if (searchResult.data.isEmpty()) { LocationNotFound() } else { - LazyColumn { + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { items( searchResult.data ) { @@ -33,7 +40,8 @@ fun LoadingLocationItems(searchResult: ApiResponse>, onClick: (Place type = it.type, label = it.name, sublabel = it.subLabel, - onClick = { onClick(it) } + onClick = { onClick(it) }, + modifier = Modifier ) } } diff --git a/app/src/main/java/com/cornellappdev/transit/ui/components/MenuItem.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/MenuItem.kt index ab5d4936..6c5b3508 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/components/MenuItem.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/components/MenuItem.kt @@ -6,7 +6,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -15,6 +15,8 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Spacer import com.cornellappdev.transit.R import com.cornellappdev.transit.models.PlaceType import com.cornellappdev.transit.ui.theme.PrimaryText @@ -23,33 +25,33 @@ import com.cornellappdev.transit.ui.theme.Style /** * Card for each entry in the search bar - * @param icon The icon for the item + * @param type The place type used to resolve icon * @param label The label for the item * @param sublabel The sublabel for the item */ @Composable -fun MenuItem(type: PlaceType, label: String, sublabel: String, onClick: () -> Unit) { +fun MenuItem( + type: PlaceType, + label: String, + sublabel: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val displayedSublabel = ecosystemSublabelFor(type) ?: sublabel + Row( - Modifier + modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp) .clickable(onClick = onClick), verticalAlignment = Alignment.CenterVertically ) { - //TODO: Add icons for each ecosystem type - if (type == PlaceType.APPLE_PLACE) { - Image( - painterResource(R.drawable.location_pin_gray), - contentDescription = "Place", - modifier = Modifier.padding(end = 20.dp), - ) - } else { - Image( - painterResource(R.drawable.bus_stop_pin), - contentDescription = "Stop", - modifier = Modifier.padding(end = 20.dp), - ) - } + Image( + painterResource(iconForPlaceType(type)), + contentDescription = type.name, + modifier = Modifier + .size(24.dp), + ) + Spacer(modifier = Modifier.size(12.dp)) Column() { Text( text = label, @@ -59,7 +61,7 @@ fun MenuItem(type: PlaceType, label: String, sublabel: String, onClick: () -> Un style = Style.heading3 ) Text( - text = sublabel, + text = displayedSublabel, color = SecondaryText, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -69,6 +71,27 @@ fun MenuItem(type: PlaceType, label: String, sublabel: String, onClick: () -> Un } } +private fun ecosystemSublabelFor(type: PlaceType): String? = when (type) { + PlaceType.EATERY -> "Eatery" + PlaceType.LIBRARY -> "Library" + PlaceType.PRINTER -> "Printer" + PlaceType.GYM -> "Gym" + else -> null +} + +private val searchIconByPlaceType = mapOf( + PlaceType.BUS_STOP to R.drawable.bus_stop_pin, + PlaceType.APPLE_PLACE to R.drawable.location_pin_gray, + PlaceType.EATERY to R.drawable.eatery_icon, + PlaceType.LIBRARY to R.drawable.library_icon, + PlaceType.GYM to R.drawable.gym_icon, + PlaceType.PRINTER to R.drawable.printer_icon +) + +@DrawableRes +private fun iconForPlaceType(type: PlaceType): Int { + return searchIconByPlaceType[type] ?: R.drawable.location_pin_gray +} @Preview(showBackground = true) @Composable @@ -79,5 +102,5 @@ private fun PreviewMenuItemBusStop() { @Preview(showBackground = true) @Composable private fun PreviewMenuItemApplePlace() { - MenuItem(PlaceType.APPLE_PLACE, "Apple Place", "Ithaca, NY", {}) + MenuItem(PlaceType.EATERY, "Apple Place", "Ithaca, NY", {}) } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/transit/ui/components/SearchSuggestions.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/SearchSuggestions.kt index a68449d0..880ac7aa 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/components/SearchSuggestions.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/components/SearchSuggestions.kt @@ -10,10 +10,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.navigation.NavController import com.cornellappdev.transit.models.Place -import com.cornellappdev.transit.ui.viewmodels.LocationUIState -import com.google.android.gms.maps.model.LatLng /** * Display for suggested searches (recents and favorites) @@ -43,6 +40,7 @@ fun SearchSuggestions( type = it.type, label = it.name, sublabel = it.subLabel, + modifier = Modifier.padding(vertical = 6.dp), onClick = { onItemClick(it) } @@ -59,6 +57,7 @@ fun SearchSuggestions( type = it.type, label = it.name, sublabel = it.subLabel, + modifier = Modifier.padding(vertical = 6.dp), onClick = { onItemClick(it) } diff --git a/app/src/main/java/com/cornellappdev/transit/ui/components/home/EcosystemBottomSheetContent.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/home/EcosystemBottomSheetContent.kt index 7c636d36..17004235 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/components/home/EcosystemBottomSheetContent.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/components/home/EcosystemBottomSheetContent.kt @@ -249,7 +249,6 @@ private fun BottomSheetFilteredContent( FilterState.LIBRARIES -> { libraryList( staticPlaces, - navigateToPlace, onDetailsClick, favorites, onFavoriteStarClick, @@ -430,6 +429,7 @@ private fun LazyListScope.gymList( ) { when (gymsApiResponse) { is ApiResponse.Error -> { + infoItem("Unable to load gyms") } is ApiResponse.Pending -> { @@ -439,6 +439,11 @@ private fun LazyListScope.gymList( } is ApiResponse.Success -> { + if (gymsApiResponse.data.isEmpty()) { + infoItem("No gyms available") + return + } + items(gymsApiResponse.data) { RoundedImagePlaceCard( imageUrl = it.imageUrl, @@ -478,12 +483,21 @@ private fun LazyListScope.printerList( ) { when (staticPlaces.printers) { is ApiResponse.Error -> { + infoItem("Unable to load printers") } is ApiResponse.Pending -> { + item { + CenteredSpinningIndicator() + } } is ApiResponse.Success -> { + if (staticPlaces.printers.data.isEmpty()) { + infoItem("No printers available") + return + } + items(staticPlaces.printers.data) { val place = it.toPlace() val alert = if (it.location.contains("*")) { @@ -529,6 +543,7 @@ private fun LazyListScope.eateryList( ) { when (eateriesApiResponse) { is ApiResponse.Error -> { + infoItem("Unable to load eateries") } is ApiResponse.Pending -> { @@ -538,6 +553,11 @@ private fun LazyListScope.eateryList( } is ApiResponse.Success -> { + if (eateriesApiResponse.data.isEmpty()) { + infoItem("No eateries available") + return + } + items(eateriesApiResponse.data) { RoundedImagePlaceCard( imageUrl = it.imageUrl, @@ -565,7 +585,6 @@ private fun LazyListScope.eateryList( */ private fun LazyListScope.libraryList( staticPlaces: StaticPlaces, - navigateToPlace: (Place) -> Unit, navigateToDetails: (DetailedEcosystemPlace) -> Unit, favorites: Set, onFavoriteStarClick: (Place) -> Unit, @@ -573,12 +592,21 @@ private fun LazyListScope.libraryList( ) { when (staticPlaces.libraries) { is ApiResponse.Error -> { + infoItem("Unable to load libraries") } is ApiResponse.Pending -> { + item { + CenteredSpinningIndicator() + } } is ApiResponse.Success -> { + if (staticPlaces.libraries.data.isEmpty()) { + infoItem("No libraries available") + return + } + items(staticPlaces.libraries.data) { RoundedImagePlaceCard( placeholderRes = R.drawable.olin_library, @@ -596,6 +624,19 @@ private fun LazyListScope.libraryList( } } +private fun LazyListScope.infoItem(message: String) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 20.dp), + horizontalArrangement = Arrangement.Center + ) { + Text(text = message) + } + } +} + @Composable private fun StandardCard( place: Place, 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 f3aa71d8..d74df943 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 @@ -178,8 +178,8 @@ fun HomeScreen( // Add search bar val addSearchBarValue = homeViewModel.addSearchQuery.collectAsStateWithLifecycle().value - // Add search bar query response - val placeQueryResponse = homeViewModel.placeQueryFlow.collectAsStateWithLifecycle().value + // Add search bar query response (backend + ecosystem, ranked by relevance) + val placeQueryResponse = homeViewModel.addSearchResultsFlow.collectAsStateWithLifecycle().value val filterStateValue = homeViewModel.filterState.collectAsStateWithLifecycle().value 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 c6d87166..d63f201a 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 @@ -814,6 +814,7 @@ private fun RouteOptionsSearchSheet( type = it.type, label = it.name, sublabel = it.subLabel, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), onClick = { if (isStart) { routeViewModel.setStartPlace( @@ -847,6 +848,7 @@ private fun RouteOptionsSearchSheet( type = it.type, label = it.name, sublabel = it.subLabel, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), onClick = { if (isStart) { routeViewModel.setStartPlace( diff --git a/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt b/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt index 00ba1f60..10c39d26 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt @@ -19,6 +19,8 @@ import com.cornellappdev.transit.models.ecosystem.UpliftGym import com.cornellappdev.transit.networking.ApiResponse import com.cornellappdev.transit.util.StringUtils.fromMetersToMiles import com.cornellappdev.transit.util.calculateDistance +import com.cornellappdev.transit.util.ecosystem.buildEcosystemSearchPlaces +import com.cornellappdev.transit.util.ecosystem.mergeAndRankSearchResults import com.google.android.gms.maps.model.LatLng import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -52,7 +55,8 @@ class HomeViewModel @Inject constructor( /** * The current query in the add favorites search bar, as a StateFlow */ - val addSearchQuery: MutableStateFlow = MutableStateFlow("") + private val _addSearchQuery: MutableStateFlow = MutableStateFlow("") + val addSearchQuery: StateFlow = _addSearchQuery.asStateFlow() /** * The list of queried places retrieved from the route repository, as a StateFlow. @@ -79,7 +83,9 @@ class HomeViewModel @Inject constructor( FilterState.PRINTERS ) - val filterState: MutableStateFlow = MutableStateFlow(FilterState.FAVORITES) + private val _filterState: MutableStateFlow = + MutableStateFlow(FilterState.FAVORITES) + val filterState: StateFlow = _filterState.asStateFlow() private val _showFilterSheet = MutableStateFlow(false) val showFilterSheet: StateFlow = _showFilterSheet.asStateFlow() @@ -112,9 +118,45 @@ class HomeViewModel @Inject constructor( ) ) + private val ecosystemSearchPlacesFlow: StateFlow> = + combine( + routeRepository.printerFlow, + routeRepository.libraryFlow, + eateryRepository.eateryFlow, + gymRepository.gymFlow + ) { printers, libraries, eateries, gyms -> + buildEcosystemSearchPlaces( + printers = printers, + libraries = libraries, + eateries = eateries, + gyms = gyms + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList() + ) + private val _showAddFavoritesSheet = MutableStateFlow(false) val showAddFavoritesSheet: StateFlow = _showAddFavoritesSheet.asStateFlow() + val addSearchResultsFlow: StateFlow>> = + combine( + _addSearchQuery, + placeQueryFlow, + ecosystemSearchPlacesFlow + ) { query, routeSearchResults, ecosystemPlaces -> + mergeAndRankSearchResults( + query = query, + routeSearchResults = routeSearchResults, + ecosystemPlaces = ecosystemPlaces + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ApiResponse.Success(emptyList()) + ) + fun toggleAddFavoritesSheet(show: Boolean) { _showAddFavoritesSheet.value = show } @@ -221,12 +263,23 @@ class HomeViewModel @Inject constructor( } }.launchIn(viewModelScope) - routeRepository.placeFlow.onEach { - if (_searchBarUiState.value is SearchBarUIState.Query) { - _searchBarUiState.value = - (_searchBarUiState.value as SearchBarUIState.Query).copy( - searched = it - ) + combine( + searchBarUiState + .filterIsInstance() + .map { it.queryText } + .distinctUntilChanged(), + placeQueryFlow, + ecosystemSearchPlacesFlow + ) { query, routeSearchResults, ecosystemPlaces -> + query to mergeAndRankSearchResults( + query = query, + routeSearchResults = routeSearchResults, + ecosystemPlaces = ecosystemPlaces + ) + }.onEach { (query, mergedResults) -> + val currentState = _searchBarUiState.value + if (currentState is SearchBarUIState.Query && currentState.queryText == query) { + _searchBarUiState.value = currentState.copy(searched = mergedResults) } }.launchIn(viewModelScope) @@ -239,7 +292,11 @@ class HomeViewModel @Inject constructor( routeRepository.makeSearch(it) }.launchIn(viewModelScope) - addSearchQuery.debounce(300L).distinctUntilChanged().onEach { + _addSearchQuery.debounce(300L) + .map { it.trim() } + .distinctUntilChanged() + .filter { it.isNotEmpty() } + .onEach { routeRepository.makeSearch(it) }.launchIn(viewModelScope) } @@ -271,7 +328,7 @@ class HomeViewModel @Inject constructor( * Change the query in the add favorites search bar and update search results */ fun onAddQueryChange(query: String) { - addSearchQuery.value = query + _addSearchQuery.value = query } /** @@ -374,7 +431,7 @@ class HomeViewModel @Inject constructor( * Set the filter selected on the bottom sheet for categories of places */ fun setCategoryFilter(filterState: FilterState) { - this.filterState.value = filterState + _filterState.value = filterState } /** 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 097598a4..b9f88c1b 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 @@ -1,7 +1,5 @@ package com.cornellappdev.transit.ui.viewmodels -import android.content.Context -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -10,39 +8,46 @@ import androidx.lifecycle.viewModelScope import com.cornellappdev.transit.models.DirectionType import com.cornellappdev.transit.models.LocationRepository import com.cornellappdev.transit.models.MapState +import com.cornellappdev.transit.models.Place import com.cornellappdev.transit.models.Route import com.cornellappdev.transit.models.RouteOptions import com.cornellappdev.transit.models.RouteRepository import com.cornellappdev.transit.models.SelectedRouteRepository import com.cornellappdev.transit.models.UserPreferenceRepository +import com.cornellappdev.transit.models.ecosystem.EateryRepository +import com.cornellappdev.transit.models.ecosystem.GymRepository import com.cornellappdev.transit.networking.ApiResponse import com.cornellappdev.transit.util.TimeUtils +import com.cornellappdev.transit.util.ecosystem.buildEcosystemSearchPlaces +import com.cornellappdev.transit.util.ecosystem.mergeAndRankSearchResults import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.LatLngBounds import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.time.Instant import java.util.Date import javax.inject.Inject -import kotlin.math.pow @HiltViewModel @OptIn(FlowPreview::class) class RouteViewModel @Inject constructor( private val routeRepository: RouteRepository, private val locationRepository: LocationRepository, + private val eateryRepository: EateryRepository, + private val gymRepository: GymRepository, private val userPreferenceRepository: UserPreferenceRepository, private val selectedRouteRepository: SelectedRouteRepository ) : ViewModel() { @@ -60,9 +65,10 @@ class RouteViewModel @Inject constructor( /** * State of the arriveBy selector */ - val arriveByFlow: MutableStateFlow = MutableStateFlow( + private val _arriveByFlow: MutableStateFlow = MutableStateFlow( ArriveByUIState.LeaveNow() ) + val arriveByFlow: StateFlow = _arriveByFlow.asStateFlow() /** * State of date picker @@ -100,21 +106,23 @@ class RouteViewModel @Inject constructor( /** * Emits whether a route should be showing on the map */ - val mapState: MutableStateFlow = + private val _mapState: MutableStateFlow = MutableStateFlow( MapState( isShowing = false, route = null ) ) + val mapState: StateFlow = _mapState.asStateFlow() /** * Emits details of a route */ - val detailsState: MutableStateFlow> = + private val _detailsState: MutableStateFlow> = MutableStateFlow( emptyList() ) + val detailsState: StateFlow> = _detailsState.asStateFlow() /** * The current UI state of the search bar, as a MutableStateFlow @@ -123,6 +131,25 @@ class RouteViewModel @Inject constructor( MutableStateFlow(SearchBarUIState.RecentAndFavorites(emptySet(), emptyList())) val searchBarUiState: StateFlow = _searchBarUiState.asStateFlow() + private val ecosystemSearchPlacesFlow: StateFlow> = + combine( + routeRepository.printerFlow, + routeRepository.libraryFlow, + eateryRepository.eateryFlow, + gymRepository.gymFlow + ) { printers, libraries, eateries, gyms -> + buildEcosystemSearchPlaces( + printers = printers, + libraries = libraries, + eateries = eateries, + gyms = gyms + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList() + ) + init { userPreferenceRepository.favoritesFlow.onEach { if (_searchBarUiState.value is SearchBarUIState.RecentAndFavorites) { @@ -142,27 +169,38 @@ class RouteViewModel @Inject constructor( } }.launchIn(viewModelScope) - routeRepository.placeFlow.onEach { - if (_searchBarUiState.value is SearchBarUIState.Query) { - _searchBarUiState.value = - (_searchBarUiState.value as SearchBarUIState.Query).copy( - searched = it - ) + combine( + searchBarUiState + .filterIsInstance() + .map { it.queryText } + .distinctUntilChanged(), + routeRepository.placeFlow, + ecosystemSearchPlacesFlow + ) { query, routeSearchResults, ecosystemPlaces -> + query to mergeAndRankSearchResults( + query = query, + routeSearchResults = routeSearchResults, + ecosystemPlaces = ecosystemPlaces + ) + }.onEach { (query, mergedResults) -> + val currentState = _searchBarUiState.value + if (currentState is SearchBarUIState.Query && currentState.queryText == query) { + _searchBarUiState.value = currentState.copy(searched = mergedResults) } }.launchIn(viewModelScope) - combine(selectedRoute, arriveByFlow) { startAndEnd, arriveBy -> + combine(selectedRoute, _arriveByFlow) { startAndEnd, arriveBy -> val startState = startAndEnd.startPlace val endState = startAndEnd.endPlace getLatestOptions(startState, endState, arriveBy) }.launchIn(viewModelScope) - mapState.onEach { + _mapState.onEach { if (it.route == null) { - detailsState.value = emptyList() + _detailsState.value = emptyList() } else { - detailsState.value = it.route.directions.map { direction -> + _detailsState.value = it.route.directions.map { direction -> DirectionDetails( startTime = TimeUtils.getHHMM( direction.startTime @@ -279,14 +317,14 @@ class RouteViewModel @Inject constructor( * Change the arriveBy parameter for routes */ fun changeArriveBy(arriveBy: ArriveByUIState) { - arriveByFlow.value = arriveBy + _arriveByFlow.value = arriveBy } /** * Set map state for home screen */ fun setMapState(value: MapState) { - mapState.value = value + _mapState.value = value } /** @@ -363,7 +401,7 @@ class RouteViewModel @Inject constructor( getLatestOptions( selectedRoute.value.startPlace, selectedRoute.value.endPlace, - arriveByFlow.value + _arriveByFlow.value ) } diff --git a/app/src/main/java/com/cornellappdev/transit/util/ecosystem/PlaceSearchMerge.kt b/app/src/main/java/com/cornellappdev/transit/util/ecosystem/PlaceSearchMerge.kt new file mode 100644 index 00000000..dd07488a --- /dev/null +++ b/app/src/main/java/com/cornellappdev/transit/util/ecosystem/PlaceSearchMerge.kt @@ -0,0 +1,104 @@ +package com.cornellappdev.transit.util.ecosystem + +import com.cornellappdev.transit.models.Place +import com.cornellappdev.transit.models.ecosystem.Eatery +import com.cornellappdev.transit.models.ecosystem.Library +import com.cornellappdev.transit.models.ecosystem.Printer +import com.cornellappdev.transit.models.ecosystem.UpliftGym +import com.cornellappdev.transit.networking.ApiResponse + +private data class RankedPlace( + val place: Place, + val score: Int, +) + +private const val NO_MATCH_SCORE = Int.MAX_VALUE +private const val BACKEND_FALLBACK_SCORE = 4 + +fun buildEcosystemSearchPlaces( + printers: ApiResponse>, + libraries: ApiResponse>, + eateries: ApiResponse>, + gyms: ApiResponse>, +): List { + val printerPlaces = (printers as? ApiResponse.Success)?.data.orEmpty().map { it.toPlace() } + val libraryPlaces = (libraries as? ApiResponse.Success)?.data.orEmpty().map { it.toPlace() } + val eateryPlaces = (eateries as? ApiResponse.Success)?.data.orEmpty().map { it.toPlace() } + val gymPlaces = (gyms as? ApiResponse.Success)?.data.orEmpty().map { it.toPlace() } + + return (printerPlaces + libraryPlaces + eateryPlaces + gymPlaces) + .distinctBy { placeSearchStableId(it) } +} + +fun mergeAndRankSearchResults( + query: String, + routeSearchResults: ApiResponse>, + ecosystemPlaces: List, +): ApiResponse> { + val normalizedQuery = query.trim().lowercase() + if (normalizedQuery.isEmpty()) { + return ApiResponse.Success(emptyList()) + } + + val routePlaces = (routeSearchResults as? ApiResponse.Success)?.data.orEmpty() + val backendRanked = routePlaces.map { place -> + val score = relevanceScore(place, normalizedQuery) + RankedPlace( + place = place, + score = if (score == NO_MATCH_SCORE) BACKEND_FALLBACK_SCORE else score + ) + } + + val ecosystemRanked = ecosystemPlaces + .map { place -> RankedPlace(place = place, score = relevanceScore(place, normalizedQuery)) } + .filter { it.score != NO_MATCH_SCORE } + + val dedupedRanked = mutableMapOf() + (backendRanked + ecosystemRanked).forEach { rankedPlace -> + val key = placeSearchStableId(rankedPlace.place) + val current = dedupedRanked[key] + if (current == null || rankedPlace.score < current.score) { + dedupedRanked[key] = rankedPlace + } + } + + val merged = dedupedRanked.values + .sortedWith( + compareBy({ it.score }, { it.place.name.length }, { it.place.name }) + ) + .map { it.place } + + if (merged.isNotEmpty()) { + return ApiResponse.Success(merged) + } + + return when (routeSearchResults) { + is ApiResponse.Pending -> ApiResponse.Pending + is ApiResponse.Error -> ApiResponse.Error + is ApiResponse.Success -> ApiResponse.Success(emptyList()) + } +} + +private fun relevanceScore(place: Place, normalizedQuery: String): Int { + val name = place.name.lowercase() + val detail = place.detail.orEmpty().lowercase() + + return when { + name.startsWith(normalizedQuery) -> 0 + name.contains(normalizedQuery) -> 1 + detail.startsWith(normalizedQuery) -> 2 + detail.contains(normalizedQuery) -> 3 + else -> NO_MATCH_SCORE + } +} + +private fun placeSearchStableId(place: Place): String { + return listOf( + place.type.name, + place.name, + place.detail.orEmpty(), + place.latitude.toString(), + place.longitude.toString() + ).joinToString("|") +} + From 5acb9a3b4f572fa0728808a1f2aa8e874d0e6962 Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Wed, 18 Mar 2026 01:46:25 -0400 Subject: [PATCH 02/11] fix: add min size to menuitem icon --- .../java/com/cornellappdev/transit/ui/components/MenuItem.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/cornellappdev/transit/ui/components/MenuItem.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/MenuItem.kt index 6c5b3508..165c912d 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/components/MenuItem.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/components/MenuItem.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize import com.cornellappdev.transit.R import com.cornellappdev.transit.models.PlaceType import com.cornellappdev.transit.ui.theme.PrimaryText @@ -42,6 +43,7 @@ fun MenuItem( Row( modifier .fillMaxWidth() + .defaultMinSize(minHeight = 36.dp) .clickable(onClick = onClick), verticalAlignment = Alignment.CenterVertically ) { From 8f357d95ebfc691cde1c81146c6d298c4583bd87 Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Wed, 18 Mar 2026 02:09:30 -0400 Subject: [PATCH 03/11] fix: refactor old implementation and move shared logic into one repository used in both home screen and route screen --- .../models/search/UnifiedSearchRepository.kt | 66 +++++++++++++++++++ .../transit/ui/screens/RouteScreen.kt | 6 ++ .../transit/ui/viewmodels/HomeViewModel.kt | 61 ++++++----------- .../transit/ui/viewmodels/RouteViewModel.kt | 64 +++++++----------- 4 files changed, 117 insertions(+), 80 deletions(-) create mode 100644 app/src/main/java/com/cornellappdev/transit/models/search/UnifiedSearchRepository.kt diff --git a/app/src/main/java/com/cornellappdev/transit/models/search/UnifiedSearchRepository.kt b/app/src/main/java/com/cornellappdev/transit/models/search/UnifiedSearchRepository.kt new file mode 100644 index 00000000..38ce6095 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/transit/models/search/UnifiedSearchRepository.kt @@ -0,0 +1,66 @@ +package com.cornellappdev.transit.models.search + +import com.cornellappdev.transit.models.Place +import com.cornellappdev.transit.models.RouteRepository +import com.cornellappdev.transit.models.ecosystem.EateryRepository +import com.cornellappdev.transit.models.ecosystem.GymRepository +import com.cornellappdev.transit.networking.ApiResponse +import com.cornellappdev.transit.util.ecosystem.buildEcosystemSearchPlaces +import com.cornellappdev.transit.util.ecosystem.mergeAndRankSearchResults +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Centralized search data source that merges route search results with ecosystem places. + * + * UI and ViewModels can share this data pipeline to keep search behavior consistent. + */ +@Singleton +class UnifiedSearchRepository @Inject constructor( + private val routeRepository: RouteRepository, + eateryRepository: EateryRepository, + gymRepository: GymRepository, + coroutineScope: CoroutineScope, +) { + + val ecosystemSearchPlacesFlow: StateFlow> = + combine( + routeRepository.printerFlow, + routeRepository.libraryFlow, + eateryRepository.eateryFlow, + gymRepository.gymFlow + ) { printers, libraries, eateries, gyms -> + buildEcosystemSearchPlaces( + printers = printers, + libraries = libraries, + eateries = eateries, + gyms = gyms + ) + }.stateIn( + scope = coroutineScope, + started = SharingStarted.Eagerly, + initialValue = emptyList() + ) + + fun mergedSearchResults(queryFlow: Flow): Flow>> = + combine( + queryFlow, + routeRepository.placeFlow, + ecosystemSearchPlacesFlow + ) { query, routeSearchResults, ecosystemPlaces -> + mergeAndRankSearchResults( + query = query, + routeSearchResults = routeSearchResults, + ecosystemPlaces = ecosystemPlaces + ) + } +} + + + 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 d63f201a..0bad3549 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 @@ -109,6 +109,9 @@ fun RouteScreen( skipHalfExpanded = true, confirmValueChange = { keyboardController?.hide() + if (it == ModalBottomSheetValue.Hidden) { + routeViewModel.onQueryChange("") + } true } ) @@ -119,6 +122,9 @@ fun RouteScreen( skipHalfExpanded = true, confirmValueChange = { keyboardController?.hide() + if (it == ModalBottomSheetValue.Hidden) { + routeViewModel.onQueryChange("") + } true } ) diff --git a/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt b/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt index 10c39d26..d39564a8 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt @@ -10,6 +10,7 @@ import com.cornellappdev.transit.models.RouteRepository import com.cornellappdev.transit.models.SelectedRouteRepository import com.cornellappdev.transit.models.ecosystem.StaticPlaces import com.cornellappdev.transit.models.UserPreferenceRepository +import com.cornellappdev.transit.models.search.UnifiedSearchRepository import com.cornellappdev.transit.models.ecosystem.Eatery import com.cornellappdev.transit.models.ecosystem.EateryRepository import com.cornellappdev.transit.models.ecosystem.GymRepository @@ -19,8 +20,6 @@ import com.cornellappdev.transit.models.ecosystem.UpliftGym import com.cornellappdev.transit.networking.ApiResponse import com.cornellappdev.transit.util.StringUtils.fromMetersToMiles import com.cornellappdev.transit.util.calculateDistance -import com.cornellappdev.transit.util.ecosystem.buildEcosystemSearchPlaces -import com.cornellappdev.transit.util.ecosystem.mergeAndRankSearchResults import com.google.android.gms.maps.model.LatLng import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -48,6 +47,7 @@ class HomeViewModel @Inject constructor( private val locationRepository: LocationRepository, private val eateryRepository: EateryRepository, private val gymRepository: GymRepository, + private val unifiedSearchRepository: UnifiedSearchRepository, private val userPreferenceRepository: UserPreferenceRepository, private val selectedRouteRepository: SelectedRouteRepository ) : ViewModel() { @@ -118,40 +118,30 @@ class HomeViewModel @Inject constructor( ) ) - private val ecosystemSearchPlacesFlow: StateFlow> = - combine( - routeRepository.printerFlow, - routeRepository.libraryFlow, - eateryRepository.eateryFlow, - gymRepository.gymFlow - ) { printers, libraries, eateries, gyms -> - buildEcosystemSearchPlaces( - printers = printers, - libraries = libraries, - eateries = eateries, - gyms = gyms - ) - }.stateIn( + private val homeQueryFlow: StateFlow = searchBarUiState + .filterIsInstance() + .map { it.queryText } + .distinctUntilChanged() + .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = emptyList() + initialValue = "" ) + private val mergedHomeSearchResultsFlow: StateFlow>> = + unifiedSearchRepository.mergedSearchResults(homeQueryFlow) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ApiResponse.Success(emptyList()) + ) + private val _showAddFavoritesSheet = MutableStateFlow(false) val showAddFavoritesSheet: StateFlow = _showAddFavoritesSheet.asStateFlow() val addSearchResultsFlow: StateFlow>> = - combine( - _addSearchQuery, - placeQueryFlow, - ecosystemSearchPlacesFlow - ) { query, routeSearchResults, ecosystemPlaces -> - mergeAndRankSearchResults( - query = query, - routeSearchResults = routeSearchResults, - ecosystemPlaces = ecosystemPlaces - ) - }.stateIn( + unifiedSearchRepository.mergedSearchResults(_addSearchQuery) + .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = ApiResponse.Success(emptyList()) @@ -263,19 +253,8 @@ class HomeViewModel @Inject constructor( } }.launchIn(viewModelScope) - combine( - searchBarUiState - .filterIsInstance() - .map { it.queryText } - .distinctUntilChanged(), - placeQueryFlow, - ecosystemSearchPlacesFlow - ) { query, routeSearchResults, ecosystemPlaces -> - query to mergeAndRankSearchResults( - query = query, - routeSearchResults = routeSearchResults, - ecosystemPlaces = ecosystemPlaces - ) + combine(homeQueryFlow, mergedHomeSearchResultsFlow) { query, mergedResults -> + query to mergedResults }.onEach { (query, mergedResults) -> val currentState = _searchBarUiState.value if (currentState is SearchBarUIState.Query && currentState.queryText == query) { 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 b9f88c1b..ca4f08b1 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 @@ -14,12 +14,9 @@ import com.cornellappdev.transit.models.RouteOptions import com.cornellappdev.transit.models.RouteRepository import com.cornellappdev.transit.models.SelectedRouteRepository import com.cornellappdev.transit.models.UserPreferenceRepository -import com.cornellappdev.transit.models.ecosystem.EateryRepository -import com.cornellappdev.transit.models.ecosystem.GymRepository +import com.cornellappdev.transit.models.search.UnifiedSearchRepository import com.cornellappdev.transit.networking.ApiResponse import com.cornellappdev.transit.util.TimeUtils -import com.cornellappdev.transit.util.ecosystem.buildEcosystemSearchPlaces -import com.cornellappdev.transit.util.ecosystem.mergeAndRankSearchResults import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.LatLngBounds import dagger.hilt.android.lifecycle.HiltViewModel @@ -31,7 +28,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -46,8 +43,7 @@ import javax.inject.Inject class RouteViewModel @Inject constructor( private val routeRepository: RouteRepository, private val locationRepository: LocationRepository, - private val eateryRepository: EateryRepository, - private val gymRepository: GymRepository, + private val unifiedSearchRepository: UnifiedSearchRepository, private val userPreferenceRepository: UserPreferenceRepository, private val selectedRouteRepository: SelectedRouteRepository ) : ViewModel() { @@ -131,25 +127,28 @@ class RouteViewModel @Inject constructor( MutableStateFlow(SearchBarUIState.RecentAndFavorites(emptySet(), emptyList())) val searchBarUiState: StateFlow = _searchBarUiState.asStateFlow() - private val ecosystemSearchPlacesFlow: StateFlow> = - combine( - routeRepository.printerFlow, - routeRepository.libraryFlow, - eateryRepository.eateryFlow, - gymRepository.gymFlow - ) { printers, libraries, eateries, gyms -> - buildEcosystemSearchPlaces( - printers = printers, - libraries = libraries, - eateries = eateries, - gyms = gyms - ) - }.stateIn( + private val routeQueryFlow: StateFlow = searchBarUiState + .map { state -> + when (state) { + is SearchBarUIState.Query -> state.queryText + is SearchBarUIState.RecentAndFavorites -> "" + } + } + .distinctUntilChanged() + .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = emptyList() + initialValue = "" ) + private val mergedRouteSearchResultsFlow: StateFlow>> = + unifiedSearchRepository.mergedSearchResults(routeQueryFlow) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ApiResponse.Success(emptyList()) + ) + init { userPreferenceRepository.favoritesFlow.onEach { if (_searchBarUiState.value is SearchBarUIState.RecentAndFavorites) { @@ -169,19 +168,8 @@ class RouteViewModel @Inject constructor( } }.launchIn(viewModelScope) - combine( - searchBarUiState - .filterIsInstance() - .map { it.queryText } - .distinctUntilChanged(), - routeRepository.placeFlow, - ecosystemSearchPlacesFlow - ) { query, routeSearchResults, ecosystemPlaces -> - query to mergeAndRankSearchResults( - query = query, - routeSearchResults = routeSearchResults, - ecosystemPlaces = ecosystemPlaces - ) + combine(routeQueryFlow, mergedRouteSearchResultsFlow) { query, mergedResults -> + query to mergedResults }.onEach { (query, mergedResults) -> val currentState = _searchBarUiState.value if (currentState is SearchBarUIState.Query && currentState.queryText == query) { @@ -241,11 +229,9 @@ class RouteViewModel @Inject constructor( }.launchIn(viewModelScope) - searchBarUiState + routeQueryFlow .debounce(300L) - .filterIsInstance() - .map { it.queryText } - .distinctUntilChanged() + .filter { it.isNotBlank() } .onEach { routeRepository.makeSearch(it) }.launchIn(viewModelScope) From 44cd1af25b5a44d2e7aa7f8dd0cacb3dfbbe2278 Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Wed, 18 Mar 2026 02:19:58 -0400 Subject: [PATCH 04/11] fix: future-proof homeviewmodel from search issues --- .../transit/ui/viewmodels/HomeViewModel.kt | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt b/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt index d39564a8..9b512956 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt @@ -30,7 +30,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -58,11 +57,6 @@ class HomeViewModel @Inject constructor( private val _addSearchQuery: MutableStateFlow = MutableStateFlow("") val addSearchQuery: StateFlow = _addSearchQuery.asStateFlow() - /** - * The list of queried places retrieved from the route repository, as a StateFlow. - */ - val placeQueryFlow: StateFlow>> = routeRepository.placeFlow - /** * The current UI state of the search bar, as a MutableStateFlow */ @@ -119,8 +113,12 @@ class HomeViewModel @Inject constructor( ) private val homeQueryFlow: StateFlow = searchBarUiState - .filterIsInstance() - .map { it.queryText } + .map { state -> + when (state) { + is SearchBarUIState.Query -> state.queryText + is SearchBarUIState.RecentAndFavorites -> "" + } + } .distinctUntilChanged() .stateIn( scope = viewModelScope, @@ -262,11 +260,9 @@ class HomeViewModel @Inject constructor( } }.launchIn(viewModelScope) - searchBarUiState + homeQueryFlow .debounce(300L) - .filterIsInstance() - .map { it.queryText } - .distinctUntilChanged() + .filter { it.isNotBlank() } .onEach { routeRepository.makeSearch(it) }.launchIn(viewModelScope) From 6684f06278ba08a65c43162a09839199142460b9 Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Wed, 18 Mar 2026 17:14:40 -0400 Subject: [PATCH 05/11] feat: add previews for menu item --- .../transit/ui/components/MenuItem.kt | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/transit/ui/components/MenuItem.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/MenuItem.kt index 165c912d..f24ac1b6 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/components/MenuItem.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/components/MenuItem.kt @@ -104,5 +104,29 @@ private fun PreviewMenuItemBusStop() { @Preview(showBackground = true) @Composable private fun PreviewMenuItemApplePlace() { - MenuItem(PlaceType.EATERY, "Apple Place", "Ithaca, NY", {}) -} \ No newline at end of file + MenuItem(PlaceType.APPLE_PLACE, "Apple Place", "Ithaca, NY", {}) +} + +@Preview(showBackground = true) +@Composable +private fun PreviewMenuItemEatery() { + MenuItem(PlaceType.EATERY, "Eatery", "Ithaca, NY", {}) +} + +@Preview(showBackground = true) +@Composable +private fun PreviewMenuItemGym() { + MenuItem(PlaceType.GYM, "Gym", "Ithaca, NY", {}) +} + +@Preview(showBackground = true) +@Composable +private fun PreviewMenuItemLibrary() { + MenuItem(PlaceType.LIBRARY, "Library", "Ithaca, NY", {}) +} + +@Preview(showBackground = true) +@Composable +private fun PreviewMenuItemPrinter() { + MenuItem(PlaceType.PRINTER, "Printer", "Ithaca, NY", {}) +} From 57bee17d5d750e3222568c6ab7d785cc4d920b7a Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Wed, 18 Mar 2026 19:28:05 -0400 Subject: [PATCH 06/11] show ft when distance < 0.1 miles --- .../transit/ui/viewmodels/HomeViewModel.kt | 35 +++++++++++-------- .../transit/util/TransitConstants.kt | 4 ++- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt b/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt index 9b512956..bbf43b4c 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt @@ -18,6 +18,7 @@ import com.cornellappdev.transit.models.ecosystem.Library import com.cornellappdev.transit.models.ecosystem.Printer import com.cornellappdev.transit.models.ecosystem.UpliftGym import com.cornellappdev.transit.networking.ApiResponse +import com.cornellappdev.transit.util.METERS_TO_FEET import com.cornellappdev.transit.util.StringUtils.fromMetersToMiles import com.cornellappdev.transit.util.calculateDistance import com.google.android.gms.maps.model.LatLng @@ -36,6 +37,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject +import kotlin.text.toDouble /** * ViewModel handling home screen UI state and search functionality @@ -140,10 +142,10 @@ class HomeViewModel @Inject constructor( val addSearchResultsFlow: StateFlow>> = unifiedSearchRepository.mergedSearchResults(_addSearchQuery) .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = ApiResponse.Success(emptyList()) - ) + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ApiResponse.Success(emptyList()) + ) fun toggleAddFavoritesSheet(show: Boolean) { _showAddFavoritesSheet.value = show @@ -272,8 +274,8 @@ class HomeViewModel @Inject constructor( .distinctUntilChanged() .filter { it.isNotEmpty() } .onEach { - routeRepository.makeSearch(it) - }.launchIn(viewModelScope) + routeRepository.makeSearch(it) + }.launchIn(viewModelScope) } /** @@ -415,14 +417,19 @@ class HomeViewModel @Inject constructor( fun distanceStringIfCurrentLocationExists(latitude: Double?, longitude: Double?): String { val currentLocationSnapshot = currentLocation.value if (currentLocationSnapshot != null && latitude != null && longitude != null) { - return " - " + - calculateDistance( - LatLng( - currentLocationSnapshot.latitude, - currentLocationSnapshot.longitude - ), LatLng(latitude, longitude) - ).toString().fromMetersToMiles() + " mi" - + var distance: String + val distanceInMeters = calculateDistance( + LatLng( + currentLocationSnapshot.latitude, + currentLocationSnapshot.longitude + ), LatLng(latitude, longitude) + ).toString() + if (distanceInMeters.toDouble() > 160) { + distance = distanceInMeters.fromMetersToMiles() + " mi" + } else { + distance = (distanceInMeters.toDouble() * METERS_TO_FEET).toInt().toString() + " ft" + } + return " - $distance" } return "" } 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 19ba516b..33dcf06d 100644 --- a/app/src/main/java/com/cornellappdev/transit/util/TransitConstants.kt +++ b/app/src/main/java/com/cornellappdev/transit/util/TransitConstants.kt @@ -12,4 +12,6 @@ const val MEDIUM_CAPACITY_THRESHOLD = 0.35f /** When the capacity turns to red */ const val HIGH_CAPACITY_THRESHOLD = 0.65f -const val NOTIFICATIONS_ENABLED = false \ No newline at end of file +const val NOTIFICATIONS_ENABLED = false + +const val METERS_TO_FEET = 3.28 \ No newline at end of file From cb5ff06592cf5c18d8cd2c2d11e88d2c07cedf52 Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Thu, 19 Mar 2026 01:47:57 -0400 Subject: [PATCH 07/11] fix: make ui/logic changes to show user more consistent and understandable information --- .../home/EcosystemBottomSheetContent.kt | 126 ++++++++++-------- .../transit/ui/screens/HomeScreen.kt | 4 +- .../transit/ui/viewmodels/HomeViewModel.kt | 51 ++++++- 3 files changed, 124 insertions(+), 57 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/transit/ui/components/home/EcosystemBottomSheetContent.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/home/EcosystemBottomSheetContent.kt index 17004235..5faf848d 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/components/home/EcosystemBottomSheetContent.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/components/home/EcosystemBottomSheetContent.kt @@ -43,6 +43,7 @@ import com.cornellappdev.transit.ui.theme.robotoFamily import com.cornellappdev.transit.ui.viewmodels.EcosystemFavoritesUiState import com.cornellappdev.transit.ui.viewmodels.FavoritesFilterSheetState import com.cornellappdev.transit.ui.viewmodels.FilterState +import com.cornellappdev.transit.util.TimeUtils.getOpenStatus import com.cornellappdev.transit.util.TimeUtils.isOpenAnnotatedStringFromOperatingHours import com.cornellappdev.transit.util.ecosystem.capacityPercentAnnotatedString import com.cornellappdev.transit.ui.viewmodels.PrinterCardUiState @@ -83,6 +84,8 @@ fun EcosystemBottomSheetContent( onRemoveAppliedFilter: (FavoritesFilterSheetState) -> Unit, operatingHoursToString: (List) -> AnnotatedString, distanceStringToPlace: (Double?, Double?) -> String, + sanitizeLibraryAddress: (String) -> String, + printerToCardUiState: (Printer) -> PrinterCardUiState, ) { Column(modifier = modifier) { Row( @@ -129,7 +132,9 @@ fun EcosystemBottomSheetContent( appliedFilters = appliedFilters, onRemoveAppliedFilter = onRemoveAppliedFilter, operatingHoursToString = operatingHoursToString, - distanceStringToPlace = distanceStringToPlace + distanceStringToPlace = distanceStringToPlace, + sanitizeLibraryAddress = sanitizeLibraryAddress, + printerToCardUiState = printerToCardUiState ) } @@ -164,6 +169,8 @@ private fun BottomSheetFilteredContent( onRemoveAppliedFilter: (FavoritesFilterSheetState) -> Unit, operatingHoursToString: (List) -> AnnotatedString, distanceStringToPlace: (Double?, Double?) -> String, + sanitizeLibraryAddress: (String) -> String, + printerToCardUiState: (Printer) -> PrinterCardUiState, ) { Column { if (currentFilter == FilterState.FAVORITES) { @@ -209,7 +216,8 @@ private fun BottomSheetFilteredContent( onDetailsClick = onDetailsClick, operatingHoursToString = operatingHoursToString, capacityToString = ::capacityPercentAnnotatedString, - distanceStringToPlace = distanceStringToPlace + distanceStringToPlace = distanceStringToPlace, + sanitizeLibraryAddress = sanitizeLibraryAddress ) } @@ -219,7 +227,8 @@ private fun BottomSheetFilteredContent( navigateToPlace = navigateToPlace, favorites = favorites, onFavoriteStarClick = onFavoriteStarClick, - distanceStringToPlace = distanceStringToPlace + distanceStringToPlace = distanceStringToPlace, + printerToCardUiState = printerToCardUiState ) } @@ -253,6 +262,7 @@ private fun BottomSheetFilteredContent( favorites, onFavoriteStarClick, distanceStringToPlace, + sanitizeLibraryAddress, ) } } @@ -277,7 +287,8 @@ private fun LazyListScope.favoriteList( onDetailsClick: (DetailedEcosystemPlace) -> Unit, operatingHoursToString: (List) -> AnnotatedString, capacityToString: (UpliftCapacity?) -> AnnotatedString, - distanceStringToPlace: (Double?, Double?) -> String + distanceStringToPlace: (Double?, Double?) -> String, + sanitizeLibraryAddress: (String) -> String, ) { item { Spacer(modifier = Modifier.height(8.dp)) @@ -295,10 +306,7 @@ private fun LazyListScope.favoriteList( RoundedImagePlaceCard( title = matchingEatery.name, subtitle = (matchingEatery.location - ?: "") + distanceStringToPlace( - matchingEatery.latitude, - matchingEatery.longitude - ), + ?: "") + distanceStringToPlace(matchingEatery.latitude, matchingEatery.longitude), isFavorite = true, onFavoriteClick = { onFavoriteStarClick(place) }, leftAnnotatedString = operatingHoursToString( @@ -322,10 +330,8 @@ private fun LazyListScope.favoriteList( if (matchingLibrary != null) { RoundedImagePlaceCard( title = matchingLibrary.location, - subtitle = matchingLibrary.address + distanceStringToPlace( - matchingLibrary.latitude, - matchingLibrary.longitude - ), + subtitle = sanitizeLibraryAddress(matchingLibrary.address) + + distanceStringToPlace(matchingLibrary.latitude, matchingLibrary.longitude), isFavorite = true, onFavoriteClick = { onFavoriteStarClick(place) } ) { @@ -344,12 +350,11 @@ private fun LazyListScope.favoriteList( PlaceType.GYM -> { val matchingGym = gymByPlace[place] if (matchingGym != null) { + val isGymOpen = getOpenStatus(matchingGym.operatingHours()).isOpen RoundedImagePlaceCard( title = matchingGym.name, - subtitle = getGymLocationString(matchingGym.name) + distanceStringToPlace( - matchingGym.latitude, - matchingGym.longitude - ), + subtitle = getGymLocationString(matchingGym.name) + + distanceStringToPlace(matchingGym.latitude, matchingGym.longitude), isFavorite = true, onFavoriteClick = { onFavoriteStarClick(place) @@ -357,9 +362,11 @@ private fun LazyListScope.favoriteList( leftAnnotatedString = operatingHoursToString( matchingGym.operatingHours() ), - rightAnnotatedString = capacityToString( - matchingGym.upliftCapacity - ), + rightAnnotatedString = if (isGymOpen) { + capacityToString(matchingGym.upliftCapacity) + } else { + null + }, ) { onDetailsClick(matchingGym) } @@ -378,10 +385,8 @@ private fun LazyListScope.favoriteList( if (matchingPrinter != null) { PrinterCard( title = matchingPrinter.title, - subtitle = matchingPrinter.subtitle + distanceStringToPlace( - place.latitude, - place.longitude - ), + subtitle = matchingPrinter.subtitle + + distanceStringToPlace(place.latitude, place.longitude), inColor = matchingPrinter.inColor, hasCopy = matchingPrinter.hasCopy, hasScan = matchingPrinter.hasScan, @@ -445,13 +450,12 @@ private fun LazyListScope.gymList( } items(gymsApiResponse.data) { + val isGymOpen = getOpenStatus(it.operatingHours()).isOpen RoundedImagePlaceCard( imageUrl = it.imageUrl, title = it.name, - subtitle = getGymLocationString(it.name) + distanceStringToPlace( - it.latitude, - it.longitude - ), + subtitle = getGymLocationString(it.name) + + distanceStringToPlace(it.latitude, it.longitude), isFavorite = it.toPlace() in favorites, onFavoriteClick = { onFavoriteStarClick(it.toPlace()) @@ -459,9 +463,11 @@ private fun LazyListScope.gymList( leftAnnotatedString = operatingHoursToString( it.operatingHours() ), - rightAnnotatedString = capacityToString( - it.upliftCapacity - ), + rightAnnotatedString = if (isGymOpen) { + capacityToString(it.upliftCapacity) + } else { + null + }, placeholderRes = R.drawable.olin_library, ) { onDetailsClick(it) @@ -480,6 +486,7 @@ private fun LazyListScope.printerList( favorites: Set, onFavoriteStarClick: (Place) -> Unit, distanceStringToPlace: (Double?, Double?) -> String, + printerToCardUiState: (Printer) -> PrinterCardUiState, ) { when (staticPlaces.printers) { is ApiResponse.Error -> { @@ -500,22 +507,15 @@ private fun LazyListScope.printerList( items(staticPlaces.printers.data) { val place = it.toPlace() - val alert = if (it.location.contains("*")) { - it.location.substringAfter("*").trim('*').trim() - } else { - "" - } + val printerUi = printerToCardUiState(it) PrinterCard( - title = it.location.substringBefore("*").trim(), - subtitle = it.description.substringAfter("-").trim() + distanceStringToPlace( - it.latitude, - it.longitude - ), - inColor = it.description.contains("Color", ignoreCase = true), - hasCopy = it.description.contains("Copy", ignoreCase = true), - hasScan = it.description.contains("Scan", ignoreCase = true), - alertMessage = alert, + title = printerUi.title, + subtitle = printerUi.subtitle + distanceStringToPlace(it.latitude, it.longitude), + inColor = printerUi.inColor, + hasCopy = printerUi.hasCopy, + hasScan = printerUi.hasScan, + alertMessage = printerUi.alertMessage, isFavorite = place in favorites, onFavoriteClick = { onFavoriteStarClick(place) @@ -589,6 +589,7 @@ private fun LazyListScope.libraryList( favorites: Set, onFavoriteStarClick: (Place) -> Unit, distanceStringToPlace: (Double?, Double?) -> String, + sanitizeLibraryAddress: (String) -> String, ) { when (staticPlaces.libraries) { is ApiResponse.Error -> { @@ -611,7 +612,8 @@ private fun LazyListScope.libraryList( RoundedImagePlaceCard( placeholderRes = R.drawable.olin_library, title = it.location, - subtitle = it.address + distanceStringToPlace(it.latitude, it.longitude), + subtitle = sanitizeLibraryAddress(it.address) + + distanceStringToPlace(it.latitude, it.longitude), isFavorite = it.toPlace() in favorites, onFavoriteClick = { onFavoriteStarClick(it.toPlace()) @@ -644,8 +646,7 @@ private fun StandardCard( navigateToPlace: (Place) -> Unit, distanceStringToPlace: (Double?, Double?) -> String, ) { - val distance = distanceStringToPlace(place.latitude, place.longitude) - val subtitle = if (distance.isBlank()) place.subLabel else "${place.subLabel}$distance" + val subtitle = place.subLabel + distanceStringToPlace(place.latitude, place.longitude) BottomSheetLocationCard( title = place.name, @@ -699,7 +700,18 @@ private fun PreviewEcosystemBottomSheet() { onFilterToggle = {}, onRemoveAppliedFilter = {}, operatingHoursToString = { _ -> AnnotatedString("") }, - distanceStringToPlace = { _, _ -> "" } + distanceStringToPlace = { _, _ -> "" }, + sanitizeLibraryAddress = { it }, + printerToCardUiState = { _ -> + PrinterCardUiState( + title = "", + subtitle = "", + inColor = false, + hasCopy = false, + hasScan = false, + alertMessage = "" + ) + } ) } @@ -860,9 +872,17 @@ private fun PreviewBottomSheetFilteredContentFavorites() { ), onRemoveAppliedFilter = {}, operatingHoursToString = { _ -> AnnotatedString("Open • 10am - 4pm") }, - distanceStringToPlace = { _, _ -> "distance" } + distanceStringToPlace = { _, _ -> "distance" }, + sanitizeLibraryAddress = { it }, + printerToCardUiState = { _ -> + PrinterCardUiState( + title = "", + subtitle = "", + inColor = false, + hasCopy = false, + hasScan = false, + alertMessage = "" + ) + } ) -} - - - +} \ No newline at end of file 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 d74df943..4929f5a0 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 @@ -359,7 +359,9 @@ fun HomeScreen( onFilterToggle = homeViewModel::toggleFavoritesFilter, onRemoveAppliedFilter = homeViewModel::removeAppliedFilter, operatingHoursToString = ::isOpenAnnotatedStringFromOperatingHours, - distanceStringToPlace = homeViewModel::distanceStringIfCurrentLocationExists + distanceStringToPlace = homeViewModel::distanceTextOrPlaceholder, + sanitizeLibraryAddress = homeViewModel::sanitizeLibraryAddress, + printerToCardUiState = homeViewModel::printerToCardUiState ) } } diff --git a/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt b/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt index bbf43b4c..fe8d3bc1 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject +import java.util.Locale import kotlin.text.toDouble /** @@ -433,6 +434,26 @@ class HomeViewModel @Inject constructor( } return "" } + + /** + * Returns distance text with a loading placeholder when current location is not ready. + */ + fun distanceTextOrPlaceholder(latitude: Double?, longitude: Double?): String { + val distanceText = distanceStringIfCurrentLocationExists(latitude, longitude) + return if (distanceText.isBlank()) " - Calculating Distance..." else distanceText + } + + /** + * Keeps only the first segment of a library address (text before the first comma). + */ + fun sanitizeLibraryAddress(address: String): String { + return address.substringBefore(",").trim() + } + + /** + * Maps raw printer fields to UI-ready fields for card rendering. + */ + fun printerToCardUiState(printer: Printer): PrinterCardUiState = printer.toPrinterCardUiState() } /** @@ -459,10 +480,23 @@ data class PrinterCardUiState( ) private fun Printer.toPrinterCardUiState(): PrinterCardUiState { - val alertMessage = location.substringAfter("*", "").trim('*').trim() + //Hard-coded way to handle closed for construction message, change when backend is updated + val constructionAlert = "CLOSED FOR CONSTRUCTION" + val constructionRegex = Regex("""\bCLOSED\s+FOR\s+CONSTRUCTION\b""", RegexOption.IGNORE_CASE) + val hasConstructionAlert = constructionRegex.containsMatchIn(location) + + val rawTitle = location.substringBefore("*").trim() + val title = rawTitle + .replace(constructionRegex, "") + .replace(Regex("""\s{2,}"""), " ") + .trim(' ', '-', ',', ';', ':') + + val starAlertMessage = location.substringAfter("*", "").trim('*').trim() + val alertMessage = if (hasConstructionAlert) constructionAlert else starAlertMessage + return PrinterCardUiState( - title = location.substringBefore("*").trim(), - subtitle = description.substringAfter("-", description).trim(), + title = title, + subtitle = description.substringAfter("-", description).trim().toTitleCaseWords(), inColor = description.contains("Color", ignoreCase = true), hasCopy = description.contains("Copy", ignoreCase = true), hasScan = description.contains("Scan", ignoreCase = true), @@ -470,6 +504,17 @@ private fun Printer.toPrinterCardUiState(): PrinterCardUiState { ) } +private fun String.toTitleCaseWords(): String { + return split(Regex("""\s+""")) + .filter { it.isNotBlank() } + .joinToString(" ") { word -> + word.lowercase(Locale.getDefault()) + .replaceFirstChar { char -> + if (char.isLowerCase()) char.titlecase(Locale.getDefault()) else char.toString() + } + } +} + private fun Set.toAllowedPlaceTypes(): Set = buildSet { if (FavoritesFilterSheetState.EATERIES in this@toAllowedPlaceTypes) add(PlaceType.EATERY) if (FavoritesFilterSheetState.LIBRARIES in this@toAllowedPlaceTypes) add(PlaceType.LIBRARY) From 5937c217c9eaebbf10feece9505abbee05274806 Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Sat, 21 Mar 2026 02:22:59 -0400 Subject: [PATCH 08/11] fix: use meters to feet conversion in route screen as well --- .../java/com/cornellappdev/transit/models/Route.kt | 4 ++-- .../cornellappdev/transit/ui/components/RouteCell.kt | 5 ++--- .../transit/ui/viewmodels/HomeViewModel.kt | 12 ++---------- .../com/cornellappdev/transit/util/StringUtils.kt | 9 +++++++++ .../cornellappdev/transit/util/TransitConstants.kt | 2 +- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/transit/models/Route.kt b/app/src/main/java/com/cornellappdev/transit/models/Route.kt index 74475223..113c9b18 100644 --- a/app/src/main/java/com/cornellappdev/transit/models/Route.kt +++ b/app/src/main/java/com/cornellappdev/transit/models/Route.kt @@ -1,6 +1,6 @@ package com.cornellappdev.transit.models -import com.cornellappdev.transit.util.StringUtils.fromMetersToMiles +import com.cornellappdev.transit.util.StringUtils.fromMetersToFeet import com.google.android.gms.maps.model.LatLng import com.squareup.moshi.Json @@ -40,7 +40,7 @@ data class Direction( val type get() = if (directionType == "walk") DirectionType.WALK else DirectionType.DEPART val distance: String - get() = distanceMeters.fromMetersToMiles() + get() = distanceMeters.fromMetersToFeet() } diff --git a/app/src/main/java/com/cornellappdev/transit/ui/components/RouteCell.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/RouteCell.kt index f5f6bb4f..771f85bc 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/components/RouteCell.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/components/RouteCell.kt @@ -41,7 +41,6 @@ import com.cornellappdev.transit.ui.theme.PrimaryText import com.cornellappdev.transit.ui.theme.TransitBlue import com.cornellappdev.transit.ui.theme.robotoFamily import com.google.android.gms.maps.model.LatLng -import java.util.Locale /** * Composable function to display a route cell with transport details. @@ -254,7 +253,7 @@ fun SingleRoute( ) if (walkOnly && distance != null) { Text( - "${String.format(Locale.US, "%.1f", distance.toFloat())} mi away", + distance, color = MetadataGray, fontFamily = robotoFamily, fontSize = 10.sp, @@ -303,7 +302,7 @@ fun SingleRoute( if (distance != null && !walkOnly) { Text( - "${String.format(Locale.US, "%.1f", distance.toFloat())} mi away", + distance, color = MetadataGray, fontFamily = robotoFamily, fontSize = 10.sp, diff --git a/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt b/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt index fe8d3bc1..3e8f8e6f 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt @@ -18,8 +18,7 @@ import com.cornellappdev.transit.models.ecosystem.Library import com.cornellappdev.transit.models.ecosystem.Printer import com.cornellappdev.transit.models.ecosystem.UpliftGym import com.cornellappdev.transit.networking.ApiResponse -import com.cornellappdev.transit.util.METERS_TO_FEET -import com.cornellappdev.transit.util.StringUtils.fromMetersToMiles +import com.cornellappdev.transit.util.StringUtils.fromMetersToFeet import com.cornellappdev.transit.util.calculateDistance import com.google.android.gms.maps.model.LatLng import dagger.hilt.android.lifecycle.HiltViewModel @@ -38,7 +37,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject import java.util.Locale -import kotlin.text.toDouble /** * ViewModel handling home screen UI state and search functionality @@ -418,19 +416,13 @@ class HomeViewModel @Inject constructor( fun distanceStringIfCurrentLocationExists(latitude: Double?, longitude: Double?): String { val currentLocationSnapshot = currentLocation.value if (currentLocationSnapshot != null && latitude != null && longitude != null) { - var distance: String val distanceInMeters = calculateDistance( LatLng( currentLocationSnapshot.latitude, currentLocationSnapshot.longitude ), LatLng(latitude, longitude) ).toString() - if (distanceInMeters.toDouble() > 160) { - distance = distanceInMeters.fromMetersToMiles() + " mi" - } else { - distance = (distanceInMeters.toDouble() * METERS_TO_FEET).toInt().toString() + " ft" - } - return " - $distance" + return " - ${distanceInMeters.fromMetersToFeet()}" } return "" } diff --git a/app/src/main/java/com/cornellappdev/transit/util/StringUtils.kt b/app/src/main/java/com/cornellappdev/transit/util/StringUtils.kt index 60e2e591..4fcf4c1d 100644 --- a/app/src/main/java/com/cornellappdev/transit/util/StringUtils.kt +++ b/app/src/main/java/com/cornellappdev/transit/util/StringUtils.kt @@ -41,6 +41,15 @@ object StringUtils { return "%.1f".format(this.toDouble() / 1609.34) } + fun String.fromMetersToFeet(): String { + val meters = this.toDoubleOrNull() ?: return this + return if (meters > 160) { + "${this.fromMetersToMiles()} mi" + } else { + "${(meters * METERS_TO_FEET).toInt()} ft" + } + } + /** * Creates an annotated string link with an arrow and an optional icon. * 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 33dcf06d..41736643 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,4 @@ const val HIGH_CAPACITY_THRESHOLD = 0.65f const val NOTIFICATIONS_ENABLED = false -const val METERS_TO_FEET = 3.28 \ No newline at end of file +const val METERS_TO_FEET = 3.28084 \ No newline at end of file From f7a05583f515d1fe928cfe040715b2df86e6822d Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Sun, 22 Mar 2026 11:34:04 -0400 Subject: [PATCH 09/11] fix: address potential race condition with pending state and make search results more lined up with expected behavior of searching --- .../transit/models/RouteRepository.kt | 40 ++++++++++++++++--- .../models/search/UnifiedSearchRepository.kt | 16 ++++++-- .../transit/ui/components/MenuItem.kt | 2 +- .../transit/ui/viewmodels/HomeViewModel.kt | 12 +++--- 4 files changed, 55 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/transit/models/RouteRepository.kt b/app/src/main/java/com/cornellappdev/transit/models/RouteRepository.kt index 3f55066b..3c782f31 100644 --- a/app/src/main/java/com/cornellappdev/transit/models/RouteRepository.kt +++ b/app/src/main/java/com/cornellappdev/transit/models/RouteRepository.kt @@ -15,6 +15,12 @@ import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import javax.inject.Singleton +import kotlin.coroutines.cancellation.CancellationException + +data class PlaceSearchState( + val query: String, + val response: ApiResponse>, +) /** * Repository for data related to routes @@ -52,6 +58,9 @@ class RouteRepository @Inject constructor( private val _placeFlow: MutableStateFlow>> = MutableStateFlow(ApiResponse.Pending) + private val _placeSearchStateFlow: MutableStateFlow = + MutableStateFlow(PlaceSearchState(query = "", response = ApiResponse.Success(emptyList()))) + private val _lastRouteFlow: MutableStateFlow> = MutableStateFlow(ApiResponse.Pending) @@ -87,6 +96,11 @@ class RouteRepository @Inject constructor( */ val placeFlow = _placeFlow.asStateFlow() + /** + * A StateFlow holding the last queried location tagged by query. + */ + val placeSearchStateFlow = _placeSearchStateFlow.asStateFlow() + /** * A StateFlow holding the list of all printers */ @@ -146,29 +160,46 @@ class RouteRepository @Inject constructor( * Makes a new call to places related to a query string. */ fun makeSearch(query: String) { + val normalizedQuery = query.trim() val token = latestSearchToken.incrementAndGet() - if (query.isBlank()) { + if (normalizedQuery.isBlank()) { if (token == latestSearchToken.get()) { _placeFlow.value = ApiResponse.Success(emptyList()) + _placeSearchStateFlow.value = + PlaceSearchState(query = "", response = ApiResponse.Success(emptyList())) } return } - _placeFlow.value = ApiResponse.Pending + _placeSearchStateFlow.value = PlaceSearchState( + query = normalizedQuery, + response = ApiResponse.Pending + ) CoroutineScope(Dispatchers.IO).launch { try { - val placeResponse = appleSearch(SearchQuery(query)) + if(token == latestSearchToken.get()){ + _placeFlow.value = ApiResponse.Pending + } + val placeResponse = appleSearch(SearchQuery(normalizedQuery)) val res = placeResponse.unwrap() val totalLocations = (res.places ?: emptyList()) + (res.stops ?: (emptyList())) if (token == latestSearchToken.get()) { _placeFlow.value = ApiResponse.Success(totalLocations) + _placeSearchStateFlow.value = PlaceSearchState( + query = normalizedQuery, + response = ApiResponse.Success(totalLocations) + ) } - } catch (_: kotlinx.coroutines.CancellationException) { + } catch (_: CancellationException) { // Ignore cancellation; latest query owns the flow update. } catch (e: Exception) { if (token == latestSearchToken.get()) { _placeFlow.value = ApiResponse.Error + _placeSearchStateFlow.value = PlaceSearchState( + query = normalizedQuery, + response = ApiResponse.Error + ) } } } @@ -211,5 +242,4 @@ class RouteRepository @Inject constructor( } } } - } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/transit/models/search/UnifiedSearchRepository.kt b/app/src/main/java/com/cornellappdev/transit/models/search/UnifiedSearchRepository.kt index 38ce6095..5de6ffd3 100644 --- a/app/src/main/java/com/cornellappdev/transit/models/search/UnifiedSearchRepository.kt +++ b/app/src/main/java/com/cornellappdev/transit/models/search/UnifiedSearchRepository.kt @@ -12,6 +12,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import javax.inject.Inject import javax.inject.Singleton @@ -50,10 +52,18 @@ class UnifiedSearchRepository @Inject constructor( fun mergedSearchResults(queryFlow: Flow): Flow>> = combine( - queryFlow, - routeRepository.placeFlow, + queryFlow + .map { it.trim() } + .distinctUntilChanged(), + routeRepository.placeSearchStateFlow, ecosystemSearchPlacesFlow - ) { query, routeSearchResults, ecosystemPlaces -> + ) { query, routeSearchState, ecosystemPlaces -> + val routeSearchResults = when { + query.isBlank() -> ApiResponse.Success(emptyList()) + routeSearchState.query.equals(query, ignoreCase = true) -> routeSearchState.response + else -> ApiResponse.Pending + } + mergeAndRankSearchResults( query = query, routeSearchResults = routeSearchResults, diff --git a/app/src/main/java/com/cornellappdev/transit/ui/components/MenuItem.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/MenuItem.kt index f24ac1b6..2d3b76a8 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/components/MenuItem.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/components/MenuItem.kt @@ -49,7 +49,7 @@ fun MenuItem( ) { Image( painterResource(iconForPlaceType(type)), - contentDescription = type.name, + contentDescription = null, modifier = Modifier .size(24.dp), ) diff --git a/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt b/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt index 3e8f8e6f..df7a18ce 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt @@ -471,20 +471,20 @@ data class PrinterCardUiState( val alertMessage: String ) +//Hard-coded way to handle closed for construction message, change when backend is updated +val PRINTER_CONSTRUCTION_ALERT = "CLOSED FOR CONSTRUCTION" +val PRINTER_CONSTRUCTION_REGEX = Regex("""\bCLOSED\s+FOR\s+CONSTRUCTION\b""", RegexOption.IGNORE_CASE) private fun Printer.toPrinterCardUiState(): PrinterCardUiState { - //Hard-coded way to handle closed for construction message, change when backend is updated - val constructionAlert = "CLOSED FOR CONSTRUCTION" - val constructionRegex = Regex("""\bCLOSED\s+FOR\s+CONSTRUCTION\b""", RegexOption.IGNORE_CASE) - val hasConstructionAlert = constructionRegex.containsMatchIn(location) + val hasConstructionAlert = PRINTER_CONSTRUCTION_REGEX.containsMatchIn(location) val rawTitle = location.substringBefore("*").trim() val title = rawTitle - .replace(constructionRegex, "") + .replace(PRINTER_CONSTRUCTION_REGEX, "") .replace(Regex("""\s{2,}"""), " ") .trim(' ', '-', ',', ';', ':') val starAlertMessage = location.substringAfter("*", "").trim('*').trim() - val alertMessage = if (hasConstructionAlert) constructionAlert else starAlertMessage + val alertMessage = if (hasConstructionAlert) PRINTER_CONSTRUCTION_ALERT else starAlertMessage return PrinterCardUiState( title = title, From ddf126cc38f00411bcd47c06592931dd353dd537 Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Wed, 25 Mar 2026 17:40:30 -0400 Subject: [PATCH 10/11] fix: address small nits --- .../transit/ui/components/LoadingLocationItems.kt | 1 - .../cornellappdev/transit/ui/components/MenuItem.kt | 3 ++- .../ui/components/home/EcosystemBottomSheetContent.kt | 10 +++++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/transit/ui/components/LoadingLocationItems.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/LoadingLocationItems.kt index 6565079a..773dd476 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/components/LoadingLocationItems.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/components/LoadingLocationItems.kt @@ -41,7 +41,6 @@ fun LoadingLocationItems(searchResult: ApiResponse>, onClick: (Place label = it.name, sublabel = it.subLabel, onClick = { onClick(it) }, - modifier = Modifier ) } } diff --git a/app/src/main/java/com/cornellappdev/transit/ui/components/MenuItem.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/MenuItem.kt index 2d3b76a8..8e587f25 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/components/MenuItem.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/components/MenuItem.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.width import com.cornellappdev.transit.R import com.cornellappdev.transit.models.PlaceType import com.cornellappdev.transit.ui.theme.PrimaryText @@ -53,7 +54,7 @@ fun MenuItem( modifier = Modifier .size(24.dp), ) - Spacer(modifier = Modifier.size(12.dp)) + Spacer(modifier = Modifier.width(12.dp)) Column() { Text( text = label, diff --git a/app/src/main/java/com/cornellappdev/transit/ui/components/home/EcosystemBottomSheetContent.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/home/EcosystemBottomSheetContent.kt index 5faf848d..e38cf4c9 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/components/home/EcosystemBottomSheetContent.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/components/home/EcosystemBottomSheetContent.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -39,6 +40,8 @@ import com.cornellappdev.transit.models.ecosystem.UpliftCapacity import com.cornellappdev.transit.models.ecosystem.UpliftGym import com.cornellappdev.transit.networking.ApiResponse import com.cornellappdev.transit.ui.theme.FavoritesDividerGray +import com.cornellappdev.transit.ui.theme.PrimaryText +import com.cornellappdev.transit.ui.theme.Style import com.cornellappdev.transit.ui.theme.robotoFamily import com.cornellappdev.transit.ui.viewmodels.EcosystemFavoritesUiState import com.cornellappdev.transit.ui.viewmodels.FavoritesFilterSheetState @@ -634,7 +637,12 @@ private fun LazyListScope.infoItem(message: String) { .padding(vertical = 20.dp), horizontalArrangement = Arrangement.Center ) { - Text(text = message) + Text(text = message + "...", + color = PrimaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = Style.heading3, + fontSize = 20.sp) } } } From 6098daea2591ee68b7a4ef87ca38b394bfb02a81 Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Thu, 26 Mar 2026 00:35:38 -0400 Subject: [PATCH 11/11] fix: round ft number to the 10s and add animation to items of the favorites list for less snappy unfavoriting --- .../home/EcosystemBottomSheetContent.kt | 208 +++++++++--------- .../transit/ui/viewmodels/HomeViewModel.kt | 19 +- 2 files changed, 124 insertions(+), 103 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/transit/ui/components/home/EcosystemBottomSheetContent.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/home/EcosystemBottomSheetContent.kt index 8a3ae7e6..574aa375 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/components/home/EcosystemBottomSheetContent.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/components/home/EcosystemBottomSheetContent.kt @@ -1,5 +1,6 @@ package com.cornellappdev.transit.ui.components.home +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -315,109 +316,125 @@ private fun LazyListScope.favoriteList( items = filteredFavorites, key = { place -> "${place.type}:${place.name}:${place.latitude}:${place.longitude}" } ) { place -> - when (place.type) { - PlaceType.EATERY -> { - val matchingEatery = eateryByPlace[place] - if (matchingEatery != null) { - RoundedImagePlaceCard( - title = matchingEatery.name, - subtitle = (matchingEatery.location - ?: "") + distanceStringToPlace(matchingEatery.latitude, matchingEatery.longitude), - isFavorite = true, - onFavoriteClick = { onFavoriteStarClick(place) }, - leftAnnotatedString = operatingHoursToString( - matchingEatery.operatingHours() + Column( + modifier = Modifier + .animateItem( + placementSpec = tween(durationMillis = 320) + ) + ) { + when (place.type) { + PlaceType.EATERY -> { + val matchingEatery = eateryByPlace[place] + if (matchingEatery != null) { + RoundedImagePlaceCard( + title = matchingEatery.name, + subtitle = (matchingEatery.location + ?: "") + distanceStringToPlace(matchingEatery.latitude, matchingEatery.longitude), + isFavorite = true, + onFavoriteClick = { onFavoriteStarClick(place) }, + leftAnnotatedString = operatingHoursToString( + matchingEatery.operatingHours() + ) + ) { + onDetailsClick(matchingEatery) + } + } else { + StandardCard( + place = place, + onFavoriteStarClick = onFavoriteStarClick, + navigateToPlace = navigateToPlace, + distanceStringToPlace = distanceStringToPlace ) - ) { - onDetailsClick(matchingEatery) } - } else { - StandardCard( - place = place, - onFavoriteStarClick = onFavoriteStarClick, - navigateToPlace = navigateToPlace, - distanceStringToPlace = distanceStringToPlace - ) } - } - PlaceType.LIBRARY -> { - val matchingLibraryCard = libraryCardByPlace[place] - if (matchingLibraryCard != null) { - val matchingLibrary = matchingLibraryCard.library - RoundedImagePlaceCard( - title = matchingLibrary.location, - subtitle = sanitizeLibraryAddress(matchingLibrary.address) + - distanceStringToPlace(matchingLibrary.latitude, matchingLibrary.longitude), - isFavorite = true, - onFavoriteClick = { onFavoriteStarClick(place) }, - ) { - // Use detailed content sheet when backend is updated - // onDetailsClick(matchingLibrary) - navigateToPlace(matchingLibrary.toPlace()) + PlaceType.LIBRARY -> { + val matchingLibraryCard = libraryCardByPlace[place] + if (matchingLibraryCard != null) { + val matchingLibrary = matchingLibraryCard.library + RoundedImagePlaceCard( + title = matchingLibrary.location, + subtitle = sanitizeLibraryAddress(matchingLibrary.address) + + distanceStringToPlace(matchingLibrary.latitude, matchingLibrary.longitude), + isFavorite = true, + onFavoriteClick = { onFavoriteStarClick(place) }, + ) { + // Use detailed content sheet when backend is updated + // onDetailsClick(matchingLibrary) + navigateToPlace(matchingLibrary.toPlace()) + } + } else { + StandardCard( + place = place, + onFavoriteStarClick = onFavoriteStarClick, + navigateToPlace = navigateToPlace, + distanceStringToPlace = distanceStringToPlace + ) } - } else { - StandardCard( - place = place, - onFavoriteStarClick = onFavoriteStarClick, - navigateToPlace = navigateToPlace, - distanceStringToPlace = distanceStringToPlace - ) } - } - PlaceType.GYM -> { - val matchingGym = gymByPlace[place] - if (matchingGym != null) { - val isGymOpen = getOpenStatus(matchingGym.operatingHours()).isOpen - RoundedImagePlaceCard( - title = matchingGym.name, - subtitle = getGymLocationString(matchingGym.name) + - distanceStringToPlace(matchingGym.latitude, matchingGym.longitude), - isFavorite = true, - onFavoriteClick = { - onFavoriteStarClick(place) - }, - leftAnnotatedString = operatingHoursToString( - matchingGym.operatingHours() - ), - rightAnnotatedString = if (isGymOpen) { - capacityToString(matchingGym.upliftCapacity) - } else { - null - }, - ) { - onDetailsClick(matchingGym) + PlaceType.GYM -> { + val matchingGym = gymByPlace[place] + if (matchingGym != null) { + val isGymOpen = getOpenStatus(matchingGym.operatingHours()).isOpen + RoundedImagePlaceCard( + title = matchingGym.name, + subtitle = getGymLocationString(matchingGym.name) + + distanceStringToPlace(matchingGym.latitude, matchingGym.longitude), + isFavorite = true, + onFavoriteClick = { + onFavoriteStarClick(place) + }, + leftAnnotatedString = operatingHoursToString( + matchingGym.operatingHours() + ), + rightAnnotatedString = if (isGymOpen) { + capacityToString(matchingGym.upliftCapacity) + } else { + null + }, + ) { + onDetailsClick(matchingGym) + } + } else { + StandardCard( + place = place, + onFavoriteStarClick = onFavoriteStarClick, + navigateToPlace = navigateToPlace, + distanceStringToPlace = distanceStringToPlace + ) } - } else { - StandardCard( - place = place, - onFavoriteStarClick = onFavoriteStarClick, - navigateToPlace = navigateToPlace, - distanceStringToPlace = distanceStringToPlace - ) } - } - PlaceType.PRINTER -> { - val matchingPrinter = printerByPlace[place] - if (matchingPrinter != null) { - PrinterCard( - title = matchingPrinter.title, - subtitle = matchingPrinter.subtitle + - distanceStringToPlace(place.latitude, place.longitude), - inColor = matchingPrinter.inColor, - hasCopy = matchingPrinter.hasCopy, - hasScan = matchingPrinter.hasScan, - alertMessage = matchingPrinter.alertMessage, - isFavorite = place in favorites, - onFavoriteClick = { - onFavoriteStarClick(place) + PlaceType.PRINTER -> { + val matchingPrinter = printerByPlace[place] + if (matchingPrinter != null) { + PrinterCard( + title = matchingPrinter.title, + subtitle = matchingPrinter.subtitle + + distanceStringToPlace(place.latitude, place.longitude), + inColor = matchingPrinter.inColor, + hasCopy = matchingPrinter.hasCopy, + hasScan = matchingPrinter.hasScan, + alertMessage = matchingPrinter.alertMessage, + isFavorite = place in favorites, + onFavoriteClick = { + onFavoriteStarClick(place) + } + ) { + navigateToPlace(place) } - ) { - navigateToPlace(place) + } else { + StandardCard( + place = place, + onFavoriteStarClick = onFavoriteStarClick, + navigateToPlace = navigateToPlace, + distanceStringToPlace = distanceStringToPlace + ) } - } else { + } + + PlaceType.BUS_STOP, PlaceType.APPLE_PLACE -> { StandardCard( place = place, onFavoriteStarClick = onFavoriteStarClick, @@ -426,15 +443,6 @@ private fun LazyListScope.favoriteList( ) } } - - PlaceType.BUS_STOP, PlaceType.APPLE_PLACE -> { - StandardCard( - place = place, - onFavoriteStarClick = onFavoriteStarClick, - navigateToPlace = navigateToPlace, - distanceStringToPlace = distanceStringToPlace - ) - } } } } diff --git a/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt b/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt index 63ebf002..e6ed401f 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt @@ -20,8 +20,8 @@ import com.cornellappdev.transit.models.ecosystem.Library import com.cornellappdev.transit.models.ecosystem.Printer import com.cornellappdev.transit.models.ecosystem.UpliftGym import com.cornellappdev.transit.networking.ApiResponse -import com.cornellappdev.transit.util.StringUtils.fromMetersToFeet import com.cornellappdev.transit.util.StringUtils.fromMetersToMiles +import com.cornellappdev.transit.util.METERS_TO_FEET import com.cornellappdev.transit.util.TimeUtils import com.cornellappdev.transit.util.calculateDistance import com.google.android.gms.maps.model.LatLng @@ -41,6 +41,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject import java.util.Locale +import kotlin.math.roundToInt /** * ViewModel handling home screen UI state and search functionality @@ -477,12 +478,24 @@ class HomeViewModel @Inject constructor( currentLocationSnapshot.latitude, currentLocationSnapshot.longitude ), LatLng(latitude, longitude) - ).toString() - return " - ${distanceInMeters.fromMetersToFeet()}" + ) + return " - ${formatDistance(distanceInMeters)}" } return "" } + /** + * Home cards show short distances in feet rounded to nearest 10 for readability. + */ + private fun formatDistance(distanceInMeters: Double): String { + return if (distanceInMeters > 160) { + "${distanceInMeters.toString().fromMetersToMiles()} mi" + } else { + val feetRoundedToTens = ((distanceInMeters * METERS_TO_FEET) / 10.0).roundToInt() * 10 + "$feetRoundedToTens ft" + } + } + /** * Returns distance text with a loading placeholder when current location is not ready. */