diff --git a/app/src/main/java/it/reyboz/bustorino/backend/LivePositionTripPattern.kt b/app/src/main/java/it/reyboz/bustorino/backend/LivePositionTripPattern.kt new file mode 100644 index 0000000..3c4c051 --- /dev/null +++ b/app/src/main/java/it/reyboz/bustorino/backend/LivePositionTripPattern.kt @@ -0,0 +1,9 @@ +package it.reyboz.bustorino.backend + +import it.reyboz.bustorino.backend.gtfs.LivePositionUpdate +import it.reyboz.bustorino.data.gtfs.MatoPattern + +data class LivePositionTripPattern( + var posUpdate: LivePositionUpdate, + var pattern: MatoPattern? +) 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 7c67413..31fb240 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt @@ -1,1340 +1,1406 @@ /* 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.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.fragment.app.viewModels 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.map.CustomInfoWindow.TouchResponder import it.reyboz.bustorino.middleware.LocationUtils import it.reyboz.bustorino.util.Permissions import it.reyboz.bustorino.viewmodels.LinesViewModel import it.reyboz.bustorino.viewmodels.LivePositionsViewModel import org.maplibre.android.MapLibre 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.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.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 class LinesDetailFragment() : ScreenBaseFragment(), OnMapReadyCallback { 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 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 //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) } 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 private lateinit var mapView: MapView private lateinit var locationComponent: LocationComponent private lateinit var mapStyle: Style protected var map: MapLibreMap? = null - private var mapInitCompleted = false private lateinit var stopsSource: GeoJsonSource private lateinit var busesSource: GeoJsonSource private lateinit var polylineSource: GeoJsonSource private var stopsLayerStarted = false private var lastStopsSizeShown = 0 private var lastUpdateTime:Long = -2 //BUS POSITIONS - private val positionsByVehDict = HashMap(5) + 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 val stopTouchResponder = TouchResponder { stopID, stopName -> Log.d(DEBUG_TAG, "Asked to show arrivals for stop ID: $stopID") fragmentListener.requestArrivalsForStopID(stopID) } 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 viewModels() //extra items to use the LibreMap private lateinit var symbolManager : SymbolManager private var stopActiveSymbol: Symbol? = null private var shownStopInBottomSheet : Stop? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lineID = requireArguments().getString(LINEID_KEY,"") MapLibre.getInstance(requireContext()) } @SuppressLint("SetTextI18n") override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { //reset statuses isBottomSheetShowing = false - mapInitCompleted = false stopsLayerStarted = false lastStopsSizeShown = 0 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) } /* - We have the pattern and the stops here, time to display them */ viewModel.stopsForPatternLiveData.observe(viewLifecycleOwner) { stops -> if(mapView.visibility ==View.VISIBLE) patternShown?.let{ - displayPatternWithStopsOnMap(it,stops) + // 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) showStopsAsList(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 } + /* + TODO: move somewhere else if(pausedFragment && viewModel.selectedPatternLiveData.value!=null){ val patt = viewModel.selectedPatternLiveData.value!! Log.d(DEBUG_TAG, "Recreating views on resume, setting pattern: ${patt.pattern.code}") showPattern(patt) pausedFragment = false } + */ + 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.get(position) //viewModel.setPatternToDisplay(patternWithStops) setPatternAndReqStops(patternWithStops) Log.d(DEBUG_TAG, "item Selected, cleaning bus markers") if(mapView.visibility == View.VISIBLE) { //Clear buses if we are changing direction currentShownPattern?.let { patt -> if(patt.directionId != patternWithStops.pattern.directionId){ stopAnimations() - positionsByVehDict.clear() + 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)) } 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)) } 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 mjson = Styles.getJsonStyleFromAsset(requireContext(), "map_style_good_noshops.json") //ViewUtils.loadJsonFromAsset(requireContext(),"map_style_good.json") activity?.run { val builder = Style.Builder().fromJson(mjson!!) mapReady.setStyle(builder) { style -> mapStyle = style //setupLayers(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 } // Start observing data initMapUserLocation(style, mapReady, requireContext()) - //initStopsLayer(style, FeatureCollection.fromFeatures(ArrayList())) - //init stop layer with this - //val stopsInCache = stopsViewModel.getAllStopsLoaded() - //if(stopsInCache.isEmpty()) + + //if(!stopsLayerStarted) initStopsPolyLineLayers(style, FeatureCollection.fromFeatures(ArrayList()), null) + /*if(patternShown!=null){ + viewModel.stopsForPatternLiveData.value?.let { + Log.d(DEBUG_TAG, "Show stops from the cache") + displayPatternWithStopsOnMap(patternShown!!, it, true) + } + } + if(!stopsLayerStarted) { + Log.d(DEBUG_TAG, "Stop layer is not started yet") + initStopsPolyLineLayers(style, FeatureCollection.fromFeatures(ArrayList()), null) + }*/ //else // displayStops(stopsInCache) setupBusLayer(style) } 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){ hideStopBottomSheet() } openStopInBottomSheet(it) isBottomSheetShowing = true //move camera if(it.latitude!=null && it.longitude!=null) //mapReady.cameraPosition = CameraPosition.Builder().target(LatLng(it.latitude!!, it.longitude!!)).build() mapReady.animateCamera(CameraUpdateFactory.newLatLng(LatLng(it.latitude!!,it.longitude!!)),750) } return@addOnMapClickListener true } else if (busNearby.isNotEmpty()){ val feature = busNearby[0] val vehid = feature.getStringProperty("veh") val route = feature.getStringProperty("line") + if(isBottomSheetShowing && shownStopInBottomSheet!=null) + hideStopBottomSheet() + //if(context!=null){ + // Toast.makeText(context, "Veh $vehid on route ${route.slice(0..route.length-2)}", Toast.LENGTH_SHORT).show() + //} + showVehicleTripInBottomSheet(vehid) - if(context!=null){ - Toast.makeText(context, "Veh $vehid on route ${route.slice(0..route.length-2)}", Toast.LENGTH_SHORT).show() - } return@addOnMapClickListener true } false } - - mapInitCompleted = true // 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") } */ //reset saved State at the end //if( savedMapStateOnPause == null) { //set initial position val zoom = 12.0 //center position //val latlngTarget = initialStopToShow?.let { // LatLng(it.latitude!!, it.longitude!!) //} ?: val latlngTarget = LatLng(MapLibreFragment.DEFAULT_CENTER_LAT, MapLibreFragment.DEFAULT_CENTER_LON) mapReady.cameraPosition = CameraPosition.Builder().target(latlngTarget).zoom(zoom).build() //} //reset saved state //savedMapStateOnPause = null } private fun observeBusPositionUpdates(){ //live bus positions liveBusViewModel.filteredLocationUpdates.observe(viewLifecycleOwner){ updates -> //Log.d(DEBUG_TAG, "Received ${updates.size} updates for the positions") if(mapView.visibility == View.GONE || patternShown ==null){ //DO NOTHING Log.w(DEBUG_TAG, "not doing anything because map is not visible") return@observe } updateBusPositionsInMap(updates) /* val filtdLineID = GtfsUtils.stripGtfsPrefix(lineID) //filter buses with direction, show those only with the same direction val updsForVeh = HashMap>() val currentPattern = viewingPattern!!.pattern val numUpds = updates.entries.size Log.d(DEBUG_TAG, "Got $numUpds updates, current pattern is: ${currentPattern.name}, directionID: ${currentPattern.directionId}") // cannot understand where this is used val patternsDirections = HashMap() for((tripId, pair) in updates.entries){ //remove trips with wrong line ideas val posUp = pair.first if(pair.first.routeID!=filtdLineID) continue if(pair.second!=null && pair.second?.pattern !=null){ val dir = pair.second?.pattern?.directionId if(dir !=null && dir == currentPattern.directionId){ updsForVeh[posUp.vehicle] = pair } patternsDirections[tripId] = dir ?: -10 } else{ updsForVeh[posUp.vehicle] = pair //Log.d(DEBUG_TAG, "No pattern for tripID: $tripId") patternsDirections[tripId] = -10 } } Log.d(DEBUG_TAG, " Filtered updates are ${updsForVeh.keys.size}") // Original updates directs: $patternsDirections\n */ //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" ) } } /** * 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 = MapUtils.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 */ private fun openStopInBottomSheet(stop: Stop){ bottomLayout?.let { //lay.findViewById(R.id.stopTitleTextView).text ="${stop.ID} - ${stop.stopDefaultName}" stopTitleTextView.text = stop.stopDefaultName stopNumberTextView.text = stop.ID + stopTitleTextView.visibility = View.VISIBLE + val string_show = if (stop.numRoutesStopping==0) "" else if (stop.numRoutesStopping <= 1) requireContext().getString(R.string.line_fill, stop.routesThatStopHereToString()) else requireContext().getString(R.string.lines_fill, stop.routesThatStopHereToString()) linesPassingTextView.text = string_show //SET ON CLICK LISTENER arrivalsCard.setOnClickListener{ fragmentListener?.requestArrivalsForStopID(stop.ID) } + arrivalsCard.visibility = View.VISIBLE + 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) ) } shownStopInBottomSheet = stop bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED } // 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 //remove initial stop //if(initialStopToShow!=null){ // initialStopToShow = null //} shownStopInBottomSheet = null } + private fun showVehicleTripInBottomSheet(veh: String){ + val data = updatesByVehDict[veh] + if(data==null) return + + bottomLayout?.let { + val lineName = FiveTNormalizer.fixShortNameForDisplay( + GtfsUtils.getLineNameFromGtfsID(data.posUpdate.routeID), true) + stopNumberTextView.text = requireContext().getString(R.string.line_fill, lineName) + data.pattern?.let { pat -> + stopTitleTextView.text = pat.headsign + stopTitleTextView.visibility = View.VISIBLE + } ?:{ + stopTitleTextView.visibility = View.GONE + } + linesPassingTextView.text = data.posUpdate.vehicle + } + arrivalsCard.visibility=View.GONE + bottomrightImage.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_magnifying_glass, activity?.theme)) + + + bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED + + } + // ------- MAP LAYERS INITIALIZE ---- /** * Initialize the map layers for the stops */ private fun initStopsPolyLineLayers(style: Style, stopFeatures:FeatureCollection, lineFeature: Feature?){ + Log.d(DEBUG_TAG, "INIT STOPS CALLED") stopsSource = GeoJsonSource(STOPS_SOURCE_ID,stopFeatures) 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)!! //set the image tint //DrawableCompat.setTint(imgBus,ContextCompat.getColor(context,R.color.line_drawn_poly)) // add icon style.addImage(STOP_IMAGE_ID,stopIcon) 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 = 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) ) style.addLayerBelow(lineLayer,"label_country_1") style.addLayerAbove(stopsLayer, 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)!!) // 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) } 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() } } 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() } 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, "Found pattern $code in position: $pos") if(pos>=0) patternsSpinner.setSelection(pos) //set pattern 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 = 100 // 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, stops: List){ + private fun displayPatternWithStopsOnMap(patternWs: MatoPatternWithStops, stops: List, zoomToPattern: Boolean){ Log.d(DEBUG_TAG, "Got the stops: ${stops.map { s->s.gtfsID }}}") //if(viewingPattern==null || map == null) return - if (map==null || !mapInitCompleted) return + if (map==null) return patternShown = patternWs 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()//stops.mapNotNull { stop -> //stop.latitude?.let { lat -> // stop.longitude?.let { lon -> for (s in stops){ 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 } ) ) } } 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) 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) Log.d(DEBUG_TAG,"Started stops layer on map") lastStopsSizeShown = features.size stopsLayerStarted = true } /* 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) //} - zoomToCurrentPattern() + if(zoomToPattern) zoomToCurrentPattern() //map.invalidate() } private fun initializeRecyclerView(){ val llManager = LinearLayoutManager(context) llManager.orientation = LinearLayoutManager.VERTICAL stopsRecyclerView.layoutManager = llManager } private fun showStopsAsList(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 displayPatternInSpinner(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 } } } /** * 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(positionsByVehDict.keys) + val vehsOld = HashSet(updatesByVehDict.keys) Log.d(DEBUG_TAG, "In fragment, have ${incomingData.size} updates to show") var countUpds = 0 //val symbolsToUpdate = ArrayList() for (upsWithTrp in incomingData.values){ val pos = upsWithTrp.first + val patternStops = upsWithTrp.second val vehID = pos.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] + val oldPos = updatesByVehDict[vehID]?.posUpdate var avoidShowingUpdateBecauseIsImpossible = false oldPos?.let{ - if(oldPos.routeID!=pos.routeID) { + + if(it.routeID!=pos.routeID) { val dist = LatLng(it.latitude, it.longitude).distanceTo(LatLng(pos.latitude, pos.longitude)) val speed = dist*3.6 / (pos.timestamp - it.timestamp) //this should be in km/h Log.w(DEBUG_TAG, "Vehicle $vehID changed route from ${oldPos.routeID} to ${pos.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==pos.latitude)&&(oldPos.longitude == pos.longitude) }?:false + val samePosition = oldPos?.let { (it.latitude==pos.latitude)&&(it.longitude == pos.longitude) }?:false if(!samePosition) { //val isPositionInBounds = isInsideVisibleRegion( // pos.latitude, pos.longitude, true //) || (oldPos?.let { isInsideVisibleRegion(it.latitude,it.longitude,true) } ?: false) val skip = true if (skip) { //animate = true //this moves both the icon and the label moveVehicleToNewPosition(pos) } else { - positionsByVehDict[vehID] = pos + //update + updatesByVehDict[vehID] = LivePositionTripPattern(pos,patternStops?.pattern) /*busLabelSymbolsByVeh[vehID]?.let { it.latLng = LatLng(pos.latitude, pos.longitude) symbolsToUpdate.add(it) }*/ } } countUpds++ } else{ + //not inside // update it simply - positionsByVehDict[vehID] = pos + updatesByVehDict[vehID] = LivePositionTripPattern(pos, patternStops?.pattern) //createLabelForVehicle(pos) } } //symbolManager.update(symbolsToUpdate) //remove old positions Log.d(DEBUG_TAG, "Updated $countUpds 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 (positionsByVehDict[vehID]!!.timestamp - currentTimeStamp > 2*60){ - positionsByVehDict.remove(vehID) + if (updatesByVehDict[vehID]!!.posUpdate.timestamp - currentTimeStamp > 2*60){ + updatesByVehDict.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 moveVehicleToNewPosition(positionUpdate: LivePositionUpdate){ - if (positionUpdate.vehicle !in positionsByVehDict.keys) + if (positionUpdate.vehicle !in updatesByVehDict.keys) return val vehID = positionUpdate.vehicle - val currentUpdate = positionsByVehDict[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(it.latitude, it.longitude) + val currentPos = LatLng(posUp.latitude, posUp.longitude) val newPos = LatLng(positionUpdate.latitude, positionUpdate.longitude) val valueAnimator = ValueAnimator.ofObject(MapUtils.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]!! + val update = updatesByVehDict[positionUpdate.vehicle]!! latLng?.let { ll-> - update.latitude = ll.latitude - update.longitude = ll.longitude + update.posUpdate.latitude = ll.latitude + update.posUpdate.longitude = ll.longitude updatePositionsIcons(false) } } }) /*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 = currentUpdate.latitude - positionUpdate.longitude = currentUpdate.longitude - positionsByVehDict[vehID] = positionUpdate + positionUpdate.latitude = posUp.latitude + positionUpdate.longitude = posUp.longitude + 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") - positionsByVehDict[positionUpdate.vehicle] = positionUpdate + //updatesByVehDict[positionUpdate.vehicle] = positionUpdate } } /** * 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 return } val features = ArrayList()//stops.mapNotNull { stop -> //stop.latitude?.let { lat -> // stop.longitude?.let { lon -> - for (pos in positionsByVehDict.values){ + for (dat in updatesByVehDict.values){ //if (s.latitude!=null && s.longitude!=null) + val pos = dat.posUpdate 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) } ) ) /*busLabelSymbolsByVeh[pos.vehicle]?.let { it.latLng = LatLng(pos.latitude, pos.longitude) symbolsToUpdate.add(it) } */ } busesSource.setGeoJson(FeatureCollection.fromFeatures(features)) //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") mapView.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) } 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 onStart() { super.onStart() mapView.onStart() } override fun onDestroy() { super.onDestroy() mapView.onDestroy() } override fun onStop() { super.onStop() mapView.onStop() } + override fun onDestroyView() { + 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 getBaseViewForSnackBar(): View? { return null } companion object { private const val LINEID_KEY="lineID" private const val STOPS_SOURCE_ID = "stops-source" private const val STOPS_LAYER_ID = "stops-layer" private const val BUSES_SOURCE_ID = "buses-source" private const val BUSES_LAYER_ID = "buses-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 DEBUG_TAG="BusTO-LinesDetailFragment" + private const val DEBUG_TAG="BusTO-LineDetailFragment" fun makeArgs(lineID: String): Bundle{ val b = Bundle() b.putString(LINEID_KEY, lineID) return b } fun newInstance(lineID: String?) = LinesDetailFragment().apply { lineID?.let { arguments = makeArgs(it) } } @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/viewmodels/LinesViewModel.kt b/app/src/main/java/it/reyboz/bustorino/viewmodels/LinesViewModel.kt index 5b17a87..d73e400 100644 --- a/app/src/main/java/it/reyboz/bustorino/viewmodels/LinesViewModel.kt +++ b/app/src/main/java/it/reyboz/bustorino/viewmodels/LinesViewModel.kt @@ -1,123 +1,132 @@ package it.reyboz.bustorino.viewmodels import android.app.Application import android.util.Log import androidx.lifecycle.* import it.reyboz.bustorino.backend.Result import it.reyboz.bustorino.backend.Stop import it.reyboz.bustorino.data.GtfsRepository import it.reyboz.bustorino.data.NextGenDB import it.reyboz.bustorino.data.OldDataRepository import it.reyboz.bustorino.data.gtfs.GtfsDatabase import it.reyboz.bustorino.data.gtfs.GtfsRoute import it.reyboz.bustorino.data.gtfs.MatoPatternWithStops import it.reyboz.bustorino.data.gtfs.PatternStop +import org.maplibre.android.geometry.LatLng import java.util.concurrent.Executors class LinesViewModel(application: Application) : AndroidViewModel(application) { private val gtfsRepo: GtfsRepository private val oldRepo: OldDataRepository //val patternsByRouteLiveData: LiveData> private val routeIDToSearch = MutableLiveData() private var lastShownPatternStops = ArrayList() val currentPatternStops = MutableLiveData>() val selectedPatternLiveData = MutableLiveData() val stopsForPatternLiveData = MutableLiveData>() private val executor = Executors.newFixedThreadPool(2) val mapShowing = MutableLiveData(true) fun setMapShowing(yes: Boolean){ mapShowing.value = yes //retrigger redraw stopsForPatternLiveData.postValue(stopsForPatternLiveData.value) } init { val gtfsDao = GtfsDatabase.getGtfsDatabase(application).gtfsDao() gtfsRepo = GtfsRepository(gtfsDao) oldRepo = OldDataRepository(executor, NextGenDB.getInstance(application)) } val routesGTTLiveData: LiveData> by lazy{ gtfsRepo.getLinesLiveDataForFeed("gtt") } val patternsWithStopsByRouteLiveData = routeIDToSearch.switchMap { gtfsRepo.getPatternsWithStopsForRouteID(it) } val gtfsRoute = routeIDToSearch.switchMap { gtfsRepo.getRouteFromGtfsId(it) } fun setRouteIDQuery(routeID: String){ routeIDToSearch.value = routeID } fun getRouteIDQueried(): String?{ return routeIDToSearch.value } var shouldShowMessage = true fun setPatternToDisplay(patternStops: MatoPatternWithStops){ selectedPatternLiveData.value = patternStops } /** * Find the */ private fun requestStopsForGTFSIDs(gtfsIDs: List){ if (gtfsIDs.equals(lastShownPatternStops)){ //nothing to do return } oldRepo.requestStopsWithGtfsIDs(gtfsIDs) { if (it.isSuccess) { stopsForPatternLiveData.postValue(it.result) } else { Log.e("BusTO-LinesVM", "Got error on callback with stops for gtfsID") it.exception?.printStackTrace() } } lastShownPatternStops.clear() for(id in gtfsIDs) lastShownPatternStops.add(id) } fun requestStopsForPatternWithStops(patternStops: MatoPatternWithStops){ val gtfsIDs = ArrayList() for(pat in patternStops.stopsIndices){ gtfsIDs.add(pat.stopGtfsId) } requestStopsForGTFSIDs(gtfsIDs) } fun getStopByID(id:String) : Stop?{ //var stop : Stop? = null val stop = stopsForPatternLiveData.value?.let { stops -> for (s in stops){ if(s.ID == id) return@let s } return@let null } return stop } + private var lastMapPos: Pair? = null + + fun saveMapPos(latLng: LatLng, zoom: Float){ + lastMapPos = Pair(latLng, zoom) + } + + fun getLastMapPos(): Pair? = lastMapPos + /*fun getLinesGTT(): MutableLiveData> { val routesData = MutableLiveData>() viewModelScope.launch { val routes=gtfsRepo.getLinesForFeed("gtt") routesData.postValue(routes) } return routesData }*/ } \ 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 9e5a744..72bf165 100644 --- a/app/src/main/java/it/reyboz/bustorino/viewmodels/LivePositionsViewModel.kt +++ b/app/src/main/java/it/reyboz/bustorino/viewmodels/LivePositionsViewModel.kt @@ -1,349 +1,348 @@ /* 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.work.WorkInfo import androidx.work.WorkManager import com.android.volley.DefaultRetryPolicy import com.android.volley.Response 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.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 typealias FullPositionUpdatesMap = HashMap> typealias FullPositionUpdate = Pair class LivePositionsViewModel(application: Application): AndroidViewModel(application) { private val gtfsRepo = GtfsRepository(application) //private val updates = UpdatesMap() private val positionsToBeMatchedLiveData = MutableLiveData>() private val netVolleyManager = NetworkVolleyManager.getInstance(application) private var mqttClient = MQTTMatoClient() private var lineListening = "" private var lastTimeReceived: 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>() 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 = MQTTMatoClient.Companion.MQTTMatoListener{ 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) } } } val time = System.currentTimeMillis() if(lastTimeReceived == (0.toLong()) || (time-lastTimeReceived)>500){ positionsToBeMatchedLiveData.postValue(mupds) lastTimeReceived = time } } //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() } } // unify trips with updates - // TODO: Unify trip data inside the LivePositionUpdate val updatesWithTripAndPatterns = gtfsTripsPatternsInDB.map { tripPatterns-> Log.i(DEBUG_TI, "Mapping trips and patterns") - val mdict = HashMap>() + 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.size > 0){ Log.d(DEBUG_TI, "Have ${routesToDownload.size} missing patterns from the DB: $routesToDownload") //downloadMissingPatterns (ArrayList(routesToDownload)) MatoPatternsDownloadWorker.downloadPatternsForRoutes(routesToDownload.toList(), getApplication()) } return@map mdict } //OBSERVE THIS TO GET THE LOCATION UPDATES FILTERED val filteredLocationUpdates = MediatorLiveData() init { filteredLocationUpdates.addSource(updatesWithTripAndPatterns){ filteredLocationUpdates.value = filterUpdatesForGtfsLine(it, gtfsLineToFilterPos.value!!) } filteredLocationUpdates.addSource(gtfsLineToFilterPos){ updatesWithTripAndPatterns.value?.let{ ups-> filteredLocationUpdates.value = filterUpdatesForGtfsLine(ups, it)} } } private fun filterUpdatesForGtfsLine(updates: FullPositionUpdatesMap, linePatt: Pair): HashMap{ val gtfsLineId = linePatt.first val pattern = linePatt.second val filtdLineID = GtfsUtils.stripGtfsPrefix(gtfsLineId) //filter buses with direction, show those only with the same direction val updsForTripId = HashMap>() 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 ideas val posUp = pair.first if(pair.first.routeID!=filtdLineID) continue if(directionId!=-100 && pair.second!=null && pair.second?.pattern !=null){ val dir = pair.second?.pattern?.directionId if(dir !=null && dir == directionId){ updsForTripId[tripId] = pair } 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 updsForTripId } fun requestMatoPosUpdates(line: String){ lineListening = line viewModelScope.launch { mqttClient.startAndSubscribe(line,matoPositionListener, getApplication()) } //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") response?.let {it:ArrayList -> if (it.size == 0) { Log.w(DEBUG_TI,"No position updates from the GTFS RT server") return } else { //Log.i(DEBUG_TI, "Posting value to positionsLiveData") viewModelScope.launch { positionsToBeMatchedLiveData.postValue(it) } } } gtfsRtRequestRunning.postValue(false) } } private val positionRequestErrorListener = Response.ErrorListener { Log.e(DEBUG_TI, "Could not download the update", it) gtfsRtRequestRunning.postValue(false) } 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) } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_magnifying_glass.xml b/app/src/main/res/drawable/ic_magnifying_glass.xml new file mode 100644 index 0000000..96d592b --- /dev/null +++ b/app/src/main/res/drawable/ic_magnifying_glass.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/app/src/main/res/layout/fragment_lines_detail.xml b/app/src/main/res/layout/fragment_lines_detail.xml index 0cc9ef2..9891e94 100644 --- a/app/src/main/res/layout/fragment_lines_detail.xml +++ b/app/src/main/res/layout/fragment_lines_detail.xml @@ -1,278 +1,280 @@ + + android:backgroundTint="?colorAccent"> \ No newline at end of file