feat: vehicle positions and state dismissal

This commit is contained in:
LavaDesu 2025-07-29 20:35:32 +10:00
parent c526269e5d
commit e52274a6ef
Signed by: cilly
GPG key ID: 6500251E087653C9
10 changed files with 163 additions and 42 deletions

View file

@ -18,7 +18,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
@ -42,6 +41,7 @@ import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberMarkerState import com.google.maps.android.compose.rememberMarkerState
import kotlinx.coroutines.flow.Flow 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.native.BanksiaTheme import moe.lava.banksia.native.BanksiaTheme
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
@ -70,7 +70,6 @@ actual fun Maps(
setLastKnownLocation: (Point) -> Unit, setLastKnownLocation: (Point) -> Unit,
extInsets: WindowInsets, extInsets: WindowInsets,
) { ) {
val scope = rememberCoroutineScope()
val camPos = rememberCameraPositionState() val camPos = rememberCameraPositionState()
val newCameraPos by cameraPositionFlow.collectAsStateWithLifecycle(null) val newCameraPos by cameraPositionFlow.collectAsStateWithLifecycle(null)
LaunchedEffect(newCameraPos) { LaunchedEffect(newCameraPos) {
@ -120,17 +119,26 @@ actual fun Maps(
val state = rememberMarkerState() val state = rememberMarkerState()
state.position = marker.point.toLatLng() state.position = marker.point.toLatLng()
MarkerComposable( MarkerComposable(
keys = arrayOf(marker.colour), keys = arrayOf(marker.data),
zIndex = if (marker.data is Marker.Data.Vehicle) 1f else 0f,
state = state, state = state,
onClick = { marker.onClick() } onClick = { marker.onClick() }
) { ) {
Box( when (marker.data) {
modifier = Modifier is Marker.Data.Stop ->
.size(12.dp) Box(
.clip(CircleShape) modifier = Modifier
.background(BanksiaTheme.colors.surface) .size(12.dp)
.border(2.dp, marker.colour, CircleShape) .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,
)
}
} }
} }
for (polyline in polylines) { for (polyline in polylines) {

View file

@ -47,6 +47,7 @@ import moe.lava.banksia.native.maps.Point
import moe.lava.banksia.native.maps.getScreenHeight 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.BanksiaEvent
import moe.lava.banksia.ui.BanksiaViewModel import moe.lava.banksia.ui.BanksiaViewModel
import moe.lava.banksia.ui.InfoPanel import moe.lava.banksia.ui.InfoPanel
import moe.lava.banksia.ui.Searcher import moe.lava.banksia.ui.Searcher
@ -139,7 +140,7 @@ fun App(
extInsets = WindowInsets(top = with(LocalDensity.current) { extInsets = WindowInsets(top = with(LocalDensity.current) {
SearchBarDefaults.InputFieldHeight.roundToPx() SearchBarDefaults.InputFieldHeight.roundToPx()
}, bottom = extInsets), }, bottom = extInsets),
markers = mapState.markers, markers = mapState.stops + mapState.vehicles,
setLastKnownLocation = viewModel::setLastKnownLocation, setLastKnownLocation = viewModel::setLastKnownLocation,
polylines = mapState.polylines, polylines = mapState.polylines,
) )
@ -168,6 +169,7 @@ fun App(
scope.launch { scope.launch {
scaffoldState.bottomSheetState.hide() scaffoldState.bottomSheetState.hide()
peekHeightMultiplier = 1F peekHeightMultiplier = 1F
viewModel.handleEvent(BanksiaEvent.DismissState)
} }
} catch (_: CancellationException) { } catch (_: CancellationException) {
peekHeightMultiplier = 1F peekHeightMultiplier = 1F

View file

@ -1,10 +1,15 @@
package moe.lava.banksia.api.ptv.structures package moe.lava.banksia.api.ptv.structures
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import moe.lava.banksia.resources.Res import moe.lava.banksia.resources.Res
import moe.lava.banksia.resources.bus import moe.lava.banksia.resources.bus
@ -18,6 +23,7 @@ import moe.lava.banksia.resources.tram_background
import moe.lava.banksia.resources.tram_icon import moe.lava.banksia.resources.tram_icon
import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.ui.tooling.preview.Preview
data class RouteTypeProperties( data class RouteTypeProperties(
val colour: Color, val colour: Color,
@ -43,18 +49,37 @@ fun PtvRouteType.getProperties(): RouteTypeProperties {
return RouteTypeProperties(colour, drawable, background, icon) return RouteTypeProperties(colour, drawable, background, icon)
} }
const val ICON_PADDING = 0.25f
@Preview
@Composable @Composable
fun ComposableRouteIcon(routeType: PtvRouteType) { private fun RouteIconPreview() {
Row {
ComposableRouteIcon(routeType = PtvRouteType.TRAIN)
ComposableRouteIcon(routeType = PtvRouteType.TRAM)
ComposableRouteIcon(routeType = PtvRouteType.BUS)
}
}
@Composable
fun ComposableRouteIcon(
modifier: Modifier = Modifier,
size: Dp = 40.dp,
routeType: PtvRouteType,
) {
val properties = routeType.getProperties() val properties = routeType.getProperties()
Image( Image(
painter = painterResource(properties.icon), painter = painterResource(properties.icon),
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = modifier
.size(size)
.aspectRatio(1f)
.padding(size * ICON_PADDING / 2)
.drawBehind { .drawBehind {
drawCircle(properties.colour, radius = (this.size.minDimension + 10.dp.toPx()) / 2f) drawCircle(properties.colour, radius = size.toPx() / 2f)
} }
) )
} }
@Composable @Composable
inline fun PtvRouteType.ComposableIcon() = ComposableRouteIcon(this) inline fun PtvRouteType.ComposableIcon(modifier: Modifier = Modifier) = ComposableRouteIcon(modifier, routeType = this)

View file

@ -6,17 +6,19 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier 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.util.BoxedValue import moe.lava.banksia.util.BoxedValue
enum class MarkerType {
GENERIC_STOP,
}
data class Marker( data class Marker(
val point: Point, val point: Point,
val type: MarkerType, val data: Data,
val colour: Color, val onClick: () -> Boolean,
val onClick: () -> Boolean ) {
) sealed class Data {
data class Stop(val colour: Color) : Data()
data class Vehicle(val type: PtvRouteType) : Data()
}
}
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)

View file

@ -22,7 +22,6 @@ import moe.lava.banksia.log
import moe.lava.banksia.native.maps.CameraPosition import moe.lava.banksia.native.maps.CameraPosition
import moe.lava.banksia.native.maps.CameraPositionBounds import moe.lava.banksia.native.maps.CameraPositionBounds
import moe.lava.banksia.native.maps.Marker 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.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.ui.state.InfoPanelState
@ -32,6 +31,8 @@ import moe.lava.banksia.util.BoxedValue
import moe.lava.banksia.util.BoxedValue.Companion.box import moe.lava.banksia.util.BoxedValue.Companion.box
sealed class BanksiaEvent { sealed class 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 SelectStop(val routeType: PtvRouteType, val stopId: Int?) : BanksiaEvent()
@ -61,6 +62,7 @@ class BanksiaViewModel : ViewModel() {
fun handleEvent(event: BanksiaEvent) { fun handleEvent(event: BanksiaEvent) {
viewModelScope.launch { viewModelScope.launch {
when (event) { when (event) {
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.SelectStop -> switchStop(event.routeType, event.stopId)
is BanksiaEvent.SearchUpdate -> searchUpdate(event.text) is BanksiaEvent.SearchUpdate -> searchUpdate(event.text)
@ -87,6 +89,13 @@ class BanksiaViewModel : ViewModel() {
lastKnownLocation = location lastKnownLocation = location
} }
private fun dismissState() {
viewModelScope.launch {
switchRoute(null)
searchUpdate("")
}
}
private suspend fun searchUpdate(text: String) { private suspend fun searchUpdate(text: String) {
val entries = ptvService.routes() val entries = ptvService.routes()
.sortedWith( .sortedWith(
@ -126,6 +135,7 @@ 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() } // viewModelScope.launch { buildDepartures() }
// viewModelScope.launch { buildRuns() } // viewModelScope.launch { buildRuns() }
@ -219,6 +229,22 @@ class BanksiaViewModel : ViewModel() {
newCameraPosition?.let { iCameraChangeEmitter.emit(it.box()) } newCameraPosition?.let { iCameraChangeEmitter.emit(it.box()) }
} }
private suspend fun buildRuns(route: PtvRoute) {
val runs = ptvService.runs(route.routeId)
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) }
}
// private suspend fun buildDepartures(route: PtvRoute) { // private suspend fun buildDepartures(route: PtvRoute) {
// val directions = ptvService.directionsByRoute(route.routeId) // val directions = ptvService.directionsByRoute(route.routeId)
// //
@ -239,27 +265,22 @@ class BanksiaViewModel : ViewModel() {
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 markers = mutableListOf<Marker>()
val colour = route.routeType.getProperties().colour val colour = route.routeType.getProperties().colour
for (stop in stops) { val markers = stops
if (stop.stopLatitude != null && stop.stopLongitude != null) { .filter { it.stopLatitude != null && it.stopLongitude != null }
val pos = Point(stop.stopLatitude!!, stop.stopLongitude!!) .map { stop ->
Marker(
val marker = Marker( point = Point(stop.stopLatitude!!, stop.stopLongitude!!),
point = pos, data = Marker.Data.Stop(colour),
type = MarkerType.GENERIC_STOP,
colour = colour,
onClick = { onClick = {
viewModelScope.launch { switchStop(route.routeType, stop.stopId) } viewModelScope.launch { switchStop(route.routeType, stop.stopId) }
false false
} }
) )
markers.add(marker)
} }
}
iMapState.update { it.copy(markers = markers) } iMapState.update { it.copy(stops = markers) }
} }
private fun buildBounds(points: List<Point>): CameraPositionBounds { private fun buildBounds(points: List<Point>): CameraPositionBounds {

View file

@ -70,13 +70,13 @@ fun InfoPanel(
} }
@Composable @Composable
private fun RouteInfoPanel( private inline fun RouteInfoPanel(
state: InfoPanelState.Route, state: InfoPanelState.Route,
onEvent: (BanksiaEvent) -> Unit, onEvent: (BanksiaEvent) -> Unit,
) { ) {
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
Row { Row {
ComposableRouteIcon(state.type) ComposableRouteIcon(routeType = state.type)
Text( Text(
state.name, state.name,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
@ -88,7 +88,7 @@ private fun RouteInfoPanel(
} }
@Composable @Composable
private fun StopInfoPanel( private inline fun StopInfoPanel(
state: InfoPanelState.Stop, state: InfoPanelState.Stop,
onEvent: (BanksiaEvent) -> Unit, onEvent: (BanksiaEvent) -> Unit,
) { ) {

View file

@ -78,7 +78,7 @@ fun Searcher(
ListItem( ListItem(
headlineContent = { Text(entry.mainText) }, headlineContent = { Text(entry.mainText) },
supportingContent = { entry.subText?.let { Text(it) } }, supportingContent = { entry.subText?.let { Text(it) } },
leadingContent = { ComposableRouteIcon(entry.routeType) }, leadingContent = { ComposableRouteIcon(routeType = entry.routeType) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

View file

@ -4,6 +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 markers: List<Marker> = listOf(), val stops: List<Marker> = listOf(),
val vehicles: List<Marker> = listOf(),
val polylines: List<Polyline> = listOf(), val polylines: List<Polyline> = listOf(),
) )

View file

@ -17,6 +17,7 @@ import moe.lava.banksia.api.ptv.structures.PtvDeparture
import moe.lava.banksia.api.ptv.structures.PtvDirection import moe.lava.banksia.api.ptv.structures.PtvDirection
import moe.lava.banksia.api.ptv.structures.PtvRoute import moe.lava.banksia.api.ptv.structures.PtvRoute
import moe.lava.banksia.api.ptv.structures.PtvRouteType import moe.lava.banksia.api.ptv.structures.PtvRouteType
import moe.lava.banksia.api.ptv.structures.PtvRun
import moe.lava.banksia.api.ptv.structures.PtvStop import moe.lava.banksia.api.ptv.structures.PtvStop
import moe.lava.banksia.log import moe.lava.banksia.log
import okio.ByteString.Companion.encodeUtf8 import okio.ByteString.Companion.encodeUtf8
@ -28,6 +29,9 @@ object Responses {
@Serializable @Serializable
data class PtvRoutesResponse(val routes: List<PtvRoute>) data class PtvRoutesResponse(val routes: List<PtvRoute>)
@Serializable
data class PtvRunsResponse(val runs: List<PtvRun>)
@Serializable @Serializable
data class PtvStopResponse(val stop: PtvStop) data class PtvStopResponse(val stop: PtvStop)
@Serializable @Serializable
@ -129,6 +133,20 @@ class PtvService {
return response.routes return response.routes
} }
suspend fun runs(routeId: Int): List<PtvRun> {
val response: Responses.PtvRunsResponse = client.get() {
url {
appendPathSegments(
"runs",
"route",
routeId.toString(),
)
parameter("expand", "VehiclePosition")
}
}.body()
return response.runs
}
suspend fun stopsByRoute(routeId: Int, routeType: PtvRouteType): List<PtvStop> { suspend fun stopsByRoute(routeId: Int, routeType: PtvRouteType): List<PtvStop> {
val response: Responses.PtvStopsResponse = client.get("stops") { val response: Responses.PtvStopsResponse = client.get("stops") {
url { url {

View file

@ -1,9 +1,46 @@
package moe.lava.banksia.api.ptv.structures package moe.lava.banksia.api.ptv.structures
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
// Some datetimes are in local time (no timezone), observed on bus vehicle positions,
// and some datetimes are in UTC, observed on train vehicle positions. We need to handle
// both cases.
private object CustomInstantSerialiser : KSerializer<Instant> {
override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor(
CustomInstantSerialiser::class.qualifiedName!!,
PrimitiveKind.STRING,
)
override fun serialize(
encoder: Encoder,
value: Instant
) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): Instant {
val str = decoder.decodeString()
return runCatching {
Instant.parse(str)
}.getOrElse {
LocalDateTime.parse(str).toInstant(TimeZone.currentSystemDefault())
}
}
}
@Serializable
data class PtvVehiclePosition( data class PtvVehiclePosition(
val latitude: Double, val latitude: Double,
val longitude: Double, val longitude: Double,
@ -12,8 +49,14 @@ data class PtvVehiclePosition(
val direction: String?, val direction: String?,
val bearing: Double?, val bearing: Double?,
val supplier: String?, val supplier: String?,
@SerialName("datetime_utc") val datetimeUtc: Instant?,
@SerialName("expiry_time") val expiryTime: Instant?, @Serializable(CustomInstantSerialiser::class)
@SerialName("datetime_utc")
val datetimeUtc: Instant?,
@Serializable(CustomInstantSerialiser::class)
@SerialName("expiry_time")
val expiryTime: Instant?,
) )
@Serializable @Serializable
@ -25,4 +68,5 @@ data class PtvRun(
@SerialName("destination_name") val destinationName: String, @SerialName("destination_name") val destinationName: String,
@SerialName("direction_id") val directionId: Int, @SerialName("direction_id") val directionId: Int,
@SerialName("status") val status: String, @SerialName("status") val status: String,
@SerialName("vehicle_position") val vehiclePosition: PtvVehiclePosition?,
) )