featwip: pretty departures
This commit is contained in:
parent
ef630b6d58
commit
4e1e05495d
13 changed files with 264 additions and 37 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>>()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue