From 41f3523a5abaf25d89ccc37a4647d498289764e2 Mon Sep 17 00:00:00 2001 From: Cilly Leang Date: Mon, 22 Jun 2026 00:14:19 +1000 Subject: [PATCH] wip departures + refactor --- core/data/build.gradle.kts | 33 +- core/data/client/build.gradle.kts | 54 --- core/data/server/build.gradle.kts | 20 -- .../banksia/core/data/DataDiModule.client.kt} | 6 +- .../repositories/ClientRouteRepository.kt | 10 + .../data/repositories/ClientStopRepository.kt | 0 .../sources/route/RouteLocalDataSource.kt | 1 + .../sources/route/RouteRemoteDataSource.kt | 1 + .../data/sources/stop/StopLocalDataSource.kt | 0 .../data/sources/stop/StopRemoteDataSource.kt | 0 .../lava/banksia/core/data/DataDiModule.kt | 13 + .../core/data/repositories/RouteRepository.kt | 3 +- .../banksia/core/data/DataDiModule.jvm.kt | 7 + .../core/sqld/mappers/StoppingPattern.kt | 5 +- .../moe/lava/banksia/core/sqld/Route.sq | 7 +- .../moe/lava/banksia/core/sqld/StopTime.sq | 21 ++ .../lava/banksia/core/sqld/StoppingPattern.sq | 3 + .../lava/banksia/core/endpoints/Endpoint.kt | 3 + .../moe/lava/banksia/core/model/StopTime.kt | 10 +- core/{data => }/stoptime/build.gradle.kts | 6 +- .../core/data/StopTimeDataDiModule.client.kt | 0 .../repositories/StopTimeRepository.client.kt | 0 .../stoptime/StopTimeRemoteDataSource.kt | 10 +- .../banksia/core/data/StopTimeDataDiModule.kt | 0 .../banksia/core/data/dto/ExtendedStopTime.kt | 34 ++ .../data/repositories/StopTimeRepository.kt | 4 +- .../stoptime/StopTimeLocalDataSource.kt | 11 +- .../core/endpoints/StopTimeEndpoints.kt | 3 + .../core/data/StopTimeDataDiModule.jvm.kt | 0 .../repositories/StopTimeRepository.jvm.kt | 0 .../banksia/server/routes/StopTimeRoute.kt | 27 ++ server/build.gradle.kts | 2 + .../moe/lava/banksia/server/Application.kt | 28 +- .../lava/banksia/server/di/ServerModules.kt | 4 +- settings.gradle.kts | 4 +- ui/build.gradle.kts | 5 +- ui/shared/build.gradle.kts | 5 + .../drawable/arrow_drop_down.xml | 9 + .../drawable/arrow_drop_up.xml | 9 + .../moe/lava/banksia/ui/di/AppModule.kt | 4 +- .../lava/banksia/ui/layout/info/InfoPanel.kt | 9 +- .../banksia/ui/layout/info/StopInfoPanel.kt | 329 ++++++++++++++++-- .../ui/screens/map/MapScreenViewModel.kt | 100 ++++-- 43 files changed, 596 insertions(+), 204 deletions(-) delete mode 100644 core/data/client/build.gradle.kts delete mode 100644 core/data/server/build.gradle.kts rename core/data/{client/src/commonMain/kotlin/moe/lava/banksia/core/data/ClientDataDiModule.kt => src/clientMain/kotlin/moe/lava/banksia/core/data/DataDiModule.client.kt} (93%) rename core/data/{client/src/commonMain => src/clientMain}/kotlin/moe/lava/banksia/core/data/repositories/ClientRouteRepository.kt (71%) rename core/data/{client/src/commonMain => src/clientMain}/kotlin/moe/lava/banksia/core/data/repositories/ClientStopRepository.kt (100%) rename core/data/{client/src/commonMain => src/clientMain}/kotlin/moe/lava/banksia/core/data/sources/route/RouteLocalDataSource.kt (92%) rename core/data/{client/src/commonMain => src/clientMain}/kotlin/moe/lava/banksia/core/data/sources/route/RouteRemoteDataSource.kt (78%) rename core/data/{client/src/commonMain => src/clientMain}/kotlin/moe/lava/banksia/core/data/sources/stop/StopLocalDataSource.kt (100%) rename core/data/{client/src/commonMain => src/clientMain}/kotlin/moe/lava/banksia/core/data/sources/stop/StopRemoteDataSource.kt (100%) create mode 100644 core/data/src/commonMain/kotlin/moe/lava/banksia/core/data/DataDiModule.kt create mode 100644 core/data/src/jvmMain/kotlin/moe/lava/banksia/core/data/DataDiModule.jvm.kt create mode 100644 core/src/commonMain/kotlin/moe/lava/banksia/core/endpoints/Endpoint.kt rename core/{data => }/stoptime/build.gradle.kts (90%) rename core/{data => }/stoptime/src/clientMain/kotlin/moe/lava/banksia/core/data/StopTimeDataDiModule.client.kt (100%) rename core/{data => }/stoptime/src/clientMain/kotlin/moe/lava/banksia/core/data/repositories/StopTimeRepository.client.kt (100%) rename core/{data => }/stoptime/src/clientMain/kotlin/moe/lava/banksia/core/data/sources/stoptime/StopTimeRemoteDataSource.kt (66%) rename core/{data => }/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/StopTimeDataDiModule.kt (100%) create mode 100644 core/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/dto/ExtendedStopTime.kt rename core/{data => }/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/StopTimeRepository.kt (80%) rename core/{data => }/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/stoptime/StopTimeLocalDataSource.kt (78%) create mode 100644 core/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/endpoints/StopTimeEndpoints.kt rename core/{data => }/stoptime/src/jvmMain/kotlin/moe/lava/banksia/core/data/StopTimeDataDiModule.jvm.kt (100%) rename core/{data => }/stoptime/src/jvmMain/kotlin/moe/lava/banksia/core/data/repositories/StopTimeRepository.jvm.kt (100%) create mode 100644 core/stoptime/src/jvmMain/kotlin/moe/lava/banksia/server/routes/StopTimeRoute.kt create mode 100644 ui/shared/src/commonMain/composeResources/drawable/arrow_drop_down.xml create mode 100644 ui/shared/src/commonMain/composeResources/drawable/arrow_drop_up.xml diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 8c89aff..ecdba19 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -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) } } } diff --git a/core/data/client/build.gradle.kts b/core/data/client/build.gradle.kts deleted file mode 100644 index c6d5e5d..0000000 --- a/core/data/client/build.gradle.kts +++ /dev/null @@ -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) - } - } -} diff --git a/core/data/server/build.gradle.kts b/core/data/server/build.gradle.kts deleted file mode 100644 index d2296d7..0000000 --- a/core/data/server/build.gradle.kts +++ /dev/null @@ -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) -} diff --git a/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/ClientDataDiModule.kt b/core/data/src/clientMain/kotlin/moe/lava/banksia/core/data/DataDiModule.client.kt similarity index 93% rename from core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/ClientDataDiModule.kt rename to core/data/src/clientMain/kotlin/moe/lava/banksia/core/data/DataDiModule.client.kt index 0384f88..104c6bc 100644 --- a/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/ClientDataDiModule.kt +++ b/core/data/src/clientMain/kotlin/moe/lava/banksia/core/data/DataDiModule.client.kt @@ -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 { diff --git a/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/ClientRouteRepository.kt b/core/data/src/clientMain/kotlin/moe/lava/banksia/core/data/repositories/ClientRouteRepository.kt similarity index 71% rename from core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/ClientRouteRepository.kt rename to core/data/src/clientMain/kotlin/moe/lava/banksia/core/data/repositories/ClientRouteRepository.kt index 467399a..f46caac 100644 --- a/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/ClientRouteRepository.kt +++ b/core/data/src/clientMain/kotlin/moe/lava/banksia/core/data/repositories/ClientRouteRepository.kt @@ -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() + 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 + } + } } diff --git a/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/ClientStopRepository.kt b/core/data/src/clientMain/kotlin/moe/lava/banksia/core/data/repositories/ClientStopRepository.kt similarity index 100% rename from core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/ClientStopRepository.kt rename to core/data/src/clientMain/kotlin/moe/lava/banksia/core/data/repositories/ClientStopRepository.kt diff --git a/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/route/RouteLocalDataSource.kt b/core/data/src/clientMain/kotlin/moe/lava/banksia/core/data/sources/route/RouteLocalDataSource.kt similarity index 92% rename from core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/route/RouteLocalDataSource.kt rename to core/data/src/clientMain/kotlin/moe/lava/banksia/core/data/sources/route/RouteLocalDataSource.kt index 8e6af0e..8286b1f 100644 --- a/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/route/RouteLocalDataSource.kt +++ b/core/data/src/clientMain/kotlin/moe/lava/banksia/core/data/sources/route/RouteLocalDataSource.kt @@ -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 { diff --git a/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/route/RouteRemoteDataSource.kt b/core/data/src/clientMain/kotlin/moe/lava/banksia/core/data/sources/route/RouteRemoteDataSource.kt similarity index 78% rename from core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/route/RouteRemoteDataSource.kt rename to core/data/src/clientMain/kotlin/moe/lava/banksia/core/data/sources/route/RouteRemoteDataSource.kt index bdcbfc1..15088fb 100644 --- a/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/route/RouteRemoteDataSource.kt +++ b/core/data/src/clientMain/kotlin/moe/lava/banksia/core/data/sources/route/RouteRemoteDataSource.kt @@ -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() + suspend fun getByPattern(patternId: Long) = client.get("routes/by_pattern/${patternId}").body() suspend fun getAll() = client.get("routes").body>() } diff --git a/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/stop/StopLocalDataSource.kt b/core/data/src/clientMain/kotlin/moe/lava/banksia/core/data/sources/stop/StopLocalDataSource.kt similarity index 100% rename from core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/stop/StopLocalDataSource.kt rename to core/data/src/clientMain/kotlin/moe/lava/banksia/core/data/sources/stop/StopLocalDataSource.kt diff --git a/core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/stop/StopRemoteDataSource.kt b/core/data/src/clientMain/kotlin/moe/lava/banksia/core/data/sources/stop/StopRemoteDataSource.kt similarity index 100% rename from core/data/client/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/stop/StopRemoteDataSource.kt rename to core/data/src/clientMain/kotlin/moe/lava/banksia/core/data/sources/stop/StopRemoteDataSource.kt diff --git a/core/data/src/commonMain/kotlin/moe/lava/banksia/core/data/DataDiModule.kt b/core/data/src/commonMain/kotlin/moe/lava/banksia/core/data/DataDiModule.kt new file mode 100644 index 0000000..eea6a0e --- /dev/null +++ b/core/data/src/commonMain/kotlin/moe/lava/banksia/core/data/DataDiModule.kt @@ -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) +} diff --git a/core/data/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/RouteRepository.kt b/core/data/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/RouteRepository.kt index fbb663f..ef3d6f1 100644 --- a/core/data/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/RouteRepository.kt +++ b/core/data/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/RouteRepository.kt @@ -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 } diff --git a/core/data/src/jvmMain/kotlin/moe/lava/banksia/core/data/DataDiModule.jvm.kt b/core/data/src/jvmMain/kotlin/moe/lava/banksia/core/data/DataDiModule.jvm.kt new file mode 100644 index 0000000..78a44d1 --- /dev/null +++ b/core/data/src/jvmMain/kotlin/moe/lava/banksia/core/data/DataDiModule.jvm.kt @@ -0,0 +1,7 @@ +package moe.lava.banksia.core.data + +import org.koin.dsl.module + +internal actual val platformModule = module { + +} diff --git a/core/sqld/src/commonMain/kotlin/moe/lava/banksia/core/sqld/mappers/StoppingPattern.kt b/core/sqld/src/commonMain/kotlin/moe/lava/banksia/core/sqld/mappers/StoppingPattern.kt index e50aa85..d1409a2 100644 --- a/core/sqld/src/commonMain/kotlin/moe/lava/banksia/core/sqld/mappers/StoppingPattern.kt +++ b/core/sqld/src/commonMain/kotlin/moe/lava/banksia/core/sqld/mappers/StoppingPattern.kt @@ -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) = StoppingPattern.Undated( +fun DbStoppingPattern.asModel(stoptimes: List>) = StoppingPattern( id = id, routeId = routeId, shapeId = shapeId, @@ -13,7 +14,7 @@ fun DbStoppingPattern.asModel(stoptimes: List) = StoppingPatte stoptimes = stoptimes, ) -fun StoppingPattern.Undated.asDb() = DbStoppingPattern( +fun StoppingPattern<*>.asDb() = DbStoppingPattern( id = id, routeId = routeId, shapeId = shapeId, diff --git a/core/sqld/src/commonMain/sqldelight/moe/lava/banksia/core/sqld/Route.sq b/core/sqld/src/commonMain/sqldelight/moe/lava/banksia/core/sqld/Route.sq index f1617f7..e607975 100644 --- a/core/sqld/src/commonMain/sqldelight/moe/lava/banksia/core/sqld/Route.sq +++ b/core/sqld/src/commonMain/sqldelight/moe/lava/banksia/core/sqld/Route.sq @@ -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 ?; diff --git a/core/sqld/src/commonMain/sqldelight/moe/lava/banksia/core/sqld/StopTime.sq b/core/sqld/src/commonMain/sqldelight/moe/lava/banksia/core/sqld/StopTime.sq index 45b3f10..06bd76b 100644 --- a/core/sqld/src/commonMain/sqldelight/moe/lava/banksia/core/sqld/StopTime.sq +++ b/core/sqld/src/commonMain/sqldelight/moe/lava/banksia/core/sqld/StopTime.sq @@ -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; diff --git a/core/sqld/src/commonMain/sqldelight/moe/lava/banksia/core/sqld/StoppingPattern.sq b/core/sqld/src/commonMain/sqldelight/moe/lava/banksia/core/sqld/StoppingPattern.sq index cc1c5ab..9a09e69 100644 --- a/core/sqld/src/commonMain/sqldelight/moe/lava/banksia/core/sqld/StoppingPattern.sq +++ b/core/sqld/src/commonMain/sqldelight/moe/lava/banksia/core/sqld/StoppingPattern.sq @@ -8,3 +8,6 @@ CREATE TABLE StoppingPattern ( insert: INSERT OR REPLACE INTO StoppingPattern VALUES ?; + +get: +SELECT * FROM StoppingPattern WHERE id == :id; diff --git a/core/src/commonMain/kotlin/moe/lava/banksia/core/endpoints/Endpoint.kt b/core/src/commonMain/kotlin/moe/lava/banksia/core/endpoints/Endpoint.kt new file mode 100644 index 0000000..7e23b5d --- /dev/null +++ b/core/src/commonMain/kotlin/moe/lava/banksia/core/endpoints/Endpoint.kt @@ -0,0 +1,3 @@ +package moe.lava.banksia.core.endpoints + +object Endpoint diff --git a/core/src/commonMain/kotlin/moe/lava/banksia/core/model/StopTime.kt b/core/src/commonMain/kotlin/moe/lava/banksia/core/model/StopTime.kt index da92819..edd7c51 100644 --- a/core/src/commonMain/kotlin/moe/lava/banksia/core/model/StopTime.kt +++ b/core/src/commonMain/kotlin/moe/lava/banksia/core/model/StopTime.kt @@ -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.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, ) diff --git a/core/data/stoptime/build.gradle.kts b/core/stoptime/build.gradle.kts similarity index 90% rename from core/data/stoptime/build.gradle.kts rename to core/stoptime/build.gradle.kts index 086e749..44cf072 100644 --- a/core/data/stoptime/build.gradle.kts +++ b/core/stoptime/build.gradle.kts @@ -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) + } } } diff --git a/core/data/stoptime/src/clientMain/kotlin/moe/lava/banksia/core/data/StopTimeDataDiModule.client.kt b/core/stoptime/src/clientMain/kotlin/moe/lava/banksia/core/data/StopTimeDataDiModule.client.kt similarity index 100% rename from core/data/stoptime/src/clientMain/kotlin/moe/lava/banksia/core/data/StopTimeDataDiModule.client.kt rename to core/stoptime/src/clientMain/kotlin/moe/lava/banksia/core/data/StopTimeDataDiModule.client.kt diff --git a/core/data/stoptime/src/clientMain/kotlin/moe/lava/banksia/core/data/repositories/StopTimeRepository.client.kt b/core/stoptime/src/clientMain/kotlin/moe/lava/banksia/core/data/repositories/StopTimeRepository.client.kt similarity index 100% rename from core/data/stoptime/src/clientMain/kotlin/moe/lava/banksia/core/data/repositories/StopTimeRepository.client.kt rename to core/stoptime/src/clientMain/kotlin/moe/lava/banksia/core/data/repositories/StopTimeRepository.client.kt diff --git a/core/data/stoptime/src/clientMain/kotlin/moe/lava/banksia/core/data/sources/stoptime/StopTimeRemoteDataSource.kt b/core/stoptime/src/clientMain/kotlin/moe/lava/banksia/core/data/sources/stoptime/StopTimeRemoteDataSource.kt similarity index 66% rename from core/data/stoptime/src/clientMain/kotlin/moe/lava/banksia/core/data/sources/stoptime/StopTimeRemoteDataSource.kt rename to core/stoptime/src/clientMain/kotlin/moe/lava/banksia/core/data/sources/stoptime/StopTimeRemoteDataSource.kt index 1d338ce..0c38f64 100644 --- a/core/data/stoptime/src/clientMain/kotlin/moe/lava/banksia/core/data/sources/stoptime/StopTimeRemoteDataSource.kt +++ b/core/stoptime/src/clientMain/kotlin/moe/lava/banksia/core/data/sources/stoptime/StopTimeRemoteDataSource.kt @@ -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 { - return client.get("stoptimes/by_stop/${stopId}") { + ): List { + return client.get(Endpoint.stopTimeByStop(stopId)) { parameter("date", date) - }.body>() + }.body>() } } diff --git a/core/data/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/StopTimeDataDiModule.kt b/core/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/StopTimeDataDiModule.kt similarity index 100% rename from core/data/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/StopTimeDataDiModule.kt rename to core/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/StopTimeDataDiModule.kt diff --git a/core/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/dto/ExtendedStopTime.kt b/core/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/dto/ExtendedStopTime.kt new file mode 100644 index 0000000..38de29d --- /dev/null +++ b/core/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/dto/ExtendedStopTime.kt @@ -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, +) diff --git a/core/data/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/StopTimeRepository.kt b/core/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/StopTimeRepository.kt similarity index 80% rename from core/data/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/StopTimeRepository.kt rename to core/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/StopTimeRepository.kt index 2de0c10..6a81c09 100644 --- a/core/data/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/StopTimeRepository.kt +++ b/core/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/repositories/StopTimeRepository.kt @@ -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> + ): Flow> } diff --git a/core/data/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/stoptime/StopTimeLocalDataSource.kt b/core/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/stoptime/StopTimeLocalDataSource.kt similarity index 78% rename from core/data/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/stoptime/StopTimeLocalDataSource.kt rename to core/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/stoptime/StopTimeLocalDataSource.kt index 03ebbda..f22dc09 100644 --- a/core/data/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/stoptime/StopTimeLocalDataSource.kt +++ b/core/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/data/sources/stoptime/StopTimeLocalDataSource.kt @@ -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() - suspend fun getAtStop(stopId: String, date: LocalDate): List { + suspend fun getAtStop(stopId: String, date: LocalDate): List { 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 } } } diff --git a/core/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/endpoints/StopTimeEndpoints.kt b/core/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/endpoints/StopTimeEndpoints.kt new file mode 100644 index 0000000..f689b2d --- /dev/null +++ b/core/stoptime/src/commonMain/kotlin/moe/lava/banksia/core/endpoints/StopTimeEndpoints.kt @@ -0,0 +1,3 @@ +package moe.lava.banksia.core.endpoints + +fun Endpoint.stopTimeByStop(stopId: String) = "stoptimes/by_stop/${stopId}" diff --git a/core/data/stoptime/src/jvmMain/kotlin/moe/lava/banksia/core/data/StopTimeDataDiModule.jvm.kt b/core/stoptime/src/jvmMain/kotlin/moe/lava/banksia/core/data/StopTimeDataDiModule.jvm.kt similarity index 100% rename from core/data/stoptime/src/jvmMain/kotlin/moe/lava/banksia/core/data/StopTimeDataDiModule.jvm.kt rename to core/stoptime/src/jvmMain/kotlin/moe/lava/banksia/core/data/StopTimeDataDiModule.jvm.kt diff --git a/core/data/stoptime/src/jvmMain/kotlin/moe/lava/banksia/core/data/repositories/StopTimeRepository.jvm.kt b/core/stoptime/src/jvmMain/kotlin/moe/lava/banksia/core/data/repositories/StopTimeRepository.jvm.kt similarity index 100% rename from core/data/stoptime/src/jvmMain/kotlin/moe/lava/banksia/core/data/repositories/StopTimeRepository.jvm.kt rename to core/stoptime/src/jvmMain/kotlin/moe/lava/banksia/core/data/repositories/StopTimeRepository.jvm.kt diff --git a/core/stoptime/src/jvmMain/kotlin/moe/lava/banksia/server/routes/StopTimeRoute.kt b/core/stoptime/src/jvmMain/kotlin/moe/lava/banksia/server/routes/StopTimeRoute.kt new file mode 100644 index 0000000..5791855 --- /dev/null +++ b/core/stoptime/src/jvmMain/kotlin/moe/lava/banksia/server/routes/StopTimeRoute.kt @@ -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() + + 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) + } +} diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 93da532..9d2cb78 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -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) diff --git a/server/src/main/kotlin/moe/lava/banksia/server/Application.kt b/server/src/main/kotlin/moe/lava/banksia/server/Application.kt index 17a9002..dedffe5 100644 --- a/server/src/main/kotlin/moe/lava/banksia/server/Application.kt +++ b/server/src/main/kotlin/moe/lava/banksia/server/Application.kt @@ -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().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() - .getForStopDated( - listOf(date.dayOfWeek).serialise().toLong(), - date.toEpochDays(), - stopId, - ) - .executeAsList() - .map { it.asModel().atDate(date) } - .sortedBy { it.time.departure } - } - call.respond(times) - } } } diff --git a/server/src/main/kotlin/moe/lava/banksia/server/di/ServerModules.kt b/server/src/main/kotlin/moe/lava/banksia/server/di/ServerModules.kt index 881becf..b2593b3 100644 --- a/server/src/main/kotlin/moe/lava/banksia/server/di/ServerModules.kt +++ b/server/src/main/kotlin/moe/lava/banksia/server/di/ServerModules.kt @@ -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) diff --git a/settings.gradle.kts b/settings.gradle.kts index 28f535e..bdb499e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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") diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 9c5c7bd..b599bc6 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -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) } diff --git a/ui/shared/build.gradle.kts b/ui/shared/build.gradle.kts index e379840..2a78572 100644 --- a/ui/shared/build.gradle.kts +++ b/ui/shared/build.gradle.kts @@ -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 } diff --git a/ui/shared/src/commonMain/composeResources/drawable/arrow_drop_down.xml b/ui/shared/src/commonMain/composeResources/drawable/arrow_drop_down.xml new file mode 100644 index 0000000..ac49572 --- /dev/null +++ b/ui/shared/src/commonMain/composeResources/drawable/arrow_drop_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/ui/shared/src/commonMain/composeResources/drawable/arrow_drop_up.xml b/ui/shared/src/commonMain/composeResources/drawable/arrow_drop_up.xml new file mode 100644 index 0000000..322fa56 --- /dev/null +++ b/ui/shared/src/commonMain/composeResources/drawable/arrow_drop_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/ui/src/commonMain/kotlin/moe/lava/banksia/ui/di/AppModule.kt b/ui/src/commonMain/kotlin/moe/lava/banksia/ui/di/AppModule.kt index a2b4d7e..cff36fb 100644 --- a/ui/src/commonMain/kotlin/moe/lava/banksia/ui/di/AppModule.kt +++ b/ui/src/commonMain/kotlin/moe/lava/banksia/ui/di/AppModule.kt @@ -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) diff --git a/ui/src/commonMain/kotlin/moe/lava/banksia/ui/layout/info/InfoPanel.kt b/ui/src/commonMain/kotlin/moe/lava/banksia/ui/layout/info/InfoPanel.kt index 55eac69..9fb37d7 100644 --- a/ui/src/commonMain/kotlin/moe/lava/banksia/ui/layout/info/InfoPanel.kt +++ b/ui/src/commonMain/kotlin/moe/lava/banksia/ui/layout/info/InfoPanel.kt @@ -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 { diff --git a/ui/src/commonMain/kotlin/moe/lava/banksia/ui/layout/info/StopInfoPanel.kt b/ui/src/commonMain/kotlin/moe/lava/banksia/ui/layout/info/StopInfoPanel.kt index dbe3b29..dc4452b 100644 --- a/ui/src/commonMain/kotlin/moe/lava/banksia/ui/layout/info/StopInfoPanel.kt +++ b/ui/src/commonMain/kotlin/moe/lava/banksia/ui/layout/info/StopInfoPanel.kt @@ -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? = null, + val departures: List? = 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, + ) + + 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 = {}, + ) +} diff --git a/ui/src/commonMain/kotlin/moe/lava/banksia/ui/screens/map/MapScreenViewModel.kt b/ui/src/commonMain/kotlin/moe/lava/banksia/ui/screens/map/MapScreenViewModel.kt index 76fb51e..de06381 100644 --- a/ui/src/commonMain/kotlin/moe/lava/banksia/ui/screens/map/MapScreenViewModel.kt +++ b/ui/src/commonMain/kotlin/moe/lava/banksia/ui/screens/map/MapScreenViewModel.kt @@ -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? = 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)