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.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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 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.util.BoxedValue
import com.google.android.gms.maps.model.CameraPosition as GoogleCameraPosition
@ -70,7 +70,6 @@ actual fun Maps(
setLastKnownLocation: (Point) -> Unit,
extInsets: WindowInsets,
) {
val scope = rememberCoroutineScope()
val camPos = rememberCameraPositionState()
val newCameraPos by cameraPositionFlow.collectAsStateWithLifecycle(null)
LaunchedEffect(newCameraPos) {
@ -120,17 +119,26 @@ actual fun Maps(
val state = rememberMarkerState()
state.position = marker.point.toLatLng()
MarkerComposable(
keys = arrayOf(marker.colour),
keys = arrayOf(marker.data),
zIndex = if (marker.data is Marker.Data.Vehicle) 1f else 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.colour, CircleShape)
.border(2.dp, marker.data.colour, CircleShape)
)
is Marker.Data.Vehicle ->
ComposableRouteIcon(
size = 30.dp,
routeType = marker.data.type,
)
}
}
}
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.resources.Res
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.InfoPanel
import moe.lava.banksia.ui.Searcher
@ -139,7 +140,7 @@ fun App(
extInsets = WindowInsets(top = with(LocalDensity.current) {
SearchBarDefaults.InputFieldHeight.roundToPx()
}, bottom = extInsets),
markers = mapState.markers,
markers = mapState.stops + mapState.vehicles,
setLastKnownLocation = viewModel::setLastKnownLocation,
polylines = mapState.polylines,
)
@ -168,6 +169,7 @@ fun App(
scope.launch {
scaffoldState.bottomSheetState.hide()
peekHeightMultiplier = 1F
viewModel.handleEvent(BanksiaEvent.DismissState)
}
} catch (_: CancellationException) {
peekHeightMultiplier = 1F

View file

@ -1,10 +1,15 @@
package moe.lava.banksia.api.ptv.structures
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.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import moe.lava.banksia.resources.Res
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 org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.ui.tooling.preview.Preview
data class RouteTypeProperties(
val colour: Color,
@ -43,18 +49,37 @@ fun PtvRouteType.getProperties(): RouteTypeProperties {
return RouteTypeProperties(colour, drawable, background, icon)
}
const val ICON_PADDING = 0.25f
@Preview
@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()
Image(
painter = painterResource(properties.icon),
contentDescription = null,
modifier = Modifier
modifier = modifier
.size(size)
.aspectRatio(1f)
.padding(size * ICON_PADDING / 2)
.drawBehind {
drawCircle(properties.colour, radius = (this.size.minDimension + 10.dp.toPx()) / 2f)
drawCircle(properties.colour, radius = size.toPx() / 2f)
}
)
}
@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.graphics.Color
import kotlinx.coroutines.flow.Flow
import moe.lava.banksia.api.ptv.structures.PtvRouteType
import moe.lava.banksia.util.BoxedValue
enum class MarkerType {
GENERIC_STOP,
}
data class Marker(
val point: Point,
val type: MarkerType,
val colour: Color,
val onClick: () -> Boolean
)
val data: Data,
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 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.CameraPositionBounds
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
@ -32,6 +31,8 @@ import moe.lava.banksia.util.BoxedValue
import moe.lava.banksia.util.BoxedValue.Companion.box
sealed class BanksiaEvent {
data object DismissState : BanksiaEvent()
data class SelectRoute(val id: Int?) : BanksiaEvent()
data class SelectStop(val routeType: PtvRouteType, val stopId: Int?) : BanksiaEvent()
@ -61,6 +62,7 @@ class BanksiaViewModel : ViewModel() {
fun handleEvent(event: BanksiaEvent) {
viewModelScope.launch {
when (event) {
is BanksiaEvent.DismissState -> dismissState()
is BanksiaEvent.SelectRoute -> switchRoute(event.id)
is BanksiaEvent.SelectStop -> switchStop(event.routeType, event.stopId)
is BanksiaEvent.SearchUpdate -> searchUpdate(event.text)
@ -87,6 +89,13 @@ class BanksiaViewModel : ViewModel() {
lastKnownLocation = location
}
private fun dismissState() {
viewModelScope.launch {
switchRoute(null)
searchUpdate("")
}
}
private suspend fun searchUpdate(text: String) {
val entries = ptvService.routes()
.sortedWith(
@ -126,6 +135,7 @@ class BanksiaViewModel : ViewModel() {
}
viewModelScope.launch { buildPolylines(route) }
viewModelScope.launch { buildRuns(route) }
viewModelScope.launch { buildStops(route) }
// viewModelScope.launch { buildDepartures() }
// viewModelScope.launch { buildRuns() }
@ -219,6 +229,22 @@ class BanksiaViewModel : ViewModel() {
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) {
// val directions = ptvService.directionsByRoute(route.routeId)
//
@ -239,27 +265,22 @@ class BanksiaViewModel : ViewModel() {
private suspend fun buildStops(route: PtvRoute) {
val stops = ptvService.stopsByRoute(route.routeId, route.routeType)
val markers = mutableListOf<Marker>()
val colour = route.routeType.getProperties().colour
for (stop in stops) {
if (stop.stopLatitude != null && stop.stopLongitude != null) {
val pos = Point(stop.stopLatitude!!, stop.stopLongitude!!)
val marker = Marker(
point = pos,
type = MarkerType.GENERIC_STOP,
colour = colour,
val markers = stops
.filter { it.stopLatitude != null && it.stopLongitude != null }
.map { stop ->
Marker(
point = Point(stop.stopLatitude!!, stop.stopLongitude!!),
data = Marker.Data.Stop(colour),
onClick = {
viewModelScope.launch { switchStop(route.routeType, stop.stopId) }
false
}
)
markers.add(marker)
}
}
iMapState.update { it.copy(markers = markers) }
iMapState.update { it.copy(stops = markers) }
}
private fun buildBounds(points: List<Point>): CameraPositionBounds {

View file

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

View file

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

View file

@ -4,6 +4,7 @@ import moe.lava.banksia.native.maps.Marker
import moe.lava.banksia.native.maps.Polyline
data class MapState(
val markers: List<Marker> = listOf(),
val stops: List<Marker> = listOf(),
val vehicles: List<Marker> = 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.PtvRoute
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.log
import okio.ByteString.Companion.encodeUtf8
@ -28,6 +29,9 @@ object Responses {
@Serializable
data class PtvRoutesResponse(val routes: List<PtvRoute>)
@Serializable
data class PtvRunsResponse(val runs: List<PtvRun>)
@Serializable
data class PtvStopResponse(val stop: PtvStop)
@Serializable
@ -129,6 +133,20 @@ class PtvService {
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> {
val response: Responses.PtvStopsResponse = client.get("stops") {
url {

View file

@ -1,9 +1,46 @@
package moe.lava.banksia.api.ptv.structures
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.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(
val latitude: Double,
val longitude: Double,
@ -12,8 +49,14 @@ data class PtvVehiclePosition(
val direction: String?,
val bearing: Double?,
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
@ -25,4 +68,5 @@ data class PtvRun(
@SerialName("destination_name") val destinationName: String,
@SerialName("direction_id") val directionId: Int,
@SerialName("status") val status: String,
@SerialName("vehicle_position") val vehiclePosition: PtvVehiclePosition?,
)