refactor: move logic to viewmodel
This commit is contained in:
parent
ba367d106c
commit
64e6ccf08b
10 changed files with 363 additions and 237 deletions
|
|
@ -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)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue