feat: preliminary run info panel, and heavy refactoring

This commit is contained in:
LavaDesu 2025-07-30 03:18:52 +10:00
parent e52274a6ef
commit ce8425d6a7
Signed by: cilly
GPG key ID: 6500251E087653C9
9 changed files with 226 additions and 97 deletions

View file

@ -43,6 +43,8 @@ import kotlinx.coroutines.flow.Flow
import moe.lava.banksia.R
import moe.lava.banksia.api.ptv.structures.ComposableRouteIcon
import moe.lava.banksia.native.BanksiaTheme
import moe.lava.banksia.ui.BanksiaEvent
import moe.lava.banksia.ui.state.MapState
import moe.lava.banksia.util.BoxedValue
import com.google.android.gms.maps.model.CameraPosition as GoogleCameraPosition
@ -64,8 +66,8 @@ actual fun getScreenHeight(): Int {
@Composable
actual fun Maps(
modifier: Modifier,
markers: List<Marker>,
polylines: List<Polyline>,
state: MapState,
onEvent: (BanksiaEvent) -> Unit,
cameraPositionFlow: Flow<BoxedValue<CameraPosition>>,
setLastKnownLocation: (Point) -> Unit,
extInsets: WindowInsets,
@ -115,33 +117,46 @@ actual fun Maps(
contentPadding = WindowInsets.safeDrawing.add(extInsets).asPaddingValues()
) {
// [TODO]: Slight lag when routes with many stops such as the 901 bus is set
for (marker in markers) {
for (marker in state.stops) {
val state = rememberMarkerState()
state.position = marker.point.toLatLng()
MarkerComposable(
keys = arrayOf(marker.data),
zIndex = if (marker.data is Marker.Data.Vehicle) 1f else 0f,
keys = arrayOf(marker),
zIndex = 0f,
state = state,
onClick = { marker.onClick() }
) {
when (marker.data) {
is Marker.Data.Stop ->
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(BanksiaTheme.colors.surface)
.border(2.dp, marker.data.colour, CircleShape)
)
is Marker.Data.Vehicle ->
ComposableRouteIcon(
size = 30.dp,
routeType = marker.data.type,
)
onClick = {
onEvent(BanksiaEvent.SelectStop(marker.type to marker.id))
false
}
) {
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(BanksiaTheme.colors.surface)
.border(2.dp, marker.colour, CircleShape)
)
}
}
for (polyline in polylines) {
for (marker in state.vehicles) {
val state = rememberMarkerState()
state.position = marker.point.toLatLng()
MarkerComposable(
keys = arrayOf(marker),
zIndex = 1f,
state = state,
onClick = {
onEvent(BanksiaEvent.SelectRun(marker.ref))
false
}
) {
ComposableRouteIcon(
size = 30.dp,
routeType = marker.type,
)
}
}
for (polyline in state.polylines) {
Polyline(
points = polyline.points.map { it.toLatLng() },
color = polyline.colour

View file

@ -136,13 +136,13 @@ fun App(
) {
Maps(
modifier = Modifier.fillMaxSize(),
state = mapState,
onEvent = viewModel::handleEvent,
cameraPositionFlow = viewModel.cameraChangeEmitter,
extInsets = WindowInsets(top = with(LocalDensity.current) {
SearchBarDefaults.InputFieldHeight.roundToPx()
}, bottom = extInsets),
markers = mapState.stops + mapState.vehicles,
setLastKnownLocation = viewModel::setLastKnownLocation,
polylines = mapState.polylines,
)
Searcher(
state = searchState,

View file

@ -7,17 +7,25 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import kotlinx.coroutines.flow.Flow
import moe.lava.banksia.api.ptv.structures.PtvRouteType
import moe.lava.banksia.ui.BanksiaEvent
import moe.lava.banksia.ui.state.MapState
import moe.lava.banksia.util.BoxedValue
data class Marker(
val point: Point,
val data: Data,
val onClick: () -> Boolean,
) {
sealed class Data {
data class Stop(val colour: Color) : Data()
data class Vehicle(val type: PtvRouteType) : Data()
}
sealed class Marker {
abstract val point: Point
data class Stop(
override val point: Point,
val id: Int,
val type: PtvRouteType,
val colour: Color,
) : Marker()
data class Vehicle(
override val point: Point,
val ref: String,
val type: PtvRouteType,
) : Marker()
}
data class Point(val lat: Double, val lng: Double)
data class Polyline(val points: List<Point>, val colour: Color)
@ -35,8 +43,8 @@ expect fun getScreenHeight(): Int
@Composable
expect fun Maps(
modifier: Modifier = Modifier,
markers: List<Marker> = listOf(),
polylines: List<Polyline> = listOf(),
state: MapState,
onEvent: (BanksiaEvent) -> Unit,
cameraPositionFlow: Flow<BoxedValue<CameraPosition>>,
setLastKnownLocation: (Point) -> Unit,
extInsets: WindowInsets,

View file

@ -10,7 +10,9 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
@ -34,12 +36,31 @@ sealed class BanksiaEvent {
data object DismissState : BanksiaEvent()
data class SelectRoute(val id: Int?) : BanksiaEvent()
data class SelectStop(val routeType: PtvRouteType, val stopId: Int?) : BanksiaEvent()
data class SelectRun(val ref: String?) : BanksiaEvent()
data class SelectStop(val typeAndId: Pair<PtvRouteType, Int>) : BanksiaEvent()
data class SearchUpdate(val text: String) : BanksiaEvent()
}
data class InternalState(
val route: Int? = null,
val stop: Pair<PtvRouteType, Int>? = null,
val run: String? = null,
)
class BanksiaViewModel : ViewModel() {
private var state = InternalState()
set(value) {
val last = field
field = value
if (value.route != last.route)
viewModelScope.launch { switchRoute(value.route) }
if (value.stop != last.stop)
viewModelScope.launch { switchStop(value.stop) }
if (value.run != last.run)
switchRun(value.run)
}
private val iInfoState = MutableStateFlow<InfoPanelState>(InfoPanelState.None)
val infoState = iInfoState.asStateFlow()
@ -64,7 +85,8 @@ class BanksiaViewModel : ViewModel() {
when (event) {
is BanksiaEvent.DismissState -> dismissState()
is BanksiaEvent.SelectRoute -> switchRoute(event.id)
is BanksiaEvent.SelectStop -> switchStop(event.routeType, event.stopId)
is BanksiaEvent.SelectRun -> switchRun(event.ref)
is BanksiaEvent.SelectStop -> switchStop(event.typeAndId)
is BanksiaEvent.SearchUpdate -> searchUpdate(event.text)
}
}
@ -135,18 +157,49 @@ class BanksiaViewModel : ViewModel() {
}
viewModelScope.launch { buildPolylines(route) }
viewModelScope.launch { buildRuns(route) }
viewModelScope.launch { buildStops(route) }
// viewModelScope.launch { buildDepartures() }
// viewModelScope.launch { buildRuns() }
buildRuns(route)
}
// [TODO]: Cleanup
private suspend fun switchStop(routeType: PtvRouteType, stopId: Int?) {
if (stopId == null) {
private fun switchRun(ref: String?) {
if (ref == null) {
iInfoState.update { InfoPanelState.None }
return
}
var lastState = iInfoState.value
var routeName: String? = null
ptvService.runFlow(ref, firstWithCache = true)
.takeWhile { lastState == iInfoState.value }
.onEach { run ->
if (routeName == null) {
lastState = iInfoState.updateAndGet {
InfoPanelState.Run(
direction = run.destinationName,
type = run.routeType,
)
}
routeName = ptvService.route(run.routeId).routeName
}
lastState = iInfoState.updateAndGet {
InfoPanelState.Run(
direction = run.destinationName,
type = run.routeType,
routeName = routeName,
)
}
}
.launchIn(viewModelScope)
}
// [TODO]: Cleanup
private suspend fun switchStop(typeAndId: Pair<PtvRouteType, Int>?) {
if (typeAndId == null) {
iInfoState.update { InfoPanelState.None }
return
}
val (routeType, stopId) = typeAndId
val stop = ptvService.stop(routeType, stopId)
val split = stop.stopName.split("/")
val name = split[0]
@ -229,40 +282,31 @@ class BanksiaViewModel : ViewModel() {
newCameraPosition?.let { iCameraChangeEmitter.emit(it.box()) }
}
private suspend fun buildRuns(route: PtvRoute) {
val runs = ptvService.runs(route.routeId)
var runsRouteKey: Int? = null
private fun buildRuns(route: PtvRoute) {
runsRouteKey = route.routeId
ptvService
.runsFlow(route.routeId)
.takeWhile { route.routeId == runsRouteKey }
.onEach { runs ->
val markers = runs
.filter { it.vehiclePosition != null }
.map { it to it.vehiclePosition!! }
.distinctBy { (_, pos) -> pos.latitude to pos.longitude }
.map { (run, pos) ->
Marker.Vehicle(
Point(pos.latitude, pos.longitude),
ref = run.runRef,
type = route.routeType,
)
}
val markers = runs.mapNotNull { it.vehiclePosition }
.distinctBy { it.latitude to it.longitude }
.map {
Marker(
Point(it.latitude, it.longitude),
onClick = { false },
data = Marker.Data.Vehicle(route.routeType)
)
iMapState.update { it.copy(vehicles = markers) }
}
.launchIn(viewModelScope)
iMapState.update { it.copy(vehicles = markers) }
}
// 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 colour = route.routeType.getProperties().colour
@ -270,13 +314,11 @@ class BanksiaViewModel : ViewModel() {
val markers = stops
.filter { it.stopLatitude != null && it.stopLongitude != null }
.map { stop ->
Marker(
Marker.Stop(
point = Point(stop.stopLatitude!!, stop.stopLongitude!!),
data = Marker.Data.Stop(colour),
onClick = {
viewModelScope.launch { switchStop(route.routeType, stop.stopId) }
false
}
id = stop.stopId,
colour = colour,
type = route.routeType,
)
}

View file

@ -57,7 +57,8 @@ fun InfoPanel(
when (state) {
is InfoPanelState.Route -> RouteInfoPanel(state, onEvent)
is InfoPanelState.Stop -> StopInfoPanel(state, onEvent)
else -> throw UnsupportedOperationException()
is InfoPanelState.Run -> RunInfoPanel(state, onEvent)
is InfoPanelState.None -> throw UnsupportedOperationException()
}
if (state.loading)
@ -87,6 +88,24 @@ private inline fun RouteInfoPanel(
}
}
@Composable
private inline fun RunInfoPanel(
state: InfoPanelState.Run,
onEvent: (BanksiaEvent) -> Unit,
) {
Column(Modifier.fillMaxWidth()) {
Row {
ComposableRouteIcon(routeType = state.type)
Text(
"${state.direction} via ${state.routeName ?: "..."}",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Start
)
}
}
}
@Composable
private inline fun StopInfoPanel(
state: InfoPanelState.Stop,

View file

@ -16,6 +16,14 @@ sealed class InfoPanelState {
override val loading = false
}
data class Run(
val direction: String,
val type: PtvRouteType,
val routeName: String? = null,
) : InfoPanelState() {
override val loading = routeName == null
}
data class Stop(
val id: Int,
val name: String,
@ -27,4 +35,4 @@ sealed class InfoPanelState {
data class Departure(val directionName: String, val formattedTimes: String)
}
}
}

View file

@ -4,7 +4,7 @@ import moe.lava.banksia.native.maps.Marker
import moe.lava.banksia.native.maps.Polyline
data class MapState(
val stops: List<Marker> = listOf(),
val vehicles: List<Marker> = listOf(),
val stops: List<Marker.Stop> = listOf(),
val vehicles: List<Marker.Vehicle> = listOf(),
val polylines: List<Polyline> = listOf(),
)

View file

@ -7,6 +7,8 @@ import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalWindowInfo
import kotlinx.coroutines.flow.Flow
import moe.lava.banksia.ui.BanksiaEvent
import moe.lava.banksia.ui.state.MapState
import moe.lava.banksia.util.BoxedValue
@OptIn(ExperimentalComposeUiApi::class)
@ -19,8 +21,8 @@ actual fun getScreenHeight(): Int {
@Composable
actual fun Maps(
modifier: Modifier,
markers: List<Marker>,
polylines: List<Polyline>,
state: MapState,
onEvent: (BanksiaEvent) -> Unit,
cameraPositionFlow: Flow<BoxedValue<CameraPosition>>,
setLastKnownLocation: (Point) -> Unit,
extInsets: WindowInsets,