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

View file

@ -42,9 +42,10 @@ fun PtvRouteType.getProperties(): RouteTypeProperties {
} }
return RouteTypeProperties(colour, drawable, background, icon) return RouteTypeProperties(colour, drawable, background, icon)
} }
@Composable @Composable
fun PtvRouteType.ComposableIcon() { fun ComposableRouteIcon(routeType: PtvRouteType) {
val properties = this.getProperties() val properties = routeType.getProperties()
Image( Image(
painter = painterResource(properties.icon), painter = painterResource(properties.icon),
contentDescription = null, 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 androidx.lifecycle.viewModelScope
import dev.icerock.moko.geo.LocationTracker import dev.icerock.moko.geo.LocationTracker
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn 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.MarkerType
import moe.lava.banksia.native.maps.Point 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.util.BoxedValue import moe.lava.banksia.util.BoxedValue
import moe.lava.banksia.util.BoxedValue.Companion.box import moe.lava.banksia.util.BoxedValue.Companion.box
data class RouteState( sealed class BanksiaEvent {}
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,
)
data class BanksiaViewState( data class BanksiaViewState(
val routeState: RouteState? = null,
val stopState: StopState? = null,
val routes: List<PtvRoute> = listOf(), val routes: List<PtvRoute> = listOf(),
val markers: List<Marker> = listOf(), val markers: List<Marker> = listOf(),
val polylines: List<Polyline> = listOf(), val polylines: List<Polyline> = listOf(),
) )
class BanksiaViewModel : ViewModel() { class BanksiaViewModel : ViewModel() {
private val iState = MutableStateFlow(BanksiaViewState()) 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 val ptvService = PtvService()
private var locationTrackerJob: Job? = null private var locationTrackerJob: Job? = null
@ -68,6 +57,8 @@ class BanksiaViewModel : ViewModel() {
} }
} }
fun handleEvent(event: BanksiaEvent) {}
fun bindTracker(locationTracker: LocationTracker) { fun bindTracker(locationTracker: LocationTracker) {
locationTrackerJob = locationTracker.getLocationsFlow() locationTrackerJob = locationTracker.getLocationsFlow()
.onEach { lastKnownLocation = Point(it.latitude, it.longitude) } .onEach { lastKnownLocation = Point(it.latitude, it.longitude) }
@ -98,36 +89,47 @@ class BanksiaViewModel : ViewModel() {
iState.update { it.copy(routes = routes) } iState.update { it.copy(routes = routes) }
} }
fun switchRoute(newRoute: PtvRoute?) { fun switchRoute(route: PtvRoute?) {
val routeState = newRoute?.let { RouteState(it) }
if (iState.value.routeState == routeState)
return
iState.update { iState.update {
it.copy( it.copy(
routeState = routeState,
stopState = null,
markers = listOf(), markers = listOf(),
polylines = listOf(), polylines = listOf(),
) )
} }
iInfoState.update {
if (routeState != null) { if (route == null)
viewModelScope.launch { InfoPanelState.None
async { buildPolylines() } else
async { buildMarkers() } 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 // [TODO]: Cleanup
suspend fun switchStop(stop: PtvStop?) { suspend fun switchStop(stop: PtvStop?) {
iState.update { state -> if (stop == null) {
state.copy(stopState = stop?.let { StopState(it) }) iInfoState.update { InfoPanelState.None }
}
if (stop == null)
return 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) val res = ptvService.departures(stop.routeType, stop.stopId)
// Map< // Map<
@ -155,18 +157,19 @@ class BanksiaViewModel : ViewModel() {
} }
val departures = timetable.values.sortedBy { it.first }.map { (name, list) -> val departures = timetable.values.sortedBy { it.first }.map { (name, list) ->
if (list.isEmpty()) if (list.isEmpty())
Pair(name, "No departures") InfoPanelState.Stop.Departure(name, "No departures")
else else
Pair(name, list.joinToString(" | ")) InfoPanelState.Stop.Departure(name, list.joinToString(" | "))
} }
iState.update { iInfoState.update {
it.copy(stopState = it.stopState?.copy(departures = departures)) if (it !is InfoPanelState.Stop)
it
else
it.copy(departures = departures)
} }
} }
private suspend fun buildPolylines() { private suspend fun buildPolylines(route: PtvRoute) {
val route = iState.value.routeState?.route ?: return
val routeWithGeo = if (route.geopath.isEmpty()) val routeWithGeo = if (route.geopath.isEmpty())
ptvService.route(route.routeId, true) ptvService.route(route.routeId, true)
else else
@ -198,9 +201,25 @@ class BanksiaViewModel : ViewModel() {
newCameraPosition?.let { iCameraChangeEmitter.emit(it.box()) } newCameraPosition?.let { iCameraChangeEmitter.emit(it.box()) }
} }
private suspend fun buildMarkers() { // private suspend fun buildDepartures(route: PtvRoute) {
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 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 stops = ptvService.stopsByRoute(route.routeId, route.routeType)
val markers = mutableListOf<Marker>() val markers = mutableListOf<Marker>()
val colour = route.routeType.getProperties().colour val colour = route.routeType.getProperties().colour
@ -222,12 +241,7 @@ class BanksiaViewModel : ViewModel() {
} }
} }
iState.update { iState.update { it.copy(markers = markers) }
it.copy(
routeState = it.routeState?.copy(stops = stops),
markers = markers
)
}
} }
private fun buildBounds(points: List<Point>): CameraPositionBounds { 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.Dp
import androidx.compose.ui.unit.coerceAtMost import androidx.compose.ui.unit.coerceAtMost
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import moe.lava.banksia.api.ptv.structures.ComposableRouteIcon
import moe.lava.banksia.ui.state.InfoPanelState
@Composable @Composable
fun StopInfoPanel( fun InfoPanel(
stopState: StopState, state: InfoPanelState,
onEvent: (BanksiaEvent) -> Unit,
onPeekHeightChange: (Dp) -> Unit, onPeekHeightChange: (Dp) -> Unit,
) { ) {
if (state is InfoPanelState.None)
return
val localDensity = LocalDensity.current val localDensity = LocalDensity.current
val (stop, departures) = stopState
Column( Column(
Modifier Modifier
@ -49,26 +54,62 @@ fun StopInfoPanel(
} }
) { ) {
Box { Box {
when (state) {
is InfoPanelState.Route -> RouteInfoPanel(state, onEvent)
is InfoPanelState.Stop -> StopInfoPanel(state, onEvent)
else -> throw UnsupportedOperationException()
}
if (state.loading)
CircularProgressIndicator(
modifier = Modifier.width(32.dp).align(Alignment.CenterEnd)
)
}
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeContent))
}
}
@Composable
private fun RouteInfoPanel(
state: InfoPanelState.Route,
onEvent: (BanksiaEvent) -> Unit,
) {
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
val split = stop.stopName.split("/") Row {
val mainName = split[0] ComposableRouteIcon(state.type)
val subName = split.getOrNull(1)
Text( Text(
mainName, state.name,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Start textAlign = TextAlign.Start
) )
if (subName != null) }
}
}
@Composable
private fun StopInfoPanel(
state: InfoPanelState.Stop,
onEvent: (BanksiaEvent) -> Unit,
) {
Column(Modifier.fillMaxWidth()) {
Text( Text(
"/ $subName", state.name,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Start
)
state.subname?.let {
Text(
"/ $it",
modifier = Modifier.padding(start = 5.dp), modifier = Modifier.padding(start = 5.dp),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
color = Color.Gray, color = Color.Gray,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Start textAlign = TextAlign.Start
) )
departures?.let { }
state.departures?.let {
Spacer(Modifier.height(5.dp)) Spacer(Modifier.height(5.dp))
it.forEach { (name, formatted) -> it.forEach { (name, formatted) ->
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@ -78,11 +119,4 @@ fun StopInfoPanel(
} }
} }
} }
if (departures == null)
CircularProgressIndicator(
modifier = Modifier.width(32.dp).align(Alignment.CenterEnd)
)
}
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeContent))
}
} }

View file

@ -5,51 +5,38 @@ 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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons 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.Clear
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBar import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults 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.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.backhandler.PredictiveBackHandler
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
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.ComposableIcon
import moe.lava.banksia.api.ptv.structures.PtvRoute import moe.lava.banksia.api.ptv.structures.PtvRoute
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.pow import kotlin.math.pow
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun Searcher( fun Searcher(
selectedRoute: PtvRoute?,
routes: List<PtvRoute>, routes: List<PtvRoute>,
expanded: Boolean, expanded: Boolean,
onExpandedChange: (Boolean) -> Unit, onExpandedChange: (Boolean) -> Unit,
@ -84,21 +71,7 @@ fun Searcher(
null -> PaddingValues() 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( SearchBarDefaults.InputField(
enabled = selectedRoute == null,
modifier = Modifier modifier = Modifier
.alpha(1f - routeInfoOpacity) .alpha(1f - routeInfoOpacity)
.padding(horizontal = 20.dp - animatedPadding), .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, expanded = expanded,
onExpandedChange = onExpandedChange, 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.client.contentnegotiation)
implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
} }
iosMain.dependencies { 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,
)