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 b863c3a..bdca6a9 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt @@ -1,1150 +1,1126 @@ /* BusTO - Fragments components Copyright (C) 2025 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.ValueAnimator import android.annotation.SuppressLint import android.content.Context import android.content.Context.LOCATION_SERVICE import android.content.SharedPreferences import android.content.res.ColorStateList import android.graphics.Color import android.location.Location 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.ImageView 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.core.view.ViewCompat import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.gson.JsonObject import it.reyboz.bustorino.BuildConfig import it.reyboz.bustorino.R import it.reyboz.bustorino.backend.FiveTNormalizer import it.reyboz.bustorino.backend.LivePositionTripPattern import it.reyboz.bustorino.backend.LivePositionsServiceStatus 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.utils import it.reyboz.bustorino.data.PreferencesHolder import it.reyboz.bustorino.data.gtfs.TripAndPatternWithStops import it.reyboz.bustorino.map.MapLibreLocationEngine import it.reyboz.bustorino.map.MapLibreUtils import it.reyboz.bustorino.middleware.FusedNativeLocationProvider import it.reyboz.bustorino.util.Permissions import it.reyboz.bustorino.util.ViewUtils import it.reyboz.bustorino.viewmodels.LivePositionsViewModel import it.reyboz.bustorino.viewmodels.MapStateViewModel 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.LocationComponentActivationOptions import org.maplibre.android.location.engine.LocationEngineCallback import org.maplibre.android.location.engine.LocationEngineRequest import org.maplibre.android.location.engine.LocationEngineResult 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 import kotlin.time.Duration.Companion.milliseconds 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 protected var locationEngine: MapLibreLocationEngine? = null protected lateinit var locationProvider: FusedNativeLocationProvider protected var shownToastNoPosition = false + private var locationEnabledOnDevice = true - + //TODO ACTIVATE THIS 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() } } protected val positionRequestResponder = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions(), ActivityResultCallback{ res -> if(!(res.containsKey(PERM_LOC_COARSE)&&res.containsKey(PERM_LOC_FINE))){ Log.e(DEBUG_TAG, "Location request does not have the correct keys") } else if(res[PERM_LOC_COARSE]!! && res[PERM_LOC_FINE]!!){ //permission OK, init map location val mMap = map if(mMap == null){ Log.w(DEBUG_TAG, "Location request completed, but map is null!") }else{ initializeMapLocationComponent(mMap,requireContext(), null) } } else{ // PERMISSION DENIED // TODO find better way to show the necessity of the permission if(shouldShowRequestPermissionRationale(PERM_LOC_FINE)) Toast.makeText(requireContext(), R.string.enable_position_message_map, Toast.LENGTH_SHORT).show() } } ) //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 extraBottomTextView: TextView protected lateinit var arrivalsCard: CardView protected lateinit var directionsCard: CardView protected lateinit var bottomrightImage: ImageView protected lateinit var locationComponent: LocationComponent protected lateinit var busPositionsIconButton: ImageButton 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 var symbolManager : SymbolManager? = null protected var stopActiveSymbol: Symbol? = null protected var stopsLayerStarted = false protected val livePositionsViewModel : LivePositionsViewModel by activityViewModels() //private lateinit var symbolManager: SymbolManager protected val mapStateViewModel: MapStateViewModel by viewModels() protected var locationInitialized = false protected var mapInitialized = false protected var receivedFirstLocation = false //location callback to decide if to zoom to the user position @SuppressLint("MissingPermission") protected val mapLibreLocationCallback = object : LocationEngineCallback { override fun onSuccess(result: LocationEngineResult) { val location: Location? = result.lastLocation Log.d(DEBUG_TAG, "Received location $location") location?.let { //check timing of the location val currentTime = System.currentTimeMillis() val discard = (currentTime - it.time) > 90 * 1000.0 // discard if it is Older than 60 seconds if(!discard) { if (!receivedFirstLocation) { onFirstReceivedLocation(it) } receivedFirstLocation = true } } if(receivedFirstLocation){ //remove this locationEngine?.removeLocationUpdates(this) } + } override fun onFailure(exception: Exception) { Log.e(DEBUG_TAG, "Error in getting position: ${exception.message}") } } + protected val deviceLocationStatusListener = FusedNativeLocationProvider.LocationStatusListener { isEnabled -> + mapStateViewModel.locationDeviceEnabled.value = isEnabled + if(locationEnabledOnDevice && !isEnabled && locationInitialized) { + warnLocationNotEnabledOnDevice() + //setMapLocationEnabled(false) + } + locationEnabledOnDevice = isEnabled + } + 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) extraBottomTextView = view.findViewById(R.id.extraBottomTextView) bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN } 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 onPause() { + super.onPause() + } + override fun onDestroyView() { bottomLayout = null + locationProvider.removeListener(deviceLocationStatusListener) super.onDestroyView() } + protected fun warnLocationNotEnabledOnDevice(){ + context?.let{ + Toast.makeText(it,R.string.enable_location_message,Toast.LENGTH_SHORT).show() + } + } + 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 } //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){ hideStopOrBusBottomSheet() } } } // Hide the bottom sheet and remove extra symbol protected open fun hideStopOrBusBottomSheet(){ 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) } extraBottomTextView.visibility = View.GONE } protected fun initSymbolManager(mapReady: MapLibreMap , style: Style){ val sm = SymbolManager(mapView, mapReady, style) sm.iconAllowOverlap = true sm.textAllowOverlap = false sm.addClickListener { _ -> if (stopActiveSymbol != null) { hideStopOrBusBottomSheet() return@addClickListener true } else return@addClickListener false } symbolManager = sm } /** * Change the icon indicating the status of the live Positions */ protected 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)) } abstract fun onMapLocationComponentInitialized() @SuppressLint("MissingPermission") protected fun setLocationComponentEnabled(enabled: Boolean): Boolean{ var changed = false map?.apply { if(locationComponent.isLocationComponentEnabled !=enabled) locationComponent.isLocationComponentEnabled= enabled changed = true} + Log.d(DEBUG_TAG, "Asked to set location component enabled: $enabled, changed: $changed") + mapStateViewModel.locationUserActive.value = enabled + return changed } @SuppressLint("MissingPermission") protected fun initializeMapLocationComponent(map: MapLibreMap, context: Context, style: Style?){ val mStyle = style ?: map.style if(locationInitialized){ Log.w(DEBUG_TAG, "trying to initialize Location Component, but it is already done") return } mStyle?.let{ style -> locationComponent = map.locationComponent locationProvider = FusedNativeLocationProvider(context) + locationProvider.addListener(deviceLocationStatusListener) locationEngine = MapLibreLocationEngine(locationProvider) val options = LocationComponentActivationOptions.builder(context, style) .useDefaultLocationEngine(false) .locationEngine(locationEngine) .build() locationComponent.activateLocationComponent(options) - //locationComponent.cameraMode = CameraMode.TRACKING - //locationComponent.renderMode = RenderMode.COMPASS - locationInitialized = true + if(BuildConfig.DEBUG) Log.d(DEBUG_TAG, "Requesting location updates") locationEngine!!.requestLocationUpdates(LocationEngineRequest.Builder(500).setDisplacement(20.0f).build(), mapLibreLocationCallback, null) - // signal to show user location icon as active - mapStateViewModel.locationActive.value = true - setLocationComponentEnabled(true) + + if(!locationEnabledOnDevice){ + warnLocationNotEnabledOnDevice() + }else { + setLocationComponentEnabled(true) + } + locationInitialized = true onMapLocationComponentInitialized() } } /** * 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 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) } /** * Shared bottom sheet setup. The [onDirectionsClick] lambda is called when * directionsCard is tapped; it receives the pattern code (empty string when * no pattern is available) so each subclass can navigate as it sees fit. */ protected fun showVehicleTripInBottomSheet( veh: String, onDirectionsClick: (patternCode: String, veh: String) -> Unit ) { val data = updatesByVehDict[veh] ?: run { 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), false ) val pat = data.pattern if (pat != null) { stopTitleTextView.text = pat.headsign stopTitleTextView.visibility = View.VISIBLE stopNumberTextView.text = getString(R.string.line_fill_towards, lineName) } else { stopTitleTextView.visibility = View.GONE stopNumberTextView.text = getString(R.string.line_fill, lineName) } directionsCard.setOnClickListener { onDirectionsClick(pat?.code ?: "", veh) } directionsCard.visibility = View.VISIBLE bottomrightImage.setImageDrawable( ResourcesCompat.getDrawable(resources, R.drawable.ic_magnifying_glass, activity?.theme) ) val colorBlue = ResourcesCompat.getColor(resources, R.color.blue_500, activity?.theme) ViewCompat.setBackgroundTintList(directionsCard, ColorStateList.valueOf(colorBlue)) linesPassingTextView.text = getString(R.string.vehicle_fill, data.posUpdate.vehicle) arrivalsCard.visibility = View.GONE extraBottomTextView.text = getString(R.string.updated_fill, utils.unixTimestampToLocalTime(data.posUpdate.timestamp)) extraBottomTextView.visibility = View.VISIBLE } vehShowing = veh bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED updatePositionsIcons(true) Log.d(DEBUG_TAG, "Shown vehicle $veh in bottom sheet") } /** * 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.milliseconds) 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 linesPassingTextView.visibility = View.VISIBLE //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?){ //determine default layer var layerAbove = "" if (lastMapStyle == MapLibreUtils.STYLE_OSM_RASTER){ layerAbove = "osm-raster" } else if (lastMapStyle == MapLibreUtils.STYLE_VECTOR){ layerAbove = "symbol-transit-airfield" } initStopsLayer(style, stopsFeatures, layerAbove) } 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).apply { 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) ) 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(selectedBusLayer, BUSES_LAYER_ID) } /** * Method used for enabling / disabling the location from the buttons */ protected fun switchUserLocationStatus(view: View?){ val enabled = if(locationInitialized) locationComponent.isLocationComponentEnabled else false val context = context ?: return if(enabled) { setMapLocationEnabled(false) - onMapLocationEnabled(false) } - else if(deviceHasGpsProvider()) { + else if(deviceHasLocationProvider()) { if(Permissions.bothLocationPermissionsGranted(context)){ - setMapLocationEnabled(true) - onMapLocationEnabled(true) + if(!locationEnabledOnDevice){ + warnLocationNotEnabledOnDevice() + } else{ + setMapLocationEnabled(true) + } } else{ Log.d(DEBUG_TAG, "Requesting permissions to show location") Permissions.getInstance(context).checkRequestLocationPermissions(requireActivity(), positionRequestResponder) } } else{ context.let { Toast.makeText(it, R.string.no_gps_on_device, Toast.LENGTH_SHORT).show() } } } + /** + * Set the map location component enabled + */ @SuppressLint("MissingPermission") protected fun setMapLocationEnabled(enabled: Boolean){ + Log.d(DEBUG_TAG, "Setting map location enabled: $enabled") map?.locationComponent?.isLocationComponentEnabled = enabled //map?.cameraPosition = - mapStateViewModel.locationActive.value = enabled + mapStateViewModel.locationUserActive.value = enabled + onMapLocationEnabled(enabled) } protected fun checkInitMapLocation(mapReady: MapLibreMap,style: Style, context: Context) { //enable location - val hasGps = deviceHasGpsProvider() + val hasGps = deviceHasLocationProvider() val permissions = Permissions.getInstance(context) if(hasGps) { if (Permissions.bothLocationPermissionsGranted(context)) { Log.d(DEBUG_TAG, "Have got the location permission, init location component") initializeMapLocationComponent(mapReady, context, style) }else { var req = false activity?.let{ req = permissions.checkRequestLocationPermissions(it, positionRequestResponder) } - //setLocationIconEnabled(false) - //setFollowingUser(false) + if(!req) { setMapLocationEnabled(false) - onMapLocationEnabled(false) } } } } /** * Set the UI elements showing that the user location is disabled */ abstract fun onMapLocationEnabled(active: Boolean) /** * Helper function to actually set the icon */ abstract fun setLocationIconEnabled(enabled: Boolean) /** * Called when we receive the first fix on the user location */ abstract fun onFirstReceivedLocation(location: Location) protected fun isBottomSheetShowing(): Boolean { return bottomSheetBehavior.state == BottomSheetBehavior.STATE_EXPANDED } - protected fun deviceHasGpsProvider(): Boolean{ + protected fun deviceHasLocationProvider(): Boolean{ val locManager = requireContext().getSystemService(LOCATION_SERVICE) as LocationManager - return locManager.allProviders.contains(LocationManager.GPS_PROVIDER) + return locManager.allProviders.size > 0 } /** * Update automatically the icon when the live position service changes status */ protected fun observeStatusLivePositions(){ 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) } } } /** * Clear all buses from the map */ protected fun clearAllBusPositionsInMap(){ for ((k, anim) in animatorsByVeh){ anim.cancel() } animatorsByVeh.clear() updatesByVehDict.clear() updatePositionsIcons(forced = false) } protected fun setCameraPosition(latitude: Double, longitude: Double, zoom: Double) { map?.cameraPosition = CameraPosition.Builder() .target(LatLng(latitude, longitude)) .zoom(zoom) .build() } protected fun showToastLocation(enabled: Boolean){ val textid = if (enabled) R.string.location_enabled else R.string.location_disabled context?.let{ Toast.makeText(it,textid,Toast.LENGTH_SHORT).show() } } 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" private const val PERM_LOC_COARSE = Manifest.permission.ACCESS_COARSE_LOCATION private const val PERM_LOC_FINE = Manifest.permission.ACCESS_FINE_LOCATION //TODO: this is hardcoded, make it modifiable by the user protected const val MAX_DIST_KM = 90.0 } } \ 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 b90649d..b862d2b 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt @@ -1,1182 +1,1182 @@ /* 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.animation.ObjectAnimator 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.widget.* import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView 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.Stop import it.reyboz.bustorino.backend.gtfs.GtfsUtils 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.map.* import it.reyboz.bustorino.util.Permissions import it.reyboz.bustorino.viewmodels.LinesViewModel import it.reyboz.bustorino.viewmodels.MapStateViewModel import it.reyboz.bustorino.viewmodels.ServiceAlertsViewModel import kotlinx.coroutines.Runnable 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.maps.MapLibreMap import org.maplibre.android.maps.Style 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_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() : GeneralMapLibreFragment() { private var lineID = "" // the GTFS line ID (e.g. "gtt:10U") private lateinit var patternsSpinner: Spinner private var patternsAdapter: ArrayAdapter? = null //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 alertsViewModel: ServiceAlertsViewModel by activityViewModels() //private var firstInit = true private var pausedFragment = false private lateinit var switchButton: ImageButton private lateinit var lineInfoButton: 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 = "" private var patternIdToShow = "" //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 //style and sources are in GeneralMapLibreFragment private lateinit var polylineSource: GeoJsonSource private lateinit var polyArrowSource: GeoJsonSource private var savedCameraPosition: CameraPosition? = null private var lastStopsSizeShown = 0 //BUS POSITIONS private var enablingPositionFromClick = false private var polyline: LineString? = null //private var stopPosList = ArrayList() //fragment actions private var showOnTopOfLine = false private var recyclerInitDone = false private var usingMQTTPositions = true private var restoredCameraInMap = false //position of live markers private val tripMarkersAnimators = HashMap() //extra items to use the LibreMap override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val args = requireArguments() lineID = args.getString(LINEID_KEY,"") stopIDFromToShow = args.getString(STOPID_FROM_KEY, "") //can be null patternIdToShow = args.getString(PATTERN_SHOW_KEY, "") } @SuppressLint("SetTextI18n") override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { //reset statuses //isBottomSheetShowing = false //stopsLayerStarted = false lastStopsSizeShown = 0 mapInitialized = false val rootView = inflater.inflate(R.layout.fragment_lines_detail, container, false) //lineID = requireArguments().getString(LINEID_KEY, "") arguments?.let { lineID = it.getString(LINEID_KEY, "") stopIDFromToShow = it.getString(STOPID_FROM_KEY, "") //can be null patternIdToShow = it.getString(PATTERN_SHOW_KEY, "") Log.d(DEBUG_TAG, "LineID selected: $lineID, stopIDFromToShow: $stopIDFromToShow, patternIdToShow: $patternIdToShow") } switchButton = rootView.findViewById(R.id.switchImageButton) locationIcon = rootView.findViewById(R.id.locationEnableIcon) busPositionsIconButton = rootView.findViewById(R.id.busPositionsImageButton) lineInfoButton = rootView.findViewById(R.id.lineInfoWarningButton) 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) // Setup close button rootView.findViewById(R.id.btnClose).setOnClickListener { hideStopOrBusBottomSheet() } val titleTextView = rootView.findViewById(R.id.titleTextView) titleTextView.text = getString(R.string.line)+" "+ GtfsUtils.lineNameDisplayFromGtfsID(lineID) 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 -> //set click Listener view.setOnClickListener(this::switchUserLocationStatus) } busPositionsIconButton.setOnClickListener { LivePositionsDialogFragment().show(parentFragmentManager, "LivePositionsDialog") } //set //INITIALIZE VIEW MODELS viewModel.setRouteIDQuery(lineID) livePositionsViewModel.setGtfsLineToFilterPos(lineID, null) //observe the change, clear buses when switching position livePositionsViewModel.useMQTTPositionsLiveData.observe(viewLifecycleOwner){ useMQTT-> //Log.d(DEBUG_TAG, "Changed MQTT positions, now have to use MQTT: $useMQTT") if (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(GtfsUtils.getLineNameFromGtfsID(lineID)) } 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 if(vehShowing.isNotEmpty()) hideStopOrBusBottomSheet() clearAllBusPositionsInMap() } } } usingMQTTPositions = useMQTT } val keySourcePositions = getString(R.string.pref_positions_source) usingMQTTPositions = PreferenceManager.getDefaultSharedPreferences(requireContext()) .getString(keySourcePositions, "mqtt").contentEquals("mqtt") viewModel.patternsWithStopsByRouteLiveData.observe(viewLifecycleOwner, this::savePatternsToShow) /* */ viewModel.stopsForPatternLiveData.observe(viewLifecycleOwner) { stops -> val pattern = viewModel.selectedPatternLiveData.value if (pattern == null) { Log.w(DEBUG_TAG, "The selectedPattern is null!") return@observe } if(mapView.visibility ==View.VISIBLE) { // We have the pattern and the stops here, time to display them //TODO: Decide if we should follow the camera view given by the previous screen (probably the map fragment) // use !restoredCameraInMap to do so // val shouldZoom = (shownStopInBottomSheet == null) //use this if we want to avoid zoom when we're keeping the stop open displayPatternWithStopsOnMap(pattern, stops, true) } else { if(stopsRecyclerView.visibility==View.VISIBLE) { patternShown = pattern 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 } - mapStateViewModel.locationActive.observe(viewLifecycleOwner) { + mapStateViewModel.locationUserActive.observe(viewLifecycleOwner) { setLocationIconEnabled(it) } // enable info button if there are alerts on the line alertsViewModel.setGtfsLineFilter(lineID) alertsViewModel.alertsByRouteLiveData.observe(viewLifecycleOwner){ list -> Log.d(DEBUG_TAG, "alerts for line $lineID: ${list.size}") if(list.isNotEmpty()){ lineInfoButton.visibility = View.VISIBLE //Log.d(DEBUG_TAG, "First alert is:\n ${list[0].longPrint()}") } else lineInfoButton.visibility = View.GONE } lineInfoButton.setOnClickListener { AlertsDialogFragment(lineID).show(parentFragmentManager, "Alerts-Line$lineID") } /* */ 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) livePositionsViewModel.retriggerPositionUpdate() } if (shownStopInBottomSheet!=null){ //check if the stop is inside the new pattern /*val s = shownStopInBottomSheet!! val newPatternStops = patternWithStops.stopsIndices val filterPStops = newPatternStops.filter { ps -> ps.stopGtfsId == "gtt:${s.ID}" } if (filterPStops.isEmpty()){ hideStopOrBusBottomSheet() } */ // do another thing, just close the stop when the pattern is changed if (patt.code != patternWithStops.pattern.code){ hideStopOrBusBottomSheet() } } } } livePositionsViewModel.setGtfsLineToFilterPos(lineID, patternWithStops.pattern) } override fun onNothingSelected(p0: AdapterView<*>?) { } } Log.d(DEBUG_TAG, "Views created!") observeStatusLivePositions() return rootView } // ------------- UI switch stuff --------- private fun hideMapAndShowStopList(){ mapView.visibility = View.GONE stopsRecyclerView.visibility = View.VISIBLE locationIcon?.visibility = View.GONE busPositionsIconButton?.visibility = View.GONE viewModel.setMapShowing(false) if(usingMQTTPositions) livePositionsViewModel.stopMatoUpdates() //map.overlayManager.remove(busPositionsOverlay) switchButton.setImageDrawable(AppCompatResources.getDrawable(requireContext(), R.drawable.ic_map_white_30)) hideStopOrBusBottomSheet() if(locationComponent.isLocationComponentEnabled){ setLocationComponentEnabled(false) shouldMapLocationBeReactivated = true } else shouldMapLocationBeReactivated = false } private fun hideStopListAndShowMap(){ stopsRecyclerView.visibility = View.GONE mapView.visibility = View.VISIBLE locationIcon?.visibility = View.VISIBLE busPositionsIconButton.visibility = View.VISIBLE viewModel.setMapShowing(true) //map.overlayManager.add(busPositionsOverlay) //map. if(usingMQTTPositions) livePositionsViewModel.requestMatoPosUpdates(GtfsUtils.getLineNameFromGtfsID(lineID)) else livePositionsViewModel.requestGTFSUpdates() switchButton.setImageDrawable(AppCompatResources.getDrawable(requireContext(), R.drawable.ic_list_30)) if(shouldMapLocationBeReactivated){ setLocationComponentEnabled(Permissions.bothLocationPermissionsGranted(requireContext())) } } override fun setLocationIconEnabled(enabled: Boolean){ if(enabled) { locationIcon?.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_red)) } else { locationIcon?.setImageDrawable(ContextCompat.getDrawable( requireContext(), R.drawable.location_circlew_grey ) ) } } override fun onMapLocationEnabled(active: Boolean) { //extra thing: show the toast showToastLocation(active) } override fun onMapLocationComponentInitialized() { //enable the position after the first fix //onMapLocationEnabled(true) } @SuppressLint("MissingPermission") override fun onFirstReceivedLocation(location: Location) { if(mapInitialized){ val center = map!!.cameraPosition.target val newPos = LatLng(location.latitude, location.longitude) Log.d(DEBUG_TAG, "Center of the map : $center") val newStatus = if(center==null || newPos.distanceTo(center) > 20*1000){ Log.d(DEBUG_TAG, "Distance from center of map to location: "+center?.distanceTo(newPos)) if(!shownToastNoPosition) context?.let{ c-> Toast.makeText(c, R.string.too_far_not_showing_location, Toast.LENGTH_LONG).show() shownToastNoPosition = true } false } else{ true } if(!newStatus) setLocationComponentEnabled(newStatus) - mapStateViewModel.locationActive.value = newStatus + mapStateViewModel.locationUserActive.value = newStatus } } // ------------- Map Code ------------------------- /** * This method sets up the map and the layers */ override fun onMapReady(mapReady: MapLibreMap) { this.map = mapReady var setViewAlready = false 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) //checkInitMapLocation(mapReady, style,requireContext()) //if(!stopsLayerStarted) initPolylineStopsLayers(style, null) setupBusLayer(style) initSymbolManager(mapReady, style) toRunWhenMapReady?.run() toRunWhenMapReady = null mapInitialized = true if(patternShown!=null){ viewModel.stopsForPatternLiveData.value?.let { Log.d(DEBUG_TAG, "Show stops from the cache") displayPatternWithStopsOnMap(patternShown!!, it, true) //Show stop from cache mapStateViewModel.lastOpenStopID.value?.let{ sID-> val s= it.filter { stop -> stop.ID==sID } if (s.isEmpty()) { if(sID.isNotEmpty()) Log.w(DEBUG_TAG,"Wanted to open stop $sID in map but it was not loaded!") } else openStopInBottomSheet(s[0]) } } } var restoredMapState = mapStateViewModel.restoreMapState(mapReady) arguments?.let { args -> // if there is a Camera State in the arguments, set it for the new camera (doesn't work yet!) if (!restoredMapState && MapCameraState.checkInBundle(args)) { val initCamState = MapCameraState.fromBundle(args) //map?.let{ MapStateViewModel.restoreMapState(mapReady, initCamState) setViewAlready = true restoredMapState = true } } restoredCameraInMap = restoredMapState } mapReady.addOnMapClickListener { point -> val screenPoint = mapReady.projection.toScreenLocation(point) val stopsNearby = mapReady.queryRenderedFeatures(screenPoint, STOPS_LAYER_ID) val busNearby = mapReady.queryRenderedFeatures(screenPoint, BUSES_LAYER_ID) //Log.d(DEBUG_TAG, "onMapClick, stopsNearby: $stopsNearby \nstopShown: $shownStopInBottomSheet \nbusNearby: $busNearby,") if (stopsNearby.isNotEmpty()) { val feature = stopsNearby[0] val id = feature.getStringProperty("id") val stop = viewModel.getStopByID(id) stop?.let { if (isBottomSheetShowing() || vehShowing.isNotEmpty()) { hideStopOrBusBottomSheet() } 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] openBusFromMapClick(feature) return@addOnMapClickListener true } false } // we start requesting the bus positions now observeBusPositionUpdates() } val zoom = 12.0 val latlngTarget = LatLng(MapLibreFragment.DEFAULT_CENTER_LAT, MapLibreFragment.DEFAULT_CENTER_LON) if(!setViewAlready) mapReady.cameraPosition = savedCameraPosition ?:CameraPosition.Builder().target(latlngTarget).zoom(zoom).build() savedCameraPosition = null if(shouldMapLocationBeReactivated) mapReady.style?.let{ checkInitMapLocation(mapReady,it, context)} } override fun showOpenStopWithSymbolLayer(): Boolean { return true } /** * Separate function to find the vehicle associated with a feature and display it */ private fun openBusFromMapClick(feature: Feature){ val vehid = feature.getStringProperty("veh") if(isBottomSheetShowing()) hideStopOrBusBottomSheet() showVehicleTripInBottomSheet(vehid) updatesByVehDict[vehid]?.let { map?.animateCamera( CameraUpdateFactory.newLatLng(LatLng(it.posUpdate.latitude, it.posUpdate.longitude)), 750 ) } } private fun observeBusPositionUpdates(){ //live bus positions livePositionsViewModel.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, hasVehicleTracking = true) { veh-> showVehicleTripInBottomSheet(veh) } //if not using MQTT positions if(!usingMQTTPositions){ livePositionsViewModel.requestDelayedGTFSUpdates(2000) } } //download missing tripIDs livePositionsViewModel.tripsGtfsIDsToQuery.observe(viewLifecycleOwner){ //gtfsPosViewModel.downloadTripsFromMato(dat); MatoTripsDownloadWorker.requestMatoTripsDownload( it, requireContext().applicationContext, "BusTO-MatoTripDownload" ) } } private fun showVehicleTripInBottomSheet(veh: String) { super.showVehicleTripInBottomSheet(veh) { patternCode, veh -> //this is checked in @GeneralMapLibreFragment //val data = updatesByVehDict[veh] ?: return@showVehicleTripInBottomSheet if (patternCode.isEmpty()) return@showVehicleTripInBottomSheet if (patternShown?.pattern?.code == patternCode) { //center view on vehicle updatesByVehDict[veh]?.let { up-> map?.let{ /* val c = it.cameraPosition it.moveCamera(CameraUpdateFactory.CameraPositionUpdate(c.bearing, LatLng(up.posUpdate.latitude, up.posUpdate.longitude), c.tilt,c.zoom, c.padding) ) */ it.animateCamera(CameraUpdateFactory.newLatLng(LatLng(up.posUpdate.latitude, up.posUpdate.longitude))) } } ?: { Toast.makeText(context, R.string.showing_same_direction, Toast.LENGTH_SHORT).show() } } else { showPatternWithCode(patternCode) } } } // ------- MAP LAYERS INITIALIZE ---- /** * Initialize the map layers for the stops */ private fun initPolylineStopsLayers(style: Style, arrowFeatures: FeatureCollection?){ Log.d(DEBUG_TAG, "INIT STOPS CALLED") stopsSource = GeoJsonSource(STOPS_SOURCE_ID) //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 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)!!) 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(arrowsLayer, POLYLINE_LAYER) stopsLayerStarted = true initStopsLayer(style, null, POLY_ARROWS_LAYER) } private fun filterPatternFromArgs(patterns: List): MatoPatternWithStops?{ var p: MatoPatternWithStops? = null if (patternIdToShow.isNotEmpty()){ for (patt in patterns) { if (patt.pattern.code == patternIdToShow){ p = patt } } if(p==null) Log.w(DEBUG_TAG, "We had to show the pattern with code $patternIdToShow, but we didn't find it") else Log.d(DEBUG_TAG, "Requesting to show pattern with code $patternIdToShow, found pattern ${p.pattern.code}") } // if we are loading from a stop, find it else if(stopIDFromToShow.isNotEmpty()) { val stopGtfsID = "gtt:$stopIDFromToShow" var pLength = 0 for (patt in patterns) { 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 } } } if(p==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 ${p.pattern.code}") } // the flag of showing pattern is not necessary anymore, we have set the pattern patternIdToShow = "" // the flag of selecting from stop needs to be used again when displaying the pattern return p } /** * 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() } val patternToShow = filterPatternFromArgs(currentPatterns) if(patternToShow!=null) { //showPattern(patternToShow) patternShown = patternToShow } 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 } 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") // this triggers the showing on the map / recyclerview if (pos !=-2) patternsSpinner.setSelection(pos) else Log.e(DEBUG_TAG, "Pattern with code $code not found!!") } /** * Zoom on the map to get the pattern */ 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){ //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") 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!!") } var reallyZoomToPattern = zoomToPattern if(stopIDFromToShow.isNotEmpty()){ //open the stop val stopfilt = stopsSorted.filter { s -> s.ID == stopIDFromToShow } if (stopfilt.isEmpty()){ Log.e(DEBUG_TAG, "Tried to show stop but it's not in the selected pattern") } else{ val stop = stopfilt[0] openStopInBottomSheet(stop) if(stop.hasCoords()) { reallyZoomToPattern = false setCameraPosition(stop.latitude!!, stop.longitude!!, 13.5) } } // Reset this to avoid checking again when showing stopIDFromToShow = "" //camera set } if(reallyZoomToPattern) zoomToCurrentPattern() } 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 } } } override fun onResume() { super.onResume() Log.d(DEBUG_TAG, "Resetting paused from onResume") pausedFragment = false val keySourcePositions = getString(R.string.pref_positions_source) usingMQTTPositions = PreferenceManager.getDefaultSharedPreferences(requireContext()) .getString(keySourcePositions, "mqtt").contentEquals("mqtt") //separate paths if(usingMQTTPositions) livePositionsViewModel.requestMatoPosUpdates(GtfsUtils.getLineNameFromGtfsID(lineID)) else livePositionsViewModel.requestGTFSUpdates() //initialize GUI here fragmentListener?.readyGUIfor(FragmentKind.LINES) } override fun onPause() { super.onPause() mapView.onPause() if(usingMQTTPositions) livePositionsViewModel.stopMatoUpdates() pausedFragment = true //save map map?.let{ //if map is initialized mapStateViewModel.saveMapState(it) } mapStateViewModel.lastOpenStopID.postValue(shownStopInBottomSheet?.ID) } override fun onStop() { super.onStop() mapView.onStop() if(locationInitialized) shouldMapLocationBeReactivated = locationComponent.isLocationComponentEnabled else shouldMapLocationBeReactivated = false } 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 setLocationComponentEnabled(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 PATTERN_SHOW_KEY ="patternIDShow" 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 makeArgsPattern(lineID: String, patternShow: String?, extraArgs: Bundle?): Bundle { val b= extraArgs ?: Bundle() b.putString(LINEID_KEY, lineID) b.putString(PATTERN_SHOW_KEY, patternShow) 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 } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/MainScreenFragment.java b/app/src/main/java/it/reyboz/bustorino/fragments/MainScreenFragment.java index 512782e..70ed479 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/MainScreenFragment.java +++ b/app/src/main/java/it/reyboz/bustorino/fragments/MainScreenFragment.java @@ -1,906 +1,906 @@ /* BusTO - Fragments components Copyright (C) 2021 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.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.location.Criteria; import android.location.Location; import android.net.Uri; import android.os.Build; import android.os.Bundle; import androidx.activity.result.ActivityResultCallback; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.AppCompatImageButton; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.core.app.ActivityCompat; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import android.os.Handler; import android.util.Log; import android.view.LayoutInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputMethodManager; import android.widget.EditText; import android.widget.FrameLayout; import android.widget.ProgressBar; import android.widget.Toast; import com.google.android.material.floatingactionbutton.FloatingActionButton; import java.util.List; import java.util.Map; import it.reyboz.bustorino.R; import it.reyboz.bustorino.backend.*; import it.reyboz.bustorino.data.PreferencesHolder; import it.reyboz.bustorino.middleware.AppLocationManager; import it.reyboz.bustorino.middleware.AsyncArrivalsSearcher; import it.reyboz.bustorino.middleware.AsyncStopsSearcher; import it.reyboz.bustorino.middleware.BarcodeScanContract; import it.reyboz.bustorino.middleware.BarcodeScanOptions; import it.reyboz.bustorino.middleware.BarcodeScanUtils; import it.reyboz.bustorino.util.LocationCriteria; import it.reyboz.bustorino.util.Permissions; import static it.reyboz.bustorino.backend.utils.getBusStopIDFromUri; import static it.reyboz.bustorino.util.Permissions.LOCATION_PERMISSIONS; /** * A simple {@link Fragment} subclass. * Use the {@link MainScreenFragment#newInstance} factory method to * create an instance of this fragment. */ public class MainScreenFragment extends ScreenBaseFragment implements FragmentListenerMain{ private static final String SAVED_FRAGMENT="saved_fragment"; private static final String DEBUG_TAG = "BusTO - MainFragment"; public static final String PENDING_STOP_SEARCH="PendingStopSearch"; public final static String FRAGMENT_TAG = "MainScreenFragment"; private FragmentHelper fragmentHelper; private SwipeRefreshLayout swipeRefreshLayout; private EditText busStopSearchByIDEditText; private EditText busStopSearchByNameEditText; private ProgressBar progressBar; private MenuItem actionHelpMenuItem; private FloatingActionButton floatingActionButton; private FrameLayout resultFrameLayout; private boolean setupOnStart = true; private boolean suppressArrivalsReload = false; //private Snackbar snackbar; /* * Search mode */ private static final int SEARCH_BY_NAME = 0; private static final int SEARCH_BY_ID = 1; //private static final int SEARCH_BY_ROUTE = 2; // implement this -- DONE! private int searchMode; //private ImageButton addToFavorites; //// HIDDEN BUT IMPORTANT ELEMENTS //// FragmentManager childFragMan; Handler mainHandler; private final Runnable refreshStop = new Runnable() { public void run() { if(getContext() == null) return; if (childFragMan.findFragmentById(R.id.resultFrame) instanceof ArrivalsFragment) { ArrivalsFragment fragment = (ArrivalsFragment) childFragMan.findFragmentById(R.id.resultFrame); if (fragment == null){ //we create a new fragment, which is WRONG Log.e("BusTO-RefreshStop", "Asking for refresh when there is no fragment"); // AsyncDataDownload(fragmentHelper, arrivalsFetchers,getContext()).execute(); } else{ //String stopName = fragment.getStopID(); //new AsyncArrivalsSearcher(fragmentHelper, fragment.getCurrentFetchersAsArray(), getContext()).execute(stopName); fragment.requestArrivalsForTheFragment(); } } else { //we create a new fragment, which is WRONG List fetcherList = utils.getDefaultArrivalsFetchers(getContext()); ArrivalsFetcher[] arrivalsFetchers = new ArrivalsFetcher[fetcherList.size()]; arrivalsFetchers = fetcherList.toArray(arrivalsFetchers); new AsyncArrivalsSearcher(fragmentHelper, arrivalsFetchers, getContext()).execute(); } } }; // private final ActivityResultLauncher barcodeLauncher = registerForActivityResult(new BarcodeScanContract(), result -> { if(result!=null && result.getContents()!=null) { //Toast.makeText(MyActivity.this, "Cancelled", Toast.LENGTH_LONG).show(); Uri uri; try { uri = Uri.parse(result.getContents()); // this apparently prevents NullPointerException. Somehow. } catch (NullPointerException e) { if (getContext()!=null) Toast.makeText(getContext().getApplicationContext(), R.string.no_qrcode, Toast.LENGTH_SHORT).show(); return; } String busStopID = getBusStopIDFromUri(uri); busStopSearchByIDEditText.setText(busStopID); requestArrivalsForStopID(busStopID); } else { //Toast.makeText(MyActivity.this, "Scanned: " + result.getContents(), Toast.LENGTH_LONG).show(); if (getContext()!=null) Toast.makeText(getContext().getApplicationContext(), R.string.no_qrcode, Toast.LENGTH_SHORT).show(); } }); /// LOCATION STUFF /// boolean pendingIntroRun = false; boolean pendingNearbyStopsFragmentRequest = false; boolean locationPermissionGranted, locationPermissionAsked = false; AppLocationManager locationManager; private final ActivityResultLauncher requestPermissionLauncher = - registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), new ActivityResultCallback>() { + registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), new ActivityResultCallback<>() { @Override public void onActivityResult(Map result) { - if(result==null) return; + if (result == null) return; - if(result.get(Manifest.permission.ACCESS_COARSE_LOCATION) == null || + if (result.get(Manifest.permission.ACCESS_COARSE_LOCATION) == null || result.get(Manifest.permission.ACCESS_FINE_LOCATION) == null) return; - Log.d(DEBUG_TAG, "Permissions for location are: "+result); - if(Boolean.TRUE.equals(result.get(Manifest.permission.ACCESS_COARSE_LOCATION)) - || Boolean.TRUE.equals(result.get(Manifest.permission.ACCESS_FINE_LOCATION))){ + Log.d(DEBUG_TAG, "Permissions for location are: " + result); + if (Boolean.TRUE.equals(result.get(Manifest.permission.ACCESS_COARSE_LOCATION)) + || Boolean.TRUE.equals(result.get(Manifest.permission.ACCESS_FINE_LOCATION))) { locationPermissionGranted = true; Log.w(DEBUG_TAG, "Starting position"); - if (mListener!= null && getContext()!=null){ - if (locationManager==null) + if (mListener != null && getContext() != null) { + if (locationManager == null) locationManager = AppLocationManager.getInstance(getContext()); locationManager.addLocationRequestFor(requester); } // show nearby fragment //showNearbyStopsFragment(); Log.d(DEBUG_TAG, "We have location permission"); - if(pendingNearbyStopsFragmentRequest){ + if (pendingNearbyStopsFragmentRequest) { showNearbyFragmentIfPossible(); pendingNearbyStopsFragmentRequest = false; } } - if(pendingNearbyStopsFragmentRequest) pendingNearbyStopsFragmentRequest =false; + if (pendingNearbyStopsFragmentRequest) pendingNearbyStopsFragmentRequest = false; } }); private final LocationCriteria cr = new LocationCriteria(2000, 10000); //Location private AppLocationManager.LocationRequester requester = new AppLocationManager.LocationRequester() { @Override public void onLocationChanged(Location loc) { } @Override public void onLocationStatusChanged(int status) { if(status == AppLocationManager.LOCATION_GPS_AVAILABLE && !isNearbyFragmentShown() && checkLocationPermission()){ //request Stops //pendingNearbyStopsRequest = false; if (getContext()!= null && !isNearbyFragmentShown()) //mainHandler.post(new NearbyStopsRequester(getContext(), cr)); showNearbyFragmentIfPossible(); } } @Override public long getLastUpdateTimeMillis() { return 50; } @Override public LocationCriteria getLocationCriteria() { return cr; } @Override public void onLocationProviderAvailable() { //Log.w(DEBUG_TAG, "pendingNearbyStopRequest: "+pendingNearbyStopsRequest); if(!isNearbyFragmentShown() && getContext()!=null){ // we should have the location permission if(!checkLocationPermission()) Log.e(DEBUG_TAG, "Asking to show nearbystopfragment when " + "we have no location permission"); pendingNearbyStopsFragmentRequest = true; //mainHandler.post(new NearbyStopsRequester(getContext(), cr)); showNearbyFragmentIfPossible(); } } @Override public void onLocationDisabled() { } }; //// ACTIVITY ATTACHED (LISTENER /// private CommonFragmentListener mListener; private String pendingStopID = null; private CoordinatorLayout coordLayout; public MainScreenFragment() { // Required empty public constructor } public static MainScreenFragment newInstance() { return new MainScreenFragment(); } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (getArguments() != null) { //do nothing Log.d(DEBUG_TAG, "ARGS ARE NOT NULL: "+getArguments()); if (getArguments().getString(PENDING_STOP_SEARCH)!=null) pendingStopID = getArguments().getString(PENDING_STOP_SEARCH); } } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { // Inflate the layout for this fragment View root = inflater.inflate(R.layout.fragment_main_screen, container, false); /// UI ELEMENTS // busStopSearchByIDEditText = root.findViewById(R.id.busStopSearchByIDEditText); busStopSearchByNameEditText = root.findViewById(R.id.busStopSearchByNameEditText); progressBar = root.findViewById(R.id.progressBar); swipeRefreshLayout = root.findViewById(R.id.listRefreshLayout); floatingActionButton = root.findViewById(R.id.floatingActionButton); resultFrameLayout = root.findViewById(R.id.resultFrame); busStopSearchByIDEditText.setSelectAllOnFocus(true); busStopSearchByIDEditText .setOnEditorActionListener((v, actionId, event) -> { // IME_ACTION_SEARCH alphabetical option if (actionId == EditorInfo.IME_ACTION_SEARCH) { onSearchClick(v); return true; } return false; }); busStopSearchByNameEditText .setOnEditorActionListener((v, actionId, event) -> { // IME_ACTION_SEARCH alphabetical option if (actionId == EditorInfo.IME_ACTION_SEARCH) { onSearchClick(v); return true; } return false; }); swipeRefreshLayout .setOnRefreshListener(() -> mainHandler.post(refreshStop)); swipeRefreshLayout.setColorSchemeResources(R.color.blue_500, R.color.orange_500); coordLayout = root.findViewById(R.id.coord_layout); floatingActionButton.setOnClickListener((this::onToggleKeyboardLayout)); AppCompatImageButton qrButton = root.findViewById(R.id.QRButton); qrButton.setOnClickListener(this::onQRButtonClick); AppCompatImageButton searchButton = root.findViewById(R.id.searchButton); searchButton.setOnClickListener(this::onSearchClick); // Fragment stuff childFragMan = getChildFragmentManager(); childFragMan.addOnBackStackChangedListener(() -> Log.d("BusTO Main Fragment", "BACK STACK CHANGED")); fragmentHelper = new FragmentHelper(this, getChildFragmentManager(), getContext(), R.id.resultFrame); setSearchModeBusStopID(); cr.setAccuracy(Criteria.ACCURACY_FINE); cr.setAltitudeRequired(false); cr.setBearingRequired(false); cr.setCostAllowed(true); cr.setPowerRequirement(Criteria.NO_REQUIREMENT); locationManager = AppLocationManager.getInstance(requireContext()); Log.d(DEBUG_TAG, "OnCreateView, savedInstanceState null: "+(savedInstanceState==null)); return root; } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); Log.d(DEBUG_TAG, "onViewCreated, SwipeRefreshLayout visible: "+(swipeRefreshLayout.getVisibility()==View.VISIBLE)); Log.d(DEBUG_TAG, "Saved instance state is: "+savedInstanceState); //Restore instance state /*if (savedInstanceState!=null){ Fragment fragment = getChildFragmentManager().getFragment(savedInstanceState, SAVED_FRAGMENT); if (fragment!=null){ getChildFragmentManager().beginTransaction().add(R.id.resultFrame, fragment).commit(); setupOnStart = false; } } */ if (getChildFragmentManager().findFragmentById(R.id.resultFrame)!= null){ swipeRefreshLayout.setVisibility(View.VISIBLE); } } @Override public void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); Log.d(DEBUG_TAG, "Saving instance state"); Fragment fragment = getChildFragmentManager().findFragmentById(R.id.resultFrame); if (fragment!=null) getChildFragmentManager().putFragment(outState, SAVED_FRAGMENT, fragment); if (fragmentHelper!=null) fragmentHelper.setBlockAllActivities(true); } public void setSuppressArrivalsReload(boolean value){ suppressArrivalsReload = value; // we have to suppress the reloading of the (possible) ArrivalsFragment /*if(value) { Fragment fragment = getChildFragmentManager().findFragmentById(R.id.resultFrame); if (fragment instanceof ArrivalsFragment) { ArrivalsFragment frag = (ArrivalsFragment) fragment; frag.setReloadOnResume(false); } } */ } /** * Cancel the reload of the arrival times * because we are going to pop the fragment */ public void cancelReloadArrivalsIfNeeded(){ if(getContext()==null) return; //we are not attached //Fragment fr = getChildFragmentManager().findFragmentById(R.id.resultFrame); fragmentHelper.stopLastRequestIfNeeded(true); toggleSpinner(false); } @Override public void onAttach(@NonNull Context context) { super.onAttach(context); Log.d(DEBUG_TAG, "OnAttach called, setupOnAttach: "+ setupOnStart); mainHandler = new Handler(); if (context instanceof CommonFragmentListener) { mListener = (CommonFragmentListener) context; } else { throw new RuntimeException(context + " must implement CommonFragmentListener"); } } @Override public void onDetach() { super.onDetach(); mListener = null; // setupOnAttached = true; } @Override public void onStart() { super.onStart(); Log.d(DEBUG_TAG, "onStart called, setupOnStart: "+setupOnStart); if (setupOnStart) { if (pendingStopID==null){ if(PreferencesHolder.hasIntroFinishedOneShot(requireContext())){ Log.d(DEBUG_TAG, "Showing nearby stops"); if(!checkLocationPermission()){ requestLocationPermission(); pendingNearbyStopsFragmentRequest = true; } else { showNearbyFragmentIfPossible(); } } else { //The Introductory Activity is about to be started, hence pause the request and show later pendingIntroRun = true; } } else{ ///TODO: if there is a stop displayed, we need to hold the update } setupOnStart = false; } } @Override public void onResume() { super.onResume(); final Context con = requireContext(); Log.w(DEBUG_TAG, "OnResume called, setupOnStart: "+ setupOnStart); if (locationManager == null) locationManager = AppLocationManager.getInstance(con); //recheck the introduction activity has been run if(pendingIntroRun && PreferencesHolder.hasIntroFinishedOneShot(con)){ //request position permission if needed if(!checkLocationPermission()){ requestLocationPermission(); pendingNearbyStopsFragmentRequest = true; } else { showNearbyFragmentIfPossible(); } //deactivate flag pendingIntroRun = false; } if(Permissions.bothLocationPermissionsGranted(con)){ Log.d(DEBUG_TAG, "Location permission OK"); if(!locationManager.isRequesterRegistered(requester)) locationManager.addLocationRequestFor(requester); } //don't request permission // if we have a pending stopID request, do it Log.d(DEBUG_TAG, "Pending stop ID for arrivals: "+pendingStopID); //this is the second time we are attaching this fragment -> Log.d(DEBUG_TAG, "Waiting for new stop request: "+ suppressArrivalsReload); //TODO: if we come back to this from another fragment, and the user has given again the permission // for the Location, we should show the Nearby Stops if(!suppressArrivalsReload && pendingStopID==null){ //none of the following cases are true // check if we are showing any fragment final Fragment fragment = getChildFragmentManager().findFragmentById(R.id.resultFrame); if(fragment==null || swipeRefreshLayout.getVisibility() != View.VISIBLE){ //we are not showing anything if(Permissions.anyLocationPermissionsGranted(getContext())){ showNearbyFragmentIfPossible(); } } } if (suppressArrivalsReload){ // we have to suppress the reloading of the (possible) ArrivalsFragment Fragment fragment = getChildFragmentManager().findFragmentById(R.id.resultFrame); if (fragment instanceof ArrivalsFragment){ ArrivalsFragment frag = (ArrivalsFragment) fragment; frag.setReloadOnResume(false); } //deactivate suppressArrivalsReload = false; } if(pendingStopID!=null){ Log.d(DEBUG_TAG, "Pending request for arrivals at stop ID: "+pendingStopID); requestArrivalsForStopID(pendingStopID); pendingStopID = null; } mListener.readyGUIfor(FragmentKind.MAIN_SCREEN_FRAGMENT); fragmentHelper.setBlockAllActivities(false); } @Override public void onPause() { //mainHandler = null; locationManager.removeLocationRequestFor(requester); super.onPause(); fragmentHelper.setBlockAllActivities(true); fragmentHelper.stopLastRequestIfNeeded(true); } /* GUI METHODS */ /** * QR scan button clicked * * @param v View QRButton clicked */ public void onQRButtonClick(View v) { BarcodeScanOptions scanOptions = new BarcodeScanOptions(); Intent intent = scanOptions.createScanIntent(); if(!BarcodeScanUtils.checkTargetPackageExists(getContext(), intent)){ BarcodeScanUtils.showDownloadDialog(null, this); }else { barcodeLauncher.launch(scanOptions); } } /** * OK this is pure shit * * @param v View clicked */ public void onSearchClick(View v) { final StopsFinderByName[] stopsFinderByNames = new StopsFinderByName[]{new GTTStopsFetcher(), new FiveTStopsFetcher()}; if (searchMode == SEARCH_BY_ID) { String busStopID = busStopSearchByIDEditText.getText().toString(); fragmentHelper.stopLastRequestIfNeeded(true); requestArrivalsForStopID(busStopID); } else { // searchMode == SEARCH_BY_NAME String query = busStopSearchByNameEditText.getText().toString(); query = query.trim(); if(getContext()!=null) { if (query.length() < 1) { Toast.makeText(getContext(), R.string.insert_bus_stop_name_error, Toast.LENGTH_SHORT).show(); } else if(query.length()< 2){ Toast.makeText(getContext(), R.string.query_too_short, Toast.LENGTH_SHORT).show(); } else { fragmentHelper.stopLastRequestIfNeeded(true); new AsyncStopsSearcher(fragmentHelper, stopsFinderByNames).execute(query); } } } } public void onToggleKeyboardLayout(View v) { if (searchMode == SEARCH_BY_NAME) { setSearchModeBusStopID(); if (busStopSearchByIDEditText.requestFocus()) { showKeyboard(); } } else { // searchMode == SEARCH_BY_ID setSearchModeBusStopName(); if (busStopSearchByNameEditText.requestFocus()) { showKeyboard(); } } } @Override public void enableRefreshLayout(boolean yes) { swipeRefreshLayout.setEnabled(yes); } ////////////////////////////////////// GUI HELPERS ///////////////////////////////////////////// public void showKeyboard() { if(getActivity() == null) return; InputMethodManager imm = (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE); View view = searchMode == SEARCH_BY_ID ? busStopSearchByIDEditText : busStopSearchByNameEditText; imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT); } private void setSearchModeBusStopID() { searchMode = SEARCH_BY_ID; busStopSearchByNameEditText.setVisibility(View.GONE); busStopSearchByNameEditText.setText(""); busStopSearchByIDEditText.setVisibility(View.VISIBLE); floatingActionButton.setImageResource(R.drawable.alphabetical); } private void setSearchModeBusStopName() { searchMode = SEARCH_BY_NAME; busStopSearchByIDEditText.setVisibility(View.GONE); busStopSearchByIDEditText.setText(""); busStopSearchByNameEditText.setVisibility(View.VISIBLE); floatingActionButton.setImageResource(R.drawable.numeric); } protected boolean isNearbyFragmentShown(){ Fragment fragment = getChildFragmentManager().findFragmentByTag(NearbyStopsFragment.FRAGMENT_TAG); return (fragment!= null && fragment.isResumed()); } /** * Having that cursor at the left of the edit text makes me cancer. * * @param busStopID bus stop ID */ private void setBusStopSearchByIDEditText(String busStopID) { busStopSearchByIDEditText.setText(busStopID); busStopSearchByIDEditText.setSelection(busStopID.length()); } @Nullable @org.jetbrains.annotations.Nullable @Override public View getBaseViewForSnackBar() { return coordLayout; } @Override public void toggleSpinner(boolean enable) { if (enable) { //already set by the RefreshListener when needed //swipeRefreshLayout.setRefreshing(true); progressBar.setVisibility(View.VISIBLE); } else { swipeRefreshLayout.setRefreshing(false); progressBar.setVisibility(View.GONE); } } private void prepareGUIForArrivals() { swipeRefreshLayout.setEnabled(true); swipeRefreshLayout.setVisibility(View.VISIBLE); //actionHelpMenuItem.setVisible(true); } private void prepareGUIForBusStops() { swipeRefreshLayout.setEnabled(false); swipeRefreshLayout.setVisibility(View.VISIBLE); //actionHelpMenuItem.setVisible(false); } private void actuallyShowNearbyStopsFragment(){ swipeRefreshLayout.setVisibility(View.VISIBLE); final Fragment existingFrag = childFragMan.findFragmentById(R.id.resultFrame); // fragment; if (!(existingFrag instanceof NearbyStopsFragment)){ Log.d(DEBUG_TAG, "actually showing Nearby Stops Fragment"); //there is no fragment showing final NearbyStopsFragment fragment = NearbyStopsFragment.newInstance(NearbyStopsFragment.FragType.STOPS); FragmentTransaction ft = childFragMan.beginTransaction(); ft.replace(R.id.resultFrame, fragment, NearbyStopsFragment.FRAGMENT_TAG); if (getActivity()!=null && !getActivity().isFinishing()) ft.commit(); else Log.e(DEBUG_TAG, "Not showing nearby fragment because activity null or is finishing"); } } @Override public void showFloatingActionButton(boolean yes) { mListener.showFloatingActionButton(yes); } /** * This provides a temporary fix to make the transition * to a single asynctask go smoother * * @param fragmentType the type of fragment created */ @Override public void readyGUIfor(FragmentKind fragmentType) { //if we are getting results, already, stop waiting for nearbyStops if (fragmentType == FragmentKind.ARRIVALS || fragmentType == FragmentKind.STOPS) { hideKeyboard(); if (pendingNearbyStopsFragmentRequest) { locationManager.removeLocationRequestFor(requester); pendingNearbyStopsFragmentRequest = false; } } if (fragmentType == null) Log.e("ActivityMain", "Problem with fragmentType"); else switch (fragmentType) { case ARRIVALS: prepareGUIForArrivals(); break; case STOPS: prepareGUIForBusStops(); break; default: Log.d(DEBUG_TAG, "Fragment type is unknown"); return; } // Shows hints } @Override public void openLineFromStop(String routeGtfsId, @Nullable String stopIDFrom) { //pass to activity mListener.openLineFromStop(routeGtfsId, stopIDFrom); } @Override public void openLineFromVehicle(String routeGtfsId, @Nullable String optionalPatternId, @Nullable Bundle args) { mListener.openLineFromVehicle(routeGtfsId, optionalPatternId, args); } @Override public void showMapCenteredOnStop(Stop stop) { if(mListener!=null) mListener.showMapCenteredOnStop(stop); } /** * Main method for stops requests * @param ID the Stop ID */ @Override public void requestArrivalsForStopID(String ID) { if (!isResumed()){ //defer request pendingStopID = ID; Log.d(DEBUG_TAG, "Deferring update for stop "+ID+ " saved: "+pendingStopID); return; } final boolean delayedRequest = !(pendingStopID==null); final FragmentManager framan = getChildFragmentManager(); if (getContext()==null){ Log.e(DEBUG_TAG, "Asked for arrivals with null context"); return; } ArrivalsFetcher[] fetchers = utils.getDefaultArrivalsFetchers(getContext()).toArray(new ArrivalsFetcher[0]); if (ID == null || ID.length() <= 0) { // we're still in UI thread, no need to mess with Progress showToastMessage(R.string.insert_bus_stop_number_error, true); toggleSpinner(false); } else if (framan.findFragmentById(R.id.resultFrame) instanceof ArrivalsFragment) { ArrivalsFragment fragment = (ArrivalsFragment) framan.findFragmentById(R.id.resultFrame); if (fragment != null && fragment.getStopID() != null && fragment.getStopID().equals(ID)){ // Run with previous fetchers //fragment.getCurrentFetchers().toArray() fragment.requestArrivalsForTheFragment(); } else{ //SHOW NEW ARRIVALS FRAGMENT //new AsyncArrivalsSearcher(fragmentHelper, fetchers, getContext()).execute(ID); fragmentHelper.createOrUpdateStopFragment(new Palina(ID), true); } } else { Log.d(DEBUG_TAG, "This is probably the first arrivals search, preparing GUI"); //prepareGUIForArrivals(); //new AsyncArrivalsSearcher(fragmentHelper,fetchers, getContext()).execute(ID); fragmentHelper.createOrUpdateStopFragment(new Palina(ID), true); } } private boolean checkLocationPermission(){ final Context context = getContext(); if(context==null) return false; final boolean isOldVersion = Build.VERSION.SDK_INT < Build.VERSION_CODES.M; final boolean noPermission = ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED; return isOldVersion || !noPermission; } private void requestLocationPermission(){ requestPermissionLauncher.launch(LOCATION_PERMISSIONS); } private void showNearbyFragmentIfPossible() { if (isNearbyFragmentShown()) { //nothing to do Log.w(DEBUG_TAG, "Asked to show nearby fragment but we already are showing it"); return; } if (getContext() == null) { Log.e(DEBUG_TAG, "Wanting to show nearby fragment but context is null"); return; } if (fragmentHelper.getLastSuccessfullySearchedBusStop() == null && !childFragMan.isDestroyed()) { //Go ahead with the request actuallyShowNearbyStopsFragment(); pendingNearbyStopsFragmentRequest = false; } } /////////// LOCATION METHODS ////////// /* private void startStopRequest(String provider) { Log.d(DEBUG_TAG, "Provider " + provider + " got enabled"); if (locmgr != null && mainHandler != null && pendingNearbyStopsRequest && locmgr.getProvider(provider).meetsCriteria(cr)) { } } */ /* * Run location requests separately and asynchronously class NearbyStopsRequester implements Runnable { Context appContext; Criteria cr; public NearbyStopsRequester(Context appContext, Criteria criteria) { this.appContext = appContext.getApplicationContext(); this.cr = criteria; } @Override public void run() { if(isNearbyFragmentShown()) { //nothing to do Log.w(DEBUG_TAG, "launched nearby fragment request but we already are showing"); return; } final boolean isOldVersion = Build.VERSION.SDK_INT < Build.VERSION_CODES.M; final boolean noPermission = ActivityCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED; //if we don't have the permission, we have to ask for it, if we haven't // asked too many times before if (noPermission) { if (!isOldVersion) { pendingNearbyStopsRequest = true; //Permissions.assertLocationPermissions(appContext,getActivity()); requestPermissionLauncher.launch(LOCATION_PERMISSIONS); Log.w(DEBUG_TAG, "Cannot get position: Asking permission, noPositionFromSys: " + noPermission); return; } else { Toast.makeText(appContext, "Asked for permission position too many times", Toast.LENGTH_LONG).show(); } } else setOption(LOCATION_PERMISSION_GIVEN, true); AppLocationManager appLocationManager = AppLocationManager.getInstance(appContext); final boolean haveProviders = appLocationManager.anyLocationProviderMatchesCriteria(cr); if (haveProviders && fragmentHelper.getLastSuccessfullySearchedBusStop() == null && !fragMan.isDestroyed()) { //Go ahead with the request Log.d("mainActivity", "Recreating stop fragment"); showNearbyStopsFragment(); pendingNearbyStopsRequest = false; } else if(!haveProviders){ Log.e(DEBUG_TAG, "NO PROVIDERS FOR POSITION"); } } } */ } \ 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 2c05198..4c7821d 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt @@ -1,748 +1,751 @@ /* BusTO - Fragments components Copyright (C) 2025 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.annotation.SuppressLint import android.content.Context import android.location.Location 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.widget.ImageButton import android.widget.RelativeLayout import android.widget.Toast import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.preference.PreferenceManager import androidx.room.concurrent.AtomicBoolean import com.google.android.material.bottomsheet.BottomSheetBehavior -import it.reyboz.bustorino.BuildConfig import it.reyboz.bustorino.R 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.MapLibreLocationEngine import it.reyboz.bustorino.map.MapLibreStyles import it.reyboz.bustorino.viewmodels.StopsMapViewModel 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.engine.LocationEngineCallback -import org.maplibre.android.location.engine.LocationEngineRequest import org.maplibre.android.location.engine.LocationEngineResult import org.maplibre.android.location.modes.CameraMode import org.maplibre.android.location.modes.RenderMode import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.Style import org.maplibre.android.plugins.annotation.Symbol import org.maplibre.geojson.Feature import org.maplibre.geojson.FeatureCollection /** * A simple [Fragment] subclass. * Use the [MapLibreFragment.newInstance] factory method to * create an instance of this fragment. */ class MapLibreFragment : GeneralMapLibreFragment() { private val stopsViewModel: StopsMapViewModel by viewModels() private var stopsShowing = ArrayList(0) // Sources for stops and buses are in GeneralMapLibreFragment private var isUserMovingCamera = false private var lastStopsSizeShown = 0 private var lastBBox = LatLngBounds.from(2.0, 2.0, 1.0,1.0) private var stopsRedrawnTimes = 0 //bottom Sheet behavior in GeneralMapLibreFragment //private var stopActiveSymbol: Symbol? = null // Location stuff private lateinit var locationManager: LocationManager private lateinit var userLocationButton: ImageButton private lateinit var centerUserButton: ImageButton private lateinit var followUserButton: ImageButton private var followingUserLocation = false private var ignoreCameraMovementForFollowing = true private var restoredMapCamera = AtomicBoolean() //BUS POSITIONS private var usingMQTTPositions = true // THIS IS INSIDE VIEW MODEL NOW 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) if (initialStopToShow==null){ } else if(!initialStopToShow!!.hasCoords()){ //null the stop if it doesn't have coordinates, we cannot find it initialStopToShow = null } } } 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) mapView.onCreate(savedInstanceState) 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) userLocationButton = rootView.findViewById(R.id.locationEnableIcon) userLocationButton.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 && locationInitialized && locationComponent.isLocationComponentEnabled){ // CameraMode.TRACKING makes the camera move and jump to the location setFollowUserLocation(!followingUserLocation) } } //locationManager = requireActivity().getSystemService(Context.LOCATION_SERVICE) as LocationManager /* if (Permissions.bothLocationPermissionsGranted(requireContext()) && deviceHasGpsProvider()) { 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() } // PERMISSIONS REQUESTED AFTER MAP SETUP } */ // Setup close button rootView.findViewById(R.id.btnClose).setOnClickListener { hideStopOrBusBottomSheet() } observeStatusLivePositions() //observe change in source of the live positions 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 if(vehShowing.isNotEmpty()) hideStopOrBusBottomSheet() clearAllBusPositionsInMap() } } } usingMQTTPositions = useMQTT } - mapStateViewModel.locationActive.observe(viewLifecycleOwner){ setLocationIconEnabled(it)} + mapStateViewModel.locationUserActive.observe(viewLifecycleOwner){ + setLocationIconEnabled(it)} mapStateViewModel.followingUserPosition.observe(viewLifecycleOwner){ updateFollowingIcon(it)} 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)) val builder = Style.Builder().fromJson(mjson!!) mapReady.setStyle(builder) { style -> mapStyle = style //setupLayers(style) addImagesStyle(style) //init stop layer with this val stopsInCache = stopsViewModel.getAllStopsLoaded() if(stopsInCache.isEmpty()) initStopsLayer(style, null) else displayStops(stopsInCache) if(showBusLayer) setupBusLayer(style, withLabels = true, busIconsScale = 1.2f) // Start observing data now that everything is set up observeStops() checkInitMapLocation(mapReady,style, context) } mapReady.addOnCameraIdleListener { map?.let { val newBbox = it.projection.visibleRegion.latLngBounds if ((newBbox.center==lastBBox.center) && (newBbox.latitudeSpan==lastBBox.latitudeSpan) && (newBbox.longitudeSpan==lastBBox.latitudeSpan)){ //do nothing } else { stopsViewModel.loadStopsInLatLngBounds(newBbox) lastBBox = newBbox } } } mapReady.addOnCameraMoveStartedListener { v-> if(v== MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE){ //the user is moving the map //isUserMovingCamera = true updateFollowingIcon(false) } } mapReady.addOnMapClickListener { point -> onMapClickReact(point) } // we start requesting the bus positions now observeBusPositionUpdates() //Restoring data if (initialStopToShow!=null && initialStopToShow?.hasCoords() == true){ val s = initialStopToShow!! if(s.hasCoords()){ mapReady.cameraPosition = CameraPosition.Builder().target( LatLng(s.latitude!!, s.longitude!!) ).zoom(DEFAULT_ZOOM).build() } restoredMapCamera.set(true) } else{ var boundsRestored = false //restore the map state here map?.let{ boundsRestored = mapStateViewModel.restoreMapState(it) mapStateViewModel.lastOpenStopID.value?.let{ sID-> val s= stopsViewModel.getStopByID(sID) if (s==null) { if(sID.isNotEmpty()) Log.w(DEBUG_TAG,"Wanted to open stop $sID in map but it was not loaded!") } else{ openStopInBottomSheet(s) } } } if(!boundsRestored){ // we have not restored the bounds, open normally in target location // TODO: check that the map is reopened in the same location val lastLoc = mapStateViewModel.locationToShow val defaultLoc = LatLng(DEFAULT_CENTER_LAT, DEFAULT_CENTER_LON) val proposedLoc = lastLoc?.let{ LatLng(lastLoc.latitude, lastLoc.longitude)} val targetLoc = if(proposedLoc == null || proposedLoc.distanceTo(defaultLoc) > MAX_DIST_KM*1000) defaultLoc else proposedLoc mapReady.cameraPosition = CameraPosition.Builder().target(targetLoc).zoom(DEFAULT_ZOOM).build() } restoredMapCamera.set(boundsRestored) } mapInitialized = true //pendingLocationActivation = true //positionRequestLauncher.launch(Permissions.LOCATION_PERMISSIONS) } private fun onMapClickReact(point: LatLng): Boolean{ map?.let { mapReady -> val screenPoint = mapReady.projection.toScreenLocation(point) val stopsFeatures = mapReady.queryRenderedFeatures(screenPoint, STOPS_LAYER_ID) val busNearby = mapReady.queryRenderedFeatures(screenPoint, BUSES_LAYER_ID) 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 Log.d(DEBUG_TAG, "Hiding clicked stop: $sameStopClicked") if (isBottomSheetShowing()) { hideStopOrBusBottomSheet() } 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") if (isBottomSheetShowing()) hideStopOrBusBottomSheet() showVehicleTripInBottomSheet(vehid) //move camera to center on vehicle updatesByVehDict[vehid]?.let { dat -> mapReady.animateCamera( CameraUpdateFactory.newLatLng(LatLng(dat.posUpdate.latitude, dat.posUpdate.longitude)), 750 ) } return true } } return false } override fun showOpenStopWithSymbolLayer(): Boolean { return false } override fun hideStopOrBusBottomSheet(){ if (shownStopInBottomSheet?.ID == initialStopToShow?.ID){ initialStopToShow = null } super.hideStopOrBusBottomSheet() } 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() } override fun onResume() { super.onResume() //mapView.onResume() handled in GeneralMapLibreFragment 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 } 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) } override fun onPause() { super.onPause() mapView.onPause() Log.d(DEBUG_TAG, "Fragment paused") map?.let{ //if map is initialized mapStateViewModel.saveMapState(it) } mapStateViewModel.lastOpenStopID.postValue(shownStopInBottomSheet?.ID) 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 if (locationInitialized) 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) } override fun getBaseViewForSnackBar(): View? { return mapView } private fun showVehicleTripInBottomSheet(veh: String) { val data = updatesByVehDict[veh] ?: return super.showVehicleTripInBottomSheet(veh) { patternCode, _ -> map?.let { mapStateViewModel.saveMapState(it) } fragmentListener?.openLineFromVehicle( data.posUpdate.getLineGTFSFormat(), patternCode, mapStateViewModel.savedCameraState?.toBundle() ) } } 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 })) { val stopToShow = stopsShowing.first { it.ID == s.ID } openStopInBottomSheet(stopToShow) 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 } } // --------------- 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: $mapInitialized" ) if (mapInitialized) updateBusPositionsInMap(data, hasVehicleTracking = true) { veh -> showVehicleTripInBottomSheet(veh) } if (!isDetached && !livePositionsViewModel.useMQTTPositionsLiveData.value!!) livePositionsViewModel.requestDelayedGTFSUpdates( 3000 ) } } // ------ LOCATION STUFF ----- @SuppressLint("MissingPermission") override fun onMapLocationComponentInitialized() { //locationComponent.cameraMode = CameraMode.TRACKING locationComponent.renderMode = RenderMode.COMPASS locationComponent.locationEngine?.apply{ // this is only called once getLastLocation(object : LocationEngineCallback { override fun onSuccess(res: LocationEngineResult?) { Log.d(DEBUG_TAG, "Got the last location, ${res?.lastLocation}") res?.lastLocation?.let { loc -> if(mapInitialized) map?.cameraPosition = CameraPosition.Builder().target(LatLng(loc.latitude, loc.longitude)).build() else mapStateViewModel.locationToShow = loc } } override fun onFailure(p0: java.lang.Exception) { - Log.e(DEBUG_TAG, "Failed to get the last location", p0) + if( p0 is MapLibreLocationEngine.NoLocationException) + Log.d(DEBUG_TAG, "Cannot find location: ${p0.message}") + else + Log.w(DEBUG_TAG, "Failed to get the last location, error: ${p0.message}",) } }) } } override fun onMapLocationEnabled(active: Boolean) { //Extra stuff to do setFollowUserLocation(active) } @SuppressLint("MissingPermission") override fun onFirstReceivedLocation(location: Location) { val it = location if(locationInitialized && !receivedFirstLocation) { //only zoom if the user position is close enough to the center val newPoint = LatLng(it.latitude, it.longitude) if(newPoint.distanceTo(LatLng( MapLibreFragment.DEFAULT_CENTER_LAT, MapLibreFragment.DEFAULT_CENTER_LON )) > MAX_DIST_KM * 1000){ //show Toast if(!shownToastNoPosition) context?.let{ c-> Toast.makeText(c, R.string.too_far_not_showing_location, Toast.LENGTH_LONG).show() shownToastNoPosition = true } setLocationComponentEnabled(false) //Update UI Status - mapStateViewModel.locationActive.value = false + mapStateViewModel.locationUserActive.value = false mapStateViewModel.followingUserPosition.value = false } else { map?.apply { animateCamera( CameraUpdateFactory.newCameraPosition( CameraPosition.Builder().target(LatLng(location.latitude, location.longitude)).build() ), 1000 ) setLocationComponentEnabled(true) locationComponent.cameraMode = CameraMode.TRACKING - mapStateViewModel.locationActive.value = true + mapStateViewModel.locationUserActive.value = true } setFollowUserLocation(true) } } else{ //check for this is when the map is used mapStateViewModel.locationToShow = location } } override fun setLocationIconEnabled(enabled: Boolean){ if (enabled) userLocationButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_red)) else userLocationButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_grey)) } 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)) } /** * This sets both the status on the component if it has been activated and the icon in the Fragment */ private fun setFollowUserLocation(enabled: Boolean){ if(locationInitialized) { if (enabled) locationComponent.cameraMode = CameraMode.TRACKING else locationComponent.cameraMode = CameraMode.NONE } //update the icon by updating the livedata mapStateViewModel.followingUserPosition.value = enabled } companion object { private const val STOPS_SOURCE_ID = "stops-source" private const val STOPS_LAYER_ID = "stops-layer" 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 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/fragments/NearbyStopsFragment.java b/app/src/main/java/it/reyboz/bustorino/fragments/NearbyStopsFragment.java index 6259bad..35df677 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/NearbyStopsFragment.java +++ b/app/src/main/java/it/reyboz/bustorino/fragments/NearbyStopsFragment.java @@ -1,634 +1,701 @@ /* BusTO - Fragments components Copyright (C) 2018 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.annotation.SuppressLint; import android.content.Context; import android.content.SharedPreferences; import android.location.Location; -import android.location.LocationManager; import android.os.Bundle; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.core.location.LocationListenerCompat; -import androidx.fragment.app.Fragment; import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProvider; import androidx.core.util.Pair; import androidx.preference.PreferenceManager; import androidx.appcompat.widget.AppCompatButton; import androidx.recyclerview.widget.RecyclerView; import androidx.work.WorkInfo; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ProgressBar; import android.widget.TextView; import it.reyboz.bustorino.BuildConfig; import it.reyboz.bustorino.R; import it.reyboz.bustorino.adapters.ArrivalsStopAdapter; import it.reyboz.bustorino.backend.*; import it.reyboz.bustorino.data.DatabaseUpdate; import it.reyboz.bustorino.adapters.SquareStopAdapter; import it.reyboz.bustorino.middleware.AutoFitGridLayoutManager; +import it.reyboz.bustorino.middleware.FusedNativeLocationProvider; import it.reyboz.bustorino.util.Permissions; import it.reyboz.bustorino.util.StopSorterByDistance; import it.reyboz.bustorino.viewmodels.NearbyStopsViewModel; import org.jetbrains.annotations.NotNull; import java.util.*; -public class NearbyStopsFragment extends Fragment { +public class NearbyStopsFragment extends ScreenBaseFragment { + + @Nullable + @Override + public View getBaseViewForSnackBar() { + return null; + } public enum FragType{ STOPS(1), ARRIVALS(2); private final int num; FragType(int num){ this.num = num; } public static FragType fromNum(int i){ switch (i){ case 1: return STOPS; case 2: return ARRIVALS; default: throw new IllegalArgumentException("type not recognized"); } } } private enum LocationShowingStatus {SEARCHING, FIRST_FIX, DISABLED, NO_PERMISSION} private FragmentListenerMain mListener; - private FragmentLocationListener fragmentLocationListener; private final static String DEBUG_TAG = "NearbyStopsFragment"; private final static String FRAGMENT_TYPE_KEY = "FragmentType"; //public final static int TYPE_STOPS = 19, TYPE_ARRIVALS = 20; private FragType fragment_type = FragType.STOPS; public final static String FRAGMENT_TAG="NearbyStopsFrag"; private RecyclerView gridRecyclerView; private SquareStopAdapter dataAdapter; private AutoFitGridLayoutManager gridLayoutManager; private GPSPoint lastPosition = null; private ProgressBar circlingProgressBar,flatProgressBar; //protected SharedPreferences globalSharedPref; //private SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener; private TextView messageTextView,titleTextView, loadingTextView; private CommonScrollListener scrollListener; private AppCompatButton switchButton; private boolean firstLocForStops = true,firstLocForArrivals = true; public static final int COLUMN_WIDTH_DP = 250; private Integer MAX_DISTANCE = -3; private int MIN_NUM_STOPS = -1; - private int TIME_INTERVAL_REQUESTS = -1; - private LocationManager locManager; //These are useful for the case of nearby arrivals private NearbyArrivalsDownloader arrivalsManager = null; private ArrivalsStopAdapter arrivalsStopAdapter = null; private ArrayList currentNearbyStops = new ArrayList<>(); - private NearbyArrivalsDownloader nearbyArrivalsDownloader; private LocationShowingStatus showingStatus = LocationShowingStatus.NO_PERMISSION; + private final FusedNativeLocationProvider.LocationUpdateListener locationUpdateListener = new FusedNativeLocationProvider.LocationUpdateListener() { + @Override + public void onLocationUpdate(@NotNull Location location) { + updateLocationViewModel(location); + } + + @Override + public void onFusedStatusChanged(boolean isEnabled) { + Log.d(DEBUG_TAG, "Location provider is enabled: " + isEnabled); + if(isEnabled){ + setShowingStatus(LocationShowingStatus.SEARCHING); + } else{ + setShowingStatus(LocationShowingStatus.DISABLED); + } + } + }; + private final FusedNativeLocationProvider.Options locationOptionsArrivals = new FusedNativeLocationProvider.Options(5*1000L, 50f), + locationOptionsStops = new FusedNativeLocationProvider.Options(1000L, 5f);; + + + + /* + TODO: we do not request the permission in this fragment, only showing it when we have the location. Request position if this changes. + private final ActivityResultLauncher permissionsResultLauncher = getPositionRequestLauncher( + granted ->{ + + } + ); + */ + private FusedNativeLocationProvider locationProvider = null; + + private final NearbyArrivalsDownloader.ArrivalsListener arrivalsListener = new NearbyArrivalsDownloader.ArrivalsListener() { @Override public void setProgress(int completedRequests, int pendingRequests) { if(flatProgressBar!=null) { if (pendingRequests == 0) { flatProgressBar.setIndeterminate(true); flatProgressBar.setVisibility(View.GONE); } else { flatProgressBar.setIndeterminate(false); flatProgressBar.setProgress(completedRequests); } } } @Override public void onAllRequestsCancelled() { if(flatProgressBar!=null) flatProgressBar.setVisibility(View.GONE); } @Override public void showCompletedArrivals(ArrayList completedPalinas) { showArrivalsInRecycler(completedPalinas); } }; //ViewModel private NearbyStopsViewModel viewModel; public NearbyStopsFragment() { // Required empty public constructor } /** * Use this factory method to create a new instance of * this fragment using the provided parameters. * @return A new instance of fragment NearbyStopsFragment. */ public static NearbyStopsFragment newInstance(FragType type) { //if(fragmentType != TYPE_STOPS && fragmentType != TYPE_ARRIVALS ) // throw new IllegalArgumentException("WRONG KIND OF FRAGMENT USED"); NearbyStopsFragment fragment = new NearbyStopsFragment(); final Bundle args = new Bundle(1); args.putInt(FRAGMENT_TYPE_KEY,type.num); fragment.setArguments(args); return fragment; } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (getArguments() != null) { setFragmentType(FragType.fromNum(getArguments().getInt(FRAGMENT_TYPE_KEY))); } - locManager = (LocationManager) requireContext().getSystemService(Context.LOCATION_SERVICE); - fragmentLocationListener = new FragmentLocationListener(); + //locManager = (LocationManager) requireContext().getSystemService(Context.LOCATION_SERVICE); + //fragmentLocationListener = new FragmentLocationListener(); if (getContext()!=null) { //globalSharedPref = getContext().getSharedPreferences(getString(R.string.mainSharedPreferences), Context.MODE_PRIVATE); //globalSharedPref.registerOnSharedPreferenceChangeListener(preferenceChangeListener); } - nearbyArrivalsDownloader = new NearbyArrivalsDownloader(getContext().getApplicationContext(), arrivalsListener); - - + //NearbyArrivalsDownloader nearbyArrivalsDownloader = new NearbyArrivalsDownloader(getContext().getApplicationContext(), arrivalsListener); + locationProvider = new FusedNativeLocationProvider(requireContext()); } @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { // Inflate the layout for this fragment if (getContext() == null) throw new RuntimeException(); View root = inflater.inflate(R.layout.fragment_nearby_stops, container, false); gridRecyclerView = root.findViewById(R.id.stopGridRecyclerView); gridLayoutManager = new AutoFitGridLayoutManager(getContext().getApplicationContext(), Float.valueOf(utils.convertDipToPixels(getContext(),COLUMN_WIDTH_DP)).intValue()); gridRecyclerView.setLayoutManager(gridLayoutManager); gridRecyclerView.setHasFixedSize(false); circlingProgressBar = root.findViewById(R.id.circularProgressBar); flatProgressBar = root.findViewById(R.id.horizontalProgressBar); messageTextView = root.findViewById(R.id.messageTextView); titleTextView = root.findViewById(R.id.titleTextView); loadingTextView = root.findViewById(R.id.positionLoadingTextView); switchButton = root.findViewById(R.id.switchButton); scrollListener = new CommonScrollListener(mListener,false); switchButton.setOnClickListener(v -> switchFragmentType()); if(BuildConfig.DEBUG) Log.d(DEBUG_TAG, "onCreateView"); final Context appContext =requireContext().getApplicationContext(); DatabaseUpdate.watchUpdateWorkStatus(getContext(), this, new Observer>() { @SuppressLint("MissingPermission") @Override public void onChanged(List workInfos) { if(workInfos.isEmpty()) { viewModel.setDBUpdateRunning(false); return; } WorkInfo wi = workInfos.get(0); - if (wi.getState() == WorkInfo.State.RUNNING && fragmentLocationListener.isRegistered) { - locManager.removeUpdates(fragmentLocationListener); - fragmentLocationListener.isRegistered = true; + if (wi.getState() == WorkInfo.State.RUNNING && locationProvider.isRunning()) { + locationProvider.stopUpdates(); viewModel.setDBUpdateRunning(true); } else{ //start the request - if(!fragmentLocationListener.isRegistered){ - requestLocationUpdates(); + if(Permissions.bothLocationPermissionsGranted(requireContext())) { + if(!locationProvider.isRunning()){ + startLocationUpdatesByType(); + } + } else{ + setShowingStatus(LocationShowingStatus.NO_PERMISSION); } + viewModel.setDBUpdateRunning(false); //actually restart request } } }); //observe the livedata viewModel.getStopsAtDistance().observe(getViewLifecycleOwner(), stops -> { Log.d(DEBUG_TAG, "Received "+stops.size()+" stops nearby"); Integer distance = viewModel.getDistanceMtLiveData().getValue(); if(distance == null){ distance = 40; } if ((stops.size() < MIN_NUM_STOPS && distance <= MAX_DISTANCE)) { viewModel.setDistance(distance + 40); //viewModel.requestStopsAtDistance(distance, true); //Log.d(DEBUG_TAG, "Doubling distance now!"); return; // THIS WORKS AS AN `else` } if(!stops.isEmpty()) { currentNearbyStops =stops; showStopsInViews(currentNearbyStops, lastPosition); } }); if(Permissions.anyLocationPermissionsGranted(appContext)){ setShowingStatus(LocationShowingStatus.SEARCHING); } else { setShowingStatus(LocationShowingStatus.NO_PERMISSION); } + //add location listener + locationProvider.addListener(locationUpdateListener); + return root; } //because linter is stupid and cannot look inside *anyLocationPermissionGranted* @SuppressLint("MissingPermission") private boolean requestLocationUpdates(){ if(Permissions.anyLocationPermissionsGranted(requireContext())) { - if (locManager.getAllProviders().contains(LocationManager.GPS_PROVIDER)) { - locManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, - 3000, 10.0f, fragmentLocationListener - ); - fragmentLocationListener.isRegistered = true; - } - fragmentLocationListener.isRegistered = false; + startLocationUpdatesByType(); return true; } else return false; } + /** + * Internal bit used to start location updates + */ + private void startLocationUpdatesByType(){ + switch (fragment_type) { + case STOPS: locationProvider.startUpdates(locationOptionsStops); break; + case ARRIVALS: locationProvider.startUpdates(locationOptionsArrivals); break; + } + } + /** * Use this method to set the fragment type * @param type the type, TYPE_ARRIVALS or TYPE_STOPS */ private void setFragmentType(FragType type){ + boolean isChanged = fragment_type != type; this.fragment_type = type; - switch(type){ + /*switch(type){ case ARRIVALS: TIME_INTERVAL_REQUESTS = 5*1000; break; case STOPS: TIME_INTERVAL_REQUESTS = 1000; } + + */ + if(isChanged){ + startLocationUpdatesByType(); + setShowingStatus(LocationShowingStatus.SEARCHING); + } + } + /** + * Set the location in the view model if it is good + * @param location new location + */ + private void updateLocationViewModel(@NonNull Location location, float accuracy){ + if(viewModel==null) { + return; + } + if(location.getAccuracy() stops, GPSPoint location){ if (stops.isEmpty()) { setNoStopsLayout(); return; } if (location == null){ // we could do something better, but it's better to do this for now return; } double minDistance = Double.POSITIVE_INFINITY; for(Stop s: stops){ minDistance = Math.min(minDistance, s.getDistanceFromLocation(location.getLatitude(), location.getLongitude())); } //quick trial to hopefully always get the stops in the correct order Collections.sort(stops,new StopSorterByDistance(location)); switch (fragment_type){ case STOPS: showStopsInRecycler(stops); break; case ARRIVALS: if(getContext()==null) break; //don't do anything if we're not attached if(arrivalsManager==null) arrivalsManager = new NearbyArrivalsDownloader(getContext().getApplicationContext(), arrivalsListener); arrivalsManager.requestArrivalsForStops(stops); /*flatProgressBar.setVisibility(View.VISIBLE); flatProgressBar.setProgress(0); flatProgressBar.setIndeterminate(false); */ //for the moment, be satisfied with only one location //AppLocationManager.getInstance(getContext()).removeLocationRequestFor(fragmentLocationListener); break; default: } } /** * To enable targeting from the Button */ public void switchFragmentType(View v){ switchFragmentType(); } /** * Call when you need to switch the type of fragment */ private void switchFragmentType(){ switch (fragment_type){ case ARRIVALS: setFragmentType(FragType.STOPS); break; case STOPS: setFragmentType(FragType.ARRIVALS); break; default: } prepareForFragmentType(); - fragmentLocationListener.lastUpdateTime = -1; //locManager.removeLocationRequestFor(fragmentLocationListener); //locManager.addLocationRequestFor(fragmentLocationListener); if(lastPosition!=null) { // we have at least one fix on the position showStopsInViews(currentNearbyStops, lastPosition); } } /** * Prepare the views for the set fragment type */ private void prepareForFragmentType(){ if(fragment_type==FragType.STOPS){ switchButton.setText(getString(R.string.show_arrivals)); titleTextView.setText(getString(R.string.nearby_stops_message)); if(arrivalsManager!=null) arrivalsManager.cancelAllRequests(); if(dataAdapter!=null) gridRecyclerView.setAdapter(dataAdapter); } else if (fragment_type==FragType.ARRIVALS){ titleTextView.setText(getString(R.string.nearby_arrivals_message)); switchButton.setText(getString(R.string.show_stops)); if(arrivalsStopAdapter!=null) gridRecyclerView.setAdapter(arrivalsStopAdapter); } } //useful methods /////// GUI METHODS //////// private void showStopsInRecycler(List stops){ if(firstLocForStops) { dataAdapter = new SquareStopAdapter(stops, mListener, lastPosition); gridRecyclerView.setAdapter(dataAdapter); firstLocForStops = false; }else { dataAdapter.setStops(stops); dataAdapter.setUserPosition(lastPosition); } dataAdapter.notifyDataSetChanged(); //showRecyclerHidingLoadMessage(); if (gridRecyclerView.getVisibility() != View.VISIBLE) { circlingProgressBar.setVisibility(View.GONE); loadingTextView.setVisibility(View.GONE); gridRecyclerView.setVisibility(View.VISIBLE); } messageTextView.setVisibility(View.GONE); if(mListener!=null) mListener.readyGUIfor(FragmentKind.NEARBY_STOPS); } private void showArrivalsInRecycler(List palinas){ Collections.sort(palinas,new StopSorterByDistance(lastPosition)); final ArrayList> routesPairList = new ArrayList<>(10); //int maxNum = Math.min(MAX_STOPS, stopList.size()); for(Palina p: palinas){ //if there are no routes available, skip stop if(p.queryAllRoutes().isEmpty()) continue; for(Route r: p.queryAllRoutes()){ //if there are no routes, should not do anything if (r.passaggi != null && !r.passaggi.isEmpty()) routesPairList.add(new Pair<>(p,r)); } } if (getContext()==null){ Log.e(DEBUG_TAG, "Trying to show arrivals in Recycler but we're not attached"); return; } if(firstLocForArrivals){ arrivalsStopAdapter = new ArrivalsStopAdapter(routesPairList,mListener,getContext(),lastPosition); gridRecyclerView.setAdapter(arrivalsStopAdapter); firstLocForArrivals = false; } else { arrivalsStopAdapter.setRoutesPairListAndPosition(routesPairList,lastPosition); } //arrivalsStopAdapter.notifyDataSetChanged(); showRecyclerHidingLoadMessage(); if(mListener!=null) mListener.readyGUIfor(FragmentKind.NEARBY_ARRIVALS); } private void setNoStopsLayout(){ messageTextView.setVisibility(View.VISIBLE); messageTextView.setText(R.string.no_stops_nearby); circlingProgressBar.setVisibility(View.GONE); loadingTextView.setVisibility(View.GONE); } /** * Does exactly what is says on the tin */ private void showRecyclerHidingLoadMessage(){ if (gridRecyclerView.getVisibility() != View.VISIBLE) { circlingProgressBar.setVisibility(View.GONE); loadingTextView.setVisibility(View.GONE); gridRecyclerView.setVisibility(View.VISIBLE); } messageTextView.setVisibility(View.GONE); } - /** + /* * Local locationListener, to use for the GPS */ + /* class FragmentLocationListener implements LocationListenerCompat { private long lastUpdateTime = -1; public boolean isRegistered = false; @Override public void onLocationChanged(@NonNull Location location) { if(viewModel==null){ return; } if(location.getAccuracy()<200) { lastPosition = new GPSPoint(location.getLatitude(), location.getLongitude()); //viewModel.requestStopsAtDistance(location.getLatitude(), location.getLongitude(), distance, true); viewModel.setLastLocation(location); } lastUpdateTime = System.currentTimeMillis(); //Log.d("BusTO:NearPositListen","can start request for stops: "+ !dbUpdateRunning); } @Override public void onProviderEnabled(@NonNull String provider) { Log.d(DEBUG_TAG, "Location provider "+provider+" enabled"); if(provider.equals(LocationManager.GPS_PROVIDER)){ setShowingStatus(LocationShowingStatus.SEARCHING); } } @Override public void onProviderDisabled(@NonNull String provider) { Log.d(DEBUG_TAG, "Location provider "+provider+" disabled"); if(provider.equals(LocationManager.GPS_PROVIDER)) { setShowingStatus(LocationShowingStatus.DISABLED); } } @Override public void onStatusChanged(@NonNull String provider, int status, @Nullable Bundle extras) { LocationListenerCompat.super.onStatusChanged(provider, status, extras); } } + + */ } diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/ScreenBaseFragment.java b/app/src/main/java/it/reyboz/bustorino/fragments/ScreenBaseFragment.java index 838863c..8e0a403 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/ScreenBaseFragment.java +++ b/app/src/main/java/it/reyboz/bustorino/fragments/ScreenBaseFragment.java @@ -1,112 +1,117 @@ package it.reyboz.bustorino.fragments; import android.Manifest; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; import android.net.Uri; import android.provider.Settings; +import android.util.Log; import android.view.View; import android.view.inputmethod.InputMethodManager; import android.widget.Toast; import androidx.activity.result.ActivityResultCallback; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import com.google.android.material.snackbar.Snackbar; import it.reyboz.bustorino.BuildConfig; import it.reyboz.bustorino.R; import java.util.Map; import static android.content.Context.MODE_PRIVATE; public abstract class ScreenBaseFragment extends Fragment { protected final static String PREF_FILE= BuildConfig.APPLICATION_ID+".fragment_prefs"; protected void setOption(String optionName, boolean value) { Context mContext = getContext(); SharedPreferences.Editor editor = mContext.getSharedPreferences(PREF_FILE, MODE_PRIVATE).edit(); editor.putBoolean(optionName, value); editor.commit(); } protected boolean getOption(String optionName, boolean optDefault) { Context mContext = getContext(); assert mContext != null; return getOption(mContext, optionName, optDefault); } protected void showToastMessage(int messageID, boolean short_lenght) { final int length = short_lenght ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG; final Context context = getContext(); if(context!=null) Toast.makeText(context, messageID, length).show(); } public void hideKeyboard() { if (getActivity()==null) return; View view = getActivity().getCurrentFocus(); if (view != null) { ((InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE)) .hideSoftInputFromWindow(view.getWindowToken(), InputMethodManager.HIDE_NOT_ALWAYS); } } /** * Find the view on which the snackbar should be shown * @return a view or null if you don't want the snackbar shown */ @Nullable public abstract View getBaseViewForSnackBar(); /** * Empty method to override properties of the Snackbar before showing it * @param snackbar the Snackbar to be possibly modified */ public void setSnackbarPropertiesBeforeShowing(Snackbar snackbar){ } public boolean showSnackbarOnDBUpdate() { return true; } public static boolean getOption(Context context, String optionName, boolean optDefault){ SharedPreferences preferences = context.getSharedPreferences(PREF_FILE, MODE_PRIVATE); return preferences.getBoolean(optionName, optDefault); } public static void setOption(Context context,String optionName, boolean value) { SharedPreferences.Editor editor = context.getSharedPreferences(PREF_FILE, MODE_PRIVATE).edit(); editor.putBoolean(optionName, value); editor.apply(); } public ActivityResultLauncher getPositionRequestLauncher(LocationRequestListener listener){ return registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), new ActivityResultCallback<>() { @Override public void onActivityResult(Map result) { if (result == null) return; if (result.get(Manifest.permission.ACCESS_COARSE_LOCATION) == null || result.get(Manifest.permission.ACCESS_FINE_LOCATION) == null) return; final boolean coarseGranted = Boolean.TRUE.equals(result.get(Manifest.permission.ACCESS_COARSE_LOCATION)); final boolean fineGranted = Boolean.TRUE.equals(result.get(Manifest.permission.ACCESS_FINE_LOCATION)); + if (coarseGranted != fineGranted){ + Log.e("BusTO-ScreenBaseFragment", "the two permissions have different values, coarse "+ + coarseGranted +", fineGranted "+fineGranted); + } - listener.onPermissionResult(coarseGranted, fineGranted); + listener.onPermissionResult(coarseGranted || fineGranted); } }); } public interface LocationRequestListener{ - void onPermissionResult(boolean isCoarseGranted, boolean isFineGranted); + void onPermissionResult(boolean locationGranted); } } diff --git a/app/src/main/java/it/reyboz/bustorino/map/MapLibreLocationEngine.kt b/app/src/main/java/it/reyboz/bustorino/map/MapLibreLocationEngine.kt index ab0ffd0..8fdc8d4 100644 --- a/app/src/main/java/it/reyboz/bustorino/map/MapLibreLocationEngine.kt +++ b/app/src/main/java/it/reyboz/bustorino/map/MapLibreLocationEngine.kt @@ -1,114 +1,118 @@ package it.reyboz.bustorino.map import android.app.PendingIntent import android.os.Looper import android.util.Log import org.maplibre.android.location.engine.LocationEngine import org.maplibre.android.location.engine.LocationEngineCallback import org.maplibre.android.location.engine.LocationEngineRequest import org.maplibre.android.location.engine.LocationEngineResult import it.reyboz.bustorino.middleware.FusedNativeLocationProvider /** * Adattatore che implementa l'interfaccia [LocationEngine] di MapLibre * delegando a [FusedNativeLocationProvider]. * * Separa completamente la logica di fusione dei provider (in [FusedNativeLocationProvider]) * dalla traduzione nel contratto MapLibre (qui). * * Uso: * val provider = FusedNativeLocationProvider(context) * val engine = MapLibreLocationEngine(provider) * // poi passa engine a LocationComponentActivationOptions */ class MapLibreLocationEngine( private val provider: FusedNativeLocationProvider, ) : LocationEngine { // Mappa callback MapLibre → listener del provider, per poterli rimuovere private val callbackListeners = HashMap, FusedNativeLocationProvider.LocationUpdateListener>() override fun getLastLocation(callback: LocationEngineCallback) { val location = provider.getLastLocationFromProviders() if (location != null) { callback.onSuccess(LocationEngineResult.create(location)) } else { callback.onFailure(NoLocationException()) } } // ------------------------------------------------------------------------- // requestLocationUpdates — overload con Looper (quello usato da MapLibre) // ------------------------------------------------------------------------- override fun requestLocationUpdates( request: LocationEngineRequest, callback: LocationEngineCallback, looper: Looper?, ) { val providerListener = FusedNativeLocationProvider.LocationUpdateListener { location -> callback.onSuccess(LocationEngineResult.create(location)) } callbackListeners[callback] = providerListener provider.addListener(providerListener) // Avvia (o riavvia) il provider con i parametri della request MapLibre. // Se il provider è già attivo con altri listener, stop+start lo ri-configura. provider.startUpdates( FusedNativeLocationProvider.Options( minIntervalMs = request.interval, minDisplacementM = request.displacement, looper = looper, ) ) } // ------------------------------------------------------------------------- // requestLocationUpdates — overload con PendingIntent (background/geofencing) // ------------------------------------------------------------------------- override fun requestLocationUpdates( request: LocationEngineRequest, pendingIntent: PendingIntent, ) { // PendingIntent is used for background updates via BroadcastReceiver. // FusedNativeLocationProvider operates in the foreground: delegating to PendingIntent // would require a different architecture (LocationManager.requestLocationUpdates // with native PendingIntent). Not supported in this implementation. throw UnsupportedOperationException( "MapLibreLocationEngine does not support updates via PendingIntent. " + "Use requestLocationUpdates(request, callback, looper) or " + "implement a dedicated BroadcastReceiver." ) } // ------------------------------------------------------------------------- // removeLocationUpdates — overload con callback // ------------------------------------------------------------------------- override fun removeLocationUpdates(callback: LocationEngineCallback) { callbackListeners.remove(callback)?.let { providerListener -> provider.removeListener(providerListener) } Log.d(DEBUG_TAG, "Removed location updates callback $callback") } // ------------------------------------------------------------------------- // removeLocationUpdates — overload con PendingIntent // ------------------------------------------------------------------------- override fun removeLocationUpdates(pendingIntent: PendingIntent) { throw UnsupportedOperationException( "MapLibreLocationEngine does not support PendingIntent removal." ) } - class NoLocationException : Exception() + + companion object { const val DEBUG_TAG = "BusTO-MapLocationEngine" + + + } -} \ No newline at end of file +} diff --git a/app/src/main/java/it/reyboz/bustorino/middleware/FusedNativeLocationProvider.kt b/app/src/main/java/it/reyboz/bustorino/middleware/FusedNativeLocationProvider.kt index 92398c1..5905eed 100644 --- a/app/src/main/java/it/reyboz/bustorino/middleware/FusedNativeLocationProvider.kt +++ b/app/src/main/java/it/reyboz/bustorino/middleware/FusedNativeLocationProvider.kt @@ -1,263 +1,348 @@ package it.reyboz.bustorino.middleware import android.annotation.SuppressLint import android.content.Context import android.location.Location import android.location.LocationListener import android.location.LocationManager import android.os.Handler import android.os.Looper import android.util.Log import it.reyboz.bustorino.BuildConfig -import it.reyboz.bustorino.util.Permissions +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.atomic.AtomicBoolean /** * Native Android location provider that fuses GPS_PROVIDER, NETWORK_PROVIDER * and PASSIVE_PROVIDER with no dependency on Google Play Services. * * Standalone class to be used anywhere in the app * */ class FusedNativeLocationProvider(context: Context) { // ------------------------------------------------------------------------- // Public interface for location update consumers // ------------------------------------------------------------------------- fun interface LocationUpdateListener { fun onLocationUpdate(location: Location) + + fun onFusedStatusChanged(isEnabled: Boolean) {} + } + + fun interface LocationStatusListener { + fun onLocationStatusChanged(isEnabled: Boolean) } /** * Configuration for location updates. * * @param minIntervalMs Minimum interval between updates in ms. * @param minDisplacementM Minimum displacement in meters to trigger an update. * @param looper Thread on which to receive callbacks. Null = main thread. * @param useGps Enables GPS_PROVIDER. * @param useNetwork Enables NETWORK_PROVIDER (WiFi + cell). * @param usePassive Enables PASSIVE_PROVIDER (zero consumption, opportunistic updates). */ data class Options( - val minIntervalMs: Long = 500L, + val minIntervalMs: Long = 1000L, val minDisplacementM: Float = 5f, val looper: Looper? = null, val useGps: Boolean = true, val useNetwork: Boolean = true, val usePassive: Boolean = true, - ) + ){ + constructor(minIntervalMs: Long, minDisplacementM: Float) : this( + minIntervalMs = minIntervalMs, + minDisplacementM = minDisplacementM, + useGps = true + ) + } private val locationManager = context.applicationContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager // List of registered listeners (called on the configured looper) private val listeners = CopyOnWriteArraySet() + private val statusListeners = CopyOnWriteArraySet() // Active Android listeners, one per provider private val activeAndroidListeners = mutableListOf() @Volatile private var bestLocation: Location? = null - @Volatile - private var running = false + private var running = AtomicBoolean(false) private var runningOptions = Options(500L, 5f, null, true, true, true) - private val activeProviders = ArrayList() + private val availableProviders = ArrayList() + + private var lastStatusUpdateEnabled = false - private var havePermissions = false + private val providersAreEnabled = ConcurrentHashMap() //private val removedListener = mutableSetOf() private val handler by lazy { Handler(runningOptions.looper ?: Looper.getMainLooper()) } //private var appContext = context.applicationContext // ------------------------------------------------------------------------- // Public API // ------------------------------------------------------------------------- /** * Adds a listener. Can be called before or after [startUpdates]. */ fun addListener(listener: LocationUpdateListener) { if(BuildConfig.DEBUG) Log.d(DEBUG_TAG, "Adding listener $listener") synchronized(listeners) { listeners.add(listener) } } + fun addListener(listener: LocationStatusListener) { + synchronized(listeners) { + statusListeners.add(listener) + } + } + /** * Removes a previously registered listener. */ fun removeListener(listener: LocationUpdateListener) { if(BuildConfig.DEBUG) Log.d(DEBUG_TAG, "Removing listener $listener") synchronized(listeners){ if(listeners.remove(listener)){ if(listeners.isEmpty()) stopUpdates() } if(BuildConfig.DEBUG) Log.d(DEBUG_TAG, "Listener now size: ${listeners.size}") } } + fun removeListener(listener: LocationStatusListener) { + synchronized(listeners) { + statusListeners.remove(listener) + } + } + /** * Starts receiving location updates from the enabled providers. * If already running, stops the existing providers first and restarts * them with the new configuration. * * Requires ACCESS_FINE_LOCATION or ACCESS_COARSE_LOCATION. */ @SuppressLint("MissingPermission") - fun startUpdates(options: Options?): Boolean { - if (running) stopUpdates() + fun startUpdates(options: Options?) { + val wasNotRunning = running.compareAndSet(false, true) + + if (!wasNotRunning) { + //it's already running, no need to stop + Log.d(DEBUG_TAG, "Requested to start updates, but provider is running") + if(options!=null){ + if(runningOptions !== options){ + Log.d(DEBUG_TAG, "Stopping and restarting") + //need to restart + stopUpdatesInternal() + startUpdates(options) + } + } + return + } if (options!=null){ runningOptions = options } + lastStatusUpdateEnabled = false val selectedProviders = buildList { if (runningOptions.useGps) add(LocationManager.GPS_PROVIDER) if (runningOptions.useNetwork) add(LocationManager.NETWORK_PROVIDER) if (runningOptions.usePassive) add(LocationManager.PASSIVE_PROVIDER) } val effectiveLooper = runningOptions.looper ?: Looper.getMainLooper() selectedProviders.forEach { provider -> - if (!locationManager.isProviderEnabled(provider)) return@forEach + val isEnabled = locationManager.isProviderEnabled(provider) + if(isEnabled) { + lastStatusUpdateEnabled = true + } + providersAreEnabled[provider] = isEnabled + //listen for location, even if the provider is not started yet + val locListener = object : LocationListener { + override fun onLocationChanged(location: Location) { + onReceiveLocation(location) + } + + override fun onProviderDisabled(provider: String) { + super.onProviderDisabled(provider) + onProviderStatusChanged(provider, false) + } - val locListener = LocationListener { location -> - if (isBetterLocation(location, bestLocation)) { - bestLocation = location - //Log.d(DEBUG_TAG, "New best location: $bestLocation") - notifyListeners(location) + override fun onProviderEnabled(provider: String) { + super.onProviderEnabled(provider) + onProviderStatusChanged(provider, true) } } - //runCatching { - locationManager.requestLocationUpdates( - provider, - runningOptions.minIntervalMs, - runningOptions.minDisplacementM, - locListener, - effectiveLooper, - ) - activeAndroidListeners.add(locListener) - activeProviders.add(provider) - //} + runCatching { + locationManager.requestLocationUpdates( + provider, + runningOptions.minIntervalMs, + runningOptions.minDisplacementM, + locListener, + effectiveLooper, + ) + activeAndroidListeners.add(locListener) + availableProviders.add(provider) + } } - - running = activeAndroidListeners.isNotEmpty() - Log.d(DEBUG_TAG, "Started location updates, running: $running, with providers: $activeProviders") - return running + notifyListenerStatus(lastStatusUpdateEnabled) + Log.d(DEBUG_TAG, "Started location updates, running: ${running.get()}, with providers: $availableProviders") } /** * Stops all updates and releases the Android listeners. * [LocationUpdateListener]s registered via [addListener] are retained: * calling [startUpdates] again will resume delivering updates to them. */ private fun stopUpdatesInternal() { - if(!running) //we have already done this - return - Log.d(DEBUG_TAG, "Actually stopping location updates, active providers: $activeProviders") - activeAndroidListeners.forEach { listener -> - runCatching { locationManager.removeUpdates(listener) } + if(running.compareAndSet(true, false)) { + //we have to stop updates + Log.d(DEBUG_TAG, "Actually stopping location updates, active providers: $availableProviders") + activeAndroidListeners.forEach { listener -> + runCatching { locationManager.removeUpdates(listener) } + } + activeAndroidListeners.clear() + //running = false is set by compareAndSet + availableProviders.clear() } - activeAndroidListeners.clear() - running = false - activeProviders.clear() } + fun isRunning(): Boolean = running.get() + /** * Returns the best known location cached by the enabled providers, * without starting continuous updates. * * May return null if no provider has ever acquired a fix * (e.g. first launch, device just turned on). * * Do not use if we do not have the Location permission */ @SuppressLint("MissingPermission") fun getLastLocationFromProviders(): Location? { val candidatesLocations = listOf( LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER, LocationManager.PASSIVE_PROVIDER, ).mapNotNull { provider -> locationManager.getLastKnownLocation(provider) } // Among the candidates, the most accurate wins. On equal accuracy, // the most recent wins. return candidatesLocations.minWithOrNull( compareBy({ it.accuracy }, { -it.time }) ) } //fun getLastReceivedBestLocation(): Location? { // return bestLocation //} private fun notifyListeners(location: Location) { //synchronized(listeners) { listeners.forEach { it.onLocationUpdate(location) } } + private fun notifyListenerStatus(enabled: Boolean){ + Log.d(DEBUG_TAG, "Notifying listeners, the position is enabled: $enabled") + listeners.forEach { it.onFusedStatusChanged(enabled) } + statusListeners.forEach { it.onLocationStatusChanged(enabled) } + } + + private fun onReceiveLocation(location: Location) { + if (isBetterLocation(location, bestLocation)) { + bestLocation = location + //Log.d(DEBUG_TAG, "New best location: $bestLocation") + notifyListeners(location) + } + } + + private fun onProviderStatusChanged(provider: String,enabled: Boolean) { + providersAreEnabled.put(provider, enabled) + val actu = providersAreEnabled.reduceValues(1, Boolean::or) + if (actu!=null && actu!=lastStatusUpdateEnabled){ + lastStatusUpdateEnabled = actu + notifyListenerStatus(actu) + } + + } + + fun isLocationEnabled(): Boolean { + val probValue = providersAreEnabled.reduceValues(1, Boolean::or) + return probValue ?: true + } + + /** * Public call for stopping the updates */ fun stopUpdates() { Log.d(DEBUG_TAG, "Stopping updates") if (Looper.myLooper() == handler.looper) { stopUpdatesInternal() } else { handler.post { stopUpdatesInternal() } } } companion object { private const val TIME_DELAY = 2 * 60 * 1_000L // two minutes private const val ACCURACY_DEGRADATION_THRESHOLD_M = 200f private const val DEBUG_TAG = "BusTO-FusedLocationProv" /** * Determines whether [candidate] is a better location than [current]. * * Criteria, in priority order: * 1. If the candidate is newer than [TIME_DELAY], always accept it. * 2. If it is significantly older, reject it. * 3. Equal freshness: the one with lower accuracy (tighter radius) wins. * 4. Same provider, same freshness delta, and not degrading too much: accept. */ @JvmStatic fun isBetterLocation(candidate: Location, current: Location?): Boolean { if (current == null) return true val timeDeltaMs = candidate.time - current.time return when { timeDeltaMs > TIME_DELAY -> true // much more recent: accept immediately timeDeltaMs < -TIME_DELAY -> false // much older: reject immediately else -> { val accuracyDeltaM = candidate.accuracy - current.accuracy when { accuracyDeltaM < 0 -> true // more accurate accuracyDeltaM == 0f && timeDeltaMs > 0 -> true // same accuracy, fresher timeDeltaMs > 0 && accuracyDeltaM <= ACCURACY_DEGRADATION_THRESHOLD_M && candidate.provider == current.provider -> true else -> false } } } } } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/viewmodels/MapStateViewModel.kt b/app/src/main/java/it/reyboz/bustorino/viewmodels/MapStateViewModel.kt index 3ca621b..3a53046 100644 --- a/app/src/main/java/it/reyboz/bustorino/viewmodels/MapStateViewModel.kt +++ b/app/src/main/java/it/reyboz/bustorino/viewmodels/MapStateViewModel.kt @@ -1,54 +1,54 @@ package it.reyboz.bustorino.viewmodels import android.location.Location import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import it.reyboz.bustorino.map.MapCameraState import org.maplibre.android.camera.CameraPosition import org.maplibre.android.geometry.LatLng -import org.maplibre.android.geometry.LatLngBounds import org.maplibre.android.maps.MapLibreMap class MapStateViewModel : ViewModel() { var savedCameraState: MapCameraState? = null private set val lastOpenStopID = MutableLiveData() fun saveMapState(map: MapLibreMap){ val cp = map.cameraPosition val newBbox = map.projection.visibleRegion.latLngBounds val cameraState = MapCameraState( latitude = newBbox.center.latitude, longitude = newBbox.center.longitude, zoom = cp.zoom, bearing = cp.bearing, tilt = cp.tilt ) savedCameraState = cameraState } fun restoreMapState(map: MapLibreMap): Boolean { return restoreMapState(map, this.savedCameraState) } var locationToShow: Location? = null - val locationActive = MutableLiveData(false) + val locationUserActive = MutableLiveData(false) val followingUserPosition = MutableLiveData(false) + val locationDeviceEnabled = MutableLiveData(false) companion object{ fun restoreMapState(map: MapLibreMap, savedCameraState: MapCameraState?): Boolean { val state = savedCameraState ?: return false map.cameraPosition = CameraPosition.Builder() .target(LatLng(state.latitude, state.longitude)) .zoom(state.zoom) .bearing(state.bearing) .tilt(state.tilt) .build() return true } } } \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_nearby_stops.xml b/app/src/main/res/layout/fragment_nearby_stops.xml index 37a7020..38e77b3 100644 --- a/app/src/main/res/layout/fragment_nearby_stops.xml +++ b/app/src/main/res/layout/fragment_nearby_stops.xml @@ -1,87 +1,87 @@ diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 624c04c..b8872a7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,251 +1,251 @@ Oui Paramètres Rechercher Numéro de l\'arrêt de bus Insérer le numéro de l\'arrêt de bus Cette application nécessite une autre application pour scanner les codes QR. Souhaitez-vous installer Barcode Scanner maintenant ? Insérer le nom de l\'arrêt de bus %1$s vers %2$s %s (destination inconnue) Vérifiez votre connexion Internet ! Nom trop court, tapez davantage de caractères et réessayez Erreur lors de l\'analyse du site 5T/GTT (foutu site!) Sélectionner l\'arrêt de bus… Ligne Lignes: %1$s Lignes interurbaines Destination: Aucun planning trouvé https://gitpull.it/w/librebusto/en/ Code source Licence11 L\'arrêt de bus est désormais dans vos favoris Favoris À propos de l\\\'application Fermer le tutoriel Majuscules partout Appuyez pour modifier Afficher les arrivées à l\'appui sur un arrêt Activer les fonctions expérimentales MaTO (le plus fréquemment mis à jour, parfois avec erreurs) Supprimer les données des trajets (libère de l\'espace) Autoriser l\'accès à la localisation Permission d\'accès à la localisation accordée Permission d\'accès à la localisation refusée OK, fermer le tutoriel Sauvegarder et restaurer Importer/Exporter les préférences La sauvegarde a été importée Vérifiez cocher au moins un élément à importer ! Importer les favoris depuis une sauvegarde Importer les préférences depuis une sauvegarde Arrivées à: %1$s En savoir plus Rencontrer l\'auteur Aide Lignes Lignes urbaines Lignes touristiques Aucun code QR trouvé, essayez d\'utiliser une autre application pour le scanner Ouvrez la wiki Ligne retirée de vos favoris Heures d\'arrivée Arrêt de bus retiré de vos favoris Ligne ajoutée à vos favoris Favoris Aucune arrivée trouvée pour les lignes : Renommer Impossible de trouver la position de l\'arrêt Distance maximale (en mètres) Autorisez l\'accès à la localisation pour l\'afficher sur la carte Mise à jour de la base de données en cours… Lancer la mise à jour manuelle de la base de données - Veuillez activer la localisation sur l\'appareil + Veuillez activer la localisation sur l\'appareil Mise à jour de la base de données Forcer la mise à jour de la base de données à l\'arrêt Afficher les arrivées Muoversi a Torino Le service de localisation en temps réel MaTO live bus est en cours d\'exécution stockage Rechercher par arrêt L\'application a planté en raison d\'un bug.\nSi vous le souhaitez, vous pouvez aidez les développeurs en envoyant le rapport de plantage par e-mail.\nVeuillez noter que ce rapport ne comporte aucune donnée sensible, seulement quelques informations sur votre téléphone et la configuration/l\'état de l\'application. Ouvrir le menu de navigation Fonctions expérimentales Lancement de la mise à jour de la base de données Filtrer par nom Ne pas modifier la direction des arrivées Majuscules sur la première lettre uniquement Section à afficher au démarrage "Source de la localisation en temps réel pour les bus et les trams" Appui long sur l\'arrêt pour afficher les options GTFS RT (moins fréquemment mis à jour, mais plus précis) Tous les trajets GTFS ont été supprimés de la base de données Sauvegarde dans un fichier Activer les notifications Notifications activées Importer depuis une sauvegarde Installer Barcode Scanner ? Appuyez sur l\\\'étoile pour ajouter l\'arrêt de bus à vos favoris\n\nPour lire les fiches horaires:\n 12:56* Heures d\'arrivée en temps réel\n 12:56 Heures d\'arrivée programmées\n\nTirez vers le bas pour actualiser la fiche\n Appui long sur la source des arrivées pour la modifier Actualités et mises à jour

Sur le canal Telegram, vous pouvez retrouver des informations sur les dernières mises à jour de l\'application

]]>
Précédent Scanner le code QR Suivant Nom de l\'arrêt de bus Aucune arrivée prévue pour cette arrêt Il semble qu\'il n\\\'y a aucun arrêt de bus avec ce nom À propos de l\\\'application Aucune ligne trouvée dans cette catégorie Aucune ligne ne correspond à la recherche Ligne %1$s Ligne %1$s, direction: Erreur interne inattendue, impossible d\'extraire les données depuis le site GTT/5T Favoris Carte Supprimer Renommer l\'arrêt de bus Aucun favori ? Ah ! Appuyez sur l\'étoile au niveau d\'un arrêt de bus pour en ajouter ! Réinitialiser J\'AI COMPRIS ! Voir sur la carte Arrêts à proximité Version de l\'application Le nombre d\'arrêts à afficher dans les arrêts récents est invalide Valeur invalide, veuillez saisir un nombre valide Recherche de l\'emplacement Aucun arrêt à proximité Nombre minmum d\'arrêts Préférences Paramètres Général Fonctionnalités expérimentales Arrêts récents Paramètres généraux Gestion de la base de données Autorisez l\'accès à la localisation pour afficher les arrêts à proximité Appuyez pour mettre à jour la base de données maintenant arrive à Afficher les arrêts Rejoindre le canal Telegram Afficher l\'introduction Recentrer sur ma position Me suivre Activer ou désactiver la localisation Localisation activée Localisation désactivée La localisation est désactivée sur l\'appareil Source des arrivées : %1$s Application GTT Site Web de GTT Site Web de 5T Torino Non défini Modification de la source des heures d\'arrivée… Appui long pour modifier la source des arrivées Source des heures d\'arrivée Sélectionnez les sources d\'heures d\'arrivée à utiliser Canal par défaut pour les notifications Opérations sur la base de données Mises à jour de la base de données de l\'application BusTO - Service de localisation en temps réel Localisation en temps réel Affichage de l\'activité associée au service de localisation en temps réel Téléchargement des trajets depuis le serveur de MaTO Permission pour %1$s demandée à de trop nombreuses reprises Impossible d\'utiliser la carte sans la permission d\'accès au stockage ! L\'application a planté et le rapport de plantage se trouve en pièce-jointe. Veuillez décrire ce que vous faisiez avant le plantage : Arrivées Carte Favoris Fermer le menu de navigation Offrir un café Carte Téléchargement des données depuis le serveur MaTO Majuscules pour les directions Afficher le tutoriel Données sauvegardées Non Vous bénéficiez de la dernière technologie en matière de respect de votre vie privée. Arrêt %1$s Licences

L\'application et le code source associé sont publiés par Valerio Bozzolan et les autres auteurs sous les termes de la licence GNU General Public License v3+. Tout le monde est donc autorisé à utiliser, étudier, améliorer et partager cette application par tout moyen et à toutes fins : à condition de respecter ces droits et d\attribuer l\'œuvre originale à Valerio Bozzolan.


Remarques

Cette application a été développée dans l\'espoir d\'être utile à tous, mais elle est fournie sans AUCUNE garantie d\'aucune sorte.

Les données utilisées par l\'application proviennent directement de GTT et d\'autres organismes publics : si vous constatez des erreurs, veuillez vous adresser à eux, et non à nous.

Cette traduction est gracieusement fournie par Ludovico Pavesi et Fabio Mazza.

Bonne utilisation ! :)

]]>
Impossible d\'ajouter aux favoris ( stockage plein ou base de données corrompue ?) ! Impossible de trouver une application où l\'afficher Arrivées à proximité Recherche des arrivées depuis %1$s Vous êtes trop loin, position non affichée Style de la carte Versatiles (vectoriel) OSM legacy (raster, plus légèr) open source pour les transports publics de Turin. Il s\'agit d\'une application indépendante, sans publicité ni traceurs d\'aucune sorte.]]> Si vous vous trouvez à un arrêt, vous pouvez scanner le code QR présent sur le panneau en appuyant sur l\'icône à gauche de la barre de recherche.]]> favoris en touchant l\'étoile à côté de son nom]]>> bleu)]]> Paramètres pour personnaliser l\'application, et la section À propos de l\'application si vous souhaitez en savoir plus sur l\'application et ses développeurs.]]> Notifications pour afficher les informations relatives au fonctionnement en arrière-plan. Appuyez sur le bouton ci-dessous pour l\'autoriser]]> Bonjour fragment vide Aucune application trouvée pour afficher l\'arrêt ! Direction déjà sélectionnée Chargement de la destination… Destination inconnue Le service des positions fonctionne normalement Aucune position reçue Erreur de connexion au serveur Erreur lors de la lecture de la réponse du serveur Erreur : réponse du serveur de type inattendu Connexion en cours... Source des positions en temps réel : Changer de source Supprimer les positions sur la carte lorsque la source des positions en temps réel est modifiée Véhicule %1$s Bienvenue !

Merci d\'avoir choisi BusTO, une application open source et indépendante utile pour se déplacer dans la ville de Turin avec un logiciel libre !

BusTO respecte votre vie privée en ne collectant aucune donnée sur votre utilisation. Elle est légère et ne contient aucune publicité !


Ici, vous trouverez plus d\'informations et des liens concernant le projet.


Tutoriel

Si vous souhaitez consulter à nouveau l\'introduction, utilisez le bouton ci-dessous :

]]>
How does it work?

Cette application est capable d\'accomplir toutes ces choses incroyables en extrayant des données de www.gtt.to.it, www.5t.torino.it ou muoversiatorino.it "pour usage personnel", ainsi que les données ouvertes du site web AperTO (aperto.comune.torino.it) .


Le travail de plusieurs personnes est à l\'origine de cette application, en particulier :
- Fabio Mazza, développeur rockstar senior actuel.
- Andrea Ugo, développeur junior rockstar actuel.
- Silviu Chiriac, créateur du logo du 2021.
- Marco M, testeur rockstar et chasseur de bugs.
- Ludovico Pavesi, ancien développeur rockstar senior (asd).
- Valerio Bozzolan, mainteneur et infrastructures (sponsor).
- Marco Gagino, contributeur et créateur de la première icône.
- JSoup bibliothèque pour le scraping web.
- makovkastar boutons flottants
- Google pour les icônes et les bibliothèques de support et de design.
- Autres icônes provenant de Bootstrap, Feather et Hero Icons.
- Tous les contributeurs, ainsi que les bêta-testeurs !


Si vous souhaitez obtenir plus d\'informations ou contribuer au développement, utilisez les boutons ci-dessous ! ]]>
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d7cc0a5..cd27aaf 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1,293 +1,293 @@ Stai utilizzando l\'ultimo ritrovato in materia di rispetto della tua privacy. Cerca Codice QR Si No Prossimo Precedente Installare Barcode Scanner? Questa azione richiede un\'altra app per scansionare i codici QR. Vuoi installare Barcode Scanner? Numero fermata Nome fermata Inserisci il numero della fermata Inserisci il nome della fermata Verifica l\'accesso ad Internet! Sembra che nessuna fermata abbia questo nome Nessun passaggio trovato alla fermata Ricerca arrivi da %1$s Errore di lettura del sito 5T/GTT (dannato sito!) Fermata: %1$s Fermata: Linea Linee Linee urbane Linee extraurbane Linee turistiche Direzione: Nessuna linea in questa categoria Nessuna linea corrisponde alla ricerca Filtra per nome Linea %1$s Linee: %1$s Linea %1$s, direzione: Fermata %1$s Scegli la fermata… Matricola %1$s Nessun passaggio Nessun QR code trovato, prova ad usare un\'altra app Preferiti Aiuto Informazioni Più informazioni Vai alla wiki https://gitpull.it/w/librebusto/it/ Codice sorgente Licenza Incontra l\'autore Mostra linea Vedi direzione Fermata aggiunta ai preferiti Impossibile aggiungere ai preferiti (memoria piena o database corrotto?)! Preferiti Mappa Nessun preferito? Arghh!\nSchiaccia sulla stella di una fermata per aggiungerla a questa lista! Rimuovi Rinomina Rinomina fermata Reset Informazioni Tocca la stella per aggiungere la fermata ai preferiti\n\nCome leggere gli orari:\n 12:56* Orario in tempo reale\n 12:56 Orario programmato\n\nTrascina giù per aggiornare l\'orario. \n Tocca a lungo su Fonte Orari per cambiare sorgente degli orari di arrivo OK! Benvenuto!

Grazie per aver scelto BusTO, un\'app open source e indipendente da GTT/5T, per spostarsi a Torino attraverso software libero!

BusTO rispetta la tua privacy non raccogliendo nessun dato sull\'utilizzo, ed è leggera e senza pubblicità!


Qui puoi trovare più informazioni e link riguardo al progetto.


Schermata iniziale

Se vuoi rivedere la schermata iniziale, usa il pulsante qui sotto: ]]> Notizie e aggiornamenti

Nel canale Telegram puoi trovare informazioni sugli ultimi aggiornamenti dell\'app

]]>
Ma come funziona?

Quest\'app ottiene i passaggi dei bus, le fermate e altre informazioni utili unendo dati forniti dal sito www.gtt.to.it, www.5t.torino.it, muoversiatorino.it "per uso personale" e altre fonti Open Data (aperto.comune.torino.it).


Ingredienti:
- Fabio Mazza attuale rockstar developer anziano.
- Andrea Ugo attuale rockstar developer in formazione.
- Silviu Chiriac designer del logo 2021.
- Marco M formidabile tester e cacciatore di bug.
- Ludovico Pavesi ex rockstar developer anziano asd.
- Valerio Bozzolan attuale manutentore.
- Marco Gagino apprezzato ex collaboratore, ideatore icona e grafica.
- JSoup libreria per "web scaping".
- Google icone e librerie di supporto e design.
- Altre icone da Bootstrap, Feather e Hero Icons
- Tutti i contributori e i beta tester!


Se vuoi avere più informazioni o contribuire allo sviluppo, usa i pulsanti qui sotto! ]]>
Licenze

L\'app e il relativo codice sorgente sono distribuiti sotto la licenza GNU General Public License v3 (https://www.gnu.org/licenses/gpl-3.0.html). Ciò significa che puoi usare, studiare, migliorare e ricondividere quest\'app con qualunque mezzo e per qualsiasi scopo: a patto di mantenere sempre questi diritti a tua volta e di dare credito a Valerio Bozzolan e agli altri autori del codice dell\'app.


Note

Quest\'applicazione è rilasciata nella speranza che sia utile a tutti ma senza NESSUNA garanzia sul suo funzionamento attuale e/o futuro.

Tutti i dati utilizzati dall\'app provengono direttamente da GTT o da simili agenzie pubbliche: se trovi che sono inesatti per qualche motivo, ti invitiamo a rivolgerti a loro.

Buon utilizzo! :)

]]>
Nome troppo corto, digita più caratteri e riprova %1$s verso %2$s %s (destinazione sconosciuta) Errore interno inaspettato, impossibile estrarre dati dal sito GTT/5T Visualizza sulla mappa Non trovo un\'applicazione dove mostrarla Posizione della fermata non trovata Fermate vicine Ricerca della posizione Nessuna fermata nei dintorni Preferenze Aggiornamento del database… Aggiornamento del database Aggiornamento database forzato Tocca per aggiornare ora il database Numero minimo di fermate Il numero di fermate da ricercare non è valido Valore errato, inserisci un numero Impostazioni Distanza massima di ricerca (m) Funzionalità sperimentali Impostazioni Generali Fermate recenti Impostazioni generali Gestione del database Comincia aggiornamento manuale del database Consenti l\'accesso alla posizione per mostrarla sulla mappa Consenti l\'accesso alla posizione per mostrare le fermate vicine - Abilitare il GPS + Abilitare la posizione sul dispositivo arriva alle alla fermata Mostra arrivi Mostra fermate Arrivi qui vicino Fermata rimossa dai preferiti Canale telegram Mostra introduzione La mia posizione Segui posizione Attiva o disattiva posizione Posizione attivata Posizione disattivata La posizione è disabilitata sul dispositivo Fonte orari: %1$s App GTT Sito GTT Sito 5T Torino Muoversi a Torino Sconosciuta Fonti orari di arrivo Scegli le fonti di orari da usare Cambiamento sorgente orari… Premi a lungo per cambiare la sorgente degli orari Nessun passaggio per le linee: Canale default delle notifiche Operazioni sul database Informazioni sul database (aggiornamento) BusTO - posizioni in tempo reale Posizioni in tempo reale Attività del servizio delle posizioni in tempo reale Servizio posizioni MaTO in tempo reale attivo Download dei trip dal server MaTO Chiesto troppe volte per il permesso %1$s Non si può usare questa funzionalità senza il permesso di archivio! di archivio Un bug ha fatto crashare l\'app! \nPremi \"OK\" per inviare il report agli sviluppatori via email, così potranno scovare e risolvere il tuo bug! \nIl report contiene piccole informazioni non sensibili sulla configurazione del tuo telefono e sullo stato dell\'app al momento del crash. L\'applicazione è crashata, e il crash report è stato messo negli allegati. Se vuoi, descrivi cosa stavi facendo prima che si interrompesse: Arrivi Mappa Preferiti Apri drawer Chiudi drawer Esperimenti Offrici un caffè Mappa Ricerca fermate Versione app Orari di arrivo Richiesto aggiornamento del database Download dati dal server MaTO Mostra direzioni in maiuscolo Non cambiare Tutto in maiuscolo Solo prima lettera maiuscola Mostra arrivi quando tocchi una fermata Abilita esperimenti Schermata da mostrare all\'avvio Tocca per cambiare Fonte posizioni in tempo reale di bus e tram MaTO (aggiornate più spesso, può avere errori) GTFS RT (aggiornato meno frequentemente, posizioni più controllate) Linea aggiunta ai preferiti Linea rimossa dai preferiti Preferite Tocca a lungo la fermata per le opzioni Stile della mappa Versatiles (vettoriale) OSM Legacy (raster, più leggera) Rimuovi i dati dei trip (libera spazio) Tutti i trip GTFS sono rimossi dal database Mostra introduzione open source per il trasporto pubblico di Torino. Stai usando un\'app indipendente, senza pubblicità e senza nessun tracciamento.]]> Se ti trovi a una fermata, puoi scansionare il codice QR presente sulla palina toccando l\'icona a sinistra della barra di ricerca.]]> preferiti toccando la stella a fianco del nome.]]> fermate più vicine a te direttamente nella schermata principale...]]> posizioni in tempo reale dei bus e tram (in blu)]]> Guarda nelle Impostazioni per personalizzare l\'app come preferisci, e su Informazioni per sapere di più sull\'app e il team di sviluppo.]]> Capito, chiudi introduzione Chiudi introduzione Abilita accesso alla posizione Accesso alla posizione abilitato Accesso alla posizione non consentito dall\'utente Abilita notifiche Notifiche abilitate Backup e ripristino Importa / esporta dati Dati salvati Salva backup Importa i dati dal backup Backup importato Seleziona almeno un elemento da importare! Importa preferiti dal backup Importa preferenze dal backup Nessuna app disponibile per mostrare la fermata! Destinazione sconosciuta Direzione già selezionata Sei troppo lontano, posizione nascosta Caricamento destinazione… Ciao fragment vuoto Il servizio delle posizioni funziona normalmente Nessuna posizione ricevuta Errore nella connessione al server Errore nel parsing della risposta Errore: risposta dal server inaspettata In connessione... Fonte posizioni in tempo reale: Cambia fonte Rimuovi posizioni sulla mappa quando si cambia fonte delle posizioni in tempo reale Aggiornato: %1$s Nessun avviso nella tua lingua, mostrati in %1$s Italiano Inglese Avvisi per la linea %1$s: Controllo degli avvisi disponibili in corso
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 59c1398..0704935 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1,229 +1,229 @@ Geef locatietoestemming Importeer/exporteer voorkeuren Notificaties om informatie te laten zien over achtergrondprocessen. Druk op de knop hieronder om deze toestemming te geven]]> Locatie toestemming is niet verleend Notificaties ingeschakeld Data opgeslagen Backup en herstel Backup naar bestand Vink tenminste één item om te importeren! Importeer data vanuit backup Backup is geïmporteerd Druk op de ster om de bushalte toe te voegen aan de favorieten\n\nHoe lees je de tijdslijnen:\n   12:56* Real-time aankomsten\n   12:56  Geplande aankomsten\n\nVeeg naar beneden om het rooster te verversen\n Houd ingedrukt om de aankomsttijdenbron te veranderen Welkom!

Dank voor het gebruiken van BusTO, een open source en onafhankelijke app die handig is om je te verplaatsen door Turijn met Free/Libre software.


Waarom deze app gebruiken?

- Je wordt nooit gevolgd
- Je ziet nooit vervelende reclames
- We zullen altijd je privacy respecteren
- Tot slot, het is lichtgewicht!


Introductie tutorial

Als je nog een keer de introductie wilt zien, gebruik dan de knop hieronder:

]]>
Zoeken Scan QR Code Wanneer het op privacy respecteren aankomt, maak je gebruik van de laatste inzichten. Bushalte nummer Voer bushalte naam in %1$s richting %2$s %s (onbekende bestemming) Het lijkt erop dat er geen bushalte met deze naam is Geen aankomsten voor deze halte Fout bij het parsen van de 5T/GTT website Kies de bushalte… Lijn Lijnen Stadslijnen Lijnen naar buiten de stad Toeristenlijnen Geen lijnen gevonden in deze categorie Geen lijnen gevonden met deze naam Bestemming: Lijn %1$s Geen rooster gevonden Geen QR code gevonden, probeer het met een andere app Onverwachte interne fout, kan geen data halen van de GTT/5T website Hulp Over de app Draag bij (en) Broncode Licentie11 Ontmoet de maker Bushalte is nu in je favorieten Bushalte is verwijderd uit je favorieten Lijn toegevoegd aan favorieten Verwijder lijn uit favorieten Favorieten Favorieten Geen favorieten? Druk op een bushalte ster om deze toe te voegen Verwijder Hernoem Over de app Aankomsttijden Ik snap het! Bekijk op de kaart Kan geen applicatie vinden om in te weergeven Kan de positie van de halte niet vinden Haltes in de buurt Verbindingen dichtbij Locatie vinden Geen haltes in de buurt Voorkeuren Instellingen Maximale afstand (meters) Algemene instellingen Toegang tot locatie toestaan om te laten zien op de kaart Toegang tot locatie toestaan om haltes in de buurt te weergeven - Schakel aub de locatie in op het apparaat + Schakel aub de locatie in op het apparaat Database update gaande… Database aan het updaten Forceer database update Klik om de database van de app te updaten arriveert om bij de halte Geef aankomsten weer Geef haltes weer Word lid van het Telegramkanaal Laat introductie zien Centreer op mijn locatie Volg mij Schakel locatie in of uit Locatie ingeschakeld Locatie uitgeschakeld Locatie is uitgeschakeld op dit apparaat Aankomsten bron: %1$s GTT Applicatie GTT Website 5T Torino website Muoversi a Torino Onbepaald Ingedrukt houden om aankomsten bron te veranderen Bronnen van aankomsttijden Selecteer welke bronnen voor aankomsttijden je wilt gebruiken Database handelingen Laat activiteit zien gerelateerd aan de live positie service Download reizen van de MaTO server Te vaak om %1$s toestemming gevraagd Kan de kaart niet gebruiken zonder opslagtoestemming De applicatie is gecrasht en het crashrapport is toegevoegd aan de bijlagen. Beschrijf alsjeblieft wat je aan het doen was voordat de crash zich voordeed: Kaart Favorieten Open navigatiebalk Sluit navigatiebalk Geef ons een koffie Kaart Zoek op halte Download data van de MaTO server Kapitaliseer routebeschrijving Schakel experimenten in Houd halte ingedrukt voor opties Bron van realtime posities voor bussen en trams Alle GTFS reizen zijn verwijderd van de database favorites door op de ster bij de naam te drukken]]> OK, sluit de tutorial Sluit de tutorial Notificaties inschakelen Importeer voorkeuren vanuit backup Volgende Voer bushalte nummer in Check je internetverbinding! Te korte naam, typ meer karakters en probeer opnieuw Aankomsten om: %1$s Lijnen: %1$s Meer over Favorieten Kaart Reset Hernoem bushalte Vorige Nee Deze applicatie vereist een app om de QR codes te scannen. Wil je nu een Barcode Scanner installeren? Bushalte naam Ja Installeer Barcode Scanner? Geen aankomsten gevonden voor deze lijnen: Nieuws en Updates

Op het Telegramkanaal vind je meer informatie over de laatste app updates

]]>
Kan niet aan favorieten toevoegen (opslag vol of corrupte database?)! App versie Ongeldige waarde, vul een geldig getal in Het aantal haltes dat wordt weergegeven in recente haltes is ongeldig Recente haltes Algemeen Minimaal aantal haltes Instellingen Experimentele functies Databasebeheer Start handmatige database update Standaardkanaal voor notificaties Updates van de app database BusTO - live positie service MaTO live buspositie service draait Live posities opslag De applicatie is gecrashed omdat je een bug tegenkwam.\nAls je wilt, kun je de ontwikkelaars helpen door een crashrapport te versturen per email. \nZorg ervoor dat er geen gevoelige data in het rapport is opgenomen, enkel de informatie over je telefoon en app instellingen is hiervoor nodig. Verander aankomsttijden bron… Startscherm Aankomsten Instellingen om het gedrag van de app aan te passen, en in Over de app als je meer wilt weten over de app en de ontwikkelaars.]]> Raak aan om te wijzigen Lanceer databaseupdate Laat aankomsten zien bij de halte Verwijder reisdata (maak ruimte vrij) Laat tutorial zien blauw)]]> Experimenten Filter op naam open source app voor Turijns openbaar vervoer. Dit is een onafhankelijke app, zonder reclame en zonder tracking.]]> Locatietoestemming gegeven Importeer favorieten vanuit backup Deze app is mede mogelijk gemaakt door

De app werkt dankzij de data te gebruiken van www.gtt.to.it, www.5t.torino.it of muoversiatorino.it "voor persoonlijke doeleinden", tezamen met de open data van de AperTO (aperto.comune.torino.it) website.


Het werk van diverse mensen achter deze app, in het bijzonder:
- Fabio Mazza, huidige senior rockstar developer.
- Andrea Ugo, huidige junior rockstar developer.
- Silviu Chiriac, ontwerper van het 2021 logo.
- Marco M, rockstar tester en bug hunter.
- Ludovico Pavesi, vorige senior rockstar developer (asd).
- Valerio Bozzolan, maintainer en infrastructuur (sponsor).
- Marco Gagino, bijdrager en ontwerper van het eerste logo.
- JSoup web schraper bibliotheek.
- makovkastar zwevende knoppen.
- Google for iconen en ondersteuning en ontwerpbibliotheken.
- Andere iconen van Bootstrap, Feather, en Hero Icons.
- Alle bijdragers en tevens de betatesters!


Als je meer technische informatie wilt of zelf wilt bijdragen aan de ontwikkeling, gebruik dan de knoppen hieronder! ]]>
Licenties

De app en de bijbehorende broncode is gepubliceerd door Valerio Bozzolan en andere ontwikkelaars onder de voorwaarden van de GNU General Public License v3+). Iedereen staat het vrij om de app te gebruiken, te bestuderen, te verbeteren en te delen op iedere mogelijke manier en voor ieder doeleinde: onder de voorwaarden dat de beheren van de rechten en het toeschrijven van het originele werk aan Valerio Bozzolan.


Notities

Deze app is ontwikkeld met de hoop bruikbaar te zijn voor iedereen, maar komt zonder enige vorm van garantie.

De data van de app komt direct van GTT en andere publieke vertegenwoordige: als je fouten vindt, geef die bij hen aan, en niet bij ons.

De vertaling is met plezier geleverd door Riccardo Caniato, Marco Gagino, Fabio Mazza en Wouter van Straalen

Nu kan ook jij het openbare vervoer hacken! :)

]]>
https://gitpull.it/w/librebusto/en/
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a326e4b..92024cb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,394 +1,394 @@ BusTO Libre BusTO BusTO dev BusTO git You\'re using the latest in technology when it comes to respecting your privacy. Search Scan QR Code Yes No Next Previous Install Barcode Scanner? This application requires an app to scan the QR codes. Would you like to install Barcode Scanner now? Bus stop number Bus stop name Insert bus stop number Insert bus stop name %1$s towards %2$s %s (unknown destination) Verify your Internet connection! Seems that no bus stop has this name No arrivals found for this stop Error parsing the 5T/GTT website (damn site!) Name too short, type more characters and retry Arrivals at: %1$s Arrivals at: Choose the bus stop… Line Lines Urban lines Extra urban lines Tourist lines No lines found in this category No lines match the searched name Destination: Lines: %1$s Line %1$s Line %1$s towards: Stop %1$s Vehicle %1$s No timetable found No QR code found, try using another app to scan Unexpected internal error, cannot extract data from GTT/5T website Help About the app More about Open the wiki https://gitpull.it/w/librebusto/en/ Source code Licence11 Meet the author Bus stop is now in your favorites Bus stop removed from your favorites Added line to favorites Remove line from favorites Favorites Favorites Favorites Map No favorites? Arghh! Press on a bus stop star to populate this list! Delete Rename Rename the bus stop Reset About the app Tap the star to add the bus stop to the favourites\n\nHow to read timelines:\n   12:56* Real-time arrivals\n   12:56   Scheduled arrivals\n\nPull down to refresh the timetable \n Long press on Arrivals source to change the source of the arrival times GOT IT! Arrival times No arrivals found for lines: Welcome!

Thanks for using BusTO, an open source and independent app useful to move around Torino using a Free/Libre software.


Why use this app?

- You\'ll never be tracked
- You\'ll never see boring ads
- We\'ll always respect your privacy
- Moreover, it\'s lightweight!


Introductory tutorial

If you want to see the introduction again, use the button below:

]]>
News and Updates

On the Telegram channel, you can find information about the latest app updates

]]>
How does it work?

This app is able to do all the amazing things it does by pulling data from www.gtt.to.it, www.5t.torino.it or muoversiatorino.it "for personal use", along with open data from the AperTO (aperto.comune.torino.it) website.


The work of several people is behind this app, in particular:
- Fabio Mazza, current senior rockstar developer.
- Andrea Ugo, current junior rockstar developer.
- Silviu Chiriac, designer of the 2021 logo.
- Marco M, rockstar tester and bug hunter.
- Ludovico Pavesi, previous senior rockstar developer (asd).
- Valerio Bozzolan, maintainer and infrastructure (sponsor).
- Marco Gagino, contributor and first icon creator.
- JSoup web scraper library.
- makovkastar floating buttons.
- Google for icons and support and design libraries.
- Other icons from Bootstrap, Feather, and Hero Icons.
- All the contributors, and the beta testers, too!


If you want more information or want to contribute to development, use the buttons below! ]]>
Licenses

The app and the related source code are released by Valerio Bozzolan and the other authors under the terms of the GNU General Public License v3+). So everyone is allowed to use, to study, to improve and to share this app by any kind of means and for any purpose: under the conditions of maintaining this rights and of attributing the original work to Valerio Bozzolan.


Notes

This app has been developed with the hope to be useful to everyone, but comes without ANY warranty of any kind.

The data used by the app comes directly from GTT and other public agencies: if you find any errors, please take it up to them, not to us.

This translation is kindly provided by Riccardo Caniato, Marco Gagino and Fabio Mazza.

Now you can hack public transport, too! :)

]]>
Cannot add to favorites (storage full or corrupted database?)! View on a map Show line details Show full direction Cannot find any application to show it in Cannot find the position of the stop ListFragment - BusTO it.reyboz.bustorino.preferences db_is_updating Nearby stops Nearby connections App version The number of stops to show in the recent stops is invalid Invalid value, put a valid number Finding location No stops nearby Minimum number of stops Preferences Settings Settings General Experimental features Maximum distance (meters) Recent stops General settings Database management Launch manual database update Allow access to location to show it on the map Allow access to location to show stops nearby - Please enable location on the device + Please enable location on the device No GPS receiver found on the device! Database update in progress… Updating the database Force database update Touch to update the app database now is arriving at at the stop %1$s - %2$s Show arrivals Show stops Join Telegram channel Show introduction Center on my location Follow me Enable or disable location Location enabled Location disabled Location is disabled on device Arrivals source: %1$s Loading arrivals from %1$s GTT App GTT Website 5T Torino website Muoversi a Torino Undetermined Changing arrival times source… Long press to change the source of arrivals @string/source_mato @string/fivetapifetcher @string/gttjsonfetcher @string/fivetscraper Sources of arrival times Select which sources of arrival times to use Default Default channel for notifications Database operations Updates of the app database BusTO - live position service Live positions Showing activity related to the live positions service MaTO live bus positions service is running Downloading trips from MaTO server Asked for %1$s permission too many times Cannot use the map with the storage permission! storage The application has crashed because you encountered a bug. \nIf you want, you can help the developers by sending the crash report via email. \nNote that no sensitive data is contained in the report, just small bits of info on your phone and app configuration/state. The application crashed and the crash report is in the attachments. Please describe what you were doing before the crash: \n Arrivals Map Favorites Open navigation drawer Close navigation drawer Experiments Buy us a coffee Map Search by stop Filter by name Launching database update Downloading data from MaTO server Downloading realtime alerts data Capitalize directions @string/directions_capitalize_no_change @string/directions_capitalize_everything @string/directions_capitalize_first_letter Do not change arrivals directions Capitalize everything Capitalize only first letter KEEP CAPITALIZE_ALL CAPITALIZE_FIRST Section to show on startup Touch to change it Show arrivals touching on stop Enable experiments Long press the stop for options @string/nav_arrivals_text @string/nav_favorites_text @string/nav_map_text @string/lines Source of real time positions for buses and trams MaTO (updated more frequently, might have errors) GTFS RT (less frequently updated, more accurate) @string/positions_source_mato_descr @string/positions_source_gtfsrt_descr "You are too far, not showing your position Style of the map Versatiles (vector) OSM legacy (raster, lighter) @string/map_style_versatiles @string/map_style_legacy_raster Remove trips data (free up space) All GTFS trips have been removed from the database Show tutorial open source app for Turin public transport. This is an independent app, with no ads and no tracking whatsoever.]]> favorites by touching the star next to its name]]> blue)]]> Settings to customize the app behaviour, and in the About the app section if you want to know more about the app and the developers.]]> Notifications permission to show the information about background processing. Press the button below to grant it]]> Grant location permission Location permission granted Location permission has not been granted Missing permission To use this functionality, the application needs access to the location, which can not only be granted in the system settings. Open settings OK, close the tutorial Close the tutorial Enable notifications Notifications enabled Backup and restore Import/export preferences Data saved Backup to file Import data from backup Backup has been imported Check at least one item to import! Import favorites from backup Import preferences from backup Hello blank fragment No map app present to show the stop! Direction is already shown Loading destination… Destination unknown Service for positions working normally No positions received Error connecting to the server Error parsing the server response Error: network response is not as expected Connecting... MaTO GTFS RT Live positions source: Switch source Clear bus positions when switching live positions source Updated: %1$s Alerts for line %1$s: No alerts in your language, showing in %1$s Italian English Checking new alerts now