Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/src/main/java/com/cornellappdev/transit/models/Route.kt
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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()
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<Place>>,
)

/**
* Repository for data related to routes
Expand Down Expand Up @@ -52,6 +58,9 @@ class RouteRepository @Inject constructor(
private val _placeFlow: MutableStateFlow<ApiResponse<List<Place>>> =
MutableStateFlow(ApiResponse.Pending)

private val _placeSearchStateFlow: MutableStateFlow<PlaceSearchState> =
MutableStateFlow(PlaceSearchState(query = "", response = ApiResponse.Success(emptyList())))

private val _lastRouteFlow: MutableStateFlow<ApiResponse<RouteOptions>> =
MutableStateFlow(ApiResponse.Pending)

Expand All @@ -61,6 +70,9 @@ class RouteRepository @Inject constructor(
private val _libraryFlow: MutableStateFlow<ApiResponse<List<Library>>> =
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) {
Expand All @@ -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
*/
Expand Down Expand Up @@ -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
)
}
}
}
}
Expand Down Expand Up @@ -194,5 +243,4 @@ class RouteRepository @Inject constructor(
_lastRouteFlow.value = ApiResponse.Error
}
}

}
Original file line number Diff line number Diff line change
@@ -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<List<Place>> =
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<String>): Flow<ApiResponse<List<Place>>> =
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
)
}
}



Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -25,15 +29,18 @@ fun LoadingLocationItems(searchResult: ApiResponse<List<Place>>, 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
) {
MenuItem(
type = it.type,
label = it.name,
sublabel = it.subLabel,
onClick = { onClick(it) }
onClick = { onClick(it) },
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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),
Comment on lines +45 to 48
Copy link

@coderabbitai coderabbitai bot Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Increase clickable height to meet accessibility touch targets.

Line 46 sets a 36.dp minimum height on a clickable row, which can result in a tap target below the 48.dp baseline.

✅ Suggested fix
-            .defaultMinSize(minHeight = 36.dp)
+            .defaultMinSize(minHeight = 48.dp)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.defaultMinSize(minHeight = 36.dp)
.clickable(onClick = onClick),
modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 48.dp)
.clickable(onClick = onClick),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/cornellappdev/transit/ui/components/MenuItem.kt` around
lines 44 - 47, The MenuItem composable currently sets a minimum height of 36.dp
on the clickable Row (modifier chain using .fillMaxWidth()
.defaultMinSize(minHeight = 36.dp) .clickable(onClick = onClick)), which yields
a touch target below the 48.dp accessibility baseline; update the modifier to
use at least 48.dp (or a dimension constant reflecting the 48.dp touch target)
for defaultMinSize(minHeight = ...) so the clickable area meets accessibility
touch-target requirements.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this important?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RyanCheung555, it's a real concern but not critical — here's the breakdown:

Why it matters:

  • Google's Material Design guidelines and Android accessibility best practices recommend a minimum 48 dp touch target for any interactive element. At 36 dp, some users (especially those with motor impairments or larger fingers) may have difficulty reliably tapping a search result row.
  • The surrounding Arrangement.spacedBy(12.dp) spacing in LoadingLocationItems does not extend the tap target — it's just visual whitespace between items.

Why it might be acceptable for now:

  • The row is fillMaxWidth(), so the full screen width is always tappable — only the vertical dimension is below the threshold.
  • If the actual rendered content (icon + two lines of text) naturally exceeds 36 dp in practice, defaultMinSize won't even kick in, making this a non-issue at runtime.
  • This is a UX/accessibility quality concern, not a functional bug.

Recommendation: It's worth fixing — changing 36.dp48.dp is a one-line change with zero functional risk and improves accessibility compliance. But if the list items are rendering taller than 36 dp naturally due to their content (which two-line text rows often do), you can verify in the preview and decide based on what you see.

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,
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -80,4 +106,28 @@ private fun PreviewMenuItemBusStop() {
@Composable
private fun PreviewMenuItemApplePlace() {
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", {})
}
Loading