refactor: split composeApp to client and ui

also renamed client.datasource to client.data, which made me realise
.gitignore was ignoring `data` and therefore some gtfsr source files
😭
This commit is contained in:
Cilly Leang 2026-03-02 00:09:33 +11:00
parent d3edabce36
commit 74338d6dce
Signed by: cilly
GPG key ID: 6500251E087653C9
62 changed files with 121 additions and 23 deletions

112
ui/build.gradle.kts Normal file
View file

@ -0,0 +1,112 @@
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.secretsGradle)
}
kotlin {
androidTarget {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
}
listOf(
// iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}
sourceSets {
androidMain.dependencies {
implementation(libs.compose.ui.tooling.preview)
implementation(libs.androidx.activity.compose)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.play.services.location)
}
commonMain.dependencies {
implementation(libs.compose.components.resources)
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material.icons.core) // TODO: move to symbols
implementation(libs.compose.material3)
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.composeunstyled)
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.contentnegotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.maplibre.compose)
implementation(libs.moko.geo)
implementation(libs.moko.geo.compose)
implementation(libs.ui.backhandler)
implementation(projects.client)
implementation(projects.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 {
debugImplementation(compose.uiTooling)
}
secrets {
propertiesFileName = "secrets.properties"
}
compose.resources {
publicResClass = true
packageOfResClass = "moe.lava.banksia.resources"
}

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:enableOnBackInvokedCallback="true"
android:usesCleartextTraffic="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="${MAPS_API_KEY}" />
<activity
android:exported="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|mnc|colorMode|density|fontScale|fontWeightAdjustment|keyboard|layoutDirection|locale|mcc|navigation|smallestScreenSize|touchscreen|uiMode"
android:name=".ui.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,17 @@
package moe.lava.banksia.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
App()
}
}
}

View file

@ -0,0 +1,24 @@
package moe.lava.banksia.ui.platform
import android.os.Build
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
@Composable
actual fun BanksiaTheme.colors(
darkTheme: Boolean,
dynamicColor: Boolean
): ColorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> darkColorScheme()
else -> lightColorScheme()
}

View file

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View file

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -0,0 +1,37 @@
[
{
"featureType": "poi.business",
"stylers": [
{
"visibility": "off"
}
]
},
{
"featureType": "poi.park",
"elementType": "labels.text",
"stylers": [
{
"visibility": "off"
}
]
},
{
"featureType": "road.highway",
"elementType": "labels.icon",
"stylers": [
{
"visibility": "off"
}
]
},
{
"featureType": "transit",
"elementType": "labels.icon",
"stylers": [
{
"visibility": "off"
}
]
}
]

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">Banksia</string>
</resources>

View file

@ -0,0 +1,14 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:gravity="fill"
android:drawable="@drawable/bus_background"
/>
<item
android:gravity="center"
android:drawable="@drawable/bus_icon"
android:top="5dp"
android:bottom="5dp"
android:left="5dp"
android:right="5dp"
/>
</layer-list>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF8200"
android:pathData="M12,12m-10,0a10,10 0,1 1,20 0a10,10 0,1 1,-20 0"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#FFFFFF" android:pathData="M4,16c0,0.88 0.39,1.67 1,2.22L5,20c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-1h8v1c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-1.78c0.61,-0.55 1,-1.34 1,-2.22L20,6c0,-3.5 -3.58,-4 -8,-4s-8,0.5 -8,4v10zM7.5,17c-0.83,0 -1.5,-0.67 -1.5,-1.5S6.67,14 7.5,14s1.5,0.67 1.5,1.5S8.33,17 7.5,17zM16.5,17c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5zM18,11L6,11L6,6h12v5z"/>
</vector>

View file

@ -0,0 +1,36 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="600dp"
android:height="600dp"
android:viewportWidth="600"
android:viewportHeight="600">
<path
android:pathData="M301.21,418.53C300.97,418.54 300.73,418.56 300.49,418.56C297.09,418.59 293.74,417.72 290.79,416.05L222.6,377.54C220.63,376.43 219,374.82 217.85,372.88C216.7,370.94 216.09,368.73 216.07,366.47L216.07,288.16C216.06,287.32 216.09,286.49 216.17,285.67C216.38,283.54 216.91,281.5 217.71,279.6L199.29,268.27L177.74,256.19C175.72,260.43 174.73,265.23 174.78,270.22L174.79,387.05C174.85,393.89 178.57,400.2 184.53,403.56L286.26,461.02C290.67,463.51 295.66,464.8 300.73,464.76C300.91,464.76 301.09,464.74 301.27,464.74C301.24,449.84 301.22,439.23 301.22,439.23L301.21,418.53Z"
android:fillColor="#041619"
android:fillType="nonZero"/>
<path
android:pathData="M409.45,242.91L312.64,188.23C303.64,183.15 292.58,183.26 283.68,188.51L187.92,245C183.31,247.73 179.93,251.62 177.75,256.17L177.74,256.19L199.29,268.27L217.71,279.6C217.83,279.32 217.92,279.02 218.05,278.74C218.24,278.36 218.43,277.98 218.64,277.62C219.06,276.88 219.52,276.18 220.04,275.51C221.37,273.8 223.01,272.35 224.87,271.25L289.06,233.39C290.42,232.59 291.87,231.96 293.39,231.51C295.53,230.87 297.77,230.6 300,230.72C302.98,230.88 305.88,231.73 308.47,233.2L373.37,269.85C375.54,271.08 377.49,272.68 379.13,274.57C379.68,275.19 380.18,275.85 380.65,276.53C380.86,276.84 381.05,277.15 381.24,277.47L397.79,266.39L420.34,252.93L420.31,252.88C417.55,248.8 413.77,245.35 409.45,242.91Z"
android:fillColor="#37BF6E"
android:fillType="nonZero"/>
<path
android:pathData="M381.24,277.47C381.51,277.92 381.77,278.38 382.01,278.84C382.21,279.24 382.39,279.65 382.57,280.06C382.91,280.88 383.19,281.73 383.41,282.59C383.74,283.88 383.92,285.21 383.93,286.57L383.93,361.1C383.96,363.95 383.35,366.77 382.16,369.36C381.93,369.86 381.69,370.35 381.42,370.83C379.75,373.79 377.32,376.27 374.39,378L310.2,415.87C307.47,417.48 304.38,418.39 301.21,418.53L301.22,439.23C301.22,439.23 301.24,449.84 301.27,464.74C306.1,464.61 310.91,463.3 315.21,460.75L410.98,404.25C419.88,399 425.31,389.37 425.22,379.03L425.22,267.85C425.17,262.48 423.34,257.34 420.34,252.93L397.79,266.39L381.24,277.47Z"
android:fillColor="#3870B2"
android:fillType="nonZero"/>
<path
android:pathData="M177.75,256.17C179.93,251.62 183.31,247.73 187.92,245L283.68,188.51C292.58,183.26 303.64,183.15 312.64,188.23L409.45,242.91C413.77,245.35 417.55,248.8 420.31,252.88L420.34,252.93L498.59,206.19C494.03,199.46 487.79,193.78 480.67,189.75L320.86,99.49C306.01,91.1 287.75,91.27 273.07,99.95L114.99,193.2C107.39,197.69 101.81,204.11 98.21,211.63L177.74,256.19L177.75,256.17ZM301.27,464.74C301.09,464.74 300.91,464.76 300.73,464.76C295.66,464.8 290.67,463.51 286.26,461.02L184.53,403.56C178.57,400.2 174.85,393.89 174.79,387.05L174.78,270.22C174.73,265.23 175.72,260.43 177.74,256.19L98.21,211.63C94.86,218.63 93.23,226.58 93.31,234.82L93.31,427.67C93.42,438.97 99.54,449.37 109.4,454.92L277.31,549.77C284.6,553.88 292.84,556.01 301.2,555.94L301.2,555.8C301.39,543.78 301.33,495.26 301.27,464.74Z"
android:strokeWidth="10"
android:fillColor="#00000000"
android:strokeColor="#083042"
android:fillType="nonZero"/>
<path
android:pathData="M498.59,206.19L420.34,252.93C423.34,257.34 425.17,262.48 425.22,267.85L425.22,379.03C425.31,389.37 419.88,399 410.98,404.25L315.21,460.75C310.91,463.3 306.1,464.61 301.27,464.74C301.33,495.26 301.39,543.78 301.2,555.8L301.2,555.94C309.48,555.87 317.74,553.68 325.11,549.32L483.18,456.06C497.87,447.39 506.85,431.49 506.69,414.43L506.69,230.91C506.6,222.02 503.57,213.5 498.59,206.19Z"
android:strokeWidth="10"
android:fillColor="#00000000"
android:strokeColor="#083042"
android:fillType="nonZero"/>
<path
android:pathData="M301.2,555.94C292.84,556.01 284.6,553.88 277.31,549.76L109.4,454.92C99.54,449.37 93.42,438.97 93.31,427.67L93.31,234.82C93.23,226.58 94.86,218.63 98.21,211.63C101.81,204.11 107.39,197.69 114.99,193.2L273.07,99.95C287.75,91.27 306.01,91.1 320.86,99.49L480.67,189.75C487.79,193.78 494.03,199.46 498.59,206.19C503.57,213.5 506.6,222.02 506.69,230.91L506.69,414.43C506.85,431.49 497.87,447.39 483.18,456.06L325.11,549.32C317.74,553.68 309.48,555.87 301.2,555.94Z"
android:strokeWidth="10"
android:fillColor="#00000000"
android:strokeColor="#083042"
android:fillType="nonZero"/>
</vector>

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,14 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:gravity="fill"
android:drawable="@drawable/train_background"
/>
<item
android:gravity="center"
android:drawable="@drawable/train_icon"
android:top="5dp"
android:bottom="5dp"
android:left="5dp"
android:right="5dp"
/>
</layer-list>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#0072CE"
android:pathData="M12,12m-10,0a10,10 0,1 1,20 0a10,10 0,1 1,-20 0"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#FFFFFF" android:pathData="M12,2c-4,0 -8,0.5 -8,4v9.5C4,17.43 5.57,19 7.5,19L6,20.5v0.5h2.23l2,-2L14,19l2,2h2v-0.5L16.5,19c1.93,0 3.5,-1.57 3.5,-3.5L20,6c0,-3.5 -3.58,-4 -8,-4zM7.5,17c-0.83,0 -1.5,-0.67 -1.5,-1.5S6.67,14 7.5,14s1.5,0.67 1.5,1.5S8.33,17 7.5,17zM11,10L6,10L6,6h5v4zM13,10L13,6h5v4h-5zM16.5,17c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5z"/>
</vector>

View file

@ -0,0 +1,14 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:gravity="fill"
android:drawable="@drawable/tram_background"
/>
<item
android:gravity="center"
android:drawable="@drawable/tram_icon"
android:top="5dp"
android:bottom="5dp"
android:left="5dp"
android:right="5dp"
/>
</layer-list>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#78BE20"
android:pathData="M12,12m-10,0a10,10 0,1 1,20 0a10,10 0,1 1,-20 0"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#FFFFFF" android:pathData="M19,16.94L19,8.5c0,-2.79 -2.61,-3.4 -6.01,-3.49l0.76,-1.51L17,3.5L17,2L7,2v1.5h4.75l-0.76,1.52C7.86,5.11 5,5.73 5,8.5v8.44c0,1.45 1.19,2.66 2.59,2.97L6,21.5v0.5h2.23l2,-2L14,20l2,2h2v-0.5L16.5,20h-0.08c1.69,0 2.58,-1.37 2.58,-3.06zM12,18.5c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5zM17,14L7,14L7,9h10v5z"/>
</vector>

View file

@ -0,0 +1,21 @@
package moe.lava.banksia.ui
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import moe.lava.banksia.di.CommonModules
import moe.lava.banksia.ui.di.AppModule
import moe.lava.banksia.ui.screens.map.MapScreen
import org.koin.compose.KoinMultiplatformApplication
import org.koin.core.annotation.KoinExperimentalAPI
import org.koin.dsl.koinConfiguration
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class, KoinExperimentalAPI::class)
@Composable
fun App() {
KoinMultiplatformApplication(config = koinConfiguration {
modules(CommonModules, AppModule)
}) {
MapScreen()
}
}

View file

@ -0,0 +1,136 @@
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.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.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.bus
import moe.lava.banksia.resources.bus_background
import moe.lava.banksia.resources.bus_icon
import moe.lava.banksia.resources.train
import moe.lava.banksia.resources.train_background
import moe.lava.banksia.resources.train_icon
import moe.lava.banksia.resources.tram
import moe.lava.banksia.resources.tram_background
import moe.lava.banksia.resources.tram_icon
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
data class RouteTypeProperties(
val colour: Color,
val drawable: DrawableResource,
val background: DrawableResource,
val icon: DrawableResource,
)
const val TRAIN_BLUE = 0xFF0072CE
const val TRAM_GREEN = 0xFF78BE20
const val BUS_ORANGE = 0xFFFF8200
const val VLINE_PURPLE = 0xFF8F1A95
fun RouteType.getUIProperties(): RouteTypeProperties {
val colour = when (this) {
MetroTrain -> TRAIN_BLUE
MetroTram -> TRAM_GREEN
MetroBus -> BUS_ORANGE
RegionalTrain -> VLINE_PURPLE
RegionalCoach -> VLINE_PURPLE
RegionalBus -> VLINE_PURPLE
SkyBus -> BUS_ORANGE
Interstate -> BUS_ORANGE
}
val (drawable, background, icon) = when (this) {
MetroTrain,
RegionalTrain,
Interstate -> Triple(
Res.drawable.train, Res.drawable.train_background, Res.drawable.train_icon
)
MetroTram -> Triple(
Res.drawable.tram, Res.drawable.tram_background, Res.drawable.tram_icon
)
MetroBus,
RegionalCoach,
RegionalBus,
SkyBus -> Triple(
Res.drawable.bus, Res.drawable.bus_background, Res.drawable.bus_icon
)
}
return RouteTypeProperties(Color(colour), drawable, background, icon)
}
fun PtvRouteType.getUIProperties(): RouteTypeProperties {
val colour = when (this) {
PtvRouteType.TRAIN -> Color(TRAIN_BLUE)
PtvRouteType.TRAM -> Color(TRAM_GREEN)
PtvRouteType.BUS, PtvRouteType.NIGHT_BUS -> Color(BUS_ORANGE)
PtvRouteType.VLINE -> Color(VLINE_PURPLE)
}
val (drawable, background, icon) = when (this) {
PtvRouteType.TRAM -> Triple(
Res.drawable.tram, Res.drawable.tram_background, Res.drawable.tram_icon
)
PtvRouteType.TRAIN, PtvRouteType.VLINE -> Triple(
Res.drawable.train, Res.drawable.train_background, Res.drawable.train_icon
)
PtvRouteType.BUS, PtvRouteType.NIGHT_BUS -> Triple(
Res.drawable.bus, Res.drawable.bus_background, Res.drawable.bus_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,12 @@
package moe.lava.banksia.ui.di
import moe.lava.banksia.client.di.ClientModule
import moe.lava.banksia.ui.screens.map.MapScreenViewModel
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val AppModule = module {
includes(ClientModule)
// ViewModel
viewModelOf(::MapScreenViewModel)
}

View file

@ -0,0 +1,187 @@
package moe.lava.banksia.ui.layout
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.backhandler.PredictiveBackHandler
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.composables.core.BottomSheet
import com.composables.core.BottomSheetState
import com.composables.core.DragIndication
import com.composables.core.SheetDetent
import com.composables.core.rememberBottomSheetState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.roundToInt
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun AppBottomSheet(
sheetState: SheetStateWrapper,
onDismiss: () -> Unit,
content: @Composable () -> Unit,
) {
var peekHeightMultiplier by remember { mutableFloatStateOf(1f) }
var sheetEnabled by remember { mutableStateOf(true) }
val scope = rememberCoroutineScope()
BottomSheet(
state = sheetState.state,
enabled = sheetEnabled,
modifier = Modifier.fillMaxSize()
// TODO: This recomposes; find a better solution using Modifier.layout
.padding(
top = 24.dp * (1f - peekHeightMultiplier),
end = 24.dp * (1f - peekHeightMultiplier),
bottom = 0.dp,
start = 24.dp * (1f - peekHeightMultiplier),
)
.shadow(4.dp, RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp))
.clip(RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp))
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.fillMaxWidth()
.imePadding(),
) {
Column(Modifier.fillMaxSize().alpha(peekHeightMultiplier)) {
DragIndication(
Modifier
.padding(vertical = 12.dp)
.height(4.dp)
.width(32.dp)
.align(Alignment.CenterHorizontally)
.background(MaterialTheme.colorScheme.onSurfaceVariant, RoundedCornerShape(100))
)
content()
}
}
PredictiveBackHandler(!sheetState.hidden) { progress ->
sheetEnabled = false
try {
progress.collect { backEvent ->
if (sheetState.peeking) {
peekHeightMultiplier = 1F - backEvent.progress
}
}
if (sheetState.expanded) {
scope.launch { sheetState.peek() }
} else if (sheetState.peeking) {
scope.launch {
sheetState.hide()
peekHeightMultiplier = 1F
onDismiss()
}
}
} catch (_: CancellationException) {
peekHeightMultiplier = 1F
}
sheetEnabled = true
}
}
class SheetStateWrapper(
val state: BottomSheetState,
private val scope: CoroutineScope,
private var p1: MutableState<Dp>,
private var p2: MutableState<Dp>,
private val peek1: SheetDetent,
private val peek2: SheetDetent,
) {
companion object {
private val saver = Saver<MutableState<Dp>, Float>(
save = { it.value.value },
restore = { mutableStateOf(it.dp) }
)
@Composable
fun create(): SheetStateWrapper {
val p1 = rememberSaveable(saver = saver) { mutableStateOf(0.dp) }
val p2 = rememberSaveable(saver = saver) { mutableStateOf(0.dp) }
val scope = rememberCoroutineScope()
val peek1 = SheetDetent(identifier = "peek1") { containerHeight, sheetHeight ->
val res = (p1.value + 40.dp)
res
}
val peek2 = SheetDetent(identifier = "peek2") { containerHeight, sheetHeight ->
val res = (p2.value + 40.dp)
res
}
val internalState = rememberBottomSheetState(
initialDetent = SheetDetent.Hidden,
detents = listOf(SheetDetent.Hidden, peek1, peek2, SheetDetent.FullyExpanded)
)
return remember { SheetStateWrapper(internalState, scope, p1, p2, peek1, peek2) }
}
}
@Suppress("NOTHING_TO_INLINE")
private inline fun stateEither(detent: SheetDetent) = state.currentDetent == detent || state.targetDetent == detent
private var peek: SheetDetent = peek1
val current get() = state.currentDetent
val target get() = state.targetDetent
val expanded get() = stateEither(SheetDetent.FullyExpanded)
val peeking get() = stateEither(peek1) || stateEither(peek2)
val hidden get() = stateEither(SheetDetent.Hidden)
val offset get() = state.offset
val bottomInset: Int @Composable get() {
return if (!hidden) {
val sheetOffset = state.offset.roundToInt()
val insets = WindowInsets.safeDrawing.getBottom(LocalDensity.current)
(sheetOffset - insets)
.coerceAtLeast(0)
.coerceIn(0, with(LocalDensity.current) { 500.dp.roundToPx() })
} else 0
}
fun hide() { state.targetDetent = SheetDetent.Hidden }
fun peek() { state.targetDetent = peek }
fun peekTo(target: Dp) {
if (peek == peek1) {
p2.value = target
peek = peek2
} else {
p1.value = target
peek = peek1
}
state.invalidateDetents()
state.targetDetent = peek
// TODO: this is broken; animateTo never finishes
// scope.launch {
// state.animateTo(peek)
// p1.value = target
// p2.value = target
// state.invalidateDetents()
// }
}
}

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

@ -0,0 +1,104 @@
package moe.lava.banksia.ui.layout
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import moe.lava.banksia.ui.components.RouteIcon
import moe.lava.banksia.ui.screens.map.MapScreenEvent
import moe.lava.banksia.ui.state.SearchState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Searcher(
state: SearchState,
onEvent: (MapScreenEvent) -> Unit,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
) {
val animatedPadding by animateDpAsState(
if (expanded) {
0.dp
} else {
20.dp
},
label = "padding"
)
Box(modifier = Modifier.fillMaxSize()) {
SearchBar(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.padding(horizontal = animatedPadding),
shadowElevation = 6.dp, // Elevation level 3
inputField = {
SearchBarDefaults.InputField(
modifier = Modifier.padding(horizontal = 20.dp - animatedPadding),
query = state.text,
onQueryChange = { onEvent(MapScreenEvent.SearchUpdate(it)) },
onSearch = {},
expanded = expanded,
onExpandedChange = onExpandedChange,
leadingIcon = { Icon(Icons.Default.Search, null) },
trailingIcon = {
if (expanded && state.text.isNotEmpty())
Icon(
imageVector = Icons.Default.Clear,
contentDescription = null,
modifier = Modifier.clickable {
onEvent(
MapScreenEvent.SearchUpdate(
""
)
)
}
)
}
)
},
expanded = expanded,
onExpandedChange = onExpandedChange,
) {
LazyColumn(modifier = Modifier.fillMaxWidth()) {
for (entry in state.entries) {
item {
ListItem(
headlineContent = { Text(entry.mainText) },
supportingContent = { entry.subText?.let { Text(it) } },
leadingContent = { RouteIcon(routeType = entry.routeType) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.clickable {
onExpandedChange(false)
onEvent(MapScreenEvent.SearchUpdate(""))
onEvent(MapScreenEvent.SelectRoute(entry.routeId))
}
)
}
}
}
}
}
}

View file

@ -0,0 +1,29 @@
package moe.lava.banksia.ui.platform
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.runtime.Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun BanksiaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable (() -> Unit)
) {
MaterialExpressiveTheme(
colorScheme = BanksiaTheme.colors(darkTheme, dynamicColor),
content = content,
)
}
@Composable
expect fun BanksiaTheme.colors(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme
object BanksiaTheme {
val colors: ColorScheme
@Composable
get() = colors(isSystemInDarkTheme(), true)
}

View file

@ -0,0 +1,136 @@
package moe.lava.banksia.ui.screens.map
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.add
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.safeContent
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBarDefaults
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.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.icerock.moko.geo.compose.BindLocationTrackerEffect
import dev.icerock.moko.geo.compose.LocationTrackerAccuracy
import dev.icerock.moko.geo.compose.rememberLocationTrackerFactory
import kotlinx.coroutines.launch
import moe.lava.banksia.resources.Res
import moe.lava.banksia.resources.my_location_24
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.SheetStateWrapper
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.koin.compose.viewmodel.koinViewModel
val MELBOURNE = Point(-37.8136, 144.9631)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun MapScreen(
viewModel: MapScreenViewModel = koinViewModel()
) {
val scope = rememberCoroutineScope()
val locationFactory = rememberLocationTrackerFactory(LocationTrackerAccuracy.Best)
val locationTracker = remember { locationFactory.createLocationTracker() }
BindLocationTrackerEffect(locationTracker)
viewModel.bindTracker(locationTracker)
scope.launch { locationTracker.startTracking() }
val infoState by viewModel.infoState.collectAsStateWithLifecycle()
val mapState by viewModel.mapState.collectAsStateWithLifecycle()
val searchState by viewModel.searchState.collectAsStateWithLifecycle()
val sheetState = SheetStateWrapper.create()
var searchExpandedState by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(infoState) {
if (infoState !is InfoPanelState.None) {
sheetState.peek()
} else {
sheetState.hide()
}
}
BanksiaTheme {
Scaffold {
Maps(
modifier = Modifier.fillMaxSize(),
state = mapState,
onEvent = viewModel::handleEvent,
cameraPositionFlow = viewModel.cameraChangeEmitter,
extInsets = WindowInsets(top = with(LocalDensity.current) {
SearchBarDefaults.InputFieldHeight.roundToPx()
}, bottom = sheetState.bottomInset),
setLastKnownLocation = viewModel::setLastKnownLocation,
)
Searcher(
state = searchState,
onEvent = viewModel::handleEvent,
expanded = searchExpandedState,
onExpandedChange = {
searchExpandedState = it
if (it) scope.launch { sheetState.hide() }
},
)
AnimatedVisibility(
visible = !searchExpandedState,
label = "search-hider",
enter = fadeIn(),
exit = fadeOut(),
) {
Box(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(
WindowInsets.safeContent.add(
WindowInsets(bottom = sheetState.bottomInset)
)
),
contentAlignment = Alignment.BottomEnd
) {
FloatingActionButton(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
onClick = { viewModel.centreCameraToLocation() },
) {
Icon(painterResource(Res.drawable.my_location_24), "Move to current location")
}
}
AppBottomSheet(
sheetState = sheetState,
onDismiss = { viewModel.handleEvent(MapScreenEvent.DismissState) }
) {
InfoPanel(
state = infoState,
onEvent = viewModel::handleEvent,
onPeekHeightChange = { ph -> sheetState.peekTo(ph) },
)
}
}
}
}
}

View file

@ -0,0 +1,357 @@
package moe.lava.banksia.ui.screens.map
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.icerock.moko.geo.LocationTracker
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import moe.lava.banksia.client.repository.RouteRepository
import moe.lava.banksia.client.repository.StopRepository
import moe.lava.banksia.data.ptv.PtvService
import moe.lava.banksia.data.ptv.structures.PtvRoute
import moe.lava.banksia.model.Route
import moe.lava.banksia.model.RouteType
import moe.lava.banksia.ui.components.getUIProperties
import moe.lava.banksia.ui.state.InfoPanelState
import moe.lava.banksia.ui.state.MapState
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 {
data object DismissState : MapScreenEvent()
data class SelectRoute(val id: String?) : MapScreenEvent()
data class SelectRun(val ref: String?) : MapScreenEvent()
data class SelectStop(val typeIdPair: Pair<RouteType, String>?) : MapScreenEvent()
data class SearchUpdate(val text: String) : MapScreenEvent()
}
data class InternalState(
val route: String? = null,
val stop: Pair<RouteType, String>? = null,
val run: String? = null,
)
class MapScreenViewModel(
private val ptvService: PtvService,
private val routeRepository: RouteRepository,
private val stopRepository: StopRepository,
) : ViewModel() {
private var state = InternalState()
set(value) {
val last = field
field = value
if (value.route != last.route)
viewModelScope.launch { switchRoute(value.route) }
if (value.stop != last.stop)
viewModelScope.launch { switchStop(value.stop) }
if (value.run != last.run)
switchRun(value.run)
}
private val iInfoState = MutableStateFlow<InfoPanelState>(InfoPanelState.None)
val infoState = iInfoState.asStateFlow()
private val iMapState = MutableStateFlow(MapState())
val mapState = iMapState.asStateFlow()
private val iCameraChangeEmitter = MutableSharedFlow<BoxedValue<CameraPosition>>()
val cameraChangeEmitter = iCameraChangeEmitter.asSharedFlow()
private val iSearchState = MutableStateFlow(SearchState())
val searchState = iSearchState.asStateFlow()
private var locationTrackerJob: Job? = null
private var lastKnownLocation: Point? = null
init {
viewModelScope.launch { searchUpdate("") }
}
fun handleEvent(event: MapScreenEvent) {
viewModelScope.launch {
when (event) {
is MapScreenEvent.DismissState -> dismissState()
is MapScreenEvent.SelectRoute -> state = InternalState(route = event.id)
is MapScreenEvent.SelectRun -> state = state.copy(run = event.ref, stop = null)
is MapScreenEvent.SelectStop -> state = state.copy(stop = event.typeIdPair, run = null)
is MapScreenEvent.SearchUpdate -> searchUpdate(event.text)
}
}
}
fun bindTracker(locationTracker: LocationTracker) {
locationTrackerJob = locationTracker.getLocationsFlow()
.onEach { lastKnownLocation = Point(it.latitude, it.longitude) }
.launchIn(viewModelScope)
}
fun centreCameraToLocation() {
viewModelScope.launch {
log("msvm", "getting..")
val routes = routeRepository.getAll()
log("msvm", routes.joinToString("\n"))
}
lastKnownLocation?.let { location ->
viewModelScope.launch {
log("bvm", "emitting $location")
iCameraChangeEmitter.emit(CameraPosition(location).box())
}
}
}
fun setLastKnownLocation(location: Point) {
lastKnownLocation = location
}
private fun dismissState() {
state = InternalState()
viewModelScope.launch { searchUpdate("") }
}
private suspend fun searchUpdate(text: String) {
iSearchState.update { it.copy(text = text) }
val entries = routeRepository.getAll()
.sortedWith(
compareBy(
{ it.type.ordinal },
{ it.number },
{ it.name }
)
)
.filter { (it.number ?: "").contains(text) || it.name.lowercase().contains(text.lowercase()) }
.map { route ->
val (main, sub) = if (route.number?.isNotEmpty() == true) {
route.number to route.name
} else {
route.name to null
}
SearchState.SearchEntry(main!!, sub, route.id, route.type)
}
iSearchState.update { SearchState(entries, text) }
}
private suspend fun switchRoute(routeId: String?) {
iMapState.update { MapState() }
if (routeId == null) {
iInfoState.update { InfoPanelState.None }
return
}
val route = routeRepository.get(routeId)
// val gtfsRoute = ptvService.route(routeId)
iInfoState.update {
InfoPanelState.Route(
name = route.name,
type = route.type,
)
}
// viewModelScope.launch { buildPolylines(gtfsRoute) }
viewModelScope.launch { buildStops(route) }
// buildRuns(gtfsRoute)
}
private fun switchRun(ref: String?) {
if (ref == null) {
iInfoState.update { InfoPanelState.None }
return
}
val lastState = state.run
var routeName: String? = null
ptvService.runFlow(ref, firstWithCache = true)
.waitUntilSubscribed(iInfoState)
.takeWhile { lastState == state.run }
.onEach { run ->
if (routeName == null) {
iInfoState.update {
InfoPanelState.Run(
direction = run.destinationName,
type = RouteType.MetroTrain, // XXX HACK TODO FIXME
)
}
routeName = ptvService.route(run.routeId).routeName
}
iInfoState.update {
InfoPanelState.Run(
direction = run.destinationName,
type = RouteType.MetroTrain, // FIXME HACK XXX TODO
routeName = routeName,
)
}
}
.launchIn(viewModelScope)
}
// [TODO]: Cleanup
private suspend fun switchStop(pair: Pair<RouteType, String>?) {
if (pair == null) {
iInfoState.update { InfoPanelState.None }
return
}
val (type, id) = pair
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)
iInfoState.update {
InfoPanelState.Stop(
id = stop.id,
name = name,
subname = subname,
)
}
val res = ptvService.departures(type, stop.id)
// Map<
// Pair<DirectionId, RouteId>,
// Pair<DirectionName, List<DepartureTimes>>
// >
val timetable = HashMap<Pair<Int, Int>, Pair<String, MutableList<String>>>()
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
val date = Instant.parse(dep.estimatedDepartureUtc ?: dep.scheduledDepartureUtc)
val min = (date - Clock.System.now()).inWholeMinutes
if (min <= -5)
return@forEach
if (min >= 65)
element.add("${((min + 30.0) / 60.0).toInt()}hr")
else
element.add("${min}mn")
}
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(" | "))
}
iInfoState.update {
if (it !is InfoPanelState.Stop)
it
else
it.copy(departures = departures)
}
}
private suspend fun buildPolylines(route: PtvRoute) {
val routeWithGeo = if (route.geopath.isEmpty())
ptvService.route(route.routeId, true)
else
route
val colour = routeWithGeo.routeType.getUIProperties().colour
val polylines = mutableListOf<Polyline>()
val allPoints = mutableListOf<Point>()
routeWithGeo.geopath.forEach { pp ->
// TODO: use gtfs colours
pp.paths.forEach { sp ->
val polyline = sp.replace(", ", ",")
.split(" ")
.map { coord ->
val s = coord.split(",")
val point = Point(s[0].toDouble(), s[1].toDouble())
allPoints.add(point)
point
}
polylines.add(Polyline(polyline, colour))
}
}
val newCameraPosition = if (allPoints.isNotEmpty())
CameraPosition(bounds = buildBounds(allPoints))
else
null
iMapState.update { it.copy(polylines = polylines) }
newCameraPosition?.let { iCameraChangeEmitter.emit(it.box()) }
}
private fun buildRuns(route: PtvRoute) {
ptvService
.runsFlow(route.routeId)
.waitUntilSubscribed(iInfoState)
// .takeWhile { state.route == route.routeId }
.onEach { runs ->
val markers = runs
.filter { it.vehiclePosition != null }
.map { it to it.vehiclePosition!! }
.distinctBy { (_, pos) -> pos.latitude to pos.longitude }
.map { (run, pos) ->
Marker.Vehicle(
Point(pos.latitude, pos.longitude),
ref = run.runRef,
type = RouteType.MetroTrain, // HACK TODO XXX FIXME
)
}
iMapState.update { it.copy(vehicles = markers) }
}
.launchIn(viewModelScope)
}
private suspend fun buildStops(route: Route) {
val stops = stopRepository.getByRoute(route.id)
val colour = route.type.getUIProperties().colour
val markers = stops
.map { stop ->
Marker.Stop(
point = stop.pos,
id = stop.id,
colour = colour,
type = route.type,
)
}
iMapState.update { it.copy(stops = markers) }
}
private fun buildBounds(points: List<Point>): CameraPositionBounds {
var north = -Double.MAX_VALUE
var south = Double.MAX_VALUE
var east = -Double.MAX_VALUE
var west = Double.MAX_VALUE
points.forEach {
if (it.lat > north)
north = it.lat
if (it.lat < south)
south = it.lat
if (it.lng > east)
east = it.lng
if (it.lng < west)
west = it.lng
}
return CameraPositionBounds(Point(north, east), Point(south, west))
}
}

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

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

View file

@ -0,0 +1,15 @@
package moe.lava.banksia.ui.state
import moe.lava.banksia.model.RouteType
data class SearchState(
val entries: List<SearchEntry> = listOf(),
val text: String = "",
) {
data class SearchEntry(
val mainText: String,
val subText: String?,
val routeId: String,
val routeType: RouteType,
)
}

View file

@ -0,0 +1,8 @@
package moe.lava.banksia.ui.utils.map
import moe.lava.banksia.util.Point
data class CameraPosition(
val centre: Point = Point(-37.8136, 144.9631),
val bounds: CameraPositionBounds? = null,
)

View file

@ -0,0 +1,5 @@
package moe.lava.banksia.ui.utils.map
import moe.lava.banksia.util.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

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

View file

@ -0,0 +1,5 @@
package moe.lava.banksia.ui
import androidx.compose.ui.window.ComposeUIViewController
fun MainViewController() = ComposeUIViewController { App() }

View file

@ -0,0 +1,15 @@
package moe.lava.banksia.ui.platform
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
@Composable
actual fun BanksiaTheme.colors(
darkTheme: Boolean,
dynamicColor: Boolean
): ColorScheme = when {
darkTheme -> darkColorScheme()
else -> lightColorScheme()
}