diff --git a/README.md b/README.md index 337c0584..a7b22fb9 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Available APIs: - [Trip Request](https://opentransportdata.swiss/en/cookbook/ojptriprequest/) - [Trip Info Request](https://opentransportdata.swiss/de/cookbook/open-journey-planner-ojp/ojptripinforequest/) - [Stop Event Request](https://opentransportdata.swiss/de/cookbook/open-journey-planner-ojp-landing-page/ojpstopeventrequest-2-0/) -- Trip Refinement Request +- [Trip Refinement Request](https://opentransportdata.swiss/de/cookbook/open-journey-planner-ojp-landing-page/ojptriprefinerequest-2-0/) ## Requirements Compatible with Android 8+ @@ -69,6 +69,34 @@ requestLocationsFromCoordinates( ) ``` +#### Get a list of sharing POIs within a bounding box (rectangle) +``` +import ch.opentransportdata.ojp.OjpSdk + +requestLocationsFromRectangle( + languageCode = LanguageCode.EN, + upperLeftLongitude = 7.431, + upperLeftLatitude = 46.945, + lowerRightLongitude = 7.446, + lowerRightLatitude = 46.950, + restrictions = LocationInformationParams( + types = emptyList(), + numberOfResults = 300, + ptModeIncluded = true, + modeFilter = ModeFilter( + exclude = false, + personalModes = listOf( + PersonalMode.BICYCLE, + PersonalMode.SCOOTER, + PersonalMode.CAR + ) + ) + ) +) +``` + +The rectangle is defined by its upper left and lower right corners (WGS84) and is typically used to load shared mobility points of interest (e.g. e-scooters, bikes, cars) for the currently visible map region. Filter the results with a `ModeFilter` on `personalModes`. Each returned `PointOfInterest` exposes its category via `sharingCategories`. A `PointOfInterestFilter` (with `PointOfInterestCategory` / OSM tags) can be passed as well. + #### Get a list of trips ``` import ch.opentransportdata.ojp.OjpSdk diff --git a/app/src/main/java/ch/opentransportdata/presentation/MainActivity.kt b/app/src/main/java/ch/opentransportdata/presentation/MainActivity.kt index 868646fc..90a4edcf 100644 --- a/app/src/main/java/ch/opentransportdata/presentation/MainActivity.kt +++ b/app/src/main/java/ch/opentransportdata/presentation/MainActivity.kt @@ -28,6 +28,7 @@ import ch.opentransportdata.ojp.data.dto.response.PlaceResultDto import androidx.navigation.NavHostController import ch.opentransportdata.presentation.feature.location.LirScreenComposable import ch.opentransportdata.presentation.feature.map.MapScreen +import ch.opentransportdata.presentation.feature.map.SharedMobilityMapScreen import ch.opentransportdata.presentation.feature.result.TripResultScreen import ch.opentransportdata.presentation.feature.search.TripSearchScreen import ch.opentransportdata.presentation.feature.stopevent.StopEventResultScreen @@ -54,7 +55,7 @@ class MainActivity : ComponentActivity() { @Composable fun OjpDemoApp() { - val bottomNavigationItems = listOf(BottomNavItem.Lir, BottomNavItem.Tir, BottomNavItem.Ser) + val bottomNavigationItems = listOf(BottomNavItem.Lir, BottomNavItem.Tir, BottomNavItem.Ser, BottomNavItem.Map) OJPAndroidSDKTheme { val navController = rememberNavController() var selectedBottomNavItem by remember { mutableIntStateOf(0) } @@ -93,6 +94,7 @@ class MainActivity : ComponentActivity() { composable { LirNavHost() } composable { TirNavHost() } composable { SerNavHost() } + composable { SharedMobilityMapScreen() } } } } diff --git a/app/src/main/java/ch/opentransportdata/presentation/feature/location/LocationScreen.kt b/app/src/main/java/ch/opentransportdata/presentation/feature/location/LocationScreen.kt index dfa3631b..21d3fa38 100644 --- a/app/src/main/java/ch/opentransportdata/presentation/feature/location/LocationScreen.kt +++ b/app/src/main/java/ch/opentransportdata/presentation/feature/location/LocationScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -75,15 +76,17 @@ fun LirScreenComposable( } } - state.value.events.forEach { event -> - when (event) { - is LocationViewModel.Event.ShowSnackBar -> { - coroutineScope.launch { - snackBarHostState.showSnackbar(message = event.message) + LaunchedEffect(state.value.events) { + state.value.events.forEach { event -> + when (event) { + is LocationViewModel.Event.ShowSnackBar -> { + coroutineScope.launch { + snackBarHostState.showSnackbar(message = event.message) + } } } + viewModel.eventHandled(event.id) } - viewModel.eventHandled(event.id) } } diff --git a/app/src/main/java/ch/opentransportdata/presentation/feature/map/MapScreen.kt b/app/src/main/java/ch/opentransportdata/presentation/feature/map/MapScreen.kt index 809e9327..2bdd864a 100644 --- a/app/src/main/java/ch/opentransportdata/presentation/feature/map/MapScreen.kt +++ b/app/src/main/java/ch/opentransportdata/presentation/feature/map/MapScreen.kt @@ -3,16 +3,15 @@ package ch.opentransportdata.presentation.feature.map import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import ch.opentransportdata.ojp.data.dto.response.GeoPositionDto import org.maplibre.android.geometry.LatLng -import org.maplibre.android.maps.Style import org.ramani.compose.CameraPosition import org.ramani.compose.MapLibre +import org.ramani.compose.MapStyle import org.ramani.compose.Polyline +import org.ramani.compose.rememberCameraPositionState /** * Created by Nico Brandenberger on 04.11.2025 @@ -25,22 +24,20 @@ fun MapScreen( ) { val styleUrl = "https://vectortiles.geo.admin.ch/styles/ch.swisstopo.basemap.vt/style.json" val mapLibrePoints = coordinates.map { LatLng(it.latitude, it.longitude) } - val cameraPosition = rememberSaveable { - mutableStateOf( - CameraPosition( - target = mapLibrePoints.first() , - zoom = zoom, - ) + val cameraPositionState = rememberCameraPositionState( + CameraPosition( + target = mapLibrePoints.first(), + zoom = zoom, ) - } + ) Surface( modifier = Modifier.fillMaxSize(), color = Color.Red ) { MapLibre( modifier = Modifier.fillMaxSize(), - styleBuilder = Style.Builder().fromUri(styleUrl), - cameraPosition = cameraPosition.value + style = MapStyle.Uri(styleUrl), + cameraPositionState = cameraPositionState ) { Polyline(points = mapLibrePoints, color = "Red", lineWidth = 1.0F) } diff --git a/app/src/main/java/ch/opentransportdata/presentation/feature/map/SharedMobilityMapScreen.kt b/app/src/main/java/ch/opentransportdata/presentation/feature/map/SharedMobilityMapScreen.kt new file mode 100644 index 00000000..f15fb641 --- /dev/null +++ b/app/src/main/java/ch/opentransportdata/presentation/feature/map/SharedMobilityMapScreen.kt @@ -0,0 +1,396 @@ +package ch.opentransportdata.presentation.feature.map + +import android.Manifest +import android.location.Location +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import ch.opentransportdata.ojp.domain.model.SharingCategory +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.google.gson.JsonPrimitive +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.maplibre.android.geometry.LatLng +import org.ramani.compose.CameraMotionType +import org.ramani.compose.CameraPosition +import org.ramani.compose.CenterState +import org.ramani.compose.Circle +import org.ramani.compose.MapLibre +import org.ramani.compose.MapStyle +import org.ramani.compose.rememberCameraPositionState +import kotlin.math.cos +import kotlin.math.pow + +/** + * Created by Deniz Kalem on 08.07.2026 + * + */ +@OptIn(ExperimentalPermissionsApi::class, FlowPreview::class) +@Composable +fun SharedMobilityMapScreen( + viewModel: SharedMobilityMapViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + val snackBarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + + val locationPermissions = rememberMultiplePermissionsState( + permissions = listOf( + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION + ) + ) + + LaunchedEffect(Unit) { + if (!locationPermissions.allPermissionsGranted) { + locationPermissions.launchMultiplePermissionRequest() + } + } + + Scaffold( + contentWindowInsets = WindowInsets(0), + snackbarHost = { SnackbarHost(hostState = snackBarHostState) } + ) { innerPadding -> + Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + ) { + if (locationPermissions.allPermissionsGranted) { + SharedMobilityMap( + state = state, + onRegionChanged = viewModel::loadPois, + onPoiSelected = viewModel::selectPoi + ) + } else { + LocationPermissionRequest( + onRequestPermission = { locationPermissions.launchMultiplePermissionRequest() } + ) + } + + CategoryFilterRow( + selectedCategories = state.selectedCategories, + onCategoryToggled = viewModel::toggleCategory, + modifier = Modifier.align(Alignment.BottomCenter) + ) + + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + + state.selectedPoi?.let { poi -> + PoiDetailCard( + poi = poi, + onClose = viewModel::clearSelectedPoi, + modifier = Modifier + .align(Alignment.TopCenter) + .statusBarsPadding() + ) + } + } + } + + LaunchedEffect(state.events) { + state.events.forEach { event -> + when (event) { + is SharedMobilityMapViewModel.Event.ShowSnackBar -> coroutineScope.launch { + snackBarHostState.showSnackbar(message = event.message) + } + } + viewModel.eventHandled(event.id) + } + } +} + +@OptIn(FlowPreview::class) +@Composable +private fun SharedMobilityMap( + state: SharedMobilityMapViewModel.UiState, + onRegionChanged: (upperLeftLon: Double, upperLeftLat: Double, lowerRightLon: Double, lowerRightLat: Double) -> Unit, + onPoiSelected: (String?) -> Unit +) { + val cameraPositionState = rememberCameraPositionState( + CameraPosition(target = BERN, zoom = DEFAULT_ZOOM) + ) + val userLocation = remember { mutableStateOf(Location(PROVIDER_INITIAL)) } + var hasCenteredOnUser by remember { mutableStateOf(false) } + + LaunchedEffect(userLocation.value) { + val location = userLocation.value + if (!hasCenteredOnUser && (location.latitude != 0.0 || location.longitude != 0.0)) { + cameraPositionState.position = CameraPosition( + target = LatLng(location.latitude, location.longitude), + zoom = DEFAULT_ZOOM, + motionType = CameraMotionType.EASE + ) + hasCenteredOnUser = true + } + } + + LaunchedEffect(Unit) { + snapshotFlowPosition(cameraPositionState) + .distinctUntilChanged() + .debounce(QUERY_DEBOUNCE_MS) + .collect { (target, zoom) -> + if (target != null && zoom != null && zoom >= MIN_QUERY_ZOOM) { + val (upperLeft, lowerRight) = boundingBox(target, zoom) + onRegionChanged( + upperLeft.longitude, + upperLeft.latitude, + lowerRight.longitude, + lowerRight.latitude + ) + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + MapLibre( + modifier = Modifier.fillMaxSize(), + style = MapStyle.Uri(STYLE_URL), + cameraPositionState = cameraPositionState, + userLocation = userLocation + ) { + state.visiblePois.forEach { poi -> + androidx.compose.runtime.key(poi.id) { + val centerState = remember(poi.latitude, poi.longitude) { + CenterState(LatLng(poi.latitude, poi.longitude)) + } + Circle( + centerState = centerState, + radius = 15f, + color = poi.category.markerColor(), + borderColor = "White", + borderWidth = 3f, + data = JsonPrimitive(poi.id), + onClick = { data -> onPoiSelected((data as? JsonPrimitive)?.asString) } + ) + } + } + } + + val zoomBy: (Double) -> Unit = { delta -> + val current = cameraPositionState.position + val target = current.target + if (target != null) { + cameraPositionState.position = CameraPosition( + target = target, + zoom = ((current.zoom ?: DEFAULT_ZOOM) + delta).coerceIn(MIN_ZOOM, MAX_ZOOM), + motionType = CameraMotionType.EASE + ) + } + } + + Column( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 80.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + SmallFloatingActionButton(onClick = { zoomBy(1.0) }) { + Icon(imageVector = Icons.Default.Add, contentDescription = "Zoom in") + } + SmallFloatingActionButton(onClick = { zoomBy(-1.0) }) { + Icon(imageVector = Icons.Default.Remove, contentDescription = "Zoom out") + } + FloatingActionButton( + onClick = { + val location = userLocation.value + if (location.latitude != 0.0 || location.longitude != 0.0) { + cameraPositionState.position = CameraPosition( + target = LatLng(location.latitude, location.longitude), + zoom = DEFAULT_ZOOM, + motionType = CameraMotionType.EASE + ) + } + } + ) { + Icon(imageVector = Icons.Default.LocationOn, contentDescription = "Center on my location") + } + } + } +} + +@Composable +private fun CategoryFilterRow( + selectedCategories: Set, + onCategoryToggled: (SharingCategory) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + SharingCategory.entries.forEach { category -> + val selected = category in selectedCategories + FilterChip( + selected = selected, + onClick = { onCategoryToggled(category) }, + label = { Text(category.displayName()) }, + colors = FilterChipDefaults.filterChipColors( + containerColor = MaterialTheme.colorScheme.surface, + labelColor = MaterialTheme.colorScheme.onSurface, + selectedContainerColor = category.uiColor(), + selectedLabelColor = Color.White + ), + border = BorderStroke( + width = 1.dp, + color = if (selected) category.uiColor() else MaterialTheme.colorScheme.outline + ), + elevation = FilterChipDefaults.filterChipElevation(elevation = 3.dp) + ) + } + } +} + +@Composable +private fun PoiDetailCard( + poi: PoiMarker, + onClose: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(12.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = poi.name.ifBlank { "POI" }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = onClose) { + Icon(imageVector = Icons.Default.Close, contentDescription = "Close") + } + } + Text( + text = poi.category?.displayName() ?: "Unbekannte Kategorie", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "%.5f, %.5f".format(poi.latitude, poi.longitude), + style = MaterialTheme.typography.bodySmall + ) + poi.additionalInformation.forEach { (key, value) -> + Text( + text = "$key: $value", + style = MaterialTheme.typography.bodySmall + ) + } + } + } +} + +@Composable +private fun LocationPermissionRequest(onRequestPermission: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Location permission is required to show the map at your current position.") + FloatingActionButton( + onClick = onRequestPermission, + modifier = Modifier.padding(top = 16.dp) + ) { + Icon(imageVector = Icons.Default.LocationOn, contentDescription = "Grant location permission") + } + } +} + +private fun snapshotFlowPosition(cameraPositionState: org.ramani.compose.CameraPositionState) = + androidx.compose.runtime.snapshotFlow { cameraPositionState.position } + .map { position -> position.target to position.zoom } + +private fun boundingBox(center: LatLng, zoom: Double): Pair { + val latSpan = 360.0 / 2.0.pow(zoom) + val lonSpan = latSpan / cos(Math.toRadians(center.latitude)).coerceAtLeast(0.01) + val upperLeft = LatLng(center.latitude + latSpan, center.longitude - lonSpan) + val lowerRight = LatLng(center.latitude - latSpan, center.longitude + lonSpan) + return upperLeft to lowerRight +} + +private fun SharingCategory.displayName(): String = when (this) { + SharingCategory.E_SCOOTER -> "E-Scooter" + SharingCategory.BIKE -> "Bike" + SharingCategory.CAR -> "Car" + SharingCategory.CHARGING_STATION -> "Charging" +} + +private fun SharingCategory.uiColor(): Color = when (this) { + SharingCategory.E_SCOOTER -> Color(0xFFE53935) + SharingCategory.BIKE -> Color(0xFF1E88E5) + SharingCategory.CAR -> Color(0xFF212121) + SharingCategory.CHARGING_STATION -> Color(0xFF43A047) +} + +private fun SharingCategory?.markerColor(): String { + val color = this?.uiColor() ?: Color(0xFFD81B60) + return "#%06X".format(0xFFFFFF and color.toArgb()) +} + +private const val STYLE_URL = + "https://vectortiles.geo.admin.ch/styles/ch.swisstopo.basemap.vt/style.json" +private const val PROVIDER_INITIAL = "initial" +private const val DEFAULT_ZOOM = 15.0 +private const val MIN_ZOOM = 1.0 +private const val MAX_ZOOM = 20.0 +private const val MIN_QUERY_ZOOM = 11.0 +private const val QUERY_DEBOUNCE_MS = 500L +private val BERN = LatLng(46.948, 7.4474) \ No newline at end of file diff --git a/app/src/main/java/ch/opentransportdata/presentation/feature/map/SharedMobilityMapViewModel.kt b/app/src/main/java/ch/opentransportdata/presentation/feature/map/SharedMobilityMapViewModel.kt new file mode 100644 index 00000000..f714f06e --- /dev/null +++ b/app/src/main/java/ch/opentransportdata/presentation/feature/map/SharedMobilityMapViewModel.kt @@ -0,0 +1,144 @@ +package ch.opentransportdata.presentation.feature.map + +import android.util.Log +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import ch.opentransportdata.ojp.domain.model.LocationInformationParams +import ch.opentransportdata.ojp.domain.model.ModeFilter +import ch.opentransportdata.ojp.domain.model.PersonalMode +import ch.opentransportdata.ojp.domain.model.Result +import ch.opentransportdata.ojp.domain.model.SharingCategory +import ch.opentransportdata.ojp.domain.model.error.OjpError +import ch.opentransportdata.ojp.domain.model.sharingCategories +import ch.opentransportdata.presentation.MainActivity +import ch.opentransportdata.presentation.util.toOjpLanguageCode +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.Locale +import java.util.UUID + +/** + * Created by Deniz Kalem on 08.07.2026 + * + */ +class SharedMobilityMapViewModel : ViewModel() { + + val state = MutableStateFlow(UiState()) + + private var loadJob: Job? = null + + fun toggleCategory(category: SharingCategory) { + val selected = state.value.selectedCategories.toMutableSet() + if (!selected.add(category)) selected.remove(category) + state.update { it.copy(selectedCategories = selected) } + } + + fun selectPoi(id: String?) { + state.update { current -> current.copy(selectedPoi = current.pois.firstOrNull { it.id == id }) } + } + + fun clearSelectedPoi() { + state.update { it.copy(selectedPoi = null) } + } + + fun loadPois( + upperLeftLongitude: Double, + upperLeftLatitude: Double, + lowerRightLongitude: Double, + lowerRightLatitude: Double + ) { + loadJob?.cancel() + loadJob = viewModelScope.launch { + state.update { it.copy(isLoading = true) } + + val result = MainActivity.ojpSdk.requestLocationsFromRectangle( + languageCode = Locale.getDefault().language.toOjpLanguageCode(), + upperLeftLongitude = upperLeftLongitude, + upperLeftLatitude = upperLeftLatitude, + lowerRightLongitude = lowerRightLongitude, + lowerRightLatitude = lowerRightLatitude, + restrictions = LocationInformationParams( + types = emptyList(), + numberOfResults = 300, + ptModeIncluded = true, + modeFilter = ModeFilter( + exclude = false, + personalModes = listOf( + PersonalMode.BICYCLE, + PersonalMode.SCOOTER, + PersonalMode.CAR + ) + ) + ) + ) + + when (result) { + is Result.Success -> { + val markers = result.data.mapIndexedNotNull { index, placeResult -> + val place = placeResult.place ?: return@mapIndexedNotNull null + val position = place.position ?: return@mapIndexedNotNull null + val poi = place.pointOfInterest + PoiMarker( + id = poi?.publicCode ?: "index_$index", + latitude = position.latitude, + longitude = position.longitude, + name = poi?.name?.text ?: place.name?.text.orEmpty(), + category = poi?.sharingCategories?.firstOrNull(), + additionalInformation = poi?.additionalInformation.orEmpty() + ) + } + state.update { it.copy(pois = markers, isLoading = false) } + } + + is Result.Error -> { + if (result.error !is OjpError.RequestCancelled) { + Log.e(TAG, "Error loading POIs", result.error.exception) + postEvent(Event.ShowSnackBar("Could not load points of interest")) + } + state.update { it.copy(isLoading = false) } + } + } + } + } + + fun eventHandled(id: Long) { + state.update { current -> current.copy(events = current.events.filterNot { it.id == id }) } + } + + private fun postEvent(event: Event) { + state.update { it.copy(events = it.events + event) } + } + + sealed class Event(val id: Long = UUID.randomUUID().mostSignificantBits) { + data class ShowSnackBar(val message: String) : Event() + } + + @Immutable + data class UiState( + val pois: List = emptyList(), + val selectedCategories: Set = SharingCategory.entries.toSet(), + val selectedPoi: PoiMarker? = null, + val isLoading: Boolean = false, + val events: List = emptyList() + ) { + val visiblePois: List + get() = pois.filter { it.category != null && it.category in selectedCategories } + } + + companion object { + private const val TAG = "SharedMobilityMapVM" + } +} + +@Immutable +data class PoiMarker( + val id: String, + val latitude: Double, + val longitude: Double, + val name: String, + val category: SharingCategory?, + val additionalInformation: Map = emptyMap() +) \ No newline at end of file diff --git a/app/src/main/java/ch/opentransportdata/presentation/feature/result/TripResultScreen.kt b/app/src/main/java/ch/opentransportdata/presentation/feature/result/TripResultScreen.kt index e0a961ef..17b14d07 100644 --- a/app/src/main/java/ch/opentransportdata/presentation/feature/result/TripResultScreen.kt +++ b/app/src/main/java/ch/opentransportdata/presentation/feature/result/TripResultScreen.kt @@ -261,26 +261,28 @@ fun TripResultScreen( } } - state.value.events.forEach { event -> - when (event) { - is TripResultViewModel.Event.ShowSnackBar -> { - coroutineScope.launch { snackBarHostState.showSnackbar(message = event.message) } - } + LaunchedEffect(state.value.events) { + state.value.events.forEach { event -> + when (event) { + is TripResultViewModel.Event.ShowSnackBar -> { + coroutineScope.launch { snackBarHostState.showSnackbar(message = event.message) } + } - is TripResultViewModel.Event.ScrollToFirstTripItem -> { - coroutineScope.launch { - val scrollItem = if (initialItemsLoaded) event.offset + 1 else 1 - try { - listState.animateScrollToItem(index = scrollItem) - } catch (e: Exception) { - Log.d("TripResultScreen", "User is still dragging and that as higher priority") - delay(2000) //delay the reset so it wont instantly load new items while still dragging + is TripResultViewModel.Event.ScrollToFirstTripItem -> { + coroutineScope.launch { + val scrollItem = if (initialItemsLoaded) event.offset + 1 else 1 + try { + listState.animateScrollToItem(index = scrollItem) + } catch (e: Exception) { + Log.d("TripResultScreen", "User is still dragging and that as higher priority") + delay(2000) //delay the reset so it wont instantly load new items while still dragging + } + viewModel.resetPreviousItemsCounter() } - viewModel.resetPreviousItemsCounter() } } + viewModel.eventHandled(event.id) } - viewModel.eventHandled(event.id) } } diff --git a/app/src/main/java/ch/opentransportdata/presentation/feature/search/TripSearchScreen.kt b/app/src/main/java/ch/opentransportdata/presentation/feature/search/TripSearchScreen.kt index 244e0677..33e2563e 100644 --- a/app/src/main/java/ch/opentransportdata/presentation/feature/search/TripSearchScreen.kt +++ b/app/src/main/java/ch/opentransportdata/presentation/feature/search/TripSearchScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -73,26 +74,28 @@ fun TripSearchScreen( } } - state.value.events.forEach { event -> - when (event) { - is TripSearchViewModel.Event.ShowSnackBar -> { - coroutineScope.launch { - snackBarHostState.showSnackbar(message = event.message) + LaunchedEffect(state.value.events) { + state.value.events.forEach { event -> + when (event) { + is TripSearchViewModel.Event.ShowSnackBar -> { + coroutineScope.launch { + snackBarHostState.showSnackbar(message = event.message) + } } - } - is TripSearchViewModel.Event.RequestTrip -> { - navHostController.navigate( - TripResults( - origin = event.origin, - via = event.via, - destination = event.destination + is TripSearchViewModel.Event.RequestTrip -> { + navHostController.navigate( + TripResults( + origin = event.origin, + via = event.via, + destination = event.destination + ) ) - ) - viewModel.resetData() + viewModel.resetData() + } } + viewModel.eventHandled(event.id) } - viewModel.eventHandled(event.id) } } diff --git a/app/src/main/java/ch/opentransportdata/presentation/feature/stopevent/StopEventResultScreen.kt b/app/src/main/java/ch/opentransportdata/presentation/feature/stopevent/StopEventResultScreen.kt index c3c45d85..fff6e27b 100644 --- a/app/src/main/java/ch/opentransportdata/presentation/feature/stopevent/StopEventResultScreen.kt +++ b/app/src/main/java/ch/opentransportdata/presentation/feature/stopevent/StopEventResultScreen.kt @@ -121,15 +121,17 @@ fun StopEventResultScreen( } } - state.value.events.forEach { event -> - when (event) { - is StopEventResultViewModel.Event.ShowSnackBar -> { - coroutineScope.launch { - snackBarHostState.showSnackbar(event.message) + LaunchedEffect(state.value.events) { + state.value.events.forEach { event -> + when (event) { + is StopEventResultViewModel.Event.ShowSnackBar -> { + coroutineScope.launch { + snackBarHostState.showSnackbar(event.message) + } } } + viewModel.eventHandled(event.id) } - viewModel.eventHandled(event.id) } } diff --git a/app/src/main/java/ch/opentransportdata/presentation/feature/stopevent/StopEventSearchScreen.kt b/app/src/main/java/ch/opentransportdata/presentation/feature/stopevent/StopEventSearchScreen.kt index 789a455d..61d7c084 100644 --- a/app/src/main/java/ch/opentransportdata/presentation/feature/stopevent/StopEventSearchScreen.kt +++ b/app/src/main/java/ch/opentransportdata/presentation/feature/stopevent/StopEventSearchScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -66,20 +67,22 @@ fun StopEventSearchScreen( } } - state.value.events.forEach { event -> - when (event) { - is StopEventSearchViewModel.Event.ShowSnackBar -> { - coroutineScope.launch { - snackBarHostState.showSnackbar(message = event.message) + LaunchedEffect(state.value.events) { + state.value.events.forEach { event -> + when (event) { + is StopEventSearchViewModel.Event.ShowSnackBar -> { + coroutineScope.launch { + snackBarHostState.showSnackbar(message = event.message) + } } - } - is StopEventSearchViewModel.Event.OpenResults -> { - navHostController.navigate(StopEventResults(stop = event.location)) - viewModel.resetData() + is StopEventSearchViewModel.Event.OpenResults -> { + navHostController.navigate(StopEventResults(stop = event.location)) + viewModel.resetData() + } } + viewModel.eventHandled(event.id) } - viewModel.eventHandled(event.id) } } diff --git a/app/src/main/java/ch/opentransportdata/presentation/navigation/BottomNavigation.kt b/app/src/main/java/ch/opentransportdata/presentation/navigation/BottomNavigation.kt index 5dc92585..e946f971 100644 --- a/app/src/main/java/ch/opentransportdata/presentation/navigation/BottomNavigation.kt +++ b/app/src/main/java/ch/opentransportdata/presentation/navigation/BottomNavigation.kt @@ -3,6 +3,7 @@ package ch.opentransportdata.presentation.navigation import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.DepartureBoard import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Map import androidx.compose.material.icons.filled.Search import androidx.compose.ui.graphics.vector.ImageVector import kotlinx.serialization.Contextual @@ -31,4 +32,10 @@ sealed class BottomNavItem(@Contextual val icon: ImageVector, val label: String) icon = Icons.Default.DepartureBoard, label = "SER" ) + + @Serializable + data object Map : BottomNavItem( + icon = Icons.Default.Map, + label = "Map" + ) } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4ddebe80..ed92b520 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ koin = "4.2.0" kotlin = "2.3.20" kotlinxCoroutinesTest = "1.10.2" lifecycleRuntimeKtx = "2.10.0" -mapLibre = "0.10.0" +mapLibre = "0.12.0" navigation = "2.9.7" okHttp = "5.3.2" playServices = "21.3.0" diff --git a/sdk/src/main/java/ch/opentransportdata/ojp/OjpSdk.kt b/sdk/src/main/java/ch/opentransportdata/ojp/OjpSdk.kt index b0216f81..1da2eb78 100644 --- a/sdk/src/main/java/ch/opentransportdata/ojp/OjpSdk.kt +++ b/sdk/src/main/java/ch/opentransportdata/ojp/OjpSdk.kt @@ -20,6 +20,7 @@ import ch.opentransportdata.ojp.domain.model.TripParams import ch.opentransportdata.ojp.domain.model.TripRefineParam import ch.opentransportdata.ojp.domain.usecase.Initializer import ch.opentransportdata.ojp.domain.usecase.RequestLocationsFromCoordinates +import ch.opentransportdata.ojp.domain.usecase.RequestLocationsFromRectangle import ch.opentransportdata.ojp.domain.usecase.RequestLocationsFromSearchTerm import ch.opentransportdata.ojp.domain.usecase.RequestMockTrips import ch.opentransportdata.ojp.domain.usecase.RequestStopEvent @@ -89,6 +90,40 @@ class OjpSdk( return OjpKoinContext.koinApp.koin.get().invoke(languageCode, term, restrictions) } + /** + * Request a list of Place Results located within the given geographical rectangle (bounding + * box). This is typically used to load points of interest (e.g. shared mobility vehicles) for + * the currently visible map region. + * + * The rectangle is defined by its upper left and lower right corners. + * + * @param languageCode The [LanguageCode] for the desired results, default is [LanguageCode.DE] + * @param upperLeftLongitude The longitude of the upper left corner of the rectangle + * @param upperLeftLatitude The latitude of the upper left corner of the rectangle + * @param lowerRightLongitude The longitude of the lower right corner of the rectangle + * @param lowerRightLatitude The latitude of the lower right corner of the rectangle + * @param restrictions Restrictions that should be used for results (e.g. a [ch.opentransportdata.ojp.domain.model.PointOfInterestFilter]) + * @return List of [PlaceResultDto] located within the given rectangle + */ + suspend fun requestLocationsFromRectangle( + languageCode: LanguageCode = LanguageCode.DE, + upperLeftLongitude: Double, + upperLeftLatitude: Double, + lowerRightLongitude: Double, + lowerRightLatitude: Double, + restrictions: LocationInformationParams + ): Result> { + return OjpKoinContext.koinApp.koin.get() + .invoke( + languageCode = languageCode, + upperLeftLongitude = upperLeftLongitude, + upperLeftLatitude = upperLeftLatitude, + lowerRightLongitude = lowerRightLongitude, + lowerRightLatitude = lowerRightLatitude, + restrictions = restrictions + ) + } + /** * Request a list of trips * diff --git a/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/request/lir/PointOfInterestFilterDto.kt b/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/request/lir/PointOfInterestFilterDto.kt new file mode 100644 index 00000000..31185fdf --- /dev/null +++ b/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/request/lir/PointOfInterestFilterDto.kt @@ -0,0 +1,23 @@ +package ch.opentransportdata.ojp.data.dto.request.lir + +import ch.opentransportdata.ojp.data.dto.OJP_NAME_SPACE +import ch.opentransportdata.ojp.data.dto.response.place.PointOfInterestCategoryDto +import kotlinx.serialization.Serializable +import nl.adaptivity.xmlutil.serialization.XmlElement +import nl.adaptivity.xmlutil.serialization.XmlSerialName + +/** + * Created by Deniz Kalem on 02.07.2026 + * + */ +@Serializable +@XmlSerialName("PointOfInterestFilter", OJP_NAME_SPACE, "") +internal data class PointOfInterestFilterDto( + @XmlElement(true) + @XmlSerialName("PointOfInterestCategory", OJP_NAME_SPACE, "") + val pointOfInterestCategory: List? = null, + + @XmlElement(true) + @XmlSerialName("Exclude", OJP_NAME_SPACE, "") + val exclude: Boolean? = null +) \ No newline at end of file diff --git a/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/request/lir/RestrictionsDto.kt b/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/request/lir/RestrictionsDto.kt index 21d0870a..d84f9e26 100644 --- a/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/request/lir/RestrictionsDto.kt +++ b/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/request/lir/RestrictionsDto.kt @@ -1,11 +1,11 @@ package ch.opentransportdata.ojp.data.dto.request.lir import ch.opentransportdata.ojp.data.dto.OJP_NAME_SPACE +import ch.opentransportdata.ojp.data.dto.request.ser.ModeFilterDto import ch.opentransportdata.ojp.domain.model.PlaceTypeRestriction import kotlinx.serialization.Serializable import nl.adaptivity.xmlutil.serialization.XmlElement import nl.adaptivity.xmlutil.serialization.XmlSerialName -import nl.adaptivity.xmlutil.serialization.XmlValue /** @@ -18,6 +18,14 @@ internal data class RestrictionsDto( @XmlSerialName("Type", OJP_NAME_SPACE, "") val types: List, + @XmlElement(true) + @XmlSerialName("Modes", OJP_NAME_SPACE, "") + val modeFilter: ModeFilterDto? = null, + + @XmlElement(true) + @XmlSerialName("PointOfInterestFilter", OJP_NAME_SPACE, "") + val pointOfInterestFilter: PointOfInterestFilterDto? = null, + @XmlElement(true) @XmlSerialName("NumberOfResults", OJP_NAME_SPACE, "") val numberOfResults: Int, diff --git a/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/request/ser/ModeFilterDto.kt b/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/request/ser/ModeFilterDto.kt index e26ca585..6a956a00 100644 --- a/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/request/ser/ModeFilterDto.kt +++ b/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/request/ser/ModeFilterDto.kt @@ -1,6 +1,7 @@ package ch.opentransportdata.ojp.data.dto.request.ser import ch.opentransportdata.ojp.data.dto.OJP_NAME_SPACE +import ch.opentransportdata.ojp.domain.model.PersonalMode import ch.opentransportdata.ojp.domain.model.PtMode import kotlinx.serialization.Serializable import nl.adaptivity.xmlutil.serialization.XmlElement @@ -17,6 +18,10 @@ internal data class ModeFilterDto( @XmlSerialName("PtMode", OJP_NAME_SPACE, "") val ptMode: List? = null, + @XmlElement(true) + @XmlSerialName("PersonalMode", OJP_NAME_SPACE, "") + val personalModes: List? = null, + @XmlElement(true) @XmlSerialName("Exclude", OJP_NAME_SPACE, "") val exclude: Boolean? = null, diff --git a/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/request/tr/PlaceReferenceDto.kt b/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/request/tr/PlaceReferenceDto.kt index e9ddf479..c7373621 100644 --- a/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/request/tr/PlaceReferenceDto.kt +++ b/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/request/tr/PlaceReferenceDto.kt @@ -14,7 +14,6 @@ import nl.adaptivity.xmlutil.serialization.XmlSerialName * * Either [ref] and [stationName] has to be set or [position]. */ -//todo: normally should create all the reference objects @Parcelize @Serializable @XmlSerialName("PlaceRef", OJP_NAME_SPACE, "") @@ -23,6 +22,12 @@ data class PlaceReferenceDto( @XmlSerialName("StopPlaceRef", OJP_NAME_SPACE, "") val ref: String? = null, @XmlElement(true) + @XmlSerialName("PointOfInterestRef", OJP_NAME_SPACE, "") + val pointOfInterestRef: String? = null, + @XmlElement(true) + @XmlSerialName("TopographicPlaceRef", OJP_NAME_SPACE, "") + val topographicPlaceRef: String? = null, + @XmlElement(true) @XmlSerialName("Name", OJP_NAME_SPACE, "") val stationName: NameDto? = null, @XmlElement(true) diff --git a/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/response/place/CategoryKeyValueDto.kt b/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/response/place/CategoryKeyValueDto.kt new file mode 100644 index 00000000..d9deffbd --- /dev/null +++ b/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/response/place/CategoryKeyValueDto.kt @@ -0,0 +1,25 @@ +package ch.opentransportdata.ojp.data.dto.response.place + +import android.os.Parcelable +import ch.opentransportdata.ojp.data.dto.OJP_NAME_SPACE +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import nl.adaptivity.xmlutil.serialization.XmlElement +import nl.adaptivity.xmlutil.serialization.XmlSerialName + +/** + * Created by Deniz Kalem on 02.07.2026 + * + */ +@Parcelize +@Serializable +@XmlSerialName("POIAdditionalInformation", OJP_NAME_SPACE, "") +data class CategoryKeyValueDto( + @XmlElement(true) + @XmlSerialName("Key", OJP_NAME_SPACE, "") + val key: String, + + @XmlElement(true) + @XmlSerialName("Value", OJP_NAME_SPACE, "") + val value: String +) : Parcelable \ No newline at end of file diff --git a/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/response/place/OsmTagDto.kt b/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/response/place/OsmTagDto.kt new file mode 100644 index 00000000..38804faa --- /dev/null +++ b/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/response/place/OsmTagDto.kt @@ -0,0 +1,25 @@ +package ch.opentransportdata.ojp.data.dto.response.place + +import android.os.Parcelable +import ch.opentransportdata.ojp.data.dto.OJP_NAME_SPACE +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import nl.adaptivity.xmlutil.serialization.XmlElement +import nl.adaptivity.xmlutil.serialization.XmlSerialName + +/** + * Created by Deniz Kalem on 02.07.2026 + * + */ +@Parcelize +@Serializable +@XmlSerialName("OsmTag", OJP_NAME_SPACE, "") +data class OsmTagDto( + @XmlElement(true) + @XmlSerialName("Tag", OJP_NAME_SPACE, "") + val tag: String, + + @XmlElement(true) + @XmlSerialName("Value", OJP_NAME_SPACE, "") + val value: String +) : Parcelable diff --git a/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/response/place/PointOfInterestAdditionalInformationDto.kt b/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/response/place/PointOfInterestAdditionalInformationDto.kt new file mode 100644 index 00000000..0e30eb19 --- /dev/null +++ b/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/response/place/PointOfInterestAdditionalInformationDto.kt @@ -0,0 +1,21 @@ +package ch.opentransportdata.ojp.data.dto.response.place + +import android.os.Parcelable +import ch.opentransportdata.ojp.data.dto.OJP_NAME_SPACE +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import nl.adaptivity.xmlutil.serialization.XmlElement +import nl.adaptivity.xmlutil.serialization.XmlSerialName + +/** + * Created by Deniz Kalem on 02.07.2026 + * + */ +@Parcelize +@Serializable +@XmlSerialName("POIAdditionalInformation", OJP_NAME_SPACE, "") +data class PointOfInterestAdditionalInformationDto( + @XmlElement(true) + @XmlSerialName("POIAdditionalInformation", OJP_NAME_SPACE, "") + val additionalInformation: List = emptyList() +) : Parcelable \ No newline at end of file diff --git a/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/response/place/PointOfInterestCategoryDto.kt b/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/response/place/PointOfInterestCategoryDto.kt new file mode 100644 index 00000000..40f29f2e --- /dev/null +++ b/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/response/place/PointOfInterestCategoryDto.kt @@ -0,0 +1,29 @@ +package ch.opentransportdata.ojp.data.dto.response.place + +import android.os.Parcelable +import ch.opentransportdata.ojp.data.dto.OJP_NAME_SPACE +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import nl.adaptivity.xmlutil.serialization.XmlElement +import nl.adaptivity.xmlutil.serialization.XmlSerialName + +/** + * Created by Deniz Kalem on 02.07.2026 + * + */ +@Parcelize +@Serializable +@XmlSerialName("PointOfInterestCategory", OJP_NAME_SPACE, "") +data class PointOfInterestCategoryDto( + @XmlElement(true) + @XmlSerialName("OsmTag", OJP_NAME_SPACE, "") + val osmTag: OsmTagDto? = null, + + @XmlElement(true) + @XmlSerialName("PointOfInterestClassification", OJP_NAME_SPACE, "") + val pointOfInterestClassification: String? = null +) : Parcelable { + + val value: String? + get() = pointOfInterestClassification ?: osmTag?.value +} diff --git a/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/response/place/PointOfInterestDto.kt b/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/response/place/PointOfInterestDto.kt index 9b247fb0..086143ed 100644 --- a/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/response/place/PointOfInterestDto.kt +++ b/sdk/src/main/java/ch/opentransportdata/ojp/data/dto/response/place/PointOfInterestDto.kt @@ -31,7 +31,21 @@ data class PointOfInterestDto( @XmlSerialName("NameSuffix", OJP_NAME_SPACE, "") val nameSuffix: NameDto? = null, + @XmlElement(true) + @XmlSerialName("PointOfInterestCategory", OJP_NAME_SPACE, "") + val pointOfInterestCategory: List? = emptyList(), + + @XmlElement(true) + @XmlSerialName("POIAdditionalInformation", OJP_NAME_SPACE, "") + val poiAdditionalInformation: PointOfInterestAdditionalInformationDto? = null, + @XmlElement(true) @XmlSerialName("TopographicPlaceRef", OJP_NAME_SPACE, "") val topographicPlaceRef: String? = null -) : AbstractPlaceDto() \ No newline at end of file +) : AbstractPlaceDto() { + + val additionalInformation: Map + get() = poiAdditionalInformation?.additionalInformation + ?.associate { it.key to it.value } + ?: emptyMap() +} \ No newline at end of file diff --git a/sdk/src/main/java/ch/opentransportdata/ojp/data/remote/location/RemoteLocationInformationDataSource.kt b/sdk/src/main/java/ch/opentransportdata/ojp/data/remote/location/RemoteLocationInformationDataSource.kt index fa184fd6..3002df3f 100644 --- a/sdk/src/main/java/ch/opentransportdata/ojp/data/remote/location/RemoteLocationInformationDataSource.kt +++ b/sdk/src/main/java/ch/opentransportdata/ojp/data/remote/location/RemoteLocationInformationDataSource.kt @@ -21,4 +21,13 @@ internal interface RemoteLocationInformationDataSource { latitude: Double, restrictions: LocationInformationParams ): OjpDto + + suspend fun searchLocationByRectangle( + languageCode: LanguageCode, + upperLeftLongitude: Double, + upperLeftLatitude: Double, + lowerRightLongitude: Double, + lowerRightLatitude: Double, + restrictions: LocationInformationParams + ): OjpDto } \ No newline at end of file diff --git a/sdk/src/main/java/ch/opentransportdata/ojp/data/remote/location/RemoteLocationInformationDataSourceImpl.kt b/sdk/src/main/java/ch/opentransportdata/ojp/data/remote/location/RemoteLocationInformationDataSourceImpl.kt index b7a55647..79133bf1 100644 --- a/sdk/src/main/java/ch/opentransportdata/ojp/data/remote/location/RemoteLocationInformationDataSourceImpl.kt +++ b/sdk/src/main/java/ch/opentransportdata/ojp/data/remote/location/RemoteLocationInformationDataSourceImpl.kt @@ -5,6 +5,9 @@ import ch.opentransportdata.ojp.data.dto.request.OjpRequestDto import ch.opentransportdata.ojp.data.dto.request.ServiceRequestContextDto import ch.opentransportdata.ojp.data.dto.request.ServiceRequestDto import ch.opentransportdata.ojp.data.dto.request.lir.* +import ch.opentransportdata.ojp.data.dto.request.ser.ModeFilterDto +import ch.opentransportdata.ojp.data.dto.response.place.OsmTagDto +import ch.opentransportdata.ojp.data.dto.response.place.PointOfInterestCategoryDto import ch.opentransportdata.ojp.data.remote.OjpService import ch.opentransportdata.ojp.domain.model.LanguageCode import ch.opentransportdata.ojp.domain.model.LocationInformationParams @@ -71,6 +74,36 @@ internal class RemoteLocationInformationDataSourceImpl( return@withContext ojpService.serviceRequest(url, request) } + override suspend fun searchLocationByRectangle( + languageCode: LanguageCode, + upperLeftLongitude: Double, + upperLeftLatitude: Double, + lowerRightLongitude: Double, + lowerRightLatitude: Double, + restrictions: LocationInformationParams + ): OjpDto = withContext(Dispatchers.IO) { + val requestTime = LocalDateTime.now() + + val request = createRequest( + languageCode = languageCode, + requestTime = requestTime, + locationInformationRequest = LocationInformationRequestDto( + requestTimestamp = requestTime, + initialInput = InitialInputDto( + geoRestriction = GeoRestrictionDto( + rectangle = RectangleDto( + upperLeft = PointDto(longitude = upperLeftLongitude, latitude = upperLeftLatitude), + lowerRight = PointDto(longitude = lowerRightLongitude, latitude = lowerRightLatitude) + ) + ) + ), + restrictions = createRestrictions(restrictions) + ) + ) + + return@withContext ojpService.serviceRequest(url, request) + } + private fun createRequest( languageCode: LanguageCode, requestTime: LocalDateTime, @@ -93,6 +126,24 @@ internal class RemoteLocationInformationDataSourceImpl( private fun createRestrictions(restrictions: LocationInformationParams): RestrictionsDto { return RestrictionsDto( types = restrictions.types, + modeFilter = restrictions.modeFilter?.let { filter -> + ModeFilterDto( + ptMode = filter.ptMode, + personalModes = filter.personalModes, + exclude = filter.exclude + ) + }, + pointOfInterestFilter = restrictions.pointOfInterestFilter?.let { filter -> + PointOfInterestFilterDto( + pointOfInterestCategory = filter.categories.map { category -> + PointOfInterestCategoryDto( + osmTag = category.osmTag?.let { OsmTagDto(tag = it.tag, value = it.value) }, + pointOfInterestClassification = category.classification + ) + }.ifEmpty { null }, + exclude = filter.exclude + ) + }, numberOfResults = restrictions.numberOfResults, ptModeIncluded = restrictions.ptModeIncluded ) diff --git a/sdk/src/main/java/ch/opentransportdata/ojp/data/remote/trip/RemoteTripDataSourceImpl.kt b/sdk/src/main/java/ch/opentransportdata/ojp/data/remote/trip/RemoteTripDataSourceImpl.kt index 764d12af..b7db0a14 100644 --- a/sdk/src/main/java/ch/opentransportdata/ojp/data/remote/trip/RemoteTripDataSourceImpl.kt +++ b/sdk/src/main/java/ch/opentransportdata/ojp/data/remote/trip/RemoteTripDataSourceImpl.kt @@ -247,6 +247,7 @@ internal class RemoteTripDataSourceImpl( modeFilter = this.modeFilter?.let { filter -> ModeFilterDto( ptMode = filter.ptMode, + personalModes = filter.personalModes, exclude = filter.exclude, ) }, diff --git a/sdk/src/main/java/ch/opentransportdata/ojp/data/repository/OjpRepositoryImpl.kt b/sdk/src/main/java/ch/opentransportdata/ojp/data/repository/OjpRepositoryImpl.kt index 925f1121..d6289b8b 100644 --- a/sdk/src/main/java/ch/opentransportdata/ojp/data/repository/OjpRepositoryImpl.kt +++ b/sdk/src/main/java/ch/opentransportdata/ojp/data/repository/OjpRepositoryImpl.kt @@ -72,6 +72,32 @@ internal class OjpRepositoryImpl( } } + override suspend fun placeResultsFromRectangle( + languageCode: LanguageCode, + upperLeftLongitude: Double, + upperLeftLatitude: Double, + lowerRightLongitude: Double, + lowerRightLatitude: Double, + restrictions: LocationInformationParams + ): Result> { + return try { + val response = remoteDataSource.searchLocationByRectangle( + languageCode = languageCode, + upperLeftLongitude = upperLeftLongitude, + upperLeftLatitude = upperLeftLatitude, + lowerRightLongitude = lowerRightLongitude, + lowerRightLatitude = lowerRightLatitude, + restrictions = restrictions + ).ojpResponse + val delivery = response?.serviceDelivery?.ojpDelivery as? LocationInformationDeliveryDto + val result = delivery?.placeResults ?: emptyList() + Result.Success(result) + } catch (exception: Exception) { + val error = handleError(exception) + Result.Error(error) + } + } + override suspend fun requestTrips( languageCode: LanguageCode, origin: PlaceReferenceDto, diff --git a/sdk/src/main/java/ch/opentransportdata/ojp/di/UseCaseModule.kt b/sdk/src/main/java/ch/opentransportdata/ojp/di/UseCaseModule.kt index 380b3938..50165077 100644 --- a/sdk/src/main/java/ch/opentransportdata/ojp/di/UseCaseModule.kt +++ b/sdk/src/main/java/ch/opentransportdata/ojp/di/UseCaseModule.kt @@ -12,6 +12,7 @@ internal val useCaseModule = module { singleOf(::Initializer) factoryOf(::RequestLocationsFromSearchTerm) factoryOf(::RequestLocationsFromCoordinates) + factoryOf(::RequestLocationsFromRectangle) singleOf(::RequestTrips) factoryOf(::RequestMockTrips) factoryOf(::UpdateTrip) diff --git a/sdk/src/main/java/ch/opentransportdata/ojp/domain/model/LocationInformationParams.kt b/sdk/src/main/java/ch/opentransportdata/ojp/domain/model/LocationInformationParams.kt index f24b498b..b1748924 100644 --- a/sdk/src/main/java/ch/opentransportdata/ojp/domain/model/LocationInformationParams.kt +++ b/sdk/src/main/java/ch/opentransportdata/ojp/domain/model/LocationInformationParams.kt @@ -6,5 +6,7 @@ package ch.opentransportdata.ojp.domain.model data class LocationInformationParams( val types: List, val numberOfResults: Int, - val ptModeIncluded: Boolean + val ptModeIncluded: Boolean, + val modeFilter: ModeFilter? = null, + val pointOfInterestFilter: PointOfInterestFilter? = null, ) diff --git a/sdk/src/main/java/ch/opentransportdata/ojp/domain/model/ModeFilter.kt b/sdk/src/main/java/ch/opentransportdata/ojp/domain/model/ModeFilter.kt index 99dad64f..9885185e 100644 --- a/sdk/src/main/java/ch/opentransportdata/ojp/domain/model/ModeFilter.kt +++ b/sdk/src/main/java/ch/opentransportdata/ojp/domain/model/ModeFilter.kt @@ -9,5 +9,6 @@ import kotlinx.parcelize.Parcelize @Parcelize data class ModeFilter( val ptMode: List? = null, + val personalModes: List? = null, val exclude: Boolean? = null, ) : Parcelable \ No newline at end of file diff --git a/sdk/src/main/java/ch/opentransportdata/ojp/domain/model/PersonalMode.kt b/sdk/src/main/java/ch/opentransportdata/ojp/domain/model/PersonalMode.kt new file mode 100644 index 00000000..24fb209a --- /dev/null +++ b/sdk/src/main/java/ch/opentransportdata/ojp/domain/model/PersonalMode.kt @@ -0,0 +1,32 @@ +package ch.opentransportdata.ojp.domain.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Created by Deniz Kalem on 02.07.2026 + * + */ +@Serializable +enum class PersonalMode { + @SerialName("foot") + FOOT, + + @SerialName("bicycle") + BICYCLE, + + @SerialName("car") + CAR, + + @SerialName("motorcycle") + MOTORCYCLE, + + @SerialName("truck") + TRUCK, + + @SerialName("scooter") + SCOOTER, + + @SerialName("other") + OTHER +} \ No newline at end of file diff --git a/sdk/src/main/java/ch/opentransportdata/ojp/domain/model/PointOfInterestFilter.kt b/sdk/src/main/java/ch/opentransportdata/ojp/domain/model/PointOfInterestFilter.kt new file mode 100644 index 00000000..f35a6a4d --- /dev/null +++ b/sdk/src/main/java/ch/opentransportdata/ojp/domain/model/PointOfInterestFilter.kt @@ -0,0 +1,32 @@ +package ch.opentransportdata.ojp.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Created by Deniz Kalem on 02.07.2026 + * + */ +@Parcelize +data class PointOfInterestFilter( + val categories: List = emptyList(), + val exclude: Boolean? = null, +) : Parcelable + +@Parcelize +data class PointOfInterestCategory( + val osmTag: OsmTag? = null, + val classification: String? = null, +) : Parcelable { + + companion object { + fun sharing(category: SharingCategory): PointOfInterestCategory = + PointOfInterestCategory(osmTag = OsmTag(tag = "amenity", value = category.value)) + } +} + +@Parcelize +data class OsmTag( + val tag: String, + val value: String, +) : Parcelable diff --git a/sdk/src/main/java/ch/opentransportdata/ojp/domain/model/SharingCategory.kt b/sdk/src/main/java/ch/opentransportdata/ojp/domain/model/SharingCategory.kt new file mode 100644 index 00000000..7510c3d6 --- /dev/null +++ b/sdk/src/main/java/ch/opentransportdata/ojp/domain/model/SharingCategory.kt @@ -0,0 +1,23 @@ +package ch.opentransportdata.ojp.domain.model + +import ch.opentransportdata.ojp.data.dto.response.place.PointOfInterestDto + +/** + * Created by Deniz Kalem on 02.07.2026 + * + */ +enum class SharingCategory(val value: String) { + E_SCOOTER("escooter_rental"), + BIKE("bicycle_rental"), + CAR("car_sharing"), + CHARGING_STATION("charging_station"); + + companion object { + fun from(value: String?): SharingCategory? = entries.firstOrNull { it.value == value } + } +} + +val PointOfInterestDto.sharingCategories: List + get() = pointOfInterestCategory + ?.mapNotNull { SharingCategory.from(it.value) } + ?: emptyList() \ No newline at end of file diff --git a/sdk/src/main/java/ch/opentransportdata/ojp/domain/repository/OjpRepository.kt b/sdk/src/main/java/ch/opentransportdata/ojp/domain/repository/OjpRepository.kt index 48dc7774..12fcd628 100644 --- a/sdk/src/main/java/ch/opentransportdata/ojp/domain/repository/OjpRepository.kt +++ b/sdk/src/main/java/ch/opentransportdata/ojp/domain/repository/OjpRepository.kt @@ -33,6 +33,15 @@ internal interface OjpRepository { restrictions: LocationInformationParams ): Result> + suspend fun placeResultsFromRectangle( + languageCode: LanguageCode, + upperLeftLongitude: Double, + upperLeftLatitude: Double, + lowerRightLongitude: Double, + lowerRightLatitude: Double, + restrictions: LocationInformationParams + ): Result> + suspend fun requestTrips( languageCode: LanguageCode, origin: PlaceReferenceDto, diff --git a/sdk/src/main/java/ch/opentransportdata/ojp/domain/usecase/RequestLocationsFromRectangle.kt b/sdk/src/main/java/ch/opentransportdata/ojp/domain/usecase/RequestLocationsFromRectangle.kt new file mode 100644 index 00000000..f5b64e2a --- /dev/null +++ b/sdk/src/main/java/ch/opentransportdata/ojp/domain/usecase/RequestLocationsFromRectangle.kt @@ -0,0 +1,34 @@ +package ch.opentransportdata.ojp.domain.usecase + +import ch.opentransportdata.ojp.data.dto.response.PlaceResultDto +import ch.opentransportdata.ojp.domain.model.LanguageCode +import ch.opentransportdata.ojp.domain.model.LocationInformationParams +import ch.opentransportdata.ojp.domain.model.Result +import ch.opentransportdata.ojp.domain.repository.OjpRepository + +/** + * Created by Deniz Kalem on 02.07.2026 + * + */ +internal class RequestLocationsFromRectangle( + private val ojpRepository: OjpRepository +) { + + suspend operator fun invoke( + languageCode: LanguageCode, + upperLeftLongitude: Double, + upperLeftLatitude: Double, + lowerRightLongitude: Double, + lowerRightLatitude: Double, + restrictions: LocationInformationParams + ): Result> { + return ojpRepository.placeResultsFromRectangle( + languageCode = languageCode, + upperLeftLongitude = upperLeftLongitude, + upperLeftLatitude = upperLeftLatitude, + lowerRightLongitude = lowerRightLongitude, + lowerRightLatitude = lowerRightLatitude, + restrictions = restrictions + ) + } +} \ No newline at end of file