diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt index c8a2c91..47f591f 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt @@ -1,209 +1,812 @@ package it.reyboz.bustorino.fragments +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.content.Context import android.content.SharedPreferences +import android.content.res.ColorStateList +import android.graphics.Color +import android.location.Location import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.animation.LinearInterpolator +import android.widget.ImageView +import android.widget.RelativeLayout +import android.widget.TextView +import androidx.cardview.widget.CardView +import androidx.core.content.res.ResourcesCompat +import androidx.core.view.ViewCompat +import androidx.lifecycle.lifecycleScope +import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.gson.JsonObject +import it.reyboz.bustorino.R +import it.reyboz.bustorino.backend.LivePositionTripPattern import it.reyboz.bustorino.backend.Stop +import it.reyboz.bustorino.backend.gtfs.LivePositionUpdate import it.reyboz.bustorino.data.PreferencesHolder +import it.reyboz.bustorino.data.gtfs.TripAndPatternWithStops +import it.reyboz.bustorino.map.MapLibreUtils +import it.reyboz.bustorino.util.ViewUtils +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.maplibre.android.MapLibre import org.maplibre.android.camera.CameraPosition import org.maplibre.android.geometry.LatLng +import org.maplibre.android.location.LocationComponent +import org.maplibre.android.location.LocationComponentOptions 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.expressions.Expression +import org.maplibre.android.style.layers.Property.ICON_ANCHOR_CENTER +import org.maplibre.android.style.layers.Property.ICON_ROTATION_ALIGNMENT_MAP +import org.maplibre.android.style.layers.Property.TEXT_ANCHOR_CENTER +import org.maplibre.android.style.layers.Property.TEXT_ROTATION_ALIGNMENT_VIEWPORT +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 abstract class GeneralMapLibreFragment: ScreenBaseFragment(), OnMapReadyCallback { protected var map: MapLibreMap? = null protected var shownStopInBottomSheet : Stop? = null protected var savedMapStateOnPause : Bundle? = null + + protected var fragmentListener: CommonFragmentListener? = null + // Declare a variable for MapView protected lateinit var mapView: MapView protected lateinit var mapStyle: Style protected lateinit var stopsSource: GeoJsonSource protected lateinit var busesSource: GeoJsonSource protected lateinit var selectedStopSource: GeoJsonSource + protected lateinit var selectedBusSource: GeoJsonSource //= GeoJsonSource(SEL_BUS_SOURCE) protected lateinit var sharedPreferences: SharedPreferences + protected lateinit var bottomSheetBehavior: BottomSheetBehavior + private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener(){ pref, key -> /*when(key){ SettingsFragment.LIBREMAP_STYLE_PREF_KEY -> reloadMap() } */ if(key == SettingsFragment.LIBREMAP_STYLE_PREF_KEY){ Log.d(DEBUG_TAG,"ASKING RELOAD OF MAP") reloadMap() } } + //Bottom sheet behavior in GeneralMapLibreFragment + protected var bottomLayout: RelativeLayout? = null + protected lateinit var stopTitleTextView: TextView + protected lateinit var stopNumberTextView: TextView + protected lateinit var linesPassingTextView: TextView + protected lateinit var arrivalsCard: CardView + protected lateinit var directionsCard: CardView + protected lateinit var bottomrightImage: ImageView + + protected lateinit var locationComponent: LocationComponent + protected var lastLocation : Location? = null + private var lastMapStyle ="" + //BUS POSITIONS + protected val updatesByVehDict = HashMap(5) + protected val animatorsByVeh = HashMap() + protected var vehShowing = "" + protected var lastUpdateTime:Long = -2 + + private val lifecycleOwnerLiveData = getViewLifecycleOwnerLiveData() + + + //extra items to use the LibreMap + protected lateinit var symbolManager : SymbolManager + protected var stopActiveSymbol: Symbol? = null + protected var stopsLayerStarted = false + + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) //sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) lastMapStyle = PreferencesHolder.getMapLibreStyleFile(requireContext()) //init map MapLibre.getInstance(requireContext()) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { lastMapStyle = PreferencesHolder.getMapLibreStyleFile(requireContext()) Log.d(DEBUG_TAG, "onCreateView lastMapStyle: $lastMapStyle") return super.onCreateView(inflater, container, savedInstanceState) } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + //init bottom sheet + val bottomSheet = view.findViewById(R.id.bottom_sheet) + bottomLayout = bottomSheet + stopTitleTextView = view.findViewById(R.id.stopTitleTextView) + stopNumberTextView = view.findViewById(R.id.stopNumberTextView) + linesPassingTextView = view.findViewById(R.id.linesPassingTextView) + arrivalsCard = view.findViewById(R.id.arrivalsCardButton) + directionsCard = view.findViewById(R.id.directionsCardButton) + bottomrightImage = view.findViewById(R.id.rightmostImageView) + bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet) + } + override fun onResume() { mapView.onResume() super.onResume() val newMapStyle = PreferencesHolder.getMapLibreStyleFile(requireContext()) Log.d(DEBUG_TAG, "onResume newMapStyle: $newMapStyle, lastMapStyle: $lastMapStyle") if(newMapStyle!=lastMapStyle){ reloadMap() } } @Deprecated("Deprecated in Java") override fun onLowMemory() { mapView.onLowMemory() super.onLowMemory() } override fun onStart() { super.onStart() mapView.onStart() } override fun onDestroy() { mapView.onDestroy() Log.d(DEBUG_TAG, "Destroyed mapView Fragment!!") super.onDestroy() } + override fun onDestroyView() { + bottomLayout = null + super.onDestroyView() + } protected fun reloadMap(){ /*map?.let { Log.d("GeneralMapFragment", "RELOADING MAP") //save map state savedMapStateOnPause = saveMapStateInBundle() onMapDestroy() //Destroy and recreate MAP mapView.onDestroy() mapView.onCreate(null) mapView.getMapAsync(this) } */ //TODO figure out how to switch map safely } - abstract fun openStopInBottomSheet(stop: Stop) - //For extra stuff to do when the map is destroyed abstract fun onMapDestroy() + override fun onAttach(context: Context) { + super.onAttach(context) + if(context is CommonFragmentListener){ + fragmentListener = context + } else throw RuntimeException("$context must implement CommonFragmentListener") + + } protected fun restoreMapStateFromBundle(bundle: Bundle): Boolean{ val nullDouble = -10_000.0 var boundsRestored =false val latCenter = bundle.getDouble("center_map_lat", nullDouble) val lonCenter = bundle.getDouble("center_map_lon",nullDouble) val zoom = bundle.getDouble("map_zoom", nullDouble) val bearing = bundle.getDouble("map_bearing", nullDouble) val tilt = bundle.getDouble("map_tilt", nullDouble) if(lonCenter!=nullDouble &&latCenter!=nullDouble) map?.let { val center = LatLng(latCenter, lonCenter) val newPos = CameraPosition.Builder().target(center) if(zoom>0) newPos.zoom(zoom) if(bearing!=nullDouble) newPos.bearing(bearing) if(tilt != nullDouble) newPos.tilt(tilt) it.cameraPosition=newPos.build() Log.d(DEBUG_TAG, "Restored map state from Bundle, center: $center, zoom: $zoom, bearing $bearing, tilt $tilt") boundsRestored =true } else{ Log.d(DEBUG_TAG, "Not restoring map state, center: $latCenter,$lonCenter; zoom: $zoom, bearing: $bearing, tilt $tilt") } val mStop = bundle.getBundle("shown_stop")?.let { Stop.fromBundle(it) } mStop?.let { openStopInBottomSheet(it) } return boundsRestored } protected fun saveMapStateBeforePause(bundle: Bundle){ map?.let { val newBbox = it.projection.visibleRegion.latLngBounds val cp = it.cameraPosition bundle.putDouble("center_map_lat", newBbox.center.latitude) bundle.putDouble("center_map_lon", newBbox.center.longitude) it.cameraPosition.zoom.let { z-> bundle.putDouble("map_zoom",z) } bundle.putDouble("map_bearing",cp.bearing) bundle.putDouble("map_tilt", cp.tilt) val locationComponent = it.locationComponent bundle.putBoolean(KEY_LOCATION_ENABLED,locationComponent.isLocationComponentEnabled) bundle.putParcelable("last_location", locationComponent.lastKnownLocation) } shownStopInBottomSheet?.let { bundle.putBundle("shown_stop", it.toBundle()) } } protected fun saveMapStateInBundle(): Bundle { val b = Bundle() saveMapStateBeforePause(b) return b } protected fun stopToGeoJsonFeature(s: Stop): Feature{ return Feature.fromGeometry( Point.fromLngLat(s.longitude!!, s.latitude!!), JsonObject().apply { addProperty("id", s.ID) addProperty("name", s.stopDefaultName) //addProperty("routes", s.routesThatStopHereToString()) // Add routes array to JSON object } ) } protected fun isPointInsideVisibleRegion(p: LatLng, other: Boolean): Boolean{ val bounds = map?.projection?.visibleRegion?.latLngBounds var inside = other bounds?.let { inside = it.contains(p) } return inside } protected fun isPointInsideVisibleRegion(lat: Double, lon: Double, other: Boolean): Boolean{ val p = LatLng(lat, lon) return isPointInsideVisibleRegion(p, other) } + protected fun removeVehiclesData(vehs: List){ + for(v in vehs){ + if (updatesByVehDict.contains(v)) { + updatesByVehDict.remove(v) + if (animatorsByVeh.contains(v)){ + animatorsByVeh[v]?.cancel() + animatorsByVeh.remove(v) + } + } + if (vehShowing==v){ + hideStopBottomSheet() + } + } + } + + // Hide the bottom sheet and remove extra symbol + protected fun hideStopBottomSheet(){ + if (stopActiveSymbol!=null){ + symbolManager.delete(stopActiveSymbol) + stopActiveSymbol = null + } + if(!showOpenStopWithSymbolLayer()){ + selectedStopSource.setGeoJson(FeatureCollection.fromFeatures(ArrayList())) + } + bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN + //isBottomSheetShowing = false + + //reset states + shownStopInBottomSheet = null + if (vehShowing!=""){ + //we are hiding a vehicle + vehShowing = "" + updatePositionsIcons(true) + } + + } + + protected fun initSymbolManager(mapReady: MapLibreMap , style: Style){ + symbolManager = SymbolManager(mapView,mapReady,style) + symbolManager.iconAllowOverlap = true + symbolManager.textAllowOverlap = false + + symbolManager.addClickListener{ _ -> + if (stopActiveSymbol!=null){ + hideStopBottomSheet() + + return@addClickListener true + } else + return@addClickListener false + } + + } + + /** + * Initialize the map location, but do not enable the component + */ + @SuppressLint("MissingPermission") + protected fun initMapUserLocation(style: Style, map: MapLibreMap, context: Context){ + locationComponent = map.locationComponent + val locationComponentOptions = + LocationComponentOptions.builder(context) + .pulseEnabled(false) + .build() + val locationComponentActivationOptions = + MapLibreUtils.buildLocationComponentActivationOptions(style, locationComponentOptions, context) + locationComponent.activateLocationComponent(locationComponentActivationOptions) + locationComponent.isLocationComponentEnabled = false + + lastLocation?.let { + if (it.accuracy < 200) + locationComponent.forceLocationUpdate(it) + } + } + + + /** + * Update function for the bus positions + * Takes the processed updates and saves them accordingly + * Unified version that works with both fragments + * + * @param incomingData Map of updates with optional trip and pattern information + * @param checkCoordinateValidity If true, validates that coordinates are positive (default: false) + * @param hasVehicleTracking If true, checks if vehShowing is updated and calls callback (default: true) + * @param trackVehicleCallback Optional callback to show vehicle details when vehShowing is updated + */ + protected fun updateBusPositionsInMap( + incomingData: HashMap>, + hasVehicleTracking: Boolean = false, + trackVehicleCallback: ((String) -> Unit)? = null + ) { + val vehsNew = HashSet(incomingData.values.map { up -> up.first.vehicle }) + val vehsOld = HashSet(updatesByVehDict.keys) + + Log.d(DEBUG_TAG, "In fragment, have ${incomingData.size} updates to show") + + var countUpds = 0 + var createdVehs = 0 + + for (upsWithTrp in incomingData.values) { + val newPos = upsWithTrp.first + val patternStops = upsWithTrp.second + val vehID = newPos.vehicle + + // Validate coordinates + if (!vehsOld.contains(vehID)) { + if (newPos.latitude <= 0 || newPos.longitude <= 0) { + Log.w(DEBUG_TAG, "Update ignored for veh $vehID on line ${newPos.routeID}, lat: ${newPos.latitude}, lon ${newPos.longitude}") + continue + } + } + + if (vehsOld.contains(vehID)) { + // Changing the location of an existing bus + val oldPosData = updatesByVehDict[vehID]!! + val oldPos = oldPosData.posUpdate + val oldPattern = oldPosData.pattern + + var avoidShowingUpdateBecauseIsImpossible = false + + // Check for impossible route changes + if (oldPos.routeID != newPos.routeID) { + val dist = LatLng(oldPos.latitude, oldPos.longitude).distanceTo( + LatLng(newPos.latitude, newPos.longitude) + ) + val speed = dist * 3.6 / (newPos.timestamp - oldPos.timestamp) // km/h + Log.w(DEBUG_TAG, "Vehicle $vehID changed route from ${oldPos.routeID} to ${newPos.routeID}, distance: $dist, speed: $speed") + if (speed > 120 || speed < 0) { + avoidShowingUpdateBecauseIsImpossible = true + } + } + + if (avoidShowingUpdateBecauseIsImpossible) { + Log.w(DEBUG_TAG, "Update for vehicle $vehID skipped") + continue + } + + // Check if position actually changed + val samePosition = (oldPos.latitude == newPos.latitude) && + (oldPos.longitude == newPos.longitude) + + val setPattern = (oldPattern == null) && (patternStops != null) + + // Copy old bearing if new one is missing + if (newPos.bearing == null && oldPos.bearing != null) { + newPos.bearing = oldPos.bearing + } + + if (!samePosition || setPattern) { + val newOrOldPosInBounds = isPointInsideVisibleRegion( + newPos.latitude, newPos.longitude, true + ) || isPointInsideVisibleRegion(oldPos.latitude, oldPos.longitude, true) + + if (newOrOldPosInBounds) { + // Update pattern data if available + patternStops?.let { + updatesByVehDict[vehID]!!.pattern = it.pattern + } + // Animate the position change + animateNewPositionMove(newPos) + } else { + // Update position without animation + updatesByVehDict[vehID] = LivePositionTripPattern( + newPos, + patternStops?.pattern + ) + } + } + countUpds++ + } else { + // New vehicle - create entry + updatesByVehDict[vehID] = LivePositionTripPattern( + newPos, + patternStops?.pattern + ) + createdVehs++ + } + + // Update vehicle details if this is the shown/tracked vehicle + if (hasVehicleTracking && vehShowing.isNotEmpty() && vehID == vehShowing) { + trackVehicleCallback?.invoke(vehID) + } + } + + // Remove old positions + Log.d(DEBUG_TAG, "Updated $countUpds vehicles, created $createdVehs vehicles") + vehsOld.removeAll(vehsNew) + + // Clean up stale vehicles (not updated for 2 minutes) + val currentTimeStamp = System.currentTimeMillis() / 1000 + for (vehID in vehsOld) { + val posData = updatesByVehDict[vehID]!! + if (currentTimeStamp - posData.posUpdate.timestamp > 2 * 60) { + // Remove the bus + updatesByVehDict.remove(vehID) + // Cancel and remove animator if exists + animatorsByVeh[vehID]?.cancel() + animatorsByVeh.remove(vehID) + } + } + + // Update UI + updatePositionsIcons(false) + } + + /** + * Update the bus positions displayed on the map, from the existing data + * + * @param forced If true, forces immediate update ignoring the 60ms throttle + */ + protected fun updatePositionsIcons(forced: Boolean) { + // Avoid frequent updates - throttle to max once per 60ms + val currentTime = System.currentTimeMillis() + if (!forced && currentTime - lastUpdateTime < 60) { + // Schedule delayed update + if(lifecycleOwnerLiveData.value != null) + viewLifecycleOwner.lifecycleScope.launch { + delay(200) + updatePositionsIcons(forced) + } + return + } + + val busFeatures = ArrayList() + val selectedBusFeatures = ArrayList() + + for (dat in updatesByVehDict.values) { + val pos = dat.posUpdate + val point = Point.fromLngLat(pos.longitude, pos.latitude) + + val newFeature = Feature.fromGeometry( + point, + JsonObject().apply { + addProperty("veh", pos.vehicle) + addProperty("trip", pos.tripID) + addProperty("bearing", pos.bearing ?: 0.0f) + addProperty("line", pos.routeID.substringBeforeLast('U')) + } + ) + + // Separate selected vehicle from others + if (vehShowing.isNotEmpty() && vehShowing == dat.posUpdate.vehicle) { + selectedBusFeatures.add(newFeature) + } else { + busFeatures.add(newFeature) + } + } + + busesSource.setGeoJson(FeatureCollection.fromFeatures(busFeatures)) + selectedBusSource.setGeoJson(FeatureCollection.fromFeatures(selectedBusFeatures)) + + lastUpdateTime = System.currentTimeMillis() + } + + /** + * Animates the transition of a vehicle from its current position to a new position + * This is the tricky part - we need to set the new positions with the data and redraw them all + * + * @param positionUpdate The new position update to animate to + */ + protected fun animateNewPositionMove(positionUpdate: LivePositionUpdate) { + val vehID = positionUpdate.vehicle + + // Check if vehicle exists in our tracking dictionary + if (vehID !in updatesByVehDict.keys) { + return + } + + val currentUpdate = updatesByVehDict[vehID] ?: run { + Log.e(DEBUG_TAG, "Have to run animation for veh $vehID but not in the dict") + return + } + + // Cancel any current animation for this vehicle + animatorsByVeh[vehID]?.cancel() + + val posUp = currentUpdate.posUpdate + val currentPos = LatLng(posUp.latitude, posUp.longitude) + val newPos = LatLng(positionUpdate.latitude, positionUpdate.longitude) + + // Create animator for smooth transition + val valueAnimator = ValueAnimator.ofObject( + MapLibreUtils.LatLngEvaluator(), + currentPos, + newPos + ) + + valueAnimator.addUpdateListener { animation -> + val latLng = animation.animatedValue as LatLng + + // Update position during animation + updatesByVehDict[vehID]?.let { update -> + update.posUpdate.latitude = latLng.latitude + update.posUpdate.longitude = latLng.longitude + updatePositionsIcons(false) + } ?: run { + Log.w(DEBUG_TAG, "The bus position to animate has been removed, but the animator is still running!") + } + } + + // Set the new position as current but keep old coordinates for animation start + positionUpdate.latitude = posUp.latitude + positionUpdate.longitude = posUp.longitude + updatesByVehDict[vehID]!!.posUpdate = positionUpdate + + // Configure and start animation + valueAnimator.duration = 300 + valueAnimator.interpolator = LinearInterpolator() + valueAnimator.start() + + // Store animator for potential cancellation + animatorsByVeh[vehID] = valueAnimator + } + + /// STOP OPENING + abstract fun showOpenStopWithSymbolLayer(): Boolean + /** + * Update the bottom sheet with the stop information + */ + protected fun openStopInBottomSheet(stop: Stop){ + bottomLayout?.let { + + //lay.findViewById(R.id.stopTitleTextView).text ="${stop.ID} - ${stop.stopDefaultName}" + val stopName = stop.stopUserName ?: stop.stopDefaultName + stopTitleTextView.text = stopName//stop.stopDefaultName + stopNumberTextView.text = getString(R.string.stop_fill,stop.ID) + stopTitleTextView.visibility = View.VISIBLE + + val string_show = if (stop.numRoutesStopping==0) "" + else requireContext().getString(R.string.lines_fill, stop.routesThatStopHereToString()) + linesPassingTextView.text = string_show + + //SET ON CLICK LISTENER + arrivalsCard.setOnClickListener{ + fragmentListener?.requestArrivalsForStopID(stop.ID) + } + + arrivalsCard.visibility = View.VISIBLE + directionsCard.visibility = View.VISIBLE + + directionsCard.setOnClickListener { + ViewUtils.openStopInOutsideApp(stop, context) + } + context?.let { + val colorIcon = ViewUtils.getColorFromTheme(it, android.R.attr.colorAccent)//ResourcesCompat.getColor(resources,R.attr.colorAccent,activity?.theme) + ViewCompat.setBackgroundTintList(directionsCard, ColorStateList.valueOf(colorIcon)) + } + + bottomrightImage.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.navigation_right, activity?.theme)) + + } + //add stop marker + if (stop.latitude!=null && stop.longitude!=null) { + Log.d(DEBUG_TAG, "Showing stop: ${stop.ID}") + + if (showOpenStopWithSymbolLayer()) { + stopActiveSymbol = symbolManager.create( + SymbolOptions() + .withLatLng(LatLng(stop.latitude!!, stop.longitude!!)) + .withIconImage(STOP_ACTIVE_IMG) + .withIconAnchor(ICON_ANCHOR_CENTER) + + ) + } else{ + val list = ArrayList() + list.add(stopToGeoJsonFeature(stop)) + selectedStopSource.setGeoJson( + FeatureCollection.fromFeatures(list) + ) + } + + } + Log.d(DEBUG_TAG, "Shown stop $stop in bottom sheet") + shownStopInBottomSheet = stop + + bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED + } + + + + protected fun stopAnimations(){ + for(anim in animatorsByVeh.values){ + anim.cancel() + } + } + + protected fun addImagesStyle(style: Style){ + 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)!!) + style.addImage("ball",ResourcesCompat.getDrawable(resources, R.drawable.ball, activity?.theme)!!) + style.addImage(BUS_IMAGE_ID,ResourcesCompat.getDrawable(resources, R.drawable.map_bus_position_icon, activity?.theme)!!) + style.addImage(BUS_SEL_IMAGE_ID, ResourcesCompat.getDrawable(resources, R.drawable.map_bus_position_icon_sel, activity?.theme)!!) + val polyIconArrow = ResourcesCompat.getDrawable(resources, R.drawable.arrow_up_box_fill, activity?.theme)!! + style.addImage(POLY_ARROW, polyIconArrow) + + } + + protected fun initStopsLayer(style: Style, stopsFeatures: FeatureCollection?){ + initStopsLayer(style, stopsFeatures,"symbol-transit-airfield" ) + } + + protected fun initStopsLayer(style: Style, stopsFeatures: FeatureCollection?, stopsLayerAbove: String){ + + + stopsSource = GeoJsonSource(STOPS_SOURCE_ID,stopsFeatures ?: FeatureCollection.fromFeatures(ArrayList())) + style.addSource(stopsSource) + + + // Stops layer + val stopsLayer = SymbolLayer(STOPS_LAYER_ID, STOPS_SOURCE_ID) + stopsLayer.withProperties( + PropertyFactory.iconImage(STOP_IMAGE_ID), + PropertyFactory.iconAnchor(ICON_ANCHOR_CENTER), + PropertyFactory.iconAllowOverlap(true), + PropertyFactory.iconIgnorePlacement(true) + ) + + style.addLayerAbove(stopsLayer, stopsLayerAbove ) //"label_country_1") this with OSM Bright + + + selectedStopSource = GeoJsonSource(SEL_STOP_SOURCE, FeatureCollection.fromFeatures(ArrayList())) + style.addSource(selectedStopSource) + + val selStopLayer = SymbolLayer(SEL_STOP_LAYER, SEL_STOP_SOURCE) + selStopLayer.withProperties( + PropertyFactory.iconImage(STOP_ACTIVE_IMG), + PropertyFactory.iconAllowOverlap(true), + PropertyFactory.iconIgnorePlacement(true), + PropertyFactory.iconAnchor(ICON_ANCHOR_CENTER), + + ) + style.addLayerAbove(selStopLayer, STOPS_LAYER_ID) + + stopsLayerStarted = true + } + + /** + * Setup the Map Layers + */ + protected fun setupBusLayer(style: Style, withLabels: Boolean =false, busIconsScale: Float = 1.0f) { + // Buses source + busesSource = GeoJsonSource(BUSES_SOURCE_ID) + style.addSource(busesSource) + //style.addImage("bus_symbol",ResourcesCompat.getDrawable(resources, R.drawable.map_bus_position_icon, activity?.theme)!!) + + selectedBusSource = GeoJsonSource(SEL_BUS_SOURCE) + style.addSource(selectedBusSource) + + // Buses layer + val busesLayer = SymbolLayer(BUSES_LAYER_ID, BUSES_SOURCE_ID).apply { + withProperties( + PropertyFactory.iconImage(BUS_IMAGE_ID), + PropertyFactory.iconSize(busIconsScale), + PropertyFactory.iconAllowOverlap(true), + PropertyFactory.iconIgnorePlacement(true), + PropertyFactory.iconRotate(Expression.get("bearing")), + PropertyFactory.iconRotationAlignment(ICON_ROTATION_ALIGNMENT_MAP) + + ) + if (withLabels){ + withProperties(PropertyFactory.textAnchor(TEXT_ANCHOR_CENTER), + PropertyFactory.textAllowOverlap(true), + PropertyFactory.textField(Expression.get("line")), + PropertyFactory.textColor(Color.WHITE), + PropertyFactory.textRotationAlignment(TEXT_ROTATION_ALIGNMENT_VIEWPORT), + PropertyFactory.textSize(12f), + PropertyFactory.textFont(arrayOf("noto_sans_regular"))) + } + } + style.addLayerAbove(busesLayer, STOPS_LAYER_ID) + + val selectedBusLayer = SymbolLayer(SEL_BUS_LAYER, SEL_BUS_SOURCE).withProperties( + PropertyFactory.iconImage(BUS_SEL_IMAGE_ID), + PropertyFactory.iconSize(busIconsScale), + PropertyFactory.iconAllowOverlap(true), + PropertyFactory.iconIgnorePlacement(true), + PropertyFactory.iconRotate(Expression.get("bearing")), + PropertyFactory.iconRotationAlignment(ICON_ROTATION_ALIGNMENT_MAP) + + ) + style.addLayerAbove(selectedBusLayer, BUSES_LAYER_ID) + + } + + protected fun isBottomSheetShowing(): Boolean { + return bottomSheetBehavior.state == BottomSheetBehavior.STATE_EXPANDED + } + companion object{ private const val DEBUG_TAG="GeneralMapLibreFragment" const val BUSES_SOURCE_ID = "buses-source" const val BUSES_LAYER_ID = "buses-layer" const val SEL_STOP_SOURCE="selected-stop-source" const val SEL_STOP_LAYER = "selected-stop-layer" + const val SEL_BUS_SOURCE = "sel_bus_source" + const val SEL_BUS_LAYER = "sel_bus_layer" + const val KEY_LOCATION_ENABLED="location_enabled" + + + protected const val STOPS_SOURCE_ID = "stops-source" + protected const val STOPS_LAYER_ID = "stops-layer" + + protected const val STOP_IMAGE_ID = "stop-img" + protected const val STOP_ACTIVE_IMG = "stop_active_img" + protected const val BUS_IMAGE_ID = "bus_symbol" + protected const val BUS_SEL_IMAGE_ID = "sel_bus_symbol" + + protected const val POLYLINE_LAYER = "polyline-layer" + protected const val POLYLINE_SOURCE = "polyline-source" + + protected const val POLY_ARROWS_LAYER = "arrows-layer" + protected const val POLY_ARROWS_SOURCE = "arrows-source" + protected const val POLY_ARROW ="poly-arrow-img" + } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt index cfe9627..3d72a86 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt @@ -1,1615 +1,1146 @@ /* BusTO - Fragments components Copyright (C) 2023 Fabio Mazza This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package it.reyboz.bustorino.fragments import android.Manifest import android.animation.ObjectAnimator -import android.animation.ValueAnimator import android.annotation.SuppressLint import android.content.Context import android.content.SharedPreferences import android.content.res.ColorStateList -import android.location.Location import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.animation.LinearInterpolator import android.widget.* import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.content.res.AppCompatResources -import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat import androidx.core.view.ViewCompat import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.gson.JsonObject import it.reyboz.bustorino.R import it.reyboz.bustorino.adapters.NameCapitalize import it.reyboz.bustorino.adapters.StopAdapterListener import it.reyboz.bustorino.adapters.StopRecyclerAdapter import it.reyboz.bustorino.backend.FiveTNormalizer -import it.reyboz.bustorino.backend.LivePositionTripPattern import it.reyboz.bustorino.backend.Stop import it.reyboz.bustorino.backend.gtfs.GtfsUtils -import it.reyboz.bustorino.backend.gtfs.LivePositionUpdate import it.reyboz.bustorino.backend.gtfs.PolylineParser import it.reyboz.bustorino.backend.utils import it.reyboz.bustorino.data.MatoTripsDownloadWorker import it.reyboz.bustorino.data.PreferencesHolder import it.reyboz.bustorino.data.gtfs.MatoPatternWithStops -import it.reyboz.bustorino.data.gtfs.TripAndPatternWithStops import it.reyboz.bustorino.map.* import it.reyboz.bustorino.middleware.LocationUtils import it.reyboz.bustorino.util.Permissions -import it.reyboz.bustorino.util.ViewUtils import it.reyboz.bustorino.viewmodels.LinesViewModel import it.reyboz.bustorino.viewmodels.LivePositionsViewModel import kotlinx.coroutines.Runnable -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import org.maplibre.android.camera.CameraPosition import org.maplibre.android.camera.CameraUpdateFactory import org.maplibre.android.geometry.LatLng import org.maplibre.android.geometry.LatLngBounds -import org.maplibre.android.location.LocationComponent -import org.maplibre.android.location.LocationComponentOptions import org.maplibre.android.maps.MapLibreMap 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.expressions.Expression import org.maplibre.android.style.layers.LineLayer import org.maplibre.android.style.layers.Property -import org.maplibre.android.style.layers.Property.ICON_ANCHOR_CENTER import org.maplibre.android.style.layers.Property.ICON_ROTATION_ALIGNMENT_MAP 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.LineString import org.maplibre.geojson.Point import java.util.concurrent.atomic.AtomicBoolean class LinesDetailFragment() : GeneralMapLibreFragment() { private var lineID = "" private lateinit var patternsSpinner: Spinner private var patternsAdapter: ArrayAdapter? = null - //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 lateinit var bottomrightImage: ImageView //private var isBottomSheetShowing = false private var shouldMapLocationBeReactivated = true private var toRunWhenMapReady : Runnable? = null private var mapInitialized = AtomicBoolean(false) //private var patternsSpinnerState: Parcelable? = null private lateinit var currentPatterns: List //private lateinit var map: MapView private var patternShown: MatoPatternWithStops? = null private val viewModel: LinesViewModel by viewModels() private val mapViewModel: MapViewModel by viewModels() private var firstInit = true private var pausedFragment = false private lateinit var switchButton: ImageButton private var favoritesButton: ImageButton? = null private var locationIcon: ImageButton? = null private var isLineInFavorite = false private var appContext: Context? = null private var isLocationPermissionOK = false private val lineSharedPrefMonitor = SharedPreferences.OnSharedPreferenceChangeListener { pref, keychanged -> if(keychanged!=PreferencesHolder.PREF_FAVORITE_LINES || lineID.isEmpty()) return@OnSharedPreferenceChangeListener val newFavorites = pref.getStringSet(PreferencesHolder.PREF_FAVORITE_LINES, HashSet()) newFavorites?.let {favorites-> isLineInFavorite = favorites.contains(lineID) //if the button has been intialized, change the icon accordingly favoritesButton?.let { button-> //avoid crashes if fragment not attached if(context==null) return@let if(isLineInFavorite) { button.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_star_filled, null)) appContext?.let { Toast.makeText(it,R.string.favorites_line_add,Toast.LENGTH_SHORT).show()} } else { button.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_star_outline, null)) appContext?.let {Toast.makeText(it,R.string.favorites_line_remove,Toast.LENGTH_SHORT).show()} } } } } private lateinit var stopsRecyclerView: RecyclerView private lateinit var descripTextView: TextView private var stopIDFromToShow: String? = null //adapter for recyclerView private val stopAdapterListener= object : StopAdapterListener { override fun onTappedStop(stop: Stop?) { if(viewModel.shouldShowMessage) { Toast.makeText(context, R.string.long_press_stop_4_options, Toast.LENGTH_SHORT).show() viewModel.shouldShowMessage=false } stop?.let { - fragmentListener.requestArrivalsForStopID(it.ID) + fragmentListener?.requestArrivalsForStopID(it.ID) } if(stop == null){ Log.e(DEBUG_TAG,"Passed wrong stop") } if(fragmentListener == null){ Log.e(DEBUG_TAG, "Fragment listener is null") } } override fun onLongPressOnStop(stop: Stop?): Boolean { TODO("Not yet implemented") } } private val patternsSorter = Comparator{ p1: MatoPatternWithStops, p2: MatoPatternWithStops -> if(p1.pattern.directionId != p2.pattern.directionId) return@Comparator p1.pattern.directionId - p2.pattern.directionId else return@Comparator -1*(p1.stopsIndices.size - p2.stopsIndices.size) } //map data //style and sources are in GeneralMapLibreFragment - private lateinit var locationComponent: LocationComponent private lateinit var polylineSource: GeoJsonSource private lateinit var polyArrowSource: GeoJsonSource - private lateinit var selectedBusSource: GeoJsonSource private var savedCameraPosition: CameraPosition? = null - private var vehShowing = "" - private var stopsLayerStarted = false private var lastStopsSizeShown = 0 - private var lastUpdateTime:Long = -2 //BUS POSITIONS - private val updatesByVehDict = HashMap(5) - private val animatorsByVeh = HashMap() - private var lastLocation : Location? = null private var enablingPositionFromClick = false private var polyline: LineString? = null private val showUserPositionRequestLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions(), ActivityResultCallback { result -> if (result == null) { Log.w(DEBUG_TAG, "Got asked permission but request is null, doing nothing?") } else if (java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_COARSE_LOCATION] && java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_FINE_LOCATION]) { // We can use the position, restart location overlay if (context == null || requireContext().getSystemService(Context.LOCATION_SERVICE) == null) return@ActivityResultCallback ///@registerForActivityResult setMapUserLocationEnabled(true, true, enablingPositionFromClick) } else Log.w(DEBUG_TAG, "No location permission") }) //private var stopPosList = ArrayList() //fragment actions - private lateinit var fragmentListener: CommonFragmentListener private var showOnTopOfLine = false private var recyclerInitDone = false private var useMQTTPositions = true //position of live markers private val tripMarkersAnimators = HashMap() private val liveBusViewModel: LivePositionsViewModel by activityViewModels() //extra items to use the LibreMap - private lateinit var symbolManager : SymbolManager - private var stopActiveSymbol: Symbol? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val args = requireArguments() lineID = args.getString(LINEID_KEY,"") stopIDFromToShow = args.getString(STOPID_FROM_KEY) } @SuppressLint("SetTextI18n") override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { //reset statuses //isBottomSheetShowing = false //stopsLayerStarted = false lastStopsSizeShown = 0 mapInitialized.set(false) val rootView = inflater.inflate(R.layout.fragment_lines_detail, container, false) //lineID = requireArguments().getString(LINEID_KEY, "") arguments?.let { lineID = it.getString(LINEID_KEY, "") } switchButton = rootView.findViewById(R.id.switchImageButton) locationIcon = rootView.findViewById(R.id.locationEnableIcon) favoritesButton = rootView.findViewById(R.id.favoritesButton) stopsRecyclerView = rootView.findViewById(R.id.patternStopsRecyclerView) descripTextView = rootView.findViewById(R.id.lineDescripTextView) descripTextView.visibility = View.INVISIBLE //map stuff mapView = rootView.findViewById(R.id.lineMap) mapView.getMapAsync(this) - //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) - bottomrightImage = bottomSheet.findViewById(R.id.rightmostImageView) - bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet) - // Setup close button rootView.findViewById(R.id.btnClose).setOnClickListener { hideStopBottomSheet() } val titleTextView = rootView.findViewById(R.id.titleTextView) titleTextView.text = getString(R.string.line)+" "+FiveTNormalizer.fixShortNameForDisplay( GtfsUtils.getLineNameFromGtfsID(lineID), true) favoritesButton?.isClickable = true favoritesButton?.setOnClickListener { if(lineID.isNotEmpty()) PreferencesHolder.addOrRemoveLineToFavorites(requireContext(),lineID,!isLineInFavorite) } val preferences = PreferencesHolder.getMainSharedPreferences(requireContext()) val favorites = preferences.getStringSet(PreferencesHolder.PREF_FAVORITE_LINES, HashSet()) if(favorites!=null && favorites.contains(lineID)){ favoritesButton?.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_star_filled, null)) isLineInFavorite = true } appContext = requireContext().applicationContext preferences.registerOnSharedPreferenceChangeListener(lineSharedPrefMonitor) patternsSpinner = rootView.findViewById(R.id.patternsSpinner) patternsAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, ArrayList()) patternsSpinner.adapter = patternsAdapter initializeRecyclerView() switchButton.setOnClickListener{ if(mapView.visibility == View.VISIBLE){ hideMapAndShowStopList() } else{ hideStopListAndShowMap() } } locationIcon?.let {view -> if(!LocationUtils.isLocationEnabled(requireContext()) || !Permissions.anyLocationPermissionsGranted(requireContext())) setLocationIconEnabled(false) //set click Listener view.setOnClickListener(this::onPositionIconButtonClick) } //set //INITIALIZE VIEW MODELS viewModel.setRouteIDQuery(lineID) liveBusViewModel.setGtfsLineToFilterPos(lineID, null) val keySourcePositions = getString(R.string.pref_positions_source) useMQTTPositions = PreferenceManager.getDefaultSharedPreferences(requireContext()) .getString(keySourcePositions, "mqtt").contentEquals("mqtt") viewModel.patternsWithStopsByRouteLiveData.observe(viewLifecycleOwner){ patterns -> savePatternsToShow(patterns) } /* */ viewModel.stopsForPatternLiveData.observe(viewLifecycleOwner) { stops -> if(mapView.visibility ==View.VISIBLE) patternShown?.let{ // We have the pattern and the stops here, time to display them displayPatternWithStopsOnMap(it,stops, true) } ?:{ Log.w(DEBUG_TAG, "The viewingPattern is null!") } else{ if(stopsRecyclerView.visibility==View.VISIBLE) showStopsInRecyclerView(stops) } } viewModel.gtfsRoute.observe(viewLifecycleOwner){route-> if(route == null){ //need to close the fragment activity?.supportFragmentManager?.popBackStack() return@observe } descripTextView.text = route.longName descripTextView.visibility = View.VISIBLE } /* */ Log.d(DEBUG_TAG,"Data ${viewModel.stopsForPatternLiveData.value}") //listeners patternsSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(p0: AdapterView<*>?, p1: View?, position: Int, p3: Long) { val currentShownPattern = patternShown?.pattern val patternWithStops = currentPatterns[position] Log.d(DEBUG_TAG, "request stops for pattern ${patternWithStops.pattern.code}") setPatternAndReqStops(patternWithStops) if(mapView.visibility == View.VISIBLE) { //Clear buses if we are changing direction currentShownPattern?.let { patt -> if(patt.directionId != patternWithStops.pattern.directionId){ stopAnimations() updatesByVehDict.clear() updatePositionsIcons(true) liveBusViewModel.retriggerPositionUpdate() } } } liveBusViewModel.setGtfsLineToFilterPos(lineID, patternWithStops.pattern) } override fun onNothingSelected(p0: AdapterView<*>?) { } } Log.d(DEBUG_TAG, "Views created!") return rootView } // ------------- UI switch stuff --------- private fun hideMapAndShowStopList(){ mapView.visibility = View.GONE stopsRecyclerView.visibility = View.VISIBLE locationIcon?.visibility = View.GONE viewModel.setMapShowing(false) if(useMQTTPositions) liveBusViewModel.stopMatoUpdates() //map.overlayManager.remove(busPositionsOverlay) switchButton.setImageDrawable(AppCompatResources.getDrawable(requireContext(), R.drawable.ic_map_white_30)) hideStopBottomSheet() if(locationComponent.isLocationComponentEnabled){ locationComponent.isLocationComponentEnabled = false shouldMapLocationBeReactivated = true } else shouldMapLocationBeReactivated = false } private fun hideStopListAndShowMap(){ stopsRecyclerView.visibility = View.GONE mapView.visibility = View.VISIBLE locationIcon?.visibility = View.VISIBLE viewModel.setMapShowing(true) //map.overlayManager.add(busPositionsOverlay) //map. if(useMQTTPositions) liveBusViewModel.requestMatoPosUpdates(GtfsUtils.getLineNameFromGtfsID(lineID)) else liveBusViewModel.requestGTFSUpdates() switchButton.setImageDrawable(AppCompatResources.getDrawable(requireContext(), R.drawable.ic_list_30)) if(shouldMapLocationBeReactivated && Permissions.bothLocationPermissionsGranted(requireContext())){ locationComponent.isLocationComponentEnabled = true } } private fun setLocationIconEnabled(setTrue: Boolean){ if(setTrue) locationIcon?.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_red)) else locationIcon?.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_grey)) } /** * Handles logic of enabling the user location on the map */ @SuppressLint("MissingPermission") private fun setMapUserLocationEnabled(enabled: Boolean, assumePermissions: Boolean, fromClick: Boolean) { if (enabled) { val permissionOk = assumePermissions || Permissions.bothLocationPermissionsGranted(requireContext()) if (permissionOk) { Log.d(DEBUG_TAG, "Permission OK, starting location component, assumed: $assumePermissions") locationComponent.isLocationComponentEnabled = true //locationComponent.cameraMode = CameraMode.TRACKING //CameraMode.TRACKING setLocationIconEnabled(true) if (fromClick) Toast.makeText(context, R.string.location_enabled, Toast.LENGTH_SHORT).show() } else { if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)) { //TODO: show dialog for permission rationale Toast.makeText(activity, R.string.enable_position_message_map, Toast.LENGTH_SHORT).show() } Log.d(DEBUG_TAG, "Requesting permission to show user location") enablingPositionFromClick = fromClick showUserPositionRequestLauncher.launch(Permissions.LOCATION_PERMISSIONS) } } else{ locationComponent.isLocationComponentEnabled = false setLocationIconEnabled(false) if (fromClick) { Toast.makeText(requireContext(), R.string.location_disabled, Toast.LENGTH_SHORT).show() //TODO: Cancel the request for the enablement of the position if needed } } } /** * Switch position icon from activ */ private fun onPositionIconButtonClick(view: View){ if(locationComponent.isLocationComponentEnabled) setMapUserLocationEnabled(false, false, true) else{ setMapUserLocationEnabled(true, false, true) } } // ------------- Map Code ------------------------- /** * This method sets up the map and the layers */ override fun onMapReady(mapReady: MapLibreMap) { this.map = mapReady val context = requireContext() val mjson = MapLibreStyles.getJsonStyleFromAsset(context, PreferencesHolder.getMapLibreStyleFile(context)) //ViewUtils.loadJsonFromAsset(requireContext(),"map_style_good.json") activity?.run { val builder = Style.Builder().fromJson(mjson!!) mapReady.setStyle(builder) { style -> + addImagesStyle(style) mapStyle = style //setupLayers(style) // Start observing data initMapUserLocation(style, mapReady, requireContext()) //if(!stopsLayerStarted) - initStopsPolyLineLayers(style, FeatureCollection.fromFeatures(ArrayList()), null, null) + initPolylineStopsLayers(style, null) setupBusLayer(style) - symbolManager = SymbolManager(mapView,mapReady,style) - symbolManager.iconAllowOverlap = true - symbolManager.textAllowOverlap = false - - symbolManager.addClickListener{ _ -> - if (stopActiveSymbol!=null){ - hideStopBottomSheet() - - return@addClickListener true - } else - return@addClickListener false - } + initSymbolManager(mapReady, style) mapViewModel.stopShowing?.let { openStopInBottomSheet(it) } mapViewModel.stopShowing = null toRunWhenMapReady?.run() toRunWhenMapReady = null mapInitialized.set(true) if(patternShown!=null){ viewModel.stopsForPatternLiveData.value?.let { Log.d(DEBUG_TAG, "Show stops from the cache") displayPatternWithStopsOnMap(patternShown!!, it, true) } } } mapReady.addOnMapClickListener { point -> val screenPoint = mapReady.projection.toScreenLocation(point) val features = mapReady.queryRenderedFeatures(screenPoint, STOPS_LAYER_ID) val busNearby = mapReady.queryRenderedFeatures(screenPoint, BUSES_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 = viewModel.getStopByID(id) stop?.let { if (isBottomSheetShowing() || vehShowing.isNotEmpty()){ hideStopBottomSheet() } openStopInBottomSheet(it) //move camera if(it.latitude!=null && it.longitude!=null) mapReady.animateCamera(CameraUpdateFactory.newLatLng(LatLng(it.latitude!!,it.longitude!!)),750) } return@addOnMapClickListener true } else if (busNearby.isNotEmpty()){ val feature = busNearby[0] val vehid = feature.getStringProperty("veh") val route = feature.getStringProperty("line") if(isBottomSheetShowing()) hideStopBottomSheet() //if(context!=null){ // Toast.makeText(context, "Veh $vehid on route ${route.slice(0..route.length-2)}", Toast.LENGTH_SHORT).show() //} showVehicleTripInBottomSheet(vehid) updatesByVehDict[vehid]?.let { //if (it.posUpdate.latitude != null && it.longitude != null) mapReady.animateCamera( CameraUpdateFactory.newLatLng(LatLng(it.posUpdate.latitude, it.posUpdate.longitude)), 750 ) } return@addOnMapClickListener true } false } // we start requesting the bus positions now observeBusPositionUpdates() } /*savedMapStateOnPause?.let{ restoreMapStateFromBundle(it) pendingLocationActivation = false Log.d(DEBUG_TAG, "Restored map state from the saved bundle") } */ val zoom = 12.0 val latlngTarget = LatLng(MapLibreFragment.DEFAULT_CENTER_LAT, MapLibreFragment.DEFAULT_CENTER_LON) mapReady.cameraPosition = savedCameraPosition ?:CameraPosition.Builder().target(latlngTarget).zoom(zoom).build() savedCameraPosition = null if(shouldMapLocationBeReactivated) setMapUserLocationEnabled(true, false, false) } + override fun showOpenStopWithSymbolLayer(): Boolean { + return true + } + private fun observeBusPositionUpdates(){ //live bus positions liveBusViewModel.filteredLocationUpdates.observe(viewLifecycleOwner){ pair -> //Log.d(DEBUG_TAG, "Received ${updates.size} updates for the positions") val updates = pair.first val vehiclesNotOnCorrectDir = pair.second if(mapView.visibility == View.GONE || patternShown ==null){ //DO NOTHING Log.w(DEBUG_TAG, "not doing anything because map is not visible") return@observe } //remove vehicles not on this direction removeVehiclesData(vehiclesNotOnCorrectDir) - updateBusPositionsInMap(updates) + updateBusPositionsInMap(updates, hasVehicleTracking = true) { veh-> + showVehicleTripInBottomSheet(veh) + } //if not using MQTT positions if(!useMQTTPositions){ liveBusViewModel.requestDelayedGTFSUpdates(2000) } } //download missing tripIDs liveBusViewModel.tripsGtfsIDsToQuery.observe(viewLifecycleOwner){ //gtfsPosViewModel.downloadTripsFromMato(dat); MatoTripsDownloadWorker.requestMatoTripsDownload( it, requireContext().applicationContext, "BusTO-MatoTripDownload" ) } } - private fun isBottomSheetShowing(): Boolean{ - return bottomSheetBehavior.state == BottomSheetBehavior.STATE_EXPANDED - } - - /** - * Initialize the map location, but do not enable the component - */ - @SuppressLint("MissingPermission") - private fun initMapUserLocation(style: Style, map: MapLibreMap, context: Context){ - locationComponent = map.locationComponent - val locationComponentOptions = - LocationComponentOptions.builder(context) - .pulseEnabled(false) - .build() - val locationComponentActivationOptions = - MapLibreUtils.buildLocationComponentActivationOptions(style, locationComponentOptions, context) - locationComponent.activateLocationComponent(locationComponentActivationOptions) - locationComponent.isLocationComponentEnabled = false - - lastLocation?.let { - if (it.accuracy < 200) - locationComponent.forceLocationUpdate(it) - } - } - /** - * Update the bottom sheet with the stop information - */ - override fun openStopInBottomSheet(stop: Stop){ - bottomLayout?.let { - - //lay.findViewById(R.id.stopTitleTextView).text ="${stop.ID} - ${stop.stopDefaultName}" - val stopName = stop.stopUserName ?: stop.stopDefaultName - stopTitleTextView.text = stopName//stop.stopDefaultName - stopNumberTextView.text = getString(R.string.stop_fill,stop.ID) - stopTitleTextView.visibility = View.VISIBLE - - val string_show = if (stop.numRoutesStopping==0) "" - else requireContext().getString(R.string.lines_fill, stop.routesThatStopHereToString()) - linesPassingTextView.text = string_show - - //SET ON CLICK LISTENER - arrivalsCard.setOnClickListener{ - fragmentListener?.requestArrivalsForStopID(stop.ID) - } - - arrivalsCard.visibility = View.VISIBLE - directionsCard.visibility = View.VISIBLE - - directionsCard.setOnClickListener { - ViewUtils.openStopInOutsideApp(stop, context) - } - context?.let { - val colorIcon = ViewUtils.getColorFromTheme(it, android.R.attr.colorAccent)//ResourcesCompat.getColor(resources,R.attr.colorAccent,activity?.theme) - ViewCompat.setBackgroundTintList(directionsCard, ColorStateList.valueOf(colorIcon)) - } - - bottomrightImage.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.navigation_right, activity?.theme)) - - } - //add stop marker - if (stop.latitude!=null && stop.longitude!=null) { - stopActiveSymbol = symbolManager.create( - SymbolOptions() - .withLatLng(LatLng(stop.latitude!!, stop.longitude!!)) - .withIconImage(STOP_ACTIVE_IMG) - .withIconAnchor(ICON_ANCHOR_CENTER) - - ) - - } - Log.d(DEBUG_TAG, "Shown stop $stop in bottom sheet") - shownStopInBottomSheet = stop - - bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED - //isBottomSheetShowing = true - } - // 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 - - //reset states - shownStopInBottomSheet = null - if (vehShowing!=""){ - //we are hiding a vehicle - vehShowing = "" - updatePositionsIcons(true) - } - - } private fun showVehicleTripInBottomSheet(veh: String){ val data = updatesByVehDict[veh] if(data==null) { Log.w(DEBUG_TAG,"Asked to show vehicle $veh, but it's not present in the updates") return } bottomLayout?.let { val lineName = FiveTNormalizer.fixShortNameForDisplay( GtfsUtils.getLineNameFromGtfsID(data.posUpdate.routeID), true) val pat = data.pattern if (pat!=null){ //WE HAVE THE DIRECTIONS DATA stopTitleTextView.text = pat.headsign stopTitleTextView.visibility = View.VISIBLE Log.d(DEBUG_TAG, "Showing headsign ${pat.headsign} for vehicle $veh") stopNumberTextView.text = requireContext().getString(R.string.line_fill_towards, lineName) bottomrightImage.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_magnifying_glass, activity?.theme)) directionsCard.setOnClickListener { data.pattern?.let { if(patternShown?.pattern?.code == it.code){ context?.let { c->Toast.makeText(c, R.string.showing_same_direction, Toast.LENGTH_SHORT).show() } }else showPatternWithCode(it.code) } //TODO // ?: { // context?.let { ctx -> Toast.makeText(ctx,"") } //} } //set color val colorBlue = ResourcesCompat.getColor(resources,R.color.blue_500,activity?.theme) ViewCompat.setBackgroundTintList(directionsCard, ColorStateList.valueOf(colorBlue)) directionsCard.visibility = View.VISIBLE } else { //stopTitleTextView.text = "NN" stopTitleTextView.visibility = View.GONE stopNumberTextView.text = requireContext().getString(R.string.line_fill, lineName) directionsCard.visibility = View.GONE } linesPassingTextView.text = requireContext().getString(R.string.vehicle_fill, data.posUpdate.vehicle) } arrivalsCard.visibility=View.GONE vehShowing = veh bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED //call update position to color the bus special updatePositionsIcons(true) //isBottomSheetShowing = true Log.d(DEBUG_TAG, "Shown vehicle $veh in bottom layout") } // ------- MAP LAYERS INITIALIZE ---- /** * Initialize the map layers for the stops */ - private fun initStopsPolyLineLayers(style: Style, stopFeatures:FeatureCollection, lineFeature: Feature?, arrowFeatures: FeatureCollection?){ + private fun initPolylineStopsLayers(style: Style, arrowFeatures: FeatureCollection?){ Log.d(DEBUG_TAG, "INIT STOPS CALLED") stopsSource = GeoJsonSource(STOPS_SOURCE_ID) - style.addSource(stopsSource) //val context = requireContext() val stopIcon = ResourcesCompat.getDrawable(resources,R.drawable.ball, activity?.theme)!! val imgStop = ResourcesCompat.getDrawable(resources,R.drawable.bus_stop_new, activity?.theme)!! val polyIconArrow = ResourcesCompat.getDrawable(resources, R.drawable.arrow_up_box_fill, activity?.theme)!! //set the image tint //DrawableCompat.setTint(imgBus,ContextCompat.getColor(context,R.color.line_drawn_poly)) - // add icon + // add icons style.addImage(STOP_IMAGE_ID,stopIcon) style.addImage(POLY_ARROW, polyIconArrow) 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) - ) + polylineSource = GeoJsonSource(POLYLINE_SOURCE) //lineFeature?.let { GeoJsonSource(POLYLINE_SOURCE, it) } ?: GeoJsonSource(POLYLINE_SOURCE) style.addSource(polylineSource) val color=ContextCompat.getColor(requireContext(),R.color.line_drawn_poly) //paint.style = Paint.Style.FILL_AND_STROKE //paint.strokeJoin = Paint.Join.ROUND //paint.strokeCap = Paint.Cap.ROUND val lineLayer = LineLayer(POLYLINE_LAYER, POLYLINE_SOURCE).withProperties( PropertyFactory.lineColor(color), PropertyFactory.lineWidth(5.0f), //originally 13f PropertyFactory.lineOpacity(1.0f), PropertyFactory.lineJoin(Property.LINE_JOIN_ROUND), PropertyFactory.lineCap(Property.LINE_CAP_ROUND) ) polyArrowSource = GeoJsonSource(POLY_ARROWS_SOURCE, arrowFeatures) style.addSource(polyArrowSource) val arrowsLayer = SymbolLayer(POLY_ARROWS_LAYER, POLY_ARROWS_SOURCE).withProperties( PropertyFactory.iconImage(POLY_ARROW), PropertyFactory.iconRotate(Expression.get("bearing")), PropertyFactory.iconRotationAlignment(ICON_ROTATION_ALIGNMENT_MAP) ) val layers = style.layers val lastLayers = layers.filter { l-> l.id.contains("city") } //Log.d(DEBUG_TAG,"Layers:\n ${style.layers.map { l -> l.id }}") Log.d(DEBUG_TAG, "City layers: ${lastLayers.map { l-> l.id }}") if(lastLayers.isNotEmpty()) style.addLayerAbove(lineLayer,lastLayers[0].id) else style.addLayerBelow(lineLayer,"label_country_1") - style.addLayerAbove(stopsLayer, POLYLINE_LAYER) + //style.addLayerAbove(stopsLayer, POLYLINE_LAYER) style.addLayerAbove(arrowsLayer, POLYLINE_LAYER) stopsLayerStarted = true - } - - - /** - * Setup the Map Layers - */ - private fun setupBusLayer(style: Style) { - // Buses source - busesSource = GeoJsonSource(BUSES_SOURCE_ID) - style.addSource(busesSource) - style.addImage("bus_symbol",ResourcesCompat.getDrawable(resources, R.drawable.map_bus_position_icon, activity?.theme)!!) - - selectedBusSource = GeoJsonSource("sel_bus_source") - style.addSource(selectedBusSource) - style.addImage("sel_bus_symbol", ResourcesCompat.getDrawable(resources, R.drawable.map_bus_position_icon_sel, activity?.theme)!!) - - // Buses layer - val busesLayer = SymbolLayer(BUSES_LAYER_ID, BUSES_SOURCE_ID).apply { - withProperties( - PropertyFactory.iconImage("bus_symbol"), - //PropertyFactory.iconSize(1.2f), - PropertyFactory.iconAllowOverlap(true), - PropertyFactory.iconIgnorePlacement(true), - PropertyFactory.iconRotate(Expression.get("bearing")), - PropertyFactory.iconRotationAlignment(ICON_ROTATION_ALIGNMENT_MAP) - - ) - } - style.addLayerAbove(busesLayer, STOPS_LAYER_ID) - - val selectedBusLayer = SymbolLayer("sel_bus_layer", "sel_bus_source").withProperties( - PropertyFactory.iconImage("sel_bus_symbol"), - //PropertyFactory.iconSize(1.2f), - PropertyFactory.iconAllowOverlap(true), - PropertyFactory.iconIgnorePlacement(true), - PropertyFactory.iconRotate(Expression.get("bearing")), - PropertyFactory.iconRotationAlignment(ICON_ROTATION_ALIGNMENT_MAP) - - ) - style.addLayerAbove(selectedBusLayer, BUSES_LAYER_ID) + initStopsLayer(style, null, POLY_ARROWS_LAYER) } - override fun onAttach(context: Context) { - super.onAttach(context) - if(context is CommonFragmentListener){ - fragmentListener = context - } else throw RuntimeException("$context must implement CommonFragmentListener") - - } - - - private fun stopAnimations(){ - for(anim in animatorsByVeh.values){ - anim.cancel() - } - } /** * Save the loaded pattern data, without the stops! */ private fun savePatternsToShow(patterns: List){ currentPatterns = patterns.sortedWith(patternsSorter) patternsAdapter?.let { it.clear() it.addAll(currentPatterns.map { p->"${p.pattern.directionId} - ${p.pattern.headsign}" }) it.notifyDataSetChanged() } // if we are loading from a stop, find it val patternToShow = stopIDFromToShow?.let { sID -> val stopGtfsID = "gtt:$sID" var p: MatoPatternWithStops? = null var pLength = 0 for(patt in currentPatterns){ for(pstop in patt.stopsIndices){ if(pstop.stopGtfsId == stopGtfsID){ //found if (patt.stopsIndices.size>pLength){ p = patt pLength = patt.stopsIndices.size } //break here, we have determined this pattern has the stop we're looking for break } } } p } if(stopIDFromToShow!=null){ if(patternToShow==null) Log.w(DEBUG_TAG, "We had to show the pattern from stop $stopIDFromToShow, but we didn't find it") else Log.d(DEBUG_TAG, "Requesting to show pattern from stop $stopIDFromToShow, found pattern ${patternToShow.pattern.code}") } //unset the stopID to show if(patternToShow!=null) { //showPattern(patternToShow) patternShown = patternToShow stopIDFromToShow = null } patternShown?.let { showPattern(it) } } /** * Called when the position of the spinner is updated */ private fun setPatternAndReqStops(patternWithStops: MatoPatternWithStops){ Log.d(DEBUG_TAG, "Requesting stops for pattern ${patternWithStops.pattern.code}") viewModel.selectedPatternLiveData.value = patternWithStops viewModel.currentPatternStops.value = patternWithStops.stopsIndices.sortedBy { i-> i.order } patternShown = patternWithStops viewModel.requestStopsForPatternWithStops(patternWithStops) } private fun showPattern(patternWs: MatoPatternWithStops){ //Log.d(DEBUG_TAG, "Finding pattern to show: ${patternWs.pattern.code}") var pos = -2 val code = patternWs.pattern.code.trim() for (k in currentPatterns.indices) { if (currentPatterns[k].pattern.code.trim() == code) { pos = k break } } Log.d(DEBUG_TAG, "Requesting stops fro pattern $code in position: $pos") if (pos !=-2) patternsSpinner.setSelection(pos) else Log.e(DEBUG_TAG, "Pattern with code $code not found!!") //request pattern stops from DB //setPatternAndReqStops(patternWs) } private fun zoomToCurrentPattern(){ if(polyline==null) return val NULL_VALUE = -4000.0 var maxLat = NULL_VALUE var minLat = NULL_VALUE var minLong = NULL_VALUE var maxLong = NULL_VALUE polyline?.let { for(p in it.coordinates()){ val lat = p.latitude() val lon = p.longitude() // get max latitude if(maxLat == NULL_VALUE) maxLat =lat else if (maxLat < lat) maxLat = lat // find min latitude if (minLat ==NULL_VALUE) minLat = lat else if (minLat > lat) minLat = lat if(maxLong == NULL_VALUE || maxLong < lon ) maxLong = lon if (minLong == NULL_VALUE || minLong > lon) minLong = lon } val padding = 50 // Pixel di padding intorno ai limiti Log.d(DEBUG_TAG, "Setting limits of bounding box of line: $minLat -> $maxLat, $minLong -> $maxLong") val bbox = LatLngBounds.from(maxLat,maxLong, minLat, minLong) //map.zoomToBoundingBox(BoundingBox(maxLat+del, maxLong+del, minLat-del, minLong-del), false) map?.animateCamera(CameraUpdateFactory.newLatLngBounds(bbox, padding)) } } private fun displayPatternWithStopsOnMap(patternWs: MatoPatternWithStops, stopsToSort: List, zoomToPattern: Boolean){ if(!mapInitialized.get()){ //set the runnable and do nothing else Log.d(DEBUG_TAG, "Delaying pattern display to when map is Ready: ${patternWs.pattern.code}") toRunWhenMapReady = Runnable { displayPatternWithStopsOnMap(patternWs, stopsToSort, zoomToPattern) } return } Log.d(DEBUG_TAG, "Got the stops: ${stopsToSort.map { s->s.gtfsID }}}") patternShown = patternWs //Problem: stops are not sorted val stopOrderD = patternWs.stopsIndices.withIndex().associate{it.value.stopGtfsId to it.index} val stopsSorted = stopsToSort.sortedBy { s-> stopOrderD[s.gtfsID] } val pattern = patternWs.pattern val pointsList = PolylineParser.decodePolyline(pattern.patternGeometryPoly, pattern.patternGeometryLength) val pointsToShow = pointsList.map { Point.fromLngLat(it.longitude, it.latitude) } Log.d(DEBUG_TAG, "The polyline has ${pointsToShow.size} points to display") polyline = LineString.fromLngLats(pointsToShow) val lineFeature = Feature.fromGeometry(polyline) //Log.d(DEBUG_TAG, "Polyline in JSON is: ${lineFeature.toJson()}") // --- STOPS--- val features = ArrayList() for (s in stopsSorted){ if (s.latitude!=null && s.longitude!=null) { val loc = if (showOnTopOfLine) findOptimalPosition(s, pointsList) else LatLng(s.latitude!!, s.longitude!!) features.add( Feature.fromGeometry( Point.fromLngLat(loc.longitude, loc.latitude), JsonObject().apply { addProperty("id", s.ID) addProperty("name", s.stopDefaultName) //addProperty("routes", s.routesThatStopHereToString()) // Add routes array to JSON object } ) ) } } // -- ARROWS -- //val splitPolyline = MapLibreUtils.splitPolyWhenDistanceTooBig(pointsList, 200.0) val arrowFeatures = ArrayList() val pointsIndexToShowIcon = MapLibreUtils.findPointsToPutDirectionMarkers(pointsList, stopsSorted, 750.0) for (idx in pointsIndexToShowIcon){ val pnow = pointsList[idx] val otherp = if(idx>1) pointsList[idx-1] else pointsList[idx+1] val bearing = if (idx>1) MapLibreUtils.getBearing(pointsList[idx-1], pnow) else MapLibreUtils.getBearing(pnow, pointsList[idx+1]) arrowFeatures.add(Feature.fromGeometry( Point.fromLngLat((pnow.longitude+otherp.longitude)/2, (pnow.latitude+otherp.latitude)/2 ), //average JsonObject().apply { addProperty("bearing", bearing) } )) } 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 (stopsLayerStarted) { stopsSource.setGeoJson(FeatureCollection.fromFeatures(features)) polylineSource.setGeoJson(lineFeature) polyArrowSource.setGeoJson(FeatureCollection.fromFeatures(arrowFeatures)) lastStopsSizeShown = features.size } else map?.let { Log.d(DEBUG_TAG, "Map stop layer is not started yet, init layer") - initStopsPolyLineLayers(mapStyle, FeatureCollection.fromFeatures(features),lineFeature, FeatureCollection.fromFeatures(arrowFeatures)) + initPolylineStopsLayers(mapStyle, FeatureCollection.fromFeatures(arrowFeatures)) Log.d(DEBUG_TAG,"Started stops layer on map") lastStopsSizeShown = features.size stopsLayerStarted = true } ?:{ Log.e(DEBUG_TAG, "Stops layer is not started!!") } /* OLD CODE for(s in stops){ val gp = val marker = MarkerUtils.makeMarker( gp, s.ID, s.stopDefaultName, s.routesThatStopHereToString(), map,stopTouchResponder, stopIcon, R.layout.linedetail_stop_infowindow, R.color.line_drawn_poly ) marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) stopsOverlay.add(marker) } */ //POINTS LIST IS NOT IN ORDER ANY MORE //if(!map.overlayManager.contains(stopsOverlay)){ // map.overlayManager.add(stopsOverlay) //} if(zoomToPattern) zoomToCurrentPattern() //map.invalidate() } private fun initializeRecyclerView(){ val llManager = LinearLayoutManager(context) llManager.orientation = LinearLayoutManager.VERTICAL stopsRecyclerView.layoutManager = llManager } private fun showStopsInRecyclerView(stops: List){ Log.d(DEBUG_TAG, "Setting stops from: "+viewModel.currentPatternStops.value) val orderBy = viewModel.currentPatternStops.value!!.withIndex().associate{it.value.stopGtfsId to it.index} val stopsSorted = stops.sortedBy { s -> orderBy[s.gtfsID] } val numStops = stopsSorted.size Log.d(DEBUG_TAG, "RecyclerView adapter is: ${stopsRecyclerView.adapter}") val setNewAdapter = true if(setNewAdapter){ stopsRecyclerView.adapter = StopRecyclerAdapter( stopsSorted, stopAdapterListener, StopRecyclerAdapter.Use.LINES, NameCapitalize.FIRST ) } } /** * This method fixes the display of the pattern, to be used when clicking on a bus */ private fun showPatternWithCode(patternId: String){ //var index = 0 Log.d(DEBUG_TAG, "Showing pattern with code $patternId ") for (i in currentPatterns.indices){ val pattStop = currentPatterns[i] if(pattStop.pattern.code == patternId){ Log.d(DEBUG_TAG, "Pattern found in position $i") //setPatternAndReqStops(pattStop) patternsSpinner.setSelection(i) break } } } - private fun removeVehiclesData(vehs: List){ - for(v in vehs){ - if (updatesByVehDict.contains(v)) { - updatesByVehDict.remove(v) - if (animatorsByVeh.contains(v)){ - animatorsByVeh[v]?.cancel() - animatorsByVeh.remove(v) - } - } - if (vehShowing==v){ - hideStopBottomSheet() - } - } - } - - /** - * Update function for the bus positions - * Takes the processed updates and saves them accordingly - * Copied from MapLibreFragment, removing the labels - */ - private fun updateBusPositionsInMap(incomingData: HashMap>){ - val vehsNew = HashSet(incomingData.values.map { up -> up.first.vehicle }) - val vehsOld = HashSet(updatesByVehDict.keys) - Log.d(DEBUG_TAG, "In fragment, have ${incomingData.size} updates to show") - - var countUpds = 0 - var createdVehs = 0 - //val symbolsToUpdate = ArrayList() - for (upsWithTrp in incomingData.values){ - val newPos = upsWithTrp.first - val patternStops = upsWithTrp.second - val vehID = newPos.vehicle - var animate = false - if (vehsOld.contains(vehID)){ - //changing the location of an existing bus - //update position only if the starting or the stopping position of the animation are in the view - val oldPos = updatesByVehDict[vehID]?.posUpdate - val oldPattern = updatesByVehDict[vehID]?.pattern - var avoidShowingUpdateBecauseIsImpossible = false - oldPos?.let{ - - if(it.routeID!=newPos.routeID) { - val dist = LatLng(it.latitude, it.longitude).distanceTo(LatLng(newPos.latitude, newPos.longitude)) - val speed = dist*3.6 / (newPos.timestamp - it.timestamp) //this should be in km/h - Log.w(DEBUG_TAG, "Vehicle $vehID changed route from ${oldPos.routeID} to ${newPos.routeID}, distance: $dist, speed: $speed") - if (speed > 120 || speed < 0){ - avoidShowingUpdateBecauseIsImpossible = true - } - } - } - if (avoidShowingUpdateBecauseIsImpossible){ - // DO NOT SHOW THIS SHIT - Log.w(DEBUG_TAG, "Update for vehicle $vehID skipped") - continue - } - - val samePosition = oldPos?.let { (it.latitude==newPos.latitude)&&(it.longitude == newPos.longitude) }?:false - val setPattern = (oldPattern==null) && (patternStops!=null) - if(newPos.bearing == null && oldPos?.bearing != null){ - //copy old bearing - newPos.bearing = oldPos.bearing - } - if((!samePosition)|| setPattern) { - - val newOrOldPosInBounds = isPointInsideVisibleRegion( - newPos.latitude, newPos.longitude, true - ) || (oldPos?.let { isPointInsideVisibleRegion(it.latitude,it.longitude,true) } ?: false) - - - //val skip = true - if (newOrOldPosInBounds) { - // update the pattern data, the position will be updated with the animation - patternStops?.let { updatesByVehDict[vehID]!!.pattern = it.pattern} - //this moves both the icon and the label - animateNewPositionMove(newPos) - - } else { - //update - updatesByVehDict[vehID] = LivePositionTripPattern(newPos,patternStops?.pattern) - /*busLabelSymbolsByVeh[vehID]?.let { - it.latLng = LatLng(pos.latitude, pos.longitude) - symbolsToUpdate.add(it) - }*/ - //if(vehShowing==vehID) - // map?.animateCamera(CameraUpdateFactory.newLatLng(LatLng(pos.latitude, pos.longitude)),500) - //TODO: Follow the vehicle - } - } - countUpds++ - } - else{ - //not inside - // update it simply - updatesByVehDict[vehID] = LivePositionTripPattern(newPos, patternStops?.pattern) - //createLabelForVehicle(pos) - //if(vehShowing==vehID) - // map?.animateCamera(CameraUpdateFactory.newLatLng(LatLng(pos.latitude, pos.longitude)),500) - createdVehs +=1 - } - if (vehID == vehShowing){ - //update the data - showVehicleTripInBottomSheet(vehID) - } - } - //symbolManager.update(symbolsToUpdate) - //remove old positions - Log.d(DEBUG_TAG, "Updated $countUpds vehicles, created $createdVehs vehicles") - vehsOld.removeAll(vehsNew) - //now vehsOld contains the vehicles id for those that have NOT been updated - val currentTimeStamp = System.currentTimeMillis() /1000 - for(vehID in vehsOld){ - //remove after 2 minutes of inactivity - if (updatesByVehDict[vehID]!!.posUpdate.timestamp - currentTimeStamp > 2*60){ - //remove the bus - updatesByVehDict.remove(vehID) - if(vehID in animatorsByVeh){ - animatorsByVeh[vehID]?.cancel() - animatorsByVeh.remove(vehID) - } - //removeVehicleLabel(vehID) - } - } - //update UI - updatePositionsIcons(false) - } - - /** - * This is the tricky part, animating the transitions - * Basically, we need to set the new positions with the data and redraw them all - */ - private fun animateNewPositionMove(positionUpdate: LivePositionUpdate){ - if (positionUpdate.vehicle !in updatesByVehDict.keys) - return - val vehID = positionUpdate.vehicle - val currentUpdate = updatesByVehDict[positionUpdate.vehicle] - currentUpdate?.let { it -> - //cancel current animation on vehicle - animatorsByVeh[vehID]?.cancel() - val posUp = it.posUpdate - - val currentPos = LatLng(posUp.latitude, posUp.longitude) - val newPos = LatLng(positionUpdate.latitude, positionUpdate.longitude) - val valueAnimator = ValueAnimator.ofObject(MapLibreUtils.LatLngEvaluator(), currentPos, newPos) - valueAnimator.addUpdateListener(object : ValueAnimator.AnimatorUpdateListener { - private var latLng: LatLng? = null - override fun onAnimationUpdate(animation: ValueAnimator) { - latLng = animation.animatedValue as LatLng - //update position on animation - val update = updatesByVehDict[positionUpdate.vehicle] - if(update!=null){ latLng?.let { ll -> - update.posUpdate.latitude = ll.latitude - update.posUpdate.longitude = ll.longitude - updatePositionsIcons(false) - } - } else{ - //The update is null - Log.w(DEBUG_TAG, "The bus position to animate has been removed, but the animator is still running!") - } - } - }) - /*valueAnimator.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationStart(animation: Animator) { - super.onAnimationStart(animation) - //val update = positionsByVehDict[positionUpdate.vehicle]!! - //remove the label at the start of the animation - //removeVehicleLabel(vehID) - val annot = busLabelSymbolsByVeh[vehID] - annot?.let { sym -> - sym.textOpacity = 0.0f - symbolsToUpdate.add(sym) - } - } - - override fun onAnimationEnd(animation: Animator) { - super.onAnimationEnd(animation) - /*val annot = busLabelSymbolsByVeh[vehID] - annot?.let { sym -> - sym.textOpacity = 1.0f - sym.latLng = newPos //LatLng(newPos) - symbolsToUpdate.add(sym) - } - - */ - } - }) - */ - animatorsByVeh[vehID]?.cancel() - //set the new position as the current one but with the old lat and lng - positionUpdate.latitude = posUp.latitude - positionUpdate.longitude = posUp.longitude - //this might be null if the updates dict does not contain the vehID - updatesByVehDict[vehID]!!.posUpdate = positionUpdate - valueAnimator.duration = 300 - valueAnimator.interpolator = LinearInterpolator() - valueAnimator.start() - - animatorsByVeh[vehID] = valueAnimator - - } ?: { - Log.e(DEBUG_TAG, "Have to run animation for veh ${positionUpdate.vehicle} but not in the dict, adding") - //updatesByVehDict[positionUpdate.vehicle] = positionUpdate - } - } - //TODO: MERGE THIS CODE WITH MapLibreFragment ONE - /** - * Update the bus positions displayed on the map, from the existing data - */ - private fun updatePositionsIcons(forced: Boolean){ - //avoid frequent updates - val currentTime = System.currentTimeMillis() - if(!forced && currentTime - lastUpdateTime < 60){ - //DO NOT UPDATE THE MAP - viewLifecycleOwner.lifecycleScope.launch { - delay(200) - updatePositionsIcons(forced) - } - return - } - - val busFeatures = ArrayList() - val selectedBusFeatures = ArrayList() - for (dat in updatesByVehDict.values){ - //if (s.latitude!=null && s.longitude!=null) - val pos = dat.posUpdate - val point = Point.fromLngLat(pos.longitude, pos.latitude) - - val newFeature = Feature.fromGeometry( - point, - JsonObject().apply { - addProperty("veh", pos.vehicle) - addProperty("trip", pos.tripID) - addProperty("bearing", pos.bearing ?:0.0f) - addProperty("line", pos.routeID) - } - ) - if (vehShowing == dat.posUpdate.vehicle) - selectedBusFeatures.add(newFeature) - else - busFeatures.add(newFeature) - /*busLabelSymbolsByVeh[pos.vehicle]?.let { - it.latLng = LatLng(pos.latitude, pos.longitude) - symbolsToUpdate.add(it) - } - - */ - } - busesSource.setGeoJson(FeatureCollection.fromFeatures(busFeatures)) - selectedBusSource.setGeoJson(FeatureCollection.fromFeatures(selectedBusFeatures)) - //update labels, clear cache to be used - //symbolManager.update(symbolsToUpdate) - //symbolsToUpdate.clear() - lastUpdateTime = System.currentTimeMillis() - } - override fun onResume() { super.onResume() Log.d(DEBUG_TAG, "Resetting paused from onResume") pausedFragment = false val keySourcePositions = getString(R.string.pref_positions_source) useMQTTPositions = PreferenceManager.getDefaultSharedPreferences(requireContext()) .getString(keySourcePositions, "mqtt").contentEquals("mqtt") //separate paths if(useMQTTPositions) liveBusViewModel.requestMatoPosUpdates(GtfsUtils.getLineNameFromGtfsID(lineID)) else liveBusViewModel.requestGTFSUpdates() if(mapViewModel.currentLat.value!=MapViewModel.INVALID) { Log.d(DEBUG_TAG, "mapViewModel posi: ${mapViewModel.currentLat.value}, ${mapViewModel.currentLong.value}"+ " zoom ${mapViewModel.currentZoom.value}") //THIS WAS A FIX FOR THE OLD OSMDROID MAP /*val controller = map.controller viewLifecycleOwner.lifecycleScope.launch { delay(100) Log.d(DEBUG_TAG, "zooming back to point") controller.animateTo(GeoPoint(mapViewModel.currentLat.value!!, mapViewModel.currentLong.value!!), mapViewModel.currentZoom.value!!,null,null) //controller.setCenter(GeoPoint(mapViewModel.currentLat.value!!, mapViewModel.currentLong.value!!)) //controller.setZoom(mapViewModel.currentZoom.value!!) } */ } //initialize GUI here - fragmentListener.readyGUIfor(FragmentKind.LINES) + fragmentListener?.readyGUIfor(FragmentKind.LINES) } override fun onPause() { super.onPause() mapView.onPause() if(useMQTTPositions) liveBusViewModel.stopMatoUpdates() pausedFragment = true //save map val camera = map?.cameraPosition camera?.let {cam-> mapViewModel.currentLat.value = cam.target?.latitude ?: -400.0 mapViewModel.currentLong.value = cam.target?.longitude ?: -400.0 mapViewModel.currentZoom.value = cam.zoom } } override fun onStop() { super.onStop() mapView.onStop() shownStopInBottomSheet?.let { mapViewModel.stopShowing = it } shouldMapLocationBeReactivated = locationComponent.isLocationComponentEnabled } override fun onDestroyView() { map?.run { Log.d(DEBUG_TAG, "Saving camera position") savedCameraPosition = cameraPosition } super.onDestroyView() Log.d(DEBUG_TAG, "Destroying the views") /*mapStyle.removeLayer(STOPS_LAYER_ID) mapStyle?.removeSource(STOPS_SOURCE_ID) mapStyle.removeLayer(POLYLINE_LAYER) mapStyle.removeSource(POLYLINE_SOURCE) */ //stopsLayerStarted = false } override fun onMapDestroy() { mapStyle.removeLayer(STOPS_LAYER_ID) mapStyle.removeSource(STOPS_SOURCE_ID) mapStyle.removeLayer(POLYLINE_LAYER) mapStyle.removeSource(POLYLINE_SOURCE) mapStyle.removeLayer(BUSES_LAYER_ID) mapStyle.removeSource(BUSES_SOURCE_ID) map?.locationComponent?.isLocationComponentEnabled = false } override fun getBaseViewForSnackBar(): View? { return null } companion object { private const val LINEID_KEY="lineID" private const val STOPID_FROM_KEY="stopID" - private const val STOPS_SOURCE_ID = "stops-source" - private const val STOPS_LAYER_ID = "stops-layer" - private const val STOP_ACTIVE_IMG = "stop_active_img" - private const val STOP_IMAGE_ID = "stop-img" - private const val POLYLINE_LAYER = "polyline-layer" - private const val POLYLINE_SOURCE = "polyline-source" - - private const val POLY_ARROWS_LAYER = "arrows-layer" - private const val POLY_ARROWS_SOURCE = "arrows-source" - private const val POLY_ARROW ="poly-arrow-img" + private const val DEBUG_TAG="BusTO-LineDetalFragment" fun makeArgs(lineID: String, stopIDFrom: String?): Bundle{ val b = Bundle() b.putString(LINEID_KEY, lineID) b.putString(STOPID_FROM_KEY, stopIDFrom) return b } fun newInstance(lineID: String?, stopIDFrom: String?) = LinesDetailFragment().apply { lineID?.let { arguments = makeArgs(it, stopIDFrom) } } @JvmStatic private fun findOptimalPosition(stop: Stop, pointsList: MutableList): LatLng{ if(stop.latitude==null || stop.longitude ==null|| pointsList.isEmpty()) throw IllegalArgumentException() val sLat = stop.latitude!! val sLong = stop.longitude!! if(pointsList.size < 2) return pointsList[0] pointsList.sortBy { utils.measuredistanceBetween(sLat, sLong, it.latitude, it.longitude) } val p1 = pointsList[0] val p2 = pointsList[1] if (p1.longitude == p2.longitude){ //Log.e(DEBUG_TAG, "Same longitude") return LatLng(sLat, p1.longitude) } else if (p1.latitude == p2.latitude){ //Log.d(DEBUG_TAG, "Same latitude") return LatLng(p2.latitude,sLong) } val m = (p1.latitude - p2.latitude) / (p1.longitude - p2.longitude) val minv = (p1.longitude-p2.longitude)/(p1.latitude - p2.latitude) val cR = p1.latitude - p1.longitude * m val longNew = (minv * sLong + sLat -cR ) / (m+minv) val latNew = (m*longNew + cR) //Log.d(DEBUG_TAG,"Stop ${stop.ID} old pos: ($sLat, $sLong), new pos ($latNew,$longNew)") return LatLng(latNew,longNew) } private const val DEFAULT_CENTER_LAT = 45.12 private const val DEFAULT_CENTER_LON = 7.6858 } enum class BottomShowing{ STOP, VEHICLE } } \ No newline at end of file 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 d502eec..cdef382 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt @@ -1,1235 +1,814 @@ package it.reyboz.bustorino.fragments import android.Manifest -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.animation.ValueAnimator import android.annotation.SuppressLint import android.content.Context -import android.graphics.Color import android.location.Location import android.location.LocationListener import android.location.LocationManager import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.animation.LinearInterpolator import android.widget.ImageButton import android.widget.RelativeLayout -import android.widget.TextView import android.widget.Toast import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts -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.activityViewModels import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.gson.JsonObject import it.reyboz.bustorino.R import it.reyboz.bustorino.backend.LivePositionsServiceStatus import it.reyboz.bustorino.backend.Stop import it.reyboz.bustorino.backend.gtfs.LivePositionUpdate import it.reyboz.bustorino.backend.mato.MQTTMatoClient import it.reyboz.bustorino.data.PreferencesHolder import it.reyboz.bustorino.data.gtfs.TripAndPatternWithStops -import it.reyboz.bustorino.map.MapLibreUtils import it.reyboz.bustorino.map.MapLibreStyles import it.reyboz.bustorino.util.Permissions -import it.reyboz.bustorino.util.ViewUtils import it.reyboz.bustorino.viewmodels.LivePositionsViewModel import it.reyboz.bustorino.viewmodels.StopsMapViewModel -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import org.maplibre.android.camera.CameraPosition import org.maplibre.android.camera.CameraUpdateFactory import org.maplibre.android.geometry.LatLng import org.maplibre.android.geometry.LatLngBounds -import org.maplibre.android.location.LocationComponent -import org.maplibre.android.location.LocationComponentOptions import org.maplibre.android.location.modes.CameraMode import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.Style import org.maplibre.android.plugins.annotation.Symbol -import org.maplibre.android.style.expressions.Expression -import org.maplibre.android.style.layers.Property.* -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 STOP_TO_SHOW = "stoptoshow" /** * A simple [Fragment] subclass. * Use the [MapLibreFragment.newInstance] factory method to * create an instance of this fragment. */ class MapLibreFragment : GeneralMapLibreFragment() { - protected var fragmentListener: CommonFragmentListener? = null - private lateinit var locationComponent: LocationComponent - private var lastLocation: Location? = null private val stopsViewModel: StopsMapViewModel by viewModels() private var stopsShowing = ArrayList(0) - private var isBottomSheetShowing = false //private lateinit var symbolManager: SymbolManager // Sources for stops and buses are in GeneralMapLibreFragment private var isUserMovingCamera = false - private var stopsLayerStarted = false private var lastStopsSizeShown = 0 private var lastBBox = LatLngBounds.from(2.0, 2.0, 1.0,1.0) private var mapInitCompleted =false private var stopsRedrawnTimes = 0 - //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 - + //bottom Sheet behavior in GeneralMapLibreFragment //private var stopActiveSymbol: Symbol? = null // Location stuff private lateinit var locationManager: LocationManager private lateinit var showUserPositionButton: ImageButton private lateinit var centerUserButton: ImageButton private lateinit var followUserButton: ImageButton private var followingUserLocation = false private var pendingLocationActivation = false private var ignoreCameraMovementForFollowing = true private var enablingPositionFromClick = false private val positionRequestLauncher = registerForActivityResult, Map>( ActivityResultContracts.RequestMultiplePermissions(), ActivityResultCallback { result -> if (result == null) { Log.w(DEBUG_TAG, "Got asked permission but request is null, doing nothing?") }else if(!pendingLocationActivation){ /// SHOULD DO NOTHING HERE Log.d(DEBUG_TAG, "Requested location but now there is no pendingLocationActivation") } else if (java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_COARSE_LOCATION] && java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_FINE_LOCATION]) { // We can use the position, restart location overlay Log.d(DEBUG_TAG, "HAVE THE PERMISSIONS") if (context == null || requireContext().getSystemService(Context.LOCATION_SERVICE) == null) return@ActivityResultCallback ///@registerForActivityResult val locationManager = requireContext().getSystemService(Context.LOCATION_SERVICE) as LocationManager var lastLoc = stopsViewModel.lastUserLocation @SuppressLint("MissingPermission") if(lastLoc==null) lastLoc = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) else Log.d(DEBUG_TAG, "Got last location from cache") if (lastLoc != null) { if(LatLng(lastLoc.latitude, lastLoc.longitude).distanceTo(DEFAULT_LATLNG) <= MAX_DIST_KM*1000){ Log.d(DEBUG_TAG, "Showing the user position") setMapLocationEnabled(true, true, false) } else{ setMapLocationEnabled(false, false,false) context?.let{Toast.makeText(it,R.string.too_far_not_showing_location, Toast.LENGTH_SHORT).show()} } } else requestInitialUserLocation() } else{ Toast.makeText(requireContext(),R.string.location_disabled, Toast.LENGTH_SHORT).show() Log.w(DEBUG_TAG, "No location permission") } }) private val showUserPositionRequestLauncher = registerForActivityResult, Map>( ActivityResultContracts.RequestMultiplePermissions(), ActivityResultCallback { result -> if (result == null) { Log.w(DEBUG_TAG, "Got asked permission but request is null, doing nothing?") } else if (java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_COARSE_LOCATION] && java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_FINE_LOCATION]) { // We can use the position, restart location overlay if (context == null || requireContext().getSystemService(Context.LOCATION_SERVICE) == null) return@ActivityResultCallback ///@registerForActivityResult setMapLocationEnabled(true, true, enablingPositionFromClick) } else Log.w(DEBUG_TAG, "No location permission") }) //BUS POSITIONS private var usingMQTTPositions = true // THIS IS INSIDE VIEW MODEL NOW private val livePositionsViewModel : LivePositionsViewModel by activityViewModels() private lateinit var busPositionsIconButton: ImageButton - private val positionsByVehDict = HashMap(5) - private val animatorsByVeh = HashMap() - private var lastUpdateTime : Long = -1 //private var busLabelSymbolsByVeh = HashMap() private val symbolsToUpdate = ArrayList() private var initialStopToShow : Stop? = null private var initialStopShown = false private var waitingDelayedBusUpdate = false //shown stuff //private var savedStateOnStop : Bundle? = null private val showBusLayer = true override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) arguments?.let { initialStopToShow = Stop.fromBundle(arguments) } } 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) //reset the counter lastStopsSizeShown = 0 stopsRedrawnTimes = 0 stopsLayerStarted = false symbolsToUpdate.clear() // Init layout view // Init the MapView mapView = rootView.findViewById(R.id.libreMapView) val restoreBundle = stopsViewModel.savedState if(restoreBundle!=null){ mapView.onCreate(restoreBundle) } else mapView.onCreate(savedInstanceState) 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) showUserPositionButton = rootView.findViewById(R.id.locationEnableIcon) showUserPositionButton.setOnClickListener(this::switchUserLocationStatus) followUserButton = rootView.findViewById(R.id.followUserImageButton) centerUserButton = rootView.findViewById(R.id.centerMapImageButton) busPositionsIconButton = rootView.findViewById(R.id.busPositionsImageButton) busPositionsIconButton.setOnClickListener { LivePositionsDialogFragment().show(parentFragmentManager, "LivePositionsDialog") } bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet) bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN arrivalsCard.setOnClickListener { if(context!=null){ Toast.makeText(context,"ARRIVALS", Toast.LENGTH_SHORT).show() } } centerUserButton.setOnClickListener { if(context!=null && locationComponent.isLocationComponentEnabled) { val location = locationComponent.lastKnownLocation location?.let { mapView.getMapAsync { map -> map.animateCamera(CameraUpdateFactory.newCameraPosition( CameraPosition.Builder().target(LatLng(location.latitude, location.longitude)).build()), 500) } } } } followUserButton.setOnClickListener { // onClick user following button if(context!=null && locationComponent.isLocationComponentEnabled){ if(followingUserLocation) locationComponent.cameraMode = CameraMode.NONE else locationComponent.cameraMode = CameraMode.TRACKING // CameraMode.TRACKING makes the camera move and jump to the location setFollowingUser(!followingUserLocation) } } locationManager = requireActivity().getSystemService(Context.LOCATION_SERVICE) as LocationManager if (Permissions.bothLocationPermissionsGranted(requireContext())) { requestInitialUserLocation() } else{ if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)) { //TODO: show dialog for permission rationale Toast.makeText(activity, R.string.enable_position_message_map, Toast.LENGTH_SHORT) .show() } } // Setup close button rootView.findViewById(R.id.btnClose).setOnClickListener { hideStopBottomSheet() } livePositionsViewModel.serviceStatus.observe(viewLifecycleOwner){ status -> //if service is active, update the bus positions icon when(status) { LivePositionsServiceStatus.OK -> setBusPositionsIcon(true, error = false) LivePositionsServiceStatus.NO_POSITIONS -> setBusPositionsIcon(true, error = true) else -> setBusPositionsIcon(true, error = true) } } livePositionsViewModel.useMQTTPositionsLiveData.observe(viewLifecycleOwner){ useMQTT-> + //Log.d(DEBUG_TAG, "Changed MQTT positions, now have to use MQTT: $useMQTT") if (showBusLayer && isResumed) { + //Log.d(DEBUG_TAG, "Deciding to switch, the current source is using MQTT: $usingMQTTPositions") if(useMQTT!=usingMQTTPositions){ // we have to switch val clearPos = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("positions_clear_on_switch_pref", true) livePositionsViewModel.clearOldPositionsUpdates() if(useMQTT){ //switching to MQTT, the GTFS positions are disabled automatically livePositionsViewModel.requestMatoPosUpdates(MQTTMatoClient.LINES_ALL) } else{ //switching to GTFS RT: stop Mato, launch first request livePositionsViewModel.stopMatoUpdates() livePositionsViewModel.requestGTFSUpdates() } + Log.d(DEBUG_TAG, "Should clear positions: $clearPos") if (clearPos) { livePositionsViewModel.clearAllPositions() //force clear of the viewed data clearAllBusPositionsInMap() } } } usingMQTTPositions = useMQTT } Log.d(DEBUG_TAG, "Fragment View Created!") //TODO: Reshow last open stop when switching back to the map fragment return rootView } /** * This method sets up the map and the layers */ override fun onMapReady(mapReady: MapLibreMap) { this.map = mapReady val context = requireContext() val mjson = MapLibreStyles.getJsonStyleFromAsset(context, PreferencesHolder.getMapLibreStyleFile(context)) //ViewUtils.loadJsonFromAsset(requireContext(),"map_style_good.json") val builder = Style.Builder().fromJson(mjson!!) mapReady.setStyle(builder) { style -> mapStyle = style //setupLayers(style) + addImagesStyle(style) - initMapLocation(style, mapReady, requireContext()) + initMapUserLocation(style, mapReady, requireContext()) //init stop layer with this val stopsInCache = stopsViewModel.getAllStopsLoaded() if(stopsInCache.isEmpty()) - initStopsLayer(style, FeatureCollection.fromFeatures(ArrayList())) + initStopsLayer(style, null) else displayStops(stopsInCache) - if(showBusLayer) setupBusLayer(style) + if(showBusLayer) setupBusLayer(style, withLabels = true, busIconsScale = 1.2f) + + initSymbolManager(mapReady, style) // Start observing data now that everything is set up observeStops() } mapReady.addOnCameraIdleListener { isUserMovingCamera = false 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.addOnCameraMoveStartedListener { v-> if(v== MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE){ //the user is moving the map isUserMovingCamera = true } map?.let { setFollowingUser(it.locationComponent.cameraMode == CameraMode.TRACKING) } //setFollowingUser() } mapReady.addOnMapClickListener { point -> onMapClickReact(point) } mapInitCompleted = true // we start requesting the bus positions now observeBusPositionUpdates() //Restoring data var boundsRestored = false pendingLocationActivation = true stopsViewModel.savedState?.let{ boundsRestored = restoreMapStateFromBundle(it) //why are we disabling it? pendingLocationActivation = it.getBoolean(KEY_LOCATION_ENABLED,true) Log.d(DEBUG_TAG, "Restored map state from the saved bundle: ") } if(pendingLocationActivation) positionRequestLauncher.launch(Permissions.LOCATION_PERMISSIONS) //reset saved State at the end if((!boundsRestored)) { //set initial position //center position val latlngTarget = initialStopToShow?.let { LatLng(it.latitude!!, it.longitude!!) } ?: LatLng(DEFAULT_CENTER_LAT, DEFAULT_CENTER_LON) mapReady.cameraPosition = CameraPosition.Builder().target(latlngTarget).zoom(DEFAULT_ZOOM).build() } //reset saved state stopsViewModel.savedState = null } private fun onMapClickReact(point: LatLng): Boolean{ map?.let { mapReady -> val screenPoint = mapReady.projection.toScreenLocation(point) - val features = mapReady.queryRenderedFeatures(screenPoint, STOPS_LAYER_ID) + val stopsFeatures = mapReady.queryRenderedFeatures(screenPoint, STOPS_LAYER_ID) val busNearby = mapReady.queryRenderedFeatures(screenPoint, BUSES_LAYER_ID) - if (features.isNotEmpty()) { - val feature = features[0] + Log.d(DEBUG_TAG, "Clicked on stops: $stopsFeatures \n and buses: $busNearby") + if (stopsFeatures.isNotEmpty()) { + val feature = stopsFeatures[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) + Log.d(DEBUG_TAG, "Decided click is on stop with id $id : $stop") stop?.let { newstop -> val sameStopClicked = shownStopInBottomSheet?.let { newstop.ID==it.ID } ?: false - if (isBottomSheetShowing) { + Log.d(DEBUG_TAG, "Hiding clicked stop: $sameStopClicked") + if (isBottomSheetShowing()) { hideStopBottomSheet() } if(!sameStopClicked){ openStopInBottomSheet(newstop) //isBottomSheetShowing = true //move camera if (newstop.latitude != null && newstop.longitude != null) //mapReady.cameraPosition = CameraPosition.Builder().target(LatLng(it.latitude!!, it.longitude!!)).build() mapReady.animateCamera( CameraUpdateFactory.newLatLng(LatLng(newstop.latitude!!, newstop.longitude!!)), 750 ) } } return true } else if (busNearby.isNotEmpty()) { val feature = busNearby[0] val vehid = feature.getStringProperty("veh") val route = feature.getStringProperty("line") Toast.makeText(context, "Veh $vehid on route $route", Toast.LENGTH_SHORT).show() return true } } return false } - - 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)!!) - style.addImage("ball",ResourcesCompat.getDrawable(resources, R.drawable.ball, activity?.theme)!!) - // Stops layer - val stopsLayer = SymbolLayer(STOPS_LAYER_ID, STOPS_SOURCE_ID) - stopsLayer.withProperties( - PropertyFactory.iconImage(STOP_IMAGE_ID), - PropertyFactory.iconAnchor(ICON_ANCHOR_CENTER), - PropertyFactory.iconAllowOverlap(true), - PropertyFactory.iconIgnorePlacement(true) - ) - - style.addLayerBelow(stopsLayer, "symbol-transit-airfield") //"label_country_1") this with OSM Bright - - - selectedStopSource = GeoJsonSource(SEL_STOP_SOURCE, FeatureCollection.fromFeatures(ArrayList())) - style.addSource(selectedStopSource) - - val selStopLayer = SymbolLayer(SEL_STOP_LAYER, SEL_STOP_SOURCE) - selStopLayer.withProperties( - PropertyFactory.iconImage(STOP_ACTIVE_IMG), - PropertyFactory.iconAllowOverlap(true), - PropertyFactory.iconIgnorePlacement(true), - PropertyFactory.iconAnchor(ICON_ANCHOR_CENTER), - - ) - style.addLayerAbove(selStopLayer, STOPS_LAYER_ID) - - stopsLayerStarted = true - } - - /** - * Setup the Map Layers - */ - private fun setupBusLayer(style: Style) { - // Buses source - busesSource = GeoJsonSource(BUSES_SOURCE_ID) - style.addSource(busesSource) - style.addImage("bus_symbol",ResourcesCompat.getDrawable(resources, R.drawable.map_bus_position_icon, activity?.theme)!!) - - // Buses layer - val busesLayer = SymbolLayer(BUSES_LAYER_ID, BUSES_SOURCE_ID).apply { - withProperties( - PropertyFactory.iconImage("bus_symbol"), - PropertyFactory.iconSize(1.2f), - PropertyFactory.iconAllowOverlap(true), - PropertyFactory.iconIgnorePlacement(true), - PropertyFactory.iconRotate(Expression.get("bearing")), - PropertyFactory.iconRotationAlignment(ICON_ROTATION_ALIGNMENT_MAP), - - PropertyFactory.textAnchor(TEXT_ANCHOR_CENTER), - PropertyFactory.textAllowOverlap(true), - PropertyFactory.textField(Expression.get("line")), - PropertyFactory.textColor(Color.WHITE), - PropertyFactory.textRotationAlignment(TEXT_ROTATION_ALIGNMENT_VIEWPORT), - PropertyFactory.textSize(12f), - PropertyFactory.textFont(arrayOf("noto_sans_regular")) - ) - } - style.addLayerAbove(busesLayer, STOPS_LAYER_ID) - - //Line names layer - /*vehiclesLabelsSource = GeoJsonSource(LABELS_SOURCE) - style.addSource(vehiclesLabelsSource) - val textLayer = SymbolLayer(LABELS_LAYER_ID, LABELS_SOURCE).apply { - withProperties( - PropertyFactory.textField("label"), - PropertyFactory.textSize(30f), - //PropertyFactory.textHaloColor(Color.BLACK), - //PropertyFactory.textHaloWidth(1f), - - PropertyFactory.textAnchor(TEXT_ANCHOR_CENTER), - PropertyFactory.textAllowOverlap(true), - PropertyFactory.textField(Expression.get("line")), - PropertyFactory.textColor(Color.WHITE), - PropertyFactory.textRotationAlignment(TEXT_ROTATION_ALIGNMENT_VIEWPORT), - PropertyFactory.textSize(12f) - - - ) - } - style.addLayerAbove(textLayer, BUSES_LAYER_ID) - - */ - + override fun showOpenStopWithSymbolLayer(): Boolean { + return false } - /** - * Update the bottom sheet with the stop information - */ - override fun openStopInBottomSheet(stop: Stop){ - bottomLayout?.let { - - //lay.findViewById(R.id.stopTitleTextView).text ="${stop.ID} - ${stop.stopDefaultName}" - val stopName = stop.stopUserName ?: stop.stopDefaultName - stopTitleTextView.text = stopName//stop.stopDefaultName - stopNumberTextView.text = getString(R.string.stop_fill,stop.ID) - val string_show = if (stop.numRoutesStopping==0) "" - else requireContext().getString(R.string.lines_fill, stop.routesThatStopHereToString()) - linesPassingTextView.text = string_show - - //SET ON CLICK LISTENER - arrivalsCard.setOnClickListener{ - fragmentListener?.requestArrivalsForStopID(stop.ID) - } - - directionsCard.setOnClickListener { - ViewUtils.openStopInOutsideApp(stop, context) - } - - - } - //add stop marker - if (stop.latitude!=null && stop.longitude!=null) { - /*stopActiveSymbol = symbolManager.create( - SymbolOptions() - .withLatLng(LatLng(stop.latitude!!, stop.longitude!!)) - .withIconImage(STOP_ACTIVE_IMG) - .withIconAnchor(ICON_ANCHOR_CENTER) - //.withTextFont(arrayOf("noto_sans_regular"))) - */ - Log.d(DEBUG_TAG, "Showing stop: ${stop.ID}") - val list = ArrayList() - list.add(stopToGeoJsonFeature(stop)) - selectedStopSource.setGeoJson( - FeatureCollection.fromFeatures(list) - ) - } - shownStopInBottomSheet = stop - bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED - isBottomSheetShowing = true - } override fun onAttach(context: Context) { super.onAttach(context) fragmentListener = if (context is CommonFragmentListener) { context } else { throw RuntimeException( context.toString() + " must implement FragmentListenerMain" ) } } override fun onDetach() { super.onDetach() fragmentListener = null } override fun onStart() { super.onStart() //restore state from viewModel stopsViewModel.savedState?.let { restoreMapStateFromBundle(it) //reset state stopsViewModel.savedState = null } } override fun onResume() { super.onResume() //mapView.onResume() handled in GeneralMapLibreFragment //val keySourcePositions = getString(R.string.pref_positions_source) if(showBusLayer) { //first, clean up all the old positions livePositionsViewModel.clearOldPositionsUpdates() if (livePositionsViewModel.useMQTTPositionsLiveData.value!!){ livePositionsViewModel.requestMatoPosUpdates(MQTTMatoClient.LINES_ALL) usingMQTTPositions = true } else { livePositionsViewModel.requestGTFSUpdates() usingMQTTPositions = false } //mapViewModel.testCascade(); livePositionsViewModel.isLastWorkResultGood.observe(this) { d: Boolean -> Log.d( DEBUG_TAG, "Last trip download result is $d" ) } livePositionsViewModel.tripsGtfsIDsToQuery.observe(this) { dat: List -> Log.i(DEBUG_TAG, "Have these trips IDs missing from the DB, to be queried: $dat") livePositionsViewModel.downloadTripsFromMato(dat) } } fragmentListener?.readyGUIfor(FragmentKind.MAP) //restore saved state savedMapStateOnPause?.let { restoreMapStateFromBundle(it) } } override fun onPause() { super.onPause() mapView.onPause() Log.d(DEBUG_TAG, "Fragment paused") savedMapStateOnPause = saveMapStateInBundle() if (livePositionsViewModel.useMQTTPositionsLiveData.value!!) livePositionsViewModel.stopMatoUpdates() } override fun onStop() { super.onStop() mapView.onStop() Log.d(DEBUG_TAG, "Fragment stopped!") stopsViewModel.savedState = Bundle().let { mapView.onSaveInstanceState(it) it } //save last location map?.locationComponent?.lastKnownLocation?.let{ stopsViewModel.lastUserLocation = it } } override fun onMapDestroy() { mapStyle.removeLayer(STOPS_LAYER_ID) mapStyle.removeSource(STOPS_SOURCE_ID) mapStyle.removeLayer(BUSES_LAYER_ID) mapStyle.removeSource(BUSES_SOURCE_ID) //map?.locationComponent?.isLocationComponentEnabled = false } override fun getBaseViewForSnackBar(): View? { return mapView } private fun observeStops() { // Observe stops stopsViewModel.stopsToShow.observe(viewLifecycleOwner) { stops -> stopsShowing = ArrayList(stops) displayStops(stopsShowing) initialStopToShow?.let{ s-> //show the stop in the bottom sheet if(!initialStopShown && (s.ID in stopsShowing.map { it.ID })) { openStopInBottomSheet(s) initialStopShown = true } } } } /** * 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, have same number of stops. After 3 times") return } /*if(stops.size> lastStopsSizeShown){ stopsRedrawnTimes = 0 } else{ stopsRedrawnTimes++ } */ 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(stopToGeoJsonFeature(s)) } 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 (stopsLayerStarted) { stopsSource.setGeoJson(FeatureCollection.fromFeatures(features)) lastStopsSizeShown = features.size } else map?.let { Log.d(DEBUG_TAG, "Map stop layer is not started yet, init layer") initStopsLayer(mapStyle, FeatureCollection.fromFeatures(features)) Log.d(DEBUG_TAG,"Started stops layer on map") lastStopsSizeShown = features.size stopsLayerStarted = true } } - // Hide the bottom sheet and remove extra symbol - private fun hideStopBottomSheet(){ - /*if (stopActiveSymbol!=null){ - symbolManager.delete(stopActiveSymbol) - stopActiveSymbol = null - } - */ - //empty the source - selectedStopSource.setGeoJson(FeatureCollection.fromFeatures(ArrayList())) - bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN - //remove initial stop - if(initialStopToShow!=null){ - initialStopToShow = null - } - //set showing - isBottomSheetShowing = false - shownStopInBottomSheet = null - } // --------------- BUS LOCATIONS STUFF -------------------------- /** * Start requesting position updates */ private fun observeBusPositionUpdates() { livePositionsViewModel.updatesWithTripAndPatterns.observe(viewLifecycleOwner) { data: HashMap> -> Log.d( DEBUG_TAG, "Have " + data.size + " trip updates, has Map start finished: " + mapInitCompleted ) if (mapInitCompleted) updateBusPositionsInMap(data) if (!isDetached && !livePositionsViewModel.useMQTTPositionsLiveData.value!!) livePositionsViewModel.requestDelayedGTFSUpdates( 3000 ) } } - private fun isInsideVisibleRegion(latitude: Double, longitude: Double, nullValue: Boolean): Boolean{ - var isInside = nullValue - val visibleRegion = map?.projection?.visibleRegion - visibleRegion?.let { - val bounds = it.latLngBounds - isInside = bounds.contains(LatLng(latitude, longitude)) - } - return isInside - } + /*private fun createLabelForVehicle(positionUpdate: LivePositionUpdate){ val symOpt = SymbolOptions() .withLatLng(LatLng(positionUpdate.latitude, positionUpdate.longitude)) .withTextColor("#ffffff") .withTextField(positionUpdate.routeID.substringBeforeLast('U')) .withTextSize(13f) .withTextAnchor(TEXT_ANCHOR_CENTER) .withTextFont(arrayOf( "noto_sans_regular"))//"noto_sans_regular", "sans-serif")) //"noto_sans_regular")) val newSymbol = symbolManager.create(symOpt ) Log.d(DEBUG_TAG, "Symbol for veh ${positionUpdate.vehicle}: $newSymbol") busLabelSymbolsByVeh[positionUpdate.vehicle] = newSymbol } private fun removeVehicleLabel(vehicle: String){ busLabelSymbolsByVeh[vehicle]?.let { symbolManager.delete(it) busLabelSymbolsByVeh.remove(vehicle) } } */ - /** - * Update function for the bus positions - * Takes the processed updates and saves them accordingly - */ - private fun updateBusPositionsInMap(incomingData: HashMap>){ - val vehsNew = HashSet(incomingData.values.map { up -> up.first.vehicle }) - val vehsOld = HashSet(positionsByVehDict.keys) - - val symbolsToUpdate = ArrayList() - for (upsWithTrp in incomingData.values){ - val newPos = upsWithTrp.first - val vehID = newPos.vehicle - //var animate = false - if (vehsOld.contains(vehID)){ - //update position only if the starting or the stopping position of the animation are in the view - val oldPos = positionsByVehDict[vehID] - var avoidShowingUpdateBecauseIsImpossible = false - oldPos?.let{ - if(oldPos.routeID!=newPos.routeID) { - val dist = LatLng(it.latitude, it.longitude).distanceTo(LatLng(newPos.latitude, newPos.longitude)) - val speed = dist*3.6 / (newPos.timestamp - it.timestamp) //this should be in km/h - Log.w(DEBUG_TAG, "Vehicle $vehID changed route from ${oldPos.routeID} to ${newPos.routeID}, distance: $dist, speed: $speed") - if (speed > 120 || speed < 0){ - avoidShowingUpdateBecauseIsImpossible = true - } - } - } - if (avoidShowingUpdateBecauseIsImpossible){ - // DO NOT SHOW THIS SHIT - Log.w(DEBUG_TAG, "Update for vehicle $vehID skipped") - continue - } - - val samePosition = oldPos?.let { (oldPos.latitude==newPos.latitude)&&(oldPos.longitude == newPos.longitude) }?:false - - if(!samePosition) { - val isPositionInBounds = isInsideVisibleRegion( - newPos.latitude, newPos.longitude, false - ) || (oldPos?.let { isInsideVisibleRegion(it.latitude,it.longitude, false) } ?: false) - if ((newPos.bearing==null && oldPos?.bearing!=null)){ - //copy old bearing - newPos.bearing = oldPos.bearing - } - if (isPositionInBounds) { - //animate = true - //this moves both the icon and the label - moveVehicleToNewPosition(newPos) - } else { - - positionsByVehDict[vehID] = newPos - /*busLabelSymbolsByVeh[vehID]?.let { - it.latLng = LatLng(pos.latitude, pos.longitude) - symbolsToUpdate.add(it) - } - - */ - } - } - } - else if(newPos.latitude>0 && newPos.longitude>0) { - //we should not have to check for this - // update it simply - positionsByVehDict[vehID] = newPos - //createLabelForVehicle(pos) - }else{ - Log.w(DEBUG_TAG, "Update ignored for veh $vehID on line ${newPos.routeID}, lat: ${newPos.latitude}, lon ${newPos.longitude}") - } - - } - // symbolManager.update(symbolsToUpdate) - //remove old positions - vehsOld.removeAll(vehsNew) - //now vehsOld contains the vehicles id for those that have NOT been updated - val currentTimeStamp = System.currentTimeMillis() /1000 - for(vehID in vehsOld){ - //remove after 2 minutes of inactivity - if (positionsByVehDict[vehID]!!.timestamp - currentTimeStamp > 2*60){ - positionsByVehDict.remove(vehID) - //removeVehicleLabel(vehID) - } - } - //finally, update UI - updatePositionsIcons() - } - - /** - * This is the tricky part, animating the transitions - * Basically, we need to set the new positions with the data and redraw them all - */ - private fun moveVehicleToNewPosition(positionUpdate: LivePositionUpdate){ - if (positionUpdate.vehicle !in positionsByVehDict.keys) - return - val vehID = positionUpdate.vehicle - val currentUpdate = positionsByVehDict[positionUpdate.vehicle] - currentUpdate?.let { it -> - //cancel current animation on vehicle - animatorsByVeh[vehID]?.cancel() - - val currentPos = LatLng(it.latitude, it.longitude) - val newPos = LatLng(positionUpdate.latitude, positionUpdate.longitude) - val valueAnimator = ValueAnimator.ofObject(MapLibreUtils.LatLngEvaluator(), currentPos, newPos) - valueAnimator.addUpdateListener(object : ValueAnimator.AnimatorUpdateListener { - private var latLng: LatLng? = null - override fun onAnimationUpdate(animation: ValueAnimator) { - latLng = animation.animatedValue as LatLng - //update position on animation - val update = positionsByVehDict[positionUpdate.vehicle]!! - latLng?.let { ll-> - update.latitude = ll.latitude - update.longitude = ll.longitude - updatePositionsIcons() - } - } - }) - valueAnimator.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationStart(animation: Animator) { - super.onAnimationStart(animation) - //val update = positionsByVehDict[positionUpdate.vehicle]!! - //remove the label at the start of the animation - /*val annot = busLabelSymbolsByVeh[vehID] - annot?.let { sym -> - sym.textOpacity = 0.0f - symbolsToUpdate.add(sym) - } - - */ - - } - - override fun onAnimationEnd(animation: Animator) { - super.onAnimationEnd(animation) - //recreate the label at the end of the animation - //createLabelForVehicle(positionUpdate) - /*val annot = busLabelSymbolsByVeh[vehID] - annot?.let { sym -> - sym.textOpacity = 1.0f - sym.latLng = newPos //LatLng(newPos) - symbolsToUpdate.add(sym) - } - - */ - } - }) - - //set the new position as the current one but with the old lat and lng - positionUpdate.latitude = currentUpdate.latitude - positionUpdate.longitude = currentUpdate.longitude - positionsByVehDict[vehID] = positionUpdate - valueAnimator.duration = 300 - valueAnimator.interpolator = LinearInterpolator() - valueAnimator.start() - - animatorsByVeh[vehID] = valueAnimator - - } ?: { - Log.e(DEBUG_TAG, "Have to run animation for veh ${positionUpdate.vehicle} but not in the dict, adding") - positionsByVehDict[positionUpdate.vehicle] = positionUpdate - } - } - - /** - * Update the bus positions displayed on the map, from the existing data - */ - private fun updatePositionsIcons(){ - //avoid frequent updates - val currentTime = System.currentTimeMillis() - //throttle updates when user is moving camera - val interval = if(isUserMovingCamera) 150 else 60 - val shouldDelayUpdateDraw = currentTime - lastUpdateTime < interval - if(shouldDelayUpdateDraw){ - //Defer map update - viewLifecycleOwner.lifecycleScope.launch { - delay(200) - updatePositionsIcons() - } - return - } - val features = ArrayList()//stops.mapNotNull { stop -> - //stop.latitude?.let { lat -> - // stop.longitude?.let { lon -> - for (pos in positionsByVehDict.values){ - //if (s.latitude!=null && s.longitude!=null) - val point = Point.fromLngLat(pos.longitude, pos.latitude) - features.add( - Feature.fromGeometry( - point, - JsonObject().apply { - addProperty("veh", pos.vehicle) - addProperty("trip", pos.tripID) - addProperty("bearing", pos.bearing ?:0.0f) - addProperty("line", pos.routeID.substringBeforeLast('U')) - } - ) - ) - /*busLabelSymbolsByVeh[pos.vehicle]?.let { - it.latLng = LatLng(pos.latitude, pos.longitude) - symbolsToUpdate.add(it) - } - - */ - } - //this updates the positions - busesSource.setGeoJson(FeatureCollection.fromFeatures(features)) - //update labels, clear cache to be used - //symbolManager.update(symbolsToUpdate) - symbolsToUpdate.clear() - lastUpdateTime = System.currentTimeMillis() - } // ------ LOCATION STUFF ----- @SuppressLint("MissingPermission") private fun requestInitialUserLocation() { val provider : String = LocationManager.GPS_PROVIDER//getBestLocationProvider() //provider.let { setLocationIconEnabled(true) Toast.makeText(requireContext(), R.string.position_searching_message, Toast.LENGTH_SHORT).show() locationManager.requestSingleUpdate(provider, object : LocationListener { override fun onLocationChanged(location: Location) { val userLatLng = LatLng(location.latitude, location.longitude) val distanceToTarget = userLatLng.distanceTo(DEFAULT_LATLNG) if (distanceToTarget <= MAX_DIST_KM*1000.0) { map?.let{ // if we are still waiting for the position to enable if(pendingLocationActivation) setMapLocationEnabled(true, true, false) } } else { Toast.makeText(context, R.string.too_far_not_showing_location, Toast.LENGTH_SHORT).show() setMapLocationEnabled(false,false, false) } } override fun onProviderDisabled(provider: String) {} override fun onProviderEnabled(provider: String) {} @Deprecated("Deprecated in Java") override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} }, null) } /** * Clear all buses from the map */ private fun clearAllBusPositionsInMap(){ for ((k, anim) in animatorsByVeh){ anim.cancel() } animatorsByVeh.clear() - positionsByVehDict.clear() - updatePositionsIcons() - } - - /** - * Initialize the map location, but do not enable the component - */ - @SuppressLint("MissingPermission") - private fun initMapLocation(style: Style, map: MapLibreMap, context: Context){ - locationComponent = map.locationComponent - val locationComponentOptions = - LocationComponentOptions.builder(context) - .pulseEnabled(true) - .build() - val locationComponentActivationOptions = - MapLibreUtils.buildLocationComponentActivationOptions(style, locationComponentOptions, context) - locationComponent.activateLocationComponent(locationComponentActivationOptions) - locationComponent.isLocationComponentEnabled = false - - lastLocation?.let { - if (it.accuracy < 200) - locationComponent.forceLocationUpdate(it) - } + updatesByVehDict.clear() + updatePositionsIcons(forced = false) } /** * Handles logic of enabling the user location on the map */ @SuppressLint("MissingPermission") private fun setMapLocationEnabled(enabled: Boolean, assumePermissions: Boolean, fromClick: Boolean) { if (enabled) { val permissionOk = assumePermissions || Permissions.bothLocationPermissionsGranted(requireContext()) if (permissionOk) { Log.d(DEBUG_TAG, "Permission OK, starting location component, assumed: $assumePermissions, fromClick: $fromClick") locationComponent.isLocationComponentEnabled = true if (initialStopToShow==null) { locationComponent.cameraMode = CameraMode.TRACKING //CameraMode.TRACKING setFollowingUser(true) } setLocationIconEnabled(true) if (fromClick) Toast.makeText(context, R.string.location_enabled, Toast.LENGTH_SHORT).show() pendingLocationActivation =false //locationComponent.locationEngine.requestLocationUpdates() } else { if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)) { //TODO: show dialog for permission rationale Toast.makeText(activity, R.string.enable_position_message_map, Toast.LENGTH_SHORT).show() } Log.d(DEBUG_TAG, "Requesting permission to show user location") enablingPositionFromClick = fromClick showUserPositionRequestLauncher.launch(Permissions.LOCATION_PERMISSIONS) } } else{ locationComponent.isLocationComponentEnabled = false setFollowingUser(false) setLocationIconEnabled(false) if (fromClick) { Toast.makeText(requireContext(), R.string.location_disabled, Toast.LENGTH_SHORT).show() if(pendingLocationActivation) pendingLocationActivation=false //Cancel the request for the enablement of the position } } } private fun setLocationIconEnabled(enabled: Boolean){ if (enabled) showUserPositionButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_red)) else showUserPositionButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_grey)) } /** * Helper method for GUI */ private fun setBusPositionsIcon(enabled: Boolean, error: Boolean){ val ctx = requireContext() if(!enabled) busPositionsIconButton.setImageDrawable(ContextCompat.getDrawable(ctx, R.drawable.bus_pos_circle_inactive)) else if(error) busPositionsIconButton.setImageDrawable(ContextCompat.getDrawable(ctx, R.drawable.bus_pos_circle_notworking)) else busPositionsIconButton.setImageDrawable(ContextCompat.getDrawable(ctx, R.drawable.bus_pos_circle_active)) } private fun updateFollowingIcon(enabled: Boolean){ if(enabled) followUserButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.walk_circle_active)) else followUserButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.walk_circle_inactive)) } private fun setFollowingUser(following: Boolean){ updateFollowingIcon(following) followingUserLocation = following if(following) ignoreCameraMovementForFollowing = true } private fun switchUserLocationStatus(view: View?){ if(pendingLocationActivation || locationComponent.isLocationComponentEnabled) setMapLocationEnabled(false, false, true) else{ pendingLocationActivation = true Log.d(DEBUG_TAG, "Request enable location") setMapLocationEnabled(true, false, true) } } + 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 LABELS_LAYER_ID = "bus-labels-layer" private const val LABELS_SOURCE = "labels-source" private const val STOP_IMAGE_ID ="bus-stop-icon" const val DEFAULT_CENTER_LAT = 45.0708 const val DEFAULT_CENTER_LON = 7.6858 private val DEFAULT_LATLNG = LatLng(DEFAULT_CENTER_LAT, DEFAULT_CENTER_LON) private val DEFAULT_ZOOM = 14.3 private const val POSITION_FOUND_ZOOM = 16.5 private const val NO_POSITION_ZOOM = 17.1 private const val MAX_DIST_KM = 90.0 private const val DEBUG_TAG = "BusTO-MapLibreFrag" private const val STOP_ACTIVE_IMG = "Stop-active" const val FRAGMENT_TAG = "BusTOMapFragment" private const val LOCATION_PERMISSION_REQUEST_CODE = 981202 /** * Use this factory method to create a new instance of * this fragment using the provided parameters. * * @param stop Eventual stop to center the map into * @return A new instance of fragment MapLibreFragment. */ @JvmStatic fun newInstance(stop: Stop?) = MapLibreFragment().apply { arguments = Bundle().let { // Cannot use Parcelable as it requires higher version of Android //stop?.let{putParcelable(STOP_TO_SHOW, it)} stop?.toBundle(it) } } } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/viewmodels/LivePositionsViewModel.kt b/app/src/main/java/it/reyboz/bustorino/viewmodels/LivePositionsViewModel.kt index 99475ac..69f3020 100644 --- a/app/src/main/java/it/reyboz/bustorino/viewmodels/LivePositionsViewModel.kt +++ b/app/src/main/java/it/reyboz/bustorino/viewmodels/LivePositionsViewModel.kt @@ -1,479 +1,479 @@ /* BusTO - ViewModel components Copyright (C) 2023 Fabio Mazza This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package it.reyboz.bustorino.viewmodels import android.app.Application import android.util.Log import androidx.lifecycle.* import androidx.preference.PreferenceManager import androidx.work.WorkInfo import androidx.work.WorkManager import com.android.volley.DefaultRetryPolicy import it.reyboz.bustorino.R import it.reyboz.bustorino.backend.Fetcher import it.reyboz.bustorino.backend.LivePositionsServiceStatus import it.reyboz.bustorino.backend.NetworkVolleyManager import it.reyboz.bustorino.backend.gtfs.GtfsRtPositionsRequest import it.reyboz.bustorino.backend.gtfs.GtfsUtils import it.reyboz.bustorino.backend.gtfs.LivePositionUpdate import it.reyboz.bustorino.backend.mato.MQTTMatoClient import it.reyboz.bustorino.backend.mato.PositionsMap import it.reyboz.bustorino.data.GtfsRepository import it.reyboz.bustorino.data.MatoPatternsDownloadWorker import it.reyboz.bustorino.data.MatoTripsDownloadWorker import it.reyboz.bustorino.data.gtfs.MatoPattern import it.reyboz.bustorino.data.gtfs.TripAndPatternWithStops import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.* import kotlin.collections.ArrayList import kotlin.collections.HashMap import kotlin.collections.HashSet import androidx.core.content.edit import androidx.lifecycle.MutableLiveData typealias FullPositionUpdatesMap = HashMap> typealias FullPositionUpdate = Pair class LivePositionsViewModel(application: Application): AndroidViewModel(application) { private val gtfsRepo = GtfsRepository(application) //chain of LiveData objects: raw positions -> tripsIDs -> tripsAndPatternsInDB -> positions with patterns //this contains the raw positions updates received from the service private val positionsToBeMatchedLiveData = MutableLiveData>() private val netVolleyManager = NetworkVolleyManager.getInstance(application) private var mqttClient = MQTTMatoClient() private var lineListening = "" private var lastTimeMQTTUpdatedPositions: Long = 0 private val gtfsRtRequestRunning = MutableLiveData(false) private val lastFailedTripsRequest = HashMap() private val workManager = WorkManager.getInstance(application) private var lastRequestedDownloadTrips = MutableLiveData>() //INPUT FILTER FOR LINE private var gtfsLineToFilterPos = MutableLiveData>() var serviceStatus = MutableLiveData(LivePositionsServiceStatus.CONNECTING) private val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(application) private val keySourcePositions = application.getString(R.string.pref_positions_source) private val LIVE_POS_PREF_MQTT : String private val LIVE_POS_PREF_GTFSRT :String val useMQTTPositionsLiveData: MutableLiveData init { sharedPrefs.registerOnSharedPreferenceChangeListener { shp, key -> if(key == keySourcePositions) { val newV = shp.getString(keySourcePositions, LIVE_POS_PREF_MQTT) useMQTTPositionsLiveData.postValue(newV.equals(LIVE_POS_PREF_MQTT)) Log.d(DEBUG_TI, "Changed position source to: $newV") } } LIVE_POS_PREF_MQTT = application.getString(R.string.positions_source_mqtt) LIVE_POS_PREF_GTFSRT = application.getString(R.string.positions_source_gtfsrt) useMQTTPositionsLiveData = MutableLiveData(isMQTTPositionsSelected()) } private fun isMQTTPositionsSelected(): Boolean{ val source = sharedPrefs.getString(keySourcePositions, LIVE_POS_PREF_MQTT) val useMQTT=source == LIVE_POS_PREF_MQTT Log.d(DEBUG_TI, "Init positions, source: $source, isMQTT: $useMQTT") return useMQTT } /** * Switch provider of live positions from MQTT to GTFSRT and viceversa */ fun switchPositionsSource(){ val usingMQTT = useMQTTPositionsLiveData.value!! //code that was in the MapLibreFragment useMQTTPositionsLiveData.value = !usingMQTT sharedPrefs.edit(commit = true) { putString( keySourcePositions, if (usingMQTT) LIVE_POS_PREF_GTFSRT else LIVE_POS_PREF_MQTT ) } - Log.d(DEBUG_TI, "Switched positions source in ViewModel, using MQTT: ${!usingMQTT}") + Log.d(DEBUG_TI, "Switched positions source in ViewModel, now using MQTT: ${!usingMQTT}") serviceStatus.value = LivePositionsServiceStatus.CONNECTING } fun setGtfsLineToFilterPos(line: String, pattern: MatoPattern?){ gtfsLineToFilterPos.value = Pair(line, pattern) } var isLastWorkResultGood = workManager .getWorkInfosForUniqueWorkLiveData(MatoTripsDownloadWorker.TAG_TRIPS).map { it -> if (it.isEmpty()) return@map false var res = true if(it[0].state == WorkInfo.State.FAILED){ val currDate = Date() res = false lastRequestedDownloadTrips.value?.let { trips-> for(tr in trips){ lastFailedTripsRequest[tr] = currDate } } } return@map res } /** * Responder to the MQTT Client */ private val matoPositionListener = object: MQTTMatoClient.Companion.MQTTMatoListener{ override fun onUpdateReceived(it: PositionsMap) { val mupds = ArrayList() if(lineListening==MQTTMatoClient.LINES_ALL){ for(sdic in it.values){ for(update in sdic.values){ mupds.add(update) } } } else{ //we're listening to one if (it.containsKey(lineListening.trim()) ){ for(up in it[lineListening]?.values!!){ mupds.add(up) } } } //avoid updating the positions too often (limit to 0.5 seconds) val time = System.currentTimeMillis() if(lastTimeMQTTUpdatedPositions == (0.toLong()) || (time-lastTimeMQTTUpdatedPositions)>500){ positionsToBeMatchedLiveData.postValue(mupds) lastTimeMQTTUpdatedPositions = time } //we have received an update, so set the status to OK serviceStatus.postValue(LivePositionsServiceStatus.OK) } override fun onStatusUpdate(status: LivePositionsServiceStatus) { serviceStatus.postValue(status) } } //find the trip IDs in the updates private val tripsIDsInUpdates = positionsToBeMatchedLiveData.map { it -> //Log.d(DEBUG_TI, "Updates map has keys ${upMap.keys}") it.map { pos -> "gtt:"+pos.tripID } } // get the trip IDs in the DB private val gtfsTripsPatternsInDB = tripsIDsInUpdates.switchMap { //Log.i(DEBUG_TI, "tripsIds in updates: ${it.size}") gtfsRepo.gtfsDao.getTripPatternStops(it) } //trip IDs to query, which are not present in the DB //REMEMBER TO OBSERVE THIS IN THE MAP val tripsGtfsIDsToQuery: LiveData> = gtfsTripsPatternsInDB.map { tripswithPatterns -> val tripNames=tripswithPatterns.map { twp-> twp.trip.tripID } Log.i(DEBUG_TI, "Have ${tripswithPatterns.size} trips in the DB") if (tripsIDsInUpdates.value!=null) return@map tripsIDsInUpdates.value!!.filter { !(tripNames.contains(it) || it.contains("null"))} else { Log.e(DEBUG_TI,"Got results for gtfsTripsInDB but not tripsIDsInUpdates??") return@map ArrayList() } } /** * This livedata object contains the final updates with patterns present in the DB */ val updatesWithTripAndPatterns = gtfsTripsPatternsInDB.map { tripPatterns-> //TODO: Change the mapping in the final updates, I don't know why the key is the tripID and not the vehicle ID Log.i(DEBUG_TI, "Mapping trips and patterns") val mdict = HashMap() //missing patterns val routesToDownload = HashSet() if(positionsToBeMatchedLiveData.value!=null) for(update in positionsToBeMatchedLiveData.value!!){ val trID:String = update.tripID var found = false for(trip in tripPatterns){ if (trip.pattern == null){ //pattern is null, which means we have to download // the pattern data from MaTO routesToDownload.add(trip.trip.routeID) } if (trip.trip.tripID == "gtt:$trID"){ found = true //insert directly mdict[trID] = Pair(update,trip) break } } if (!found){ //Log.d(DEBUG_TI, "Cannot find pattern ${tr}") //give the update anyway mdict[trID] = Pair(update,null) } } //have to request download of missing Patterns if (routesToDownload.isNotEmpty()){ Log.d(DEBUG_TI, "Have ${routesToDownload.size} missing patterns from the DB: $routesToDownload") //downloadMissingPatterns (ArrayList(routesToDownload)) MatoPatternsDownloadWorker.downloadPatternsForRoutes(routesToDownload.toList(), getApplication()) } return@map mdict } fun clearOldPositionsUpdates(){ //RETURN if the map is null val positionsOld = positionsToBeMatchedLiveData.value ?: return val currentTimeSecs = (System.currentTimeMillis() / 1000 ) val updatedList = ArrayList() for (up in positionsOld){ //If the time has passed, remove it if (currentTimeSecs - up.timestamp <= MAX_MINUTES_CLEAR_POSITIONS*60) //TODO decide time limit in minutes updatedList.add(up) } val diff = positionsOld.size - updatedList.size Log.d(DEBUG_TI, "Removed ${diff} positions marked as old") // Re-trigger all the LiveData chain positionsToBeMatchedLiveData.value = updatedList } fun clearAllPositions(){ positionsToBeMatchedLiveData.postValue(ArrayList()) Log.d(DEBUG_TI, "Cleared all positions in LivePositionsViewModel") } //OBSERVE THIS TO GET THE LOCATION UPDATES FILTERED val filteredLocationUpdates = MediatorLiveData>>() init { filteredLocationUpdates.addSource(updatesWithTripAndPatterns){ filteredLocationUpdates.postValue(filterUpdatesForGtfsLine(it, gtfsLineToFilterPos.value!!)) } filteredLocationUpdates.addSource(gtfsLineToFilterPos){ //Log.d(DEBUG_TI, "line to filter change to: ${gtfsLineToFilterPos.value}") updatesWithTripAndPatterns.value?.let{ ups-> filteredLocationUpdates.postValue(filterUpdatesForGtfsLine(ups, it)) //Log.d(DEBUG_TI, "Set ${ups.size} updates as new value for filteredLocation") } } } private fun filterUpdatesForGtfsLine(updates: FullPositionUpdatesMap, linePatt: Pair): Pair, List>{ val gtfsLineId = linePatt.first val pattern = linePatt.second val updsForTripId = HashMap>() val vehicleOnWrongDirection = mutableListOf() //supporting the eventual null case when there is no need to filter if (gtfsLineId == "ALL"){ //copy the dict for ((tripId, pair) in updates.entries) { updsForTripId[tripId] = pair } } else { val filtdLineID = GtfsUtils.stripGtfsPrefix(gtfsLineId) //filter buses with direction, show those only with the same direction val directionId = pattern?.directionId ?: -100 val numUpds = updates.entries.size Log.d( DEBUG_TI, "Got $numUpds updates, current pattern is: ${pattern?.name}, directionID: ${pattern?.directionId}" ) // cannot understand where this is used //val patternsDirections = HashMap() for ((tripId, pair) in updates.entries) { //remove trips with wrong line val posUp = pair.first val vehicle = pair.first.vehicle if (pair.first.routeID != filtdLineID) continue if (directionId != -100 && pair.second != null && pair.second?.pattern != null) { val dir = pair.second!!.pattern!!.directionId if (dir == directionId) { //add the trip updsForTripId[tripId] = pair } else { vehicleOnWrongDirection.add(vehicle) } //patternsDirections[tripId] = dir ?: -10 } else { updsForTripId[tripId] = pair //Log.d(DEBUG_TAG, "No pattern for tripID: $tripId") //patternsDirections[tripId] = -10 } } } Log.d(DEBUG_TI, "Filtered updates are ${updsForTripId.keys.size}") // Original updates directs: $patternsDirections\n return Pair(updsForTripId, vehicleOnWrongDirection) } fun requestMatoPosUpdates(line: String){ lineListening = line viewModelScope.launch { mqttClient.startAndSubscribe(line,matoPositionListener, getApplication()) //clear old positions (useful when we are coming back to the map after some time) mqttClient.clearOldPositions(MAX_MINUTES_CLEAR_POSITIONS) } //updatePositions(1000) } fun stopMatoUpdates(){ viewModelScope.launch { val tt = System.currentTimeMillis() mqttClient.stopMatoRequests(matoPositionListener) val time = System.currentTimeMillis() -tt Log.d(DEBUG_TI, "Took $time ms to unsubscribe") } } fun retriggerPositionUpdate(){ if(positionsToBeMatchedLiveData.value!=null){ positionsToBeMatchedLiveData.postValue(positionsToBeMatchedLiveData.value) } } //Gtfs Real time private val gtfsPositionsReqListener = object: GtfsRtPositionsRequest.Companion.RequestListener{ override fun onResponse(response: ArrayList?) { Log.i(DEBUG_TI,"Got response from the GTFS RT server") if (response == null){ serviceStatus.postValue(LivePositionsServiceStatus.ERROR_CONNECTION) } else response.let { it:ArrayList -> val ss: LivePositionsServiceStatus if (it.size == 0) { Log.w(DEBUG_TI,"No position updates from the GTFS RT server") ss = LivePositionsServiceStatus.NO_POSITIONS } else { //Log.i(DEBUG_TI, "Posting value to positionsLiveData") viewModelScope.launch { positionsToBeMatchedLiveData.postValue(it) } ss = LivePositionsServiceStatus.OK } serviceStatus.postValue(ss) } gtfsRtRequestRunning.postValue(false) } } /** * Listener for the errors in downloading positions from GTFS RT */ private val positionRequestErrorListener = GtfsRtPositionsRequest.Companion.ErrorListener { Log.e(DEBUG_TI, "Could not download the update", it) gtfsRtRequestRunning.postValue(false) if(it is GtfsRtPositionsRequest.RequestError){ val status = when(it.result) { Fetcher.Result.OK -> LivePositionsServiceStatus.OK Fetcher.Result.PARSER_ERROR -> LivePositionsServiceStatus.ERROR_PARSING_RESPONSE Fetcher.Result.SERVER_ERROR_404 -> LivePositionsServiceStatus.ERROR_NETWORK_RESPONSE Fetcher.Result.SERVER_ERROR -> LivePositionsServiceStatus.ERROR_NETWORK_RESPONSE Fetcher.Result.CONNECTION_ERROR -> LivePositionsServiceStatus.ERROR_CONNECTION else -> LivePositionsServiceStatus.ERROR_CONNECTION } serviceStatus.postValue(status) } else serviceStatus.postValue(LivePositionsServiceStatus.ERROR_NETWORK_RESPONSE) } fun requestGTFSUpdates(){ if(gtfsRtRequestRunning.value == null || !gtfsRtRequestRunning.value!!) { val request = GtfsRtPositionsRequest(positionRequestErrorListener, gtfsPositionsReqListener) request.setRetryPolicy( DefaultRetryPolicy(1000,10,DefaultRetryPolicy.DEFAULT_BACKOFF_MULT) ) netVolleyManager.requestQueue.add(request) Log.i(DEBUG_TI, "Requested GTFS realtime position updates") gtfsRtRequestRunning.value = true } } fun requestDelayedGTFSUpdates(timems: Long){ viewModelScope.launch { delay(timems) requestGTFSUpdates() } } override fun onCleared() { //stop the MQTT Service Log.d(DEBUG_TI, "Clearing the live positions view model, stopping the mqttClient") mqttClient.disconnect() super.onCleared() } //Request trips download fun downloadTripsFromMato(trips: List): Boolean{ if(trips.isEmpty()) return false var shouldContinue = false val currentDateTime = Date().time for (tr in trips){ if (!lastFailedTripsRequest.containsKey(tr)){ shouldContinue = true break } else{ //Log.i(DEBUG_TI, "Last time the trip has failed is ${lastFailedTripsRequest[tr]}") if ((lastFailedTripsRequest[tr]!!.time - currentDateTime) > MAX_TIME_RETRY){ shouldContinue =true break } } } if (shouldContinue) { //if one trip val workRequ =MatoTripsDownloadWorker.requestMatoTripsDownload(trips, getApplication(), "BusTO-MatoTripsDown") workRequ?.let { req -> Log.d(DEBUG_TI, "Enqueueing new work, saving work info") lastRequestedDownloadTrips.postValue(trips) //isLastWorkResultGood = } } else{ Log.w(DEBUG_TI, "Requested to fetch data for ${trips.size} trips but they all have failed before in the last $MAX_MINUTES_RETRY mins") } return shouldContinue } companion object{ private const val DEBUG_TI = "BusTO-LivePosViewModel" private const val MAX_MINUTES_RETRY = 3 private const val MAX_TIME_RETRY = MAX_MINUTES_RETRY*60*1000 //3 minutes (in milliseconds) public const val MAX_MINUTES_CLEAR_POSITIONS = 8 } } \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_lines_detail.xml b/app/src/main/res/layout/fragment_lines_detail.xml index d1b9ec4..16e6779 100644 --- a/app/src/main/res/layout/fragment_lines_detail.xml +++ b/app/src/main/res/layout/fragment_lines_detail.xml @@ -1,280 +1,155 @@ - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_map_libre.xml b/app/src/main/res/layout/fragment_map_libre.xml index a77bc0e..3af205f 100644 --- a/app/src/main/res/layout/fragment_map_libre.xml +++ b/app/src/main/res/layout/fragment_map_libre.xml @@ -1,211 +1,88 @@ - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/app/src/main/res/layout/map_include_bottom_sheet.xml b/app/src/main/res/layout/map_include_bottom_sheet.xml new file mode 100644 index 0000000..1e4c5ba --- /dev/null +++ b/app/src/main/res/layout/map_include_bottom_sheet.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file