From e52274a6ef2120f12fb3dc2b2b815ebc91612ad1 Mon Sep 17 00:00:00 2001 From: LavaDesu Date: Tue, 29 Jul 2025 20:35:32 +1000 Subject: [PATCH] feat: vehicle positions and state dismissal --- .../lava/banksia/native/maps/Maps.android.kt | 28 +++++++---- .../commonMain/kotlin/moe/lava/banksia/App.kt | 4 +- .../banksia/api/ptv/structures/RouteType.kt | 33 +++++++++++-- .../moe/lava/banksia/native/maps/Maps.kt | 16 ++++--- .../moe/lava/banksia/ui/BanksiaViewModel.kt | 47 +++++++++++++----- .../kotlin/moe/lava/banksia/ui/InfoPanel.kt | 6 +-- .../kotlin/moe/lava/banksia/ui/Searcher.kt | 2 +- .../moe/lava/banksia/ui/state/MapState.kt | 3 +- .../moe/lava/banksia/api/ptv/PtvService.kt | 18 +++++++ .../lava/banksia/api/ptv/structures/PtvRun.kt | 48 ++++++++++++++++++- 10 files changed, 163 insertions(+), 42 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/moe/lava/banksia/native/maps/Maps.android.kt b/composeApp/src/androidMain/kotlin/moe/lava/banksia/native/maps/Maps.android.kt index 44e252f..0f30142 100644 --- a/composeApp/src/androidMain/kotlin/moe/lava/banksia/native/maps/Maps.android.kt +++ b/composeApp/src/androidMain/kotlin/moe/lava/banksia/native/maps/Maps.android.kt @@ -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() } ) { - Box( - modifier = Modifier - .size(12.dp) - .clip(CircleShape) - .background(BanksiaTheme.colors.surface) - .border(2.dp, marker.colour, CircleShape) - ) + 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, + ) + } } } for (polyline in polylines) { diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt index 5cb8854..81b7f3e 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/api/ptv/structures/RouteType.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/api/ptv/structures/RouteType.kt index 7e4fdb8..9b50daa 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/api/ptv/structures/RouteType.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/api/ptv/structures/RouteType.kt @@ -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) \ No newline at end of file +inline fun PtvRouteType.ComposableIcon(modifier: Modifier = Modifier) = ComposableRouteIcon(modifier, routeType = this) diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/native/maps/Maps.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/native/maps/Maps.kt index 5d71557..7f83794 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/native/maps/Maps.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/native/maps/Maps.kt @@ -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, val colour: Color) diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/BanksiaViewModel.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/BanksiaViewModel.kt index baf92d9..84c26d3 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/BanksiaViewModel.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/BanksiaViewModel.kt @@ -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() 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): CameraPositionBounds { diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/InfoPanel.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/InfoPanel.kt index 082edac..c864aa5 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/InfoPanel.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/InfoPanel.kt @@ -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, ) { diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/Searcher.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/Searcher.kt index 28a6fc8..6afe9d8 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/Searcher.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/Searcher.kt @@ -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() diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/MapState.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/MapState.kt index 4f14013..9e065a5 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/MapState.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/MapState.kt @@ -4,6 +4,7 @@ import moe.lava.banksia.native.maps.Marker import moe.lava.banksia.native.maps.Polyline data class MapState( - val markers: List = listOf(), + val stops: List = listOf(), + val vehicles: List = listOf(), val polylines: List = listOf(), ) diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/PtvService.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/PtvService.kt index b6abc26..f796e4b 100644 --- a/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/PtvService.kt +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/PtvService.kt @@ -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) + @Serializable + data class PtvRunsResponse(val runs: List) + @Serializable data class PtvStopResponse(val stop: PtvStop) @Serializable @@ -129,6 +133,20 @@ class PtvService { return response.routes } + suspend fun runs(routeId: Int): List { + 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 { val response: Responses.PtvStopsResponse = client.get("stops") { url { diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/structures/PtvRun.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/structures/PtvRun.kt index b35d0b4..17f4856 100644 --- a/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/structures/PtvRun.kt +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/structures/PtvRun.kt @@ -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 { + 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?, )