Compare commits

..

1 commit

Author SHA1 Message Date
b187b63195
feat: ios support 2026-03-05 00:00:18 +11:00
212 changed files with 2868 additions and 3474 deletions

5
.gitignore vendored
View file

@ -18,6 +18,5 @@ captures
**/xcshareddata/WorkspaceSettings.xcsettings **/xcshareddata/WorkspaceSettings.xcsettings
secrets.properties secrets.properties
/core/src/commonMain/kotlin/moe/lava/banksia/core/Constants.kt shared/src/commonMain/kotlin/moe/lava/banksia/Constants.kt
/data/ data/
/data

View file

@ -1,57 +0,0 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
kotlin {
target {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
compilerOptions {
freeCompilerArgs.add("-Xexplicit-backing-fields")
}
dependencies {
implementation(projects.ui)
implementation(libs.androidx.activity.compose)
implementation(libs.compose.ui.tooling.preview)
}
}
dependencies {
debugImplementation(libs.compose.ui.tooling)
}
android {
namespace = "moe.lava.banksia"
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
applicationId = "moe.lava.banksia"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "1.0"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("debug")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}

View file

@ -2,12 +2,11 @@ plugins {
// this is necessary to avoid the plugins to be loaded multiple times // this is necessary to avoid the plugins to be loaded multiple times
// in each subproject's classloader // in each subproject's classloader
alias(libs.plugins.androidApplication) apply false alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidMultiplatformLibrary) apply false alias(libs.plugins.androidLibrary) apply false
alias(libs.plugins.composeMultiplatform) apply false alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.kotlinJvm) apply false alias(libs.plugins.kotlinJvm) apply false
alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.sqldelight) apply false
alias(libs.plugins.wire) apply false alias(libs.plugins.wire) apply false
} }

View file

@ -1,31 +1,27 @@
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.net.URI
plugins { plugins {
alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization) alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.androidMultiplatformLibrary) alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler) alias(libs.plugins.composeCompiler)
alias(libs.plugins.secretsGradle) alias(libs.plugins.secretsGradle)
alias(libs.plugins.spm)
} }
kotlin { kotlin {
android { androidTarget {
namespace = "moe.lava.banksia.ui" @OptIn(ExperimentalKotlinGradlePluginApi::class)
compileSdk = libs.versions.android.compileSdk.get().toInt()
compilerOptions { compilerOptions {
jvmTarget.set(JvmTarget.JVM_11) jvmTarget.set(JvmTarget.JVM_11)
} }
androidResources {
enable = true
}
} }
compilerOptions { compilerOptions {
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime") freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
freeCompilerArgs.add("-Xexplicit-backing-fields")
} }
listOf( listOf(
@ -33,17 +29,26 @@ kotlin {
iosArm64(), iosArm64(),
iosSimulatorArm64() iosSimulatorArm64()
).forEach { iosTarget -> ).forEach { iosTarget ->
iosTarget.compilations {
getByName("main") {
cinterops.create("spmMaplibre")
}
}
iosTarget.binaries.framework { iosTarget.binaries.framework {
baseName = "ComposeApp" baseName = "ComposeApp"
isStatic = true isStatic = true
} }
// iosTarget.swiftPackageConfig(cinteropName = "banksia") {
// }
} }
sourceSets { sourceSets {
androidMain.dependencies { androidMain.dependencies {
implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.ui.tooling.preview)
implementation(libs.androidx.activity.compose)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.play.services.location) implementation(libs.play.services.location)
implementation(projects.ui.shared)
} }
commonMain.dependencies { commonMain.dependencies {
implementation(libs.compose.components.resources) implementation(libs.compose.components.resources)
@ -67,21 +72,61 @@ kotlin {
implementation(libs.maplibre.compose) implementation(libs.maplibre.compose)
implementation(libs.moko.geo) implementation(libs.moko.geo)
implementation(libs.moko.geo.compose) implementation(libs.moko.geo.compose)
implementation(projects.shared)
implementation(libs.ui.backhandler) implementation(libs.ui.backhandler)
implementation(projects.core)
implementation(projects.core.data)
implementation(projects.core.stoptime)
implementation(projects.ui.maps)
implementation(projects.ui.shared)
} }
} }
} }
android {
namespace = "moe.lava.banksia"
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
applicationId = "moe.lava.banksia"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "1.0"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("debug")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
dependencies { dependencies {
androidRuntimeClasspath(libs.compose.ui.tooling) debugImplementation(compose.uiTooling)
} }
secrets { secrets {
propertiesFileName = "secrets.properties" propertiesFileName = "secrets.properties"
} }
compose.resources {
publicResClass = true
packageOfResClass = "moe.lava.banksia.resources"
}
swiftPackageConfig {
create("spmMaplibre") {
dependency {
remotePackageVersion(
url = URI("https://github.com/maplibre/maplibre-gl-native-distribution.git"),
products = { add("MapLibre") },
version = "6.17.1",
)
}
}
}

View file

@ -13,6 +13,9 @@
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar"> android:theme="@android:style/Theme.Material.Light.NoActionBar">
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="${MAPS_API_KEY}" />
<activity <activity
android:exported="true" android:exported="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|mnc|colorMode|density|fontScale|fontWeightAdjustment|keyboard|layoutDirection|locale|mcc|navigation|smallestScreenSize|touchscreen|uiMode" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|mnc|colorMode|density|fontScale|fontWeightAdjustment|keyboard|layoutDirection|locale|mcc|navigation|smallestScreenSize|touchscreen|uiMode"

View file

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#FFFFFFFF" android:pathData="M12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM20.94,11c-0.46,-4.17 -3.77,-7.48 -7.94,-7.94L13,1h-2v2.06C6.83,3.52 3.52,6.83 3.06,11L1,11v2h2.06c0.46,4.17 3.77,7.48 7.94,7.94L11,23h2v-2.06c4.17,-0.46 7.48,-3.77 7.94,-7.94L23,13v-2h-2.06zM12,19c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7 7,3.13 7,7 -3.13,7 -7,7z"/>
</vector>

View file

@ -0,0 +1,11 @@
package moe.lava.banksia.client.datasource.local
import moe.lava.banksia.model.Route
import moe.lava.banksia.room.dao.RouteDao
import moe.lava.banksia.room.entity.asEntity
class RouteLocalDataSource(private val dao: RouteDao) {
suspend fun get(id: String) = dao.get(id)
suspend fun getAll() = dao.getAll()
suspend fun save(vararg routes: Route) = dao.insertOrReplaceAll(*routes.map { it.asEntity() }.toTypedArray())
}

View file

@ -0,0 +1,12 @@
package moe.lava.banksia.client.datasource.local
import moe.lava.banksia.model.Stop
import moe.lava.banksia.room.dao.RouteDao
import moe.lava.banksia.room.dao.StopDao
import moe.lava.banksia.room.entity.asEntity
class StopLocalDataSource(private val dao: StopDao, private val routeDao: RouteDao) {
suspend fun get(id: String) = dao.get(id)
suspend fun getByRoute(id: String) = routeDao.stops(id)
suspend fun save(vararg stops: Stop) = dao.insertOrReplaceAll(*stops.map { it.asEntity() }.toTypedArray())
}

View file

@ -0,0 +1,11 @@
package moe.lava.banksia.client.datasource.remote
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import moe.lava.banksia.model.Route
class RouteRemoteDataSource(val client: HttpClient) {
suspend fun get(id: String) = client.get("routes/${id}").body<Route>()
suspend fun getAll() = client.get("routes").body<List<Route>>()
}

View file

@ -1,11 +1,11 @@
package moe.lava.banksia.core.data.sources.stop package moe.lava.banksia.client.datasource.remote
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.get import io.ktor.client.request.get
import moe.lava.banksia.core.model.Stop import moe.lava.banksia.model.Stop
internal class StopRemoteDataSource(val client: HttpClient) { class StopRemoteDataSource(val client: HttpClient) {
suspend fun get(id: String) = client.get("stops/${id}").body<Stop>() suspend fun get(id: String) = client.get("stops/${id}").body<Stop>()
suspend fun getByRoute(id: String) = client.get("route_stops/${id}").body<List<Stop>>() suspend fun getByRoute(id: String) = client.get("route_stops/${id}").body<List<Stop>>()
} }

View file

@ -1,4 +1,4 @@
package moe.lava.banksia.core.data package moe.lava.banksia.client.di
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpSend import io.ktor.client.plugins.HttpSend
@ -7,22 +7,21 @@ import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.plugin import io.ktor.client.plugins.plugin
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import moe.lava.banksia.core.Constants import moe.lava.banksia.Constants
import moe.lava.banksia.core.data.repositories.ClientRouteRepository import moe.lava.banksia.client.datasource.local.RouteLocalDataSource
import moe.lava.banksia.core.data.repositories.ClientStopRepository import moe.lava.banksia.client.datasource.local.StopLocalDataSource
import moe.lava.banksia.core.data.repositories.RouteRepository import moe.lava.banksia.client.datasource.remote.RouteRemoteDataSource
import moe.lava.banksia.core.data.repositories.StopRepository import moe.lava.banksia.client.datasource.remote.StopRemoteDataSource
import moe.lava.banksia.core.data.sources.route.RouteLocalDataSource import moe.lava.banksia.client.repository.RouteRepository
import moe.lava.banksia.core.data.sources.route.RouteRemoteDataSource import moe.lava.banksia.client.repository.StopRepository
import moe.lava.banksia.core.data.sources.stop.StopLocalDataSource
import moe.lava.banksia.core.data.sources.stop.StopRemoteDataSource
import moe.lava.banksia.core.util.log
import moe.lava.banksia.data.ptv.PtvService import moe.lava.banksia.data.ptv.PtvService
import moe.lava.banksia.ui.screens.map.MapScreenViewModel
import moe.lava.banksia.util.log
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module import org.koin.dsl.module
actual val platformModule = module { val ClientModule = module {
// HTTP Clients // HTTP Clients
singleOf(::PtvService) singleOf(::PtvService)
single { single {
@ -51,6 +50,9 @@ actual val platformModule = module {
singleOf(::StopRemoteDataSource) singleOf(::StopRemoteDataSource)
// Repositories // Repositories
singleOf(::ClientRouteRepository) bind RouteRepository::class singleOf(::RouteRepository)
singleOf(::ClientStopRepository) bind StopRepository::class singleOf(::StopRepository)
// ViewModel
viewModelOf(::MapScreenViewModel)
} }

View file

@ -0,0 +1,25 @@
package moe.lava.banksia.client.repository
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import moe.lava.banksia.client.datasource.local.RouteLocalDataSource
import moe.lava.banksia.client.datasource.remote.RouteRemoteDataSource
class RouteRepository(
private val local: RouteLocalDataSource,
private val remote: RouteRemoteDataSource,
) {
private val mutex = Mutex()
suspend fun getAll() = mutex.withLock {
local
.getAll()
.map { it.asModel() }
.ifEmpty {
remote
.getAll()
.also { local.save(*it.toTypedArray()) }
}
}
suspend fun get(id: String) = mutex.withLock { local.get(id)?.asModel() ?: remote.get(id) }
}

View file

@ -0,0 +1,22 @@
package moe.lava.banksia.client.repository
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import moe.lava.banksia.client.datasource.local.StopLocalDataSource
import moe.lava.banksia.client.datasource.remote.StopRemoteDataSource
class StopRepository(
private val local: StopLocalDataSource,
private val remote: StopRemoteDataSource,
) {
private val mutex = Mutex()
suspend fun get(id: String) = mutex.withLock { local.get(id)?.asModel() ?: remote.get(id) }
suspend fun getByRoute(id: String) = mutex.withLock {
local
.getByRoute(id)
.map { it.asModel() }
.ifEmpty { null }
?: remote.getByRoute(id)
}
}

View file

@ -3,7 +3,8 @@ package moe.lava.banksia.ui
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import moe.lava.banksia.ui.di.AppModule import moe.lava.banksia.client.di.ClientModule
import moe.lava.banksia.di.CommonModules
import moe.lava.banksia.ui.screens.map.MapScreen import moe.lava.banksia.ui.screens.map.MapScreen
import org.koin.compose.KoinMultiplatformApplication import org.koin.compose.KoinMultiplatformApplication
import org.koin.core.annotation.KoinExperimentalAPI import org.koin.core.annotation.KoinExperimentalAPI
@ -13,7 +14,7 @@ import org.koin.dsl.koinConfiguration
@Composable @Composable
fun App() { fun App() {
KoinMultiplatformApplication(config = koinConfiguration { KoinMultiplatformApplication(config = koinConfiguration {
modules(AppModule) modules(CommonModules, ClientModule)
}) { }) {
MapScreen() MapScreen()
} }

View file

@ -1,8 +1,27 @@
package moe.lava.banksia.ui.extensions package moe.lava.banksia.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import moe.lava.banksia.core.model.RouteType import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import moe.lava.banksia.data.ptv.structures.PtvRouteType import moe.lava.banksia.data.ptv.structures.PtvRouteType
import moe.lava.banksia.model.RouteType
import moe.lava.banksia.model.RouteType.Interstate
import moe.lava.banksia.model.RouteType.MetroBus
import moe.lava.banksia.model.RouteType.MetroTrain
import moe.lava.banksia.model.RouteType.MetroTram
import moe.lava.banksia.model.RouteType.RegionalBus
import moe.lava.banksia.model.RouteType.RegionalCoach
import moe.lava.banksia.model.RouteType.RegionalTrain
import moe.lava.banksia.model.RouteType.SkyBus
import moe.lava.banksia.resources.Res import moe.lava.banksia.resources.Res
import moe.lava.banksia.resources.bus import moe.lava.banksia.resources.bus
import moe.lava.banksia.resources.bus_background import moe.lava.banksia.resources.bus_background
@ -14,6 +33,7 @@ import moe.lava.banksia.resources.tram
import moe.lava.banksia.resources.tram_background import moe.lava.banksia.resources.tram_background
import moe.lava.banksia.resources.tram_icon import moe.lava.banksia.resources.tram_icon
import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
data class RouteTypeProperties( data class RouteTypeProperties(
val colour: Color, val colour: Color,
@ -29,31 +49,31 @@ const val VLINE_PURPLE = 0xFF8F1A95
fun RouteType.getUIProperties(): RouteTypeProperties { fun RouteType.getUIProperties(): RouteTypeProperties {
val colour = when (this) { val colour = when (this) {
RouteType.MetroTrain -> TRAIN_BLUE MetroTrain -> TRAIN_BLUE
RouteType.MetroTram -> TRAM_GREEN MetroTram -> TRAM_GREEN
RouteType.MetroBus -> BUS_ORANGE MetroBus -> BUS_ORANGE
RouteType.RegionalTrain -> VLINE_PURPLE RegionalTrain -> VLINE_PURPLE
RouteType.RegionalCoach -> VLINE_PURPLE RegionalCoach -> VLINE_PURPLE
RouteType.RegionalBus -> VLINE_PURPLE RegionalBus -> VLINE_PURPLE
RouteType.SkyBus -> BUS_ORANGE SkyBus -> BUS_ORANGE
RouteType.Interstate -> BUS_ORANGE Interstate -> BUS_ORANGE
} }
val (drawable, background, icon) = when (this) { val (drawable, background, icon) = when (this) {
RouteType.MetroTrain, MetroTrain,
RouteType.RegionalTrain, RegionalTrain,
RouteType.Interstate -> Triple( Interstate -> Triple(
Res.drawable.train, Res.drawable.train_background, Res.drawable.train_icon Res.drawable.train, Res.drawable.train_background, Res.drawable.train_icon
) )
RouteType.MetroTram -> Triple( MetroTram -> Triple(
Res.drawable.tram, Res.drawable.tram_background, Res.drawable.tram_icon Res.drawable.tram, Res.drawable.tram_background, Res.drawable.tram_icon
) )
RouteType.MetroBus, MetroBus,
RouteType.RegionalCoach, RegionalCoach,
RouteType.RegionalBus, RegionalBus,
RouteType.SkyBus -> Triple( SkyBus -> Triple(
Res.drawable.bus, Res.drawable.bus_background, Res.drawable.bus_icon Res.drawable.bus, Res.drawable.bus_background, Res.drawable.bus_icon
) )
} }
@ -82,3 +102,35 @@ fun PtvRouteType.getUIProperties(): RouteTypeProperties {
return RouteTypeProperties(colour, drawable, background, icon) return RouteTypeProperties(colour, drawable, background, icon)
} }
@Composable
fun RouteIcon(
modifier: Modifier = Modifier.Companion,
size: Dp = 40.dp,
routeType: RouteType,
) {
val properties = routeType.getUIProperties()
Image(
painter = painterResource(properties.icon),
contentDescription = null,
modifier = modifier
.size(size)
.aspectRatio(1f)
.padding(size * ICON_PADDING / 2)
.drawBehind {
drawCircle(properties.colour, radius = size.toPx() / 2f)
}
)
}
const val ICON_PADDING = 0.25f
@Preview
@Composable
private fun RouteIconPreview() {
Row {
RouteIcon(routeType = MetroTrain)
RouteIcon(routeType = MetroTram)
RouteIcon(routeType = MetroBus)
}
}

View file

@ -0,0 +1,177 @@
package moe.lava.banksia.ui.layout
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
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.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContent
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
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.unit.Dp
import androidx.compose.ui.unit.coerceAtMost
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import moe.lava.banksia.ui.components.RouteIcon
import moe.lava.banksia.ui.screens.map.MapScreenEvent
import moe.lava.banksia.ui.state.InfoPanelState
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun InfoPanel(
state: InfoPanelState,
onEvent: (MapScreenEvent) -> Unit,
onPeekHeightChange: (Dp) -> Unit,
) {
if (state is InfoPanelState.None)
return
val localDensity = LocalDensity.current
var delayedLoad by remember { mutableStateOf(false) }
LaunchedEffect(state.loading) {
if (state.loading) {
delay(200.milliseconds)
delayedLoad = true
} else {
delayedLoad = false
}
}
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.onSizeChanged {
onPeekHeightChange(with(localDensity) { it.height.toDp().coerceAtMost(250.dp) })
}
) {
Box {
when (state) {
is InfoPanelState.Route -> RouteInfoPanel(state, onEvent)
is InfoPanelState.Stop -> StopInfoPanel(state, onEvent)
is InfoPanelState.Run -> RunInfoPanel(state, onEvent)
is InfoPanelState.None -> throw UnsupportedOperationException()
}
this@Column.AnimatedVisibility(
modifier = Modifier.align(Alignment.TopEnd),
visible = delayedLoad,
label = "sheet-loading",
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(),
) {
LoadingIndicator(
modifier = Modifier.size(48.dp)
)
}
}
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeContent))
}
}
@Composable
private inline fun RouteInfoPanel(
state: InfoPanelState.Route,
onEvent: (MapScreenEvent) -> Unit,
) {
Column(Modifier.fillMaxWidth()) {
Row {
RouteIcon(routeType = state.type)
Text(
state.name,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Start
)
}
}
}
@Composable
private inline fun RunInfoPanel(
state: InfoPanelState.Run,
onEvent: (MapScreenEvent) -> Unit,
) {
Column(Modifier.fillMaxWidth()) {
Row {
RouteIcon(routeType = state.type)
Text(
"${state.direction} via ${state.routeName ?: "..."}",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Start
)
}
}
}
@Composable
private inline fun StopInfoPanel(
state: InfoPanelState.Stop,
onEvent: (MapScreenEvent) -> 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) {
Text(
name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
formatted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 5.dp)
)
}
}
}
}
}

View file

@ -35,16 +35,17 @@ import kotlinx.coroutines.launch
import moe.lava.banksia.resources.Res import moe.lava.banksia.resources.Res
import moe.lava.banksia.resources.my_location_24 import moe.lava.banksia.resources.my_location_24
import moe.lava.banksia.ui.layout.AppBottomSheet import moe.lava.banksia.ui.layout.AppBottomSheet
import moe.lava.banksia.ui.layout.InfoPanel
import moe.lava.banksia.ui.layout.Searcher import moe.lava.banksia.ui.layout.Searcher
import moe.lava.banksia.ui.layout.SheetStateWrapper import moe.lava.banksia.ui.layout.SheetStateWrapper
import moe.lava.banksia.ui.layout.info.InfoPanel
import moe.lava.banksia.ui.layout.info.InfoPanelState
import moe.lava.banksia.ui.map.Maps
import moe.lava.banksia.ui.map.rememberMapsPositionState
import moe.lava.banksia.ui.platform.BanksiaTheme import moe.lava.banksia.ui.platform.BanksiaTheme
import moe.lava.banksia.ui.state.InfoPanelState
import moe.lava.banksia.util.Point
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
val MELBOURNE = Point(-37.8136, 144.9631)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable @Composable
fun MapScreen( fun MapScreen(
@ -65,13 +66,6 @@ fun MapScreen(
val sheetState = SheetStateWrapper.create() val sheetState = SheetStateWrapper.create()
var searchExpandedState by rememberSaveable { mutableStateOf(false) } var searchExpandedState by rememberSaveable { mutableStateOf(false) }
val mapsPositionState = rememberMapsPositionState()
scope.launch {
viewModel.cameraChangeEmitter.collect {
mapsPositionState.update(it.value)
}
}
LaunchedEffect(infoState) { LaunchedEffect(infoState) {
if (infoState !is InfoPanelState.None) { if (infoState !is InfoPanelState.None) {
sheetState.peek() sheetState.peek()
@ -84,21 +78,14 @@ fun MapScreen(
Scaffold { Scaffold {
Maps( Maps(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
insets = WindowInsets(top = with(LocalDensity.current) { state = mapState,
onEvent = viewModel::handleEvent,
cameraPositionFlow = viewModel.cameraChangeEmitter,
extInsets = WindowInsets(top = with(LocalDensity.current) {
SearchBarDefaults.InputFieldHeight.roundToPx() SearchBarDefaults.InputFieldHeight.roundToPx()
}, bottom = sheetState.bottomInset), }, bottom = sheetState.bottomInset),
stops = mapState.stops, setLastKnownLocation = viewModel::setLastKnownLocation,
positionState = mapsPositionState,
// vehicles = mapState.vehicles,
onStopClicked = { stop ->
viewModel.handleEvent(MapScreenEvent.SelectStop(stop))
},
// onEvent = viewModel::handleEvent,
// cameraPositionFlow = viewModel.cameraChangeEmitter,
// setLastKnownLocation = viewModel::setLastKnownLocation,
) )
// onEvent()
Searcher( Searcher(
state = searchState, state = searchState,
onEvent = viewModel::handleEvent, onEvent = viewModel::handleEvent,

View file

@ -13,57 +13,48 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone import moe.lava.banksia.client.repository.RouteRepository
import kotlinx.datetime.toInstant import moe.lava.banksia.client.repository.StopRepository
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
import moe.lava.banksia.core.model.Route
import moe.lava.banksia.core.model.RouteType
import moe.lava.banksia.core.util.BoxedValue
import moe.lava.banksia.core.util.BoxedValue.Companion.box
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.data.ptv.PtvService
import moe.lava.banksia.ui.extensions.getUIProperties import moe.lava.banksia.data.ptv.structures.PtvRoute
import moe.lava.banksia.ui.layout.info.InfoPanelEvent import moe.lava.banksia.model.Route
import moe.lava.banksia.ui.layout.info.InfoPanelState import moe.lava.banksia.model.RouteType
import moe.lava.banksia.ui.layout.info.RouteInfoPanelState import moe.lava.banksia.ui.components.getUIProperties
import moe.lava.banksia.ui.layout.info.StopInfoPanelEvent import moe.lava.banksia.ui.state.InfoPanelState
import moe.lava.banksia.ui.layout.info.StopInfoPanelState
import moe.lava.banksia.ui.layout.info.TripInfoPanelState
import moe.lava.banksia.ui.map.util.CameraPosition
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.MapState
import moe.lava.banksia.ui.state.SearchState import moe.lava.banksia.ui.state.SearchState
import moe.lava.banksia.ui.utils.map.CameraPosition
import moe.lava.banksia.ui.utils.map.CameraPositionBounds
import moe.lava.banksia.ui.utils.map.Marker
import moe.lava.banksia.ui.utils.map.Polyline
import moe.lava.banksia.util.BoxedValue
import moe.lava.banksia.util.BoxedValue.Companion.box
import moe.lava.banksia.util.LoopFlow.Companion.waitUntilSubscribed
import moe.lava.banksia.util.Point
import moe.lava.banksia.util.log
import kotlin.time.Clock
import kotlin.time.Instant
sealed class MapScreenEvent { sealed class MapScreenEvent {
data object DismissState : MapScreenEvent() data object DismissState : MapScreenEvent()
data class SelectRoute(val id: String?) : MapScreenEvent() data class SelectRoute(val id: String?) : MapScreenEvent()
data class SelectRun(val ref: String?) : MapScreenEvent() data class SelectRun(val ref: String?) : MapScreenEvent()
data class SelectStop(val id: String?) : MapScreenEvent() data class SelectStop(val typeIdPair: Pair<RouteType, String>?) : MapScreenEvent()
data class SearchUpdate(val text: String) : MapScreenEvent() data class SearchUpdate(val text: String) : MapScreenEvent()
} }
private data class InternalState( data class InternalState(
val route: String? = null, val route: String? = null,
val stop: String? = null, val stop: Pair<RouteType, String>? = null,
val run: String? = null, val run: String? = null,
val lastStopDepartures: List<ExtendedStopTime>? = null,
val stopsGrouped: Boolean = true,
) )
class MapScreenViewModel( class MapScreenViewModel(
private val ptvService: PtvService, private val ptvService: PtvService,
private val routeRepository: RouteRepository, private val routeRepository: RouteRepository,
private val stopRepository: StopRepository, private val stopRepository: StopRepository,
private val stopTimeRepository: StopTimeRepository,
) : ViewModel() { ) : ViewModel() {
private var state = InternalState() private var state = InternalState()
set(value) { set(value) {
@ -73,10 +64,6 @@ class MapScreenViewModel(
viewModelScope.launch { switchRoute(value.route) } viewModelScope.launch { switchRoute(value.route) }
if (value.stop != last.stop) if (value.stop != last.stop)
viewModelScope.launch { switchStop(value.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) if (value.run != last.run)
switchRun(value.run) switchRun(value.run)
} }
@ -105,20 +92,12 @@ class MapScreenViewModel(
is MapScreenEvent.DismissState -> dismissState() is MapScreenEvent.DismissState -> dismissState()
is MapScreenEvent.SelectRoute -> state = InternalState(route = event.id) is MapScreenEvent.SelectRoute -> state = InternalState(route = event.id)
is MapScreenEvent.SelectRun -> state = state.copy(run = event.ref, stop = null) is MapScreenEvent.SelectRun -> state = state.copy(run = event.ref, stop = null)
is MapScreenEvent.SelectStop -> state = state.copy(stop = event.id, run = null) is MapScreenEvent.SelectStop -> state = state.copy(stop = event.typeIdPair, run = null)
is MapScreenEvent.SearchUpdate -> searchUpdate(event.text) is MapScreenEvent.SearchUpdate -> searchUpdate(event.text)
} }
} }
} }
fun handleEvent(event: InfoPanelEvent) {
viewModelScope.launch {
when (event) {
StopInfoPanelEvent.ToggleGrouping -> state = state.copy(stopsGrouped = !state.stopsGrouped)
}
}
}
fun bindTracker(locationTracker: LocationTracker) { fun bindTracker(locationTracker: LocationTracker) {
locationTrackerJob = locationTracker.getLocationsFlow() locationTrackerJob = locationTracker.getLocationsFlow()
.onEach { lastKnownLocation = Point(it.latitude, it.longitude) } .onEach { lastKnownLocation = Point(it.latitude, it.longitude) }
@ -126,6 +105,11 @@ class MapScreenViewModel(
} }
fun centreCameraToLocation() { fun centreCameraToLocation() {
viewModelScope.launch {
log("msvm", "getting..")
val routes = routeRepository.getAll()
log("msvm", routes.joinToString("\n"))
}
lastKnownLocation?.let { location -> lastKnownLocation?.let { location ->
viewModelScope.launch { viewModelScope.launch {
log("bvm", "emitting $location") log("bvm", "emitting $location")
@ -175,9 +159,9 @@ class MapScreenViewModel(
} }
val route = routeRepository.get(routeId) val route = routeRepository.get(routeId)
?: return // val gtfsRoute = ptvService.route(routeId)
iInfoState.update { iInfoState.update {
RouteInfoPanelState( InfoPanelState.Route(
name = route.name, name = route.name,
type = route.type, type = route.type,
) )
@ -202,7 +186,7 @@ class MapScreenViewModel(
.onEach { run -> .onEach { run ->
if (routeName == null) { if (routeName == null) {
iInfoState.update { iInfoState.update {
TripInfoPanelState( InfoPanelState.Run(
direction = run.destinationName, direction = run.destinationName,
type = RouteType.MetroTrain, // XXX HACK TODO FIXME type = RouteType.MetroTrain, // XXX HACK TODO FIXME
) )
@ -211,7 +195,7 @@ class MapScreenViewModel(
} }
iInfoState.update { iInfoState.update {
TripInfoPanelState( InfoPanelState.Run(
direction = run.destinationName, direction = run.destinationName,
type = RouteType.MetroTrain, // FIXME HACK XXX TODO type = RouteType.MetroTrain, // FIXME HACK XXX TODO
routeName = routeName, routeName = routeName,
@ -222,84 +206,65 @@ class MapScreenViewModel(
} }
// [TODO]: Cleanup // [TODO]: Cleanup
private suspend fun switchStop(id: String?) { private suspend fun switchStop(pair: Pair<RouteType, String>?) {
if (id == null) { if (pair == null) {
iInfoState.update { InfoPanelState.None } iInfoState.update { InfoPanelState.None }
state = state.copy(lastStopDepartures = null)
return return
} }
val (type, id) = pair
val stop = stopRepository.get(id) val stop = stopRepository.get(id)
// val stop = ptvService.stop(routeType, stopId)
val split = stop.name.split("/") val split = stop.name.split("/")
val name = split[0] val name = split[0]
val subname = split.getOrNull(1) val subname = split.getOrNull(1)
iInfoState.update { iInfoState.update {
StopInfoPanelState( InfoPanelState.Stop(
id = stop.id, id = stop.id,
name = name, name = name,
subname = subname, subname = subname,
) )
} }
stopTimeRepository.getForStop(id) val res = ptvService.departures(type, stop.id)
.onEach { departures -> // Map<
state = state.copy( // Pair<DirectionId, RouteId>,
lastStopDepartures = departures // Pair<DirectionName, List<DepartureTimes>>
) // >
} val timetable = HashMap<Pair<Int, Int>, Pair<String, MutableList<String>>>()
.launchIn(viewModelScope) res.departures.forEach { dep ->
} val key = Pair(dep.directionId, dep.routeId)
val direction = ptvService.direction(dep.directionId, dep.routeId)
val route = res.routes[dep.routeId.toString()]
val prefix = route?.let { if (it.routeNumber == "") "" else "${it.routeNumber} - " } ?: ""
val element = timetable.getOrPut(key) { Pair(prefix + direction.directionName, mutableListOf()) }.second
if (element.size >= 5)
return@forEach
private fun friendlyPlatform(platform: String) = val date = Instant.parse(dep.estimatedDepartureUtc ?: dep.scheduledDepartureUtc)
platform.takeUnless { it.firstOrNull()?.isDigit() == true } val min = (date - Clock.System.now()).inWholeMinutes
?: "Platform $platform" if (min <= -5)
private fun buildDepartures() { return@forEach
val rawDepartures = state.lastStopDepartures ?: return if (min >= 65)
val departures = if (state.stopsGrouped) { element.add("${((min + 30.0) / 60.0).toInt()}hr")
rawDepartures else
.groupBy { it.stopPlatformCode } element.add("${min}mn")
.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()),
)
} }
) val departures = timetable.values.sortedBy { it.first }.map { (name, list) ->
if (list.isEmpty())
InfoPanelState.Stop.Departure(name, "No departures")
else
InfoPanelState.Stop.Departure(name, list.joinToString(" | "))
} }
} 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 { iInfoState.update {
if (it !is StopInfoPanelState) if (it !is InfoPanelState.Stop)
it it
else else
it.copy(departures = departures) it.copy(departures = departures)
} }
} }
}
/*private suspend fun buildPolylines(route: PtvRoute) { private suspend fun buildPolylines(route: PtvRoute) {
val routeWithGeo = if (route.geopath.isEmpty()) val routeWithGeo = if (route.geopath.isEmpty())
ptvService.route(route.routeId, true) ptvService.route(route.routeId, true)
else else
@ -329,9 +294,9 @@ class MapScreenViewModel(
iMapState.update { it.copy(polylines = polylines) } iMapState.update { it.copy(polylines = polylines) }
newCameraPosition?.let { iCameraChangeEmitter.emit(it.box()) } newCameraPosition?.let { iCameraChangeEmitter.emit(it.box()) }
}*/ }
/*private fun buildRuns(route: PtvRoute) { private fun buildRuns(route: PtvRoute) {
ptvService ptvService
.runsFlow(route.routeId) .runsFlow(route.routeId)
.waitUntilSubscribed(iInfoState) .waitUntilSubscribed(iInfoState)
@ -352,16 +317,19 @@ class MapScreenViewModel(
iMapState.update { it.copy(vehicles = markers) } iMapState.update { it.copy(vehicles = markers) }
} }
.launchIn(viewModelScope) .launchIn(viewModelScope)
}*/
}
private suspend fun buildStops(route: Route) { private suspend fun buildStops(route: Route) {
val stops = stopRepository.getByRoute(route.id) val stops = stopRepository.getByRoute(route.id)
val colour = route.type.getUIProperties().colour
val markers = stops val markers = stops
.map { stop -> .map { stop ->
Marker.Stop( Marker.Stop(
point = stop.pos, point = stop.pos,
id = stop.id, id = stop.id,
colour = colour,
type = route.type, type = route.type,
) )
} }

View file

@ -0,0 +1,210 @@
@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
package moe.lava.banksia.ui.screens.map
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.add
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromJsonElement
import moe.lava.banksia.model.RouteType
import moe.lava.banksia.ui.components.getUIProperties
import moe.lava.banksia.ui.platform.BanksiaTheme
import moe.lava.banksia.ui.state.MapState
import moe.lava.banksia.ui.utils.map.CameraPosition
import moe.lava.banksia.ui.utils.map.Marker
import moe.lava.banksia.util.BoxedValue
import moe.lava.banksia.util.Point
import moe.lava.banksia.util.log
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.expressions.dsl.case
import org.maplibre.compose.expressions.dsl.const
import org.maplibre.compose.expressions.dsl.convertToString
import org.maplibre.compose.expressions.dsl.feature
import org.maplibre.compose.expressions.dsl.switch
import org.maplibre.compose.layers.CircleLayer
import org.maplibre.compose.map.MapOptions
import org.maplibre.compose.map.MaplibreMap
import org.maplibre.compose.map.OrnamentOptions
import org.maplibre.compose.sources.GeoJsonData
import org.maplibre.compose.sources.rememberGeoJsonSource
import org.maplibre.compose.style.BaseStyle
import org.maplibre.compose.util.ClickResult
import org.maplibre.spatialk.geojson.BoundingBox
import org.maplibre.spatialk.geojson.FeatureCollection
import org.maplibre.spatialk.geojson.Position
import org.maplibre.spatialk.geojson.dsl.addFeature
import org.maplibre.spatialk.geojson.dsl.buildFeatureCollection
import org.maplibre.compose.camera.CameraPosition as MLCameraPosition
import org.maplibre.spatialk.geojson.Point as MLPoint
fun Point.toPos(): Position = Position(this.lng, this.lat)
@Serializable
data class MarkerProps(
val type: RouteType,
)
private fun buildMarkers(markers: List<Marker>): FeatureCollection<MLPoint, MarkerProps> {
return buildFeatureCollection {
markers.forEach { marker ->
val type = when (marker) {
is Marker.Stop -> marker.type
is Marker.Vehicle -> marker.type
}
val id = when (marker) {
is Marker.Stop -> marker.id
is Marker.Vehicle -> marker.ref
}
addFeature(
geometry = MLPoint(marker.point.toPos()),
properties = MarkerProps(type),
) {
setId(id)
}
}
}
}
private val colorTypeExpression @Composable get() = switch(
input = feature["type"].convertToString(),
cases = RouteType.entries.map {
case(label = it.name, output = const(it.getUIProperties().colour))
}.toTypedArray(),
fallback = const(BanksiaTheme.colors.surface),
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Maps(
modifier: Modifier,
state: MapState,
onEvent: (MapScreenEvent) -> Unit,
cameraPositionFlow: Flow<BoxedValue<CameraPosition>>,
setLastKnownLocation: (Point) -> Unit,
extInsets: WindowInsets,
) {
val camPos = rememberCameraState(
MLCameraPosition(
zoom = 16.0,
target = MELBOURNE.toPos()
)
)
val newCameraPos by cameraPositionFlow.collectAsStateWithLifecycle(null)
LaunchedEffect(newCameraPos) {
log("maps", "newPos ${newCameraPos?.value}")
val pos = newCameraPos?.value ?: return@LaunchedEffect
if (pos.bounds != null) {
val (northeast, southwest) = pos.bounds
camPos.animateTo(
boundingBox = BoundingBox(
southwest.toPos(),
northeast.toPos()
)
)
} else {
camPos.animateTo(MLCameraPosition(
target = pos.centre.toPos(),
zoom = 16.0,
))
}
}
//
// val ctx = LocalContext.current
// val fusedLocation = remember { LocationServices.getFusedLocationProviderClient(ctx) }
// LaunchedEffect(Unit) {
// @SuppressLint("MissingPermission")
// fusedLocation.lastLocation.addOnSuccessListener {
// if (it != null) {
// camPos.position = MLCameraPosition(
// zoom = 16.0,
// target = Position(it.longitude, it.latitude)
// )
// setLastKnownLocation(Point(it.latitude, it.longitude))
// }
// }
// }
MaplibreMap(
modifier = modifier,
baseStyle = BaseStyle.Uri("https://tiles.openfreemap.org/styles/positron"),
cameraState = camPos,
options = MapOptions(
ornamentOptions = OrnamentOptions(
padding = WindowInsets.safeDrawing.add(extInsets).asPaddingValues(),
isScaleBarEnabled = false,
isAttributionEnabled = false,
)
)
) {
if (state.stops.isNotEmpty()) {
val stopsSource = rememberGeoJsonSource(
GeoJsonData.Features(buildMarkers(state.stops))
)
CircleLayer(
id = "maps-stops0",
source = stopsSource,
color = const(BanksiaTheme.colors.surface),
radius = const(3.dp),
strokeWidth = const(2.dp),
strokeColor = colorTypeExpression,
)
CircleLayer(
id = "maps-stops0-clickhandler",
source = stopsSource,
color = const(Color.Transparent),
radius = const(12.dp),
onClick = { features ->
val feature = features[0]
val marker = Json.decodeFromJsonElement<MarkerProps>(feature.properties!!)
onEvent(MapScreenEvent.SelectStop(marker.type to feature.id!!.content))
ClickResult.Consume
}
)
}
// TODO
// if (state.vehicles.isNotEmpty()) {
// val stopsSource = rememberGeoJsonSource(
// GeoJsonData.Features(buildMarkers(state.vehicles))
// )
// SymbolLayer
// CircleLayer(
// id = "maps-vehicles0",
// source = stopsSource,
// color = const(BanksiaTheme.colors.surface),
// radius = const(3.dp),
// strokeWidth = const(2.dp),
// strokeColor = colorTypeExpression,
// onClick = { features ->
// val feature = features[0]
// val marker = Json.decodeFromJsonElement<MarkerProps>(feature.properties!!)
// onEvent(MapScreenEvent.SelectStop(marker.type to feature.id!!.content))
// ClickResult.Consume
// }
// )
// }
//
// if (state.polylines.isNotEmpty()) {
// val polySource = rememberGeoJsonSource(
//
// )
// LineLayer(
// id = "maps-routeline",
// source = polySource,
// color = colorTypeExpression,
// )
// }
}
}

View file

@ -0,0 +1,38 @@
package moe.lava.banksia.ui.state
import moe.lava.banksia.model.RouteType
sealed class InfoPanelState {
abstract val loading: Boolean
data object None : InfoPanelState() {
override val loading = false
}
data class Route(
val name: String,
val type: RouteType,
) : InfoPanelState() {
override val loading = false
}
data class Run(
val direction: String,
val type: RouteType,
val routeName: String? = null,
) : InfoPanelState() {
override val loading = routeName == null
}
data class Stop(
val id: String,
val name: String,
val subname: String? = null,
val departures: List<Departure>? = null,
) : InfoPanelState() {
override val loading: Boolean
get() = departures == null
data class Departure(val directionName: String, val formattedTimes: String)
}
}

View file

@ -1,7 +1,7 @@
package moe.lava.banksia.ui.state package moe.lava.banksia.ui.state
import moe.lava.banksia.ui.map.util.Marker import moe.lava.banksia.ui.utils.map.Marker
import moe.lava.banksia.ui.map.util.Polyline import moe.lava.banksia.ui.utils.map.Polyline
data class MapState( data class MapState(
val stops: List<Marker.Stop> = listOf(), val stops: List<Marker.Stop> = listOf(),

View file

@ -1,6 +1,6 @@
package moe.lava.banksia.ui.state package moe.lava.banksia.ui.state
import moe.lava.banksia.core.model.RouteType import moe.lava.banksia.model.RouteType
data class SearchState( data class SearchState(
val entries: List<SearchEntry> = listOf(), val entries: List<SearchEntry> = listOf(),

View file

@ -1,6 +1,6 @@
package moe.lava.banksia.ui.map.util package moe.lava.banksia.ui.utils.map
import moe.lava.banksia.core.util.Point import moe.lava.banksia.util.Point
data class CameraPosition( data class CameraPosition(
val centre: Point = Point(-37.8136, 144.9631), val centre: Point = Point(-37.8136, 144.9631),

View file

@ -1,5 +1,5 @@
package moe.lava.banksia.ui.map.util package moe.lava.banksia.ui.utils.map
import moe.lava.banksia.core.util.Point import moe.lava.banksia.util.Point
data class CameraPositionBounds(val northeast: Point, val southwest: Point) data class CameraPositionBounds(val northeast: Point, val southwest: Point)

View file

@ -0,0 +1,22 @@
package moe.lava.banksia.ui.utils.map
import androidx.compose.ui.graphics.Color
import moe.lava.banksia.model.RouteType
import moe.lava.banksia.util.Point
sealed class Marker {
abstract val point: Point
data class Stop(
override val point: Point,
val id: String,
val type: RouteType,
val colour: Color,
) : Marker()
data class Vehicle(
override val point: Point,
val ref: String,
val type: RouteType,
) : Marker()
}

View file

@ -1,6 +1,6 @@
package moe.lava.banksia.ui.map.util package moe.lava.banksia.ui.utils.map
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import moe.lava.banksia.core.util.Point import moe.lava.banksia.util.Point
data class Polyline(val points: List<Point>, val colour: Color) data class Polyline(val points: List<Point>, val colour: Color)

View file

@ -0,0 +1,15 @@
import Foundation
/**
This is a starting class to set up your bridge.
Ensure that your class is public and has the @objcMembers / @objc annotation.
This file has been created because the folder is empty.
Ignore this file if you don't need it or set "spmforkmp.disableStartupFile=true" inside your gradle file
**/
/**
@objcMembers public class StartHere: NSObject {
public override init() {
super.init()
}
}
**/

View file

@ -1,64 +0,0 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidMultiplatformLibrary)
}
kotlin {
android {
namespace = "moe.lava.banksia.core.data"
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 {
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.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,36 +0,0 @@
package moe.lava.banksia.core.data.repositories
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(
private val local: RouteLocalDataSource,
private val remote: RouteRemoteDataSource,
) : RouteRepository {
private val mutex = Mutex()
override suspend fun getAll() = mutex.withLock {
local
.getAll()
.map { it.asModel() }
.ifEmpty {
remote
.getAll()
.also { local.save(*it.toTypedArray()) }
}
}
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

@ -1,23 +0,0 @@
package moe.lava.banksia.core.data.repositories
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import moe.lava.banksia.core.data.sources.stop.StopLocalDataSource
import moe.lava.banksia.core.data.sources.stop.StopRemoteDataSource
import moe.lava.banksia.core.sqld.mappers.asModel
internal class ClientStopRepository internal constructor(
private val local: StopLocalDataSource,
private val remote: StopRemoteDataSource,
) : StopRepository {
private val mutex = Mutex()
override suspend fun get(id: String) = mutex.withLock { local.get(id)?.asModel() ?: remote.get(id) }
override suspend fun getByRoute(id: String) = mutex.withLock {
local
.getByRoute(id)
.map { it.asModel() }
.ifEmpty { null }
?: remote.getByRoute(id)
}
}

View file

@ -1,23 +0,0 @@
package moe.lava.banksia.core.data.sources.route
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
import moe.lava.banksia.core.model.Route
import moe.lava.banksia.core.sqld.RouteQueries
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 {
routes.forEach {
queries.insert(it.asDb())
}
}
}
}
}

View file

@ -1,12 +0,0 @@
package moe.lava.banksia.core.data.sources.route
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
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

@ -1,22 +0,0 @@
package moe.lava.banksia.core.data.sources.stop
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
import moe.lava.banksia.core.model.Stop
import moe.lava.banksia.core.sqld.StopQueries
import moe.lava.banksia.core.sqld.mappers.asDb
internal class StopLocalDataSource(private val queries: StopQueries) {
suspend fun get(id: String) = withContext(Dispatchers.IO) { queries.get(id).executeAsOneOrNull() }
suspend fun getByRoute(id: String) = withContext(Dispatchers.IO) { queries.getByRoute(id).executeAsList() }
suspend fun save(vararg stops: Stop) {
withContext(Dispatchers.IO) {
queries.transaction {
stops.forEach {
queries.insert(it.asDb())
}
}
}
}
}

View file

@ -1,13 +0,0 @@
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

@ -1,9 +0,0 @@
package moe.lava.banksia.core.data.repositories
import moe.lava.banksia.core.model.Route
interface RouteRepository {
suspend fun get(id: String): Route?
suspend fun getByPattern(patternId: Long): Route?
suspend fun getAll(): List<Route>
}

View file

@ -1,8 +0,0 @@
package moe.lava.banksia.core.data.repositories
import moe.lava.banksia.core.model.Stop
interface StopRepository {
suspend fun get(id: String): Stop
suspend fun getByRoute(id: String): List<Stop>
}

View file

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

View file

@ -1,53 +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.sqldelight)
}
kotlin {
android {
namespace = "moe.lava.banksia.core.sqld"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
iosArm64()
iosSimulatorArm64()
jvm()
sourceSets {
androidMain.dependencies {
implementation(libs.sqldelight.driver.android)
}
commonMain.dependencies {
implementation(libs.okio)
implementation(libs.koin.core)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(projects.core)
}
nativeMain.dependencies {
implementation(libs.sqldelight.driver.native)
}
jvmMain.dependencies {
implementation(libs.sqldelight.driver.jvm)
}
}
}
sqldelight {
databases {
register("BanksiaDatabase") {
packageName.set("moe.lava.banksia.core.sqld")
schemaOutputDirectory.set(file("src/commonMain/sqldelight/schema"))
}
}
}

View file

@ -1,14 +0,0 @@
package moe.lava.banksia.core.sqld
import android.content.Context
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
actual class DatabaseManager : KoinComponent {
actual val database by lazy {
val ctx = get<Context>().applicationContext
val driver = AndroidSqliteDriver(BanksiaDatabase.Schema, ctx, "${DBNAME}.db")
BanksiaDatabase(driver)
}
}

View file

@ -1,7 +0,0 @@
package moe.lava.banksia.core.sqld
internal const val DBNAME = "timetable"
expect class DatabaseManager() {
val database: BanksiaDatabase
}

View file

@ -1,17 +0,0 @@
package moe.lava.banksia.core.sqld
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val sqldDiModule = module {
singleOf(::DatabaseManager)
factory { get<DatabaseManager>().database }
factory { get<BanksiaDatabase>().routeQueries }
factory { get<BanksiaDatabase>().serviceQueries }
factory { get<BanksiaDatabase>().serviceExceptionQueries }
factory { get<BanksiaDatabase>().shapeQueries }
factory { get<BanksiaDatabase>().stopQueries }
factory { get<BanksiaDatabase>().stoppingPatternQueries }
factory { get<BanksiaDatabase>().stopTimeQueries }
factory { get<BanksiaDatabase>().tripQueries }
}

View file

@ -1,14 +0,0 @@
package moe.lava.banksia.core.sqld.mappers
import moe.lava.banksia.core.model.Route
import moe.lava.banksia.core.model.RouteType
import moe.lava.banksia.core.sqld.Route as DbRoute
fun DbRoute.asModel() = Route(
id = id,
type = RouteType.from(type.toInt()),
number = number,
name = name,
)
fun Route.asDb() = DbRoute(id, type.value.toLong(), number, name)

View file

@ -1,21 +0,0 @@
package moe.lava.banksia.core.sqld.mappers
import kotlinx.datetime.LocalDate
import moe.lava.banksia.core.model.Service
import moe.lava.banksia.core.util.deserialiseDaysBitflag
import moe.lava.banksia.core.util.serialise
import moe.lava.banksia.core.sqld.Service as DbService
fun DbService.asModel() = Service(
id = id,
days = days.toInt().deserialiseDaysBitflag(),
start = LocalDate.fromEpochDays(start),
end = LocalDate.fromEpochDays(end),
)
fun Service.asDb() = DbService(
id = id,
days = days.serialise().toLong(),
start = start.toEpochDays(),
end = end.toEpochDays(),
)

View file

@ -1,17 +0,0 @@
package moe.lava.banksia.core.sqld.mappers
import kotlinx.datetime.LocalDate
import moe.lava.banksia.core.model.ServiceException
import moe.lava.banksia.core.sqld.ServiceException as DbServiceException
fun DbServiceException.asModel() = ServiceException(
serviceId = serviceId,
date = LocalDate.fromEpochDays(date),
type = type.toInt(),
)
fun ServiceException.asDb() = DbServiceException(
serviceId = serviceId,
type = date.toEpochDays(),
date = type.toLong(),
)

View file

@ -1,52 +0,0 @@
package moe.lava.banksia.core.sqld.mappers
import moe.lava.banksia.core.model.Shape
import moe.lava.banksia.core.model.ShapePath
import moe.lava.banksia.core.util.Point
import moe.lava.banksia.core.sqld.Shape as DbShape
fun DbShape.asModel() = Shape(
id = id,
path = bytesToPath(path),
)
fun Shape.asDb() = DbShape(
id = id,
path = bytesFromPath(path),
)
private fun bytesToPath(value: ByteArray): ShapePath {
return value
.asSequence()
.asIterable()
.chunked(8) {
(it[0].toLong() and 0xFF) or
(it[1].toLong() and 0xFF shl 8) or
(it[2].toLong() and 0xFF shl 16) or
(it[3].toLong() and 0xFF shl 24) or
(it[4].toLong() and 0xFF shl 32) or
(it[5].toLong() and 0xFF shl 40) or
(it[6].toLong() and 0xFF shl 48) or
(it[7].toLong() and 0xFF shl 56)
}
.map { Double.fromBits(it) }
.chunked(2)
.map { (lat, lng) -> Point(lat, lng) }
.toList()
}
private fun bytesFromPath(path: ShapePath): ByteArray {
return path
.flatMap { (lat, lng) -> listOf(lat.toBits(), lng.toBits()) }
.flatMap { i -> listOf(
i.toByte(),
(i shr 8).toByte(),
(i shr 16).toByte(),
(i shr 24).toByte(),
(i shr 32).toByte(),
(i shr 40).toByte(),
(i shr 48).toByte(),
(i shr 56).toByte(),
) }
.toByteArray()
}

View file

@ -1,26 +0,0 @@
package moe.lava.banksia.core.sqld.mappers
import moe.lava.banksia.core.model.Stop
import moe.lava.banksia.core.util.Point
import moe.lava.banksia.core.sqld.Stop as DbStop
fun DbStop.asModel() = Stop(
id = id,
name = name,
pos = Point(lat, lng),
parent = parent,
hasWheelChairBoarding = hasWheelChairBoarding == 1L,
level = level,
platformCode = platformCode,
)
fun Stop.asDb() = DbStop(
id = id,
name = name,
lat = pos.lat,
lng = pos.lng,
parent = parent,
hasWheelChairBoarding = if (hasWheelChairBoarding) 1L else 0L,
level = level,
platformCode = platformCode
)

View file

@ -1,27 +0,0 @@
package moe.lava.banksia.core.sqld.mappers
import moe.lava.banksia.core.model.FutureTime
import moe.lava.banksia.core.model.FutureTime.Companion.asInt
import moe.lava.banksia.core.model.StopTime
import moe.lava.banksia.core.model.TimeType
import moe.lava.banksia.core.sqld.StopTime as DbStopTime
fun DbStopTime.asModel() = StopTime(
patternId = patternId,
stopId = stopId,
time = TimeType.Undated(
arrival = FutureTime.fromInt((departureTime + arrivalDelta).toInt()),
departure = FutureTime.fromInt(departureTime.toInt()),
),
pickupType = pickupType.toInt(),
dropOffType = dropOffType.toInt(),
)
fun StopTime.Undated.asDb() = DbStopTime(
patternId = patternId,
stopId = stopId,
arrivalDelta = (time.arrival.asInt() - time.departure.asInt()).toLong(),
departureTime = time.departure.asInt().toLong(),
pickupType = pickupType.toLong(),
dropOffType = dropOffType.toLong(),
)

View file

@ -1,23 +0,0 @@
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 <T: TimeType> DbStoppingPattern.asModel(stoptimes: List<StopTime<T>>) = StoppingPattern(
id = id,
routeId = routeId,
shapeId = shapeId,
headsign = headsign,
wheelchairAccessible = wheelchairAccessible == 1L,
stoptimes = stoptimes,
)
fun StoppingPattern<*>.asDb() = DbStoppingPattern(
id = id,
routeId = routeId,
shapeId = shapeId,
headsign = headsign,
wheelchairAccessible = if (wheelchairAccessible) 1L else 0L,
)

View file

@ -1,27 +0,0 @@
package moe.lava.banksia.core.sqld.mappers
import moe.lava.banksia.core.model.Service
import moe.lava.banksia.core.model.StoppingPattern
import moe.lava.banksia.core.model.Trip
import moe.lava.banksia.core.sqld.Trip as DbTrip
fun DbTrip.asModel(pattern: StoppingPattern.Undated, service: Service): Trip.Undated {
if (serviceId != service.id) {
throw IllegalArgumentException("trip and service id mismatch (${serviceId} != ${service.id})")
}
return Trip(
id = gtfsId,
pattern = pattern,
service = service,
directionId = directionId.toInt(),
blockId = blockId.toString(),
)
}
fun Trip.Undated.asDb() = DbTrip(
gtfsId = id,
patternId = pattern.id,
serviceId = service.id,
directionId = directionId.toLong(),
blockId = blockId?.toLong(),
)

View file

@ -1,20 +0,0 @@
CREATE TABLE Route (
id TEXT PRIMARY KEY NOT NULL,
type INTEGER NOT NULL,
number TEXT,
name TEXT NOT NULL
);
getAll:
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 OR REPLACE INTO Route VALUES ?;

View file

@ -1,11 +0,0 @@
CREATE TABLE Service (
id TEXT PRIMARY KEY NOT NULL,
days INTEGER NOT NULL,
start INTEGER NOT NULL,
end INTEGER NOT NULL
);
CREATE INDEX idx_Service_days ON Service (days);
insert:
INSERT INTO Service VALUES ?;

View file

@ -1,9 +0,0 @@
CREATE TABLE ServiceException (
serviceId TEXT NOT NULL,
type INTEGER NOT NULL,
date INTEGER NOT NULL,
PRIMARY KEY (serviceId, type)
);
insert:
INSERT INTO ServiceException VALUES ?;

View file

@ -1,7 +0,0 @@
CREATE TABLE Shape (
id TEXT PRIMARY KEY NOT NULL,
path BLOB NOT NULL
);
insert:
INSERT INTO Shape VALUES ?;

View file

@ -1,54 +0,0 @@
CREATE TABLE Stop (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
lat REAL NOT NULL,
lng REAL NOT NULL,
parent TEXT REFERENCES Stop(id),
hasWheelChairBoarding INTEGER NOT NULL,
level TEXT,
platformCode TEXT
);
CREATE INDEX idx_Stop_parent ON Stop (parent);
getAll:
SELECT * FROM Stop;
getAllParentless:
SELECT * FROM Stop WHERE platformCode IS NOT NULL AND parent IS NULL;
get:
SELECT * FROM Stop WHERE id == ?;
getMany:
SELECT * FROM Stop WHERE id IN ?;
insert:
INSERT INTO Stop VALUES ?;
updateParents:
UPDATE Stop SET parent = ? WHERE id IN ?;
getByRoute:
SELECT Stop.* FROM Stop
INNER JOIN StopTime ON StopTime.stopId == Stop.id
INNER JOIN StoppingPattern ON StoppingPattern.id == StopTime.patternId
WHERE StoppingPattern.routeId == :id
GROUP BY Stop.id;
-- I vibecoded this, sorry
getParentsByRoute:
WITH RECURSIVE Tree AS (
SELECT Stop.* FROM Stop
INNER JOIN StopTime ON StopTime.stopId == Stop.id
INNER JOIN StoppingPattern ON StoppingPattern.id == StopTime.patternId
WHERE StoppingPattern.routeId == :id
GROUP BY Stop.id
UNION ALL
SELECT s.*
FROM Stop s
INNER JOIN Tree t ON s.id = t.parent
)
SELECT DISTINCT * FROM Tree WHERE parent IS NULL;

View file

@ -1,45 +0,0 @@
CREATE TABLE StopTime (
patternId INTEGER NOT NULL REFERENCES StoppingPattern (id),
stopId TEXT NOT NULL REFERENCES Stop (id),
arrivalDelta INTEGER NOT NULL,
departureTime INTEGER NOT NULL,
pickupType INTEGER NOT NULL,
dropOffType INTEGER NOT NULL,
PRIMARY KEY (patternId, stopId)
) WITHOUT ROWID;
CREATE INDEX idx_StopTime_stopId ON StopTime (stopId);
insert:
INSERT OR REPLACE INTO StopTime VALUES ?;
getForStopDated:
SELECT DISTINCT StopTime.* 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
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

@ -1,13 +0,0 @@
CREATE TABLE StoppingPattern (
id INTEGER PRIMARY KEY NOT NULL,
routeId TEXT NOT NULL REFERENCES Route (id),
shapeId TEXT NOT NULL REFERENCES Shape (id),
headsign TEXT NOT NULL,
wheelchairAccessible INTEGER NOT NULL
);
insert:
INSERT OR REPLACE INTO StoppingPattern VALUES ?;
get:
SELECT * FROM StoppingPattern WHERE id == :id;

View file

@ -1,13 +0,0 @@
CREATE TABLE Trip (
gtfsId TEXT PRIMARY KEY NOT NULL,
patternId INTEGER NOT NULL REFERENCES StoppingPattern (id),
serviceId TEXT NOT NULL REFERENCES Service (id),
blockId INTEGER,
directionId INTEGER NOT NULL
);
CREATE INDEX idx_Trip_patternId ON Trip (patternId);
CREATE INDEX idx_Trip_serviceId ON Trip (serviceId);
insert:
INSERT OR REPLACE INTO Trip VALUES ?;

View file

@ -1,11 +0,0 @@
package moe.lava.banksia.core.sqld
import app.cash.sqldelight.driver.native.NativeSqliteDriver
import org.koin.core.component.KoinComponent
actual class DatabaseManager : KoinComponent {
actual val database by lazy {
val driver = NativeSqliteDriver(BanksiaDatabase.Schema, "${DBNAME}.db")
BanksiaDatabase(driver)
}
}

View file

@ -1,56 +0,0 @@
package moe.lava.banksia.core.sqld
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import moe.lava.banksia.core.util.error
import org.koin.core.component.KoinComponent
import java.io.File
import java.util.Properties
import kotlin.system.exitProcess
actual class DatabaseManager : KoinComponent {
private var driver = connect()
actual val database get() = BanksiaDatabase(driver)
private fun connect(path: String = "./data/${DBNAME}.db") =
JdbcSqliteDriver("jdbc:sqlite:${path}", Properties(), BanksiaDatabase.Schema)
.apply { execute(null, "PRAGMA journal_mode = OFF;", 0) }
fun makeAlt() = run {
File("./data/${DBNAME}_alt.db").takeIf { it.exists() }?.delete()
val driver = connect("./data/${DBNAME}_alt.db")
BanksiaDatabase(driver) to { driver.close() }
}
fun swap(scope: CoroutineScope = CoroutineScope(Dispatchers.IO)) {
val live = File("./data/${DBNAME}.db")
val alt = File("./data/${DBNAME}_alt.db")
val old = File("./data/${DBNAME}_old.db")
if (live.takeIf { it.exists() }?.renameTo(old) == false) {
error("DatabaseManager", "Failed to rename database from live to old (${live.absolutePath} -> ${old.absolutePath})")
return
}
if (alt.takeIf { it.exists() }?.renameTo(live) == false) {
error("DatabaseManager", "Failed to rename database from alt to live, trying to undo.. (${alt.absolutePath} -> ${live.absolutePath})")
if (!live.renameTo(old)) {
error("DatabaseManager", "Failed to undo, critical failure, exiting..")
exitProcess(1)
}
return
}
val oldDriver = driver
driver = connect()
scope.launch {
delay(5000)
if (old.takeIf { it.exists() }?.delete() == false) {
error("DatabaseManager", "Failed to unlink old database, stray files! (${old.absolutePath})")
}
oldDriver.close()
}
}
}

View file

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

View file

@ -1,11 +0,0 @@
package moe.lava.banksia.core.model
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
@Serializable
data class ServiceException(
val serviceId: String,
val date: LocalDate,
val type: Int,
)

View file

@ -1,45 +0,0 @@
package moe.lava.banksia.core.model
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.Serializable
@Serializable
data class StopTime<T: TimeType>(
val patternId: Long,
val stopId: String,
val time: T,
val pickupType: Int,
val dropOffType: Int,
) {
typealias Dated = StopTime<TimeType.Dated>
typealias Undated = StopTime<TimeType.Undated>
}
@Serializable
sealed class TimeType {
@Serializable
data class Undated(
val arrival: FutureTime,
val departure: FutureTime,
) : TimeType()
@Serializable
data class Dated(
val arrival: LocalDateTime,
val departure: LocalDateTime,
) : 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 = time.atDate(date),
pickupType = pickupType,
dropOffType = dropOffType,
)

View file

@ -1,16 +0,0 @@
package moe.lava.banksia.core.model
import kotlinx.serialization.Serializable
@Serializable
data class StoppingPattern<T: TimeType>(
val id: Long,
val routeId: String,
val shapeId: String,
val headsign: String,
val wheelchairAccessible: Boolean,
val stoptimes: List<StopTime<T>>,
) {
typealias Dated = StoppingPattern<TimeType.Dated>
typealias Undated = StoppingPattern<TimeType.Undated>
}

View file

@ -1,15 +0,0 @@
package moe.lava.banksia.core.model
import kotlinx.serialization.Serializable
@Serializable
data class Trip<T: TimeType>(
val id: String,
val pattern: StoppingPattern<T>,
val service: Service,
val directionId: Int,
val blockId: String?,
) {
typealias Dated = Trip<TimeType.Dated>
typealias Undated = Trip<TimeType.Undated>
}

View file

@ -1,36 +0,0 @@
package moe.lava.banksia.core.util
import kotlinx.datetime.DayOfWeek
private fun Int.check(other: Int) = (this and other) != 0
fun Int.deserialiseDaysBitflag(): List<DayOfWeek> = buildList {
val days = this@deserialiseDaysBitflag
if (days.check(1))
add(DayOfWeek.MONDAY)
if (days.check(1 shl 1))
add(DayOfWeek.TUESDAY)
if (days.check(1 shl 2))
add(DayOfWeek.WEDNESDAY)
if (days.check(1 shl 3))
add(DayOfWeek.THURSDAY)
if (days.check(1 shl 4))
add(DayOfWeek.FRIDAY)
if (days.check(1 shl 5))
add(DayOfWeek.SATURDAY)
if (days.check(1 shl 6))
add(DayOfWeek.SUNDAY)
}
fun List<DayOfWeek>.serialise(): Int =
this.fold(0) { vl, n ->
vl + when (n) {
DayOfWeek.MONDAY -> 1
DayOfWeek.TUESDAY -> 1 shl 1
DayOfWeek.WEDNESDAY -> 1 shl 2
DayOfWeek.THURSDAY -> 1 shl 3
DayOfWeek.FRIDAY -> 1 shl 4
DayOfWeek.SATURDAY -> 1 shl 5
DayOfWeek.SUNDAY -> 1 shl 6
}
}

View file

@ -1,9 +0,0 @@
package moe.lava.banksia.core.util
actual fun log(tag: String, msg: String) {
TODO("Not yet implemented")
}
actual fun error(tag: String, msg: String, throwable: Throwable?) {
TODO("Not yet implemented")
}

Some files were not shown because too many files have changed in this diff Show more