refactor(ui): split into shared, maps, and main modules

This commit is contained in:
Cilly Leang 2026-03-26 02:55:46 +11:00
parent aab03ced07
commit a79c95829e
Signed by: cilly
GPG key ID: 6500251E087653C9
43 changed files with 539 additions and 378 deletions

View file

@ -13,6 +13,10 @@ kotlin {
} }
} }
compilerOptions {
freeCompilerArgs.add("-Xexplicit-backing-fields")
}
dependencies { dependencies {
implementation(projects.ui) implementation(projects.ui)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)

View file

@ -20,7 +20,6 @@ kotlin {
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime") freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
} }
iosX64()
iosArm64() iosArm64()
iosSimulatorArm64() iosSimulatorArm64()

View file

@ -0,0 +1,44 @@
package moe.lava.banksia.client.data.stoptime
import moe.lava.banksia.data.ptv.PtvService
import moe.lava.banksia.model.RouteType
import moe.lava.banksia.model.StopTime
class StopTimePtvDataSource(
private val ptvService: PtvService,
) {
suspend fun getForStop(type: RouteType, stopId: String): List<StopTime> {
return listOf()
// val res = ptvService.departures(type, stopId)
// // 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(" | "))
// }
}
}

View file

@ -12,8 +12,10 @@ import moe.lava.banksia.client.data.route.RouteLocalDataSource
import moe.lava.banksia.client.data.route.RouteRemoteDataSource import moe.lava.banksia.client.data.route.RouteRemoteDataSource
import moe.lava.banksia.client.data.stop.StopLocalDataSource import moe.lava.banksia.client.data.stop.StopLocalDataSource
import moe.lava.banksia.client.data.stop.StopRemoteDataSource import moe.lava.banksia.client.data.stop.StopRemoteDataSource
import moe.lava.banksia.client.data.stoptime.StopTimePtvDataSource
import moe.lava.banksia.client.repository.RouteRepository import moe.lava.banksia.client.repository.RouteRepository
import moe.lava.banksia.client.repository.StopRepository import moe.lava.banksia.client.repository.StopRepository
import moe.lava.banksia.client.repository.StopTimeRepository
import moe.lava.banksia.data.ptv.PtvService import moe.lava.banksia.data.ptv.PtvService
import moe.lava.banksia.util.log import moe.lava.banksia.util.log
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
@ -46,8 +48,10 @@ val ClientModule = module {
singleOf(::RouteRemoteDataSource) singleOf(::RouteRemoteDataSource)
singleOf(::StopLocalDataSource) singleOf(::StopLocalDataSource)
singleOf(::StopRemoteDataSource) singleOf(::StopRemoteDataSource)
singleOf(::StopTimePtvDataSource)
// Repositories // Repositories
singleOf(::RouteRepository) singleOf(::RouteRepository)
singleOf(::StopRepository) singleOf(::StopRepository)
singleOf(::StopTimeRepository)
} }

View file

@ -0,0 +1,13 @@
package moe.lava.banksia.client.repository
import moe.lava.banksia.client.data.stoptime.StopTimePtvDataSource
import moe.lava.banksia.model.StopTime
class StopTimeRepository(
private val ptv: StopTimePtvDataSource,
) {
// TODO
suspend fun getForStop(id: String): List<StopTime> {
return listOf()
}
}

View file

@ -36,3 +36,5 @@ include(":client")
include(":server") include(":server")
include(":shared") include(":shared")
include(":ui") include(":ui")
include(":ui:maps")
include(":ui:shared")

View file

@ -27,7 +27,6 @@ kotlin {
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime") freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
} }
iosX64()
iosArm64() iosArm64()
iosSimulatorArm64() iosSimulatorArm64()
@ -59,7 +58,6 @@ kotlin {
dependencies { dependencies {
add("kspAndroid", libs.room.compiler) add("kspAndroid", libs.room.compiler)
add("kspIosX64", libs.room.compiler)
add("kspIosArm64", libs.room.compiler) add("kspIosArm64", libs.room.compiler)
add("kspIosSimulatorArm64", libs.room.compiler) add("kspIosSimulatorArm64", libs.room.compiler)
add("kspJvm", libs.room.compiler) add("kspJvm", libs.room.compiler)

View file

@ -1,5 +1,6 @@
package moe.lava.banksia.util package moe.lava.banksia.util
/** Wraps an arbitrary value, such that equality checks are forced to be done by reference */
class BoxedValue<T>(val value: T) { class BoxedValue<T>(val value: T) {
operator fun component1() = value operator fun component1() = value

View file

@ -25,6 +25,7 @@ kotlin {
compilerOptions { compilerOptions {
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime") freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
freeCompilerArgs.add("-Xexplicit-backing-fields")
} }
listOf( listOf(
@ -68,6 +69,8 @@ kotlin {
implementation(projects.client) implementation(projects.client)
implementation(projects.shared) implementation(projects.shared)
implementation(projects.ui.maps)
implementation(projects.ui.shared)
} }
} }
} }
@ -79,8 +82,3 @@ dependencies {
secrets { secrets {
propertiesFileName = "secrets.properties" propertiesFileName = "secrets.properties"
} }
compose.resources {
publicResClass = true
packageOfResClass = "moe.lava.banksia.resources"
}

56
ui/maps/build.gradle.kts Normal file
View file

@ -0,0 +1,56 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
kotlin {
android {
namespace = "moe.lava.banksia.ui.map"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
freeCompilerArgs.add("-Xexplicit-backing-fields")
}
iosArm64()
iosSimulatorArm64()
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.koin.core)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.maplibre.compose)
implementation(libs.moko.geo)
implementation(libs.moko.geo.compose)
implementation(libs.compose.components.resources)
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.ui)
implementation(projects.shared)
implementation(projects.ui.shared)
}
}
}

View file

@ -0,0 +1,81 @@
package moe.lava.banksia.ui.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.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.serialization.json.JsonObject
import moe.lava.banksia.ui.map.mappers.routeColorExpression
import moe.lava.banksia.ui.platform.BanksiaTheme
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.expressions.dsl.const
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.Feature
import org.maplibre.spatialk.geojson.Geometry
@Composable
internal fun MapLibreMaps(
modifier: Modifier,
insets: WindowInsets,
positionState: MapsPositionState,
stops: GeoJsonData.Features?,
// vehicles: GeoJsonData.Features?,
stopInnerColor: Color,
onStopClicked: (Feature<Geometry, JsonObject?>) -> Unit,
) {
val camPos = rememberCameraState(
CameraPosition(
zoom = 16.0,
target = MELBOURNE_POS
)
)
MaplibreMap(
modifier = modifier,
baseStyle = BaseStyle.Uri("https://tiles.openfreemap.org/styles/positron"),
cameraState = camPos,
options = MapOptions(
ornamentOptions = OrnamentOptions(
padding = WindowInsets.safeDrawing.add(insets).asPaddingValues(),
isScaleBarEnabled = false,
isAttributionEnabled = false,
)
)
) {
if (stops != null) {
val stopsSource = rememberGeoJsonSource(stops)
CircleLayer(
id = "maps-stops0",
source = stopsSource,
color = const(BanksiaTheme.colors.surface),
radius = const(3.dp),
strokeWidth = const(2.dp),
strokeColor = routeColorExpression,
)
CircleLayer(
id = "maps-stops0-clickhandler",
source = stopsSource,
color = const(Color.Transparent),
radius = const(12.dp),
onClick = { features ->
// onEvent(MapScreenEvent.SelectStop(marker.type to feature.id!!.content))
// val marker = Json.decodeFromJsonElement<T>(feature.properties!!)
onStopClicked(features[0])
ClickResult.Consume
}
)
}
}
}

View file

@ -0,0 +1,37 @@
package moe.lava.banksia.ui.map
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import moe.lava.banksia.ui.map.mappers.asFeatures
import moe.lava.banksia.ui.map.mappers.toPosition
import moe.lava.banksia.ui.map.util.Marker
import moe.lava.banksia.ui.platform.BanksiaTheme
import moe.lava.banksia.util.Point
internal val MELBOURNE = Point(-37.8136, 144.9631)
internal val MELBOURNE_POS = MELBOURNE.toPosition()
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Maps(
modifier: Modifier = Modifier,
insets: WindowInsets = WindowInsets(),
stops: List<Marker.Stop> = listOf(),
// vehicles: List<Marker.Vehicle> = listOf(),
positionState: MapsPositionState = rememberMapsPositionState(),
onStopClicked: (id: String) -> Unit = {},
// onVehicleClicked: (id: String) -> Unit = {},
) {
MapLibreMaps(
modifier = modifier,
insets = insets,
positionState = positionState,
stops = stops.takeIf { it.isNotEmpty() }?.asFeatures(),
// vehicles = vehicles.takeIf { it.isNotEmpty() }?.asFeatures(),
stopInnerColor = BanksiaTheme.colors.surface,
onStopClicked = { feature -> onStopClicked(feature.id!!.content) },
// onVehicleClicked = {},
)
}

View file

@ -0,0 +1,27 @@
package moe.lava.banksia.ui.map
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
import moe.lava.banksia.util.Point
class MapsPositionState internal constructor(
private val scope: CoroutineScope
) {
internal val updates: SharedFlow<Point>
field = MutableSharedFlow()
fun update(position: Point) {
scope.launch { updates.emit(position) }
}
}
@Composable
fun rememberMapsPositionState(): MapsPositionState {
val scope = rememberCoroutineScope()
return remember { MapsPositionState(scope) }
}

View file

@ -0,0 +1,40 @@
package moe.lava.banksia.ui.map.mappers
import kotlinx.serialization.Serializable
import moe.lava.banksia.model.RouteType
import moe.lava.banksia.ui.map.util.Marker
import org.maplibre.compose.sources.GeoJsonData
import org.maplibre.spatialk.geojson.FeatureCollection
import org.maplibre.spatialk.geojson.dsl.addFeature
import org.maplibre.spatialk.geojson.dsl.buildFeatureCollection
import org.maplibre.spatialk.geojson.Point as MLPoint
@Serializable
data class MarkerProps(
val type: RouteType,
)
@Suppress("NOTHING_TO_INLINE")
internal inline fun Iterable<Marker>.asFeatures() = GeoJsonData.Features(asFeatureCollection())
internal fun Iterable<Marker>.asFeatureCollection(): FeatureCollection<MLPoint, MarkerProps> {
val markers = this
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.toPosition()),
properties = MarkerProps(type),
) {
setId(id)
}
}
}
}

View file

@ -0,0 +1,6 @@
package moe.lava.banksia.ui.map.mappers
import moe.lava.banksia.util.Point
import org.maplibre.spatialk.geojson.Position
internal fun Point.toPosition() = Position(lng, lat)

View file

@ -0,0 +1,19 @@
package moe.lava.banksia.ui.map.mappers
import androidx.compose.runtime.Composable
import moe.lava.banksia.model.RouteType
import moe.lava.banksia.ui.extensions.getUIProperties
import moe.lava.banksia.ui.platform.BanksiaTheme
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
internal val routeColorExpression @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),
)

View file

@ -1,4 +1,4 @@
package moe.lava.banksia.ui.utils.map package moe.lava.banksia.ui.map.util
import moe.lava.banksia.util.Point import moe.lava.banksia.util.Point

View file

@ -1,4 +1,4 @@
package moe.lava.banksia.ui.utils.map package moe.lava.banksia.ui.map.util
import moe.lava.banksia.util.Point import moe.lava.banksia.util.Point

View file

@ -0,0 +1,28 @@
package moe.lava.banksia.ui.map.util
import kotlinx.serialization.Serializable
import moe.lava.banksia.model.RouteType
import moe.lava.banksia.util.Point
@Serializable
sealed class Marker {
abstract val point: Point
sealed class Typed : Marker() {
abstract val type: RouteType
}
@Serializable
data class Stop(
override val point: Point,
override val type: RouteType,
val id: String,
) : Typed()
@Serializable
data class Vehicle(
override val point: Point,
override val type: RouteType,
val ref: String,
) : Typed()
}

View file

@ -1,4 +1,4 @@
package moe.lava.banksia.ui.utils.map package moe.lava.banksia.ui.map.util
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import moe.lava.banksia.util.Point import moe.lava.banksia.util.Point

View file

@ -0,0 +1,50 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
kotlin {
android {
namespace = "moe.lava.banksia.ui.shared"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
freeCompilerArgs.add("-Xexplicit-backing-fields")
}
iosArm64()
iosSimulatorArm64()
sourceSets {
commonMain.dependencies {
implementation(libs.compose.components.resources)
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(projects.shared)
}
}
}
dependencies {
androidRuntimeClasspath(libs.compose.ui.tooling)
}
compose.resources {
publicResClass = true
packageOfResClass = "moe.lava.banksia.resources"
}

View file

@ -0,0 +1,52 @@
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.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import moe.lava.banksia.model.RouteType
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.ui.extensions.getUIProperties
import org.jetbrains.compose.resources.painterResource
@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
internal fun RouteIconPreview() {
Row {
RouteIcon(routeType = MetroTrain)
RouteIcon(routeType = MetroTram)
RouteIcon(routeType = MetroBus)
}
}

View file

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

View file

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

@ -38,14 +38,12 @@ import moe.lava.banksia.ui.layout.AppBottomSheet
import moe.lava.banksia.ui.layout.InfoPanel import moe.lava.banksia.ui.layout.InfoPanel
import moe.lava.banksia.ui.layout.Searcher import moe.lava.banksia.ui.layout.Searcher
import moe.lava.banksia.ui.layout.SheetStateWrapper import moe.lava.banksia.ui.layout.SheetStateWrapper
import moe.lava.banksia.ui.map.Maps
import moe.lava.banksia.ui.platform.BanksiaTheme import moe.lava.banksia.ui.platform.BanksiaTheme
import moe.lava.banksia.ui.state.InfoPanelState import moe.lava.banksia.ui.state.InfoPanelState
import moe.lava.banksia.util.Point
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
val MELBOURNE = Point(-37.8136, 144.9631)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable @Composable
fun MapScreen( fun MapScreen(
@ -78,14 +76,20 @@ fun MapScreen(
Scaffold { Scaffold {
Maps( Maps(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
state = mapState, insets = WindowInsets(top = with(LocalDensity.current) {
onEvent = viewModel::handleEvent,
cameraPositionFlow = viewModel.cameraChangeEmitter,
extInsets = WindowInsets(top = with(LocalDensity.current) {
SearchBarDefaults.InputFieldHeight.roundToPx() SearchBarDefaults.InputFieldHeight.roundToPx()
}, bottom = sheetState.bottomInset), }, bottom = sheetState.bottomInset),
setLastKnownLocation = viewModel::setLastKnownLocation, stops = mapState.stops,
// vehicles = mapState.vehicles,
onStopClicked = { stop ->
viewModel.handleEvent(MapScreenEvent.SelectStop(stop))
},
// onEvent = viewModel::handleEvent,
// cameraPositionFlow = viewModel.cameraChangeEmitter,
// setLastKnownLocation = viewModel::setLastKnownLocation,
) )
// onEvent()
Searcher( Searcher(
state = searchState, state = searchState,
onEvent = viewModel::handleEvent, onEvent = viewModel::handleEvent,

View file

@ -15,39 +15,35 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import moe.lava.banksia.client.repository.RouteRepository import moe.lava.banksia.client.repository.RouteRepository
import moe.lava.banksia.client.repository.StopRepository import moe.lava.banksia.client.repository.StopRepository
import moe.lava.banksia.client.repository.StopTimeRepository
import moe.lava.banksia.data.ptv.PtvService 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.Route
import moe.lava.banksia.model.RouteType import moe.lava.banksia.model.RouteType
import moe.lava.banksia.ui.components.getUIProperties import moe.lava.banksia.ui.map.util.CameraPosition
import moe.lava.banksia.ui.map.util.CameraPositionBounds
import moe.lava.banksia.ui.map.util.Marker
import moe.lava.banksia.ui.state.InfoPanelState import moe.lava.banksia.ui.state.InfoPanelState
import moe.lava.banksia.ui.state.MapState import moe.lava.banksia.ui.state.MapState
import moe.lava.banksia.ui.state.SearchState import moe.lava.banksia.ui.state.SearchState
import moe.lava.banksia.ui.utils.map.CameraPosition
import moe.lava.banksia.ui.utils.map.CameraPositionBounds
import moe.lava.banksia.ui.utils.map.Marker
import moe.lava.banksia.ui.utils.map.Polyline
import moe.lava.banksia.util.BoxedValue import moe.lava.banksia.util.BoxedValue
import moe.lava.banksia.util.BoxedValue.Companion.box import moe.lava.banksia.util.BoxedValue.Companion.box
import moe.lava.banksia.util.LoopFlow.Companion.waitUntilSubscribed import moe.lava.banksia.util.LoopFlow.Companion.waitUntilSubscribed
import moe.lava.banksia.util.Point import moe.lava.banksia.util.Point
import moe.lava.banksia.util.log import moe.lava.banksia.util.log
import kotlin.time.Clock
import kotlin.time.Instant
sealed class MapScreenEvent { sealed class MapScreenEvent {
data object DismissState : MapScreenEvent() data object DismissState : MapScreenEvent()
data class SelectRoute(val id: String?) : MapScreenEvent() data class SelectRoute(val id: String?) : MapScreenEvent()
data class SelectRun(val ref: String?) : MapScreenEvent() data class SelectRun(val ref: String?) : MapScreenEvent()
data class SelectStop(val typeIdPair: Pair<RouteType, String>?) : MapScreenEvent() data class SelectStop(val id: String?) : MapScreenEvent()
data class SearchUpdate(val text: String) : MapScreenEvent() data class SearchUpdate(val text: String) : MapScreenEvent()
} }
data class InternalState( data class InternalState(
val route: String? = null, val route: String? = null,
val stop: Pair<RouteType, String>? = null, val stop: String? = null,
val run: String? = null, val run: String? = null,
) )
@ -55,6 +51,7 @@ class MapScreenViewModel(
private val ptvService: PtvService, private val ptvService: PtvService,
private val routeRepository: RouteRepository, private val routeRepository: RouteRepository,
private val stopRepository: StopRepository, private val stopRepository: StopRepository,
private val stopTimeRepository: StopTimeRepository,
) : ViewModel() { ) : ViewModel() {
private var state = InternalState() private var state = InternalState()
set(value) { set(value) {
@ -92,7 +89,7 @@ class MapScreenViewModel(
is MapScreenEvent.DismissState -> dismissState() is MapScreenEvent.DismissState -> dismissState()
is MapScreenEvent.SelectRoute -> state = InternalState(route = event.id) is MapScreenEvent.SelectRoute -> state = InternalState(route = event.id)
is MapScreenEvent.SelectRun -> state = state.copy(run = event.ref, stop = null) is MapScreenEvent.SelectRun -> state = state.copy(run = event.ref, stop = null)
is MapScreenEvent.SelectStop -> state = state.copy(stop = event.typeIdPair, run = null) is MapScreenEvent.SelectStop -> state = state.copy(stop = event.id, run = null)
is MapScreenEvent.SearchUpdate -> searchUpdate(event.text) is MapScreenEvent.SearchUpdate -> searchUpdate(event.text)
} }
} }
@ -206,12 +203,11 @@ class MapScreenViewModel(
} }
// [TODO]: Cleanup // [TODO]: Cleanup
private suspend fun switchStop(pair: Pair<RouteType, String>?) { private suspend fun switchStop(id: String?) {
if (pair == null) { if (id == null) {
iInfoState.update { InfoPanelState.None } iInfoState.update { InfoPanelState.None }
return return
} }
val (type, id) = pair
val stop = stopRepository.get(id) val stop = stopRepository.get(id)
// val stop = ptvService.stop(routeType, stopId) // val stop = ptvService.stop(routeType, stopId)
@ -226,35 +222,29 @@ class MapScreenViewModel(
) )
} }
val res = ptvService.departures(type, stop.id) val departures = stopTimeRepository.getForStop(id)
// Map< .filter { it.headsign != null }
// Pair<DirectionId, RouteId>, .groupBy { it.headsign!! }
// Pair<DirectionName, List<DepartureTimes>> .map { (headsign, stopTimes) ->
// > InfoPanelState.Stop.Departure(headsign, "...")
val timetable = HashMap<Pair<Int, Int>, Pair<String, MutableList<String>>>() // TODO
res.departures.forEach { dep -> // val tmsF = stopTimes.map { time ->
val key = Pair(dep.directionId, dep.routeId) // val key = Pair(dep.directionId, dep.routeId)
val direction = ptvService.direction(dep.directionId, dep.routeId) // val direction = ptvService.direction(dep.directionId, dep.routeId)
val route = res.routes[dep.routeId.toString()] // val route = res.routes[dep.routeId.toString()]
val prefix = route?.let { if (it.routeNumber == "") "" else "${it.routeNumber} - " } ?: "" // val prefix = route?.let { if (it.routeNumber == "") "" else "${it.routeNumber} - " } ?: ""
val element = timetable.getOrPut(key) { Pair(prefix + direction.directionName, mutableListOf()) }.second // val element = timetable.getOrPut(key) { Pair(prefix + direction.directionName, mutableListOf()) }.second
if (element.size >= 5) // if (element.size >= 5)
return@forEach // return@forEach
//
val date = Instant.parse(dep.estimatedDepartureUtc ?: dep.scheduledDepartureUtc) // val min = (time.departureTime.time - Clock.System.now()).inWholeMinutes
val min = (date - Clock.System.now()).inWholeMinutes // if (min <= -5)
if (min <= -5) // return@forEach
return@forEach // if (min >= 65)
if (min >= 65) // element.add("${((min + 30.0) / 60.0).toInt()}hr")
element.add("${((min + 30.0) / 60.0).toInt()}hr") // else
else // element.add("${min}mn")
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 { iInfoState.update {
if (it !is InfoPanelState.Stop) if (it !is InfoPanelState.Stop)
@ -264,7 +254,7 @@ class MapScreenViewModel(
} }
} }
private suspend fun buildPolylines(route: PtvRoute) { /*private suspend fun buildPolylines(route: PtvRoute) {
val routeWithGeo = if (route.geopath.isEmpty()) val routeWithGeo = if (route.geopath.isEmpty())
ptvService.route(route.routeId, true) ptvService.route(route.routeId, true)
else else
@ -294,9 +284,9 @@ class MapScreenViewModel(
iMapState.update { it.copy(polylines = polylines) } iMapState.update { it.copy(polylines = polylines) }
newCameraPosition?.let { iCameraChangeEmitter.emit(it.box()) } newCameraPosition?.let { iCameraChangeEmitter.emit(it.box()) }
} }*/
private fun buildRuns(route: PtvRoute) { /*private fun buildRuns(route: PtvRoute) {
ptvService ptvService
.runsFlow(route.routeId) .runsFlow(route.routeId)
.waitUntilSubscribed(iInfoState) .waitUntilSubscribed(iInfoState)
@ -317,19 +307,16 @@ class MapScreenViewModel(
iMapState.update { it.copy(vehicles = markers) } iMapState.update { it.copy(vehicles = markers) }
} }
.launchIn(viewModelScope) .launchIn(viewModelScope)
}*/
}
private suspend fun buildStops(route: Route) { private suspend fun buildStops(route: Route) {
val stops = stopRepository.getByRoute(route.id) val stops = stopRepository.getByRoute(route.id)
val colour = route.type.getUIProperties().colour
val markers = stops val markers = stops
.map { stop -> .map { stop ->
Marker.Stop( Marker.Stop(
point = stop.pos, point = stop.pos,
id = stop.id, id = stop.id,
colour = colour,
type = route.type, type = route.type,
) )
} }

View file

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

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

View file

@ -1,22 +0,0 @@
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()
}