wip departures + refactor

This commit is contained in:
Cilly Leang 2026-06-22 00:14:19 +10:00
parent b31067992d
commit 41f3523a5a
Signed by: cilly
GPG key ID: 6500251E087653C9
43 changed files with 596 additions and 204 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,334 @@
package moe.lava.banksia.ui.layout.info
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
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.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.items
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.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()
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,
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun StopInfoPanel(
state: StopInfoPanelState,
onEvent: (StopInfoPanelEvent) -> Unit,
) {
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 colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
selectedContainerColor = MaterialTheme.colorScheme.primary,
selectedContentColor = MaterialTheme.colorScheme.onPrimary,
)
// val spec = fadeIn(MaterialTheme.motionScheme.defaultEffectsSpec())
// .togetherWith(fadeOut(MaterialTheme.motionScheme.defaultEffectsSpec()))
val spec = fadeIn(tween(300, 300)) togetherWith fadeOut(tween(300))
AnimatedContent(
targetState = state,
contentKey = { it.id },
// transitionSpec = { spec },
transitionSpec = { spec },
) { state ->
Column(Modifier.fillMaxWidth().fillMaxHeight()) {
Row {
Column {
Text(
name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
formatted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 5.dp)
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 ->
val lazyState = if (departurePlatforms.size == 1) {
LazyListState(firstVisibleItemIndex =
departurePlatforms[0].departures.indexOfFirst {
it.time > Clock.System.now()
}.coerceAtLeast(0)
)
} else LazyListState()
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
state = lazyState,
) {
if (departurePlatforms.size > 1) {
items(departurePlatforms) { (platform, departures) ->
// 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(
modifier = Modifier.height(200.dp),
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)
) {
departures
.filter { it.time > Clock.System.now() }
.take(5)
.forEachIndexed { idx, dep ->
SegmentedListItem(
onClick = {},
colors = colors,
shapes = ListItemDefaults.segmentedShapes(
idx + if (departurePlatforms.size > 1) 1 else 0,
departures.size + 1
),
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,
)
}
}
}
}
}
Spacer(modifier = Modifier.height(10.dp))
}
} else if (departurePlatforms.size == 1) {
itemsIndexed(departurePlatforms[0].departures) { idx, dep ->
// departurePlatforms[0].departures.forEachIndexed { idx, dep ->
SegmentedListItem(
onClick = {},
colors = colors,
shapes = ListItemDefaults.segmentedShapes(
idx,
departurePlatforms[0].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(
text = dep.headsign,
style = MaterialTheme.typography.labelLargeEmphasized,
)
}
}
}
}
}
}
}
}
}
}
@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)