286 lines
11 KiB
Kotlin
286 lines
11 KiB
Kotlin
package moe.lava.banksia
|
|
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.WindowInsets
|
|
import androidx.compose.foundation.layout.add
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.safeContent
|
|
import androidx.compose.foundation.layout.safeDrawing
|
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
|
import androidx.compose.material3.BottomSheetDefaults
|
|
import androidx.compose.material3.BottomSheetScaffold
|
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
import androidx.compose.material3.FloatingActionButton
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.SearchBarDefaults
|
|
import androidx.compose.material3.SheetValue
|
|
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
|
import androidx.compose.material3.rememberStandardBottomSheetState
|
|
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.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
|
import androidx.compose.ui.Modifier
|
|
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 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.Searcher
|
|
import moe.lava.banksia.ui.StopInfoPanel
|
|
import org.jetbrains.compose.resources.painterResource
|
|
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))
|
|
}
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
|
|
@Composable
|
|
@Preview
|
|
fun App() {
|
|
val ptvService = remember { PtvService() }
|
|
|
|
val scaffoldState = rememberBottomSheetScaffoldState(
|
|
bottomSheetState = rememberStandardBottomSheetState(
|
|
initialValue = SheetValue.Hidden,
|
|
skipHiddenState = false
|
|
)
|
|
)
|
|
|
|
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 ||
|
|
sheetState.targetValue != SheetValue.Hidden
|
|
) {
|
|
val offset = runCatching { sheetState.requireOffset() }
|
|
val scaffoldOffset = offset.getOrDefault(0.0f).roundToInt()
|
|
(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)
|
|
}
|
|
}
|
|
|
|
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 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
|
|
}
|
|
} },
|
|
sheetDragHandle = {
|
|
val density = LocalDensity.current
|
|
Box(
|
|
Modifier
|
|
.fillMaxWidth()
|
|
.padding(horizontal = 10.dp)
|
|
.onSizeChanged {
|
|
handleHeight = with(density) { it.height.toDp() }
|
|
}
|
|
) {
|
|
BottomSheetDefaults.DragHandle(modifier = Modifier.align(Alignment.Center))
|
|
}
|
|
},
|
|
sheetSwipeEnabled = sheetSwipeEnabled,
|
|
) {
|
|
Maps(
|
|
modifier = Modifier.fillMaxSize(),
|
|
newCameraPosition = newCameraPosition,
|
|
cameraPositionUpdated = { newCameraPosition = null },
|
|
extInsets = WindowInsets(top = with(LocalDensity.current) {
|
|
SearchBarDefaults.InputFieldHeight.roundToPx()
|
|
}, bottom = extInsets),
|
|
markers = markers,
|
|
polylines = polylines,
|
|
)
|
|
Searcher(
|
|
ptvService = ptvService,
|
|
expanded = searchExpandedState,
|
|
onExpandedChange = {
|
|
searchExpandedState = it
|
|
if (it)
|
|
scope.launch { scaffoldState.bottomSheetState.hide() }
|
|
},
|
|
route = route,
|
|
text = searchTextState,
|
|
onTextChange = { searchTextState = it },
|
|
onRouteChange = { route = it }
|
|
)
|
|
|
|
PredictiveBackHandler(scaffoldState.bottomSheetState.currentValue != SheetValue.Hidden) { progress ->
|
|
sheetSwipeEnabled = false
|
|
try {
|
|
progress.collect { backEvent ->
|
|
if (scaffoldState.bottomSheetState.currentValue == SheetValue.PartiallyExpanded) {
|
|
peekHeightMultiplier = 1F - backEvent.progress
|
|
}
|
|
}
|
|
if (scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded)
|
|
scope.launch { scaffoldState.bottomSheetState.partialExpand() }
|
|
else if (scaffoldState.bottomSheetState.currentValue == SheetValue.PartiallyExpanded)
|
|
scope.launch {
|
|
scaffoldState.bottomSheetState.hide()
|
|
peekHeightMultiplier = 1F
|
|
}
|
|
} catch (_: CancellationException) {
|
|
peekHeightMultiplier = 1F
|
|
}
|
|
sheetSwipeEnabled = true
|
|
}
|
|
|
|
Box(
|
|
Modifier.windowInsetsPadding(WindowInsets.safeContent.add(WindowInsets(bottom = extInsets))),
|
|
contentAlignment = Alignment.BottomEnd
|
|
) {
|
|
FloatingActionButton(
|
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
onClick = {
|
|
newCameraPosition = Pair(lastLocation, null)
|
|
},
|
|
) {
|
|
Icon(painterResource(Res.drawable.my_location_24), "Move to current location")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|