From 64e6ccf08bf93ee8d1c6f9178b55b3668a344d6e Mon Sep 17 00:00:00 2001 From: LavaDesu Date: Mon, 28 Jul 2025 01:39:31 +1000 Subject: [PATCH] refactor: move logic to viewmodel --- composeApp/build.gradle.kts | 2 +- .../lava/banksia/native/maps/Maps.android.kt | 50 ++-- .../commonMain/kotlin/moe/lava/banksia/App.kt | 171 +++--------- .../moe/lava/banksia/native/maps/Maps.kt | 13 +- .../moe/lava/banksia/ui/BanksiaViewModel.kt | 256 ++++++++++++++++++ .../kotlin/moe/lava/banksia/ui/Searcher.kt | 40 +-- .../moe/lava/banksia/ui/StopInfoPanel.kt | 58 +--- .../moe/lava/banksia/native/maps/Maps.ios.kt | 6 +- gradle/libs.versions.toml | 1 + .../moe/lava/banksia/api/ptv/PtvService.kt | 3 + 10 files changed, 363 insertions(+), 237 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/BanksiaViewModel.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index d525163..230ac68 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -47,6 +47,7 @@ kotlin { implementation(compose.components.resources) implementation(compose.components.uiToolingPreview) implementation(libs.androidx.lifecycle.viewmodel) + implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.datetime) @@ -54,7 +55,6 @@ kotlin { implementation(libs.moko.geo.compose) implementation(projects.shared) implementation(libs.ui.backhandler) - } } } 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 82d90a9..ea40ccf 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 @@ -16,7 +16,9 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ExperimentalMaterial3Api 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 @@ -24,9 +26,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.android.gms.location.LocationServices import com.google.android.gms.maps.CameraUpdateFactory -import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.LatLngBounds import com.google.android.gms.maps.model.MapStyleOptions @@ -38,8 +40,11 @@ import com.google.maps.android.compose.MarkerComposable import com.google.maps.android.compose.Polyline 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.native.BanksiaTheme +import moe.lava.banksia.ui.BoxedValue +import com.google.android.gms.maps.model.CameraPosition as GoogleCameraPosition fun Point.toLatLng(): LatLng = LatLng(this.lat, this.lng) @@ -61,31 +66,36 @@ actual fun Maps( modifier: Modifier, markers: List, polylines: List, - newCameraPosition: Pair?>?, - cameraPositionUpdated: () -> Unit, + cameraPositionFlow: Flow>, + setLastKnownLocation: (Point) -> Unit, extInsets: WindowInsets, ) { - var camPos = rememberCameraPositionState() + val scope = rememberCoroutineScope() + val camPos = rememberCameraPositionState() + val newCameraPos by cameraPositionFlow.collectAsStateWithLifecycle(null) + LaunchedEffect(newCameraPos) { + val pos = newCameraPos?.value ?: return@LaunchedEffect + val update = if (pos.bounds != null) { + val (northeast, southwest) = pos.bounds + val bounds = LatLngBounds( + southwest.toLatLng(), + northeast.toLatLng() + ) + CameraUpdateFactory.newLatLngBounds(bounds, 150) + } else + CameraUpdateFactory.newLatLngZoom(pos.centre.toLatLng(), 16.0f) + + camPos.animate(update, 1000) + } + val ctx = LocalContext.current val fusedLocation = remember { LocationServices.getFusedLocationProviderClient(ctx) } LaunchedEffect(Unit) { fusedLocation.lastLocation.addOnSuccessListener { - if (it != null) - camPos.position = CameraPosition(LatLng(it.latitude, it.longitude), 16.0f, 0.0f, 0.0f) - } - } - LaunchedEffect(newCameraPosition) { - if (newCameraPosition != null) { - if (newCameraPosition.second != null) { - val (northeast, southwest) = newCameraPosition.second!! - val bounds = LatLngBounds( - southwest.toLatLng(), - northeast.toLatLng() - ) - camPos.animate(CameraUpdateFactory.newLatLngBounds(bounds, 150), 1000) - } else - camPos.animate(CameraUpdateFactory.newLatLngZoom(newCameraPosition.first.toLatLng(), 16.0f), 1000) - cameraPositionUpdated() + if (it != null) { + camPos.position = GoogleCameraPosition(LatLng(it.latitude, it.longitude), 16.0f, 0.0f, 0.0f) + setLastKnownLocation(Point(it.latitude, it.longitude)) + } } } diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt index f981b3b..1418c1f 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt @@ -23,10 +23,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -35,24 +35,19 @@ import androidx.compose.ui.backhandler.PredictiveBackHandler import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import dev.icerock.moko.geo.compose.BindLocationTrackerEffect import dev.icerock.moko.geo.compose.LocationTrackerAccuracy import dev.icerock.moko.geo.compose.rememberLocationTrackerFactory -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch -import moe.lava.banksia.api.ptv.PtvService -import moe.lava.banksia.api.ptv.structures.PtvRoute -import moe.lava.banksia.api.ptv.structures.PtvStop -import moe.lava.banksia.api.ptv.structures.getProperties import moe.lava.banksia.native.BanksiaTheme import moe.lava.banksia.native.maps.Maps -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.native.maps.getScreenHeight import moe.lava.banksia.resources.Res import moe.lava.banksia.resources.my_location_24 +import moe.lava.banksia.ui.BanksiaViewModel import moe.lava.banksia.ui.Searcher import moe.lava.banksia.ui.StopInfoPanel import org.jetbrains.compose.resources.painterResource @@ -60,29 +55,23 @@ import org.jetbrains.compose.ui.tooling.preview.Preview import kotlin.coroutines.cancellation.CancellationException import kotlin.math.roundToInt -fun buildBounds(points: List): Pair { - var north = -Double.MAX_VALUE - var south = Double.MAX_VALUE - var east = -Double.MAX_VALUE - var west = Double.MAX_VALUE - points.forEach { - if (it.lat > north) - north = it.lat; - if (it.lat < south) - south = it.lat; - if (it.lng > east) - east = it.lng; - if (it.lng < west) - west = it.lng; - } - return Pair(Point(north, east), Point(south, west)) -} +val MELBOURNE = Point(-37.8136, 144.9631) @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable @Preview -fun App() { - val ptvService = remember { PtvService() } +fun App( + viewModel: BanksiaViewModel = viewModel() +) { + val scope = rememberCoroutineScope() + + val locationFactory = rememberLocationTrackerFactory(LocationTrackerAccuracy.Best) + val locationTracker = remember { locationFactory.createLocationTracker() } + BindLocationTrackerEffect(locationTracker) + viewModel.bindTracker(locationTracker) + scope.launch { locationTracker.startTracking() } + + val state by viewModel.state.collectAsStateWithLifecycle() val scaffoldState = rememberBottomSheetScaffoldState( bottomSheetState = rememberStandardBottomSheetState( @@ -91,18 +80,6 @@ fun App() { ) ) - val locationFactory = rememberLocationTrackerFactory(LocationTrackerAccuracy.Best) - val locationTracker = remember { locationFactory.createLocationTracker() } - BindLocationTrackerEffect(locationTracker) - var lastLocation by remember { mutableStateOf(Point(-37.8136, 144.9631)) } - var newCameraPosition by remember { - mutableStateOf?>?>( - Pair(Point(-37.8136, 144.9631), null) - ) - } - var searchTextState by remember { mutableStateOf("") } - var searchExpandedState by remember { mutableStateOf(false) } - val sheetState = scaffoldState.bottomSheetState val extInsets = if ( sheetState.currentValue != SheetValue.Hidden || @@ -113,72 +90,31 @@ fun App() { (getScreenHeight() - scaffoldOffset - WindowInsets.safeDrawing.getBottom(LocalDensity.current)).coerceAtLeast(0) } else 0 - var scope = rememberCoroutineScope() - scope.launch { - val flow = locationTracker.getLocationsFlow() - locationTracker.startTracking() - flow.distinctUntilChanged().collect { - lastLocation = Point(it.latitude, it.longitude) - } + LaunchedEffect(state.stopState) { + val isShown = state.stopState != null + if (isShown) + scope.launch { scaffoldState.bottomSheetState.partialExpand() } + else + scope.launch { scaffoldState.bottomSheetState.hide() } } - var route by remember { mutableStateOf(null) } - val polylines = remember { mutableStateListOf() } - - LaunchedEffect(route) { - val route = route - polylines.clear() - if (route == null) - return@LaunchedEffect - val geoRoute = ptvService.route(route.routeId, true) - val colour = route.routeType.getProperties().colour - - val allPoints = mutableListOf() - geoRoute.geopath.forEach { pp -> - // TODO: use gtfs colours - pp.paths.forEach { sp -> - val polyline = sp.replace(", ", ",") - .split(" ") - .map { coord -> - val s = coord.split(",") - val point = Point(s[0].toDouble(), s[1].toDouble()) - allPoints.add(point) - point - } - polylines.add(Polyline(polyline, colour)) - } - } - if (allPoints.isNotEmpty()) - newCameraPosition = Pair(Point(0.0, 0.0), buildBounds(allPoints)) - } - - var sheetSwipeEnabled by remember { mutableStateOf(true) } + var searchTextState by rememberSaveable { mutableStateOf("") } + var searchExpandedState by rememberSaveable { mutableStateOf(false) } + var sheetSwipeEnabled by rememberSaveable { mutableStateOf(true) } var handleHeight by remember { mutableStateOf(0.dp) } var peekHeight by remember { mutableStateOf(0.dp) } var peekHeightMultiplier by remember { mutableFloatStateOf(1F) } - var stop by remember { mutableStateOf(null) } - var markers by remember { mutableStateOf(listOf()) } - LaunchedEffect(route) { - markers = listOf() - route?.let { route -> - markers = buildStops(ptvService, route) { - stop = it - scope.launch { scaffoldState.bottomSheetState.partialExpand() } - } - } - } - BanksiaTheme { BottomSheetScaffold( scaffoldState = scaffoldState, sheetPeekHeight = (handleHeight + peekHeight) * peekHeightMultiplier, modifier = Modifier.fillMaxSize(), - sheetContent = { stop?.let { - StopInfoPanel(ptvService, it) { - peekHeight = it + sheetContent = { + state.stopState?.let { stopState -> + StopInfoPanel(stopState) { peekHeight = it } } - } }, + }, sheetDragHandle = { val density = LocalDensity.current Box( @@ -196,26 +132,26 @@ fun App() { ) { Maps( modifier = Modifier.fillMaxSize(), - newCameraPosition = newCameraPosition, - cameraPositionUpdated = { newCameraPosition = null }, + cameraPositionFlow = viewModel.cameraChangeEmitter, extInsets = WindowInsets(top = with(LocalDensity.current) { SearchBarDefaults.InputFieldHeight.roundToPx() }, bottom = extInsets), - markers = markers, - polylines = polylines, + markers = state.markers, + setLastKnownLocation = viewModel::setLastKnownLocation, + polylines = state.polylines, ) Searcher( - ptvService = ptvService, + selectedRoute = state.routeState?.route, + routes = state.routes, expanded = searchExpandedState, onExpandedChange = { searchExpandedState = it if (it) scope.launch { scaffoldState.bottomSheetState.hide() } }, - route = route, text = searchTextState, onTextChange = { searchTextState = it }, - onRouteChange = { route = it } + onRouteChange = { viewModel.switchRoute(it) } ) PredictiveBackHandler(scaffoldState.bottomSheetState.currentValue != SheetValue.Hidden) { progress -> @@ -245,9 +181,7 @@ fun App() { ) { FloatingActionButton( containerColor = MaterialTheme.colorScheme.surfaceContainer, - onClick = { - newCameraPosition = Pair(lastLocation, null) - }, + onClick = { viewModel.centreCameraToLocation() }, ) { Icon(painterResource(Res.drawable.my_location_24), "Move to current location") } @@ -255,32 +189,3 @@ fun App() { } } } - -suspend fun buildStops( - ptvService: PtvService, - route: PtvRoute, - launchInfoPanel: (PtvStop) -> Unit, -): List { - var stops = ptvService.stopsByRoute(route.routeId, route.routeType) - var res = 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, - onClick = { - launchInfoPanel(stop) - false - } - ) - res.add(marker) - } - } - - return res -} 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 a102e93..31e2137 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 @@ -5,6 +5,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api 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.ui.BoxedValue enum class MarkerType { GENERIC_STOP, @@ -18,6 +20,12 @@ data class Marker( data class Point(val lat: Double, val lng: Double) data class Polyline(val points: List, val colour: Color) +data class CameraPositionBounds(val northeast: Point, val southwest: Point) +data class CameraPosition( + val centre: Point = Point(-37.8136, 144.9631), + val bounds: CameraPositionBounds? = null, +) + @Composable expect fun getScreenHeight(): Int @@ -27,8 +35,7 @@ expect fun Maps( modifier: Modifier = Modifier, markers: List = listOf(), polylines: List = listOf(), - // > - newCameraPosition: Pair?>? = Pair(Point(-37.8136, 144.9631), null), - cameraPositionUpdated: () -> 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 new file mode 100644 index 0000000..1a86745 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/BanksiaViewModel.kt @@ -0,0 +1,256 @@ +package moe.lava.banksia.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.icerock.moko.geo.LocationTracker +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import moe.lava.banksia.api.ptv.PtvService +import moe.lava.banksia.api.ptv.structures.PtvRoute +import moe.lava.banksia.api.ptv.structures.PtvStop +import moe.lava.banksia.api.ptv.structures.getProperties +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.BoxedValue.Companion.box + +data class RouteState( + val route: PtvRoute, + val stops: List? = null, +) + +data class StopState( + val stop: PtvStop, +// val departures: List? = null, + val departures: List>? = null, +) + +data class BanksiaViewState( + val routeState: RouteState? = null, + val stopState: StopState? = null, + + val routes: List = listOf(), + + val markers: List = listOf(), + val polylines: List = listOf(), +) + +class BoxedValue(val value: T) { + operator fun component1() = value + + companion object { + fun T.box() = BoxedValue(this) + } +} + +class BanksiaViewModel : ViewModel() { + private val iState = MutableStateFlow(BanksiaViewState()) + val state: StateFlow = iState.asStateFlow() + + private val ptvService = PtvService() + private var locationTrackerJob: Job? = null + private var lastKnownLocation: Point? = null + + private val iCameraChangeEmitter = MutableSharedFlow>() + val cameraChangeEmitter = iCameraChangeEmitter.asSharedFlow() + + init { + viewModelScope.launch { + requestRoutes() + } + } + + fun bindTracker(locationTracker: LocationTracker) { + locationTrackerJob = locationTracker.getLocationsFlow() + .onEach { lastKnownLocation = Point(it.latitude, it.longitude) } + .launchIn(viewModelScope) + } + + fun centreCameraToLocation() { + lastKnownLocation?.let { location -> + viewModelScope.launch { + log("bvm", "emitting $location") + iCameraChangeEmitter.emit(CameraPosition(location).box()) + } + } + } + + fun setLastKnownLocation(location: Point) { + lastKnownLocation = location + } + + private suspend fun requestRoutes() { + val routes = ptvService.routes().sortedWith( + compareBy( + { it.gtfsSubType()?.ordinal }, + { it.routeNumber.toIntOrNull() }, + { it.routeName } + ) + ) + iState.update { it.copy(routes = routes) } + } + + fun switchRoute(newRoute: PtvRoute?) { + val routeState = newRoute?.let { RouteState(it) } + if (iState.value.routeState == routeState) + return + + iState.update { + it.copy( + routeState = routeState, + markers = listOf(), + polylines = listOf(), + ) + } + + if (routeState != null) { + viewModelScope.launch { + async { buildPolylines() } + async { buildMarkers() } + } + } + } + + // [TODO]: Cleanup + suspend fun switchStop(stop: PtvStop?) { + iState.update { state -> + state.copy(stopState = stop?.let { StopState(it) }) + } + + if (stop == null) + return + + val res = ptvService.departures(stop.routeType, stop.stopId) + // Map< + // Pair, + // Pair> + // > + val timetable = HashMap, Pair>>() + res.departures.forEach { dep -> + val key = Pair(dep.directionId, dep.routeId) + val direction = ptvService.cache.direction(dep.directionId, dep.routeId) ?: return@forEach + val route = res.routes[dep.routeId.toString()] + val prefix = route?.let { if (it.routeNumber == "") "" else "${it.routeNumber} - " } ?: "" + val element = timetable.getOrPut(key) { Pair(prefix + direction.directionName, mutableListOf()) }.second + if (element.size >= 5) + return@forEach + + val date = Instant.Companion.parse(dep.estimatedDepartureUtc ?: dep.scheduledDepartureUtc) + val min = (date - Clock.System.now()).inWholeMinutes + if (min <= -5) + return@forEach + if (min >= 65) + element.add("${((min + 30.0) / 60.0).toInt()}hr") + else + element.add("${min}mn") + } + val departures = timetable.values.sortedBy { it.first }.map { (name, list) -> + if (list.isEmpty()) + Pair(name, "No departures") + else + Pair(name, list.joinToString(" | ")) + } + iState.update { + it.copy(stopState = it.stopState?.copy(departures = departures)) + } + } + + private suspend fun buildPolylines() { + val route = iState.value.routeState?.route ?: return + + val routeWithGeo = if (route.geopath.isEmpty()) + ptvService.route(route.routeId, true) + else + route + val colour = routeWithGeo.routeType.getProperties().colour + + val polylines = mutableListOf() + val allPoints = mutableListOf() + routeWithGeo.geopath.forEach { pp -> + // TODO: use gtfs colours + pp.paths.forEach { sp -> + val polyline = sp.replace(", ", ",") + .split(" ") + .map { coord -> + val s = coord.split(",") + val point = Point(s[0].toDouble(), s[1].toDouble()) + allPoints.add(point) + point + } + polylines.add(Polyline(polyline, colour)) + } + } + val newCameraPosition = if (allPoints.isNotEmpty()) + CameraPosition(bounds = buildBounds(allPoints)) + else + null + + iState.update { it.copy(polylines = polylines) } + newCameraPosition?.let { iCameraChangeEmitter.emit(it.box()) } + } + + private suspend fun buildMarkers() { + val route = iState.value.routeState?.route ?: return + + 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, + onClick = { + viewModelScope.launch { switchStop(stop) } + false + } + ) + markers.add(marker) + } + } + + iState.update { + it.copy( + routeState = it.routeState?.copy(stops = stops), + markers = markers + ) + } + } + + private fun buildBounds(points: List): CameraPositionBounds { + var north = -Double.MAX_VALUE + var south = Double.MAX_VALUE + var east = -Double.MAX_VALUE + var west = Double.MAX_VALUE + points.forEach { + if (it.lat > north) + north = it.lat; + if (it.lat < south) + south = it.lat; + if (it.lng > east) + east = it.lng; + if (it.lng < west) + west = it.lng; + } + return CameraPositionBounds(Point(north, east), Point(south, west)) + } +} 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 0e24555..77a8a93 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/Searcher.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/Searcher.kt @@ -41,7 +41,6 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import moe.lava.banksia.api.ptv.PtvService import moe.lava.banksia.api.ptv.structures.ComposableIcon import moe.lava.banksia.api.ptv.structures.PtvRoute import kotlin.coroutines.cancellation.CancellationException @@ -50,10 +49,10 @@ import kotlin.math.pow @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable fun Searcher( - ptvService: PtvService, + selectedRoute: PtvRoute?, + routes: List, expanded: Boolean, onExpandedChange: (Boolean) -> Unit, - route: PtvRoute?, text: String, onTextChange: (String) -> Unit, onRouteChange: (PtvRoute?) -> Unit, @@ -66,18 +65,8 @@ fun Searcher( }, label = "padding" ) - var routes by remember { mutableStateOf(listOf()) } + Box(modifier = Modifier.fillMaxSize()) { - LaunchedEffect(Unit) { - val localRoutes = ptvService.routes() - routes = localRoutes.sortedWith( - compareBy( - { it.gtfsSubType()?.ordinal }, - { it.routeNumber.toIntOrNull() }, - { it.routeName } - ) - ) - } SearchBar( modifier = Modifier .align(Alignment.TopCenter) @@ -89,14 +78,13 @@ fun Searcher( var backEdgeIsLeft by remember { mutableStateOf(null) } val routeInfoOpacity by animateFloatAsState((1f - backProgress).pow(3)) val slideState by animateDpAsState((50 * backProgress).dp) - val slidePadding = if (backEdgeIsLeft == true) - PaddingValues(start = slideState) - else if (backEdgeIsLeft == false) - PaddingValues(end = slideState) - else - PaddingValues() + val slidePadding = when (backEdgeIsLeft) { + true -> PaddingValues(start = slideState) + false -> PaddingValues(end = slideState) + null -> PaddingValues() + } - PredictiveBackHandler(enabled = route != null) { progress -> + PredictiveBackHandler(enabled = selectedRoute != null) { progress -> try { progress.collect { backEvent -> backProgress = backEvent.progress @@ -110,7 +98,7 @@ fun Searcher( backEdgeIsLeft = null } SearchBarDefaults.InputField( - enabled = route == null, + enabled = selectedRoute == null, modifier = Modifier .alpha(1f - routeInfoOpacity) .padding(horizontal = 20.dp - animatedPadding), @@ -129,11 +117,11 @@ fun Searcher( ) } ) - LaunchedEffect(route) { - backProgress = if (route != null) 0f else 1f; + LaunchedEffect(selectedRoute) { + backProgress = if (selectedRoute != null) 0f else 1f; } - if (route != null) - RouteInfo(routeInfoOpacity, slidePadding, onRouteChange, route) + if (selectedRoute != null) + RouteInfo(routeInfoOpacity, slidePadding, onRouteChange, selectedRoute) }, expanded = expanded, onExpandedChange = onExpandedChange, diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/StopInfoPanel.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/StopInfoPanel.kt index d2d2498..491f21f 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/StopInfoPanel.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/StopInfoPanel.kt @@ -18,11 +18,6 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -34,55 +29,15 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.coerceAtMost import androidx.compose.ui.unit.dp -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant -import moe.lava.banksia.api.ptv.PtvService -import moe.lava.banksia.api.ptv.structures.PtvStop @Composable fun StopInfoPanel( - ptvService: PtvService, - stop: PtvStop, + stopState: StopState, onPeekHeightChange: (Dp) -> Unit, ) { - var departures by remember { mutableStateOf>>(listOf()) } - var loading by remember { mutableStateOf(true) } - // [TODO]: Cleanup - LaunchedEffect(stop) { - loading = true - val res = ptvService.departures(stop.routeType, stop.stopId) - // Map< - // Pair, - // Pair> - // > - val timetable = HashMap, Pair>>() - res.departures.forEach { dep -> - val key = Pair(dep.directionId, dep.routeId) - val direction = ptvService.cache.direction(dep.directionId, dep.routeId) ?: return@forEach - val route = res.routes[dep.routeId.toString()] - val prefix = route?.let { if (it.routeNumber == "") "" else "${it.routeNumber} - " } ?: "" - val element = timetable.getOrPut(key) { Pair(prefix + direction.directionName, mutableListOf()) }.second - if (element.size >= 5) - return@forEach + val localDensity = LocalDensity.current + val (stop, departures) = stopState - val date = Instant.parse(dep.estimatedDepartureUtc ?: dep.scheduledDepartureUtc) - val min = (date - Clock.System.now()).inWholeMinutes - if (min <= -5) - return@forEach - if (min >= 65) - element.add("${((min + 30.0) / 60.0).toInt()}hr") - else - element.add("${min}mn") - } - departures = timetable.values.sortedBy { it.first }.map { (name, list) -> - if (list.isEmpty()) - Pair(name, "No departures") - else - Pair(name, list.joinToString(" | ")) - } - loading = false - } - val localDensity = LocalDensity.current; Column( Modifier .fillMaxWidth() @@ -113,10 +68,9 @@ fun StopInfoPanel( fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Start ) - if (!loading) - { + departures?.let { Spacer(Modifier.height(5.dp)) - departures.forEach { (name, formatted) -> + it.forEach { (name, formatted) -> Row(verticalAlignment = Alignment.CenterVertically) { Text(name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) Text(formatted, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 5.dp)) @@ -124,7 +78,7 @@ fun StopInfoPanel( } } } - if (loading) + if (departures == null) CircularProgressIndicator( modifier = Modifier.width(32.dp).align(Alignment.CenterEnd) ) 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 cf22226..f79a722 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 @@ -6,6 +6,8 @@ import androidx.compose.runtime.Composable 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.BoxedValue @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -19,8 +21,8 @@ actual fun Maps( modifier: Modifier, markers: List, polylines: List, - newCameraPosition: Pair?>?, - cameraPositionUpdated: () -> Unit, + cameraPositionFlow: Flow>, + setLastKnownLocation: (Point) -> Unit, extInsets: WindowInsets, ) { TODO("Not yet implemented") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ef8de1e..aa17427 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ moko-geo-compose = { module = "dev.icerock.moko:geo-compose", version.ref = "geo kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodel-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } 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 088b172..9257c31 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 @@ -20,6 +20,7 @@ import moe.lava.banksia.api.ptv.structures.PtvRouteType import moe.lava.banksia.api.ptv.structures.PtvStop import moe.lava.banksia.log import okio.ByteString.Companion.encodeUtf8 +import kotlin.random.Random object Responses { @Serializable @@ -71,6 +72,8 @@ class PtvService { constructor() { client.plugin(HttpSend).intercept { req -> req.parameter("devid", Constants.devid) + @OptIn(ExperimentalStdlibApi::class) + req.parameter("nonce", Random.nextBytes(6).toHexString()) val fullPath = req.url.build().encodedPathAndQuery val hash = fullPath.encodeUtf8().hmacSha1(Constants.key.encodeUtf8()).hex() req.parameter("signature", hash)