Banksia/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt

282 lines
11 KiB
Kotlin
Raw Normal View History

2025-04-13 00:51:32 +10:00
package moe.lava.banksia
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.add
2025-04-13 01:27:49 +10:00
import androidx.compose.foundation.layout.fillMaxSize
2025-04-30 00:11:21 +10:00
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
2025-04-30 00:11:21 +10:00
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
2025-04-14 13:35:26 +10:00
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
2025-04-14 13:35:26 +10:00
import androidx.compose.runtime.Composable
2025-04-15 17:25:47 +10:00
import androidx.compose.runtime.LaunchedEffect
2025-04-14 13:35:26 +10:00
import androidx.compose.runtime.getValue
2025-04-29 15:01:28 +10:00
import androidx.compose.runtime.mutableFloatStateOf
2025-04-15 17:25:47 +10:00
import androidx.compose.runtime.mutableStateListOf
2025-04-14 13:35:26 +10:00
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
2025-04-14 13:35:26 +10:00
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
2025-04-29 15:01:28 +10:00
import androidx.compose.ui.ExperimentalComposeUiApi
2025-04-13 00:51:32 +10:00
import androidx.compose.ui.Modifier
2025-04-29 15:01:28 +10:00
import androidx.compose.ui.backhandler.PredictiveBackHandler
2025-04-30 00:11:21 +10:00
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
2025-04-14 21:07:05 +10:00
import moe.lava.banksia.api.ptv.PtvService
import moe.lava.banksia.api.ptv.structures.PtvRoute
2025-04-29 22:58:26 +10:00
import moe.lava.banksia.api.ptv.structures.PtvStop
2025-04-15 17:25:47 +10:00
import moe.lava.banksia.api.ptv.structures.getProperties
import moe.lava.banksia.native.BanksiaTheme
2025-04-13 01:27:49 +10:00
import moe.lava.banksia.native.maps.Maps
2025-04-29 22:58:26 +10:00
import moe.lava.banksia.native.maps.Marker
import moe.lava.banksia.native.maps.MarkerType
import moe.lava.banksia.native.maps.Point
2025-04-15 17:25:47 +10:00
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
2025-04-14 13:35:26 +10:00
import moe.lava.banksia.ui.Searcher
2025-04-30 00:11:21 +10:00
import moe.lava.banksia.ui.StopInfoPanel
import org.jetbrains.compose.resources.painterResource
2025-04-14 13:35:26 +10:00
import org.jetbrains.compose.ui.tooling.preview.Preview
2025-04-29 15:01:28 +10:00
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.roundToInt
2025-04-13 00:51:32 +10:00
2025-04-15 17:25:47 +10:00
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))
}
2025-04-29 15:01:28 +10:00
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
2025-04-13 00:51:32 +10:00
@Composable
@Preview
fun App() {
2025-04-15 17:25:47 +10:00
val ptvService = remember { PtvService() }
val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(
2025-04-30 00:13:23 +10:00
initialValue = SheetValue.Hidden,
skipHiddenState = false
)
)
2025-04-14 13:35:26 +10:00
val locationFactory = rememberLocationTrackerFactory(LocationTrackerAccuracy.Best)
val locationTracker = remember { locationFactory.createLocationTracker() }
BindLocationTrackerEffect(locationTracker)
var lastLocation by remember { mutableStateOf(Point(-37.8136, 144.9631)) }
2025-04-15 17:25:47 +10:00
var newCameraPosition by remember {
mutableStateOf<Pair<Point, Pair<Point, Point>?>?>(
Pair(Point(-37.8136, 144.9631), null)
)
}
2025-04-14 13:35:26 +10:00
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) }
2025-04-15 17:25:47 +10:00
val polylines = remember { mutableStateListOf<Polyline>() }
LaunchedEffect(route) {
val route = route
if (route == null)
return@LaunchedEffect
val geoRoute = ptvService.route(route.routeId, true)
val colour = route.routeType.getProperties().colour
val allPoints = mutableListOf<Point>()
polylines.clear()
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))
2025-04-15 17:25:47 +10:00
}
2025-04-29 15:01:28 +10:00
var sheetSwipeEnabled by remember { mutableStateOf(true) }
2025-04-30 00:11:21 +10:00
var handleHeight by remember { mutableStateOf(0.dp) }
var peekHeight by remember { mutableStateOf(0.dp) }
2025-04-29 15:01:28 +10:00
var peekHeightMultiplier by remember { mutableFloatStateOf(1F) }
2025-04-30 00:11:21 +10:00
var stop by remember { mutableStateOf<PtvStop?>(null) }
2025-04-29 22:58:26 +10:00
var markers by remember { mutableStateOf(listOf<Marker>()) }
2025-04-30 00:11:21 +10:00
LaunchedEffect(route) {
route?.let { route ->
markers = buildStops(ptvService, route) {
stop = it
scope.launch { scaffoldState.bottomSheetState.partialExpand() }
}
}
}
2025-04-29 22:58:26 +10:00
BanksiaTheme {
BottomSheetScaffold(
scaffoldState = scaffoldState,
2025-04-30 00:11:21 +10:00
sheetPeekHeight = (handleHeight + peekHeight) * peekHeightMultiplier,
modifier = Modifier.fillMaxSize(),
2025-04-30 00:11:21 +10:00
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))
}
},
2025-04-29 15:01:28 +10:00
sheetSwipeEnabled = sheetSwipeEnabled,
) {
Maps(
modifier = Modifier.fillMaxSize(),
newCameraPosition = newCameraPosition,
cameraPositionUpdated = { newCameraPosition = null },
2025-04-29 15:47:37 +10:00
extInsets = WindowInsets(top = with(LocalDensity.current) { 56.dp.roundToPx() }, bottom = extInsets),
2025-04-29 22:58:26 +10:00
markers = markers,
2025-04-15 17:25:47 +10:00
polylines = polylines,
)
2025-04-14 13:35:26 +10:00
Searcher(
2025-04-15 17:25:47 +10:00
ptvService = ptvService,
2025-04-14 13:35:26 +10:00
expanded = searchExpandedState,
onExpandedChange = {
searchExpandedState = it
if (it)
scope.launch { scaffoldState.bottomSheetState.hide() }
},
2025-04-14 13:35:26 +10:00
text = searchTextState,
onTextChange = { searchTextState = it },
2025-04-15 17:25:47 +10:00
onRouteChange = { route = it }
2025-04-14 13:35:26 +10:00
)
2025-04-29 15:01:28 +10:00
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 = {
2025-04-15 17:25:47 +10:00
newCameraPosition = Pair(lastLocation, null)
},
) {
Icon(painterResource(Res.drawable.my_location_24), "Move to current location")
}
}
2025-04-13 00:51:32 +10:00
}
}
2025-04-14 21:53:07 +10:00
}
2025-04-29 22:58:26 +10:00
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
}