feat: stop info panel

This commit is contained in:
LavaDesu 2025-04-30 00:11:21 +10:00
parent b417118e3d
commit 339e8c802f
Signed by: cilly
GPG key ID: 6500251E087653C9
7 changed files with 258 additions and 13 deletions

View file

@ -49,6 +49,7 @@ kotlin {
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.moko.geo)
implementation(libs.moko.geo.compose)
implementation(projects.shared)

View file

@ -4,9 +4,12 @@ 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
@ -28,6 +31,7 @@ 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
@ -49,6 +53,7 @@ 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
@ -147,18 +152,44 @@ fun App() {
}
var sheetSwipeEnabled by remember { mutableStateOf(true) }
var peekHeight by remember { mutableStateOf(128.dp) }
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) { route?.let { markers = buildStops(ptvService, it) {} } }
LaunchedEffect(route) {
route?.let { route ->
markers = buildStops(ptvService, route) {
stop = it
scope.launch { scaffoldState.bottomSheetState.partialExpand() }
}
}
}
BanksiaTheme {
BottomSheetScaffold(
scaffoldState = scaffoldState,
sheetPeekHeight = peekHeight * peekHeightMultiplier,
sheetPeekHeight = (handleHeight + peekHeight) * peekHeightMultiplier,
modifier = Modifier.fillMaxSize(),
sheetContent = { Box(modifier = Modifier) },
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(

View file

@ -0,0 +1,134 @@
package moe.lava.banksia.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContent
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
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,
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 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()
.padding(horizontal = 24.dp)
.heightIn(max = 250.dp)
.verticalScroll(rememberScrollState())
.onSizeChanged {
onPeekHeightChange(with(localDensity) { it.height.toDp().coerceAtMost(250.dp) })
}
) {
Box {
Column(Modifier.fillMaxWidth()) {
val split = stop.stopName.split("/")
val mainName = split[0]
val subName = split.getOrNull(1)
Text(
mainName,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Start
)
if (subName != null)
Text(
"/ $subName",
modifier = Modifier.padding(start = 5.dp),
style = MaterialTheme.typography.titleSmall,
color = Color.Gray,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Start
)
if (!loading)
{
Spacer(Modifier.height(5.dp))
departures.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))
}
}
}
}
if (loading)
CircularProgressIndicator(
modifier = Modifier.width(32.dp).align(Alignment.CenterEnd)
)
}
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeContent))
}
}