Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
4e1e05495d
featwip: pretty departures 2026-04-13 03:58:00 +10:00
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 kotlinx.coroutines.sync.withLock
import moe.lava.banksia.core.data.sources.route.RouteLocalDataSource import moe.lava.banksia.core.data.sources.route.RouteLocalDataSource
import moe.lava.banksia.core.data.sources.route.RouteRemoteDataSource import moe.lava.banksia.core.data.sources.route.RouteRemoteDataSource
import moe.lava.banksia.core.model.Route
internal class ClientRouteRepository internal constructor( internal class ClientRouteRepository internal constructor(
private val local: RouteLocalDataSource, 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 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) { internal class RouteLocalDataSource(private val dao: RouteDao) {
suspend fun get(id: String) = dao.get(id) suspend fun get(id: String) = dao.get(id)
suspend fun getAll() = dao.getAll() 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()) 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) { internal class RouteRemoteDataSource(val client: HttpClient) {
suspend fun get(id: String) = client.get("routes/${id}").body<Route>() 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>>() 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 import moe.lava.banksia.core.model.Route
interface RouteRepository { 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> suspend fun getAll(): List<Route>
} }

View file

@ -28,6 +28,9 @@ interface RouteDao {
@Query("DELETE FROM Route") @Query("DELETE FROM Route")
suspend fun deleteAll() 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(""" @Query("""
SELECT Stop.* FROM Stop SELECT Stop.* FROM Stop
INNER JOIN StopTime ON StopTime.stopId == Stop.id INNER JOIN StopTime ON StopTime.stopId == Stop.id

View file

@ -118,6 +118,16 @@ fun Application.module() {
else else
call.respond(HttpStatusCode.NotFound) 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") { get("/stops") {
val routes = withContext(context = Dispatchers.IO) { val routes = withContext(context = Dispatchers.IO) {
get<StopDao>().getAll() get<StopDao>().getAll()

View file

@ -41,7 +41,9 @@ kotlin {
sourceSets { sourceSets {
androidMain.dependencies { androidMain.dependencies {
implementation(libs.compose.ui.tooling.preview)
implementation(libs.play.services.location) implementation(libs.play.services.location)
implementation(projects.ui.shared)
} }
commonMain.dependencies { commonMain.dependencies {
implementation(libs.compose.components.resources) implementation(libs.compose.components.resources)

View file

@ -16,6 +16,10 @@ kotlin {
compilerOptions { compilerOptions {
jvmTarget.set(JvmTarget.JVM_11) jvmTarget.set(JvmTarget.JVM_11)
} }
androidResources {
enable = true
}
} }
compilerOptions { compilerOptions {
@ -47,4 +51,5 @@ dependencies {
compose.resources { compose.resources {
publicResClass = true publicResClass = true
packageOfResClass = "moe.lava.banksia.resources" 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) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun InfoPanel( fun InfoPanel(
modifier: Modifier = Modifier,
state: InfoPanelState, state: InfoPanelState,
onEvent: (InfoPanelEvent) -> Unit, onEvent: (InfoPanelEvent) -> Unit,
onPeekHeightChange: (Dp) -> Unit, onPeekHeightChange: (Dp) -> Unit,
@ -65,7 +66,7 @@ fun InfoPanel(
} }
Column( Column(
Modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 24.dp) .padding(horizontal = 24.dp)
.onSizeChanged { .onSizeChanged {

View file

@ -1,21 +1,51 @@
package moe.lava.banksia.ui.layout.info 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.MaterialTheme
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.ShapeDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign 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 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() sealed class StopInfoPanelEvent : InfoPanelEvent()
@ -23,19 +53,37 @@ data class StopInfoPanelState(
val id: String, val id: String,
val name: String, val name: String,
val subname: String? = null, val subname: String? = null,
val departures: List<Departure>? = null, val departures: List<DeparturePlatforms>? = null,
) : InfoPanelState() { ) : InfoPanelState() {
override val loading: Boolean override val loading: Boolean
get() = departures == null 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 @Composable
internal fun StopInfoPanel( internal fun StopInfoPanel(
state: StopInfoPanelState, state: StopInfoPanelState,
onEvent: (StopInfoPanelEvent) -> Unit, onEvent: (StopInfoPanelEvent) -> Unit,
) { ) {
val colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
selectedContainerColor = MaterialTheme.colorScheme.primary,
selectedContentColor = MaterialTheme.colorScheme.onPrimary,
)
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
Text( Text(
state.name, state.name,
@ -53,23 +101,122 @@ internal fun StopInfoPanel(
textAlign = TextAlign.Start textAlign = TextAlign.Start
) )
} }
state.departures?.let { state.departures?.let { departurePlatforms ->
Spacer(Modifier.height(5.dp)) Spacer(Modifier.height(5.dp))
it.forEach { (name, formatted) -> Column(
Row(verticalAlignment = Alignment.CenterVertically) { modifier = Modifier.verticalScroll(rememberScrollState()),
Text( verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
name, ) {
style = MaterialTheme.typography.titleMedium, departurePlatforms.forEach { (platform, departures) ->
fontWeight = FontWeight.SemiBold var expanded by rememberSaveable { mutableStateOf(true) }
) val base = ListItemDefaults.segmentedShapes(0, 2)
Text( val large = MaterialTheme.shapes.large
formatted,
maxLines = 1, if (departurePlatforms.size > 1) {
overflow = TextOverflow.Ellipsis, SegmentedListItem(
modifier = Modifier.padding(horizontal = 5.dp) 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 = {},
)
}

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.Point
import moe.lava.banksia.core.util.log import moe.lava.banksia.core.util.log
import moe.lava.banksia.data.ptv.PtvService 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.InfoPanelEvent
import moe.lava.banksia.ui.layout.info.InfoPanelState import moe.lava.banksia.ui.layout.info.InfoPanelState
import moe.lava.banksia.ui.layout.info.RouteInfoPanelState 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.map.util.Marker
import moe.lava.banksia.ui.state.MapState import moe.lava.banksia.ui.state.MapState
import moe.lava.banksia.ui.state.SearchState import moe.lava.banksia.ui.state.SearchState
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes
sealed class MapScreenEvent { sealed class MapScreenEvent {
data object DismissState : MapScreenEvent() data object DismissState : MapScreenEvent()
@ -165,6 +164,7 @@ class MapScreenViewModel(
} }
val route = routeRepository.get(routeId) val route = routeRepository.get(routeId)
?: return
// val gtfsRoute = ptvService.route(routeId) // val gtfsRoute = ptvService.route(routeId)
iInfoState.update { iInfoState.update {
RouteInfoPanelState( RouteInfoPanelState(
@ -232,23 +232,51 @@ class MapScreenViewModel(
} }
val departures = stopTimeRepository.getForStop(id) val departures = stopTimeRepository.getForStop(id)
.filter { !it.headsign.isNullOrBlank() } .groupBy { it.stopId }
.groupBy { it.headsign!! } .mapKeys { (id) ->
.map { (headsign, stopTimes) -> val stop = stopRepository.get(id)
val now = Clock.System.now() if (stop.platformCode.firstOrNull()?.isDigit() == true) {
val times = stopTimes "Platform " + stop.platformCode
.map { it.arrivalTime.toInstant(TimeZone.currentSystemDefault()) } } else {
.filter { it >= (now - 1.minutes) } stop.platformCode
.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)
} }
.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 { iInfoState.update {
if (it !is StopInfoPanelState) if (it !is StopInfoPanelState)
it it