Compare commits

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

1 commit

Author SHA1 Message Date
8b3016004b
feat: basic departures support
also a huge refactor to simplify modules
2026-06-23 00:07:10 +10:00
44 changed files with 627 additions and 211 deletions

View file

@ -25,9 +25,40 @@ kotlin {
jvm()
sourceSets {
val clientMain by creating {
dependsOn(commonMain.get())
}
androidMain.get().dependsOn(clientMain)
iosArm64Main.get().dependsOn(clientMain)
iosSimulatorArm64Main.get().dependsOn(clientMain)
commonMain.dependencies {
implementation(libs.koin.core)
implementation(projects.core)
api(projects.core.data.stoptime)
api(projects.core.stoptime)
}
androidMain.dependencies {
implementation(libs.koin.compose)
implementation(libs.ktor.client.okhttp)
}
commonMain.dependencies {
implementation(libs.okio)
implementation(libs.koin.core)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.contentnegotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.serialization.protobuf)
implementation(projects.core)
implementation(projects.core.sqld)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
}

View file

@ -1,54 +0,0 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.ksp)
}
kotlin {
android {
namespace = "moe.lava.banksia.core.data.client"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
}
iosArm64()
iosSimulatorArm64()
jvm()
sourceSets {
androidMain.dependencies {
implementation(libs.koin.compose)
implementation(libs.ktor.client.okhttp)
}
commonMain.dependencies {
api(projects.core.data)
implementation(libs.okio)
implementation(libs.koin.core)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.contentnegotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.serialization.protobuf)
implementation(projects.core)
implementation(projects.core.sqld)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
}

View file

@ -1,20 +0,0 @@
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
}
}
dependencies {
implementation(libs.okio)
implementation(libs.koin.core)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
api(projects.core.data)
implementation(projects.core)
}

View file

@ -16,17 +16,13 @@ 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.stop.StopLocalDataSource
import moe.lava.banksia.core.data.sources.stop.StopRemoteDataSource
import moe.lava.banksia.core.sqld.sqldDiModule
import moe.lava.banksia.core.util.log
import moe.lava.banksia.data.ptv.PtvService
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
val clientDataDiModule = module {
includes(sqldDiModule)
includes(stopTimeDataDiModule)
actual val platformModule = module {
// HTTP Clients
singleOf(::PtvService)
single {

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
import moe.lava.banksia.core.sqld.mappers.asModel
internal class ClientRouteRepository internal constructor(
@ -22,5 +23,14 @@ internal class ClientRouteRepository internal constructor(
}
}
private val tripRouteMap = mutableMapOf<Long, Route>()
override suspend fun get(id: String) = mutex.withLock { local.get(id)?.asModel() ?: remote.get(id) }
override suspend fun getByPattern(patternId: Long) = mutex.withLock {
tripRouteMap[patternId]
?: remote.getByPattern(patternId).also {
local.save(it)
tripRouteMap[patternId] = it
}
}
}

View file

@ -10,6 +10,7 @@ import moe.lava.banksia.core.sqld.mappers.asDb
internal class RouteLocalDataSource(private val queries: RouteQueries) {
suspend fun get(id: String) = withContext(Dispatchers.IO) { queries.get(id).executeAsOneOrNull() }
suspend fun getAll() = withContext(Dispatchers.IO) { queries.getAll().executeAsList() }
// suspend fun getByTrip(tripId: String) = dao.getByTrip(tripId)
suspend fun save(vararg routes: Route) {
withContext(Dispatchers.IO) {
queries.transaction {

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 getByPattern(patternId: Long) = client.get("routes/by_pattern/${patternId}").body<Route>()
suspend fun getAll() = client.get("routes").body<List<Route>>()
}

View file

@ -0,0 +1,13 @@
package moe.lava.banksia.core.data
import moe.lava.banksia.core.sqld.sqldDiModule
import org.koin.core.module.Module
import org.koin.dsl.module
internal expect val platformModule: Module
val dataDiModule = module {
includes(platformModule)
includes(sqldDiModule)
includes(stopTimeDataDiModule)
}

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 getByPattern(patternId: Long): Route?
suspend fun getAll(): List<Route>
}

View file

@ -0,0 +1,7 @@
package moe.lava.banksia.core.data
import org.koin.dsl.module
internal actual val platformModule = module {
}

View file

@ -2,9 +2,10 @@ package moe.lava.banksia.core.sqld.mappers
import moe.lava.banksia.core.model.StopTime
import moe.lava.banksia.core.model.StoppingPattern
import moe.lava.banksia.core.model.TimeType
import moe.lava.banksia.core.sqld.StoppingPattern as DbStoppingPattern
fun DbStoppingPattern.asModel(stoptimes: List<StopTime.Undated>) = StoppingPattern.Undated(
fun <T: TimeType> DbStoppingPattern.asModel(stoptimes: List<StopTime<T>>) = StoppingPattern(
id = id,
routeId = routeId,
shapeId = shapeId,
@ -13,7 +14,7 @@ fun DbStoppingPattern.asModel(stoptimes: List<StopTime.Undated>) = StoppingPatte
stoptimes = stoptimes,
)
fun StoppingPattern.Undated.asDb() = DbStoppingPattern(
fun StoppingPattern<*>.asDb() = DbStoppingPattern(
id = id,
routeId = routeId,
shapeId = shapeId,

View file

@ -11,5 +11,10 @@ SELECT * FROM Route;
get:
SELECT * FROM Route WHERE id == ?;
getByPattern:
SELECT Route.* FROM Route
INNER JOIN StoppingPattern ON Route.id == StoppingPattern.routeId
WHERE StoppingPattern.id == :patternId;
insert:
INSERT INTO Route VALUES ?;
INSERT OR REPLACE INTO Route VALUES ?;

View file

@ -22,3 +22,24 @@ INNER JOIN StoppingPattern ON StoppingPattern.id == Trip.patternId
WHERE StopTime.patternId == StoppingPattern.id
AND StopTime.stopId IN (SELECT Stop.id FROM Stop WHERE Stop.parent == :stopId OR Stop.id == :stopId)
AND ServiceException.type IS NULL;
getExtendedForStop:
SELECT DISTINCT
StopTime.patternId,
StopTime.arrivalDelta,
StopTime.departureTime,
StoppingPattern.headsign,
Route.type AS routeType,
Route.number AS routeNumber,
Route.name AS routeName,
Stop.platformCode AS stopPlatformCode
FROM StopTime
INNER JOIN Service ON Service.days & :days = :days AND :date BETWEEN Service.start AND Service.`end`
LEFT JOIN ServiceException ON ServiceException.serviceId == Service.id AND ServiceException.date == :date
INNER JOIN Trip ON Trip.serviceId == Service.id
INNER JOIN StoppingPattern ON StoppingPattern.id == Trip.patternId
INNER JOIN Route ON Route.id == StoppingPattern.routeId
INNER JOIN Stop ON Stop.id == StopTime.stopId
WHERE StopTime.patternId == StoppingPattern.id
AND StopTime.stopId IN (SELECT Stop.id FROM Stop WHERE Stop.parent == :stopId OR Stop.id == :stopId)
AND ServiceException.type IS NULL;

View file

@ -8,3 +8,6 @@ CREATE TABLE StoppingPattern (
insert:
INSERT OR REPLACE INTO StoppingPattern VALUES ?;
get:
SELECT * FROM StoppingPattern WHERE id == :id;

View file

@ -0,0 +1,3 @@
package moe.lava.banksia.core.endpoints
object Endpoint

View file

@ -31,13 +31,15 @@ sealed class TimeType {
) : TimeType()
}
fun TimeType.Undated.atDate(date: LocalDate) = TimeType.Dated(
arrival = arrival.atDate(date),
departure = departure.atDate(date),
)
fun StopTime<TimeType.Undated>.atDate(date: LocalDate) = StopTime(
patternId = patternId,
stopId = stopId,
time = TimeType.Dated(
arrival = time.arrival.atDate(date),
departure = time.departure.atDate(date),
),
time = time.atDate(date),
pickupType = pickupType,
dropOffType = dropOffType,
)

View file

@ -9,7 +9,7 @@ plugins {
kotlin {
android {
namespace = "moe.lava.banksia.core.data.stoptime"
namespace = "moe.lava.banksia.core.stoptime"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compilerOptions {
@ -56,5 +56,9 @@ kotlin {
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
jvmMain.dependencies {
implementation(libs.koin.ktor)
implementation(libs.ktor.server.core)
}
}
}

View file

@ -7,7 +7,9 @@ import io.ktor.client.request.parameter
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
import moe.lava.banksia.core.model.StopTime
import moe.lava.banksia.core.data.dto.ExtendedStopTime
import moe.lava.banksia.core.endpoints.Endpoint
import moe.lava.banksia.core.endpoints.stopTimeByStop
import kotlin.time.Clock
internal class StopTimeRemoteDataSource(
@ -16,9 +18,9 @@ internal class StopTimeRemoteDataSource(
suspend fun getAtStop(
stopId: String,
date: LocalDate? = Clock.System.todayIn(TimeZone.currentSystemDefault()),
): List<StopTime.Dated> {
return client.get("stoptimes/by_stop/${stopId}") {
): List<ExtendedStopTime> {
return client.get(Endpoint.stopTimeByStop(stopId)) {
parameter("date", date)
}.body<List<StopTime.Dated>>()
}.body<List<ExtendedStopTime>>()
}
}

View file

@ -0,0 +1,34 @@
package moe.lava.banksia.core.data.dto
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
import moe.lava.banksia.core.model.FutureTime
import moe.lava.banksia.core.model.RouteType
import moe.lava.banksia.core.model.TimeType
import moe.lava.banksia.core.model.atDate
import moe.lava.banksia.core.sqld.GetExtendedForStop
@Serializable
data class ExtendedStopTime(
val patternId: Long,
val stopPlatformCode: String?,
val time: TimeType.Dated,
val headsign: String?,
val routeType: RouteType,
val routeNumber: String?,
val routeName: String,
)
// TODO: This probably doesn't belong here
fun GetExtendedForStop.asModel(date: LocalDate) = ExtendedStopTime(
patternId = patternId,
stopPlatformCode = stopPlatformCode,
time = TimeType.Undated(
arrival = FutureTime.fromInt((departureTime + arrivalDelta).toInt()),
departure = FutureTime.fromInt(departureTime.toInt()),
).atDate(date),
headsign = headsign,
routeType = RouteType.from(routeType.toInt()),
routeNumber = routeNumber,
routeName = routeName,
)

View file

@ -4,12 +4,12 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
import moe.lava.banksia.core.model.StopTime
import moe.lava.banksia.core.data.dto.ExtendedStopTime
import kotlin.time.Clock
expect class StopTimeRepository {
suspend fun getForStop(
id: String,
date: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault()),
): Flow<List<StopTime.Dated>>
): Flow<List<ExtendedStopTime>>
}

View file

@ -4,10 +4,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
import kotlinx.datetime.LocalDate
import moe.lava.banksia.core.model.StopTime
import moe.lava.banksia.core.model.atDate
import moe.lava.banksia.core.data.dto.ExtendedStopTime
import moe.lava.banksia.core.data.dto.asModel
import moe.lava.banksia.core.sqld.StopTimeQueries
import moe.lava.banksia.core.sqld.mappers.asModel
import moe.lava.banksia.core.util.serialise
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
@ -15,16 +14,16 @@ import org.koin.core.component.get
internal class StopTimeLocalDataSource : KoinComponent {
private val queries get() = get<StopTimeQueries>()
suspend fun getAtStop(stopId: String, date: LocalDate): List<StopTime.Dated> {
suspend fun getAtStop(stopId: String, date: LocalDate): List<ExtendedStopTime> {
return withContext(context = Dispatchers.IO) {
queries
.getForStopDated(
.getExtendedForStop(
listOf(date.dayOfWeek).serialise().toLong(),
date.toEpochDays(),
stopId,
)
.executeAsList()
.map { it.asModel().atDate(date) }
.map { it.asModel(date) }
.sortedBy { it.time.departure }
}
}

View file

@ -0,0 +1,3 @@
package moe.lava.banksia.core.endpoints
fun Endpoint.stopTimeByStop(stopId: String) = "stoptimes/by_stop/${stopId}"

View file

@ -0,0 +1,27 @@
package moe.lava.banksia.server.routes
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
import moe.lava.banksia.core.data.repositories.StopTimeRepository
import moe.lava.banksia.core.endpoints.Endpoint
import moe.lava.banksia.core.endpoints.stopTimeByStop
import org.koin.ktor.ext.inject
import kotlin.time.Clock
fun Route.stopTimeRoutes() {
val repo by inject<StopTimeRepository>()
get(Endpoint.stopTimeByStop("{stop_id}")) {
val stopId = call.parameters["stop_id"]!!
val date = call.queryParameters["date"]
?.let { LocalDate.parse(it, LocalDate.Formats.ISO) }
?: Clock.System.todayIn(TimeZone.currentSystemDefault())
val data = repo.getForStop(stopId, date).first()
call.respond(data)
}
}

View file

@ -1,11 +1,11 @@
[versions]
agp = "9.1.0"
android-compileSdk = "36"
android-compileSdk = "37"
android-minSdk = "24"
android-targetSdk = "36"
android-targetSdk = "37"
androidx-activity= "1.13.0"
androidx-lifecycle = "2.10.0"
compose-multiplatform = "1.11.0-alpha04"
compose-multiplatform = "1.12.0-alpha02"
composeunstyled = "1.49.6"
coroutines = "1.10.2"
geo = "0.8.0"
@ -19,7 +19,7 @@ ktor = "3.4.1"
logback = "1.5.32"
maplibre = "0.12.1"
material = "1.7.3"
material3 = "1.11.0-alpha04"
material3 = "1.11.0-alpha07"
okio = "3.17.0"
playServicesLocation = "21.3.0"
secretsGradlePlugin = "2.0.1"

View file

@ -20,7 +20,9 @@ kotlin {
dependencies {
implementation(projects.core)
implementation(projects.core.data)
implementation(projects.core.sqld)
implementation(projects.core.stoptime)
implementation(projects.server.gtfs)
implementation(projects.server.gtfsRt)

View file

@ -15,22 +15,16 @@ import io.ktor.server.routing.routing
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
import moe.lava.banksia.core.Constants
import moe.lava.banksia.core.model.atDate
import moe.lava.banksia.core.sqld.RouteQueries
import moe.lava.banksia.core.sqld.StopQueries
import moe.lava.banksia.core.sqld.StopTimeQueries
import moe.lava.banksia.core.sqld.mappers.asModel
import moe.lava.banksia.core.util.serialise
import moe.lava.banksia.server.di.ServerModules
import moe.lava.banksia.server.gtfsrt.GtfsrtService
import moe.lava.banksia.server.routes.stopTimeRoutes
import org.koin.dsl.module
import org.koin.ktor.ext.get
import org.koin.ktor.plugin.Koin
import kotlin.time.Clock
fun main() {
if (System.getenv("BANKSIA_PRODUCTION") == "1") Constants.devMode = false
@ -53,6 +47,8 @@ fun Application.module() {
launch { get<GtfsrtService>().start(this, !Constants.devMode) }
routing {
stopTimeRoutes()
if (Constants.devMode) {
get("/fixup") {
call.respondText("received")
@ -137,23 +133,5 @@ fun Application.module() {
}
call.respond(stops.map { it.asModel() })
}
get("/stoptimes/by_stop/{stop_id}") {
val stopId = call.parameters["stop_id"]!!
val date = call.queryParameters["date"]
?.let { LocalDate.parse(it, LocalDate.Formats.ISO) }
?: Clock.System.todayIn(TimeZone.currentSystemDefault())
val times = withContext(context = Dispatchers.IO) {
get<StopTimeQueries>()
.getForStopDated(
listOf(date.dayOfWeek).serialise().toLong(),
date.toEpochDays(),
stopId,
)
.executeAsList()
.map { it.asModel().atDate(date) }
.sortedBy { it.time.departure }
}
call.respond(times)
}
}
}

View file

@ -1,7 +1,7 @@
package moe.lava.banksia.server.di
import io.ktor.client.HttpClient
import moe.lava.banksia.core.sqld.sqldDiModule
import moe.lava.banksia.core.data.dataDiModule
import moe.lava.banksia.server.GtfsDataFixer
import moe.lava.banksia.server.GtfsImporter
import moe.lava.banksia.server.gtfs.GtfsParser
@ -11,7 +11,7 @@ import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val ServerModules = module {
includes(sqldDiModule)
includes(dataDiModule)
single { HttpClient() }
singleOf(::GtfsParser)

View file

@ -37,9 +37,7 @@ include(":server:gtfs")
include(":server:gtfs_rt")
include(":core")
include(":core:data")
include(":core:data:client")
include(":core:data:server")
include(":core:data:stoptime")
include(":core:stoptime")
include(":core:sqld")
include(":ui")
include(":ui:maps")

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,42 +1,297 @@
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
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
) {
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(
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,
) {
Column(Modifier.fillMaxWidth()) {
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,
@ -53,23 +308,51 @@ internal fun StopInfoPanel(
textAlign = TextAlign.Start
)
}
state.departures?.let {
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)
}
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,27 +242,54 @@ 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"
.onEach { departures ->
state = state.copy(
lastStopDepartures = departures
)
}
}
StopInfoPanelState.Departure(headsign, times)
.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
@ -260,7 +297,6 @@ class MapScreenViewModel(
it.copy(departures = departures)
}
}
.launchIn(viewModelScope)
}
/*private suspend fun buildPolylines(route: PtvRoute) {