From 4e1e05495d3deab07d492c7b8052317a047a6dd8 Mon Sep 17 00:00:00 2001 From: Cilly Leang Date: Mon, 13 Apr 2026 03:58:00 +1000 Subject: [PATCH] featwip: pretty departures --- .../repositories/ClientRouteRepository.kt | 10 + .../sources/route/RouteLocalDataSource.kt | 1 + .../sources/route/RouteRemoteDataSource.kt | 1 + .../core/data/repositories/RouteRepository.kt | 3 +- .../lava/banksia/core/room/dao/RouteDao.kt | 3 + .../moe/lava/banksia/server/Application.kt | 10 + ui/build.gradle.kts | 2 + ui/shared/build.gradle.kts | 5 + .../drawable/arrow_drop_down.xml | 9 + .../drawable/arrow_drop_up.xml | 9 + .../lava/banksia/ui/layout/info/InfoPanel.kt | 3 +- .../banksia/ui/layout/info/StopInfoPanel.kt | 181 ++++++++++++++++-- .../ui/screens/map/MapScreenViewModel.kt | 64 +++++-- 13 files changed, 264 insertions(+), 37 deletions(-) create mode 100644 ui/shared/src/commonMain/composeResources/drawable/arrow_drop_down.xml create mode 100644 ui/shared/src/commonMain/composeResources/drawable/arrow_drop_up.xml diff --git a/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/ClientRouteRepository.kt b/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/ClientRouteRepository.kt index 70a8905..2644785 100644 --- a/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/ClientRouteRepository.kt +++ b/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/ClientRouteRepository.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import moe.lava.banksia.core.data.sources.route.RouteLocalDataSource import moe.lava.banksia.core.data.sources.route.RouteRemoteDataSource +import moe.lava.banksia.core.model.Route internal class ClientRouteRepository internal constructor( private val local: RouteLocalDataSource, @@ -21,5 +22,14 @@ internal class ClientRouteRepository internal constructor( } } + private val tripRouteMap = mutableMapOf() + override suspend fun get(id: String) = mutex.withLock { local.get(id)?.asModel() ?: remote.get(id) } + override suspend fun getByTrip(tripId: String) = mutex.withLock { + tripRouteMap[tripId] + ?: remote.getByTrip(tripId).also { + local.save(it) + tripRouteMap[tripId] = it + } + } } diff --git a/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/route/RouteLocalDataSource.kt b/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/route/RouteLocalDataSource.kt index ca267c3..e319c80 100644 --- a/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/route/RouteLocalDataSource.kt +++ b/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/route/RouteLocalDataSource.kt @@ -7,5 +7,6 @@ import moe.lava.banksia.core.room.entity.asEntity internal class RouteLocalDataSource(private val dao: RouteDao) { suspend fun get(id: String) = dao.get(id) suspend fun getAll() = dao.getAll() + suspend fun getByTrip(tripId: String) = dao.getByTrip(tripId) suspend fun save(vararg routes: Route) = dao.insertOrReplaceAll(*routes.map { it.asEntity() }.toTypedArray()) } diff --git a/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/route/RouteRemoteDataSource.kt b/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/route/RouteRemoteDataSource.kt index bdcbfc1..b37bff1 100644 --- a/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/route/RouteRemoteDataSource.kt +++ b/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/route/RouteRemoteDataSource.kt @@ -7,5 +7,6 @@ import moe.lava.banksia.core.model.Route internal class RouteRemoteDataSource(val client: HttpClient) { suspend fun get(id: String) = client.get("routes/${id}").body() + suspend fun getByTrip(tripId: String) = client.get("routes/by_trip/${tripId}").body() suspend fun getAll() = client.get("routes").body>() } diff --git a/core/data/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/RouteRepository.kt b/core/data/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/RouteRepository.kt index fbb663f..fb302a5 100644 --- a/core/data/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/RouteRepository.kt +++ b/core/data/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/RouteRepository.kt @@ -3,6 +3,7 @@ package moe.lava.banksia.core.data.repositories import moe.lava.banksia.core.model.Route interface RouteRepository { - suspend fun get(id: String): Route + suspend fun get(id: String): Route? + suspend fun getByTrip(tripId: String): Route? suspend fun getAll(): List } diff --git a/core/room/src/commonMain/kotlin/moe/lava/banksia/core/room/dao/RouteDao.kt b/core/room/src/commonMain/kotlin/moe/lava/banksia/core/room/dao/RouteDao.kt index c791f81..1c9d4df 100644 --- a/core/room/src/commonMain/kotlin/moe/lava/banksia/core/room/dao/RouteDao.kt +++ b/core/room/src/commonMain/kotlin/moe/lava/banksia/core/room/dao/RouteDao.kt @@ -28,6 +28,9 @@ interface RouteDao { @Query("DELETE FROM Route") suspend fun deleteAll() + @Query("SELECT * FROM Route INNER JOIN Trip on Route.id == Trip.routeId WHERE Trip.id == :tripId") + suspend fun getByTrip(tripId: String): RouteEntity? + @Query(""" SELECT Stop.* FROM Stop INNER JOIN StopTime ON StopTime.stopId == Stop.id diff --git a/server/src/main/kotlin/moe/lava/banksia/server/Application.kt b/server/src/main/kotlin/moe/lava/banksia/server/Application.kt index 0981b80..9e0c957 100644 --- a/server/src/main/kotlin/moe/lava/banksia/server/Application.kt +++ b/server/src/main/kotlin/moe/lava/banksia/server/Application.kt @@ -118,6 +118,16 @@ fun Application.module() { else call.respond(HttpStatusCode.NotFound) } + get("/routes/by_trip/{trip_id}") { + val tripId = call.parameters["trip_id"]!! + val route = withContext(context = Dispatchers.IO) { + get().getByTrip(tripId) + } + if (route != null) + call.respond(route.asModel()) + else + call.respond(HttpStatusCode.NotFound) + } get("/stops") { val routes = withContext(context = Dispatchers.IO) { get().getAll() diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 9c5c7bd..871412d 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -41,7 +41,9 @@ kotlin { sourceSets { androidMain.dependencies { + implementation(libs.compose.ui.tooling.preview) implementation(libs.play.services.location) + implementation(projects.ui.shared) } commonMain.dependencies { implementation(libs.compose.components.resources) diff --git a/ui/shared/build.gradle.kts b/ui/shared/build.gradle.kts index e379840..2a78572 100644 --- a/ui/shared/build.gradle.kts +++ b/ui/shared/build.gradle.kts @@ -16,6 +16,10 @@ kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } + + androidResources { + enable = true + } } compilerOptions { @@ -47,4 +51,5 @@ dependencies { compose.resources { publicResClass = true packageOfResClass = "moe.lava.banksia.resources" + generateResClass = always } diff --git a/ui/shared/src/commonMain/composeResources/drawable/arrow_drop_down.xml b/ui/shared/src/commonMain/composeResources/drawable/arrow_drop_down.xml new file mode 100644 index 0000000..ac49572 --- /dev/null +++ b/ui/shared/src/commonMain/composeResources/drawable/arrow_drop_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/ui/shared/src/commonMain/composeResources/drawable/arrow_drop_up.xml b/ui/shared/src/commonMain/composeResources/drawable/arrow_drop_up.xml new file mode 100644 index 0000000..322fa56 --- /dev/null +++ b/ui/shared/src/commonMain/composeResources/drawable/arrow_drop_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/ui/src/commonMain/kotlin/moe/lava/banksia/ui/layout/info/InfoPanel.kt b/ui/src/commonMain/kotlin/moe/lava/banksia/ui/layout/info/InfoPanel.kt index 55eac69..4783998 100644 --- a/ui/src/commonMain/kotlin/moe/lava/banksia/ui/layout/info/InfoPanel.kt +++ b/ui/src/commonMain/kotlin/moe/lava/banksia/ui/layout/info/InfoPanel.kt @@ -45,6 +45,7 @@ sealed class InfoPanelState { @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun InfoPanel( + modifier: Modifier = Modifier, state: InfoPanelState, onEvent: (InfoPanelEvent) -> Unit, onPeekHeightChange: (Dp) -> Unit, @@ -65,7 +66,7 @@ fun InfoPanel( } Column( - Modifier + modifier = modifier .fillMaxWidth() .padding(horizontal = 24.dp) .onSizeChanged { diff --git a/ui/src/commonMain/kotlin/moe/lava/banksia/ui/layout/info/StopInfoPanel.kt b/ui/src/commonMain/kotlin/moe/lava/banksia/ui/layout/info/StopInfoPanel.kt index dbe3b29..e7eb04b 100644 --- a/ui/src/commonMain/kotlin/moe/lava/banksia/ui/layout/info/StopInfoPanel.kt +++ b/ui/src/commonMain/kotlin/moe/lava/banksia/ui/layout/info/StopInfoPanel.kt @@ -1,21 +1,51 @@ package moe.lava.banksia.ui.layout.info +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +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.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.ShapeDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color 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.tooling.preview.Preview import androidx.compose.ui.unit.dp +import moe.lava.banksia.resources.Res +import moe.lava.banksia.resources.arrow_drop_down +import moe.lava.banksia.resources.arrow_drop_up +import moe.lava.banksia.ui.extensions.BUS_ORANGE +import moe.lava.banksia.ui.extensions.TRAIN_BLUE +import moe.lava.banksia.ui.platform.BanksiaTheme +import org.jetbrains.compose.resources.painterResource +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant sealed class StopInfoPanelEvent : InfoPanelEvent() @@ -23,19 +53,37 @@ data class StopInfoPanelState( val id: String, val name: String, val subname: String? = null, - val departures: List? = null, + val departures: List? = null, ) : InfoPanelState() { override val loading: Boolean get() = departures == null - data class Departure(val directionName: String, val formattedTimes: String) + data class DeparturePlatforms( + val platform: String, + val departures: List, + ) + + data class DepartureInfo( + val routeName: String, + val routeColour: Color?, + val headsign: String, + val description: String?, + val time: Instant, + ) } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable internal fun StopInfoPanel( state: StopInfoPanelState, onEvent: (StopInfoPanelEvent) -> Unit, ) { + val colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + selectedContainerColor = MaterialTheme.colorScheme.primary, + selectedContentColor = MaterialTheme.colorScheme.onPrimary, + ) + Column(Modifier.fillMaxWidth()) { Text( state.name, @@ -53,23 +101,122 @@ internal fun StopInfoPanel( textAlign = TextAlign.Start ) } - state.departures?.let { + state.departures?.let { departurePlatforms -> Spacer(Modifier.height(5.dp)) - it.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) - ) + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + departurePlatforms.forEach { (platform, departures) -> + var expanded by rememberSaveable { mutableStateOf(true) } + val base = ListItemDefaults.segmentedShapes(0, 2) + val large = MaterialTheme.shapes.large + + if (departurePlatforms.size > 1) { + SegmentedListItem( + onClick = { expanded = !expanded }, + colors = colors, + shapes = if (expanded) base else base.copy(shape = large), + trailingContent = { + Icon( + painterResource(if (expanded) Res.drawable.arrow_drop_up else Res.drawable.arrow_drop_down), + contentDescription = null, + modifier = Modifier + .background( + if (expanded) MaterialTheme.colorScheme.surface else Color.Transparent, + shape = RoundedCornerShape(100) + ) + .padding(6.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + }, + ) { + Text( + text = platform, + style = MaterialTheme.typography.labelLarge, + ) + } + } + AnimatedVisibility( + visible = expanded, + enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()), + exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()), + ) { + Column(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) { + departures.forEachIndexed { idx, dep -> + SegmentedListItem( + onClick = {}, +// onClick = { onNavigate(ch) }, + colors = colors, + shapes = ListItemDefaults.segmentedShapes( + idx + 1, + departures.size + 1 + ), + supportingContent = { + dep.description?.let { Text(dep.description) } + }, + trailingContent = { + Text( + text = (dep.time - Clock.System.now()).inWholeMinutes.toString(), + style = MaterialTheme.typography.headlineSmallEmphasized, + ) + }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Box( + Modifier + .clip(ShapeDefaults.ExtraSmall) + .background(dep.routeColour ?: MaterialTheme.colorScheme.surface) + .padding(vertical = 2.dp, horizontal = 4.dp) + ) { + Text( + text = dep.routeName, + style = MaterialTheme.typography.labelSmallEmphasized, + color = MaterialTheme.colorScheme.surface, + ) + } + Text( + text = dep.headsign, + style = MaterialTheme.typography.labelLargeEmphasized, + ) + } + } + } + } + } + Spacer(Modifier.height(10.dp)) } } } } } + +@Preview +@Composable +internal fun StopInfoPanelPreview() { + fun dateIn(dur: Duration) = (Clock.System.now() + dur) + + InfoPanel( + modifier = Modifier.background(BanksiaTheme.colors.background), + state = StopInfoPanelState( + id = "id", + name = "name", + subname = "sub", + departures = listOf( + StopInfoPanelState.DeparturePlatforms("Platform 1", listOf( + StopInfoPanelState.DepartureInfo("Sunbury", Color(TRAIN_BLUE), "Sunbury", "··· Malvern -> Anzac ··· Sunbury", dateIn(2.minutes)), + StopInfoPanelState.DepartureInfo("Sunbury", Color(TRAIN_BLUE), "West Footscray", "Express via Metro Tunnel", dateIn(8.minutes)), + )), + StopInfoPanelState.DeparturePlatforms("Platform 2", listOf( + StopInfoPanelState.DepartureInfo("237", Color(BUS_ORANGE), "Westall", null, dateIn(7.minutes)), + StopInfoPanelState.DepartureInfo("442", Color(BUS_ORANGE), "Dandenong", null, dateIn(8.minutes)), + )), + ), + ), + onEvent = {}, + onPeekHeightChange = {}, + ) +} diff --git a/ui/src/commonMain/kotlin/moe/lava/banksia/ui/screens/map/MapScreenViewModel.kt b/ui/src/commonMain/kotlin/moe/lava/banksia/ui/screens/map/MapScreenViewModel.kt index c4bd768..729630f 100644 --- a/ui/src/commonMain/kotlin/moe/lava/banksia/ui/screens/map/MapScreenViewModel.kt +++ b/ui/src/commonMain/kotlin/moe/lava/banksia/ui/screens/map/MapScreenViewModel.kt @@ -26,6 +26,7 @@ import moe.lava.banksia.core.util.LoopFlow.Companion.waitUntilSubscribed import moe.lava.banksia.core.util.Point import moe.lava.banksia.core.util.log import moe.lava.banksia.data.ptv.PtvService +import moe.lava.banksia.ui.extensions.getUIProperties import moe.lava.banksia.ui.layout.info.InfoPanelEvent import moe.lava.banksia.ui.layout.info.InfoPanelState import moe.lava.banksia.ui.layout.info.RouteInfoPanelState @@ -36,8 +37,6 @@ import moe.lava.banksia.ui.map.util.CameraPositionBounds import moe.lava.banksia.ui.map.util.Marker import moe.lava.banksia.ui.state.MapState import moe.lava.banksia.ui.state.SearchState -import kotlin.time.Clock -import kotlin.time.Duration.Companion.minutes sealed class MapScreenEvent { data object DismissState : MapScreenEvent() @@ -165,6 +164,7 @@ class MapScreenViewModel( } val route = routeRepository.get(routeId) + ?: return // val gtfsRoute = ptvService.route(routeId) iInfoState.update { RouteInfoPanelState( @@ -232,23 +232,51 @@ class MapScreenViewModel( } val departures = stopTimeRepository.getForStop(id) - .filter { !it.headsign.isNullOrBlank() } - .groupBy { it.headsign!! } - .map { (headsign, stopTimes) -> - val now = Clock.System.now() - val times = stopTimes - .map { it.arrivalTime.toInstant(TimeZone.currentSystemDefault()) } - .filter { it >= (now - 1.minutes) } - .joinToString(" | ") { - val diff = (it - now).inWholeMinutes.coerceAtLeast(0) - if (diff >= 65) { - "${((diff + 30.0) / 60.0).toInt()}hr" - } else { - "${diff}mn" - } - } - StopInfoPanelState.Departure(headsign, times) + .groupBy { it.stopId } + .mapKeys { (id) -> + val stop = stopRepository.get(id) + if (stop.platformCode.firstOrNull()?.isDigit() == true) { + "Platform " + stop.platformCode + } else { + stop.platformCode + } } + .entries + .sortedBy { (platform) -> platform } + .map { (platform, deps) -> + StopInfoPanelState.DeparturePlatforms( + platform = platform, + departures = deps.take(5).mapNotNull { + val route = routeRepository.getByTrip(it.tripId) + ?: return@mapNotNull null + StopInfoPanelState.DepartureInfo( + routeName = route.number ?: route.name, + routeColour = route.type.getUIProperties().colour, + headsign = it.headsign ?: route.name, + description = null, + time = it.departureTime.toInstant(TimeZone.currentSystemDefault()), + ) + } + ) + } +// val departures = stopTimeRepository.getForStop(id) +// .filter { !it.headsign.isNullOrBlank() } +// .groupBy { it.headsign!! } +// .map { (headsign, stopTimes) -> +// val now = Clock.System.now() +// val times = stopTimes +// .map { it.arrivalTime.toInstant(TimeZone.currentSystemDefault()) } +// .filter { it >= (now - 1.minutes) } +// .joinToString(" | ") { +// val diff = (it - now).inWholeMinutes.coerceAtLeast(0) +// if (diff >= 65) { +// "${((diff + 30.0) / 60.0).toInt()}hr" +// } else { +// "${diff}mn" +// } +// } +// StopInfoPanelState.DeparturePlatforms(headsign, times) +// } iInfoState.update { if (it !is StopInfoPanelState) it