From fa682a6422b6e70088f502245dfea42626daba81 Mon Sep 17 00:00:00 2001 From: deka91 Date: Tue, 30 Jun 2026 14:57:33 +0200 Subject: [PATCH 01/12] Upgrade ramani-maps to 0.12.0 and migrate MapScreen to new MapLibre API --- .../presentation/feature/map/MapScreen.kt | 21 ++++++++----------- gradle/libs.versions.toml | 2 +- 2 files changed, 10 insertions(+), 13 deletions(-) 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/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" From 18eb7add22f5e6385c381e989d4a29788bc02b0c Mon Sep 17 00:00:00 2001 From: deka91 Date: Wed, 8 Jul 2026 15:58:48 +0200 Subject: [PATCH 02/12] Add response DTOs for point of interest details --- .../dto/response/place/CategoryKeyValueDto.kt | 25 ++++++++++++++++ .../ojp/data/dto/response/place/OsmTagDto.kt | 25 ++++++++++++++++ ...PointOfInterestAdditionalInformationDto.kt | 21 ++++++++++++++ .../place/PointOfInterestCategoryDto.kt | 29 +++++++++++++++++++ .../dto/response/place/PointOfInterestDto.kt | 16 +++++++++- 5 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 sdk/src/main/java/ch/opentransportdata/ojp/data/dto/response/place/CategoryKeyValueDto.kt create mode 100644 sdk/src/main/java/ch/opentransportdata/ojp/data/dto/response/place/OsmTagDto.kt create mode 100644 sdk/src/main/java/ch/opentransportdata/ojp/data/dto/response/place/PointOfInterestAdditionalInformationDto.kt create mode 100644 sdk/src/main/java/ch/opentransportdata/ojp/data/dto/response/place/PointOfInterestCategoryDto.kt 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 From fa74a815e1dfd5dc5224c2dd66dfed7e1e818071 Mon Sep 17 00:00:00 2001 From: deka91 Date: Wed, 8 Jul 2026 16:00:09 +0200 Subject: [PATCH 03/12] Add domain models for POI filter and personal modes --- .../domain/model/LocationInformationParams.kt | 4 ++- .../ojp/domain/model/ModeFilter.kt | 1 + .../ojp/domain/model/PersonalMode.kt | 32 +++++++++++++++++++ .../ojp/domain/model/PointOfInterestFilter.kt | 32 +++++++++++++++++++ .../ojp/domain/model/SharingCategory.kt | 23 +++++++++++++ 5 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 sdk/src/main/java/ch/opentransportdata/ojp/domain/model/PersonalMode.kt create mode 100644 sdk/src/main/java/ch/opentransportdata/ojp/domain/model/PointOfInterestFilter.kt create mode 100644 sdk/src/main/java/ch/opentransportdata/ojp/domain/model/SharingCategory.kt 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 From a03694d2703289844ee67c8cf5f7aa89fc08b938 Mon Sep 17 00:00:00 2001 From: deka91 Date: Wed, 8 Jul 2026 16:00:32 +0200 Subject: [PATCH 04/12] Add request DTOs for POI filter and personal modes --- .../ojp/data/dto/request/lir/RestrictionsDto.kt | 10 +++++++++- .../ojp/data/dto/request/ser/ModeFilterDto.kt | 5 +++++ 2 files changed, 14 insertions(+), 1 deletion(-) 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, From c217419c2f623c84dc168a54c486caa6541f94b9 Mon Sep 17 00:00:00 2001 From: deka91 Date: Wed, 8 Jul 2026 16:00:58 +0200 Subject: [PATCH 05/12] Implement location request by rectangle --- .../request/lir/PointOfInterestFilterDto.kt | 23 +++++++++ .../RemoteLocationInformationDataSource.kt | 9 ++++ ...RemoteLocationInformationDataSourceImpl.kt | 51 +++++++++++++++++++ .../remote/trip/RemoteTripDataSourceImpl.kt | 1 + .../ojp/data/repository/OjpRepositoryImpl.kt | 26 ++++++++++ .../opentransportdata/ojp/di/UseCaseModule.kt | 1 + .../ojp/domain/repository/OjpRepository.kt | 9 ++++ .../usecase/RequestLocationsFromRectangle.kt | 34 +++++++++++++ 8 files changed, 154 insertions(+) create mode 100644 sdk/src/main/java/ch/opentransportdata/ojp/data/dto/request/lir/PointOfInterestFilterDto.kt create mode 100644 sdk/src/main/java/ch/opentransportdata/ojp/domain/usecase/RequestLocationsFromRectangle.kt 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/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/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 From cfcb09eee255e56f367efb1c1eda611169efd004 Mon Sep 17 00:00:00 2001 From: deka91 Date: Wed, 8 Jul 2026 16:01:12 +0200 Subject: [PATCH 06/12] Expose requestLocationsFromRectangle in OjpSdk --- .../java/ch/opentransportdata/ojp/OjpSdk.kt | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) 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 * From 48f39b76042de3efa4bcb6400eaa665a26c7d31b Mon Sep 17 00:00:00 2001 From: deka91 Date: Wed, 8 Jul 2026 17:13:47 +0200 Subject: [PATCH 07/12] Add shared mobility map tab to demo app --- .../presentation/MainActivity.kt | 4 +- .../feature/map/SharedMobilityMapScreen.kt | 394 ++++++++++++++++++ .../feature/map/SharedMobilityMapViewModel.kt | 153 +++++++ .../navigation/BottomNavigation.kt | 7 + 4 files changed, 557 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/ch/opentransportdata/presentation/feature/map/SharedMobilityMapScreen.kt create mode 100644 app/src/main/java/ch/opentransportdata/presentation/feature/map/SharedMobilityMapViewModel.kt 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/map/SharedMobilityMapScreen.kt b/app/src/main/java/ch/opentransportdata/presentation/feature/map/SharedMobilityMapScreen.kt new file mode 100644 index 00000000..4b4eddbf --- /dev/null +++ b/app/src/main/java/ch/opentransportdata/presentation/feature/map/SharedMobilityMapScreen.kt @@ -0,0 +1,394 @@ +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() + ) + } + } + } + + 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 = 14.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..3c0ea4e2 --- /dev/null +++ b/app/src/main/java/ch/opentransportdata/presentation/feature/map/SharedMobilityMapViewModel.kt @@ -0,0 +1,153 @@ +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.PlaceTypeRestriction +import ch.opentransportdata.ojp.domain.model.PointOfInterestCategory +import ch.opentransportdata.ojp.domain.model.PointOfInterestFilter +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 = listOf(PlaceTypeRestriction.POI), + numberOfResults = 100, + ptModeIncluded = false, + pointOfInterestFilter = PointOfInterestFilter( + categories = SharingCategory.entries.map { PointOfInterestCategory.sharing(it) } + ) + ) + ) + + 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 + val name = poi?.name?.text ?: place.name?.text.orEmpty() + PoiMarker( + id = poi?.publicCode ?: "index_$index", + latitude = position.latitude, + longitude = position.longitude, + name = name, + category = poi?.sharingCategories?.firstOrNull() ?: classifyByName(name), + additionalInformation = poi?.additionalInformation.orEmpty() + ) + } + Log.d(TAG, "Loaded ${result.data.size} place results -> ${markers.size} markers") + 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) } + } + } + } + } + + private fun classifyByName(name: String): SharingCategory? { + val value = name.lowercase(Locale.getDefault()) + return when { + listOf("scooter", "trottinett").any { it in value } -> SharingCategory.E_SCOOTER + listOf("charg", "ladestation", "lade").any { it in value } -> SharingCategory.CHARGING_STATION + listOf("velo", "bike", "fahrrad", "zweirad", "cycle").any { it in value } -> SharingCategory.BIKE + listOf("car", "auto", "mobility", "sharing").any { it in value } -> SharingCategory.CAR + else -> null + } + } + + 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/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 From b117f5b82a38aa55264cbf9d2616bd96a6b83285 Mon Sep 17 00:00:00 2001 From: deka91 Date: Wed, 8 Jul 2026 17:17:05 +0200 Subject: [PATCH 08/12] Add rectangle location request example to README --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 337c0584..02295f7f 100644 --- a/README.md +++ b/README.md @@ -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.415, + upperLeftLatitude = 46.970, + lowerRightLongitude = 7.480, + lowerRightLatitude = 46.926, + restrictions = LocationInformationParams( + types = listOf(PlaceTypeRestriction.POI), + numberOfResults = 300, + ptModeIncluded = false, + pointOfInterestFilter = PointOfInterestFilter( + categories = listOf( + PointOfInterestCategory.sharing(SharingCategory.E_SCOOTER), + PointOfInterestCategory.sharing(SharingCategory.BIKE), + PointOfInterestCategory.sharing(SharingCategory.CAR), + PointOfInterestCategory.sharing(SharingCategory.CHARGING_STATION) + ) + ) + ) +) +``` + +The rectangle is defined by its upper left and lower right corners (WGS84) and is typically used to load points of interest (e.g. shared mobility vehicles) for the currently visible map region. Use `PointOfInterestFilter` to restrict the results to specific POI categories. Alternatively you can pass a `ModeFilter` with `personalModes` (e.g. `PersonalMode.BICYCLE`, `PersonalMode.SCOOTER`, `PersonalMode.CAR`). + #### Get a list of trips ``` import ch.opentransportdata.ojp.OjpSdk From e156ae3fd6b83d57ef33fe8860909c30fc75a7b4 Mon Sep 17 00:00:00 2001 From: deka91 Date: Wed, 8 Jul 2026 17:20:37 +0200 Subject: [PATCH 09/12] Add missing API link for trip refinement request --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 02295f7f..7a864b65 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+ From 9f3cba04dc9e72d7631fabf213fc947324d8178d Mon Sep 17 00:00:00 2001 From: deka91 Date: Thu, 9 Jul 2026 09:06:38 +0200 Subject: [PATCH 10/12] Add POI and topographic place refs to trip PlaceRef --- .../presentation/feature/map/SharedMobilityMapViewModel.kt | 1 - .../ojp/data/dto/request/tr/PlaceReferenceDto.kt | 7 ++++++- 2 files changed, 6 insertions(+), 2 deletions(-) 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 index 3c0ea4e2..b1dc2603 100644 --- a/app/src/main/java/ch/opentransportdata/presentation/feature/map/SharedMobilityMapViewModel.kt +++ b/app/src/main/java/ch/opentransportdata/presentation/feature/map/SharedMobilityMapViewModel.kt @@ -87,7 +87,6 @@ class SharedMobilityMapViewModel : ViewModel() { additionalInformation = poi?.additionalInformation.orEmpty() ) } - Log.d(TAG, "Loaded ${result.data.size} place results -> ${markers.size} markers") state.update { it.copy(pois = markers, isLoading = false) } } 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) From cf3fa709632cd459ed0023df2d9e5fb3137fb5ca Mon Sep 17 00:00:00 2001 From: deka91 Date: Thu, 9 Jul 2026 10:36:46 +0200 Subject: [PATCH 11/12] Fix CoroutineCreationDuringComposition lint error in screens --- .../feature/location/LocationScreen.kt | 15 +++++---- .../feature/map/SharedMobilityMapScreen.kt | 12 ++++--- .../feature/result/TripResultScreen.kt | 32 +++++++++--------- .../feature/search/TripSearchScreen.kt | 33 ++++++++++--------- .../stopevent/StopEventResultScreen.kt | 14 ++++---- .../stopevent/StopEventSearchScreen.kt | 23 +++++++------ 6 files changed, 72 insertions(+), 57 deletions(-) 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/SharedMobilityMapScreen.kt b/app/src/main/java/ch/opentransportdata/presentation/feature/map/SharedMobilityMapScreen.kt index 4b4eddbf..a0ae7bec 100644 --- a/app/src/main/java/ch/opentransportdata/presentation/feature/map/SharedMobilityMapScreen.kt +++ b/app/src/main/java/ch/opentransportdata/presentation/feature/map/SharedMobilityMapScreen.kt @@ -138,13 +138,15 @@ fun SharedMobilityMapScreen( } } - state.events.forEach { event -> - when (event) { - is SharedMobilityMapViewModel.Event.ShowSnackBar -> coroutineScope.launch { - snackBarHostState.showSnackbar(message = event.message) + LaunchedEffect(state.events) { + state.events.forEach { event -> + when (event) { + is SharedMobilityMapViewModel.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/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) } } From 6d0accdf1937bd1103afa9c44a9839807589d39c Mon Sep 17 00:00:00 2001 From: deka91 Date: Thu, 9 Jul 2026 11:34:32 +0200 Subject: [PATCH 12/12] Load sharing POIs via personal mode filter --- README.md | 26 +++++++------- .../feature/map/SharedMobilityMapScreen.kt | 2 +- .../feature/map/SharedMobilityMapViewModel.kt | 36 ++++++++----------- 3 files changed, 28 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 7a864b65..a7b22fb9 100644 --- a/README.md +++ b/README.md @@ -75,27 +75,27 @@ import ch.opentransportdata.ojp.OjpSdk requestLocationsFromRectangle( languageCode = LanguageCode.EN, - upperLeftLongitude = 7.415, - upperLeftLatitude = 46.970, - lowerRightLongitude = 7.480, - lowerRightLatitude = 46.926, + upperLeftLongitude = 7.431, + upperLeftLatitude = 46.945, + lowerRightLongitude = 7.446, + lowerRightLatitude = 46.950, restrictions = LocationInformationParams( - types = listOf(PlaceTypeRestriction.POI), + types = emptyList(), numberOfResults = 300, - ptModeIncluded = false, - pointOfInterestFilter = PointOfInterestFilter( - categories = listOf( - PointOfInterestCategory.sharing(SharingCategory.E_SCOOTER), - PointOfInterestCategory.sharing(SharingCategory.BIKE), - PointOfInterestCategory.sharing(SharingCategory.CAR), - PointOfInterestCategory.sharing(SharingCategory.CHARGING_STATION) + 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 points of interest (e.g. shared mobility vehicles) for the currently visible map region. Use `PointOfInterestFilter` to restrict the results to specific POI categories. Alternatively you can pass a `ModeFilter` with `personalModes` (e.g. `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 ``` 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 index a0ae7bec..f15fb641 100644 --- a/app/src/main/java/ch/opentransportdata/presentation/feature/map/SharedMobilityMapScreen.kt +++ b/app/src/main/java/ch/opentransportdata/presentation/feature/map/SharedMobilityMapScreen.kt @@ -388,7 +388,7 @@ private fun SharingCategory?.markerColor(): String { 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 = 14.0 +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 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 index b1dc2603..f714f06e 100644 --- a/app/src/main/java/ch/opentransportdata/presentation/feature/map/SharedMobilityMapViewModel.kt +++ b/app/src/main/java/ch/opentransportdata/presentation/feature/map/SharedMobilityMapViewModel.kt @@ -5,9 +5,8 @@ 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.PlaceTypeRestriction -import ch.opentransportdata.ojp.domain.model.PointOfInterestCategory -import ch.opentransportdata.ojp.domain.model.PointOfInterestFilter +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 @@ -62,11 +61,16 @@ class SharedMobilityMapViewModel : ViewModel() { lowerRightLongitude = lowerRightLongitude, lowerRightLatitude = lowerRightLatitude, restrictions = LocationInformationParams( - types = listOf(PlaceTypeRestriction.POI), - numberOfResults = 100, - ptModeIncluded = false, - pointOfInterestFilter = PointOfInterestFilter( - categories = SharingCategory.entries.map { PointOfInterestCategory.sharing(it) } + types = emptyList(), + numberOfResults = 300, + ptModeIncluded = true, + modeFilter = ModeFilter( + exclude = false, + personalModes = listOf( + PersonalMode.BICYCLE, + PersonalMode.SCOOTER, + PersonalMode.CAR + ) ) ) ) @@ -77,13 +81,12 @@ class SharedMobilityMapViewModel : ViewModel() { val place = placeResult.place ?: return@mapIndexedNotNull null val position = place.position ?: return@mapIndexedNotNull null val poi = place.pointOfInterest - val name = poi?.name?.text ?: place.name?.text.orEmpty() PoiMarker( id = poi?.publicCode ?: "index_$index", latitude = position.latitude, longitude = position.longitude, - name = name, - category = poi?.sharingCategories?.firstOrNull() ?: classifyByName(name), + name = poi?.name?.text ?: place.name?.text.orEmpty(), + category = poi?.sharingCategories?.firstOrNull(), additionalInformation = poi?.additionalInformation.orEmpty() ) } @@ -101,17 +104,6 @@ class SharedMobilityMapViewModel : ViewModel() { } } - private fun classifyByName(name: String): SharingCategory? { - val value = name.lowercase(Locale.getDefault()) - return when { - listOf("scooter", "trottinett").any { it in value } -> SharingCategory.E_SCOOTER - listOf("charg", "ladestation", "lade").any { it in value } -> SharingCategory.CHARGING_STATION - listOf("velo", "bike", "fahrrad", "zweirad", "cycle").any { it in value } -> SharingCategory.BIKE - listOf("car", "auto", "mobility", "sharing").any { it in value } -> SharingCategory.CAR - else -> null - } - } - fun eventHandled(id: Long) { state.update { current -> current.copy(events = current.events.filterNot { it.id == id }) } }