diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index c3699d6..add54c9 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -41,6 +41,7 @@ kotlin { implementation(libs.play.services.location) implementation(libs.play.services.maps) implementation(libs.maps.compose) + implementation(libs.maps.compose.utils) } commonMain.dependencies { implementation(compose.runtime) @@ -53,8 +54,13 @@ kotlin { implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.koin.compose) + implementation(libs.koin.compose.viewmodel) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.datetime) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.contentnegotiation) + implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.moko.geo) implementation(libs.moko.geo.compose) implementation(projects.shared) diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 1e57403..928349e 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -11,6 +11,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:enableOnBackInvokedCallback="true" + android:usesCleartextTraffic="true" android:theme="@android:style/Theme.Material.Light.NoActionBar"> Unit, + onEvent: (MapScreenEvent) -> Unit, cameraPositionFlow: Flow>, setLastKnownLocation: (Point) -> Unit, extInsets: WindowInsets, @@ -135,7 +135,7 @@ actual fun Maps( zIndex = 0f, state = state, onClick = { - onEvent(BanksiaEvent.SelectStop(marker.type to marker.id)) + onEvent(MapScreenEvent.SelectStop(marker.type to marker.id)) false } ) { @@ -155,7 +155,7 @@ actual fun Maps( zIndex = 1f, state = state, onClick = { - onEvent(BanksiaEvent.SelectRun(marker.ref)) + onEvent(MapScreenEvent.SelectRun(marker.ref)) false } ) { diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/client/datasource/local/RouteLocalDataSource.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/client/datasource/local/RouteLocalDataSource.kt new file mode 100644 index 0000000..bfbb204 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/client/datasource/local/RouteLocalDataSource.kt @@ -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()) +} diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/client/datasource/local/StopLocalDataSource.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/client/datasource/local/StopLocalDataSource.kt new file mode 100644 index 0000000..1b418a0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/client/datasource/local/StopLocalDataSource.kt @@ -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()) +} diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/client/datasource/remote/RouteRemoteDataSource.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/client/datasource/remote/RouteRemoteDataSource.kt new file mode 100644 index 0000000..80ad0c6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/client/datasource/remote/RouteRemoteDataSource.kt @@ -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() + suspend fun getAll() = client.get("/routes").body>() +} diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/client/datasource/remote/StopRemoteDataSource.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/client/datasource/remote/StopRemoteDataSource.kt new file mode 100644 index 0000000..3c9d599 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/client/datasource/remote/StopRemoteDataSource.kt @@ -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() + suspend fun getByRoute(id: String) = client.get("/route_stops/${id}").body>() +} diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/client/di/ClientModule.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/client/di/ClientModule.kt new file mode 100644 index 0000000..7f5d14b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/client/di/ClientModule.kt @@ -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) +} diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/client/repository/RouteRepository.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/client/repository/RouteRepository.kt new file mode 100644 index 0000000..e237769 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/client/repository/RouteRepository.kt @@ -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) +} diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/client/repository/StopRepository.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/client/repository/StopRepository.kt new file mode 100644 index 0000000..83e9ed9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/client/repository/StopRepository.kt @@ -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) +} diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/App.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/App.kt index 9589e78..1f30bbe 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/App.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/App.kt @@ -1,194 +1,21 @@ package moe.lava.banksia.ui -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.add -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeContent -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material3.BottomSheetDefaults -import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SearchBarDefaults -import androidx.compose.material3.SheetValue -import androidx.compose.material3.rememberBottomSheetScaffoldState -import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.backhandler.PredictiveBackHandler -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import dev.icerock.moko.geo.compose.BindLocationTrackerEffect -import dev.icerock.moko.geo.compose.LocationTrackerAccuracy -import dev.icerock.moko.geo.compose.rememberLocationTrackerFactory -import kotlinx.coroutines.launch -import moe.lava.banksia.resources.Res -import moe.lava.banksia.resources.my_location_24 -import moe.lava.banksia.ui.layout.InfoPanel -import moe.lava.banksia.ui.layout.Searcher -import moe.lava.banksia.ui.platform.BanksiaTheme -import moe.lava.banksia.ui.platform.maps.Maps -import moe.lava.banksia.ui.platform.maps.getScreenHeight -import moe.lava.banksia.ui.state.InfoPanelState -import moe.lava.banksia.util.Point -import org.jetbrains.compose.resources.painterResource -import kotlin.coroutines.cancellation.CancellationException -import kotlin.math.roundToInt +import moe.lava.banksia.client.di.ClientModule +import moe.lava.banksia.di.CommonModules +import moe.lava.banksia.ui.screens.MapScreen +import org.koin.compose.KoinMultiplatformApplication +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.dsl.koinConfiguration -val MELBOURNE = Point(-37.8136, 144.9631) - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class, KoinExperimentalAPI::class) @Composable -fun App( - viewModel: BanksiaViewModel = viewModel() -) { - val scope = rememberCoroutineScope() - - val locationFactory = rememberLocationTrackerFactory(LocationTrackerAccuracy.Best) - val locationTracker = remember { locationFactory.createLocationTracker() } - BindLocationTrackerEffect(locationTracker) - viewModel.bindTracker(locationTracker) - scope.launch { locationTracker.startTracking() } - - val infoState by viewModel.infoState.collectAsStateWithLifecycle() - val mapState by viewModel.mapState.collectAsStateWithLifecycle() - val searchState by viewModel.searchState.collectAsStateWithLifecycle() - - val scaffoldState = rememberBottomSheetScaffoldState( - bottomSheetState = rememberStandardBottomSheetState( - initialValue = SheetValue.Hidden, - skipHiddenState = false - ) - ) - - val sheetState = scaffoldState.bottomSheetState - val extInsets = if ( - sheetState.currentValue != SheetValue.Hidden || - sheetState.targetValue != SheetValue.Hidden - ) { - val offset = runCatching { sheetState.requireOffset() } - val scaffoldOffset = offset.getOrDefault(0.0f).roundToInt() - (getScreenHeight() - scaffoldOffset - WindowInsets.Companion.safeDrawing.getBottom( - LocalDensity.current)).coerceAtLeast(0) - } else 0 - - LaunchedEffect(infoState) { - if (infoState !is InfoPanelState.None) - scope.launch { scaffoldState.bottomSheetState.partialExpand() } - else - scope.launch { scaffoldState.bottomSheetState.hide() } - } - - var searchExpandedState by rememberSaveable { mutableStateOf(false) } - var sheetSwipeEnabled by rememberSaveable { mutableStateOf(true) } - var handleHeight by remember { mutableStateOf(0.dp) } - var peekHeight by remember { mutableStateOf(0.dp) } - var peekHeightMultiplier by remember { mutableFloatStateOf(1F) } - - BanksiaTheme { - BottomSheetScaffold( - scaffoldState = scaffoldState, - sheetPeekHeight = (handleHeight + peekHeight) * peekHeightMultiplier, - modifier = Modifier.Companion.fillMaxSize(), - sheetContent = { - InfoPanel( - state = infoState, - onEvent = viewModel::handleEvent, - onPeekHeightChange = { peekHeight = it }, - ) - }, - sheetDragHandle = { - val density = LocalDensity.current - Box( - Modifier.Companion - .fillMaxWidth() - .padding(horizontal = 10.dp) - .onSizeChanged { - handleHeight = with(density) { it.height.toDp() } - } - ) { - BottomSheetDefaults.DragHandle(modifier = Modifier.Companion.align(Alignment.Companion.Center)) - } - }, - sheetSwipeEnabled = sheetSwipeEnabled, - ) { - Maps( - modifier = Modifier.Companion.fillMaxSize(), - state = mapState, - onEvent = viewModel::handleEvent, - cameraPositionFlow = viewModel.cameraChangeEmitter, - extInsets = WindowInsets(top = with(LocalDensity.current) { - SearchBarDefaults.InputFieldHeight.roundToPx() - }, bottom = extInsets), - setLastKnownLocation = viewModel::setLastKnownLocation, - ) - Searcher( - state = searchState, - onEvent = viewModel::handleEvent, - expanded = searchExpandedState, - onExpandedChange = { - searchExpandedState = it - if (it) - scope.launch { scaffoldState.bottomSheetState.hide() } - }, - ) - - PredictiveBackHandler(scaffoldState.bottomSheetState.currentValue != SheetValue.Hidden) { progress -> - sheetSwipeEnabled = false - try { - progress.collect { backEvent -> - if (scaffoldState.bottomSheetState.currentValue == SheetValue.PartiallyExpanded) { - peekHeightMultiplier = 1F - backEvent.progress - } - } - if (scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded) - scope.launch { scaffoldState.bottomSheetState.partialExpand() } - else if (scaffoldState.bottomSheetState.currentValue == SheetValue.PartiallyExpanded) - scope.launch { - scaffoldState.bottomSheetState.hide() - peekHeightMultiplier = 1F - viewModel.handleEvent(BanksiaEvent.DismissState) - } - } catch (_: CancellationException) { - peekHeightMultiplier = 1F - } - sheetSwipeEnabled = true - } - - Box( - Modifier.Companion.windowInsetsPadding( - WindowInsets.Companion.safeContent.add( - WindowInsets(bottom = extInsets) - ) - ), - contentAlignment = Alignment.Companion.BottomEnd - ) { - FloatingActionButton( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - onClick = { viewModel.centreCameraToLocation() }, - ) { - Icon(painterResource(Res.drawable.my_location_24), "Move to current location") - } - } - } +fun App() { + KoinMultiplatformApplication(config = koinConfiguration { + modules(CommonModules, ClientModule) + }) { + MapScreen() } } diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/components/RouteIcon.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/components/RouteIcon.kt index 151012d..e147aea 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/components/RouteIcon.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/components/RouteIcon.kt @@ -12,6 +12,15 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import moe.lava.banksia.data.ptv.structures.PtvRouteType +import moe.lava.banksia.model.RouteType +import moe.lava.banksia.model.RouteType.Interstate +import moe.lava.banksia.model.RouteType.MetroBus +import moe.lava.banksia.model.RouteType.MetroTrain +import moe.lava.banksia.model.RouteType.MetroTram +import moe.lava.banksia.model.RouteType.RegionalBus +import moe.lava.banksia.model.RouteType.RegionalCoach +import moe.lava.banksia.model.RouteType.RegionalTrain +import moe.lava.banksia.model.RouteType.SkyBus import moe.lava.banksia.resources.Res import moe.lava.banksia.resources.bus import moe.lava.banksia.resources.bus_background @@ -33,12 +42,51 @@ data class RouteTypeProperties( val icon: DrawableResource, ) +const val TRAIN_BLUE = 0xFF0072CE +const val TRAM_GREEN = 0xFF78BE20 +const val BUS_ORANGE = 0xFFFF8200 +const val VLINE_PURPLE = 0xFF8F1A95 + +fun RouteType.getUIProperties(): RouteTypeProperties { + val colour = when (this) { + MetroTrain -> TRAIN_BLUE + MetroTram -> TRAM_GREEN + MetroBus -> BUS_ORANGE + RegionalTrain -> VLINE_PURPLE + RegionalCoach -> VLINE_PURPLE + RegionalBus -> VLINE_PURPLE + SkyBus -> BUS_ORANGE + Interstate -> BUS_ORANGE + } + + val (drawable, background, icon) = when (this) { + MetroTrain, + RegionalTrain, + Interstate -> Triple( + Res.drawable.train, Res.drawable.train_background, Res.drawable.train_icon + ) + + MetroTram -> Triple( + Res.drawable.tram, Res.drawable.tram_background, Res.drawable.tram_icon + ) + + MetroBus, + RegionalCoach, + RegionalBus, + SkyBus -> Triple( + Res.drawable.bus, Res.drawable.bus_background, Res.drawable.bus_icon + ) + } + + return RouteTypeProperties(Color(colour), drawable, background, icon) +} + fun PtvRouteType.getUIProperties(): RouteTypeProperties { val colour = when (this) { - PtvRouteType.TRAIN -> Color(0xFF0072CE) - PtvRouteType.TRAM -> Color(0xFF78BE20) - PtvRouteType.BUS, PtvRouteType.NIGHT_BUS -> Color(0xFFFF8200) - PtvRouteType.VLINE -> Color(0xFF8F1A95) + PtvRouteType.TRAIN -> Color(TRAIN_BLUE) + PtvRouteType.TRAM -> Color(TRAM_GREEN) + PtvRouteType.BUS, PtvRouteType.NIGHT_BUS -> Color(BUS_ORANGE) + PtvRouteType.VLINE -> Color(VLINE_PURPLE) } val (drawable, background, icon) = when (this) { PtvRouteType.TRAM -> Triple( @@ -58,7 +106,7 @@ fun PtvRouteType.getUIProperties(): RouteTypeProperties { fun RouteIcon( modifier: Modifier = Modifier.Companion, size: Dp = 40.dp, - routeType: PtvRouteType, + routeType: RouteType, ) { val properties = routeType.getUIProperties() Image( @@ -80,9 +128,9 @@ const val ICON_PADDING = 0.25f @Composable private fun RouteIconPreview() { Row { - RouteIcon(routeType = PtvRouteType.TRAIN) - RouteIcon(routeType = PtvRouteType.TRAM) - RouteIcon(routeType = PtvRouteType.BUS) + RouteIcon(routeType = RouteType.MetroTrain) + RouteIcon(routeType = RouteType.MetroTram) + RouteIcon(routeType = RouteType.MetroBus) } } diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/layout/InfoPanel.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/layout/InfoPanel.kt index 745f777..00e871b 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/layout/InfoPanel.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/layout/InfoPanel.kt @@ -29,14 +29,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.coerceAtMost import androidx.compose.ui.unit.dp -import moe.lava.banksia.ui.BanksiaEvent import moe.lava.banksia.ui.components.RouteIcon +import moe.lava.banksia.ui.screens.MapScreenEvent import moe.lava.banksia.ui.state.InfoPanelState @Composable fun InfoPanel( state: InfoPanelState, - onEvent: (BanksiaEvent) -> Unit, + onEvent: (MapScreenEvent) -> Unit, onPeekHeightChange: (Dp) -> Unit, ) { if (state is InfoPanelState.None) @@ -74,7 +74,7 @@ fun InfoPanel( @Composable private inline fun RouteInfoPanel( state: InfoPanelState.Route, - onEvent: (BanksiaEvent) -> Unit, + onEvent: (MapScreenEvent) -> Unit, ) { Column(Modifier.Companion.fillMaxWidth()) { Row { @@ -92,7 +92,7 @@ private inline fun RouteInfoPanel( @Composable private inline fun RunInfoPanel( state: InfoPanelState.Run, - onEvent: (BanksiaEvent) -> Unit, + onEvent: (MapScreenEvent) -> Unit, ) { Column(Modifier.Companion.fillMaxWidth()) { Row { @@ -110,7 +110,7 @@ private inline fun RunInfoPanel( @Composable private inline fun StopInfoPanel( state: InfoPanelState.Stop, - onEvent: (BanksiaEvent) -> Unit, + onEvent: (MapScreenEvent) -> Unit, ) { Column(Modifier.Companion.fillMaxWidth()) { Text( diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/layout/Searcher.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/layout/Searcher.kt index 3dbe38f..7269ee7 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/layout/Searcher.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/layout/Searcher.kt @@ -23,15 +23,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import moe.lava.banksia.ui.BanksiaEvent import moe.lava.banksia.ui.components.RouteIcon +import moe.lava.banksia.ui.screens.MapScreenEvent import moe.lava.banksia.ui.state.SearchState @OptIn(ExperimentalMaterial3Api::class) @Composable fun Searcher( state: SearchState, - onEvent: (BanksiaEvent) -> Unit, + onEvent: (MapScreenEvent) -> Unit, expanded: Boolean, onExpandedChange: (Boolean) -> Unit, ) { @@ -55,7 +55,7 @@ fun Searcher( SearchBarDefaults.InputField( modifier = Modifier.Companion.padding(horizontal = 20.dp - animatedPadding), query = state.text, - onQueryChange = { onEvent(BanksiaEvent.SearchUpdate(it)) }, + onQueryChange = { onEvent(MapScreenEvent.SearchUpdate(it)) }, onSearch = {}, expanded = expanded, onExpandedChange = onExpandedChange, @@ -67,7 +67,7 @@ fun Searcher( contentDescription = null, modifier = Modifier.Companion.clickable { onEvent( - BanksiaEvent.SearchUpdate( + MapScreenEvent.SearchUpdate( "" ) ) @@ -92,8 +92,8 @@ fun Searcher( .padding(horizontal = 16.dp, vertical = 4.dp) .clickable { onExpandedChange(false) - onEvent(BanksiaEvent.SearchUpdate("")) - onEvent(BanksiaEvent.SelectRoute(entry.routeId)) + onEvent(MapScreenEvent.SearchUpdate("")) + onEvent(MapScreenEvent.SelectRoute(entry.routeId)) } ) } diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/platform/maps/Maps.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/platform/maps/Maps.kt index 2aa252e..06678e9 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/platform/maps/Maps.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/platform/maps/Maps.kt @@ -5,7 +5,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import kotlinx.coroutines.flow.Flow -import moe.lava.banksia.ui.BanksiaEvent +import moe.lava.banksia.ui.screens.MapScreenEvent import moe.lava.banksia.ui.state.MapState import moe.lava.banksia.util.BoxedValue import moe.lava.banksia.util.Point @@ -18,7 +18,7 @@ expect fun getScreenHeight(): Int expect fun Maps( modifier: Modifier = Modifier.Companion, state: MapState, - onEvent: (BanksiaEvent) -> Unit, + onEvent: (MapScreenEvent) -> Unit, cameraPositionFlow: Flow>, setLastKnownLocation: (Point) -> Unit, extInsets: WindowInsets, diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/platform/maps/Marker.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/platform/maps/Marker.kt index 223335b..53e8642 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/platform/maps/Marker.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/platform/maps/Marker.kt @@ -1,7 +1,7 @@ package moe.lava.banksia.ui.platform.maps import androidx.compose.ui.graphics.Color -import moe.lava.banksia.data.ptv.structures.PtvRouteType +import moe.lava.banksia.model.RouteType import moe.lava.banksia.util.Point sealed class Marker { @@ -9,14 +9,14 @@ sealed class Marker { data class Stop( override val point: Point, - val id: Int, - val type: PtvRouteType, + val id: String, + val type: RouteType, val colour: Color, ) : Marker() data class Vehicle( override val point: Point, val ref: String, - val type: PtvRouteType, + val type: RouteType, ) : Marker() } diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/screens/MapScreen.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/screens/MapScreen.kt new file mode 100644 index 0000000..fa50ecc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/screens/MapScreen.kt @@ -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") + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/BanksiaViewModel.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/screens/MapScreenViewModel.kt similarity index 74% rename from composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/BanksiaViewModel.kt rename to composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/screens/MapScreenViewModel.kt index 94d0178..1c22cc2 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/BanksiaViewModel.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/screens/MapScreenViewModel.kt @@ -1,4 +1,4 @@ -package moe.lava.banksia.ui +package moe.lava.banksia.ui.screens import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -13,9 +13,12 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import moe.lava.banksia.client.repository.RouteRepository +import moe.lava.banksia.client.repository.StopRepository import moe.lava.banksia.data.ptv.PtvService import moe.lava.banksia.data.ptv.structures.PtvRoute -import moe.lava.banksia.data.ptv.structures.PtvRouteType +import moe.lava.banksia.model.Route +import moe.lava.banksia.model.RouteType import moe.lava.banksia.ui.components.getUIProperties import moe.lava.banksia.ui.platform.maps.CameraPosition import moe.lava.banksia.ui.platform.maps.CameraPositionBounds @@ -32,23 +35,27 @@ import moe.lava.banksia.util.log import kotlin.time.Clock import kotlin.time.Instant -sealed class BanksiaEvent { - data object DismissState : BanksiaEvent() +sealed class MapScreenEvent { + data object DismissState : MapScreenEvent() - data class SelectRoute(val id: Int?) : BanksiaEvent() - data class SelectRun(val ref: String?) : BanksiaEvent() - data class SelectStop(val typeAndId: Pair) : BanksiaEvent() + data class SelectRoute(val id: String?) : MapScreenEvent() + data class SelectRun(val ref: String?) : MapScreenEvent() + data class SelectStop(val typeIdPair: Pair?) : MapScreenEvent() - data class SearchUpdate(val text: String) : BanksiaEvent() + data class SearchUpdate(val text: String) : MapScreenEvent() } data class InternalState( - val route: Int? = null, - val stop: Pair? = null, + val route: String? = null, + val stop: Pair? = null, val run: String? = null, ) -class BanksiaViewModel : ViewModel() { +class MapScreenViewModel( + private val ptvService: PtvService, + private val routeRepository: RouteRepository, + private val stopRepository: StopRepository, +) : ViewModel() { private var state = InternalState() set(value) { val last = field @@ -72,7 +79,6 @@ class BanksiaViewModel : ViewModel() { private val iSearchState = MutableStateFlow(SearchState()) val searchState = iSearchState.asStateFlow() - private val ptvService = PtvService(viewModelScope) private var locationTrackerJob: Job? = null private var lastKnownLocation: Point? = null @@ -80,14 +86,14 @@ class BanksiaViewModel : ViewModel() { viewModelScope.launch { searchUpdate("") } } - fun handleEvent(event: BanksiaEvent) { + fun handleEvent(event: MapScreenEvent) { viewModelScope.launch { when (event) { - is BanksiaEvent.DismissState -> dismissState() - is BanksiaEvent.SelectRoute -> state = InternalState(route = event.id) - is BanksiaEvent.SelectRun -> state = state.copy(run = event.ref, stop = null) - is BanksiaEvent.SelectStop -> state = state.copy(stop = event.typeAndId, run = null) - is BanksiaEvent.SearchUpdate -> searchUpdate(event.text) + is MapScreenEvent.DismissState -> dismissState() + is MapScreenEvent.SelectRoute -> state = InternalState(route = event.id) + is MapScreenEvent.SelectRun -> state = state.copy(run = event.ref, stop = null) + is MapScreenEvent.SelectStop -> state = state.copy(stop = event.typeIdPair, run = null) + is MapScreenEvent.SearchUpdate -> searchUpdate(event.text) } } } @@ -99,6 +105,11 @@ class BanksiaViewModel : ViewModel() { } fun centreCameraToLocation() { + viewModelScope.launch { + log("msvm", "getting..") + val routes = routeRepository.getAll() + log("msvm", routes.joinToString("\n")) + } lastKnownLocation?.let { location -> viewModelScope.launch { log("bvm", "emitting $location") @@ -117,46 +128,48 @@ class BanksiaViewModel : ViewModel() { } private suspend fun searchUpdate(text: String) { - val entries = ptvService.routes() + iSearchState.update { it.copy(text = text) } + val entries = routeRepository.getAll() .sortedWith( compareBy( - { it.gtfsSubType()?.ordinal }, - { it.routeNumber.toIntOrNull() }, - { it.routeName } + { it.type.ordinal }, + { it.number }, + { it.name } ) ) - .filter { it.routeNumber.contains(text) || it.routeName.lowercase().contains(text.lowercase()) } + .filter { (it.number ?: "").contains(text) || it.name.lowercase().contains(text.lowercase()) } .map { route -> - val (main, sub) = if (route.routeNumber.isNotEmpty()) { - route.routeNumber to route.routeName + val (main, sub) = if (route.number?.isNotEmpty() == true) { + route.number to route.name } else { - route.routeName to null + route.name to null } - SearchState.SearchEntry(main, sub, route.routeId, route.routeType) + SearchState.SearchEntry(main!!, sub, route.id, route.type) } iSearchState.update { SearchState(entries, text) } } - private suspend fun switchRoute(routeId: Int?) { + private suspend fun switchRoute(routeId: String?) { iMapState.update { MapState() } if (routeId == null) { iInfoState.update { InfoPanelState.None } return } - val route = ptvService.route(routeId) + val route = routeRepository.get(routeId) +// val gtfsRoute = ptvService.route(routeId) iInfoState.update { InfoPanelState.Route( - name = route.routeName, - type = route.routeType, + name = route.name, + type = route.type, ) } - viewModelScope.launch { buildPolylines(route) } +// viewModelScope.launch { buildPolylines(gtfsRoute) } viewModelScope.launch { buildStops(route) } - buildRuns(route) +// buildRuns(gtfsRoute) } private fun switchRun(ref: String?) { @@ -175,7 +188,7 @@ class BanksiaViewModel : ViewModel() { iInfoState.update { InfoPanelState.Run( direction = run.destinationName, - type = run.routeType, + type = RouteType.MetroTrain, // XXX HACK TODO FIXME ) } routeName = ptvService.route(run.routeId).routeName @@ -184,7 +197,7 @@ class BanksiaViewModel : ViewModel() { iInfoState.update { InfoPanelState.Run( direction = run.destinationName, - type = run.routeType, + type = RouteType.MetroTrain, // FIXME HACK XXX TODO routeName = routeName, ) } @@ -193,25 +206,27 @@ class BanksiaViewModel : ViewModel() { } // [TODO]: Cleanup - private suspend fun switchStop(typeAndId: Pair?) { - if (typeAndId == null) { + private suspend fun switchStop(pair: Pair?) { + if (pair == null) { iInfoState.update { InfoPanelState.None } return } - val (routeType, stopId) = typeAndId - val stop = ptvService.stop(routeType, stopId) - val split = stop.stopName.split("/") + val (type, id) = pair + + val stop = stopRepository.get(id) +// val stop = ptvService.stop(routeType, stopId) + val split = stop.name.split("/") val name = split[0] val subname = split.getOrNull(1) iInfoState.update { InfoPanelState.Stop( - id = stop.stopId, + id = stop.id, name = name, subname = subname, ) } - val res = ptvService.departures(stop.routeType, stop.stopId) + val res = ptvService.departures(type, stop.id) // Map< // Pair, // Pair> @@ -285,7 +300,7 @@ class BanksiaViewModel : ViewModel() { ptvService .runsFlow(route.routeId) .waitUntilSubscribed(iInfoState) - .takeWhile { state.route == route.routeId } +// .takeWhile { state.route == route.routeId } .onEach { runs -> val markers = runs .filter { it.vehiclePosition != null } @@ -295,7 +310,7 @@ class BanksiaViewModel : ViewModel() { Marker.Vehicle( Point(pos.latitude, pos.longitude), ref = run.runRef, - type = route.routeType, + type = RouteType.MetroTrain, // HACK TODO XXX FIXME ) } @@ -305,18 +320,17 @@ class BanksiaViewModel : ViewModel() { } - private suspend fun buildStops(route: PtvRoute) { - val stops = ptvService.stopsByRoute(route.routeId, route.routeType) - val colour = route.routeType.getUIProperties().colour + private suspend fun buildStops(route: Route) { + val stops = stopRepository.getByRoute(route.id) + val colour = route.type.getUIProperties().colour val markers = stops - .filter { it.stopLatitude != null && it.stopLongitude != null } .map { stop -> Marker.Stop( - point = Point(stop.stopLatitude!!, stop.stopLongitude!!), - id = stop.stopId, + point = stop.pos, + id = stop.id, colour = colour, - type = route.routeType, + type = route.type, ) } diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/InfoPanelState.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/InfoPanelState.kt index 1ad4eb3..b0acbec 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/InfoPanelState.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/InfoPanelState.kt @@ -1,6 +1,6 @@ package moe.lava.banksia.ui.state -import moe.lava.banksia.data.ptv.structures.PtvRouteType +import moe.lava.banksia.model.RouteType sealed class InfoPanelState { abstract val loading: Boolean @@ -11,21 +11,21 @@ sealed class InfoPanelState { data class Route( val name: String, - val type: PtvRouteType, + val type: RouteType, ) : InfoPanelState() { override val loading = false } data class Run( val direction: String, - val type: PtvRouteType, + val type: RouteType, val routeName: String? = null, ) : InfoPanelState() { override val loading = routeName == null } data class Stop( - val id: Int, + val id: String, val name: String, val subname: String? = null, val departures: List? = null, diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/SearchState.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/SearchState.kt index 41d9ae6..05429cb 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/SearchState.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/state/SearchState.kt @@ -1,6 +1,6 @@ package moe.lava.banksia.ui.state -import moe.lava.banksia.data.ptv.structures.PtvRouteType +import moe.lava.banksia.model.RouteType data class SearchState( val entries: List = listOf(), @@ -9,7 +9,7 @@ data class SearchState( data class SearchEntry( val mainText: String, val subText: String?, - val routeId: Int, - val routeType: PtvRouteType, + val routeId: String, + val routeType: RouteType, ) } diff --git a/composeApp/src/iosMain/kotlin/moe/lava/banksia/ui/platform/maps/Maps.ios.kt b/composeApp/src/iosMain/kotlin/moe/lava/banksia/ui/platform/maps/Maps.ios.kt index b3e0bfc..399e8b9 100644 --- a/composeApp/src/iosMain/kotlin/moe/lava/banksia/ui/platform/maps/Maps.ios.kt +++ b/composeApp/src/iosMain/kotlin/moe/lava/banksia/ui/platform/maps/Maps.ios.kt @@ -7,7 +7,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalWindowInfo import kotlinx.coroutines.flow.Flow -import moe.lava.banksia.ui.BanksiaEvent +import moe.lava.banksia.ui.screens.MapScreenEvent import moe.lava.banksia.ui.state.MapState import moe.lava.banksia.util.BoxedValue import moe.lava.banksia.util.Point @@ -23,7 +23,7 @@ actual fun getScreenHeight(): Int { actual fun Maps( modifier: Modifier, state: MapState, - onEvent: (BanksiaEvent) -> Unit, + onEvent: (MapScreenEvent) -> Unit, cameraPositionFlow: Flow>, setLastKnownLocation: (Point) -> Unit, extInsets: WindowInsets, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 531c88e..517ece2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ koin = "4.1.0" kotlin = "2.2.0" kotlinxDatetime = "0.7.1" kotlinxSerializationCsv = "0.2.18" -kotlinxSerializationJson = "1.9.0" +kotlinxSerialization = "1.9.0" ksp = "2.2.0-2.0.2" ktor = "3.2.3" 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-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" } +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-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-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } 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-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-core = { module = "io.ktor:ktor-client-core", 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-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-netty = { module = "io.ktor:ktor-server-netty-jvm", 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" } 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" } 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" } diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 8ecec3b..2f7d989 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -18,9 +18,12 @@ dependencies { implementation(libs.koin.core) implementation(libs.koin.ktor) implementation(libs.kotlinx.serialization.csv) - implementation(libs.ktor.client.core) + implementation(libs.kotlinx.datetime) implementation(libs.ktor.client.contentnegotiation) + implementation(libs.ktor.client.core) 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.netty) implementation(libs.room.runtime) diff --git a/server/src/main/kotlin/moe/lava/banksia/server/Application.kt b/server/src/main/kotlin/moe/lava/banksia/server/Application.kt index 1969312..07901df 100644 --- a/server/src/main/kotlin/moe/lava/banksia/server/Application.kt +++ b/server/src/main/kotlin/moe/lava/banksia/server/Application.kt @@ -1,17 +1,24 @@ package moe.lava.banksia.server 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.install import io.ktor.server.application.log import io.ktor.server.engine.embeddedServer 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.routing.get import io.ktor.server.routing.routing import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext 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.gtfs.GtfsHandler import org.koin.dsl.module @@ -24,6 +31,9 @@ fun main() { } fun Application.module() { + install(ContentNegotiation) { + json() + } install(Koin) { modules(module { single { log } }) modules(CommonModules, ServerModules) @@ -40,5 +50,66 @@ fun Application.module() { handler.update(datasetUrl) } } + + get("/routes") { + val routes = withContext(context = Dispatchers.IO) { + inject().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().value.get(routeId) + } + if (route != null) + call.respond(route.asModel()) + else + call.respond(HttpStatusCode.NotFound) + } + get("/stops") { + val routes = withContext(context = Dispatchers.IO) { + inject().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().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() + if (useParent) + routeDao.stopsParent(routeId) + else + routeDao.stops(routeId) + } + call.respond(stops.map { it.asModel() }) +// val stops = withContext(Dispatchers.IO) { +// val stopDao by inject() +// val stopTimeDao by inject() +// val tripDao by inject() +// +// 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) + } } } diff --git a/server/src/main/kotlin/moe/lava/banksia/server/gtfs/GtfsHandler.kt b/server/src/main/kotlin/moe/lava/banksia/server/gtfs/GtfsHandler.kt index 7c537f4..213c0b9 100644 --- a/server/src/main/kotlin/moe/lava/banksia/server/gtfs/GtfsHandler.kt +++ b/server/src/main/kotlin/moe/lava/banksia/server/gtfs/GtfsHandler.kt @@ -12,12 +12,18 @@ import io.ktor.utils.io.copyAndClose import kotlinx.serialization.decodeFromString import kotlinx.serialization.modules.EmptySerializersModule import moe.lava.banksia.model.Route -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.ShapeDao +import moe.lava.banksia.model.Stop +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.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 java.io.File import java.util.zip.ZipFile @@ -25,9 +31,7 @@ import java.util.zip.ZipFile class GtfsHandler( private val log: Logger, private val client: HttpClient, - - private val routeDao: RouteDao, - private val shapeDao: ShapeDao, + private val db: Database, ) { private val csv = CsvFormat(StringDeferringConfig(EmptySerializersModule())) private val datasetPath = File("/tmp/banksia", "dataset.zip") @@ -49,27 +53,30 @@ class GtfsHandler( } 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) { + val dao = db.routeDao log.info("parsing routes...") val routes = files .filter { it.name == "routes.txt" } .flatMap { fd -> parseRoutes(fd) } log.info("inserting routes...") - routeDao.deleteAll() - routeDao.insertAll(*routes.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!") + dao.deleteAll() + dao.insertAll(*routes.map { it.asEntity() }.toTypedArray()) } private fun parseRoutes(fd: File) = @@ -77,12 +84,24 @@ class GtfsHandler( .map { with(it) { Route( id = route_id, - type = RouteType.from(fd.parentFile.name.toInt()), + type = RouteTypeConverter.from(fd.parentFile.name.toInt()), number = route_short_name, name = route_long_name, ) } } + private suspend fun addShapes(files: List) { + 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) = fd.parseCsv() .groupBy { it.shape_id } @@ -94,6 +113,95 @@ class GtfsHandler( Shape(id, points) } + private suspend fun addStops(files: List) { + 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() + .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) { + 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() + .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) { + 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() + .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 { val outputs = mutableListOf() @@ -114,7 +222,7 @@ class GtfsHandler( private fun extractAll(fd: File) = extract(fd).flatMap(::extract) - private fun File.parseCsv(): List = this + private inline fun File.parseCsv(): List = this .readText() .replace("\uFEFF", "") // remove bom .replace("\r\n", "\n") // crlf -> lf diff --git a/server/src/main/kotlin/moe/lava/banksia/server/gtfs/structures/GtfsStop.kt b/server/src/main/kotlin/moe/lava/banksia/server/gtfs/structures/GtfsStop.kt new file mode 100644 index 0000000..023a3e1 --- /dev/null +++ b/server/src/main/kotlin/moe/lava/banksia/server/gtfs/structures/GtfsStop.kt @@ -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, +) diff --git a/server/src/main/kotlin/moe/lava/banksia/server/gtfs/structures/GtfsStopTime.kt b/server/src/main/kotlin/moe/lava/banksia/server/gtfs/structures/GtfsStopTime.kt new file mode 100644 index 0000000..61e8a1c --- /dev/null +++ b/server/src/main/kotlin/moe/lava/banksia/server/gtfs/structures/GtfsStopTime.kt @@ -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) + } + } +} diff --git a/server/src/main/kotlin/moe/lava/banksia/server/gtfs/structures/GtfsTrip.kt b/server/src/main/kotlin/moe/lava/banksia/server/gtfs/structures/GtfsTrip.kt new file mode 100644 index 0000000..fcfc864 --- /dev/null +++ b/server/src/main/kotlin/moe/lava/banksia/server/gtfs/structures/GtfsTrip.kt @@ -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, +) diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 53a79bf..a1c2391 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -37,6 +37,7 @@ kotlin { sourceSets { androidMain.dependencies { + implementation(libs.koin.compose) implementation(libs.ktor.client.okhttp) } commonMain.dependencies { @@ -48,6 +49,7 @@ kotlin { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.serialization.protobuf) implementation(libs.room.runtime) implementation(libs.sqlite.bundled) } diff --git a/shared/schemas/moe.lava.banksia.room.Database/2.json b/shared/schemas/moe.lava.banksia.room.Database/2.json new file mode 100644 index 0000000..04a14e3 --- /dev/null +++ b/shared/schemas/moe.lava.banksia.room.Database/2.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/moe/lava/banksia/di/PlatformModule.android.kt b/shared/src/androidMain/kotlin/moe/lava/banksia/di/PlatformModule.android.kt index 6a198c9..0447f4b 100644 --- a/shared/src/androidMain/kotlin/moe/lava/banksia/di/PlatformModule.android.kt +++ b/shared/src/androidMain/kotlin/moe/lava/banksia/di/PlatformModule.android.kt @@ -6,6 +6,7 @@ import androidx.room.RoomDatabase import moe.lava.banksia.room.Database import org.koin.core.parameter.ParametersHolder import org.koin.core.scope.Scope +import org.koin.dsl.module class AndroidDatabaseBuilder(val ctx: Context) : PlatformDatabaseBuilder { override fun getBuilder(): RoomDatabase.Builder { @@ -19,4 +20,6 @@ class AndroidDatabaseBuilder(val ctx: Context) : PlatformDatabaseBuilder { } actual fun Scope.provideDatabaseBuilder(p: ParametersHolder): PlatformDatabaseBuilder = - AndroidDatabaseBuilder(p.get()) + AndroidDatabaseBuilder(get()) + +internal actual val ExtPlatformModule = module { } diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/data/ptv/PtvService.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/data/ptv/PtvService.kt index c7e5f3b..9a3ead6 100644 --- a/shared/src/commonMain/kotlin/moe/lava/banksia/data/ptv/PtvService.kt +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/data/ptv/PtvService.kt @@ -13,7 +13,6 @@ import io.ktor.client.request.url import io.ktor.client.statement.HttpResponse import io.ktor.http.appendPathSegments import io.ktor.serialization.kotlinx.json.json -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.serialization.Serializable 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.PtvRoute 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.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.error import moe.lava.banksia.util.log @@ -59,16 +59,15 @@ suspend inline fun MutableMap.getOrPutSuspend(key: K, defaultValue: return this[key]!! } -class PtvService(coroutineScope: CoroutineScope) { +class PtvService() { class PtvCache( - coroutineScope: CoroutineScope, - val directions: CacheMap, PtvDirection> = CacheMap(coroutineScope), - val routes: CacheMap = CacheMap(coroutineScope), - val runs: CacheMap = CacheMap(coroutineScope), - val stops: CacheMap = CacheMap(coroutineScope), + val directions: MutableMap, PtvDirection> = mutableMapOf(), + val routes: MutableMap = mutableMapOf(), + val runs: MutableMap = mutableMapOf(), + val stops: MutableMap = mutableMapOf(), ) - val cache = PtvCache(coroutineScope) + val cache = PtvCache() private val client = HttpClient() { install(ContentNegotiation) { @@ -227,6 +226,20 @@ class PtvService(coroutineScope: CoroutineScope) { 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 = client .safeGet ("departures") { diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/data/ptv/structures/PtvRouteType.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/data/ptv/structures/PtvRouteType.kt index 611b6c4..0726665 100644 --- a/shared/src/commonMain/kotlin/moe/lava/banksia/data/ptv/structures/PtvRouteType.kt +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/data/ptv/structures/PtvRouteType.kt @@ -7,6 +7,7 @@ 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.RouteType private object PtvRouteTypeSerialiser : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( @@ -30,4 +31,20 @@ enum class PtvRouteType { BUS, VLINE, 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) + } } diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/di/CommonModules.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/di/CommonModules.kt index 850907c..769d461 100644 --- a/shared/src/commonMain/kotlin/moe/lava/banksia/di/CommonModules.kt +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/di/CommonModules.kt @@ -7,6 +7,9 @@ val CommonModules = module { includes(PlatformModule) single { Database.build(get().getBuilder()) } - single { get().getRouteDao() } - single { get().getShapeDao() } + single { get().routeDao } + single { get().shapeDao } + single { get().stopDao } + single { get().stopTimeDao } + single { get().tripDao } } diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/di/PlatformModule.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/di/PlatformModule.kt index f620bdf..6f29f14 100644 --- a/shared/src/commonMain/kotlin/moe/lava/banksia/di/PlatformModule.kt +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/di/PlatformModule.kt @@ -2,6 +2,7 @@ package moe.lava.banksia.di import androidx.room.RoomDatabase import moe.lava.banksia.room.Database +import org.koin.core.module.Module import org.koin.core.parameter.ParametersHolder import org.koin.core.scope.Scope import org.koin.dsl.module @@ -12,6 +13,9 @@ interface PlatformDatabaseBuilder { expect fun Scope.provideDatabaseBuilder(p: ParametersHolder): PlatformDatabaseBuilder +internal expect val ExtPlatformModule: Module + internal val PlatformModule = module { + includes(ExtPlatformModule) single { provideDatabaseBuilder(it) } } diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/model/FutureTime.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/model/FutureTime.kt new file mode 100644 index 0000000..728a530 --- /dev/null +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/model/FutureTime.kt @@ -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 { + 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()) +} diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/model/Route.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/model/Route.kt index a56e49b..9cfff0f 100644 --- a/shared/src/commonMain/kotlin/moe/lava/banksia/model/Route.kt +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/model/Route.kt @@ -1,11 +1,10 @@ package moe.lava.banksia.model -import androidx.room.Entity -import androidx.room.PrimaryKey +import kotlinx.serialization.Serializable -@Entity +@Serializable data class Route( - @PrimaryKey val id: String, + val id: String, val type: RouteType, val number: String?, val name: String, diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/model/RouteType.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/model/RouteType.kt index d6b4446..08a9c53 100644 --- a/shared/src/commonMain/kotlin/moe/lava/banksia/model/RouteType.kt +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/model/RouteType.kt @@ -1,7 +1,8 @@ package moe.lava.banksia.model -import androidx.room.TypeConverter +import kotlinx.serialization.Serializable +@Serializable enum class RouteType(val value: Int) { MetroTrain(2), MetroTram(3), @@ -12,12 +13,4 @@ enum class RouteType(val value: Int) { SkyBus(11), Interstate(10), ; - - companion object { - @TypeConverter - fun from(value: Int) = RouteType.entries.first { it.value == value } - - @TypeConverter - fun to(routeType: RouteType) = routeType.value - } } diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/model/Run.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/model/Run.kt new file mode 100644 index 0000000..328a4b0 --- /dev/null +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/model/Run.kt @@ -0,0 +1,5 @@ +package moe.lava.banksia.model + +data class Run( + val ref: String, +) diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/model/Service.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/model/Service.kt new file mode 100644 index 0000000..a57fb82 --- /dev/null +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/model/Service.kt @@ -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, + val start: LocalDate, + val end: LocalDate, +) diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/model/Shape.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/model/Shape.kt index c412170..6299ca0 100644 --- a/shared/src/commonMain/kotlin/moe/lava/banksia/model/Shape.kt +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/model/Shape.kt @@ -1,16 +1,12 @@ package moe.lava.banksia.model -import androidx.room.Entity -import androidx.room.PrimaryKey -import androidx.room.TypeConverters -import moe.lava.banksia.room.converter.ShapeConverter +import kotlinx.serialization.Serializable import moe.lava.banksia.util.Point typealias ShapePath = List -@Entity -@TypeConverters(ShapeConverter::class) +@Serializable data class Shape( - @PrimaryKey val id: String, + val id: String, val path: ShapePath, ) diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/model/Stop.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/model/Stop.kt new file mode 100644 index 0000000..df10a58 --- /dev/null +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/model/Stop.kt @@ -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, +) diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/model/StopTime.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/model/StopTime.kt new file mode 100644 index 0000000..682839d --- /dev/null +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/model/StopTime.kt @@ -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, +) diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/model/Trip.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/model/Trip.kt new file mode 100644 index 0000000..ef95eea --- /dev/null +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/model/Trip.kt @@ -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, +) diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/room/Database.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/room/Database.kt index 5c9b009..2690ece 100644 --- a/shared/src/commonMain/kotlin/moe/lava/banksia/room/Database.kt +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/room/Database.kt @@ -1,28 +1,51 @@ package moe.lava.banksia.room +import androidx.room.AutoMigration import androidx.room.RoomDatabase import androidx.room.TypeConverters import androidx.sqlite.driver.bundled.BundledSQLiteDriver import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO -import moe.lava.banksia.model.Route -import moe.lava.banksia.model.RouteType -import moe.lava.banksia.model.Shape +import moe.lava.banksia.room.converter.RouteTypeConverter import moe.lava.banksia.room.dao.RouteDao 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 -@DatabaseAnnotation(entities = [Route::class, Shape::class], version = 1) -@TypeConverters(RouteType.Companion::class) +@DatabaseAnnotation( + 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 fun getRouteDao(): RouteDao - abstract fun getShapeDao(): ShapeDao + abstract val routeDao: RouteDao + abstract val shapeDao: ShapeDao + abstract val stopDao: StopDao + abstract val stopTimeDao: StopTimeDao + abstract val tripDao: TripDao companion object { fun build(base: Builder) = - base.fallbackToDestructiveMigrationOnDowngrade(true) + base.fallbackToDestructiveMigration(true) .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) +// .fallbackToDestructiveMigration(true) .build() } } diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/room/converter/RouteTypeConverter.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/room/converter/RouteTypeConverter.kt new file mode 100644 index 0000000..8927f14 --- /dev/null +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/room/converter/RouteTypeConverter.kt @@ -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 +} diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/room/converter/ShapeConverter.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/room/converter/ShapePathConverter.kt similarity index 97% rename from shared/src/commonMain/kotlin/moe/lava/banksia/room/converter/ShapeConverter.kt rename to shared/src/commonMain/kotlin/moe/lava/banksia/room/converter/ShapePathConverter.kt index bd52b3d..08a8064 100644 --- a/shared/src/commonMain/kotlin/moe/lava/banksia/room/converter/ShapeConverter.kt +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/room/converter/ShapePathConverter.kt @@ -4,7 +4,7 @@ import androidx.room.TypeConverter import moe.lava.banksia.model.ShapePath import moe.lava.banksia.util.Point -object ShapeConverter { +object ShapePathConverter { @TypeConverter fun from(value: ByteArray): ShapePath { return value diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/room/dao/RouteDao.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/room/dao/RouteDao.kt index 4355068..0174f0f 100644 --- a/shared/src/commonMain/kotlin/moe/lava/banksia/room/dao/RouteDao.kt +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/room/dao/RouteDao.kt @@ -3,23 +3,47 @@ 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.model.Route +import moe.lava.banksia.room.entity.RouteEntity +import moe.lava.banksia.room.entity.StopEntity @Dao interface RouteDao { @Query("SELECT * FROM Route") - suspend fun getAll(): List + suspend fun getAll(): List @Query("SELECT * FROM Route WHERE id == :id") - suspend fun get(id: String): Route? + suspend fun get(id: String): RouteEntity? @Insert - suspend fun insertAll(vararg routes: Route) + suspend fun insertAll(vararg routes: RouteEntity) + + @Insert(onConflict = REPLACE) + suspend fun insertOrReplaceAll(vararg routes: RouteEntity) @Delete - suspend fun delete(route: Route) + suspend fun delete(route: RouteEntity) @Query("DELETE FROM Route") 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 + + @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 } diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/room/dao/ShapeDao.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/room/dao/ShapeDao.kt index 56c6114..c48735a 100644 --- a/shared/src/commonMain/kotlin/moe/lava/banksia/room/dao/ShapeDao.kt +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/room/dao/ShapeDao.kt @@ -4,18 +4,18 @@ import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.Query -import moe.lava.banksia.model.Shape +import moe.lava.banksia.room.entity.ShapeEntity @Dao interface ShapeDao { @Query("SELECT * FROM Shape WHERE id == :id") - suspend fun get(id: String): Shape? + suspend fun get(id: String): ShapeEntity? @Insert - suspend fun insertAll(vararg shapes: Shape) + suspend fun insertAll(vararg shapes: ShapeEntity) @Delete - suspend fun delete(shape: Shape) + suspend fun delete(shape: ShapeEntity) @Query("DELETE FROM Shape") suspend fun deleteAll() diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/room/dao/StopDao.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/room/dao/StopDao.kt new file mode 100644 index 0000000..f6b2ef2 --- /dev/null +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/room/dao/StopDao.kt @@ -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 + + @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): List + + @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() +} diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/room/dao/StopTimeDao.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/room/dao/StopTimeDao.kt new file mode 100644 index 0000000..88485f4 --- /dev/null +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/room/dao/StopTimeDao.kt @@ -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 + + @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): List + + @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() +} diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/room/dao/TripDao.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/room/dao/TripDao.kt new file mode 100644 index 0000000..9778a1a --- /dev/null +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/room/dao/TripDao.kt @@ -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 + + @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 + + @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() +} diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/room/entity/RouteEntity.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/room/entity/RouteEntity.kt new file mode 100644 index 0000000..cc690d6 --- /dev/null +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/room/entity/RouteEntity.kt @@ -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) diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/room/entity/ServiceEntity.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/room/entity/ServiceEntity.kt new file mode 100644 index 0000000..4b14a95 --- /dev/null +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/room/entity/ServiceEntity.kt @@ -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 = 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): 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(), +) diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/room/entity/ShapeEntity.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/room/entity/ShapeEntity.kt new file mode 100644 index 0000000..87ca671 --- /dev/null +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/room/entity/ShapeEntity.kt @@ -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) diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/room/entity/StopEntity.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/room/entity/StopEntity.kt new file mode 100644 index 0000000..9c6cf15 --- /dev/null +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/room/entity/StopEntity.kt @@ -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) diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/room/entity/StopTimeEntity.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/room/entity/StopTimeEntity.kt new file mode 100644 index 0000000..9b0aac8 --- /dev/null +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/room/entity/StopTimeEntity.kt @@ -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, +) diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/room/entity/TripEntity.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/room/entity/TripEntity.kt new file mode 100644 index 0000000..ca7e9a7 --- /dev/null +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/room/entity/TripEntity.kt @@ -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) diff --git a/shared/src/commonMain/kotlin/moe/lava/banksia/util/Point.kt b/shared/src/commonMain/kotlin/moe/lava/banksia/util/Point.kt index b54453b..4aae7d4 100644 --- a/shared/src/commonMain/kotlin/moe/lava/banksia/util/Point.kt +++ b/shared/src/commonMain/kotlin/moe/lava/banksia/util/Point.kt @@ -1,3 +1,6 @@ package moe.lava.banksia.util +import kotlinx.serialization.Serializable + +@Serializable data class Point(val lat: Double, val lng: Double) diff --git a/shared/src/iosMain/kotlin/moe/lava/banksia/di/PlatformModule.ios.kt b/shared/src/iosMain/kotlin/moe/lava/banksia/di/PlatformModule.ios.kt index 7e49dbf..8597856 100644 --- a/shared/src/iosMain/kotlin/moe/lava/banksia/di/PlatformModule.ios.kt +++ b/shared/src/iosMain/kotlin/moe/lava/banksia/di/PlatformModule.ios.kt @@ -4,6 +4,7 @@ import androidx.room.RoomDatabase import moe.lava.banksia.room.Database import org.koin.core.parameter.ParametersHolder import org.koin.core.scope.Scope +import org.koin.dsl.module class IosDatabaseBuilder() : PlatformDatabaseBuilder { override fun getBuilder(): RoomDatabase.Builder { @@ -13,3 +14,5 @@ class IosDatabaseBuilder() : PlatformDatabaseBuilder { actual fun Scope.provideDatabaseBuilder(p: ParametersHolder): PlatformDatabaseBuilder = IosDatabaseBuilder() + +internal actual val ExtPlatformModule = module { } diff --git a/shared/src/jvmMain/kotlin/moe/lava/banksia/di/PlatformModule.jvm.kt b/shared/src/jvmMain/kotlin/moe/lava/banksia/di/PlatformModule.jvm.kt index 84bf64e..11789dd 100644 --- a/shared/src/jvmMain/kotlin/moe/lava/banksia/di/PlatformModule.jvm.kt +++ b/shared/src/jvmMain/kotlin/moe/lava/banksia/di/PlatformModule.jvm.kt @@ -5,6 +5,7 @@ import androidx.room.RoomDatabase import moe.lava.banksia.room.Database import org.koin.core.parameter.ParametersHolder import org.koin.core.scope.Scope +import org.koin.dsl.module import java.io.File class JvmDatabaseBuilder() : PlatformDatabaseBuilder { @@ -18,3 +19,5 @@ class JvmDatabaseBuilder() : PlatformDatabaseBuilder { actual fun Scope.provideDatabaseBuilder(p: ParametersHolder): PlatformDatabaseBuilder = JvmDatabaseBuilder() + +internal actual val ExtPlatformModule = module { }