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.R
import moe.lava.banksia.api.ptv.structures.ComposableRouteIcon import moe.lava.banksia.api.ptv.structures.ComposableRouteIcon
import moe.lava.banksia.native.BanksiaTheme 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 moe.lava.banksia.util.BoxedValue
import com.google.android.gms.maps.model.CameraPosition as GoogleCameraPosition import com.google.android.gms.maps.model.CameraPosition as GoogleCameraPosition
@ -64,8 +66,8 @@ actual fun getScreenHeight(): Int {
@Composable @Composable
actual fun Maps( actual fun Maps(
modifier: Modifier, modifier: Modifier,
markers: List<Marker>, state: MapState,
polylines: List<Polyline>, onEvent: (BanksiaEvent) -> Unit,
cameraPositionFlow: Flow<BoxedValue<CameraPosition>>, cameraPositionFlow: Flow<BoxedValue<CameraPosition>>,
setLastKnownLocation: (Point) -> Unit, setLastKnownLocation: (Point) -> Unit,
extInsets: WindowInsets, extInsets: WindowInsets,
@ -115,33 +117,46 @@ actual fun Maps(
contentPadding = WindowInsets.safeDrawing.add(extInsets).asPaddingValues() contentPadding = WindowInsets.safeDrawing.add(extInsets).asPaddingValues()
) { ) {
// [TODO]: Slight lag when routes with many stops such as the 901 bus is set // [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() val state = rememberMarkerState()
state.position = marker.point.toLatLng() state.position = marker.point.toLatLng()
MarkerComposable( MarkerComposable(
keys = arrayOf(marker.data), keys = arrayOf(marker),
zIndex = if (marker.data is Marker.Data.Vehicle) 1f else 0f, zIndex = 0f,
state = state, state = state,
onClick = { marker.onClick() } onClick = {
) { onEvent(BanksiaEvent.SelectStop(marker.type to marker.id))
when (marker.data) { false
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,
)
} }
) {
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( Polyline(
points = polyline.points.map { it.toLatLng() }, points = polyline.points.map { it.toLatLng() },
color = polyline.colour color = polyline.colour

View file

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

View file

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

View file

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

View file

@ -57,7 +57,8 @@ fun InfoPanel(
when (state) { when (state) {
is InfoPanelState.Route -> RouteInfoPanel(state, onEvent) is InfoPanelState.Route -> RouteInfoPanel(state, onEvent)
is InfoPanelState.Stop -> StopInfoPanel(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) 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 @Composable
private inline fun StopInfoPanel( private inline fun StopInfoPanel(
state: InfoPanelState.Stop, state: InfoPanelState.Stop,

View file

@ -16,6 +16,14 @@ sealed class InfoPanelState {
override val loading = false 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( data class Stop(
val id: Int, val id: Int,
val name: String, val name: String,
@ -27,4 +35,4 @@ sealed class InfoPanelState {
data class Departure(val directionName: String, val formattedTimes: String) 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 import moe.lava.banksia.native.maps.Polyline
data class MapState( data class MapState(
val stops: List<Marker> = listOf(), val stops: List<Marker.Stop> = listOf(),
val vehicles: List<Marker> = listOf(), val vehicles: List<Marker.Vehicle> = listOf(),
val polylines: List<Polyline> = 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.Modifier
import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.LocalWindowInfo
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import moe.lava.banksia.ui.BanksiaEvent
import moe.lava.banksia.ui.state.MapState
import moe.lava.banksia.util.BoxedValue import moe.lava.banksia.util.BoxedValue
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@ -19,8 +21,8 @@ actual fun getScreenHeight(): Int {
@Composable @Composable
actual fun Maps( actual fun Maps(
modifier: Modifier, modifier: Modifier,
markers: List<Marker>, state: MapState,
polylines: List<Polyline>, onEvent: (BanksiaEvent) -> Unit,
cameraPositionFlow: Flow<BoxedValue<CameraPosition>>, cameraPositionFlow: Flow<BoxedValue<CameraPosition>>,
setLastKnownLocation: (Point) -> Unit, setLastKnownLocation: (Point) -> Unit,
extInsets: WindowInsets, extInsets: WindowInsets,

View file

@ -10,6 +10,9 @@ import io.ktor.client.request.get
import io.ktor.client.request.parameter import io.ktor.client.request.parameter
import io.ktor.http.appendPathSegments import io.ktor.http.appendPathSegments
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import moe.lava.banksia.Constants import moe.lava.banksia.Constants
@ -44,12 +47,12 @@ object Responses {
data class PtvDirectionsResponse(val directions: List<PtvDirection>) data class PtvDirectionsResponse(val directions: List<PtvDirection>)
} }
class PtvService { 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 routes: HashMap<Int, PtvRoute> = HashMap(),
private val runs: HashMap<String, PtvRun> = HashMap(),
private val stops: HashMap<Int, PtvStop> = HashMap(), private val stops: HashMap<Int, PtvStop> = HashMap(),
) { ) {
suspend fun direction(directionID: Int, routeID: Int): PtvDirection? { suspend fun direction(directionID: Int, routeID: Int): PtvDirection? {
@ -79,6 +82,14 @@ class PtvService {
} }
fun getStop(stopId: Int) = stops[stopId] fun getStop(stopId: Int) = stops[stopId]
fun addRuns(runs: Iterable<PtvRun>) {
runs.forEach {
this.runs[it.runRef] = it
}
}
fun getRun(runRef: String) = runs[runRef]
} }
val cache = PtvCache(this) val cache = PtvCache(this)
@ -133,18 +144,42 @@ class PtvService {
return response.routes return response.routes
} }
suspend fun runs(routeId: Int): List<PtvRun> { fun runFlow(ref: String, firstWithCache: Boolean = false, intervalMillis: Long = 5000): Flow<PtvRun> = flow {
val response: Responses.PtvRunsResponse = client.get() { val cached = cache.getRun(ref)
url { if (firstWithCache && cached != null)
appendPathSegments( emit(cached)
"runs",
"route", while (true) {
routeId.toString(), val response: Responses.PtvRunsResponse = client.get {
) url {
parameter("expand", "VehiclePosition") appendPathSegments(
} "runs",
}.body() ref,
return response.runs )
}
}.body()
cache.addRuns(response.runs)
emit(response.runs[0])
delay(intervalMillis)
}
}
fun runsFlow(routeId: Int, intervalMillis: Long = 5000): Flow<List<PtvRun>> = flow {
while (true) {
val response: Responses.PtvRunsResponse = client.get {
url {
appendPathSegments(
"runs",
"route",
routeId.toString(),
)
parameter("expand", "VehiclePosition")
}
}.body()
cache.addRuns(response.runs)
emit(response.runs)
delay(intervalMillis)
}
} }
suspend fun stopsByRoute(routeId: Int, routeType: PtvRouteType): List<PtvStop> { suspend fun stopsByRoute(routeId: Int, routeType: PtvRouteType): List<PtvStop> {