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.viewmodel)
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.moko.geo) implementation(libs.moko.geo)
implementation(libs.moko.geo.compose) implementation(libs.moko.geo.compose)
implementation(projects.shared) 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.WindowInsets
import androidx.compose.foundation.layout.add import androidx.compose.foundation.layout.add
import androidx.compose.foundation.layout.fillMaxSize 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.safeContent
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
@ -28,6 +31,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.backhandler.PredictiveBackHandler import androidx.compose.ui.backhandler.PredictiveBackHandler
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.icerock.moko.geo.compose.BindLocationTrackerEffect 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.Res
import moe.lava.banksia.resources.my_location_24 import moe.lava.banksia.resources.my_location_24
import moe.lava.banksia.ui.Searcher import moe.lava.banksia.ui.Searcher
import moe.lava.banksia.ui.StopInfoPanel
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.ui.tooling.preview.Preview import org.jetbrains.compose.ui.tooling.preview.Preview
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
@ -147,18 +152,44 @@ fun App() {
} }
var sheetSwipeEnabled by remember { mutableStateOf(true) } 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 peekHeightMultiplier by remember { mutableFloatStateOf(1F) }
var stop by remember { mutableStateOf<PtvStop?>(null) }
var markers by remember { mutableStateOf(listOf<Marker>()) } 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 { BanksiaTheme {
BottomSheetScaffold( BottomSheetScaffold(
scaffoldState = scaffoldState, scaffoldState = scaffoldState,
sheetPeekHeight = peekHeight * peekHeightMultiplier, sheetPeekHeight = (handleHeight + peekHeight) * peekHeightMultiplier,
modifier = Modifier.fillMaxSize(), 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, sheetSwipeEnabled = sheetSwipeEnabled,
) { ) {
Maps( 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))
}
}

View file

@ -16,6 +16,7 @@ coroutines = "1.9.0"
geo = "0.8.0" geo = "0.8.0"
junit = "4.13.2" junit = "4.13.2"
kotlin = "2.1.10" kotlin = "2.1.10"
kotlinxDatetime = "0.6.2"
kotlinxSerializationJson = "1.8.1" kotlinxSerializationJson = "1.8.1"
ktor = "3.1.1" ktor = "3.1.1"
logback = "1.5.17" logback = "1.5.17"
@ -28,20 +29,13 @@ secretsGradlePlugin = "2.0.1"
[libraries] [libraries]
moko-geo = { module = "dev.icerock.moko:geo", version.ref = "geo" } moko-geo = { module = "dev.icerock.moko:geo", version.ref = "geo" }
moko-geo-compose = { module = "dev.icerock.moko:geo-compose", version.ref = "geo" } moko-geo-compose = { module = "dev.icerock.moko:geo-compose", version.ref = "geo" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" }
androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-junit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" }
androidx-material = { group = "com.google.android.material", name = "material", version.ref = "androidx-material" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidx-constraintlayout" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
ktor-client-contentnegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-contentnegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }

View file

@ -13,6 +13,8 @@ import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import moe.lava.banksia.Constants import moe.lava.banksia.Constants
import moe.lava.banksia.api.ptv.structures.PtvDeparture
import moe.lava.banksia.api.ptv.structures.PtvDirection
import moe.lava.banksia.api.ptv.structures.PtvRoute import moe.lava.banksia.api.ptv.structures.PtvRoute
import moe.lava.banksia.api.ptv.structures.PtvRouteType import moe.lava.banksia.api.ptv.structures.PtvRouteType
import moe.lava.banksia.api.ptv.structures.PtvStop import moe.lava.banksia.api.ptv.structures.PtvStop
@ -27,9 +29,34 @@ object Responses {
@Serializable @Serializable
data class PtvStopsResponse(val stops: List<PtvStop>) data class PtvStopsResponse(val stops: List<PtvStop>)
@Serializable
data class PtvDeparturesResponse(val departures: List<PtvDeparture>, val routes: Map<String, PtvRoute>, val directions: Map<String, PtvDirection>)
@Serializable
data class PtvDirectionsResponse(val directions: List<PtvDirection>)
} }
class PtvService { class PtvService {
class PtvCache(
private val service: PtvService,
private val directions: HashMap<Pair<Int, Int>, PtvDirection> = HashMap(),
) {
suspend fun direction(directionID: Int, routeID: Int): PtvDirection? {
val ret = directions[Pair(directionID, routeID)]
if (ret == null) {
val res = service.directionsByRoute(routeID)
for (dir in res)
directions[Pair(dir.directionId, dir.routeId)] = dir
}
return ret ?: directions[Pair(directionID, routeID)]
}
}
val cache = PtvCache(this)
private val client = HttpClient() { private val client = HttpClient() {
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { json(Json {
@ -68,9 +95,10 @@ class PtvService {
} }
suspend fun stopsByRoute(routeId: Int, routeType: PtvRouteType): List<PtvStop> { suspend fun stopsByRoute(routeId: Int, routeType: PtvRouteType): List<PtvStop> {
val response: Responses.PtvStopsResponse = client.get("stops/route") { val response: Responses.PtvStopsResponse = client.get("stops") {
url { url {
appendPathSegments( appendPathSegments(
"route",
routeId.toString(), routeId.toString(),
"route_type", "route_type",
routeType.ordinal.toString() routeType.ordinal.toString()
@ -79,4 +107,38 @@ class PtvService {
}.body() }.body()
return response.stops return response.stops
} }
suspend fun directionsByRoute(routeId: Int): List<PtvDirection> {
val response: Responses.PtvDirectionsResponse = client.get("directions") {
url {
appendPathSegments("route", routeId.toString())
}
}.body()
return response.directions
}
suspend fun direction(id: Int, routeType: PtvRouteType?): List<PtvDirection> {
val response: Responses.PtvDirectionsResponse = client.get("directions") {
url {
appendPathSegments(id.toString())
if (routeType != null)
appendPathSegments("route_type", routeType.ordinal.toString())
}
}.body()
return response.directions
}
suspend fun departures(routeType: PtvRouteType, stopId: Int): Responses.PtvDeparturesResponse =
client.get("departures") {
url {
appendPathSegments(
"route_type",
routeType.ordinal.toString(),
"stop",
stopId.toString()
)
parameter("expand", "Route")
parameter("expand", "Direction")
}
}.body()
} }

View file

@ -0,0 +1,12 @@
package moe.lava.banksia.api.ptv.structures
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class PtvDeparture(
@SerialName("scheduled_departure_utc") val scheduledDepartureUtc: String,
@SerialName("estimated_departure_utc") val estimatedDepartureUtc: String?,
@SerialName("direction_id") val directionId: Int,
@SerialName("route_id") val routeId: Int,
)

View file

@ -0,0 +1,11 @@
package moe.lava.banksia.api.ptv.structures
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class PtvDirection(
@SerialName("direction_id") val directionId: Int,
@SerialName("direction_name") val directionName: String,
@SerialName("route_id") val routeId: Int,
)