diff --git a/app/build.gradle b/app/build.gradle index fd38b81..dd46625 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,146 +1,148 @@ apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-android' apply plugin: 'com.android.application' android { compileSdk 34 namespace "it.reyboz.bustorino" defaultConfig { applicationId "it.reyboz.bustorino" minSdkVersion 21 targetSdkVersion 34 buildToolsVersion = '34.0.0' versionCode 60 versionName "2.2.3" vectorDrawables.useSupportLibrary = true multiDexEnabled true javaCompileOptions { annotationProcessorOptions { arguments = ["room.schemaLocation": "$projectDir/assets/schemas/".toString()] } } testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } testOptions { unitTests.returnDefaultValues = true } sourceSets { androidTest.assets.srcDirs += files("$projectDir/assets/schemas/".toString()) } buildTypes { debug { applicationIdSuffix ".debug" versionNameSuffix "-dev" } gitpull{ applicationIdSuffix ".gitdev" versionNameSuffix "-gitdev" } } repositories { mavenCentral() mavenLocal() } dependencies { //new libraries } compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } kotlin { jvmToolchain 17 } lint { abortOnError false } } dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" implementation 'androidx.legacy:legacy-support-v4:1.0.0' // Guava implementation for DBUpdateWorker implementation 'com.google.guava:guava:29.0-android' implementation "androidx.fragment:fragment-ktx:$fragment_version" implementation "androidx.activity:activity:$activity_version" implementation "androidx.annotation:annotation:1.6.0" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation "androidx.appcompat:appcompat:$appcompat_version" implementation "androidx.appcompat:appcompat-resources:$appcompat_version" implementation "androidx.preference:preference:$preference_version" implementation "androidx.work:work-runtime:$work_version" implementation "androidx.work:work-runtime-ktx:$work_version" implementation "com.google.android.material:material:1.11.0" implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation "androidx.coordinatorlayout:coordinatorlayout:1.2.0" implementation 'org.jsoup:jsoup:1.15.3' implementation 'com.readystatesoftware.sqliteasset:sqliteassethelper:2.0.1' implementation 'com.android.volley:volley:1.2.1' implementation 'org.osmdroid:osmdroid-android:6.1.18' //maplibre implementation 'org.maplibre.gl:android-sdk:11.8.0' implementation 'org.maplibre.gl:android-sdk-turf:6.0.1' + implementation 'org.maplibre.gl:android-plugin-annotation-v9:3.0.2' + // remember to enable maven repo jitpack.io when wanting to use osmbonuspack //implementation 'com.github.MKergall:osmbonuspack:6.9.0' // ACRA implementation "ch.acra:acra-mail:$acra_version" implementation "ch.acra:acra-dialog:$acra_version" // google transit realtime implementation 'com.google.protobuf:protobuf-java:3.19.6' // mqtt library implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5' implementation 'com.github.fabmazz:paho.mqtt.android:v1.0.0' // ViewModel implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" // LiveData implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" // Lifecycles only (without ViewModel or LiveData) implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" // Legacy implementation 'androidx.legacy:legacy-support-v4:1.0.0' // Room components implementation "androidx.room:room-runtime:$room_version" implementation "androidx.room:room-ktx:$room_version" kapt "androidx.room:room-compiler:$room_version" //multidex - we need this to build the app implementation "androidx.multidex:multidex:$multidex_version" implementation 'de.siegmar:fastcsv:2.2.2' testImplementation 'junit:junit:4.12' implementation 'junit:junit:4.12' implementation "androidx.test.ext:junit:1.1.5" implementation "androidx.test:core:$androidXTestVersion" implementation "androidx.test:runner:$androidXTestVersion" implementation "androidx.room:room-testing:$room_version" androidTestImplementation "androidx.test.ext:junit:1.1.5" androidTestImplementation "androidx.test:core:$androidXTestVersion" androidTestImplementation "androidx.test:runner:$androidXTestVersion" androidTestImplementation "androidx.test:rules:$androidXTestVersion" androidTestImplementation "androidx.room:room-testing:$room_version" } diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt index 177bcd1..20445aa 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt @@ -1,413 +1,504 @@ package it.reyboz.bustorino.fragments + import android.annotation.SuppressLint import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas import android.location.Location import android.os.Bundle import android.util.Log -import androidx.fragment.app.Fragment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.RelativeLayout import android.widget.TextView +import android.widget.Toast +import androidx.cardview.widget.CardView +import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.gson.Gson import com.google.gson.JsonObject import it.reyboz.bustorino.R import it.reyboz.bustorino.backend.Stop import it.reyboz.bustorino.map.Styles import it.reyboz.bustorino.viewmodels.StopsMapViewModel import org.maplibre.android.MapLibre +import org.maplibre.android.annotations.Icon +import org.maplibre.android.annotations.IconFactory import org.maplibre.android.camera.CameraPosition import org.maplibre.android.camera.CameraUpdateFactory -import org.maplibre.android.maps.MapView import org.maplibre.android.geometry.LatLng import org.maplibre.android.geometry.LatLngBounds import org.maplibre.android.location.LocationComponent import org.maplibre.android.location.LocationComponentActivationOptions import org.maplibre.android.location.LocationComponentOptions import org.maplibre.android.location.engine.LocationEngineRequest import org.maplibre.android.location.modes.CameraMode import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.MapView import org.maplibre.android.maps.OnMapReadyCallback import org.maplibre.android.maps.Style +import org.maplibre.android.plugins.annotation.Symbol +import org.maplibre.android.plugins.annotation.SymbolManager +import org.maplibre.android.plugins.annotation.SymbolOptions +import org.maplibre.android.style.layers.Property.ICON_ANCHOR_CENTER import org.maplibre.android.style.layers.PropertyFactory import org.maplibre.android.style.layers.SymbolLayer import org.maplibre.android.style.sources.GeoJsonSource import org.maplibre.geojson.Feature import org.maplibre.geojson.FeatureCollection import org.maplibre.geojson.Point // TODO: Rename parameter arguments, choose names that match // the fragment initialization parameters, e.g. ARG_ITEM_NUMBER private const val ARG_PARAM1 = "param1" private const val ARG_PARAM2 = "param2" /** * A simple [Fragment] subclass. * Use the [MapLibreFragment.newInstance] factory method to * create an instance of this fragment. */ class MapLibreFragment : Fragment(), OnMapReadyCallback { //private var param1: String? = null //private var param2: String? = null // Declare a variable for MapView private lateinit var mapView: MapView private lateinit var locationComponent: LocationComponent private var lastLocation: Location? = null private val stopsViewModel: StopsMapViewModel by viewModels() private val gson = Gson() private var stopsShowing = ArrayList(0) private var isBottomSheetShowing = false + private lateinit var symbolManager: SymbolManager protected var map: MapLibreMap? = null // Sources for stops and buses private lateinit var stopsSource: GeoJsonSource private lateinit var busesSource: GeoJsonSource private var isStopsLayerStarted = false private var lastStopsSizeShown = 0 private var lastBBox = LatLngBounds.from(2.0, 2.0, 1.0,1.0) private lateinit var mapStyle: Style //bottom Sheet behavior private lateinit var bottomSheetBehavior: BottomSheetBehavior private var bottomLayout: RelativeLayout? = null private lateinit var stopTitleTextView: TextView private lateinit var stopNumberTextView: TextView private lateinit var linesPassingTextView: TextView + private lateinit var arrivalsCard: CardView + private lateinit var directionsCard: CardView + + private var stopActiveSymbol: Symbol? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) /*arguments?.let { param1 = it.getString(ARG_PARAM1) param2 = it.getString(ARG_PARAM2) } */ MapLibre.getInstance(requireContext()) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { // Inflate the layout for this fragment val rootView = inflater.inflate(R.layout.fragment_map_libre, container, false) // Init layout view // Init the MapView mapView = rootView.findViewById(R.id.libreMapView) mapView.getMapAsync(this) //{ //map -> //map.setStyle("https://demotiles.maplibre.org/style.json") } //init bottom sheet val bottomSheet = rootView.findViewById(R.id.bottom_sheet) bottomLayout = bottomSheet stopTitleTextView = bottomSheet.findViewById(R.id.stopTitleTextView) stopNumberTextView = bottomSheet.findViewById(R.id.stopNumberTextView) linesPassingTextView = bottomSheet.findViewById(R.id.linesPassingTextView) + arrivalsCard = bottomSheet.findViewById(R.id.arrivalsCardButton) + directionsCard = bottomSheet.findViewById(R.id.directionsCardButton) bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet) bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN + arrivalsCard.setOnClickListener { + if(context!=null){ + Toast.makeText(context,"ARRIVALS", Toast.LENGTH_SHORT).show() + } + } // Setup close button rootView.findViewById(R.id.btnClose).setOnClickListener { - bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN - isBottomSheetShowing = false + hideStopBottomSheet() } return rootView } + /** + * This method sets up the map + */ override fun onMapReady(mapReady: MapLibreMap) { this.map = mapReady mapReady.cameraPosition = CameraPosition.Builder().target(LatLng(DEFAULT_CENTER_LAT, DEFAULT_CENTER_LON)).zoom( 15.0).build() val mjson = Styles.getJsonStyleFromAsset(requireContext(), "map_style_good_noshops.json")//ViewUtils.loadJsonFromAsset(requireContext(),"map_style_good.json") activity?.run { mapReady.setStyle(Style.Builder().fromJson(mjson!!)) { style -> mapStyle = style - setupLayers(style) + //setupLayers(style) + + symbolManager = SymbolManager(mapView,mapReady,style) + symbolManager.iconAllowOverlap = true + symbolManager.textAllowOverlap = true + symbolManager.addClickListener{ _ -> + if (stopActiveSymbol!=null){ + hideStopBottomSheet() + + return@addClickListener true + } else + return@addClickListener false + } // Start observing data observeViewModels() initLocation(style, mapReady, requireContext()) } mapReady.addOnCameraIdleListener { map?.let { val newBbox = it.projection.visibleRegion.latLngBounds if ((newBbox.center==lastBBox.center) && (newBbox.latitudeSpan==lastBBox.latitudeSpan) && (newBbox.longitudeSpan==lastBBox.latitudeSpan)){ //do nothing } else { stopsViewModel.loadStopsInLatLngBounds(newBbox) lastBBox = newBbox } } } mapReady.addOnMapClickListener { point -> val screenPoint = mapReady.projection.toScreenLocation(point) val features = mapReady.queryRenderedFeatures(screenPoint, STOPS_LAYER_ID) if (features.isNotEmpty()) { val feature = features[0] val id = feature.getStringProperty("id") val name = feature.getStringProperty("name") //Toast.makeText(requireContext(), "Clicked on $name ($id)", Toast.LENGTH_SHORT).show() val stop = stopsViewModel.getStopByID(id) stop?.let { if (isBottomSheetShowing){ - bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN - isBottomSheetShowing = false + hideStopBottomSheet() } showStopInBottomSheet(it) bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED isBottomSheetShowing = true //move camera if(it.latitude!=null && it.longitude!=null) //mapReady.cameraPosition = CameraPosition.Builder().target(LatLng(it.latitude!!, it.longitude!!)).build() mapReady.animateCamera(CameraUpdateFactory.newLatLng(LatLng(it.latitude!!,it.longitude!!)),750) } return@addOnMapClickListener true } false } //makeStyleMapBoxUrl(false)) } } @SuppressLint("MissingPermission") private fun initLocation(style: Style, map: MapLibreMap, context: Context){ locationComponent = map.locationComponent val locationComponentOptions = LocationComponentOptions.builder(context) .pulseEnabled(true) .build() val locationComponentActivationOptions = buildLocationComponentActivationOptions(style, locationComponentOptions, context) locationComponent.activateLocationComponent(locationComponentActivationOptions) locationComponent.isLocationComponentEnabled = true locationComponent.cameraMode = CameraMode.TRACKING //CameraMode.TRACKING locationComponent.forceLocationUpdate(lastLocation) } private fun buildLocationComponentActivationOptions( style: Style, locationComponentOptions: LocationComponentOptions, context: Context ): LocationComponentActivationOptions { return LocationComponentActivationOptions .builder(context, style) .locationComponentOptions(locationComponentOptions) .useDefaultLocationEngine(true) .locationEngineRequest( LocationEngineRequest.Builder(750) .setFastestInterval(750) .setPriority(LocationEngineRequest.PRIORITY_HIGH_ACCURACY) .build() ) .build() } - private fun startLayerStops(style: Style, features:FeatureCollection){ + private fun initStopsLayer(style: Style, features:FeatureCollection){ stopsSource = GeoJsonSource(STOPS_SOURCE_ID,features) style.addSource(stopsSource) // add icon style.addImage(STOP_IMAGE_ID, ResourcesCompat.getDrawable(resources,R.drawable.bus_stop_new, activity?.theme)!!) + + style.addImage(STOP_ACTIVE_IMG, ResourcesCompat.getDrawable(resources, R.drawable.bus_stop_new_highlight, activity?.theme)!!) // Stops layer val stopsLayer = SymbolLayer(STOPS_LAYER_ID, STOPS_SOURCE_ID) stopsLayer.withProperties( PropertyFactory.iconImage(STOP_IMAGE_ID), PropertyFactory.iconAllowOverlap(true), PropertyFactory.iconIgnorePlacement(true) ) style.addLayerBelow(stopsLayer, "label_country_1") isStopsLayerStarted = true } /** * Setup the Map Layers */ - private fun setupLayers(style: Style) { + //private fun setupLayers(style: Style) { // Buses source // TODO when adding the buses //busesSource = GeoJsonSource(BUSES_SOURCE_ID) //style.addSource(busesSource) /* // TODO when adding the buses // Buses layer val busesLayer = SymbolLayer(BUSES_LAYER_ID, BUSES_SOURCE_ID).apply { withProperties( PropertyFactory.iconImage("bus"), PropertyFactory.iconSize(1.0f), PropertyFactory.iconAllowOverlap(true), PropertyFactory.iconRotate(Expression.get("bearing")) ) } style.addLayer(busesLayer) */ - } + //} private fun showStopInBottomSheet(stop: Stop?){ if (stop==null) return - bottomLayout?.let { lay -> + bottomLayout?.let { //lay.findViewById(R.id.stopTitleTextView).text ="${stop.ID} - ${stop.stopDefaultName}" stopTitleTextView.text = stop.stopDefaultName stopNumberTextView.text = stop.ID val string_show = if (stop.numRoutesStopping==0) "" else if (stop.numRoutesStopping <= 1) requireContext().getString(R.string.line_fill, stop.routesThatStopHereToString()) else requireContext().getString(R.string.lines_fill, stop.routesThatStopHereToString()) linesPassingTextView.text = string_show } + //add stop marker + if (stop.latitude!=null && stop.longitude!=null) { + /*val marker = map?.addMarker( + MarkerOptions() + .position(LatLng(stop.latitude!!, stop.longitude!!)) // example coords + .icon( + //IconFactory.getInstance(requireContext()).fromBitmap( + getIconFromVectorDrawable(requireContext(), R.drawable.bus_stop_new_highlight) + //R.drawable.bus_stop_new_highlight) + //IconFactory.getInstance(requireContext()) + //.fromResource(R.drawable.bus_stop_new_highlight) + ) + .title(stop.stopDefaultName) + + ) + */ + stopActiveSymbol = symbolManager.create( + SymbolOptions() + .withLatLng(LatLng(stop.latitude!!, stop.longitude!!)) + .withIconImage(STOP_ACTIVE_IMG) + .withIconAnchor(ICON_ANCHOR_CENTER) + + ) + + } } override fun onStart() { super.onStart() mapView.onStart() } override fun onResume() { super.onResume() mapView.onResume() } override fun onPause() { super.onPause() mapView.onPause() } override fun onStop() { super.onStop() mapView.onStop() } override fun onLowMemory() { super.onLowMemory() mapView.onLowMemory() } override fun onDestroy() { super.onDestroy() mapView.onDestroy() } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) mapView.onSaveInstanceState(outState) } private fun observeViewModels() { // Observe stops stopsViewModel.stopsToShow.observe(viewLifecycleOwner) { stops -> - stopsShowing = stops - displayStops(stops) + stopsShowing = ArrayList(stops) + displayStops(stopsShowing) } } /** * Add the stops to the layers */ private fun displayStops(stops: List?) { if (stops.isNullOrEmpty()) return if (stops.size==lastStopsSizeShown){ Log.d(DEBUG_TAG, "Not updating, we have the same stop (can only increase!)") return } - val features = stops.mapNotNull { stop -> - stop.latitude?.let { lat -> - stop.longitude?.let { lon -> - Feature.fromGeometry( - Point.fromLngLat(lon, lat), + val features = ArrayList()//stops.mapNotNull { stop -> + //stop.latitude?.let { lat -> + // stop.longitude?.let { lon -> + for (s in stops){ + if (s.latitude!=null && s.longitude!=null) + features.add( + Feature.fromGeometry( + Point.fromLngLat(s.longitude!!, s.latitude!!), JsonObject().apply { - addProperty("id", stop.ID) - addProperty("name", stop.stopDefaultName) - addProperty("routes", stop.routesThatStopHereToString()) // Add routes array to JSON object + addProperty("id", s.ID) + addProperty("name", s.stopDefaultName) + addProperty("routes", s.routesThatStopHereToString()) // Add routes array to JSON object } ) - } - } + ) + + } Log.d(DEBUG_TAG,"Have put ${features.size} stops to display") + // if the layer is already started, substitute the stops inside, otherwise start it if (isStopsLayerStarted) { stopsSource.setGeoJson(FeatureCollection.fromFeatures(features)) lastStopsSizeShown = features.size } else - map?.let { startLayerStops(mapStyle, FeatureCollection.fromFeatures(features)) + map?.let { initStopsLayer(mapStyle, FeatureCollection.fromFeatures(features)) Log.d(DEBUG_TAG,"Started stops layer on map") lastStopsSizeShown = features.size } } + // Hide the bottom sheet and remove extra symbol + private fun hideStopBottomSheet(){ + if (stopActiveSymbol!=null){ + symbolManager.delete(stopActiveSymbol) + stopActiveSymbol = null + } + bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN + isBottomSheetShowing = false + } companion object { private const val STOPS_SOURCE_ID = "stops-source" private const val STOPS_LAYER_ID = "stops-layer" private const val STOPS_LAYER_SEL_ID ="stops-layer-selected" private const val BUSES_SOURCE_ID = "buses-source" private const val BUSES_LAYER_ID = "buses-layer" private const val STOP_IMAGE_ID ="bus-stop-icon" private const val DEFAULT_CENTER_LAT = 45.0708 private const val DEFAULT_CENTER_LON = 7.6858 private const val POSITION_FOUND_ZOOM = 16.5 private const val NO_POSITION_ZOOM = 17.1 private const val ACCESS_TOKEN="KxO8lF4U3kiO63m0c7lzqDCDrMUVg1OA2JVzRXxxmYSyjugr1xpe4W4Db5rFNvbQ" private const val MAPLIBRE_URL = "https://api.jawg.io/styles/" private const val DEBUG_TAG = "BusTO-MapLibreFrag" + private const val STOP_ACTIVE_IMG = "Stop-active" /** * Use this factory method to create a new instance of * this fragment using the provided parameters. * * @param param1 Parameter 1. * @param param2 Parameter 2. * @return A new instance of fragment MapLibreFragment. */ // TODO: Rename and change types and number of parameters @JvmStatic fun newInstance(param1: String, param2: String) = MapLibreFragment().apply { arguments = Bundle().apply { putString(ARG_PARAM1, param1) putString(ARG_PARAM2, param2) } } private fun makeStyleUrl(style: String = "jawg-streets") = "${MAPLIBRE_URL+ style}.json?access-token=${ACCESS_TOKEN}" private fun makeStyleMapBoxUrl(dark: Boolean) = if(dark) "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json" else //"https://basemaps.cartocdn.com/gl/positron-gl-style/style.json" "https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json" const val OPENFREEMAP_LIBERY = "https://tiles.openfreemap.org/styles/liberty" const val OPENFREEMAP_BRIGHT = "https://tiles.openfreemap.org/styles/bright" + + fun getIconFromVectorDrawable(context: Context, drawableId: Int): Icon { + val drawable = ContextCompat.getDrawable(context, drawableId) + requireNotNull(drawable) { "Drawable not found." } + + drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) + val bitmap = Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + drawable.draw(canvas) + + return IconFactory.getInstance(context).fromBitmap(bitmap) + } } } \ No newline at end of file diff --git a/app/src/main/res/drawable/bus_stop_new.xml b/app/src/main/res/drawable/bus_stop_new.xml index 61e535c..1fc8451 100644 --- a/app/src/main/res/drawable/bus_stop_new.xml +++ b/app/src/main/res/drawable/bus_stop_new.xml @@ -1,21 +1,21 @@ diff --git a/app/src/main/res/drawable/bus_stop_new_highlight.xml b/app/src/main/res/drawable/bus_stop_new_highlight.xml index c9b7f47..c4f081b 100644 --- a/app/src/main/res/drawable/bus_stop_new_highlight.xml +++ b/app/src/main/res/drawable/bus_stop_new_highlight.xml @@ -1,21 +1,21 @@ diff --git a/app/src/main/res/layout/fragment_map_libre.xml b/app/src/main/res/layout/fragment_map_libre.xml index bdd4b57..a9c12f3 100644 --- a/app/src/main/res/layout/fragment_map_libre.xml +++ b/app/src/main/res/layout/fragment_map_libre.xml @@ -1,135 +1,141 @@ + app:behavior_peekHeight="4dp" + android:clickable="true" + android:focusable="true"> - + android:layout_toStartOf="@id/directionsCardButton" + android:backgroundTint="?android:attr/colorPrimary" + android:foreground="?selectableItemBackground"> \ No newline at end of file