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.location)
implementation(libs.play.services.maps) implementation(libs.play.services.maps)
implementation(libs.maps.compose) implementation(libs.maps.compose)
implementation(libs.maps.compose.utils)
} }
commonMain.dependencies { commonMain.dependencies {
implementation(compose.runtime) implementation(compose.runtime)
@ -53,8 +54,13 @@ kotlin {
implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime) 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)
implementation(libs.moko.geo.compose) implementation(libs.moko.geo.compose)
implementation(projects.shared) implementation(projects.shared)

View file

@ -11,6 +11,7 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
android:usesCleartextTraffic="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar"> android:theme="@android:style/Theme.Material.Light.NoActionBar">
<meta-data <meta-data
android:name="com.google.android.geo.API_KEY" 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 com.google.maps.android.compose.rememberUpdatedMarkerState
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import moe.lava.banksia.R import moe.lava.banksia.R
import moe.lava.banksia.ui.BanksiaEvent
import moe.lava.banksia.ui.components.RouteIcon import moe.lava.banksia.ui.components.RouteIcon
import moe.lava.banksia.ui.platform.BanksiaTheme 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.ui.state.MapState
import moe.lava.banksia.util.BoxedValue import moe.lava.banksia.util.BoxedValue
import moe.lava.banksia.util.Point import moe.lava.banksia.util.Point
@ -67,7 +67,7 @@ actual fun getScreenHeight(): Int {
actual fun Maps( actual fun Maps(
modifier: Modifier, modifier: Modifier,
state: MapState, state: MapState,
onEvent: (BanksiaEvent) -> Unit, onEvent: (MapScreenEvent) -> Unit,
cameraPositionFlow: Flow<BoxedValue<CameraPosition>>, cameraPositionFlow: Flow<BoxedValue<CameraPosition>>,
setLastKnownLocation: (Point) -> Unit, setLastKnownLocation: (Point) -> Unit,
extInsets: WindowInsets, extInsets: WindowInsets,
@ -135,7 +135,7 @@ actual fun Maps(
zIndex = 0f, zIndex = 0f,
state = state, state = state,
onClick = { onClick = {
onEvent(BanksiaEvent.SelectStop(marker.type to marker.id)) onEvent(MapScreenEvent.SelectStop(marker.type to marker.id))
false false
} }
) { ) {
@ -155,7 +155,7 @@ actual fun Maps(
zIndex = 1f, zIndex = 1f,
state = state, state = state,
onClick = { onClick = {
onEvent(BanksiaEvent.SelectRun(marker.ref)) onEvent(MapScreenEvent.SelectRun(marker.ref))
false 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 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.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.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.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import moe.lava.banksia.client.di.ClientModule
import androidx.compose.ui.backhandler.PredictiveBackHandler import moe.lava.banksia.di.CommonModules
import androidx.compose.ui.layout.onSizeChanged import moe.lava.banksia.ui.screens.MapScreen
import androidx.compose.ui.platform.LocalDensity import org.koin.compose.KoinMultiplatformApplication
import androidx.compose.ui.unit.dp import org.koin.core.annotation.KoinExperimentalAPI
import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.dsl.koinConfiguration
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
val MELBOURNE = Point(-37.8136, 144.9631) @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class, KoinExperimentalAPI::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable @Composable
fun App( fun App() {
viewModel: BanksiaViewModel = viewModel() KoinMultiplatformApplication(config = koinConfiguration {
) { modules(CommonModules, ClientModule)
val scope = rememberCoroutineScope() }) {
MapScreen()
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")
}
}
}
} }
} }

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 androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import moe.lava.banksia.data.ptv.structures.PtvRouteType import moe.lava.banksia.data.ptv.structures.PtvRouteType
import moe.lava.banksia.model.RouteType
import moe.lava.banksia.model.RouteType.Interstate
import moe.lava.banksia.model.RouteType.MetroBus
import moe.lava.banksia.model.RouteType.MetroTrain
import moe.lava.banksia.model.RouteType.MetroTram
import moe.lava.banksia.model.RouteType.RegionalBus
import moe.lava.banksia.model.RouteType.RegionalCoach
import moe.lava.banksia.model.RouteType.RegionalTrain
import moe.lava.banksia.model.RouteType.SkyBus
import moe.lava.banksia.resources.Res import moe.lava.banksia.resources.Res
import moe.lava.banksia.resources.bus import moe.lava.banksia.resources.bus
import moe.lava.banksia.resources.bus_background import moe.lava.banksia.resources.bus_background
@ -33,12 +42,51 @@ data class RouteTypeProperties(
val icon: 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 { fun PtvRouteType.getUIProperties(): RouteTypeProperties {
val colour = when (this) { val colour = when (this) {
PtvRouteType.TRAIN -> Color(0xFF0072CE) PtvRouteType.TRAIN -> Color(TRAIN_BLUE)
PtvRouteType.TRAM -> Color(0xFF78BE20) PtvRouteType.TRAM -> Color(TRAM_GREEN)
PtvRouteType.BUS, PtvRouteType.NIGHT_BUS -> Color(0xFFFF8200) PtvRouteType.BUS, PtvRouteType.NIGHT_BUS -> Color(BUS_ORANGE)
PtvRouteType.VLINE -> Color(0xFF8F1A95) PtvRouteType.VLINE -> Color(VLINE_PURPLE)
} }
val (drawable, background, icon) = when (this) { val (drawable, background, icon) = when (this) {
PtvRouteType.TRAM -> Triple( PtvRouteType.TRAM -> Triple(
@ -58,7 +106,7 @@ fun PtvRouteType.getUIProperties(): RouteTypeProperties {
fun RouteIcon( fun RouteIcon(
modifier: Modifier = Modifier.Companion, modifier: Modifier = Modifier.Companion,
size: Dp = 40.dp, size: Dp = 40.dp,
routeType: PtvRouteType, routeType: RouteType,
) { ) {
val properties = routeType.getUIProperties() val properties = routeType.getUIProperties()
Image( Image(
@ -80,9 +128,9 @@ const val ICON_PADDING = 0.25f
@Composable @Composable
private fun RouteIconPreview() { private fun RouteIconPreview() {
Row { Row {
RouteIcon(routeType = PtvRouteType.TRAIN) RouteIcon(routeType = RouteType.MetroTrain)
RouteIcon(routeType = PtvRouteType.TRAM) RouteIcon(routeType = RouteType.MetroTram)
RouteIcon(routeType = PtvRouteType.BUS) 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.Dp
import androidx.compose.ui.unit.coerceAtMost import androidx.compose.ui.unit.coerceAtMost
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import moe.lava.banksia.ui.BanksiaEvent
import moe.lava.banksia.ui.components.RouteIcon import moe.lava.banksia.ui.components.RouteIcon
import moe.lava.banksia.ui.screens.MapScreenEvent
import moe.lava.banksia.ui.state.InfoPanelState import moe.lava.banksia.ui.state.InfoPanelState
@Composable @Composable
fun InfoPanel( fun InfoPanel(
state: InfoPanelState, state: InfoPanelState,
onEvent: (BanksiaEvent) -> Unit, onEvent: (MapScreenEvent) -> Unit,
onPeekHeightChange: (Dp) -> Unit, onPeekHeightChange: (Dp) -> Unit,
) { ) {
if (state is InfoPanelState.None) if (state is InfoPanelState.None)
@ -74,7 +74,7 @@ fun InfoPanel(
@Composable @Composable
private inline fun RouteInfoPanel( private inline fun RouteInfoPanel(
state: InfoPanelState.Route, state: InfoPanelState.Route,
onEvent: (BanksiaEvent) -> Unit, onEvent: (MapScreenEvent) -> Unit,
) { ) {
Column(Modifier.Companion.fillMaxWidth()) { Column(Modifier.Companion.fillMaxWidth()) {
Row { Row {
@ -92,7 +92,7 @@ private inline fun RouteInfoPanel(
@Composable @Composable
private inline fun RunInfoPanel( private inline fun RunInfoPanel(
state: InfoPanelState.Run, state: InfoPanelState.Run,
onEvent: (BanksiaEvent) -> Unit, onEvent: (MapScreenEvent) -> Unit,
) { ) {
Column(Modifier.Companion.fillMaxWidth()) { Column(Modifier.Companion.fillMaxWidth()) {
Row { Row {
@ -110,7 +110,7 @@ private inline fun RunInfoPanel(
@Composable @Composable
private inline fun StopInfoPanel( private inline fun StopInfoPanel(
state: InfoPanelState.Stop, state: InfoPanelState.Stop,
onEvent: (BanksiaEvent) -> Unit, onEvent: (MapScreenEvent) -> Unit,
) { ) {
Column(Modifier.Companion.fillMaxWidth()) { Column(Modifier.Companion.fillMaxWidth()) {
Text( Text(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,7 +19,7 @@ koin = "4.1.0"
kotlin = "2.2.0" kotlin = "2.2.0"
kotlinxDatetime = "0.7.1" kotlinxDatetime = "0.7.1"
kotlinxSerializationCsv = "0.2.18" kotlinxSerializationCsv = "0.2.18"
kotlinxSerializationJson = "1.9.0" kotlinxSerialization = "1.9.0"
ksp = "2.2.0-2.0.2" ksp = "2.2.0-2.0.2"
ktor = "3.2.3" ktor = "3.2.3"
logback = "1.5.18" logback = "1.5.18"
@ -39,23 +39,29 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodel-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-ktor = { module = "io.insert-koin:koin-ktor", version.ref = "koin" } koin-ktor = { module = "io.insert-koin:koin-ktor", version.ref = "koin" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
kotlinx-serialization-csv = { module = "com.lightningkite:kotlinx-serialization-csv-durable", version.ref = "kotlinxSerializationCsv" } kotlinx-serialization-csv = { module = "com.lightningkite:kotlinx-serialization-csv-durable", version.ref = "kotlinxSerializationCsv" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
kotlinx-serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "kotlinxSerialization" }
ktor-client-contentnegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-contentnegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-server-contentnegotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
ktor-server-core = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" } ktor-server-core = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" }
ktor-server-netty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" } ktor-server-netty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" }
ktor-server-tests = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" } ktor-server-tests = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "mapsCompose" } maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "mapsCompose" }
maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", version.ref = "mapsCompose" }
okio = { module = "com.squareup.okio:okio", version.ref = "okio" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" } play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" }
play-services-maps = { module = "com.google.android.gms:play-services-maps", version.ref = "playServicesMaps" } play-services-maps = { module = "com.google.android.gms:play-services-maps", version.ref = "playServicesMaps" }

View file

@ -18,9 +18,12 @@ dependencies {
implementation(libs.koin.core) implementation(libs.koin.core)
implementation(libs.koin.ktor) implementation(libs.koin.ktor)
implementation(libs.kotlinx.serialization.csv) implementation(libs.kotlinx.serialization.csv)
implementation(libs.ktor.client.core) implementation(libs.kotlinx.datetime)
implementation(libs.ktor.client.contentnegotiation) implementation(libs.ktor.client.contentnegotiation)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.server.contentnegotiation)
implementation(libs.ktor.server.core) implementation(libs.ktor.server.core)
implementation(libs.ktor.server.netty) implementation(libs.ktor.server.netty)
implementation(libs.room.runtime) implementation(libs.room.runtime)

View file

@ -1,17 +1,24 @@
package moe.lava.banksia.server package moe.lava.banksia.server
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.application.install import io.ktor.server.application.install
import io.ktor.server.application.log import io.ktor.server.application.log
import io.ktor.server.engine.embeddedServer import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty import io.ktor.server.netty.Netty
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.response.respond
import io.ktor.server.response.respondText import io.ktor.server.response.respondText
import io.ktor.server.routing.get import io.ktor.server.routing.get
import io.ktor.server.routing.routing import io.ktor.server.routing.routing
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import moe.lava.banksia.di.CommonModules import moe.lava.banksia.di.CommonModules
import moe.lava.banksia.room.dao.RouteDao
import moe.lava.banksia.room.dao.StopDao
import moe.lava.banksia.server.di.ServerModules import moe.lava.banksia.server.di.ServerModules
import moe.lava.banksia.server.gtfs.GtfsHandler import moe.lava.banksia.server.gtfs.GtfsHandler
import org.koin.dsl.module import org.koin.dsl.module
@ -24,6 +31,9 @@ fun main() {
} }
fun Application.module() { fun Application.module() {
install(ContentNegotiation) {
json()
}
install(Koin) { install(Koin) {
modules(module { single { log } }) modules(module { single { log } })
modules(CommonModules, ServerModules) modules(CommonModules, ServerModules)
@ -40,5 +50,66 @@ fun Application.module() {
handler.update(datasetUrl) handler.update(datasetUrl)
} }
} }
get("/routes") {
val routes = withContext(context = Dispatchers.IO) {
inject<RouteDao>().value.getAll()
}
val res = routes.map { it.asModel() }
call.respond(res)
}
get("/routes/{route_id}") {
val routeId = call.parameters["route_id"]!!
val route = withContext(context = Dispatchers.IO) {
inject<RouteDao>().value.get(routeId)
}
if (route != null)
call.respond(route.asModel())
else
call.respond(HttpStatusCode.NotFound)
}
get("/stops") {
val routes = withContext(context = Dispatchers.IO) {
inject<StopDao>().value.getAll()
}
val res = routes.map { it.asModel() }
call.respond(res)
}
get("/stops/{stop_id}") {
val stopId = call.parameters["stop_id"]!!
val stop = withContext(context = Dispatchers.IO) {
inject<StopDao>().value.get(stopId)
}
if (stop != null)
call.respond(stop.asModel())
else
call.respond(HttpStatusCode.NotFound)
}
get("/route_stops/{route_id}") {
val routeId = call.parameters["route_id"]!!
val useParent = call.queryParameters["parent"] in listOf("true", "1")
val stops = withContext(Dispatchers.IO) {
val routeDao by inject<RouteDao>()
if (useParent)
routeDao.stopsParent(routeId)
else
routeDao.stops(routeId)
}
call.respond(stops.map { it.asModel() })
// val stops = withContext(Dispatchers.IO) {
// val stopDao by inject<StopDao>()
// val stopTimeDao by inject<StopTimeDao>()
// val tripDao by inject<TripDao>()
//
// tripDao.getByRoute(routeId)
// .map { it.id }
// .let { stopTimeDao.get(it) }
// .flatMap { it.asModel().stopInfos }
// .map { it.stopId }
// .let { stopDao.get(it) }
// .map { it.asModel() }
// }
// call.respond(stops)
}
} }
} }

View file

@ -12,12 +12,18 @@ import io.ktor.utils.io.copyAndClose
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.modules.EmptySerializersModule import kotlinx.serialization.modules.EmptySerializersModule
import moe.lava.banksia.model.Route import moe.lava.banksia.model.Route
import moe.lava.banksia.model.RouteType
import moe.lava.banksia.model.Shape import moe.lava.banksia.model.Shape
import moe.lava.banksia.room.dao.RouteDao import moe.lava.banksia.model.Stop
import moe.lava.banksia.room.dao.ShapeDao import moe.lava.banksia.model.StopTime
import moe.lava.banksia.model.Trip
import moe.lava.banksia.room.Database
import moe.lava.banksia.room.converter.RouteTypeConverter
import moe.lava.banksia.room.entity.asEntity
import moe.lava.banksia.server.gtfs.structures.GtfsRoute import moe.lava.banksia.server.gtfs.structures.GtfsRoute
import moe.lava.banksia.server.gtfs.structures.GtfsShape import moe.lava.banksia.server.gtfs.structures.GtfsShape
import moe.lava.banksia.server.gtfs.structures.GtfsStop
import moe.lava.banksia.server.gtfs.structures.GtfsStopTime
import moe.lava.banksia.server.gtfs.structures.GtfsTrip
import moe.lava.banksia.util.Point import moe.lava.banksia.util.Point
import java.io.File import java.io.File
import java.util.zip.ZipFile import java.util.zip.ZipFile
@ -25,9 +31,7 @@ import java.util.zip.ZipFile
class GtfsHandler( class GtfsHandler(
private val log: Logger, private val log: Logger,
private val client: HttpClient, private val client: HttpClient,
private val db: Database,
private val routeDao: RouteDao,
private val shapeDao: ShapeDao,
) { ) {
private val csv = CsvFormat(StringDeferringConfig(EmptySerializersModule())) private val csv = CsvFormat(StringDeferringConfig(EmptySerializersModule()))
private val datasetPath = File("/tmp/banksia", "dataset.zip") private val datasetPath = File("/tmp/banksia", "dataset.zip")
@ -49,27 +53,30 @@ class GtfsHandler(
} }
log.info("extracting...") log.info("extracting...")
val files = extractAll(datasetPath) // val files = extractAll(datasetPath)
val files = datasetPath.parentFile
.listFiles { it.isDirectory }
.flatMap { d -> d.listFiles { f -> f.extension == "txt" }.toList() }
addRoutes(files)
addStops(files)
addShapes(files)
addTrips(files)
addStopTimes(files)
log.info("done!")
}
private suspend fun addRoutes(files: List<File>) {
val dao = db.routeDao
log.info("parsing routes...") log.info("parsing routes...")
val routes = files val routes = files
.filter { it.name == "routes.txt" } .filter { it.name == "routes.txt" }
.flatMap { fd -> parseRoutes(fd) } .flatMap { fd -> parseRoutes(fd) }
log.info("inserting routes...") log.info("inserting routes...")
routeDao.deleteAll() dao.deleteAll()
routeDao.insertAll(*routes.toTypedArray()) dao.insertAll(*routes.map { it.asEntity() }.toTypedArray())
log.info("parsing shapes...")
val shapes = files
.filter { it.name == "shapes.txt" }
.flatMap { fd -> parseShapes(fd) }
log.info("inserting shapes...")
shapeDao.deleteAll()
shapeDao.insertAll(*shapes.toTypedArray())
log.info("done!")
} }
private fun parseRoutes(fd: File) = private fun parseRoutes(fd: File) =
@ -77,12 +84,24 @@ class GtfsHandler(
.map { with(it) { .map { with(it) {
Route( Route(
id = route_id, id = route_id,
type = RouteType.from(fd.parentFile.name.toInt()), type = RouteTypeConverter.from(fd.parentFile.name.toInt()),
number = route_short_name, number = route_short_name,
name = route_long_name, name = route_long_name,
) )
} } } }
private suspend fun addShapes(files: List<File>) {
val dao = db.shapeDao
log.info("parsing shapes...")
val shapes = files
.filter { it.name == "shapes.txt" }
.flatMap { fd -> parseShapes(fd) }
log.info("inserting shapes...")
dao.deleteAll()
dao.insertAll(*shapes.map { it.asEntity() }.toTypedArray())
}
private fun parseShapes(fd: File) = private fun parseShapes(fd: File) =
fd.parseCsv<GtfsShape>() fd.parseCsv<GtfsShape>()
.groupBy { it.shape_id } .groupBy { it.shape_id }
@ -94,6 +113,95 @@ class GtfsHandler(
Shape(id, points) Shape(id, points)
} }
private suspend fun addStops(files: List<File>) {
val dao = db.stopDao
log.info("parsing stops...")
val stops = files
.filter { it.name == "stops.txt" }
.flatMap { fd -> parseStops(fd) }
log.info("inserting stops...")
dao.deleteAll()
stops
.groupBy { it.id }
.forEach { (id, gstops) ->
if (gstops.size > 1) {
// if (gstops.withIndex().any { (i, stop) -> i != 0 && stop == gstops[i - 1] })
gstops.forEach {
log.info("duplicate $id: $it")
}
}
}
dao.insertOrReplaceAll(*stops.map { it.asEntity() }.toTypedArray())
}
private fun parseStops(fd: File) =
fd.parseCsv<GtfsStop>()
.map { with(it) {
Stop(
id = stop_id,
name = stop_name,
pos = Point(stop_lat, stop_lon),
parent = parent_station,
hasWheelChairBoarding = wheelchair_boarding == "1",
level = level_id,
platformCode = platform_code,
)
} }
private suspend fun addStopTimes(files: List<File>) {
val dao = db.stopTimeDao
log.info("parsing stop times...")
val stopTimes = files
.filter { it.name == "stop_times.txt" }
.flatMap { fd -> parseStopTimes(fd) }
log.info("inserting stop times...")
dao.deleteAll()
dao.insertOrReplaceAll(*stopTimes.map { it.asEntity() }.toTypedArray())
}
private fun parseStopTimes(fd: File) =
fd.parseCsv<GtfsStopTime>()
.map { with(it) {
StopTime(
tripId = trip_id,
stopId = stop_id,
arrivalTime = GtfsStopTime.parseGtfsTime(arrival_time),
departureTime = GtfsStopTime.parseGtfsTime(departure_time),
headsign = stop_headsign,
pickupType = pickup_type,
dropOffType = drop_off_type,
)
} }
private suspend fun addTrips(files: List<File>) {
val dao = db.tripDao
log.info("parsing trips...")
val trips = files
.filter { it.name == "trips.txt" }
.flatMap { fd -> parseTrips(fd) }
log.info("inserting trips...")
dao.deleteAll()
dao.insertOrReplaceAll(*trips.map { it.asEntity() }.toTypedArray())
}
private fun parseTrips(fd: File) =
fd.parseCsv<GtfsTrip>()
.map { with(it) {
Trip(
id = trip_id,
routeId = route_id,
serviceId = service_id,
shapeId = shape_id.ifEmpty { null },
tripHeadsign = trip_headsign,
directionId = direction_id,
blockId = block_id,
wheelchairAccessible = wheelchair_accessible,
)
} }
private fun extract(fd: File): List<File> { private fun extract(fd: File): List<File> {
val outputs = mutableListOf<File>() val outputs = mutableListOf<File>()
@ -114,7 +222,7 @@ class GtfsHandler(
private fun extractAll(fd: File) = extract(fd).flatMap(::extract) private fun extractAll(fd: File) = extract(fd).flatMap(::extract)
private fun <T> File.parseCsv(): List<T> = this private inline fun <reified T> File.parseCsv(): List<T> = this
.readText() .readText()
.replace("\uFEFF", "") // remove bom .replace("\uFEFF", "") // remove bom
.replace("\r\n", "\n") // crlf -> lf .replace("\r\n", "\n") // crlf -> lf

View file

@ -0,0 +1,17 @@
package moe.lava.banksia.server.gtfs.structures
import kotlinx.serialization.Serializable
@Suppress("PropertyName")
@Serializable
data class GtfsStop(
val stop_id: String,
val stop_name: String,
val stop_lat: Double,
val stop_lon: Double,
val location_type: String,
val parent_station: String,
val wheelchair_boarding: String,
val level_id: String,
val platform_code: String,
)

View file

@ -0,0 +1,25 @@
package moe.lava.banksia.server.gtfs.structures
import kotlinx.serialization.Serializable
import moe.lava.banksia.model.FutureTime
@Suppress("PropertyName")
@Serializable
data class GtfsStopTime(
val trip_id: String,
val arrival_time: String,
val departure_time: String,
val stop_id: String,
val stop_sequence: Int,
val stop_headsign: String,
val pickup_type: Int,
val drop_off_type: Int,
val shape_dist_traveled: String,
) {
companion object {
fun parseGtfsTime(time: String): FutureTime {
val (hour, minute, second) = time.split(":").map { it.toInt() }
return FutureTime.from(hour, minute, second)
}
}
}

View file

@ -0,0 +1,16 @@
package moe.lava.banksia.server.gtfs.structures
import kotlinx.serialization.Serializable
@Suppress("PropertyName")
@Serializable
data class GtfsTrip(
val route_id: String,
val service_id: String,
val trip_id: String,
val shape_id: String,
val trip_headsign: String,
val direction_id: String,
val block_id: String,
val wheelchair_accessible: String,
)

View file

@ -37,6 +37,7 @@ kotlin {
sourceSets { sourceSets {
androidMain.dependencies { androidMain.dependencies {
implementation(libs.koin.compose)
implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.okhttp)
} }
commonMain.dependencies { commonMain.dependencies {
@ -48,6 +49,7 @@ kotlin {
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.serialization.protobuf)
implementation(libs.room.runtime) implementation(libs.room.runtime)
implementation(libs.sqlite.bundled) implementation(libs.sqlite.bundled)
} }

View file

@ -0,0 +1,315 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "83ece554400bb035c267dc2414c23293",
"entities": [
{
"tableName": "Route",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `type` INTEGER NOT NULL, `number` TEXT, `name` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "number",
"columnName": "number",
"affinity": "TEXT"
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "Shape",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `path` BLOB NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "path",
"columnName": "path",
"affinity": "BLOB",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "Stop",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `lat` REAL NOT NULL, `lng` REAL NOT NULL, `parent` TEXT NOT NULL, `hasWheelChairBoarding` INTEGER NOT NULL, `level` TEXT NOT NULL, `platformCode` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lat",
"columnName": "lat",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "lng",
"columnName": "lng",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "parent",
"columnName": "parent",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "hasWheelChairBoarding",
"columnName": "hasWheelChairBoarding",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "level",
"columnName": "level",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "platformCode",
"columnName": "platformCode",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_Stop_parent",
"unique": false,
"columnNames": [
"parent"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Stop_parent` ON `${TABLE_NAME}` (`parent`)"
}
]
},
{
"tableName": "StopTime",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tripId` TEXT NOT NULL, `stopId` TEXT NOT NULL, `arrivalTime` INTEGER NOT NULL, `departureTime` INTEGER NOT NULL, `headsign` TEXT, `pickupType` INTEGER NOT NULL, `dropOffType` INTEGER NOT NULL, PRIMARY KEY(`tripId`, `stopId`), FOREIGN KEY(`tripId`) REFERENCES `Trip`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`stopId`) REFERENCES `Stop`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "tripId",
"columnName": "tripId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "stopId",
"columnName": "stopId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "arrivalTime",
"columnName": "arrivalTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "departureTime",
"columnName": "departureTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "headsign",
"columnName": "headsign",
"affinity": "TEXT"
},
{
"fieldPath": "pickupType",
"columnName": "pickupType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dropOffType",
"columnName": "dropOffType",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"tripId",
"stopId"
]
},
"foreignKeys": [
{
"table": "Trip",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"tripId"
],
"referencedColumns": [
"id"
]
},
{
"table": "Stop",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"stopId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "Trip",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `routeId` TEXT NOT NULL, `serviceId` TEXT NOT NULL, `shapeId` TEXT, `tripHeadsign` TEXT NOT NULL, `directionId` TEXT NOT NULL, `blockId` TEXT NOT NULL, `wheelchairAccessible` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`routeId`) REFERENCES `Route`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`shapeId`) REFERENCES `Shape`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "routeId",
"columnName": "routeId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "serviceId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "shapeId",
"columnName": "shapeId",
"affinity": "TEXT"
},
{
"fieldPath": "tripHeadsign",
"columnName": "tripHeadsign",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "directionId",
"columnName": "directionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockId",
"columnName": "blockId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wheelchairAccessible",
"columnName": "wheelchairAccessible",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_Trip_routeId",
"unique": false,
"columnNames": [
"routeId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_Trip_routeId` ON `${TABLE_NAME}` (`routeId`)"
}
],
"foreignKeys": [
{
"table": "Route",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"routeId"
],
"referencedColumns": [
"id"
]
},
{
"table": "Shape",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"shapeId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '83ece554400bb035c267dc2414c23293')"
]
}
}

View file

@ -6,6 +6,7 @@ import androidx.room.RoomDatabase
import moe.lava.banksia.room.Database import moe.lava.banksia.room.Database
import org.koin.core.parameter.ParametersHolder import org.koin.core.parameter.ParametersHolder
import org.koin.core.scope.Scope import org.koin.core.scope.Scope
import org.koin.dsl.module
class AndroidDatabaseBuilder(val ctx: Context) : PlatformDatabaseBuilder { class AndroidDatabaseBuilder(val ctx: Context) : PlatformDatabaseBuilder {
override fun getBuilder(): RoomDatabase.Builder<Database> { override fun getBuilder(): RoomDatabase.Builder<Database> {
@ -19,4 +20,6 @@ class AndroidDatabaseBuilder(val ctx: Context) : PlatformDatabaseBuilder {
} }
actual fun Scope.provideDatabaseBuilder(p: ParametersHolder): PlatformDatabaseBuilder = actual fun Scope.provideDatabaseBuilder(p: ParametersHolder): PlatformDatabaseBuilder =
AndroidDatabaseBuilder(p.get()) AndroidDatabaseBuilder(get())
internal actual val ExtPlatformModule = module { }

View file

@ -13,7 +13,6 @@ import io.ktor.client.request.url
import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpResponse
import io.ktor.http.appendPathSegments import io.ktor.http.appendPathSegments
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -22,9 +21,10 @@ import moe.lava.banksia.data.ptv.structures.PtvDeparture
import moe.lava.banksia.data.ptv.structures.PtvDirection import moe.lava.banksia.data.ptv.structures.PtvDirection
import moe.lava.banksia.data.ptv.structures.PtvRoute import moe.lava.banksia.data.ptv.structures.PtvRoute
import moe.lava.banksia.data.ptv.structures.PtvRouteType import moe.lava.banksia.data.ptv.structures.PtvRouteType
import moe.lava.banksia.data.ptv.structures.PtvRouteType.Companion.asPtvType
import moe.lava.banksia.data.ptv.structures.PtvRun import moe.lava.banksia.data.ptv.structures.PtvRun
import moe.lava.banksia.data.ptv.structures.PtvStop import moe.lava.banksia.data.ptv.structures.PtvStop
import moe.lava.banksia.util.CacheMap import moe.lava.banksia.model.RouteType
import moe.lava.banksia.util.LoopFlow.Companion.initWith import moe.lava.banksia.util.LoopFlow.Companion.initWith
import moe.lava.banksia.util.error import moe.lava.banksia.util.error
import moe.lava.banksia.util.log import moe.lava.banksia.util.log
@ -59,16 +59,15 @@ suspend inline fun <K, V> MutableMap<K, V>.getOrPutSuspend(key: K, defaultValue:
return this[key]!! return this[key]!!
} }
class PtvService(coroutineScope: CoroutineScope) { class PtvService() {
class PtvCache( class PtvCache(
coroutineScope: CoroutineScope, val directions: MutableMap<Pair<Int, Int>, PtvDirection> = mutableMapOf(),
val directions: CacheMap<Pair<Int, Int>, PtvDirection> = CacheMap(coroutineScope), val routes: MutableMap<Int, PtvRoute> = mutableMapOf(),
val routes: CacheMap<Int, PtvRoute> = CacheMap(coroutineScope), val runs: MutableMap<String, PtvRun> = mutableMapOf(),
val runs: CacheMap<String, PtvRun> = CacheMap(coroutineScope), val stops: MutableMap<Int, PtvStop> = mutableMapOf(),
val stops: CacheMap<Int, PtvStop> = CacheMap(coroutineScope),
) )
val cache = PtvCache(coroutineScope) val cache = PtvCache()
private val client = HttpClient() { private val client = HttpClient() {
install(ContentNegotiation) { install(ContentNegotiation) {
@ -227,6 +226,20 @@ class PtvService(coroutineScope: CoroutineScope) {
return cache.directions[directionId to routeId]!! return cache.directions[directionId to routeId]!!
} }
suspend fun departures(routeType: RouteType, stopId: String): Responses.PtvDeparturesResponse =
client
.safeGet ("departures") {
url {
appendPathSegments(
"route_type", routeType.asPtvType().ordinal.toString(),
"stop", stopId.toString(),
)
parameter("expand", "Route")
parameter("expand", "Direction")
parameter("gtfs", "true")
}
}.body()
suspend fun departures(routeType: PtvRouteType, stopId: Int): Responses.PtvDeparturesResponse = suspend fun departures(routeType: PtvRouteType, stopId: Int): Responses.PtvDeparturesResponse =
client client
.safeGet ("departures") { .safeGet ("departures") {

View file

@ -7,6 +7,7 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.encoding.Encoder
import moe.lava.banksia.model.RouteType
private object PtvRouteTypeSerialiser : KSerializer<PtvRouteType> { private object PtvRouteTypeSerialiser : KSerializer<PtvRouteType> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(
@ -30,4 +31,20 @@ enum class PtvRouteType {
BUS, BUS,
VLINE, VLINE,
NIGHT_BUS, NIGHT_BUS,
;
companion object {
fun fromModel(type: RouteType) = when (type) {
RouteType.MetroTrain -> TRAIN
RouteType.MetroTram -> TRAM
RouteType.MetroBus -> BUS
RouteType.RegionalTrain -> VLINE
RouteType.RegionalCoach -> BUS
RouteType.RegionalBus -> BUS
RouteType.SkyBus -> BUS
RouteType.Interstate -> TRAIN
}
fun RouteType.asPtvType() = fromModel(this)
}
} }

View file

@ -7,6 +7,9 @@ val CommonModules = module {
includes(PlatformModule) includes(PlatformModule)
single { Database.build(get<PlatformDatabaseBuilder>().getBuilder()) } single { Database.build(get<PlatformDatabaseBuilder>().getBuilder()) }
single { get<Database>().getRouteDao() } single { get<Database>().routeDao }
single { get<Database>().getShapeDao() } single { get<Database>().shapeDao }
single { get<Database>().stopDao }
single { get<Database>().stopTimeDao }
single { get<Database>().tripDao }
} }

View file

@ -2,6 +2,7 @@ package moe.lava.banksia.di
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import moe.lava.banksia.room.Database import moe.lava.banksia.room.Database
import org.koin.core.module.Module
import org.koin.core.parameter.ParametersHolder import org.koin.core.parameter.ParametersHolder
import org.koin.core.scope.Scope import org.koin.core.scope.Scope
import org.koin.dsl.module import org.koin.dsl.module
@ -12,6 +13,9 @@ interface PlatformDatabaseBuilder {
expect fun Scope.provideDatabaseBuilder(p: ParametersHolder): PlatformDatabaseBuilder expect fun Scope.provideDatabaseBuilder(p: ParametersHolder): PlatformDatabaseBuilder
internal expect val ExtPlatformModule: Module
internal val PlatformModule = module { internal val PlatformModule = module {
includes(ExtPlatformModule)
single { provideDatabaseBuilder(it) } single { provideDatabaseBuilder(it) }
} }

View file

@ -0,0 +1,50 @@
package moe.lava.banksia.model
import kotlinx.datetime.LocalTime
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import moe.lava.banksia.model.FutureTime.Companion.asInt
@Serializable(FutureTimeSerialiser::class)
data class FutureTime(
val dayOffset: Boolean,
val time: LocalTime,
) {
companion object {
fun from(hour: Int, minute: Int, second: Int): FutureTime {
var nHour = hour
val nextDay = hour >= 24
if (nextDay)
nHour -= 24
val time = LocalTime(nHour, minute, second)
return FutureTime(nextDay, time)
}
fun FutureTime.asInt() =
trueHour * 3600 + minute * 60 + second
fun fromInt(int: Int) = FutureTime.from(
int / 3600,
(int / 60) % 60,
int % 60,
)
}
val hour = time.hour
val minute = time.minute
val second = time.second
val trueHour = time.hour + (if (dayOffset) 24 else 0)
}
object FutureTimeSerialiser: KSerializer<FutureTime> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor(FutureTimeSerialiser::class.qualifiedName!!, PrimitiveKind.INT)
override fun serialize(encoder: Encoder, value: FutureTime) = encoder.encodeInt(value.asInt())
override fun deserialize(decoder: Decoder) = FutureTime.fromInt(decoder.decodeInt())
}

View file

@ -1,11 +1,10 @@
package moe.lava.banksia.model package moe.lava.banksia.model
import androidx.room.Entity import kotlinx.serialization.Serializable
import androidx.room.PrimaryKey
@Entity @Serializable
data class Route( data class Route(
@PrimaryKey val id: String, val id: String,
val type: RouteType, val type: RouteType,
val number: String?, val number: String?,
val name: String, val name: String,

View file

@ -1,7 +1,8 @@
package moe.lava.banksia.model package moe.lava.banksia.model
import androidx.room.TypeConverter import kotlinx.serialization.Serializable
@Serializable
enum class RouteType(val value: Int) { enum class RouteType(val value: Int) {
MetroTrain(2), MetroTrain(2),
MetroTram(3), MetroTram(3),
@ -12,12 +13,4 @@ enum class RouteType(val value: Int) {
SkyBus(11), SkyBus(11),
Interstate(10), Interstate(10),
; ;
companion object {
@TypeConverter
fun from(value: Int) = RouteType.entries.first { it.value == value }
@TypeConverter
fun to(routeType: RouteType) = routeType.value
}
} }

View file

@ -0,0 +1,5 @@
package moe.lava.banksia.model
data class Run(
val ref: String,
)

View file

@ -0,0 +1,13 @@
package moe.lava.banksia.model
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
@Serializable
data class Service(
val id: String,
val days: List<DayOfWeek>,
val start: LocalDate,
val end: LocalDate,
)

View file

@ -1,16 +1,12 @@
package moe.lava.banksia.model package moe.lava.banksia.model
import androidx.room.Entity import kotlinx.serialization.Serializable
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import moe.lava.banksia.room.converter.ShapeConverter
import moe.lava.banksia.util.Point import moe.lava.banksia.util.Point
typealias ShapePath = List<Point> typealias ShapePath = List<Point>
@Entity @Serializable
@TypeConverters(ShapeConverter::class)
data class Shape( data class Shape(
@PrimaryKey val id: String, val id: String,
val path: ShapePath, val path: ShapePath,
) )

View file

@ -0,0 +1,15 @@
package moe.lava.banksia.model
import kotlinx.serialization.Serializable
import moe.lava.banksia.util.Point
@Serializable
data class Stop(
val id: String,
val name: String,
val pos: Point,
val parent: String,
val hasWheelChairBoarding: Boolean,
val level: String,
val platformCode: String,
)

View file

@ -0,0 +1,14 @@
package moe.lava.banksia.model
import kotlinx.serialization.Serializable
@Serializable
data class StopTime(
val tripId: String,
val stopId: String,
val arrivalTime: FutureTime,
val departureTime: FutureTime,
val headsign: String?,
val pickupType: Int,
val dropOffType: Int,
)

View file

@ -0,0 +1,15 @@
package moe.lava.banksia.model
import kotlinx.serialization.Serializable
@Serializable
data class Trip(
val id: String,
val routeId: String,
val serviceId: String,
val shapeId: String?,
val tripHeadsign: String,
val directionId: String,
val blockId: String,
val wheelchairAccessible: String,
)

View file

@ -1,28 +1,51 @@
package moe.lava.banksia.room package moe.lava.banksia.room
import androidx.room.AutoMigration
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
import moe.lava.banksia.model.Route import moe.lava.banksia.room.converter.RouteTypeConverter
import moe.lava.banksia.model.RouteType
import moe.lava.banksia.model.Shape
import moe.lava.banksia.room.dao.RouteDao import moe.lava.banksia.room.dao.RouteDao
import moe.lava.banksia.room.dao.ShapeDao import moe.lava.banksia.room.dao.ShapeDao
import moe.lava.banksia.room.dao.StopDao
import moe.lava.banksia.room.dao.StopTimeDao
import moe.lava.banksia.room.dao.TripDao
import moe.lava.banksia.room.entity.RouteEntity
import moe.lava.banksia.room.entity.ShapeEntity
import moe.lava.banksia.room.entity.StopEntity
import moe.lava.banksia.room.entity.StopTimeEntity
import moe.lava.banksia.room.entity.TripEntity
import androidx.room.Database as DatabaseAnnotation import androidx.room.Database as DatabaseAnnotation
@DatabaseAnnotation(entities = [Route::class, Shape::class], version = 1) @DatabaseAnnotation(
@TypeConverters(RouteType.Companion::class) version = 2,
entities = [
RouteEntity::class,
ShapeEntity::class,
StopEntity::class,
StopTimeEntity::class,
TripEntity::class,
],
autoMigrations = [
AutoMigration(from = 1, to = 2),
]
)
@TypeConverters(RouteTypeConverter::class)
abstract class Database : RoomDatabase() { abstract class Database : RoomDatabase() {
abstract fun getRouteDao(): RouteDao abstract val routeDao: RouteDao
abstract fun getShapeDao(): ShapeDao abstract val shapeDao: ShapeDao
abstract val stopDao: StopDao
abstract val stopTimeDao: StopTimeDao
abstract val tripDao: TripDao
companion object { companion object {
fun build(base: Builder<Database>) = fun build(base: Builder<Database>) =
base.fallbackToDestructiveMigrationOnDowngrade(true) base.fallbackToDestructiveMigration(true)
.setDriver(BundledSQLiteDriver()) .setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO) .setQueryCoroutineContext(Dispatchers.IO)
// .fallbackToDestructiveMigration(true)
.build() .build()
} }
} }

View file

@ -0,0 +1,12 @@
package moe.lava.banksia.room.converter
import androidx.room.TypeConverter
import moe.lava.banksia.model.RouteType
object RouteTypeConverter {
@TypeConverter
fun from(value: Int) = RouteType.entries.first { it.value == value }
@TypeConverter
fun to(routeType: RouteType) = routeType.value
}

View file

@ -4,7 +4,7 @@ import androidx.room.TypeConverter
import moe.lava.banksia.model.ShapePath import moe.lava.banksia.model.ShapePath
import moe.lava.banksia.util.Point import moe.lava.banksia.util.Point
object ShapeConverter { object ShapePathConverter {
@TypeConverter @TypeConverter
fun from(value: ByteArray): ShapePath { fun from(value: ByteArray): ShapePath {
return value return value

View file

@ -3,23 +3,47 @@ package moe.lava.banksia.room.dao
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy.Companion.REPLACE
import androidx.room.Query import androidx.room.Query
import moe.lava.banksia.model.Route import moe.lava.banksia.room.entity.RouteEntity
import moe.lava.banksia.room.entity.StopEntity
@Dao @Dao
interface RouteDao { interface RouteDao {
@Query("SELECT * FROM Route") @Query("SELECT * FROM Route")
suspend fun getAll(): List<Route> suspend fun getAll(): List<RouteEntity>
@Query("SELECT * FROM Route WHERE id == :id") @Query("SELECT * FROM Route WHERE id == :id")
suspend fun get(id: String): Route? suspend fun get(id: String): RouteEntity?
@Insert @Insert
suspend fun insertAll(vararg routes: Route) suspend fun insertAll(vararg routes: RouteEntity)
@Insert(onConflict = REPLACE)
suspend fun insertOrReplaceAll(vararg routes: RouteEntity)
@Delete @Delete
suspend fun delete(route: Route) suspend fun delete(route: RouteEntity)
@Query("DELETE FROM Route") @Query("DELETE FROM Route")
suspend fun deleteAll() suspend fun deleteAll()
@Query("""
SELECT Stop.* FROM Stop
INNER JOIN StopTime ON StopTime.stopId == Stop.id
INNER JOIN Trip ON Trip.id == StopTime.tripId
WHERE Trip.routeId == :id
GROUP BY Stop.id
""")
suspend fun stops(id: String): List<StopEntity>
@Query("""
SELECT Stop.* FROM Stop
INNER JOIN Stop Child ON Child.parent == Stop.id
INNER JOIN StopTime ON StopTime.stopId == Child.id
INNER JOIN Trip ON Trip.id == StopTime.tripId
WHERE Trip.routeId == :id
GROUP BY Stop.id
""")
suspend fun stopsParent(id: String): List<StopEntity>
} }

View file

@ -4,18 +4,18 @@ import androidx.room.Dao
import androidx.room.Delete import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.Query import androidx.room.Query
import moe.lava.banksia.model.Shape import moe.lava.banksia.room.entity.ShapeEntity
@Dao @Dao
interface ShapeDao { interface ShapeDao {
@Query("SELECT * FROM Shape WHERE id == :id") @Query("SELECT * FROM Shape WHERE id == :id")
suspend fun get(id: String): Shape? suspend fun get(id: String): ShapeEntity?
@Insert @Insert
suspend fun insertAll(vararg shapes: Shape) suspend fun insertAll(vararg shapes: ShapeEntity)
@Delete @Delete
suspend fun delete(shape: Shape) suspend fun delete(shape: ShapeEntity)
@Query("DELETE FROM Shape") @Query("DELETE FROM Shape")
suspend fun deleteAll() suspend fun deleteAll()

View file

@ -0,0 +1,32 @@
package moe.lava.banksia.room.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy.Companion.REPLACE
import androidx.room.Query
import moe.lava.banksia.room.entity.StopEntity
@Dao
interface StopDao {
@Query("SELECT * FROM Stop")
suspend fun getAll(): List<StopEntity>
@Query("SELECT * FROM Stop WHERE id == :id")
suspend fun get(id: String): StopEntity?
@Query("SELECT * FROM Stop WHERE id IN (:ids)")
suspend fun get(ids: List<String>): List<StopEntity>
@Insert
suspend fun insertAll(vararg stops: StopEntity)
@Insert(onConflict = REPLACE)
suspend fun insertOrReplaceAll(vararg stops: StopEntity)
@Delete
suspend fun delete(stop: StopEntity)
@Query("DELETE FROM Stop")
suspend fun deleteAll()
}

View file

@ -0,0 +1,32 @@
package moe.lava.banksia.room.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy.Companion.REPLACE
import androidx.room.Query
import moe.lava.banksia.room.entity.StopTimeEntity
@Dao
interface StopTimeDao {
@Query("SELECT * FROM StopTime")
suspend fun getAll(): List<StopTimeEntity>
@Query("SELECT * FROM StopTime WHERE tripId == :tripId")
suspend fun get(tripId: String): StopTimeEntity?
@Query("SELECT * FROM StopTime WHERE tripId IN (:tripIds)")
suspend fun get(tripIds: List<String>): List<StopTimeEntity>
@Insert
suspend fun insertAll(vararg stopTimes: StopTimeEntity)
@Insert(onConflict = REPLACE)
suspend fun insertOrReplaceAll(vararg stopTimes: StopTimeEntity)
@Delete
suspend fun delete(stopTime: StopTimeEntity)
@Query("DELETE FROM StopTime")
suspend fun deleteAll()
}

View file

@ -0,0 +1,32 @@
package moe.lava.banksia.room.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy.Companion.REPLACE
import androidx.room.Query
import moe.lava.banksia.room.entity.TripEntity
@Dao
interface TripDao {
@Query("SELECT * FROM Trip")
suspend fun getAll(): List<TripEntity>
@Query("SELECT * FROM Trip WHERE id == :id")
suspend fun get(id: String): TripEntity?
@Query("SELECT * FROM Trip WHERE routeId == :id")
suspend fun getByRoute(id: String): List<TripEntity>
@Insert
suspend fun insertAll(vararg trips: TripEntity)
@Insert(onConflict = REPLACE)
suspend fun insertOrReplaceAll(vararg trips: TripEntity)
@Delete
suspend fun delete(trip: TripEntity)
@Query("DELETE FROM Trip")
suspend fun deleteAll()
}

View file

@ -0,0 +1,18 @@
package moe.lava.banksia.room.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import moe.lava.banksia.model.Route
import moe.lava.banksia.model.RouteType
@Entity("Route")
data class RouteEntity(
@PrimaryKey val id: String,
val type: RouteType,
val number: String?,
val name: String,
) {
fun asModel() = Route(id, type, number, name)
}
fun Route.asEntity() = RouteEntity(id, type, number, name)

View file

@ -0,0 +1,58 @@
package moe.lava.banksia.room.entity
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import moe.lava.banksia.model.Service
data class ServiceEntity(
val id: String,
val days: Int,
val start: Int,
val end: Int,
) {
object Parser {
private fun Int.check(other: Int) = (this and other) != 0
fun deserialiseDays(days: Int): List<DayOfWeek> = buildList {
if (days.check(1))
add(DayOfWeek.MONDAY)
if (days.check(1 shl 1))
add(DayOfWeek.TUESDAY)
if (days.check(1 shl 2))
add(DayOfWeek.WEDNESDAY)
if (days.check(1 shl 3))
add(DayOfWeek.THURSDAY)
if (days.check(1 shl 4))
add(DayOfWeek.FRIDAY)
if (days.check(1 shl 5))
add(DayOfWeek.SATURDAY)
if (days.check(1 shl 6))
add(DayOfWeek.SUNDAY)
}
fun serialiseDays(days: List<DayOfWeek>): Int =
days.fold(0) { vl, n ->
vl + when (n) {
DayOfWeek.MONDAY -> 1
DayOfWeek.TUESDAY -> 1 shl 1
DayOfWeek.WEDNESDAY -> 1 shl 2
DayOfWeek.THURSDAY -> 1 shl 3
DayOfWeek.FRIDAY -> 1 shl 4
DayOfWeek.SATURDAY -> 1 shl 5
DayOfWeek.SUNDAY -> 1 shl 6
}
}
}
fun asModel() = Service(
id,
Parser.deserialiseDays(days),
LocalDate.fromEpochDays(start),
LocalDate.fromEpochDays(end),
)
}
fun Service.asEntity() = ServiceEntity(
id,
ServiceEntity.Parser.serialiseDays(days),
start.toEpochDays().toInt(),
end.toEpochDays().toInt(),
)

View file

@ -0,0 +1,19 @@
package moe.lava.banksia.room.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import moe.lava.banksia.model.Shape
import moe.lava.banksia.model.ShapePath
import moe.lava.banksia.room.converter.ShapePathConverter
@Entity("Shape")
@TypeConverters(ShapePathConverter::class)
data class ShapeEntity(
@PrimaryKey val id: String,
val path: ShapePath,
) {
fun asModel() = Shape(id, path)
}
fun Shape.asEntity() = ShapeEntity(id, path)

View file

@ -0,0 +1,23 @@
package moe.lava.banksia.room.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import moe.lava.banksia.model.Stop
import moe.lava.banksia.util.Point
@Entity("Stop")
data class StopEntity(
@PrimaryKey val id: String,
val name: String,
val lat: Double,
val lng: Double,
@ColumnInfo(index = true) val parent: String,
val hasWheelChairBoarding: Boolean,
val level: String,
val platformCode: String,
) {
fun asModel() = Stop(id, name, Point(lat, lng), parent, hasWheelChairBoarding, level, platformCode)
}
fun Stop.asEntity() = StopEntity(id, name, pos.lat, pos.lng, parent, hasWheelChairBoarding, level, platformCode)

View file

@ -0,0 +1,48 @@
package moe.lava.banksia.room.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import kotlinx.serialization.ExperimentalSerializationApi
import moe.lava.banksia.model.FutureTime
import moe.lava.banksia.model.FutureTime.Companion.asInt
import moe.lava.banksia.model.StopTime
@Entity(
"StopTime",
primaryKeys = ["tripId", "stopId"],
foreignKeys = [
ForeignKey(TripEntity::class, parentColumns = ["id"], childColumns = ["tripId"], onDelete = CASCADE),
ForeignKey(StopEntity::class, parentColumns = ["id"], childColumns = ["stopId"], onDelete = CASCADE),
]
)
data class StopTimeEntity(
val tripId: String,
val stopId: String,
val arrivalTime: Int,
val departureTime: Int,
val headsign: String?,
val pickupType: Int,
val dropOffType: Int,
) {
fun asModel() = StopTime(
tripId,
stopId,
FutureTime.fromInt(arrivalTime),
FutureTime.fromInt(departureTime),
headsign,
pickupType,
dropOffType,
)
}
@OptIn(ExperimentalSerializationApi::class)
fun StopTime.asEntity() = StopTimeEntity(
tripId,
stopId,
arrivalTime.asInt(),
departureTime.asInt(),
headsign,
pickupType,
dropOffType,
)

View file

@ -0,0 +1,30 @@
package moe.lava.banksia.room.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import androidx.room.PrimaryKey
import moe.lava.banksia.model.Trip
@Entity(
"Trip",
foreignKeys = [
ForeignKey(RouteEntity::class, parentColumns = ["id"], childColumns = ["routeId"], onDelete = CASCADE),
ForeignKey(ShapeEntity::class, parentColumns = ["id"], childColumns = ["shapeId"], onDelete = CASCADE),
],
)
data class TripEntity(
@PrimaryKey val id: String,
@ColumnInfo(index = true) val routeId: String,
val serviceId: String,
val shapeId: String?,
val tripHeadsign: String,
val directionId: String,
val blockId: String,
val wheelchairAccessible: String,
) {
fun asModel() = Trip(id, routeId, serviceId, shapeId, tripHeadsign, directionId, blockId, wheelchairAccessible)
}
fun Trip.asEntity() = TripEntity(id, routeId, serviceId, shapeId, tripHeadsign, directionId, blockId, wheelchairAccessible)

View file

@ -1,3 +1,6 @@
package moe.lava.banksia.util package moe.lava.banksia.util
import kotlinx.serialization.Serializable
@Serializable
data class Point(val lat: Double, val lng: Double) data class Point(val lat: Double, val lng: Double)

View file

@ -4,6 +4,7 @@ import androidx.room.RoomDatabase
import moe.lava.banksia.room.Database import moe.lava.banksia.room.Database
import org.koin.core.parameter.ParametersHolder import org.koin.core.parameter.ParametersHolder
import org.koin.core.scope.Scope import org.koin.core.scope.Scope
import org.koin.dsl.module
class IosDatabaseBuilder() : PlatformDatabaseBuilder { class IosDatabaseBuilder() : PlatformDatabaseBuilder {
override fun getBuilder(): RoomDatabase.Builder<Database> { override fun getBuilder(): RoomDatabase.Builder<Database> {
@ -13,3 +14,5 @@ class IosDatabaseBuilder() : PlatformDatabaseBuilder {
actual fun Scope.provideDatabaseBuilder(p: ParametersHolder): PlatformDatabaseBuilder = actual fun Scope.provideDatabaseBuilder(p: ParametersHolder): PlatformDatabaseBuilder =
IosDatabaseBuilder() IosDatabaseBuilder()
internal actual val ExtPlatformModule = module { }

View file

@ -5,6 +5,7 @@ import androidx.room.RoomDatabase
import moe.lava.banksia.room.Database import moe.lava.banksia.room.Database
import org.koin.core.parameter.ParametersHolder import org.koin.core.parameter.ParametersHolder
import org.koin.core.scope.Scope import org.koin.core.scope.Scope
import org.koin.dsl.module
import java.io.File import java.io.File
class JvmDatabaseBuilder() : PlatformDatabaseBuilder { class JvmDatabaseBuilder() : PlatformDatabaseBuilder {
@ -18,3 +19,5 @@ class JvmDatabaseBuilder() : PlatformDatabaseBuilder {
actual fun Scope.provideDatabaseBuilder(p: ParametersHolder): PlatformDatabaseBuilder = actual fun Scope.provideDatabaseBuilder(p: ParametersHolder): PlatformDatabaseBuilder =
JvmDatabaseBuilder() JvmDatabaseBuilder()
internal actual val ExtPlatformModule = module { }