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.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)
}
}
}

View file

@ -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<Marker>,
polylines: List<Polyline>,
newCameraPosition: Pair<Point, Pair<Point, Point>?>?,
cameraPositionUpdated: () -> Unit,
cameraPositionFlow: Flow<BoxedValue<CameraPosition>>,
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))
}
}
}

View file

@ -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<Point>): Pair<Point, Point> {
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, 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 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<PtvRoute?>(null) }
val polylines = remember { mutableStateListOf<Polyline>() }
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 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<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 {
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<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.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<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
expect fun getScreenHeight(): Int
@ -27,8 +35,7 @@ expect fun Maps(
modifier: Modifier = Modifier,
markers: List<Marker> = listOf(),
polylines: List<Polyline> = listOf(),
// <Centre: Point, Bounds?: <Northeast, Southwest>>
newCameraPosition: Pair<Point, Pair<Point, Point>?>? = Pair(Point(-37.8136, 144.9631), null),
cameraPositionUpdated: () -> Unit,
cameraPositionFlow: Flow<BoxedValue<CameraPosition>>,
setLastKnownLocation: (Point) -> Unit,
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.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<PtvRoute>,
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<PtvRoute>()) }
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<Boolean?>(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,

View file

@ -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<List<Pair<String, String>>>(listOf()) }
var loading by remember { mutableStateOf(true) }
// [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 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)
)

View file

@ -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<Marker>,
polylines: List<Polyline>,
newCameraPosition: Pair<Point, Pair<Point, Point>?>?,
cameraPositionUpdated: () -> Unit,
cameraPositionFlow: Flow<BoxedValue<CameraPosition>>,
setLastKnownLocation: (Point) -> Unit,
extInsets: WindowInsets,
) {
TODO("Not yet implemented")