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)
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 ->

View file

@ -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<PtvRoute> = 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>(InfoPanelState.None)
val infoState = iInfoState.asStateFlow()
@ -48,17 +47,26 @@ class BanksiaViewModel : ViewModel() {
private val iCameraChangeEmitter = MutableSharedFlow<BoxedValue<CameraPosition>>()
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(
private suspend fun searchUpdate(text: String) {
val entries = ptvService.routes()
.sortedWith(
compareBy(
{ it.gtfsSubType()?.ordinal },
{ it.routeNumber.toIntOrNull() },
{ it.routeName }
)
)
iState.update { it.copy(routes = routes) }
.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
}
fun switchRoute(route: PtvRoute?) {
SearchState.SearchEntry(main, sub, route.routeId, route.routeType)
}
iSearchState.update { SearchState(entries, text) }
}
private suspend fun switchRoute(routeId: Int?) {
iMapState.update { MapState() }
if (routeId == null) {
iInfoState.update { InfoPanelState.None }
return
}
val route = ptvService.route(routeId)
iInfoState.update {
if (route == null)
InfoPanelState.None
else
InfoPanelState.Route(
name = route.routeName,
type = route.routeType,
)
}
if (route != null) {
viewModelScope.launch { buildPolylines(route) }
viewModelScope.launch { buildStops(route) }
// viewModelScope.launch { buildDepartures() }
// viewModelScope.launch { buildRuns() }
}
}
// [TODO]: Cleanup
suspend fun switchStop(stop: PtvStop?) {
if (stop == null) {
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
}
)

View file

@ -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<PtvRoute>,
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<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(
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))
}
)
}

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
data class PtvRoutesResponse(val routes: List<PtvRoute>)
@Serializable
data class PtvStopResponse(val stop: PtvStop)
@Serializable
data class PtvStopsResponse(val stops: List<PtvStop>)
@ -43,6 +45,8 @@ class PtvService {
class PtvCache(
private val service: PtvService,
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? {
val ret = directions[Pair(directionID, routeID)]
@ -54,6 +58,23 @@ class PtvService {
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)
@ -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<PtvRoute> {
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<PtvDirection> {