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 7447522..113c9b1 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/models/RouteRepository.kt b/app/src/main/java/com/cornellappdev/transit/models/RouteRepository.kt index b98fbae..57b9b25 100644 --- a/app/src/main/java/com/cornellappdev/transit/models/RouteRepository.kt +++ b/app/src/main/java/com/cornellappdev/transit/models/RouteRepository.kt @@ -7,14 +7,20 @@ import com.cornellappdev.transit.networking.EcosystemNetworkApi import com.cornellappdev.transit.networking.RoutesNetworkApi import com.cornellappdev.transit.util.ECOSYSTEM_FLAG import com.google.android.gms.maps.model.LatLng -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope 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 +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) @@ -61,6 +70,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) { @@ -84,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 */ @@ -143,15 +160,47 @@ class RouteRepository @Inject constructor( * Makes a new call to places related to a query string. */ fun makeSearch(query: String) { - _placeFlow.value = ApiResponse.Pending + val normalizedQuery = query.trim() + val token = latestSearchToken.incrementAndGet() + + if (normalizedQuery.isBlank()) { + if (token == latestSearchToken.get()) { + _placeFlow.value = ApiResponse.Success(emptyList()) + _placeSearchStateFlow.value = + PlaceSearchState(query = "", response = ApiResponse.Success(emptyList())) + } + return + } + + _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())) - _placeFlow.value = ApiResponse.Success(totalLocations) + if (token == latestSearchToken.get()) { + _placeFlow.value = ApiResponse.Success(totalLocations) + _placeSearchStateFlow.value = PlaceSearchState( + query = normalizedQuery, + response = ApiResponse.Success(totalLocations) + ) + } + } catch (_: CancellationException) { + // Ignore cancellation; latest query owns the flow update. } catch (e: Exception) { - _placeFlow.value = ApiResponse.Error + if (token == latestSearchToken.get()) { + _placeFlow.value = ApiResponse.Error + _placeSearchStateFlow.value = PlaceSearchState( + query = normalizedQuery, + response = ApiResponse.Error + ) + } } } } @@ -194,5 +243,4 @@ class RouteRepository @Inject constructor( _lastRouteFlow.value = ApiResponse.Error } } - } \ 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 new file mode 100644 index 0000000..5de6ffd --- /dev/null +++ b/app/src/main/java/com/cornellappdev/transit/models/search/UnifiedSearchRepository.kt @@ -0,0 +1,76 @@ +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.distinctUntilChanged +import kotlinx.coroutines.flow.map +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 + .map { it.trim() } + .distinctUntilChanged(), + routeRepository.placeSearchStateFlow, + ecosystemSearchPlacesFlow + ) { 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, + ecosystemPlaces = ecosystemPlaces + ) + } +} + + + 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 61deeda..773dd47 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,7 @@ fun LoadingLocationItems(searchResult: ApiResponse>, onClick: (Place type = it.type, label = it.name, sublabel = it.subLabel, - onClick = { onClick(it) } + onClick = { onClick(it) }, ) } } 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 ab5d493..8e587f2 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,10 @@ 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 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 @@ -23,33 +27,34 @@ 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) + .defaultMinSize(minHeight = 36.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 = null, + modifier = Modifier + .size(24.dp), + ) + Spacer(modifier = Modifier.width(12.dp)) Column() { Text( text = label, @@ -59,7 +64,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 +74,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 @@ -80,4 +106,28 @@ private fun PreviewMenuItemBusStop() { @Composable private fun PreviewMenuItemApplePlace() { MenuItem(PlaceType.APPLE_PLACE, "Apple Place", "Ithaca, NY", {}) -} \ No newline at end of file +} + +@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", {}) +} 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 f5f6bb4..771f85b 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/components/SearchSuggestions.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/SearchSuggestions.kt index a68449d..880ac7a 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 aaf308b..574aa37 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 @@ -25,6 +26,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 @@ -41,11 +43,14 @@ 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 import com.cornellappdev.transit.ui.viewmodels.FilterState import com.cornellappdev.transit.ui.viewmodels.LibraryCardUiState +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 @@ -89,6 +94,8 @@ fun EcosystemBottomSheetContent( onRemoveAppliedFilter: (FavoritesFilterSheetState) -> Unit, operatingHoursToString: (List) -> AnnotatedString, distanceStringToPlace: (Double?, Double?) -> String, + sanitizeLibraryAddress: (String) -> String, + printerToCardUiState: (Printer) -> PrinterCardUiState, listState: LazyListState, ) { Column(modifier = modifier) { @@ -138,7 +145,9 @@ fun EcosystemBottomSheetContent( onRemoveAppliedFilter = onRemoveAppliedFilter, operatingHoursToString = operatingHoursToString, distanceStringToPlace = distanceStringToPlace, - listState = listState + sanitizeLibraryAddress = sanitizeLibraryAddress, + printerToCardUiState = printerToCardUiState, + listState = listState, ) } @@ -174,6 +183,8 @@ private fun BottomSheetFilteredContent( onRemoveAppliedFilter: (FavoritesFilterSheetState) -> Unit, operatingHoursToString: (List) -> AnnotatedString, distanceStringToPlace: (Double?, Double?) -> String, + sanitizeLibraryAddress: (String) -> String, + printerToCardUiState: (Printer) -> PrinterCardUiState, listState: LazyListState, ) { Column { @@ -221,7 +232,8 @@ private fun BottomSheetFilteredContent( onDetailsClick = onDetailsClick, operatingHoursToString = operatingHoursToString, capacityToString = ::capacityPercentAnnotatedString, - distanceStringToPlace = distanceStringToPlace + distanceStringToPlace = distanceStringToPlace, + sanitizeLibraryAddress = sanitizeLibraryAddress, ) } @@ -231,7 +243,8 @@ private fun BottomSheetFilteredContent( navigateToPlace = navigateToPlace, favorites = favorites, onFavoriteStarClick = onFavoriteStarClick, - distanceStringToPlace = distanceStringToPlace + distanceStringToPlace = distanceStringToPlace, + printerToCardUiState = printerToCardUiState, ) } @@ -266,6 +279,7 @@ private fun BottomSheetFilteredContent( favorites, onFavoriteStarClick, distanceStringToPlace, + sanitizeLibraryAddress, ) } } @@ -290,7 +304,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)) @@ -301,115 +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 = 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) { - RoundedImagePlaceCard( - title = matchingGym.name, - subtitle = getGymLocationString(matchingGym.name) + distanceStringToPlace( - matchingGym.latitude, - matchingGym.longitude - ), - isFavorite = true, - onFavoriteClick = { - onFavoriteStarClick(place) - }, - leftAnnotatedString = operatingHoursToString( - matchingGym.operatingHours() - ), - rightAnnotatedString = capacityToString( - matchingGym.upliftCapacity - ), - ) { - 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, @@ -418,15 +443,6 @@ private fun LazyListScope.favoriteList( ) } } - - PlaceType.BUS_STOP, PlaceType.APPLE_PLACE -> { - StandardCard( - place = place, - onFavoriteStarClick = onFavoriteStarClick, - navigateToPlace = navigateToPlace, - distanceStringToPlace = distanceStringToPlace - ) - } } } } @@ -445,6 +461,7 @@ private fun LazyListScope.gymList( ) { when (gymsApiResponse) { is ApiResponse.Error -> { + infoItem("Unable to load gyms") } is ApiResponse.Pending -> { @@ -454,14 +471,18 @@ private fun LazyListScope.gymList( } is ApiResponse.Success -> { + if (gymsApiResponse.data.isEmpty()) { + infoItem("No gyms available") + return + } + 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()) @@ -469,9 +490,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) @@ -490,33 +513,36 @@ private fun LazyListScope.printerList( favorites: Set, onFavoriteStarClick: (Place) -> Unit, distanceStringToPlace: (Double?, Double?) -> String, + printerToCardUiState: (Printer) -> PrinterCardUiState, ) { 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("*")) { - 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) @@ -544,6 +570,7 @@ private fun LazyListScope.eateryList( ) { when (eateriesApiResponse) { is ApiResponse.Error -> { + infoItem("Unable to load eateries") } is ApiResponse.Pending -> { @@ -553,6 +580,11 @@ private fun LazyListScope.eateryList( } is ApiResponse.Success -> { + if (eateriesApiResponse.data.isEmpty()) { + infoItem("No eateries available") + return + } + items(eateriesApiResponse.data) { RoundedImagePlaceCard( imageUrl = it.imageUrl, @@ -585,21 +617,31 @@ private fun LazyListScope.libraryList( favorites: Set, onFavoriteStarClick: (Place) -> Unit, distanceStringToPlace: (Double?, Double?) -> String, + sanitizeLibraryAddress: (String) -> String, ) { when (libraryCardsApiResponse) { is ApiResponse.Error -> { + infoItem("Unable to load libraries") } is ApiResponse.Pending -> { + item { + CenteredSpinningIndicator() + } } is ApiResponse.Success -> { + if (libraryCardsApiResponse.data.isEmpty()) { + infoItem("No libraries available") + return + } + items(libraryCardsApiResponse.data) { val library = it.library RoundedImagePlaceCard( placeholderRes = it.placeholderRes, title = library.location, - subtitle = library.address + distanceStringToPlace(library.latitude, library.longitude), + subtitle = sanitizeLibraryAddress(library.address) + distanceStringToPlace(library.latitude, library.longitude), isFavorite = library.toPlace() in favorites, onFavoriteClick = { onFavoriteStarClick(library.toPlace()) @@ -614,6 +656,24 @@ 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 + "...", + color = PrimaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = Style.heading3, + fontSize = 20.sp) + } + } +} + @Composable private fun StandardCard( place: Place, @@ -621,8 +681,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, @@ -678,7 +737,18 @@ private fun PreviewEcosystemBottomSheet() { onRemoveAppliedFilter = {}, operatingHoursToString = { _ -> AnnotatedString("") }, distanceStringToPlace = { _, _ -> "" }, - listState = rememberLazyListState() + listState = rememberLazyListState(), + sanitizeLibraryAddress = { it }, + printerToCardUiState = { _ -> + PrinterCardUiState( + title = "", + subtitle = "", + inColor = false, + hasCopy = false, + hasScan = false, + alertMessage = "" + ) + } ) } @@ -860,6 +930,17 @@ private fun PreviewBottomSheetFilteredContentFavorites() { onRemoveAppliedFilter = {}, operatingHoursToString = { _ -> AnnotatedString("Open • 10am - 4pm") }, distanceStringToPlace = { _, _ -> "distance" }, + sanitizeLibraryAddress = { it }, + printerToCardUiState = { _ -> + PrinterCardUiState( + title = "", + subtitle = "", + inColor = false, + hasCopy = false, + hasScan = false, + alertMessage = "" + ) + }, listState = rememberLazyListState() ) } 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 e6e34c3..2a49f7e 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 @@ -181,8 +181,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 @@ -387,8 +387,10 @@ fun HomeScreen( onFilterToggle = homeViewModel::toggleFavoritesFilter, onRemoveAppliedFilter = homeViewModel::removeAppliedFilter, operatingHoursToString = ::isOpenAnnotatedStringFromOperatingHours, - distanceStringToPlace = homeViewModel::distanceStringIfCurrentLocationExists, - listState = listStateFor(filterStateValue) + listState = listStateFor(filterStateValue), + distanceStringToPlace = homeViewModel::distanceTextOrPlaceholder, + sanitizeLibraryAddress = homeViewModel::sanitizeLibraryAddress, + printerToCardUiState = homeViewModel::printerToCardUiState ) } } 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 6ffaa96..63ad841 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 } ) @@ -854,6 +860,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( @@ -887,6 +894,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 6e6a786..e6ed401 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 @@ -12,6 +12,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 @@ -20,6 +21,7 @@ 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.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 @@ -31,13 +33,15 @@ 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 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 @@ -48,6 +52,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() { @@ -73,12 +78,8 @@ class HomeViewModel @Inject constructor( /** * The current query in the add favorites search bar, as a StateFlow */ - val addSearchQuery: MutableStateFlow = MutableStateFlow("") - - /** - * The list of queried places retrieved from the route repository, as a StateFlow. - */ - val placeQueryFlow: StateFlow>> = routeRepository.placeFlow + private val _addSearchQuery: MutableStateFlow = MutableStateFlow("") + val addSearchQuery: StateFlow = _addSearchQuery.asStateFlow() /** * The current UI state of the search bar, as a MutableStateFlow @@ -100,7 +101,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() @@ -156,9 +159,39 @@ class HomeViewModel @Inject constructor( ) ) + private val homeQueryFlow: StateFlow = searchBarUiState + .map { state -> + when (state) { + is SearchBarUIState.Query -> state.queryText + is SearchBarUIState.RecentAndFavorites -> "" + } + } + .distinctUntilChanged() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + 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>> = + unifiedSearchRepository.mergedSearchResults(_addSearchQuery) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ApiResponse.Success(emptyList()) + ) + fun toggleAddFavoritesSheet(show: Boolean) { _showAddFavoritesSheet.value = show } @@ -276,27 +309,29 @@ 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(homeQueryFlow, mergedHomeSearchResultsFlow) { query, mergedResults -> + query to mergedResults + }.onEach { (query, mergedResults) -> + val currentState = _searchBarUiState.value + if (currentState is SearchBarUIState.Query && currentState.queryText == query) { + _searchBarUiState.value = currentState.copy(searched = mergedResults) } }.launchIn(viewModelScope) - searchBarUiState + homeQueryFlow .debounce(300L) - .filterIsInstance() - .map { it.queryText } - .distinctUntilChanged() + .filter { it.isNotBlank() } .onEach { routeRepository.makeSearch(it) }.launchIn(viewModelScope) - addSearchQuery.debounce(300L).distinctUntilChanged().onEach { - routeRepository.makeSearch(it) - }.launchIn(viewModelScope) + _addSearchQuery.debounce(300L) + .map { it.trim() } + .distinctUntilChanged() + .filter { it.isNotEmpty() } + .onEach { + routeRepository.makeSearch(it) + }.launchIn(viewModelScope) } /** @@ -326,7 +361,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 } /** @@ -429,7 +464,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 } /** @@ -438,18 +473,49 @@ 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" - + val distanceInMeters = calculateDistance( + LatLng( + currentLocationSnapshot.latitude, + currentLocationSnapshot.longitude + ), LatLng(latitude, longitude) + ) + 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. + */ + 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() + /** * Returns a numerical distance from a location to the current location if both exist, otherwise returns Double.MAX_VALUE */ @@ -523,11 +589,24 @@ 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 { - val alertMessage = location.substringAfter("*", "").trim('*').trim() + val hasConstructionAlert = PRINTER_CONSTRUCTION_REGEX.containsMatchIn(location) + + val rawTitle = location.substringBefore("*").trim() + val title = rawTitle + .replace(PRINTER_CONSTRUCTION_REGEX, "") + .replace(Regex("""\s{2,}"""), " ") + .trim(' ', '-', ',', ';', ':') + + val starAlertMessage = location.substringAfter("*", "").trim('*').trim() + val alertMessage = if (hasConstructionAlert) PRINTER_CONSTRUCTION_ALERT 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), @@ -535,6 +614,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 val libraryImageOverridesByLocation: Map = mapOf( // Temporary placeholders: each location is explicitly configurable for future per-library assets. "africana studies and research center" to R.drawable.library_img_africana_studies, 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 7a34a5a..e40daba 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,11 +8,13 @@ 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.search.UnifiedSearchRepository import com.cornellappdev.transit.networking.ApiResponse import com.cornellappdev.transit.util.TimeUtils import com.google.android.gms.maps.model.LatLng @@ -23,27 +23,28 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job 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.filter 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 unifiedSearchRepository: UnifiedSearchRepository, private val userPreferenceRepository: UserPreferenceRepository, private val selectedRouteRepository: SelectedRouteRepository ) : ViewModel() { @@ -63,9 +64,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 @@ -103,21 +105,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 @@ -126,6 +130,28 @@ class RouteViewModel @Inject constructor( MutableStateFlow(SearchBarUIState.RecentAndFavorites(emptySet(), emptyList())) val searchBarUiState: StateFlow = _searchBarUiState.asStateFlow() + 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 = "" + ) + + 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) { @@ -145,27 +171,27 @@ 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(routeQueryFlow, mergedRouteSearchResultsFlow) { query, mergedResults -> + query to mergedResults + }.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 @@ -206,11 +232,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) @@ -283,14 +307,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 } /** @@ -367,7 +391,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/StringUtils.kt b/app/src/main/java/com/cornellappdev/transit/util/StringUtils.kt index 60e2e59..4fcf4c1 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 19ba516..4173664 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.28084 \ No newline at end of file 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 0000000..dd07488 --- /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("|") +} +