feat: server-handled routes and stops

This commit is contained in:
Cilly Leang 2025-08-12 22:43:33 +10:00
parent efba64ea90
commit 58ee095522
Signed by: cilly
GPG key ID: 6500251E087653C9
61 changed files with 1634 additions and 349 deletions

View file

@ -41,6 +41,7 @@ kotlin {
implementation(libs.play.services.location)
implementation(libs.play.services.maps)
implementation(libs.maps.compose)
implementation(libs.maps.compose.utils)
}
commonMain.dependencies {
implementation(compose.runtime)
@ -53,8 +54,13 @@ kotlin {
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.moko.geo)
implementation(libs.moko.geo.compose)
implementation(projects.shared)

View file

@ -11,6 +11,7 @@
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"

View file

@ -41,9 +41,9 @@ import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberUpdatedMarkerState
import kotlinx.coroutines.flow.Flow
import moe.lava.banksia.R
import moe.lava.banksia.ui.BanksiaEvent
import moe.lava.banksia.ui.components.RouteIcon
import moe.lava.banksia.ui.platform.BanksiaTheme
import moe.lava.banksia.ui.screens.MapScreenEvent
import moe.lava.banksia.ui.state.MapState
import moe.lava.banksia.util.BoxedValue
import moe.lava.banksia.util.Point
@ -67,7 +67,7 @@ actual fun getScreenHeight(): Int {
actual fun Maps(
modifier: Modifier,
state: MapState,
onEvent: (BanksiaEvent) -> Unit,
onEvent: (MapScreenEvent) -> Unit,
cameraPositionFlow: Flow<BoxedValue<CameraPosition>>,
setLastKnownLocation: (Point) -> Unit,
extInsets: WindowInsets,
@ -135,7 +135,7 @@ actual fun Maps(
zIndex = 0f,
state = state,
onClick = {
onEvent(BanksiaEvent.SelectStop(marker.type to marker.id))
onEvent(MapScreenEvent.SelectStop(marker.type to marker.id))
false
}
) {
@ -155,7 +155,7 @@ actual fun Maps(
zIndex = 1f,
state = state,
onClick = {
onEvent(BanksiaEvent.SelectRun(marker.ref))
onEvent(MapScreenEvent.SelectRun(marker.ref))
false
}
) {

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,49 @@
package moe.lava.banksia.client.di
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import moe.lava.banksia.Constants
import moe.lava.banksia.client.datasource.local.RouteLocalDataSource
import moe.lava.banksia.client.datasource.local.StopLocalDataSource
import moe.lava.banksia.client.datasource.remote.RouteRemoteDataSource
import moe.lava.banksia.client.datasource.remote.StopRemoteDataSource
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.ui.screens.MapScreenViewModel
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val ClientModule = module {
// HTTP Clients
singleOf(::PtvService)
single {
HttpClient() {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
})
}
defaultRequest {
url(Constants.serverUrl)
}
}
}
// Data sources
singleOf(::RouteLocalDataSource)
singleOf(::RouteRemoteDataSource)
singleOf(::StopLocalDataSource)
singleOf(::StopRemoteDataSource)
// Repositories
singleOf(::RouteRepository)
singleOf(::StopRepository)
// ViewModel
viewModelOf(::MapScreenViewModel)
}

View file

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

View file

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

View file

@ -1,194 +1,21 @@
package moe.lava.banksia.ui
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContent
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.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.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
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.InfoPanel
import moe.lava.banksia.ui.layout.Searcher
import moe.lava.banksia.ui.platform.BanksiaTheme
import moe.lava.banksia.ui.platform.maps.Maps
import moe.lava.banksia.ui.platform.maps.getScreenHeight
import moe.lava.banksia.ui.state.InfoPanelState
import moe.lava.banksia.util.Point
import org.jetbrains.compose.resources.painterResource
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.roundToInt
import moe.lava.banksia.client.di.ClientModule
import moe.lava.banksia.di.CommonModules
import moe.lava.banksia.ui.screens.MapScreen
import org.koin.compose.KoinMultiplatformApplication
import org.koin.core.annotation.KoinExperimentalAPI
import org.koin.dsl.koinConfiguration
val MELBOURNE = Point(-37.8136, 144.9631)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class, KoinExperimentalAPI::class)
@Composable
fun App(
viewModel: BanksiaViewModel = viewModel()
) {
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 scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(
initialValue = SheetValue.Hidden,
skipHiddenState = false
)
)
val sheetState = scaffoldState.bottomSheetState
val extInsets = if (
sheetState.currentValue != SheetValue.Hidden ||
sheetState.targetValue != SheetValue.Hidden
) {
val offset = runCatching { sheetState.requireOffset() }
val scaffoldOffset = offset.getOrDefault(0.0f).roundToInt()
(getScreenHeight() - scaffoldOffset - WindowInsets.Companion.safeDrawing.getBottom(
LocalDensity.current)).coerceAtLeast(0)
} else 0
LaunchedEffect(infoState) {
if (infoState !is InfoPanelState.None)
scope.launch { scaffoldState.bottomSheetState.partialExpand() }
else
scope.launch { scaffoldState.bottomSheetState.hide() }
}
var searchExpandedState by rememberSaveable { mutableStateOf(false) }
var sheetSwipeEnabled by rememberSaveable { mutableStateOf(true) }
var handleHeight by remember { mutableStateOf(0.dp) }
var peekHeight by remember { mutableStateOf(0.dp) }
var peekHeightMultiplier by remember { mutableFloatStateOf(1F) }
BanksiaTheme {
BottomSheetScaffold(
scaffoldState = scaffoldState,
sheetPeekHeight = (handleHeight + peekHeight) * peekHeightMultiplier,
modifier = Modifier.Companion.fillMaxSize(),
sheetContent = {
InfoPanel(
state = infoState,
onEvent = viewModel::handleEvent,
onPeekHeightChange = { peekHeight = it },
)
},
sheetDragHandle = {
val density = LocalDensity.current
Box(
Modifier.Companion
.fillMaxWidth()
.padding(horizontal = 10.dp)
.onSizeChanged {
handleHeight = with(density) { it.height.toDp() }
}
) {
BottomSheetDefaults.DragHandle(modifier = Modifier.Companion.align(Alignment.Companion.Center))
}
},
sheetSwipeEnabled = sheetSwipeEnabled,
) {
Maps(
modifier = Modifier.Companion.fillMaxSize(),
state = mapState,
onEvent = viewModel::handleEvent,
cameraPositionFlow = viewModel.cameraChangeEmitter,
extInsets = WindowInsets(top = with(LocalDensity.current) {
SearchBarDefaults.InputFieldHeight.roundToPx()
}, bottom = extInsets),
setLastKnownLocation = viewModel::setLastKnownLocation,
)
Searcher(
state = searchState,
onEvent = viewModel::handleEvent,
expanded = searchExpandedState,
onExpandedChange = {
searchExpandedState = it
if (it)
scope.launch { scaffoldState.bottomSheetState.hide() }
},
)
PredictiveBackHandler(scaffoldState.bottomSheetState.currentValue != SheetValue.Hidden) { progress ->
sheetSwipeEnabled = false
try {
progress.collect { backEvent ->
if (scaffoldState.bottomSheetState.currentValue == SheetValue.PartiallyExpanded) {
peekHeightMultiplier = 1F - backEvent.progress
}
}
if (scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded)
scope.launch { scaffoldState.bottomSheetState.partialExpand() }
else if (scaffoldState.bottomSheetState.currentValue == SheetValue.PartiallyExpanded)
scope.launch {
scaffoldState.bottomSheetState.hide()
peekHeightMultiplier = 1F
viewModel.handleEvent(BanksiaEvent.DismissState)
}
} catch (_: CancellationException) {
peekHeightMultiplier = 1F
}
sheetSwipeEnabled = true
}
Box(
Modifier.Companion.windowInsetsPadding(
WindowInsets.Companion.safeContent.add(
WindowInsets(bottom = extInsets)
)
),
contentAlignment = Alignment.Companion.BottomEnd
) {
FloatingActionButton(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
onClick = { viewModel.centreCameraToLocation() },
) {
Icon(painterResource(Res.drawable.my_location_24), "Move to current location")
}
}
}
fun App() {
KoinMultiplatformApplication(config = koinConfiguration {
modules(CommonModules, ClientModule)
}) {
MapScreen()
}
}

View file

@ -12,6 +12,15 @@ import androidx.compose.ui.graphics.Color
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
@ -33,12 +42,51 @@ data class RouteTypeProperties(
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(0xFF0072CE)
PtvRouteType.TRAM -> Color(0xFF78BE20)
PtvRouteType.BUS, PtvRouteType.NIGHT_BUS -> Color(0xFFFF8200)
PtvRouteType.VLINE -> Color(0xFF8F1A95)
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(
@ -58,7 +106,7 @@ fun PtvRouteType.getUIProperties(): RouteTypeProperties {
fun RouteIcon(
modifier: Modifier = Modifier.Companion,
size: Dp = 40.dp,
routeType: PtvRouteType,
routeType: RouteType,
) {
val properties = routeType.getUIProperties()
Image(
@ -80,9 +128,9 @@ const val ICON_PADDING = 0.25f
@Composable
private fun RouteIconPreview() {
Row {
RouteIcon(routeType = PtvRouteType.TRAIN)
RouteIcon(routeType = PtvRouteType.TRAM)
RouteIcon(routeType = PtvRouteType.BUS)
RouteIcon(routeType = RouteType.MetroTrain)
RouteIcon(routeType = RouteType.MetroTram)
RouteIcon(routeType = RouteType.MetroBus)
}
}

View file

@ -29,14 +29,14 @@ 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 moe.lava.banksia.ui.BanksiaEvent
import moe.lava.banksia.ui.components.RouteIcon
import moe.lava.banksia.ui.screens.MapScreenEvent
import moe.lava.banksia.ui.state.InfoPanelState
@Composable
fun InfoPanel(
state: InfoPanelState,
onEvent: (BanksiaEvent) -> Unit,
onEvent: (MapScreenEvent) -> Unit,
onPeekHeightChange: (Dp) -> Unit,
) {
if (state is InfoPanelState.None)
@ -74,7 +74,7 @@ fun InfoPanel(
@Composable
private inline fun RouteInfoPanel(
state: InfoPanelState.Route,
onEvent: (BanksiaEvent) -> Unit,
onEvent: (MapScreenEvent) -> Unit,
) {
Column(Modifier.Companion.fillMaxWidth()) {
Row {
@ -92,7 +92,7 @@ private inline fun RouteInfoPanel(
@Composable
private inline fun RunInfoPanel(
state: InfoPanelState.Run,
onEvent: (BanksiaEvent) -> Unit,
onEvent: (MapScreenEvent) -> Unit,
) {
Column(Modifier.Companion.fillMaxWidth()) {
Row {
@ -110,7 +110,7 @@ private inline fun RunInfoPanel(
@Composable
private inline fun StopInfoPanel(
state: InfoPanelState.Stop,
onEvent: (BanksiaEvent) -> Unit,
onEvent: (MapScreenEvent) -> Unit,
) {
Column(Modifier.Companion.fillMaxWidth()) {
Text(

View file

@ -23,15 +23,15 @@ 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.BanksiaEvent
import moe.lava.banksia.ui.components.RouteIcon
import moe.lava.banksia.ui.screens.MapScreenEvent
import moe.lava.banksia.ui.state.SearchState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Searcher(
state: SearchState,
onEvent: (BanksiaEvent) -> Unit,
onEvent: (MapScreenEvent) -> Unit,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
) {
@ -55,7 +55,7 @@ fun Searcher(
SearchBarDefaults.InputField(
modifier = Modifier.Companion.padding(horizontal = 20.dp - animatedPadding),
query = state.text,
onQueryChange = { onEvent(BanksiaEvent.SearchUpdate(it)) },
onQueryChange = { onEvent(MapScreenEvent.SearchUpdate(it)) },
onSearch = {},
expanded = expanded,
onExpandedChange = onExpandedChange,
@ -67,7 +67,7 @@ fun Searcher(
contentDescription = null,
modifier = Modifier.Companion.clickable {
onEvent(
BanksiaEvent.SearchUpdate(
MapScreenEvent.SearchUpdate(
""
)
)
@ -92,8 +92,8 @@ fun Searcher(
.padding(horizontal = 16.dp, vertical = 4.dp)
.clickable {
onExpandedChange(false)
onEvent(BanksiaEvent.SearchUpdate(""))
onEvent(BanksiaEvent.SelectRoute(entry.routeId))
onEvent(MapScreenEvent.SearchUpdate(""))
onEvent(MapScreenEvent.SelectRoute(entry.routeId))
}
)
}

View file

@ -5,7 +5,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import kotlinx.coroutines.flow.Flow
import moe.lava.banksia.ui.BanksiaEvent
import moe.lava.banksia.ui.screens.MapScreenEvent
import moe.lava.banksia.ui.state.MapState
import moe.lava.banksia.util.BoxedValue
import moe.lava.banksia.util.Point
@ -18,7 +18,7 @@ expect fun getScreenHeight(): Int
expect fun Maps(
modifier: Modifier = Modifier.Companion,
state: MapState,
onEvent: (BanksiaEvent) -> Unit,
onEvent: (MapScreenEvent) -> Unit,
cameraPositionFlow: Flow<BoxedValue<CameraPosition>>,
setLastKnownLocation: (Point) -> Unit,
extInsets: WindowInsets,

View file

@ -1,7 +1,7 @@
package moe.lava.banksia.ui.platform.maps
import androidx.compose.ui.graphics.Color
import moe.lava.banksia.data.ptv.structures.PtvRouteType
import moe.lava.banksia.model.RouteType
import moe.lava.banksia.util.Point
sealed class Marker {
@ -9,14 +9,14 @@ sealed class Marker {
data class Stop(
override val point: Point,
val id: Int,
val type: PtvRouteType,
val id: String,
val type: RouteType,
val colour: Color,
) : Marker()
data class Vehicle(
override val point: Point,
val ref: String,
val type: PtvRouteType,
val type: RouteType,
) : Marker()
}

View file

@ -0,0 +1,194 @@
package moe.lava.banksia.ui.screens
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContent
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.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.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
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.InfoPanel
import moe.lava.banksia.ui.layout.Searcher
import moe.lava.banksia.ui.platform.BanksiaTheme
import moe.lava.banksia.ui.platform.maps.Maps
import moe.lava.banksia.ui.platform.maps.getScreenHeight
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
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.roundToInt
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 scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(
initialValue = SheetValue.Hidden,
skipHiddenState = false
)
)
val sheetState = scaffoldState.bottomSheetState
val extInsets = if (
sheetState.currentValue != SheetValue.Hidden ||
sheetState.targetValue != SheetValue.Hidden
) {
val offset = runCatching { sheetState.requireOffset() }
val scaffoldOffset = offset.getOrDefault(0.0f).roundToInt()
(getScreenHeight() - scaffoldOffset - WindowInsets.Companion.safeDrawing.getBottom(
LocalDensity.current)).coerceAtLeast(0)
} else 0
LaunchedEffect(infoState) {
if (infoState !is InfoPanelState.None)
scope.launch { scaffoldState.bottomSheetState.partialExpand() }
else
scope.launch { scaffoldState.bottomSheetState.hide() }
}
var searchExpandedState by rememberSaveable { mutableStateOf(false) }
var sheetSwipeEnabled by rememberSaveable { mutableStateOf(true) }
var handleHeight by remember { mutableStateOf(0.dp) }
var peekHeight by remember { mutableStateOf(0.dp) }
var peekHeightMultiplier by remember { mutableFloatStateOf(1F) }
BanksiaTheme {
BottomSheetScaffold(
scaffoldState = scaffoldState,
sheetPeekHeight = (handleHeight + peekHeight) * peekHeightMultiplier,
modifier = Modifier.Companion.fillMaxSize(),
sheetContent = {
InfoPanel(
state = infoState,
onEvent = viewModel::handleEvent,
onPeekHeightChange = { peekHeight = it },
)
},
sheetDragHandle = {
val density = LocalDensity.current
Box(
Modifier.Companion
.fillMaxWidth()
.padding(horizontal = 10.dp)
.onSizeChanged {
handleHeight = with(density) { it.height.toDp() }
}
) {
BottomSheetDefaults.DragHandle(modifier = Modifier.Companion.align(Alignment.Companion.Center))
}
},
sheetSwipeEnabled = sheetSwipeEnabled,
) {
Maps(
modifier = Modifier.Companion.fillMaxSize(),
state = mapState,
onEvent = viewModel::handleEvent,
cameraPositionFlow = viewModel.cameraChangeEmitter,
extInsets = WindowInsets(top = with(LocalDensity.current) {
SearchBarDefaults.InputFieldHeight.roundToPx()
}, bottom = extInsets),
setLastKnownLocation = viewModel::setLastKnownLocation,
)
Searcher(
state = searchState,
onEvent = viewModel::handleEvent,
expanded = searchExpandedState,
onExpandedChange = {
searchExpandedState = it
if (it)
scope.launch { scaffoldState.bottomSheetState.hide() }
},
)
PredictiveBackHandler(scaffoldState.bottomSheetState.currentValue != SheetValue.Hidden) { progress ->
sheetSwipeEnabled = false
try {
progress.collect { backEvent ->
if (scaffoldState.bottomSheetState.currentValue == SheetValue.PartiallyExpanded) {
peekHeightMultiplier = 1F - backEvent.progress
}
}
if (scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded)
scope.launch { scaffoldState.bottomSheetState.partialExpand() }
else if (scaffoldState.bottomSheetState.currentValue == SheetValue.PartiallyExpanded)
scope.launch {
scaffoldState.bottomSheetState.hide()
peekHeightMultiplier = 1F
viewModel.handleEvent(MapScreenEvent.DismissState)
}
} catch (_: CancellationException) {
peekHeightMultiplier = 1F
}
sheetSwipeEnabled = true
}
Box(
Modifier.Companion.windowInsetsPadding(
WindowInsets.Companion.safeContent.add(
WindowInsets(bottom = extInsets)
)
),
contentAlignment = Alignment.Companion.BottomEnd
) {
FloatingActionButton(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
onClick = { viewModel.centreCameraToLocation() },
) {
Icon(painterResource(Res.drawable.my_location_24), "Move to current location")
}
}
}
}
}

View file

@ -1,4 +1,4 @@
package moe.lava.banksia.ui
package moe.lava.banksia.ui.screens
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -13,9 +13,12 @@ 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.data.ptv.structures.PtvRouteType
import moe.lava.banksia.model.Route
import moe.lava.banksia.model.RouteType
import moe.lava.banksia.ui.components.getUIProperties
import moe.lava.banksia.ui.platform.maps.CameraPosition
import moe.lava.banksia.ui.platform.maps.CameraPositionBounds
@ -32,23 +35,27 @@ import moe.lava.banksia.util.log
import kotlin.time.Clock
import kotlin.time.Instant
sealed class BanksiaEvent {
data object DismissState : BanksiaEvent()
sealed class MapScreenEvent {
data object DismissState : MapScreenEvent()
data class SelectRoute(val id: Int?) : BanksiaEvent()
data class SelectRun(val ref: String?) : BanksiaEvent()
data class SelectStop(val typeAndId: Pair<PtvRouteType, Int>) : BanksiaEvent()
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) : BanksiaEvent()
data class SearchUpdate(val text: String) : MapScreenEvent()
}
data class InternalState(
val route: Int? = null,
val stop: Pair<PtvRouteType, Int>? = null,
val route: String? = null,
val stop: Pair<RouteType, String>? = null,
val run: String? = null,
)
class BanksiaViewModel : ViewModel() {
class MapScreenViewModel(
private val ptvService: PtvService,
private val routeRepository: RouteRepository,
private val stopRepository: StopRepository,
) : ViewModel() {
private var state = InternalState()
set(value) {
val last = field
@ -72,7 +79,6 @@ class BanksiaViewModel : ViewModel() {
private val iSearchState = MutableStateFlow(SearchState())
val searchState = iSearchState.asStateFlow()
private val ptvService = PtvService(viewModelScope)
private var locationTrackerJob: Job? = null
private var lastKnownLocation: Point? = null
@ -80,14 +86,14 @@ class BanksiaViewModel : ViewModel() {
viewModelScope.launch { searchUpdate("") }
}
fun handleEvent(event: BanksiaEvent) {
fun handleEvent(event: MapScreenEvent) {
viewModelScope.launch {
when (event) {
is BanksiaEvent.DismissState -> dismissState()
is BanksiaEvent.SelectRoute -> state = InternalState(route = event.id)
is BanksiaEvent.SelectRun -> state = state.copy(run = event.ref, stop = null)
is BanksiaEvent.SelectStop -> state = state.copy(stop = event.typeAndId, run = null)
is BanksiaEvent.SearchUpdate -> searchUpdate(event.text)
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)
}
}
}
@ -99,6 +105,11 @@ class BanksiaViewModel : ViewModel() {
}
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")
@ -117,46 +128,48 @@ class BanksiaViewModel : ViewModel() {
}
private suspend fun searchUpdate(text: String) {
val entries = ptvService.routes()
iSearchState.update { it.copy(text = text) }
val entries = routeRepository.getAll()
.sortedWith(
compareBy(
{ it.gtfsSubType()?.ordinal },
{ it.routeNumber.toIntOrNull() },
{ it.routeName }
{ it.type.ordinal },
{ it.number },
{ it.name }
)
)
.filter { it.routeNumber.contains(text) || it.routeName.lowercase().contains(text.lowercase()) }
.filter { (it.number ?: "").contains(text) || it.name.lowercase().contains(text.lowercase()) }
.map { route ->
val (main, sub) = if (route.routeNumber.isNotEmpty()) {
route.routeNumber to route.routeName
val (main, sub) = if (route.number?.isNotEmpty() == true) {
route.number to route.name
} else {
route.routeName to null
route.name to null
}
SearchState.SearchEntry(main, sub, route.routeId, route.routeType)
SearchState.SearchEntry(main!!, sub, route.id, route.type)
}
iSearchState.update { SearchState(entries, text) }
}
private suspend fun switchRoute(routeId: Int?) {
private suspend fun switchRoute(routeId: String?) {
iMapState.update { MapState() }
if (routeId == null) {
iInfoState.update { InfoPanelState.None }
return
}
val route = ptvService.route(routeId)
val route = routeRepository.get(routeId)
// val gtfsRoute = ptvService.route(routeId)
iInfoState.update {
InfoPanelState.Route(
name = route.routeName,
type = route.routeType,
name = route.name,
type = route.type,
)
}
viewModelScope.launch { buildPolylines(route) }
// viewModelScope.launch { buildPolylines(gtfsRoute) }
viewModelScope.launch { buildStops(route) }
buildRuns(route)
// buildRuns(gtfsRoute)
}
private fun switchRun(ref: String?) {
@ -175,7 +188,7 @@ class BanksiaViewModel : ViewModel() {
iInfoState.update {
InfoPanelState.Run(
direction = run.destinationName,
type = run.routeType,
type = RouteType.MetroTrain, // XXX HACK TODO FIXME
)
}
routeName = ptvService.route(run.routeId).routeName
@ -184,7 +197,7 @@ class BanksiaViewModel : ViewModel() {
iInfoState.update {
InfoPanelState.Run(
direction = run.destinationName,
type = run.routeType,
type = RouteType.MetroTrain, // FIXME HACK XXX TODO
routeName = routeName,
)
}
@ -193,25 +206,27 @@ class BanksiaViewModel : ViewModel() {
}
// [TODO]: Cleanup
private suspend fun switchStop(typeAndId: Pair<PtvRouteType, Int>?) {
if (typeAndId == null) {
private suspend fun switchStop(pair: Pair<RouteType, String>?) {
if (pair == null) {
iInfoState.update { InfoPanelState.None }
return
}
val (routeType, stopId) = typeAndId
val stop = ptvService.stop(routeType, stopId)
val split = stop.stopName.split("/")
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.stopId,
id = stop.id,
name = name,
subname = subname,
)
}
val res = ptvService.departures(stop.routeType, stop.stopId)
val res = ptvService.departures(type, stop.id)
// Map<
// Pair<DirectionId, RouteId>,
// Pair<DirectionName, List<DepartureTimes>>
@ -285,7 +300,7 @@ class BanksiaViewModel : ViewModel() {
ptvService
.runsFlow(route.routeId)
.waitUntilSubscribed(iInfoState)
.takeWhile { state.route == route.routeId }
// .takeWhile { state.route == route.routeId }
.onEach { runs ->
val markers = runs
.filter { it.vehiclePosition != null }
@ -295,7 +310,7 @@ class BanksiaViewModel : ViewModel() {
Marker.Vehicle(
Point(pos.latitude, pos.longitude),
ref = run.runRef,
type = route.routeType,
type = RouteType.MetroTrain, // HACK TODO XXX FIXME
)
}
@ -305,18 +320,17 @@ class BanksiaViewModel : ViewModel() {
}
private suspend fun buildStops(route: PtvRoute) {
val stops = ptvService.stopsByRoute(route.routeId, route.routeType)
val colour = route.routeType.getUIProperties().colour
private suspend fun buildStops(route: Route) {
val stops = stopRepository.getByRoute(route.id)
val colour = route.type.getUIProperties().colour
val markers = stops
.filter { it.stopLatitude != null && it.stopLongitude != null }
.map { stop ->
Marker.Stop(
point = Point(stop.stopLatitude!!, stop.stopLongitude!!),
id = stop.stopId,
point = stop.pos,
id = stop.id,
colour = colour,
type = route.routeType,
type = route.type,
)
}

View file

@ -1,6 +1,6 @@
package moe.lava.banksia.ui.state
import moe.lava.banksia.data.ptv.structures.PtvRouteType
import moe.lava.banksia.model.RouteType
sealed class InfoPanelState {
abstract val loading: Boolean
@ -11,21 +11,21 @@ sealed class InfoPanelState {
data class Route(
val name: String,
val type: PtvRouteType,
val type: RouteType,
) : InfoPanelState() {
override val loading = false
}
data class Run(
val direction: String,
val type: PtvRouteType,
val type: RouteType,
val routeName: String? = null,
) : InfoPanelState() {
override val loading = routeName == null
}
data class Stop(
val id: Int,
val id: String,
val name: String,
val subname: String? = null,
val departures: List<Departure>? = null,

View file

@ -1,6 +1,6 @@
package moe.lava.banksia.ui.state
import moe.lava.banksia.data.ptv.structures.PtvRouteType
import moe.lava.banksia.model.RouteType
data class SearchState(
val entries: List<SearchEntry> = listOf(),
@ -9,7 +9,7 @@ data class SearchState(
data class SearchEntry(
val mainText: String,
val subText: String?,
val routeId: Int,
val routeType: PtvRouteType,
val routeId: String,
val routeType: RouteType,
)
}

View file

@ -7,7 +7,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalWindowInfo
import kotlinx.coroutines.flow.Flow
import moe.lava.banksia.ui.BanksiaEvent
import moe.lava.banksia.ui.screens.MapScreenEvent
import moe.lava.banksia.ui.state.MapState
import moe.lava.banksia.util.BoxedValue
import moe.lava.banksia.util.Point
@ -23,7 +23,7 @@ actual fun getScreenHeight(): Int {
actual fun Maps(
modifier: Modifier,
state: MapState,
onEvent: (BanksiaEvent) -> Unit,
onEvent: (MapScreenEvent) -> Unit,
cameraPositionFlow: Flow<BoxedValue<CameraPosition>>,
setLastKnownLocation: (Point) -> Unit,
extInsets: WindowInsets,