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
😭
112
ui/build.gradle.kts
Normal 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"
|
||||
}
|
||||
30
ui/src/androidMain/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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>
|
||||
170
ui/src/androidMain/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||
5
ui/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
BIN
ui/src/androidMain/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
ui/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
ui/src/androidMain/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
ui/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
ui/src/androidMain/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
ui/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
ui/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
ui/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
ui/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
ui/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
37
ui/src/androidMain/res/raw/def_mapstyle.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
3
ui/src/androidMain/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">Banksia</string>
|
||||
</resources>
|
||||
14
ui/src/commonMain/composeResources/drawable/bus.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
5
ui/src/commonMain/composeResources/drawable/bus_icon.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
14
ui/src/commonMain/composeResources/drawable/train.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
14
ui/src/commonMain/composeResources/drawable/tram.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
21
ui/src/commonMain/kotlin/moe/lava/banksia/ui/App.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
12
ui/src/commonMain/kotlin/moe/lava/banksia/ui/di/AppModule.kt
Normal 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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
// }
|
||||
}
|
||||
}
|
||||
177
ui/src/commonMain/kotlin/moe/lava/banksia/ui/layout/InfoPanel.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
104
ui/src/commonMain/kotlin/moe/lava/banksia/ui/layout/Searcher.kt
Normal 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))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
210
ui/src/commonMain/kotlin/moe/lava/banksia/ui/screens/map/Maps.kt
Normal 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,
|
||||
// )
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package moe.lava.banksia.ui
|
||||
|
||||
import androidx.compose.ui.window.ComposeUIViewController
|
||||
|
||||
fun MainViewController() = ComposeUIViewController { App() }
|
||||
|
|
@ -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()
|
||||
}
|
||||