diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 23b62b8..d525163 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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) diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt index 0aabf22..08dd9d3 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/App.kt @@ -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(null) } var markers by remember { mutableStateOf(listOf()) } - 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( diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/StopInfoPanel.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/StopInfoPanel.kt new file mode 100644 index 0000000..d2d2498 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/StopInfoPanel.kt @@ -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>>(listOf()) } + var loading by remember { mutableStateOf(true) } + // [TODO]: Cleanup + LaunchedEffect(stop) { + loading = true + val res = ptvService.departures(stop.routeType, stop.stopId) + // Map< + // Pair, + // Pair> + // > + val timetable = HashMap, Pair>>() + 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)) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2e3f43f..ef8de1e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ coroutines = "1.9.0" geo = "0.8.0" junit = "4.13.2" kotlin = "2.1.10" +kotlinxDatetime = "0.6.2" kotlinxSerializationJson = "1.8.1" ktor = "3.1.1" logback = "1.5.17" @@ -28,20 +29,13 @@ secretsGradlePlugin = "2.0.1" [libraries] moko-geo = { module = "dev.icerock.moko:geo", 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" } -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-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" } 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-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } 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-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/PtvService.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/PtvService.kt index 22bd3f2..088b172 100644 --- a/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/PtvService.kt +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/PtvService.kt @@ -13,6 +13,8 @@ import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json 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.PtvRouteType import moe.lava.banksia.api.ptv.structures.PtvStop @@ -27,9 +29,34 @@ object Responses { @Serializable data class PtvStopsResponse(val stops: List) + + @Serializable + data class PtvDeparturesResponse(val departures: List, val routes: Map, val directions: Map) + + @Serializable + data class PtvDirectionsResponse(val directions: List) } + class PtvService { + class PtvCache( + private val service: PtvService, + private val directions: HashMap, 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() { install(ContentNegotiation) { json(Json { @@ -68,9 +95,10 @@ class PtvService { } suspend fun stopsByRoute(routeId: Int, routeType: PtvRouteType): List { - val response: Responses.PtvStopsResponse = client.get("stops/route") { + val response: Responses.PtvStopsResponse = client.get("stops") { url { appendPathSegments( + "route", routeId.toString(), "route_type", routeType.ordinal.toString() @@ -79,4 +107,38 @@ class PtvService { }.body() return response.stops } + + suspend fun directionsByRoute(routeId: Int): List { + val response: Responses.PtvDirectionsResponse = client.get("directions") { + url { + appendPathSegments("route", routeId.toString()) + } + }.body() + return response.directions + } + + suspend fun direction(id: Int, routeType: PtvRouteType?): List { + 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() } diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/structures/PtvDeparture.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/structures/PtvDeparture.kt new file mode 100644 index 0000000..3cd9ac2 --- /dev/null +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/structures/PtvDeparture.kt @@ -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, +) diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/structures/PtvDirection.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/structures/PtvDirection.kt new file mode 100644 index 0000000..837a10e --- /dev/null +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/api/ptv/structures/PtvDirection.kt @@ -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, +)