feat: basic departures support

also a huge refactor to simplify modules
This commit is contained in:
Cilly Leang 2026-06-23 00:07:10 +10:00
parent b31067992d
commit 8b3016004b
Signed by: cilly
GPG key ID: 6500251E087653C9
44 changed files with 627 additions and 211 deletions

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)
@ -68,7 +70,8 @@ kotlin {
implementation(libs.ui.backhandler)
implementation(projects.core)
implementation(projects.core.data.client)
implementation(projects.core.data)
implementation(projects.core.stoptime)
implementation(projects.ui.maps)
implementation(projects.ui.shared)
}

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

@ -1,12 +1,12 @@
package moe.lava.banksia.ui.di
import moe.lava.banksia.core.data.clientDataDiModule
import moe.lava.banksia.core.data.dataDiModule
import moe.lava.banksia.ui.screens.map.MapScreenViewModel
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val AppModule = module {
includes(clientDataDiModule)
includes(dataDiModule)
// ViewModel
viewModelOf(::MapScreenViewModel)

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContent
import androidx.compose.foundation.layout.size
@ -27,7 +28,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceAtMost
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
@ -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,11 +66,13 @@ fun InfoPanel(
}
Column(
Modifier
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.heightIn(min = 350.dp)
.onSizeChanged {
onPeekHeightChange(with(localDensity) { it.height.toDp().coerceAtMost(250.dp) })
// onPeekHeightChange(with(localDensity) { it.height.toDp().coerceAtMost(250.dp) })
onPeekHeightChange(350.dp)
}
) {
Box {

View file

@ -1,75 +1,358 @@
package moe.lava.banksia.ui.layout.info
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.mutableStateListOf
import androidx.compose.runtime.remember
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()
sealed class StopInfoPanelEvent : InfoPanelEvent() {
data object ToggleGrouping : StopInfoPanelEvent()
}
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
get() = departures.isNullOrEmpty()
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,
)
}
@Composable
internal fun StopInfoPanel(
state: StopInfoPanelState,
onEvent: (StopInfoPanelEvent) -> Unit,
private fun listColors() = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
selectedContainerColor = MaterialTheme.colorScheme.primary,
selectedContentColor = MaterialTheme.colorScheme.onPrimary,
)
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun MonoPlatform(
state: StopInfoPanelState.DeparturePlatforms
) {
Column(Modifier.fillMaxWidth()) {
Text(
state.name,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Start
)
state.subname?.let {
Text(
"/ $it",
modifier = Modifier.padding(start = 5.dp),
style = MaterialTheme.typography.titleSmall,
color = Color.Gray,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Start
)
}
state.departures?.let {
Spacer(Modifier.height(5.dp))
it.forEach { (name, formatted) ->
Row(verticalAlignment = Alignment.CenterVertically) {
val departures = state.departures
val lazyState = LazyListState(firstVisibleItemIndex =
departures.indexOfFirst {
it.time > Clock.System.now()
}.coerceAtLeast(0)
)
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
state = lazyState,
) {
itemsIndexed(departures) { idx, dep ->
SegmentedListItem(
onClick = {},
colors = listColors(),
shapes = ListItemDefaults.segmentedShapes(
idx,
departures.size,
),
supportingContent = {
dep.description?.let { Text(dep.description) }
},
trailingContent = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy((-4).dp)
) {
Text(
text = (dep.time - Clock.System.now()).inWholeMinutes.toString(),
style = MaterialTheme.typography.headlineSmallEmphasized,
)
Text(
text = "mn",
style = MaterialTheme.typography.labelSmallEmphasized,
)
}
},
) {
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(
name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
formatted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 5.dp)
text = dep.headsign,
style = MaterialTheme.typography.labelLargeEmphasized,
)
}
}
}
}
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun ManyPlatforms(
state: List<StopInfoPanelState.DeparturePlatforms>,
) {
val expandedList = remember { mutableStateListOf(*Array(state.size) { true }) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
state.forEachIndexed { idx, depInfo ->
val (platform, departures) = depInfo
val expanded = expandedList[idx]
stickyHeader(key = "header_${depInfo.hashCode()}") {
val base = ListItemDefaults.segmentedShapes(0, 2)
val large = MaterialTheme.shapes.large
Box(
Modifier
.animateItem()
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.padding(bottom = ListItemDefaults.SegmentedGap)
) {
SegmentedListItem(
onClick = { expandedList[idx] = !expandedList[idx] },
colors = listColors(),
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,
)
}
}
}
if (expanded) {
item(key = "items_${depInfo.hashCode()}") {
Column(
modifier = Modifier.animateItem(),
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
) {
departures.filter { it.time > Clock.System.now() }.take(5)
.forEachIndexed { idx, dep ->
SegmentedListItem(
onClick = {},
colors = listColors(),
shapes = ListItemDefaults.segmentedShapes(
idx + 1,
(departures.size + 1).coerceAtMost(6),
),
supportingContent = {
dep.description?.let { Text(dep.description) }
},
trailingContent = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy((-4).dp)
) {
Text(
text = (dep.time - Clock.System.now()).inWholeMinutes.toString(),
style = MaterialTheme.typography.headlineSmallEmphasized,
)
Text(
text = "mn",
style = MaterialTheme.typography.labelSmallEmphasized,
)
}
},
) {
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,
)
}
}
}
}
}
}
item(key = "spacer_${depInfo.hashCode()}") {
Spacer(
modifier = Modifier.animateItem().height(10.dp)
)
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun StopInfoPanel(
state: StopInfoPanelState,
onEvent: (StopInfoPanelEvent) -> Unit,
) {
val spec = fadeIn(tween(300, 300)) togetherWith fadeOut(tween(300))
AnimatedContent(
targetState = state,
contentKey = { it.id },
transitionSpec = { spec },
) { state ->
Column(Modifier.fillMaxWidth().fillMaxHeight()) {
Row {
Column {
Text(
state.name,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Start
)
state.subname?.let {
Text(
"/ $it",
modifier = Modifier.padding(start = 5.dp),
style = MaterialTheme.typography.titleSmall,
color = Color.Gray,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Start
)
}
}
IconButton(
onClick = { onEvent(StopInfoPanelEvent.ToggleGrouping) },
) { Icon(Icons.Default.Edit, null) }
}
Spacer(Modifier.height(10.dp))
AnimatedContent(
targetState = state.departures,
transitionSpec = { spec },
) { departures ->
departures?.let { departurePlatforms ->
if (departurePlatforms.size > 1) {
ManyPlatforms(departurePlatforms)
} else if (departurePlatforms.size == 1) {
MonoPlatform(departurePlatforms[0])
}
}
}
}
}
}
@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

@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import moe.lava.banksia.core.data.dto.ExtendedStopTime
import moe.lava.banksia.core.data.repositories.RouteRepository
import moe.lava.banksia.core.data.repositories.StopRepository
import moe.lava.banksia.core.data.repositories.StopTimeRepository
@ -26,9 +27,11 @@ 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
import moe.lava.banksia.ui.layout.info.StopInfoPanelEvent
import moe.lava.banksia.ui.layout.info.StopInfoPanelState
import moe.lava.banksia.ui.layout.info.TripInfoPanelState
import moe.lava.banksia.ui.map.util.CameraPosition
@ -36,8 +39,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()
@ -53,6 +54,9 @@ private data class InternalState(
val route: String? = null,
val stop: String? = null,
val run: String? = null,
val lastStopDepartures: List<ExtendedStopTime>? = null,
val stopsGrouped: Boolean = true,
)
class MapScreenViewModel(
@ -69,6 +73,10 @@ class MapScreenViewModel(
viewModelScope.launch { switchRoute(value.route) }
if (value.stop != last.stop)
viewModelScope.launch { switchStop(value.stop) }
if (value.lastStopDepartures != last.lastStopDepartures)
viewModelScope.launch { buildDepartures() }
if (value.stopsGrouped != last.stopsGrouped)
viewModelScope.launch { buildDepartures() }
if (value.run != last.run)
switchRun(value.run)
}
@ -105,7 +113,9 @@ class MapScreenViewModel(
fun handleEvent(event: InfoPanelEvent) {
viewModelScope.launch {
// when (event) { }
when (event) {
StopInfoPanelEvent.ToggleGrouping -> state = state.copy(stopsGrouped = !state.stopsGrouped)
}
}
}
@ -165,7 +175,7 @@ class MapScreenViewModel(
}
val route = routeRepository.get(routeId)
// val gtfsRoute = ptvService.route(routeId)
?: return
iInfoState.update {
RouteInfoPanelState(
name = route.name,
@ -215,11 +225,11 @@ class MapScreenViewModel(
private suspend fun switchStop(id: String?) {
if (id == null) {
iInfoState.update { InfoPanelState.None }
state = state.copy(lastStopDepartures = null)
return
}
val stop = stopRepository.get(id)
// val stop = ptvService.stop(routeType, stopId)
val split = stop.name.split("/")
val name = split[0]
val subname = split.getOrNull(1)
@ -232,37 +242,63 @@ class MapScreenViewModel(
}
stopTimeRepository.getForStop(id)
.onEach { stoptimes ->
val departures = stoptimes
// .filter { !it.headsign.isNullOrBlank() }
// .groupBy { it.headsign!! }
.groupBy { it.stopId } // TODO: Placeholder
.map { (headsign, stopTimes) ->
val now = Clock.System.now()
val times = stopTimes
.map { it.time.arrival.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)
}
iInfoState.update {
if (it !is StopInfoPanelState)
it
else
it.copy(departures = departures)
}
.onEach { departures ->
state = state.copy(
lastStopDepartures = departures
)
}
.launchIn(viewModelScope)
}
private fun friendlyPlatform(platform: String) =
platform.takeUnless { it.firstOrNull()?.isDigit() == true }
?: "Platform $platform"
private fun buildDepartures() {
val rawDepartures = state.lastStopDepartures ?: return
val departures = if (state.stopsGrouped) {
rawDepartures
.groupBy { it.stopPlatformCode }
.mapKeys { (platform) -> platform?.let { friendlyPlatform(it) } }
.entries
.sortedBy { (platform) -> platform }
.map { (platform, deps) ->
StopInfoPanelState.DeparturePlatforms(
platform = platform ?: "",
departures = deps.map {
StopInfoPanelState.DepartureInfo(
routeName = it.routeNumber ?: it.routeName,
routeColour = it.routeType.getUIProperties().colour,
headsign = it.headsign ?: it.routeName,
description = null,
time = it.time.departure.toInstant(TimeZone.currentSystemDefault()),
)
}
)
}
} else if (rawDepartures.isEmpty()) {
listOf()
} else {
listOf(StopInfoPanelState.DeparturePlatforms(platform = "", departures = rawDepartures.map { dep ->
StopInfoPanelState.DepartureInfo(
routeName = dep.routeNumber ?: dep.routeName,
routeColour = dep.routeType.getUIProperties().colour,
headsign = dep.headsign ?: dep.routeName,
description = dep.stopPlatformCode?.let { friendlyPlatform(it) },
time = dep.time.departure.toInstant(TimeZone.currentSystemDefault()),
)
}))
}
departures.let { departures ->
iInfoState.update {
if (it !is StopInfoPanelState)
it
else
it.copy(departures = departures)
}
}
}
/*private suspend fun buildPolylines(route: PtvRoute) {
val routeWithGeo = if (route.geopath.isEmpty())
ptvService.route(route.routeId, true)