refactor: move logic to viewmodel

This commit is contained in:
LavaDesu 2025-07-28 01:39:31 +10:00
parent ba367d106c
commit 64e6ccf08b
Signed by: cilly
GPG key ID: 6500251E087653C9
10 changed files with 363 additions and 237 deletions

View file

@ -47,6 +47,7 @@ kotlin {
implementation(compose.components.resources) implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview) implementation(compose.components.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
@ -54,7 +55,6 @@ kotlin {
implementation(libs.moko.geo.compose) implementation(libs.moko.geo.compose)
implementation(projects.shared) implementation(projects.shared)
implementation(libs.ui.backhandler) implementation(libs.ui.backhandler)
} }
} }
} }

View file

@ -16,7 +16,9 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
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
@ -24,9 +26,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.android.gms.location.LocationServices import com.google.android.gms.location.LocationServices
import com.google.android.gms.maps.CameraUpdateFactory 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.LatLng
import com.google.android.gms.maps.model.LatLngBounds import com.google.android.gms.maps.model.LatLngBounds
import com.google.android.gms.maps.model.MapStyleOptions 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.Polyline
import com.google.maps.android.compose.rememberCameraPositionState 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 moe.lava.banksia.R import moe.lava.banksia.R
import moe.lava.banksia.native.BanksiaTheme 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) fun Point.toLatLng(): LatLng = LatLng(this.lat, this.lng)
@ -61,31 +66,36 @@ actual fun Maps(
modifier: Modifier, modifier: Modifier,
markers: List<Marker>, markers: List<Marker>,
polylines: List<Polyline>, polylines: List<Polyline>,
newCameraPosition: Pair<Point, Pair<Point, Point>?>?, cameraPositionFlow: Flow<BoxedValue<CameraPosition>>,
cameraPositionUpdated: () -> Unit, setLastKnownLocation: (Point) -> Unit,
extInsets: WindowInsets, extInsets: WindowInsets,
) { ) {
var camPos = rememberCameraPositionState() val scope = rememberCoroutineScope()
val ctx = LocalContext.current val camPos = rememberCameraPositionState()
val fusedLocation = remember { LocationServices.getFusedLocationProviderClient(ctx) } val newCameraPos by cameraPositionFlow.collectAsStateWithLifecycle(null)
LaunchedEffect(Unit) { LaunchedEffect(newCameraPos) {
fusedLocation.lastLocation.addOnSuccessListener { val pos = newCameraPos?.value ?: return@LaunchedEffect
if (it != null) val update = if (pos.bounds != null) {
camPos.position = CameraPosition(LatLng(it.latitude, it.longitude), 16.0f, 0.0f, 0.0f) val (northeast, southwest) = pos.bounds
}
}
LaunchedEffect(newCameraPosition) {
if (newCameraPosition != null) {
if (newCameraPosition.second != null) {
val (northeast, southwest) = newCameraPosition.second!!
val bounds = LatLngBounds( val bounds = LatLngBounds(
southwest.toLatLng(), southwest.toLatLng(),
northeast.toLatLng() northeast.toLatLng()
) )
camPos.animate(CameraUpdateFactory.newLatLngBounds(bounds, 150), 1000) CameraUpdateFactory.newLatLngBounds(bounds, 150)
} else } else
camPos.animate(CameraUpdateFactory.newLatLngZoom(newCameraPosition.first.toLatLng(), 16.0f), 1000) CameraUpdateFactory.newLatLngZoom(pos.centre.toLatLng(), 16.0f)
cameraPositionUpdated()
camPos.animate(update, 1000)
}
val ctx = LocalContext.current
val fusedLocation = remember { LocationServices.getFusedLocationProviderClient(ctx) }
LaunchedEffect(Unit) {
fusedLocation.lastLocation.addOnSuccessListener {
if (it != null) {
camPos.position = GoogleCameraPosition(LatLng(it.latitude, it.longitude), 16.0f, 0.0f, 0.0f)
setLastKnownLocation(Point(it.latitude, it.longitude))
}
} }
} }

View file

@ -23,10 +23,10 @@ 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.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi 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.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp 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.BindLocationTrackerEffect
import dev.icerock.moko.geo.compose.LocationTrackerAccuracy import dev.icerock.moko.geo.compose.LocationTrackerAccuracy
import dev.icerock.moko.geo.compose.rememberLocationTrackerFactory import dev.icerock.moko.geo.compose.rememberLocationTrackerFactory
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch 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.BanksiaTheme
import moe.lava.banksia.native.maps.Maps 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.Point
import moe.lava.banksia.native.maps.Polyline
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.BanksiaViewModel
import moe.lava.banksia.ui.Searcher import moe.lava.banksia.ui.Searcher
import moe.lava.banksia.ui.StopInfoPanel import moe.lava.banksia.ui.StopInfoPanel
import org.jetbrains.compose.resources.painterResource 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.coroutines.cancellation.CancellationException
import kotlin.math.roundToInt import kotlin.math.roundToInt
fun buildBounds(points: List<Point>): Pair<Point, Point> { val MELBOURNE = Point(-37.8136, 144.9631)
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))
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable @Composable
@Preview @Preview
fun App() { fun App(
val ptvService = remember { PtvService() } 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( val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState( 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, Pair<Point, Point>?>?>(
Pair(Point(-37.8136, 144.9631), null)
)
}
var searchTextState by remember { mutableStateOf("") }
var searchExpandedState by remember { mutableStateOf(false) }
val sheetState = scaffoldState.bottomSheetState val sheetState = scaffoldState.bottomSheetState
val extInsets = if ( val extInsets = if (
sheetState.currentValue != SheetValue.Hidden || sheetState.currentValue != SheetValue.Hidden ||
@ -113,72 +90,31 @@ fun App() {
(getScreenHeight() - scaffoldOffset - WindowInsets.safeDrawing.getBottom(LocalDensity.current)).coerceAtLeast(0) (getScreenHeight() - scaffoldOffset - WindowInsets.safeDrawing.getBottom(LocalDensity.current)).coerceAtLeast(0)
} else 0 } else 0
var scope = rememberCoroutineScope() LaunchedEffect(state.stopState) {
scope.launch { val isShown = state.stopState != null
val flow = locationTracker.getLocationsFlow() if (isShown)
locationTracker.startTracking() scope.launch { scaffoldState.bottomSheetState.partialExpand() }
flow.distinctUntilChanged().collect { else
lastLocation = Point(it.latitude, it.longitude) scope.launch { scaffoldState.bottomSheetState.hide() }
}
} }
var route by remember { mutableStateOf<PtvRoute?>(null) } var searchTextState by rememberSaveable { mutableStateOf("") }
val polylines = remember { mutableStateListOf<Polyline>() } var searchExpandedState by rememberSaveable { mutableStateOf(false) }
var sheetSwipeEnabled by rememberSaveable { mutableStateOf(true) }
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<Point>()
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 handleHeight by remember { mutableStateOf(0.dp) } var handleHeight by remember { mutableStateOf(0.dp) }
var peekHeight by remember { mutableStateOf(0.dp) } var peekHeight by remember { mutableStateOf(0.dp) }
var peekHeightMultiplier by remember { mutableFloatStateOf(1F) } var peekHeightMultiplier by remember { mutableFloatStateOf(1F) }
var stop by remember { mutableStateOf<PtvStop?>(null) }
var markers by remember { mutableStateOf(listOf<Marker>()) }
LaunchedEffect(route) {
markers = listOf()
route?.let { route ->
markers = buildStops(ptvService, route) {
stop = it
scope.launch { scaffoldState.bottomSheetState.partialExpand() }
}
}
}
BanksiaTheme { BanksiaTheme {
BottomSheetScaffold( BottomSheetScaffold(
scaffoldState = scaffoldState, scaffoldState = scaffoldState,
sheetPeekHeight = (handleHeight + peekHeight) * peekHeightMultiplier, sheetPeekHeight = (handleHeight + peekHeight) * peekHeightMultiplier,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
sheetContent = { stop?.let { sheetContent = {
StopInfoPanel(ptvService, it) { state.stopState?.let { stopState ->
peekHeight = it StopInfoPanel(stopState) { peekHeight = it }
} }
} }, },
sheetDragHandle = { sheetDragHandle = {
val density = LocalDensity.current val density = LocalDensity.current
Box( Box(
@ -196,26 +132,26 @@ fun App() {
) { ) {
Maps( Maps(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
newCameraPosition = newCameraPosition, cameraPositionFlow = viewModel.cameraChangeEmitter,
cameraPositionUpdated = { newCameraPosition = null },
extInsets = WindowInsets(top = with(LocalDensity.current) { extInsets = WindowInsets(top = with(LocalDensity.current) {
SearchBarDefaults.InputFieldHeight.roundToPx() SearchBarDefaults.InputFieldHeight.roundToPx()
}, bottom = extInsets), }, bottom = extInsets),
markers = markers, markers = state.markers,
polylines = polylines, setLastKnownLocation = viewModel::setLastKnownLocation,
polylines = state.polylines,
) )
Searcher( Searcher(
ptvService = ptvService, selectedRoute = state.routeState?.route,
routes = state.routes,
expanded = searchExpandedState, expanded = searchExpandedState,
onExpandedChange = { onExpandedChange = {
searchExpandedState = it searchExpandedState = it
if (it) if (it)
scope.launch { scaffoldState.bottomSheetState.hide() } scope.launch { scaffoldState.bottomSheetState.hide() }
}, },
route = route,
text = searchTextState, text = searchTextState,
onTextChange = { searchTextState = it }, onTextChange = { searchTextState = it },
onRouteChange = { route = it } onRouteChange = { viewModel.switchRoute(it) }
) )
PredictiveBackHandler(scaffoldState.bottomSheetState.currentValue != SheetValue.Hidden) { progress -> PredictiveBackHandler(scaffoldState.bottomSheetState.currentValue != SheetValue.Hidden) { progress ->
@ -245,9 +181,7 @@ fun App() {
) { ) {
FloatingActionButton( FloatingActionButton(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
onClick = { onClick = { viewModel.centreCameraToLocation() },
newCameraPosition = Pair(lastLocation, null)
},
) { ) {
Icon(painterResource(Res.drawable.my_location_24), "Move to current location") 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<Marker> {
var stops = ptvService.stopsByRoute(route.routeId, route.routeType)
var res = 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,
onClick = {
launchInfoPanel(stop)
false
}
)
res.add(marker)
}
}
return res
}

View file

@ -5,6 +5,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable 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 moe.lava.banksia.ui.BoxedValue
enum class MarkerType { enum class MarkerType {
GENERIC_STOP, GENERIC_STOP,
@ -18,6 +20,12 @@ data class Marker(
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)
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 @Composable
expect fun getScreenHeight(): Int expect fun getScreenHeight(): Int
@ -27,8 +35,7 @@ expect fun Maps(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
markers: List<Marker> = listOf(), markers: List<Marker> = listOf(),
polylines: List<Polyline> = listOf(), polylines: List<Polyline> = listOf(),
// <Centre: Point, Bounds?: <Northeast, Southwest>> cameraPositionFlow: Flow<BoxedValue<CameraPosition>>,
newCameraPosition: Pair<Point, Pair<Point, Point>?>? = Pair(Point(-37.8136, 144.9631), null), setLastKnownLocation: (Point) -> Unit,
cameraPositionUpdated: () -> Unit,
extInsets: WindowInsets, extInsets: WindowInsets,
) )

View file

@ -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<PtvStop>? = null,
)
data class StopState(
val stop: PtvStop,
// val departures: List<PtvDeparture>? = null,
val departures: List<Pair<String, String>>? = null,
)
data class BanksiaViewState(
val routeState: RouteState? = null,
val stopState: StopState? = null,
val routes: List<PtvRoute> = listOf(),
val markers: List<Marker> = listOf(),
val polylines: List<Polyline> = listOf(),
)
class BoxedValue<T>(val value: T) {
operator fun component1() = value
companion object {
fun <T> T.box() = BoxedValue(this)
}
}
class BanksiaViewModel : ViewModel() {
private val iState = MutableStateFlow(BanksiaViewState())
val state: StateFlow<BanksiaViewState> = iState.asStateFlow()
private val ptvService = PtvService()
private var locationTrackerJob: Job? = null
private var lastKnownLocation: Point? = null
private val iCameraChangeEmitter = MutableSharedFlow<BoxedValue<CameraPosition>>()
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<DirectionId, RouteId>,
// Pair<DirectionName, List<DepartureTimes>>
// >
val timetable = HashMap<Pair<Int, Int>, Pair<String, MutableList<String>>>()
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<Polyline>()
val allPoints = mutableListOf<Point>()
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<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,
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<Point>): 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))
}
}

View file

@ -41,7 +41,6 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.ComposableIcon
import moe.lava.banksia.api.ptv.structures.PtvRoute import moe.lava.banksia.api.ptv.structures.PtvRoute
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
@ -50,10 +49,10 @@ import kotlin.math.pow
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable @Composable
fun Searcher( fun Searcher(
ptvService: PtvService, selectedRoute: PtvRoute?,
routes: List<PtvRoute>,
expanded: Boolean, expanded: Boolean,
onExpandedChange: (Boolean) -> Unit, onExpandedChange: (Boolean) -> Unit,
route: PtvRoute?,
text: String, text: String,
onTextChange: (String) -> Unit, onTextChange: (String) -> Unit,
onRouteChange: (PtvRoute?) -> Unit, onRouteChange: (PtvRoute?) -> Unit,
@ -66,18 +65,8 @@ fun Searcher(
}, },
label = "padding" label = "padding"
) )
var routes by remember { mutableStateOf(listOf<PtvRoute>()) }
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
LaunchedEffect(Unit) {
val localRoutes = ptvService.routes()
routes = localRoutes.sortedWith(
compareBy(
{ it.gtfsSubType()?.ordinal },
{ it.routeNumber.toIntOrNull() },
{ it.routeName }
)
)
}
SearchBar( SearchBar(
modifier = Modifier modifier = Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
@ -89,14 +78,13 @@ fun Searcher(
var backEdgeIsLeft by remember { mutableStateOf<Boolean?>(null) } var backEdgeIsLeft by remember { mutableStateOf<Boolean?>(null) }
val routeInfoOpacity by animateFloatAsState((1f - backProgress).pow(3)) val routeInfoOpacity by animateFloatAsState((1f - backProgress).pow(3))
val slideState by animateDpAsState((50 * backProgress).dp) val slideState by animateDpAsState((50 * backProgress).dp)
val slidePadding = if (backEdgeIsLeft == true) val slidePadding = when (backEdgeIsLeft) {
PaddingValues(start = slideState) true -> PaddingValues(start = slideState)
else if (backEdgeIsLeft == false) false -> PaddingValues(end = slideState)
PaddingValues(end = slideState) null -> PaddingValues()
else }
PaddingValues()
PredictiveBackHandler(enabled = route != null) { progress -> PredictiveBackHandler(enabled = selectedRoute != null) { progress ->
try { try {
progress.collect { backEvent -> progress.collect { backEvent ->
backProgress = backEvent.progress backProgress = backEvent.progress
@ -110,7 +98,7 @@ fun Searcher(
backEdgeIsLeft = null backEdgeIsLeft = null
} }
SearchBarDefaults.InputField( SearchBarDefaults.InputField(
enabled = route == null, enabled = selectedRoute == null,
modifier = Modifier modifier = Modifier
.alpha(1f - routeInfoOpacity) .alpha(1f - routeInfoOpacity)
.padding(horizontal = 20.dp - animatedPadding), .padding(horizontal = 20.dp - animatedPadding),
@ -129,11 +117,11 @@ fun Searcher(
) )
} }
) )
LaunchedEffect(route) { LaunchedEffect(selectedRoute) {
backProgress = if (route != null) 0f else 1f; backProgress = if (selectedRoute != null) 0f else 1f;
} }
if (route != null) if (selectedRoute != null)
RouteInfo(routeInfoOpacity, slidePadding, onRouteChange, route) RouteInfo(routeInfoOpacity, slidePadding, onRouteChange, selectedRoute)
}, },
expanded = expanded, expanded = expanded,
onExpandedChange = onExpandedChange, onExpandedChange = onExpandedChange,

View file

@ -18,11 +18,6 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.Dp
import androidx.compose.ui.unit.coerceAtMost import androidx.compose.ui.unit.coerceAtMost
import androidx.compose.ui.unit.dp 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 @Composable
fun StopInfoPanel( fun StopInfoPanel(
ptvService: PtvService, stopState: StopState,
stop: PtvStop,
onPeekHeightChange: (Dp) -> Unit, onPeekHeightChange: (Dp) -> Unit,
) { ) {
var departures by remember { mutableStateOf<List<Pair<String, String>>>(listOf()) } val localDensity = LocalDensity.current
var loading by remember { mutableStateOf(true) } val (stop, departures) = stopState
// [TODO]: Cleanup
LaunchedEffect(stop) {
loading = true
val res = ptvService.departures(stop.routeType, stop.stopId)
// Map<
// Pair<DirectionId, RouteId>,
// Pair<DirectionName, List<DepartureTimes>>
// >
val timetable = HashMap<Pair<Int, Int>, Pair<String, MutableList<String>>>()
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.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( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -113,10 +68,9 @@ fun StopInfoPanel(
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Start textAlign = TextAlign.Start
) )
if (!loading) departures?.let {
{
Spacer(Modifier.height(5.dp)) Spacer(Modifier.height(5.dp))
departures.forEach { (name, formatted) -> it.forEach { (name, formatted) ->
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text(name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) Text(name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(formatted, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 5.dp)) 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( CircularProgressIndicator(
modifier = Modifier.width(32.dp).align(Alignment.CenterEnd) modifier = Modifier.width(32.dp).align(Alignment.CenterEnd)
) )

View file

@ -6,6 +6,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.LocalWindowInfo
import kotlinx.coroutines.flow.Flow
import moe.lava.banksia.ui.BoxedValue
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
@ -19,8 +21,8 @@ actual fun Maps(
modifier: Modifier, modifier: Modifier,
markers: List<Marker>, markers: List<Marker>,
polylines: List<Polyline>, polylines: List<Polyline>,
newCameraPosition: Pair<Point, Pair<Point, Point>?>?, cameraPositionFlow: Flow<BoxedValue<CameraPosition>>,
cameraPositionUpdated: () -> Unit, setLastKnownLocation: (Point) -> Unit,
extInsets: WindowInsets, extInsets: WindowInsets,
) { ) {
TODO("Not yet implemented") TODO("Not yet implemented")

View file

@ -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" } 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-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 = { 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" } 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-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }

View file

@ -20,6 +20,7 @@ import moe.lava.banksia.api.ptv.structures.PtvRouteType
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
import kotlin.random.Random
object Responses { object Responses {
@Serializable @Serializable
@ -71,6 +72,8 @@ class PtvService {
constructor() { constructor() {
client.plugin(HttpSend).intercept { req -> client.plugin(HttpSend).intercept { req ->
req.parameter("devid", Constants.devid) req.parameter("devid", Constants.devid)
@OptIn(ExperimentalStdlibApi::class)
req.parameter("nonce", Random.nextBytes(6).toHexString())
val fullPath = req.url.build().encodedPathAndQuery val fullPath = req.url.build().encodedPathAndQuery
val hash = fullPath.encodeUtf8().hmacSha1(Constants.key.encodeUtf8()).hex() val hash = fullPath.encodeUtf8().hmacSha1(Constants.key.encodeUtf8()).hex()
req.parameter("signature", hash) req.parameter("signature", hash)