From c526269e5d6313e6895174aab456f92d01cf910f Mon Sep 17 00:00:00 2001 From: LavaDesu Date: Tue, 29 Jul 2025 00:21:26 +1000 Subject: [PATCH] refactor: split out searchstate --- .../commonMain/kotlin/moe/lava/banksia/App.kt | 9 +- .../moe/lava/banksia/ui/BanksiaViewModel.kt | 108 +++++++++++------- .../kotlin/moe/lava/banksia/ui/Searcher.kt | 59 +++------- .../moe/lava/banksia/ui/state/SearchState.kt | 15 +++ .../moe/lava/banksia/api/ptv/PtvService.kt | 56 ++++++++- 5 files changed, 153 insertions(+), 94 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/SearchState.kt diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt index 786d85d..5cb8854 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt @@ -72,9 +72,9 @@ fun App( viewModel.bindTracker(locationTracker) scope.launch { locationTracker.startTracking() } - val state by viewModel.state.collectAsStateWithLifecycle() val infoState by viewModel.infoState.collectAsStateWithLifecycle() val mapState by viewModel.mapState.collectAsStateWithLifecycle() + val searchState by viewModel.searchState.collectAsStateWithLifecycle() val scaffoldState = rememberBottomSheetScaffoldState( bottomSheetState = rememberStandardBottomSheetState( @@ -100,7 +100,6 @@ fun App( scope.launch { scaffoldState.bottomSheetState.hide() } } - var searchTextState by rememberSaveable { mutableStateOf("") } var searchExpandedState by rememberSaveable { mutableStateOf(false) } var sheetSwipeEnabled by rememberSaveable { mutableStateOf(true) } var handleHeight by remember { mutableStateOf(0.dp) } @@ -145,16 +144,14 @@ fun App( polylines = mapState.polylines, ) Searcher( - routes = state.routes, + state = searchState, + onEvent = viewModel::handleEvent, expanded = searchExpandedState, onExpandedChange = { searchExpandedState = it if (it) scope.launch { scaffoldState.bottomSheetState.hide() } }, - text = searchTextState, - onTextChange = { searchTextState = it }, - onRouteChange = { viewModel.switchRoute(it) } ) PredictiveBackHandler(scaffoldState.bottomSheetState.currentValue != SheetValue.Hidden) { progress -> diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/BanksiaViewModel.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/BanksiaViewModel.kt index 28b9a49..baf92d9 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/BanksiaViewModel.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/BanksiaViewModel.kt @@ -16,7 +16,7 @@ import kotlinx.datetime.Clock import kotlinx.datetime.Instant import moe.lava.banksia.api.ptv.PtvService import moe.lava.banksia.api.ptv.structures.PtvRoute -import moe.lava.banksia.api.ptv.structures.PtvStop +import moe.lava.banksia.api.ptv.structures.PtvRouteType import moe.lava.banksia.api.ptv.structures.getProperties import moe.lava.banksia.log import moe.lava.banksia.native.maps.CameraPosition @@ -27,19 +27,18 @@ import moe.lava.banksia.native.maps.Point import moe.lava.banksia.native.maps.Polyline import moe.lava.banksia.ui.state.InfoPanelState import moe.lava.banksia.ui.state.MapState +import moe.lava.banksia.ui.state.SearchState import moe.lava.banksia.util.BoxedValue import moe.lava.banksia.util.BoxedValue.Companion.box -sealed class BanksiaEvent {} +sealed class BanksiaEvent { + data class SelectRoute(val id: Int?) : BanksiaEvent() + data class SelectStop(val routeType: PtvRouteType, val stopId: Int?) : BanksiaEvent() -data class BanksiaViewState( - val routes: List = listOf(), -) + data class SearchUpdate(val text: String) : BanksiaEvent() +} class BanksiaViewModel : ViewModel() { - private val iState = MutableStateFlow(BanksiaViewState()) - val state = iState.asStateFlow() - private val iInfoState = MutableStateFlow(InfoPanelState.None) val infoState = iInfoState.asStateFlow() @@ -48,17 +47,26 @@ class BanksiaViewModel : ViewModel() { private val iCameraChangeEmitter = MutableSharedFlow>() val cameraChangeEmitter = iCameraChangeEmitter.asSharedFlow() + private val iSearchState = MutableStateFlow(SearchState()) + val searchState = iSearchState.asStateFlow() + private val ptvService = PtvService() private var locationTrackerJob: Job? = null private var lastKnownLocation: Point? = null init { - viewModelScope.launch { - requestRoutes() - } + viewModelScope.launch { searchUpdate("") } } - fun handleEvent(event: BanksiaEvent) {} + fun handleEvent(event: BanksiaEvent) { + viewModelScope.launch { + when (event) { + is BanksiaEvent.SelectRoute -> switchRoute(event.id) + is BanksiaEvent.SelectStop -> switchStop(event.routeType, event.stopId) + is BanksiaEvent.SearchUpdate -> searchUpdate(event.text) + } + } + } fun bindTracker(locationTracker: LocationTracker) { locationTrackerJob = locationTracker.getLocationsFlow() @@ -79,43 +87,57 @@ class BanksiaViewModel : ViewModel() { lastKnownLocation = location } - private suspend fun requestRoutes() { - val routes = ptvService.routes().sortedWith( - compareBy( - { it.gtfsSubType()?.ordinal }, - { it.routeNumber.toIntOrNull() }, - { it.routeName } - ) - ) - iState.update { it.copy(routes = routes) } - } - - fun switchRoute(route: PtvRoute?) { - iMapState.update { MapState() } - iInfoState.update { - if (route == null) - InfoPanelState.None - else - InfoPanelState.Route( - name = route.routeName, - type = route.routeType, + private suspend fun searchUpdate(text: String) { + val entries = ptvService.routes() + .sortedWith( + compareBy( + { it.gtfsSubType()?.ordinal }, + { it.routeNumber.toIntOrNull() }, + { it.routeName } ) - } + ) + .filter { it.routeNumber.contains(text) || it.routeName.lowercase().contains(text.lowercase()) } + .map { route -> + val (main, sub) = if (route.routeNumber.isNotEmpty()) { + route.routeNumber to route.routeName + } else { + route.routeName to null + } - if (route != null) { - viewModelScope.launch { buildPolylines(route) } - viewModelScope.launch { buildStops(route) } -// viewModelScope.launch { buildDepartures() } -// viewModelScope.launch { buildRuns() } - } + SearchState.SearchEntry(main, sub, route.routeId, route.routeType) + } + + iSearchState.update { SearchState(entries, text) } } - // [TODO]: Cleanup - suspend fun switchStop(stop: PtvStop?) { - if (stop == null) { + private suspend fun switchRoute(routeId: Int?) { + iMapState.update { MapState() } + if (routeId == null) { iInfoState.update { InfoPanelState.None } return } + + val route = ptvService.route(routeId) + iInfoState.update { + InfoPanelState.Route( + name = route.routeName, + type = route.routeType, + ) + } + + viewModelScope.launch { buildPolylines(route) } + viewModelScope.launch { buildStops(route) } +// viewModelScope.launch { buildDepartures() } +// viewModelScope.launch { buildRuns() } + } + + // [TODO]: Cleanup + private suspend fun switchStop(routeType: PtvRouteType, stopId: Int?) { + if (stopId == null) { + iInfoState.update { InfoPanelState.None } + return + } + val stop = ptvService.stop(routeType, stopId) val split = stop.stopName.split("/") val name = split[0] val subname = split.getOrNull(1) @@ -229,7 +251,7 @@ class BanksiaViewModel : ViewModel() { type = MarkerType.GENERIC_STOP, colour = colour, onClick = { - viewModelScope.launch { switchStop(stop) } + viewModelScope.launch { switchStop(route.routeType, stop.stopId) } false } ) diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/Searcher.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/Searcher.kt index d61a528..28a6fc8 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/Searcher.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/Searcher.kt @@ -1,10 +1,8 @@ package moe.lava.banksia.ui import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -21,28 +19,20 @@ import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import moe.lava.banksia.api.ptv.structures.ComposableIcon -import moe.lava.banksia.api.ptv.structures.PtvRoute -import kotlin.math.pow +import moe.lava.banksia.api.ptv.structures.ComposableRouteIcon +import moe.lava.banksia.ui.state.SearchState @OptIn(ExperimentalMaterial3Api::class) @Composable fun Searcher( - routes: List, + state: SearchState, + onEvent: (BanksiaEvent) -> Unit, expanded: Boolean, onExpandedChange: (Boolean) -> Unit, - text: String, - onTextChange: (String) -> Unit, - onRouteChange: (PtvRoute?) -> Unit, ) { val animatedPadding by animateDpAsState( if (expanded) { @@ -61,32 +51,20 @@ fun Searcher( .padding(horizontal = animatedPadding), shadowElevation = 6.dp, // Elevation level 3 inputField = { - var backProgress by remember { mutableFloatStateOf(1f) } - var backEdgeIsLeft by remember { mutableStateOf(null) } - val routeInfoOpacity by animateFloatAsState((1f - backProgress).pow(3)) - val slideState by animateDpAsState((50 * backProgress).dp) - val slidePadding = when (backEdgeIsLeft) { - true -> PaddingValues(start = slideState) - false -> PaddingValues(end = slideState) - null -> PaddingValues() - } - SearchBarDefaults.InputField( - modifier = Modifier - .alpha(1f - routeInfoOpacity) - .padding(horizontal = 20.dp - animatedPadding), - query = text, - onQueryChange = onTextChange, + modifier = Modifier.padding(horizontal = 20.dp - animatedPadding), + query = state.text, + onQueryChange = { onEvent(BanksiaEvent.SearchUpdate(it)) }, onSearch = {}, expanded = expanded, onExpandedChange = onExpandedChange, leadingIcon = { Icon(Icons.Default.Search, null) }, trailingIcon = { - if (expanded && text.isNotEmpty()) + if (expanded && state.text.isNotEmpty()) Icon( imageVector = Icons.Default.Clear, contentDescription = null, - modifier = Modifier.clickable { onTextChange("") } + modifier = Modifier.clickable { onEvent(BanksiaEvent.SearchUpdate("")) } ) } ) @@ -95,27 +73,20 @@ fun Searcher( onExpandedChange = onExpandedChange, ) { LazyColumn(modifier = Modifier.fillMaxWidth()) { - for (route in routes) { - if (!route.routeNumber.contains(text) && - !route.routeName.lowercase().contains(text.lowercase())) - continue + for (entry in state.entries) { item { ListItem( - headlineContent = { Text(route.routeNumber.ifEmpty { route.routeName }) }, - supportingContent = { - if (route.routeNumber.isNotEmpty()) { - Text(route.routeName) - } - }, - leadingContent = { route.routeType.ComposableIcon() }, + headlineContent = { Text(entry.mainText) }, + supportingContent = { entry.subText?.let { Text(it) } }, + leadingContent = { ComposableRouteIcon(entry.routeType) }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 4.dp) .clickable { - onTextChange("") onExpandedChange(false) - onRouteChange(route) + onEvent(BanksiaEvent.SearchUpdate("")) + onEvent(BanksiaEvent.SelectRoute(entry.routeId)) } ) } diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/SearchState.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/SearchState.kt new file mode 100644 index 0000000..20c248e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/SearchState.kt @@ -0,0 +1,15 @@ +package moe.lava.banksia.ui.state + +import moe.lava.banksia.api.ptv.structures.PtvRouteType + +data class SearchState( + val entries: List = listOf(), + val text: String = "", +) { + data class SearchEntry( + val mainText: String, + val subText: String?, + val routeId: Int, + val routeType: PtvRouteType, + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/PtvService.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/PtvService.kt index 9257c31..b6abc26 100644 --- a/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/PtvService.kt +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/PtvService.kt @@ -28,6 +28,8 @@ object Responses { @Serializable data class PtvRoutesResponse(val routes: List) + @Serializable + data class PtvStopResponse(val stop: PtvStop) @Serializable data class PtvStopsResponse(val stops: List) @@ -43,6 +45,8 @@ class PtvService { class PtvCache( private val service: PtvService, private val directions: HashMap, PtvDirection> = HashMap(), + private val routes: HashMap = HashMap(), + private val stops: HashMap = HashMap(), ) { suspend fun direction(directionID: Int, routeID: Int): PtvDirection? { val ret = directions[Pair(directionID, routeID)] @@ -54,6 +58,23 @@ class PtvService { return ret ?: directions[Pair(directionID, routeID)] } + + fun setRoutes(routes: Iterable) { + routes.forEach { + this.routes[it.routeId] = it + } + } + + fun getRoute(routeId: Int) = routes[routeId] + fun getRoutes() = routes.values.toList() + + fun addStops(stops: Iterable) { + stops.forEach { + this.stops[it.stopId] = it + } + } + + fun getStop(stopId: Int) = stops[stopId] } val cache = PtvCache(this) @@ -83,17 +104,28 @@ class PtvService { } suspend fun route(id: Int, includeGeopath: Boolean = false): PtvRoute { + val cached = cache.getRoute(id) + // TODO: im braindead so clean this up later + if (cached != null && (!includeGeopath || (includeGeopath && cached.geopath.isNotEmpty()))) + return cached + val response: Responses.PtvRouteResponse = client.get("routes") { url { appendPathSegments(id.toString()) parameters.append("include_geopath", if (includeGeopath) "true" else "false") } }.body() + cache.setRoutes(listOf(response.route)) return response.route } suspend fun routes(): List { + val cached = cache.getRoutes() + if (cached.isNotEmpty()) + return cached + val response: Responses.PtvRoutesResponse = client.get("routes").body() + cache.setRoutes(response.routes) return response.routes } @@ -108,7 +140,29 @@ class PtvService { ) } }.body() - return response.stops + val stops = response.stops + cache.addStops(stops) + return stops + } + + suspend fun stop(routeType: PtvRouteType, stopId: Int): PtvStop { + val cached = cache.getStop(stopId) + if (cached != null) + return cached + + val response: Responses.PtvStopResponse = client.get() { + url { + appendPathSegments( + "stops", + stopId.toString(), + "route_type", + routeType.ordinal.toString(), + ) + } + }.body() + val stop = response.stop + cache.addStops(listOf(stop)) + return stop } suspend fun directionsByRoute(routeId: Int): List {