From c7be280665370a342dc5bca353abbe93136db6c1 Mon Sep 17 00:00:00 2001 From: Karan Sharma <55722391+ksharma-xyz@users.noreply.github.com> Date: Tue, 12 Nov 2024 00:35:51 +1100 Subject: [PATCH] UI: Improve stop search results handling and animations (#334) ### TL;DR Enhanced the stop search functionality with improved error handling and smoother UI transitions. ### What changed? - Added key-based list item rendering for better performance - Implemented a 1-second delay before showing "No match found" message - Added tracking of query timestamps to prevent false "No match found" displays - Improved error state handling with distinct keys for error messages - Removed redundant Unit placeholder for empty state ### How to test? 1. Open the stop search screen 2. Enter a search query 3. Verify that "No match found" appears after 1 second if no results 4. Enter a valid stop name and confirm results appear 5. Clear the search and verify smooth transitions 6. Test with network errors to verify error message display ### Screenshots https://github.com/user-attachments/assets/881f2270-fe2b-4645-b2c5-7d4ba6a648c5 ### Why make this change? To provide a more polished user experience by eliminating UI flicker, improving state transitions, and ensuring more reliable error handling when searching for stops. --- .../planner/ui/searchstop/SearchStopScreen.kt | 63 +++++++++++-------- .../ui/searchstop/SearchStopViewModel.kt | 3 +- 2 files changed, 40 insertions(+), 26 deletions(-) diff --git a/feature/trip-planner/ui/src/main/kotlin/xyz/ksharma/krail/trip/planner/ui/searchstop/SearchStopScreen.kt b/feature/trip-planner/ui/src/main/kotlin/xyz/ksharma/krail/trip/planner/ui/searchstop/SearchStopScreen.kt index 9194fd41..bffc7bbb 100644 --- a/feature/trip-planner/ui/src/main/kotlin/xyz/ksharma/krail/trip/planner/ui/searchstop/SearchStopScreen.kt +++ b/feature/trip-planner/ui/src/main/kotlin/xyz/ksharma/krail/trip/planner/ui/searchstop/SearchStopScreen.kt @@ -9,11 +9,13 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -80,10 +82,23 @@ fun SearchStopScreen( } var displayNoMatchFound by remember { mutableStateOf(false) } - LaunchedEffect(searchStopState.stops.isEmpty()) { - if (!searchStopState.isLoading && textFieldText.isNotBlank() && searchStopState.stops.isEmpty()) { - delay(500) - displayNoMatchFound = true + var lastQueryTime by remember { mutableLongStateOf(0L) } + LaunchedEffect( + key1 = textFieldText, + key2 = searchStopState.stops, + key3 = searchStopState.isLoading, + ) { + if (textFieldText.isNotBlank() && searchStopState.stops.isEmpty()) { + // To ensure a smooth transition from the results state to the "No match found" state, + // track the time of the last query. If new results come in during the delay period, + // then lastQueryTime will be different, therefore, it will prevent + // "No match found" message from being displayed. + val currentQueryTime = System.currentTimeMillis() + lastQueryTime = currentQueryTime + delay(1000) + if (lastQueryTime == currentQueryTime && searchStopState.stops.isEmpty()) { + displayNoMatchFound = true + } } else { displayNoMatchFound = false } @@ -123,7 +138,7 @@ fun SearchStopScreen( contentPadding = PaddingValues(top = 16.dp, bottom = 48.dp), ) { if (searchStopState.isError && textFieldText.isNotBlank()) { - item { + item(key = "Error") { ErrorMessage( title = "Eh! That's not looking right mate.", message = "Let's try searching again.", @@ -133,27 +148,27 @@ fun SearchStopScreen( ) } } else if (searchStopState.stops.isNotEmpty() && textFieldText.isNotBlank()) { - searchStopState.stops.forEach { stop -> - item { - StopSearchListItem( - stopId = stop.stopId, - stopName = stop.stopName, - transportModeSet = stop.transportModeType.toImmutableSet(), - textColor = KrailTheme.colors.label, - onClick = { stopItem -> - keyboard?.hide() - onStopSelect(stopItem) - }, - modifier = Modifier - .fillMaxWidth() - .animateItem(), - ) + items( + items = searchStopState.stops, + key = { it.stopId }, + ) { stop -> + StopSearchListItem( + stopId = stop.stopId, + stopName = stop.stopName, + transportModeSet = stop.transportModeType.toImmutableSet(), + textColor = KrailTheme.colors.label, + onClick = { stopItem -> + keyboard?.hide() + onStopSelect(stopItem) + }, + modifier = Modifier + .fillMaxWidth(), + ) - Divider() - } + Divider() } } else if (displayNoMatchFound && textFieldText.isNotBlank()) { - item { + item(key = "no_match") { ErrorMessage( title = "No match found!", message = if (textFieldText.length < 4) { @@ -166,8 +181,6 @@ fun SearchStopScreen( .animateItem(), ) } - } else { - Unit } } } diff --git a/feature/trip-planner/ui/src/main/kotlin/xyz/ksharma/krail/trip/planner/ui/searchstop/SearchStopViewModel.kt b/feature/trip-planner/ui/src/main/kotlin/xyz/ksharma/krail/trip/planner/ui/searchstop/SearchStopViewModel.kt index dfc2ae65..2861edb0 100644 --- a/feature/trip-planner/ui/src/main/kotlin/xyz/ksharma/krail/trip/planner/ui/searchstop/SearchStopViewModel.kt +++ b/feature/trip-planner/ui/src/main/kotlin/xyz/ksharma/krail/trip/planner/ui/searchstop/SearchStopViewModel.kt @@ -38,7 +38,8 @@ class SearchStopViewModel @Inject constructor( viewModelScope.launch { tripPlanningRepository.stopFinder(stopSearchQuery = query) .onSuccess { response: StopFinderResponse -> - updateUiState { displayData(response.toStopResults()) } + val results = response.toStopResults() + updateUiState { displayData(results) } }.onFailure { updateUiState { displayError() } }