refactor: split up state for info panel

This commit is contained in:
LavaDesu 2025-07-28 22:24:33 +10:00
parent 1fa2a9bc10
commit b376e7da5b
Signed by: cilly
GPG key ID: 6500251E087653C9
8 changed files with 205 additions and 168 deletions

View file

@ -48,8 +48,9 @@ import moe.lava.banksia.native.maps.getScreenHeight
import moe.lava.banksia.resources.Res
import moe.lava.banksia.resources.my_location_24
import moe.lava.banksia.ui.BanksiaViewModel
import moe.lava.banksia.ui.InfoPanel
import moe.lava.banksia.ui.Searcher
import moe.lava.banksia.ui.StopInfoPanel
import moe.lava.banksia.ui.state.InfoPanelState
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import kotlin.coroutines.cancellation.CancellationException
@ -72,6 +73,7 @@ fun App(
scope.launch { locationTracker.startTracking() }
val state by viewModel.state.collectAsStateWithLifecycle()
val infoState by viewModel.infoState.collectAsStateWithLifecycle()
val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(
@ -90,9 +92,8 @@ fun App(
(getScreenHeight() - scaffoldOffset - WindowInsets.safeDrawing.getBottom(LocalDensity.current)).coerceAtLeast(0)
} else 0
LaunchedEffect(state.stopState) {
val isShown = state.stopState != null
if (isShown)
LaunchedEffect(infoState) {
if (infoState !is InfoPanelState.None)
scope.launch { scaffoldState.bottomSheetState.partialExpand() }
else
scope.launch { scaffoldState.bottomSheetState.hide() }
@ -111,9 +112,11 @@ fun App(
sheetPeekHeight = (handleHeight + peekHeight) * peekHeightMultiplier,
modifier = Modifier.fillMaxSize(),
sheetContent = {
state.stopState?.let { stopState ->
StopInfoPanel(stopState) { peekHeight = it }
}
InfoPanel(
state = infoState,
onEvent = viewModel::handleEvent,
onPeekHeightChange = { peekHeight = it },
)
},
sheetDragHandle = {
val density = LocalDensity.current
@ -141,7 +144,6 @@ fun App(
polylines = state.polylines,
)
Searcher(
selectedRoute = state.routeState?.route,
routes = state.routes,
expanded = searchExpandedState,
onExpandedChange = {

View file

@ -42,9 +42,10 @@ fun PtvRouteType.getProperties(): RouteTypeProperties {
}
return RouteTypeProperties(colour, drawable, background, icon)
}
@Composable
fun PtvRouteType.ComposableIcon() {
val properties = this.getProperties()
fun ComposableRouteIcon(routeType: PtvRouteType) {
val properties = routeType.getProperties()
Image(
painter = painterResource(properties.icon),
contentDescription = null,
@ -54,3 +55,6 @@ fun PtvRouteType.ComposableIcon() {
}
)
}
@Composable
inline fun PtvRouteType.ComposableIcon() = ComposableRouteIcon(this)

View file

@ -4,10 +4,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.icerock.moko.geo.LocationTracker
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
@ -27,33 +25,24 @@ import moe.lava.banksia.native.maps.Marker
import moe.lava.banksia.native.maps.MarkerType
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.util.BoxedValue
import moe.lava.banksia.util.BoxedValue.Companion.box
data class RouteState(
val route: PtvRoute,
val stops: List<PtvStop>? = null,
)
data class StopState(
val stop: PtvStop,
// val departures: List<PtvDeparture>? = null,
val departures: List<Pair<String, String>>? = null,
)
sealed class BanksiaEvent {}
data class BanksiaViewState(
val routeState: RouteState? = null,
val stopState: StopState? = null,
val routes: List<PtvRoute> = listOf(),
val markers: List<Marker> = listOf(),
val polylines: List<Polyline> = listOf(),
)
class BanksiaViewModel : ViewModel() {
private val iState = MutableStateFlow(BanksiaViewState())
val state: StateFlow<BanksiaViewState> = iState.asStateFlow()
val state = iState.asStateFlow()
private val iInfoState = MutableStateFlow<InfoPanelState>(InfoPanelState.None)
val infoState = iInfoState.asStateFlow()
private val ptvService = PtvService()
private var locationTrackerJob: Job? = null
@ -68,6 +57,8 @@ class BanksiaViewModel : ViewModel() {
}
}
fun handleEvent(event: BanksiaEvent) {}
fun bindTracker(locationTracker: LocationTracker) {
locationTrackerJob = locationTracker.getLocationsFlow()
.onEach { lastKnownLocation = Point(it.latitude, it.longitude) }
@ -98,36 +89,47 @@ class BanksiaViewModel : ViewModel() {
iState.update { it.copy(routes = routes) }
}
fun switchRoute(newRoute: PtvRoute?) {
val routeState = newRoute?.let { RouteState(it) }
if (iState.value.routeState == routeState)
return
fun switchRoute(route: PtvRoute?) {
iState.update {
it.copy(
routeState = routeState,
stopState = null,
markers = listOf(),
polylines = listOf(),
)
}
iInfoState.update {
if (route == null)
InfoPanelState.None
else
InfoPanelState.Route(
name = route.routeName,
type = route.routeType,
)
}
if (routeState != null) {
viewModelScope.launch {
async { buildPolylines() }
async { buildMarkers() }
}
if (route != null) {
viewModelScope.launch { buildPolylines(route) }
viewModelScope.launch { buildStops(route) }
// viewModelScope.launch { buildDepartures() }
// viewModelScope.launch { buildRuns() }
}
}
// [TODO]: Cleanup
suspend fun switchStop(stop: PtvStop?) {
iState.update { state ->
state.copy(stopState = stop?.let { StopState(it) })
}
if (stop == null)
if (stop == null) {
iInfoState.update { InfoPanelState.None }
return
}
val split = stop.stopName.split("/")
val name = split[0]
val subname = split.getOrNull(1)
iInfoState.update {
InfoPanelState.Stop(
id = stop.stopId,
name = name,
subname = subname,
)
}
val res = ptvService.departures(stop.routeType, stop.stopId)
// Map<
@ -155,18 +157,19 @@ class BanksiaViewModel : ViewModel() {
}
val departures = timetable.values.sortedBy { it.first }.map { (name, list) ->
if (list.isEmpty())
Pair(name, "No departures")
InfoPanelState.Stop.Departure(name, "No departures")
else
Pair(name, list.joinToString(" | "))
InfoPanelState.Stop.Departure(name, list.joinToString(" | "))
}
iState.update {
it.copy(stopState = it.stopState?.copy(departures = departures))
iInfoState.update {
if (it !is InfoPanelState.Stop)
it
else
it.copy(departures = departures)
}
}
private suspend fun buildPolylines() {
val route = iState.value.routeState?.route ?: return
private suspend fun buildPolylines(route: PtvRoute) {
val routeWithGeo = if (route.geopath.isEmpty())
ptvService.route(route.routeId, true)
else
@ -198,9 +201,25 @@ class BanksiaViewModel : ViewModel() {
newCameraPosition?.let { iCameraChangeEmitter.emit(it.box()) }
}
private suspend fun buildMarkers() {
val route = iState.value.routeState?.route ?: return
// private suspend fun buildDepartures(route: PtvRoute) {
// val directions = ptvService.directionsByRoute(route.routeId)
//
// iState.update {
// it.copy(routeState = it.routeState?.copy(directions = directions))
// }
// }
//
// private suspend fun buildRuns() {
// val route = iState.value.routeState?.route ?: return
//
// val directions = ptvService.directionsByRoute(route.routeId)
//
// iState.update {
// it.copy(routeState = it.routeState?.copy(directions = directions))
// }
// }
private suspend fun buildStops(route: PtvRoute) {
val stops = ptvService.stopsByRoute(route.routeId, route.routeType)
val markers = mutableListOf<Marker>()
val colour = route.routeType.getProperties().colour
@ -222,12 +241,7 @@ class BanksiaViewModel : ViewModel() {
}
}
iState.update {
it.copy(
routeState = it.routeState?.copy(stops = stops),
markers = markers
)
}
iState.update { it.copy(markers = markers) }
}
private fun buildBounds(points: List<Point>): CameraPositionBounds {

View file

@ -29,14 +29,19 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceAtMost
import androidx.compose.ui.unit.dp
import moe.lava.banksia.api.ptv.structures.ComposableRouteIcon
import moe.lava.banksia.ui.state.InfoPanelState
@Composable
fun StopInfoPanel(
stopState: StopState,
fun InfoPanel(
state: InfoPanelState,
onEvent: (BanksiaEvent) -> Unit,
onPeekHeightChange: (Dp) -> Unit,
) {
if (state is InfoPanelState.None)
return
val localDensity = LocalDensity.current
val (stop, departures) = stopState
Column(
Modifier
@ -49,36 +54,13 @@ fun StopInfoPanel(
}
) {
Box {
Column(Modifier.fillMaxWidth()) {
val split = stop.stopName.split("/")
val mainName = split[0]
val subName = split.getOrNull(1)
Text(
mainName,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Start
)
if (subName != null)
Text(
"/ $subName",
modifier = Modifier.padding(start = 5.dp),
style = MaterialTheme.typography.titleSmall,
color = Color.Gray,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Start
)
departures?.let {
Spacer(Modifier.height(5.dp))
it.forEach { (name, formatted) ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(formatted, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 5.dp))
}
}
}
when (state) {
is InfoPanelState.Route -> RouteInfoPanel(state, onEvent)
is InfoPanelState.Stop -> StopInfoPanel(state, onEvent)
else -> throw UnsupportedOperationException()
}
if (departures == null)
if (state.loading)
CircularProgressIndicator(
modifier = Modifier.width(32.dp).align(Alignment.CenterEnd)
)
@ -86,3 +68,55 @@ fun StopInfoPanel(
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeContent))
}
}
@Composable
private fun RouteInfoPanel(
state: InfoPanelState.Route,
onEvent: (BanksiaEvent) -> Unit,
) {
Column(Modifier.fillMaxWidth()) {
Row {
ComposableRouteIcon(state.type)
Text(
state.name,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Start
)
}
}
}
@Composable
private fun StopInfoPanel(
state: InfoPanelState.Stop,
onEvent: (BanksiaEvent) -> Unit,
) {
Column(Modifier.fillMaxWidth()) {
Text(
state.name,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Start
)
state.subname?.let {
Text(
"/ $it",
modifier = Modifier.padding(start = 5.dp),
style = MaterialTheme.typography.titleSmall,
color = Color.Gray,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Start
)
}
state.departures?.let {
Spacer(Modifier.height(5.dp))
it.forEach { (name, formatted) ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(formatted, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 5.dp))
}
}
}
}
}

View file

@ -5,51 +5,38 @@ 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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.backhandler.PredictiveBackHandler
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import moe.lava.banksia.api.ptv.structures.ComposableIcon
import moe.lava.banksia.api.ptv.structures.PtvRoute
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.pow
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Searcher(
selectedRoute: PtvRoute?,
routes: List<PtvRoute>,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
@ -84,21 +71,7 @@ fun Searcher(
null -> PaddingValues()
}
PredictiveBackHandler(enabled = selectedRoute != null) { progress ->
try {
progress.collect { backEvent ->
backProgress = backEvent.progress
backEdgeIsLeft = backEvent.swipeEdge == 0
}
backProgress = 1f
onRouteChange(null)
} catch (_: CancellationException) {
backProgress = 0f
}
backEdgeIsLeft = null
}
SearchBarDefaults.InputField(
enabled = selectedRoute == null,
modifier = Modifier
.alpha(1f - routeInfoOpacity)
.padding(horizontal = 20.dp - animatedPadding),
@ -117,11 +90,6 @@ fun Searcher(
)
}
)
LaunchedEffect(selectedRoute) {
backProgress = if (selectedRoute != null) 0f else 1f;
}
if (selectedRoute != null)
RouteInfo(routeInfoOpacity, slidePadding, onRouteChange, selectedRoute)
},
expanded = expanded,
onExpandedChange = onExpandedChange,
@ -156,47 +124,3 @@ fun Searcher(
}
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun RouteInfo(
routeInfoOpacity: Float,
slidePadding: PaddingValues,
onRouteChange: (PtvRoute?) -> Unit,
route: PtvRoute
) {
Box(
modifier = Modifier
.alpha(routeInfoOpacity)
.sizeIn(
minHeight = SearchBarDefaults.InputFieldHeight,
maxHeight = SearchBarDefaults.InputFieldHeight,
)
.fillMaxWidth()
.padding(slidePadding)
) {
IconButton(
modifier = Modifier.align(Alignment.CenterStart),
onClick = {
onRouteChange(null)
}
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Clear route")
}
Row(
modifier = Modifier
.padding(horizontal = 50.dp)
.align(Alignment.Center),
verticalAlignment = Alignment.CenterVertically,
) {
route.routeType.ComposableIcon()
Spacer(Modifier.width(15.dp))
Text(
route.routeNumber.ifEmpty { route.routeName },
style = MaterialTheme.typography.headlineSmall,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
}
}

View file

@ -0,0 +1,30 @@
package moe.lava.banksia.ui.state
import moe.lava.banksia.api.ptv.structures.PtvRouteType
sealed class InfoPanelState {
abstract val loading: Boolean
data object None : InfoPanelState() {
override val loading = false
}
data class Route(
val name: String,
val type: PtvRouteType,
) : InfoPanelState() {
override val loading = false
}
data class Stop(
val id: Int,
val name: String,
val subname: String? = null,
val departures: List<Departure>? = null,
) : InfoPanelState() {
override val loading: Boolean
get() = departures == null
data class Departure(val directionName: String, val formattedTimes: String)
}
}

View file

@ -32,6 +32,7 @@ kotlin {
implementation(libs.ktor.client.contentnegotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
}
iosMain.dependencies {

View file

@ -0,0 +1,28 @@
package moe.lava.banksia.api.ptv.structures
import kotlinx.datetime.Instant
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
data class PtvVehiclePosition(
val latitude: Double,
val longitude: Double,
val easting: Double?,
val northing: Double?,
val direction: String?,
val bearing: Double?,
val supplier: String?,
@SerialName("datetime_utc") val datetimeUtc: Instant?,
@SerialName("expiry_time") val expiryTime: Instant?,
)
@Serializable
data class PtvRun(
@SerialName("run_ref") val runRef: String,
@SerialName("route_id") val routeId: Int,
@SerialName("route_type") val routeType: PtvRouteType,
@SerialName("final_stop_id") val finalStopId: Int,
@SerialName("destination_name") val destinationName: String,
@SerialName("direction_id") val directionId: Int,
@SerialName("status") val status: String,
)