refactor: split out searchstate

This commit is contained in:
LavaDesu 2025-07-29 00:21:26 +10:00
parent c26e522a2e
commit c526269e5d
Signed by: cilly
GPG key ID: 6500251E087653C9
5 changed files with 153 additions and 94 deletions

View file

@ -72,9 +72,9 @@ fun App(
viewModel.bindTracker(locationTracker) viewModel.bindTracker(locationTracker)
scope.launch { locationTracker.startTracking() } scope.launch { locationTracker.startTracking() }
val state by viewModel.state.collectAsStateWithLifecycle()
val infoState by viewModel.infoState.collectAsStateWithLifecycle() val infoState by viewModel.infoState.collectAsStateWithLifecycle()
val mapState by viewModel.mapState.collectAsStateWithLifecycle() val mapState by viewModel.mapState.collectAsStateWithLifecycle()
val searchState by viewModel.searchState.collectAsStateWithLifecycle()
val scaffoldState = rememberBottomSheetScaffoldState( val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState( bottomSheetState = rememberStandardBottomSheetState(
@ -100,7 +100,6 @@ fun App(
scope.launch { scaffoldState.bottomSheetState.hide() } scope.launch { scaffoldState.bottomSheetState.hide() }
} }
var searchTextState by rememberSaveable { mutableStateOf("") }
var searchExpandedState by rememberSaveable { mutableStateOf(false) } var searchExpandedState by rememberSaveable { mutableStateOf(false) }
var sheetSwipeEnabled by rememberSaveable { mutableStateOf(true) } var sheetSwipeEnabled by rememberSaveable { mutableStateOf(true) }
var handleHeight by remember { mutableStateOf(0.dp) } var handleHeight by remember { mutableStateOf(0.dp) }
@ -145,16 +144,14 @@ fun App(
polylines = mapState.polylines, polylines = mapState.polylines,
) )
Searcher( Searcher(
routes = state.routes, state = searchState,
onEvent = viewModel::handleEvent,
expanded = searchExpandedState, expanded = searchExpandedState,
onExpandedChange = { onExpandedChange = {
searchExpandedState = it searchExpandedState = it
if (it) if (it)
scope.launch { scaffoldState.bottomSheetState.hide() } scope.launch { scaffoldState.bottomSheetState.hide() }
}, },
text = searchTextState,
onTextChange = { searchTextState = it },
onRouteChange = { viewModel.switchRoute(it) }
) )
PredictiveBackHandler(scaffoldState.bottomSheetState.currentValue != SheetValue.Hidden) { progress -> PredictiveBackHandler(scaffoldState.bottomSheetState.currentValue != SheetValue.Hidden) { progress ->

View file

@ -16,7 +16,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import moe.lava.banksia.api.ptv.PtvService import moe.lava.banksia.api.ptv.PtvService
import moe.lava.banksia.api.ptv.structures.PtvRoute 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.api.ptv.structures.getProperties
import moe.lava.banksia.log import moe.lava.banksia.log
import moe.lava.banksia.native.maps.CameraPosition 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.native.maps.Polyline
import moe.lava.banksia.ui.state.InfoPanelState import moe.lava.banksia.ui.state.InfoPanelState
import moe.lava.banksia.ui.state.MapState 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
import moe.lava.banksia.util.BoxedValue.Companion.box 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( data class SearchUpdate(val text: String) : BanksiaEvent()
val routes: List<PtvRoute> = listOf(), }
)
class BanksiaViewModel : ViewModel() { class BanksiaViewModel : ViewModel() {
private val iState = MutableStateFlow(BanksiaViewState())
val state = iState.asStateFlow()
private val iInfoState = MutableStateFlow<InfoPanelState>(InfoPanelState.None) private val iInfoState = MutableStateFlow<InfoPanelState>(InfoPanelState.None)
val infoState = iInfoState.asStateFlow() val infoState = iInfoState.asStateFlow()
@ -48,17 +47,26 @@ class BanksiaViewModel : ViewModel() {
private val iCameraChangeEmitter = MutableSharedFlow<BoxedValue<CameraPosition>>() private val iCameraChangeEmitter = MutableSharedFlow<BoxedValue<CameraPosition>>()
val cameraChangeEmitter = iCameraChangeEmitter.asSharedFlow() val cameraChangeEmitter = iCameraChangeEmitter.asSharedFlow()
private val iSearchState = MutableStateFlow(SearchState())
val searchState = iSearchState.asStateFlow()
private val ptvService = PtvService() private val ptvService = PtvService()
private var locationTrackerJob: Job? = null private var locationTrackerJob: Job? = null
private var lastKnownLocation: Point? = null private var lastKnownLocation: Point? = null
init { init {
viewModelScope.launch { viewModelScope.launch { searchUpdate("") }
requestRoutes()
}
} }
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) { fun bindTracker(locationTracker: LocationTracker) {
locationTrackerJob = locationTracker.getLocationsFlow() locationTrackerJob = locationTracker.getLocationsFlow()
@ -79,43 +87,57 @@ class BanksiaViewModel : ViewModel() {
lastKnownLocation = location lastKnownLocation = location
} }
private suspend fun requestRoutes() { private suspend fun searchUpdate(text: String) {
val routes = ptvService.routes().sortedWith( val entries = ptvService.routes()
compareBy( .sortedWith(
{ it.gtfsSubType()?.ordinal }, compareBy(
{ it.routeNumber.toIntOrNull() }, { it.gtfsSubType()?.ordinal },
{ it.routeName } { 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,
) )
} )
.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) { SearchState.SearchEntry(main, sub, route.routeId, route.routeType)
viewModelScope.launch { buildPolylines(route) } }
viewModelScope.launch { buildStops(route) }
// viewModelScope.launch { buildDepartures() } iSearchState.update { SearchState(entries, text) }
// viewModelScope.launch { buildRuns() }
}
} }
// [TODO]: Cleanup private suspend fun switchRoute(routeId: Int?) {
suspend fun switchStop(stop: PtvStop?) { iMapState.update { MapState() }
if (stop == null) { if (routeId == null) {
iInfoState.update { InfoPanelState.None } iInfoState.update { InfoPanelState.None }
return 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 split = stop.stopName.split("/")
val name = split[0] val name = split[0]
val subname = split.getOrNull(1) val subname = split.getOrNull(1)
@ -229,7 +251,7 @@ class BanksiaViewModel : ViewModel() {
type = MarkerType.GENERIC_STOP, type = MarkerType.GENERIC_STOP,
colour = colour, colour = colour,
onClick = { onClick = {
viewModelScope.launch { switchStop(stop) } viewModelScope.launch { switchStop(route.routeType, stop.stopId) }
false false
} }
) )

View file

@ -1,10 +1,8 @@
package moe.lava.banksia.ui package moe.lava.banksia.ui
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -21,28 +19,20 @@ import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import moe.lava.banksia.api.ptv.structures.ComposableIcon import moe.lava.banksia.api.ptv.structures.ComposableRouteIcon
import moe.lava.banksia.api.ptv.structures.PtvRoute import moe.lava.banksia.ui.state.SearchState
import kotlin.math.pow
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun Searcher( fun Searcher(
routes: List<PtvRoute>, state: SearchState,
onEvent: (BanksiaEvent) -> Unit,
expanded: Boolean, expanded: Boolean,
onExpandedChange: (Boolean) -> Unit, onExpandedChange: (Boolean) -> Unit,
text: String,
onTextChange: (String) -> Unit,
onRouteChange: (PtvRoute?) -> Unit,
) { ) {
val animatedPadding by animateDpAsState( val animatedPadding by animateDpAsState(
if (expanded) { if (expanded) {
@ -61,32 +51,20 @@ fun Searcher(
.padding(horizontal = animatedPadding), .padding(horizontal = animatedPadding),
shadowElevation = 6.dp, // Elevation level 3 shadowElevation = 6.dp, // Elevation level 3
inputField = { inputField = {
var backProgress by remember { mutableFloatStateOf(1f) }
var backEdgeIsLeft by remember { mutableStateOf<Boolean?>(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( SearchBarDefaults.InputField(
modifier = Modifier modifier = Modifier.padding(horizontal = 20.dp - animatedPadding),
.alpha(1f - routeInfoOpacity) query = state.text,
.padding(horizontal = 20.dp - animatedPadding), onQueryChange = { onEvent(BanksiaEvent.SearchUpdate(it)) },
query = text,
onQueryChange = onTextChange,
onSearch = {}, onSearch = {},
expanded = expanded, expanded = expanded,
onExpandedChange = onExpandedChange, onExpandedChange = onExpandedChange,
leadingIcon = { Icon(Icons.Default.Search, null) }, leadingIcon = { Icon(Icons.Default.Search, null) },
trailingIcon = { trailingIcon = {
if (expanded && text.isNotEmpty()) if (expanded && state.text.isNotEmpty())
Icon( Icon(
imageVector = Icons.Default.Clear, imageVector = Icons.Default.Clear,
contentDescription = null, contentDescription = null,
modifier = Modifier.clickable { onTextChange("") } modifier = Modifier.clickable { onEvent(BanksiaEvent.SearchUpdate("")) }
) )
} }
) )
@ -95,27 +73,20 @@ fun Searcher(
onExpandedChange = onExpandedChange, onExpandedChange = onExpandedChange,
) { ) {
LazyColumn(modifier = Modifier.fillMaxWidth()) { LazyColumn(modifier = Modifier.fillMaxWidth()) {
for (route in routes) { for (entry in state.entries) {
if (!route.routeNumber.contains(text) &&
!route.routeName.lowercase().contains(text.lowercase()))
continue
item { item {
ListItem( ListItem(
headlineContent = { Text(route.routeNumber.ifEmpty { route.routeName }) }, headlineContent = { Text(entry.mainText) },
supportingContent = { supportingContent = { entry.subText?.let { Text(it) } },
if (route.routeNumber.isNotEmpty()) { leadingContent = { ComposableRouteIcon(entry.routeType) },
Text(route.routeName)
}
},
leadingContent = { route.routeType.ComposableIcon() },
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp) .padding(horizontal = 16.dp, vertical = 4.dp)
.clickable { .clickable {
onTextChange("")
onExpandedChange(false) onExpandedChange(false)
onRouteChange(route) onEvent(BanksiaEvent.SearchUpdate(""))
onEvent(BanksiaEvent.SelectRoute(entry.routeId))
} }
) )
} }

View file

@ -0,0 +1,15 @@
package moe.lava.banksia.ui.state
import moe.lava.banksia.api.ptv.structures.PtvRouteType
data class SearchState(
val entries: List<SearchEntry> = listOf(),
val text: String = "",
) {
data class SearchEntry(
val mainText: String,
val subText: String?,
val routeId: Int,
val routeType: PtvRouteType,
)
}

View file

@ -28,6 +28,8 @@ object Responses {
@Serializable @Serializable
data class PtvRoutesResponse(val routes: List<PtvRoute>) data class PtvRoutesResponse(val routes: List<PtvRoute>)
@Serializable
data class PtvStopResponse(val stop: PtvStop)
@Serializable @Serializable
data class PtvStopsResponse(val stops: List<PtvStop>) data class PtvStopsResponse(val stops: List<PtvStop>)
@ -43,6 +45,8 @@ class PtvService {
class PtvCache( class PtvCache(
private val service: PtvService, private val service: PtvService,
private val directions: HashMap<Pair<Int, Int>, PtvDirection> = HashMap(), private val directions: HashMap<Pair<Int, Int>, PtvDirection> = HashMap(),
private val routes: HashMap<Int, PtvRoute> = HashMap(),
private val stops: HashMap<Int, PtvStop> = HashMap(),
) { ) {
suspend fun direction(directionID: Int, routeID: Int): PtvDirection? { suspend fun direction(directionID: Int, routeID: Int): PtvDirection? {
val ret = directions[Pair(directionID, routeID)] val ret = directions[Pair(directionID, routeID)]
@ -54,6 +58,23 @@ class PtvService {
return ret ?: directions[Pair(directionID, routeID)] return ret ?: directions[Pair(directionID, routeID)]
} }
fun setRoutes(routes: Iterable<PtvRoute>) {
routes.forEach {
this.routes[it.routeId] = it
}
}
fun getRoute(routeId: Int) = routes[routeId]
fun getRoutes() = routes.values.toList()
fun addStops(stops: Iterable<PtvStop>) {
stops.forEach {
this.stops[it.stopId] = it
}
}
fun getStop(stopId: Int) = stops[stopId]
} }
val cache = PtvCache(this) val cache = PtvCache(this)
@ -83,17 +104,28 @@ class PtvService {
} }
suspend fun route(id: Int, includeGeopath: Boolean = false): PtvRoute { 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") { val response: Responses.PtvRouteResponse = client.get("routes") {
url { url {
appendPathSegments(id.toString()) appendPathSegments(id.toString())
parameters.append("include_geopath", if (includeGeopath) "true" else "false") parameters.append("include_geopath", if (includeGeopath) "true" else "false")
} }
}.body() }.body()
cache.setRoutes(listOf(response.route))
return response.route return response.route
} }
suspend fun routes(): List<PtvRoute> { suspend fun routes(): List<PtvRoute> {
val cached = cache.getRoutes()
if (cached.isNotEmpty())
return cached
val response: Responses.PtvRoutesResponse = client.get("routes").body() val response: Responses.PtvRoutesResponse = client.get("routes").body()
cache.setRoutes(response.routes)
return response.routes return response.routes
} }
@ -108,7 +140,29 @@ class PtvService {
) )
} }
}.body() }.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<PtvDirection> { suspend fun directionsByRoute(routeId: Int): List<PtvDirection> {