featwip: pretty departures

This commit is contained in:
Cilly Leang 2026-04-13 03:58:00 +10:00
parent ef630b6d58
commit 4e1e05495d
Signed by: cilly
GPG key ID: 6500251E087653C9
13 changed files with 264 additions and 37 deletions

View file

@ -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<String, Route>()
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
}
}
}

View file

@ -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())
}

View file

@ -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<Route>()
suspend fun getByTrip(tripId: String) = client.get("routes/by_trip/${tripId}").body<Route>()
suspend fun getAll() = client.get("routes").body<List<Route>>()
}

View file

@ -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<Route>
}

View file

@ -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

View file

@ -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<RouteDao>().getByTrip(tripId)
}
if (route != null)
call.respond(route.asModel())
else
call.respond(HttpStatusCode.NotFound)
}
get("/stops") {
val routes = withContext(context = Dispatchers.IO) {
get<StopDao>().getAll()

View file

@ -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)

View file

@ -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
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M480,600L280,400L680,400L480,600Z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M280,560L480,360L680,560L280,560Z"/>
</vector>

View file

@ -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 {

View file

@ -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<Departure>? = null,
val departures: List<DeparturePlatforms>? = 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<DepartureInfo>,
)
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
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(
formatted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 5.dp)
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 = {},
)
}

View file

@ -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"
.groupBy { it.stopId }
.mapKeys { (id) ->
val stop = stopRepository.get(id)
if (stop.platformCode.firstOrNull()?.isDigit() == true) {
"Platform " + stop.platformCode
} else {
"${diff}mn"
stop.platformCode
}
}
StopInfoPanelState.Departure(headsign, times)
.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