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 0f30142..a7d0051 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 @@ -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, - polylines: List, + state: MapState, + onEvent: (BanksiaEvent) -> Unit, cameraPositionFlow: Flow>, 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 diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt index 81b7f3e..6db2cb0 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt @@ -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, 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 7f83794..a87eec4 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 @@ -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, val colour: Color) @@ -35,8 +43,8 @@ expect fun getScreenHeight(): Int @Composable expect fun Maps( modifier: Modifier = Modifier, - markers: List = listOf(), - polylines: List = listOf(), + state: MapState, + onEvent: (BanksiaEvent) -> Unit, cameraPositionFlow: Flow>, setLastKnownLocation: (Point) -> Unit, extInsets: WindowInsets, 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 84c26d3..3fe327c 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/BanksiaViewModel.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/BanksiaViewModel.kt @@ -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) : BanksiaEvent() data class SearchUpdate(val text: String) : BanksiaEvent() } +data class InternalState( + val route: Int? = null, + val stop: Pair? = 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.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?) { + 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, ) } 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 c864aa5..5262a33 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/InfoPanel.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/InfoPanel.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/InfoPanelState.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/InfoPanelState.kt index bc005bc..da7281b 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/InfoPanelState.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/InfoPanelState.kt @@ -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) } -} \ No newline at end of file +} 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 9e065a5..15258a1 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,7 +4,7 @@ import moe.lava.banksia.native.maps.Marker import moe.lava.banksia.native.maps.Polyline data class MapState( - val stops: List = listOf(), - val vehicles: List = listOf(), + val stops: List = listOf(), + val vehicles: List = listOf(), val polylines: List = listOf(), ) diff --git a/composeApp/src/iosMain/kotlin/moe/lava/banksia/native/maps/Maps.ios.kt b/composeApp/src/iosMain/kotlin/moe/lava/banksia/native/maps/Maps.ios.kt index c197e43..b2f134c 100644 --- a/composeApp/src/iosMain/kotlin/moe/lava/banksia/native/maps/Maps.ios.kt +++ b/composeApp/src/iosMain/kotlin/moe/lava/banksia/native/maps/Maps.ios.kt @@ -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, - polylines: List, + state: MapState, + onEvent: (BanksiaEvent) -> Unit, cameraPositionFlow: Flow>, setLastKnownLocation: (Point) -> Unit, extInsets: WindowInsets, 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 f796e4b..467feae 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 @@ -10,6 +10,9 @@ import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.http.appendPathSegments 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.json.Json import moe.lava.banksia.Constants @@ -44,12 +47,12 @@ object Responses { data class PtvDirectionsResponse(val directions: List) } - class PtvService { class PtvCache( private val service: PtvService, private val directions: HashMap, PtvDirection> = HashMap(), private val routes: HashMap = HashMap(), + private val runs: HashMap = HashMap(), private val stops: HashMap = HashMap(), ) { suspend fun direction(directionID: Int, routeID: Int): PtvDirection? { @@ -79,6 +82,14 @@ class PtvService { } fun getStop(stopId: Int) = stops[stopId] + + fun addRuns(runs: Iterable) { + runs.forEach { + this.runs[it.runRef] = it + } + } + + fun getRun(runRef: String) = runs[runRef] } val cache = PtvCache(this) @@ -133,18 +144,42 @@ 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 + fun runFlow(ref: String, firstWithCache: Boolean = false, intervalMillis: Long = 5000): Flow = flow { + val cached = cache.getRun(ref) + if (firstWithCache && cached != null) + emit(cached) + + while (true) { + val response: Responses.PtvRunsResponse = client.get { + url { + appendPathSegments( + "runs", + ref, + ) + } + }.body() + cache.addRuns(response.runs) + emit(response.runs[0]) + delay(intervalMillis) + } + } + + fun runsFlow(routeId: Int, intervalMillis: Long = 5000): Flow> = 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 {