diff --git a/app/src/main/java/it/reyboz/bustorino/backend/VehicleUtils.kt b/app/src/main/java/it/reyboz/bustorino/backend/VehicleUtils.kt new file mode 100644 index 0000000..e87bd82 --- /dev/null +++ b/app/src/main/java/it/reyboz/bustorino/backend/VehicleUtils.kt @@ -0,0 +1,92 @@ +package it.reyboz.bustorino.backend + +import it.reyboz.bustorino.backend.VehicleUtils.VehicleType +import java.util.Locale.getDefault + +data class VehicleClassInfo( + val vehClass: Int, + val name: String, + val kindString: String, + val type: VehicleType, + val matricolaStart: Int, + val matricolaEnd: Int, +) { + constructor(vehicleClass: Int, name: String, type: String, matricolaStart: Int, matricolaEnd: Int) : this( + vehicleClass, name, type,VehicleType.fromString(type), matricolaStart, matricolaEnd + ) +} + +object VehicleUtils { + private val items = listOf( + VehicleClassInfo(2800, "Arancio", "Tram", 2801, 2857), + VehicleClassInfo(2800, "Arancio", "Tram", 2858, 2902), + VehicleClassInfo(5000, "5000", "Tram", 5000, 5053), + VehicleClassInfo(6000, "6000", "Tram", 6000, 6005), + VehicleClassInfo(6000, "6000", "Tram", 6006, 6054), + VehicleClassInfo(8000, "Hitachi", "Tram", 8000, 8099), + VehicleClassInfo(800, "Citelis 18m", "Bus 18m", 790, 797), + VehicleClassInfo(800, "Citelis 18m", "Bus 18m", 800, 869), + VehicleClassInfo(800, "Citelis 18m", "Bus 18m", 870, 874), + VehicleClassInfo(800, "Citelis 18m", "Bus 18m", 1310, 1313), + VehicleClassInfo(1350, "Conecto 18m", "Bus 18m", 1350, 1396), + VehicleClassInfo(9300, "UrbanWay 18m", "Bus 18m", 9300, 9318), + VehicleClassInfo(9300, "UrbanWay 18m", "Bus 18m", 9320, 9356), + VehicleClassInfo(30, "BYD K9", "E-Bus", 30, 49), + VehicleClassInfo(50, "BYD K7", "E-Bus", 50, 57), + VehicleClassInfo(60, "MiniBusE", "E-Bus", 60, 81), + VehicleClassInfo(110, "Neocity", "Bus", 110, 115), + VehicleClassInfo(2300, "Cityclass", "Bus", 2300, 2349), + VehicleClassInfo(3400, "Conecto", "Bus", 2400, 2447), + VehicleClassInfo(2300, "Cityclass", "Bus", 2700, 2787), + VehicleClassInfo(3000, "Citelis", "Bus", 3000, 3099), + VehicleClassInfo(3000, "Citelis", "Bus", 3300, 3380), + VehicleClassInfo(3400, "Conecto", "Bus", 3400, 3440), + VehicleClassInfo(9000, "BYD K9", "E-Bus", 9000, 9059), + VehicleClassInfo(9000, "BYD K9", "E-Bus", 9060, 9119), + VehicleClassInfo(9000, "BYD K9", "E-Bus", 9120, 9121), + VehicleClassInfo(9200, "Citymood", "Bus", 9200, 9251), + VehicleClassInfo(9200, "Citymood", "Bus", 9252, 9261), + VehicleClassInfo(9400, "E-Way", "E-Bus", 9400, 9599), + VehicleClassInfo(9600, "E-Way 18m", "E-Bus", 9600, 9699) + ) + + fun getTypeForLabel(label: String): VehicleClassInfo? { + try { + val matricola = Integer.parseInt(label) + for (el in items) { + if(matricola >= el.matricolaStart && matricola<= el.matricolaEnd) { + return el + } + } + return null + + } catch (e: Exception) { + return null + } + } + enum class VehicleType { + BUS, TRAM, ELECTRIC_BUS; + + fun getName(): String { + return when (this) { + BUS -> "Bus" + TRAM -> "Tram" + ELECTRIC_BUS -> "E-Bus" + } + } + + companion object { + @JvmStatic + fun fromString(string: String): VehicleType { + return when (string.lowercase(getDefault())) { + "bus" -> BUS + "bus 18m" -> BUS + "tram" -> TRAM + "e-bus" -> ELECTRIC_BUS + "e-bus 18m" -> ELECTRIC_BUS + else -> throw IllegalArgumentException("Unknown vehicle type: $string") + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/backend/gtfs/GtfsUtils.java b/app/src/main/java/it/reyboz/bustorino/backend/gtfs/GtfsUtils.java index 8ace7f2..f4e28a5 100644 --- a/app/src/main/java/it/reyboz/bustorino/backend/gtfs/GtfsUtils.java +++ b/app/src/main/java/it/reyboz/bustorino/backend/gtfs/GtfsUtils.java @@ -1,87 +1,87 @@ /* BusTO - Backend 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.backend.gtfs; import androidx.core.util.Pair; import it.reyboz.bustorino.backend.FiveTNormalizer; import it.reyboz.bustorino.backend.ServiceType; abstract public class GtfsUtils { - public static final String GTFSRT_URL_POSITION = "http://percorsieorari.gtt.to.it/das_gtfsrt/vehicle_position.aspx"; + public static final String GTFSRT_URL_POSITION = "https://percorsieorari.gtt.to.it/das_gtfsrt/vehicle_position.aspx"; - public static final String GTFSRT_URL_TRIP_UPDATES ="http://percorsieorari.gtt.to.it/das_gtfsrt/trip_update.aspx"; - public static final String GTFSRT_URL_ALERTS = "http://percorsieorari.gtt.to.it/das_gtfsrt/alerts.aspx"; + public static final String GTFSRT_URL_TRIP_UPDATES ="https://percorsieorari.gtt.to.it/das_gtfsrt/trip_update.aspx"; + public static final String GTFSRT_URL_ALERTS = "https://percorsieorari.gtt.to.it/das_gtfsrt/alerts.aspx"; public static String stripGtfsPrefix(String routeID){ String[] explo = routeID.split(":"); //default is String toParse = routeID; if(explo.length>1) { toParse = explo[1]; } return toParse; } public static Pair getRouteInfoFromGTFS(String routeID){ String[] explo = routeID.split(":"); //default is String toParse = routeID; if(explo.length>1) { toParse = explo[1]; } ServiceType serviceType=ServiceType.UNKNOWN; final int length = toParse.length(); final char v =toParse.charAt(length-1); switch (v){ case 'E': serviceType = ServiceType.EXTRAURBANO; break; case 'F': serviceType = ServiceType.FERROVIA; break; case 'T': serviceType = ServiceType.TURISTICO; break; case 'U': serviceType=ServiceType.URBANO; } //boolean barrato=false; String num = toParse.substring(0, length-1); /*if(toParse.charAt(length-2)=='B'){ //is barrato barrato = true; num = toParse.substring(0,length-2)+" /"; }else { num = toParse.substring(0,length-1); }*/ return new Pair<>(serviceType,num); } public static String getLineNameFromGtfsID(String routeID){ return getRouteInfoFromGTFS(routeID).second; } public static String lineNameDisplayFromGtfsID(String routeID){ String name = getRouteInfoFromGTFS(routeID).second; String altName = FiveTNormalizer.routeInternalToDisplay(name); if (altName==null) //WTF WHY DOES IT HAVE TO BE NULL return name; else return altName; } } diff --git a/app/src/main/java/it/reyboz/bustorino/data/GtfsAlertDBDownloadWorker.kt b/app/src/main/java/it/reyboz/bustorino/data/GtfsAlertDBDownloadWorker.kt index 32d7b15..6f54cb4 100644 --- a/app/src/main/java/it/reyboz/bustorino/data/GtfsAlertDBDownloadWorker.kt +++ b/app/src/main/java/it/reyboz/bustorino/data/GtfsAlertDBDownloadWorker.kt @@ -1,120 +1,132 @@ package it.reyboz.bustorino.data import android.app.NotificationManager import android.content.Context import android.util.Log +import androidx.lifecycle.LiveData import androidx.work.BackoffPolicy import androidx.work.CoroutineWorker import androidx.work.Data import androidx.work.ForegroundInfo import androidx.work.OneTimeWorkRequest import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkInfo +import androidx.work.WorkManager import androidx.work.WorkerParameters import com.android.volley.Response import com.android.volley.VolleyError import com.android.volley.toolbox.RequestFuture import com.google.transit.realtime.GtfsRealtime import it.reyboz.bustorino.R import it.reyboz.bustorino.backend.NetworkVolleyManager import it.reyboz.bustorino.backend.Notifications import it.reyboz.bustorino.backend.gtfs.GtfsRtAlertsRequest +import it.reyboz.bustorino.data.DBUpdateWorker.Companion.WORK_NAME import it.reyboz.bustorino.data.GtfsMaintenanceWorker.Companion.OPERATION_TYPE import it.reyboz.bustorino.data.gtfs.GtfsAlertsActivePeriods import it.reyboz.bustorino.data.gtfs.GtfsAlertsTranslation import it.reyboz.bustorino.data.gtfs.GtfsAlertEntity import it.reyboz.bustorino.data.gtfs.GtfsAlertInformedEntity import it.reyboz.bustorino.data.gtfs.GtfsAlertsDBConverter import it.reyboz.bustorino.data.gtfs.GtfsDatabase import java.util.concurrent.ExecutionException import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException class GtfsAlertDBDownloadWorker(appContext: Context, workerParams: WorkerParameters): CoroutineWorker(appContext, workerParams) { override suspend fun doWork(): Result { val volleyManager = NetworkVolleyManager.getInstance(applicationContext) val gtfsDatabase = GtfsDatabase.getGtfsDatabase(applicationContext) //use future to wait for request val dao =gtfsDatabase.alertsDao() //clear old ones dao.deleteOlderThanHours(24) var attempts = 0 var notOK = true var resuList = ArrayList() while (notOK && attempts < 5) { Log.d(DEBUG_TAG, "Fetching alerts, trial $attempts") val future = RequestFuture.newFuture>() val req = GtfsRtAlertsRequest(object : Response.ErrorListener { override fun onErrorResponse(err: VolleyError) { - Log.e(DEBUG_TAG, "Error getting alerts: ${err.message}", err) + Log.e(DEBUG_TAG, "Error getting alerts, message: ${err.message}", err) } }, future) volleyManager.requestQueue.add(req) try { - resuList = future.get(10, TimeUnit.SECONDS) + resuList = future.get(15, TimeUnit.SECONDS) if (resuList.isNotEmpty()){ Log.d(DEBUG_TAG, "Have no alerts, attempt $attempts") notOK = false } } catch (e: InterruptedException) { - e.printStackTrace() - Log.e(DEBUG_TAG, e.message, e) + //e.printStackTrace() + Log.w(DEBUG_TAG, "Interrupted: ", e) } catch (e: ExecutionException) { - e.printStackTrace() - Log.e(DEBUG_TAG, e.message, e) + //e.printStackTrace() + Log.w(DEBUG_TAG, e.message, e) } catch (e: TimeoutException) { - e.printStackTrace() - Log.e(DEBUG_TAG, e.message, e) + //e.printStackTrace() + Log.w(DEBUG_TAG, "Timeout for download", e) } attempts++ } if (notOK) { return Result.failure() } val timeReceived = System.currentTimeMillis() val alertsToAdd = ArrayList() val translToAdd = ArrayList() val activePeriods = ArrayList() val informedEntities = ArrayList() for(e in resuList){ val parsedRes = GtfsAlertsDBConverter.fromFeedEntity(e, timeReceived) alertsToAdd.add(parsedRes.alert) translToAdd.addAll(parsedRes.translations) activePeriods.addAll(parsedRes.activePeriods) informedEntities.addAll(parsedRes.informedEntities) } Log.d(DEBUG_TAG, "alerts received: ${alertsToAdd.size}") dao.insertMissingAlerts(alertsToAdd, translToAdd, activePeriods, informedEntities) return Result.success() } override suspend fun getForegroundInfo(): ForegroundInfo { val context = applicationContext Notifications.createDBNotificationChannelIfNeeded(context) return ForegroundInfo(NOTIFICATION_ID, Notifications.makeDBUpdateLowPriorityNotification(context, context.getString(R.string.downloading_alerts_message))) } companion object{ private const val NOTIFICATION_ID = 271899102 private const val DEBUG_TAG = "BusTO-GTFSRTAlertsDown" + @JvmStatic fun makeOneTimeRequest(tag: String): OneTimeWorkRequest { //val data = Data.Builder().putString(OPERATION_TYPE, type).build() return OneTimeWorkRequest.Builder(GtfsAlertDBDownloadWorker::class.java) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.SECONDS) .addTag(tag) .build() } + + @JvmStatic + fun getWorkInfoLiveData(context: Context): LiveData> { + val workManager = WorkManager.getInstance(context) + return workManager.getWorkInfosForUniqueWorkLiveData(WORK_NAME) + } } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/data/gtfs/AlertsDao.kt b/app/src/main/java/it/reyboz/bustorino/data/gtfs/AlertsDao.kt index 5487ad4..f2ed29d 100644 --- a/app/src/main/java/it/reyboz/bustorino/data/gtfs/AlertsDao.kt +++ b/app/src/main/java/it/reyboz/bustorino/data/gtfs/AlertsDao.kt @@ -1,181 +1,181 @@ package it.reyboz.bustorino.data.gtfs import androidx.lifecycle.LiveData import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction @Dao interface AlertsDao { @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertAlert(alert: GtfsAlertEntity) @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertAlerts(alerts: List) @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertTranslations(items: List) @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertActivePeriods(items: List) @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertInformedEntities(items: List) @Query("DELETE FROM gtfsrt_alert_translations WHERE alertId = :id") suspend fun deleteTranslationsFor(id: String) @Query("DELETE FROM alerts_active_periods WHERE alertId = :id") suspend fun deleteActivePeriodsFor(id: String) @Query("DELETE FROM alerts_informed_entities WHERE alertId = :id") suspend fun deleteInformedEntitiesFor(id: String) /** * Inserisce o aggiorna un alert e tutti i suoi figli atomicamente. * * Nota: se l'alert esiste già, ne preserviamo il valore di `seen` esistente * (non vogliamo che un re-fetch del feed reimposti a false un alert già letto). * Il chiamante può forzare un valore passandolo dentro `alert.seen`; in quel * caso si usa quello. */ @Transaction suspend fun insertMissingAlerts( alerts: List, translations: List, periods: List, entities: List, preserveSeen: Boolean = true ) { /* *** CONSIDER THIS if we ever need to replace the data instead of ignoring *** val toInsert = if (preserveSeen) { val existingSeen = isUserSeen(alert.id) if (existingSeen != null) alert.copy(userSeen = existingSeen) else alert } else { alert } insertAlert(toInsert) */ // Pulizia esplicita dei figli prima di reinserirli. // Le CASCADE coprirebbero il caso di REPLACE su PK, ma essere espliciti // evita sorprese e funziona anche se un giorno cambiamo strategia. //deleteTranslationsFor(alert.id) //deleteActivePeriodsFor(alert.id) //deleteInformedEntitiesFor(alert.id) if(alerts.isNotEmpty()) insertAlerts(alerts) if (translations.isNotEmpty()) insertTranslations(translations) if (periods.isNotEmpty()) insertActivePeriods(periods) if (entities.isNotEmpty()) insertInformedEntities(entities) } // ---------- "Seen" flag ---------- @Query("SELECT userSeen FROM gtfsrt_alerts WHERE id = :id") suspend fun isUserSeen(id: String): Boolean? @Query("UPDATE gtfsrt_alerts SET userSeen = :seen WHERE id = :id") suspend fun setSeen(id: String, seen: Boolean) @Query("UPDATE gtfsrt_alerts SET userSeen = 1") suspend fun markAllSeen() //@Query("SELECT COUNT(*) FROM gtfsrt_alerts WHERE userSeen = 0") //suspend fun countUnseen(): Int // ---------- Read ---------- @Transaction @Query("SELECT * FROM gtfsrt_alerts ORDER BY fetchedAt DESC") fun getAllAlertsLiveData(): LiveData> @Transaction @Query("SELECT * FROM gtfsrt_alerts") suspend fun getAllAlerts(): List @Transaction @Query("SELECT * FROM gtfsrt_alerts WHERE userSeen = 0 ORDER BY fetchedAt DESC") suspend fun getUnseenAlerts(): List @Transaction @Query("SELECT * FROM gtfsrt_alerts WHERE id = :id") suspend fun getAlert(id: String): AlertWithDetails? @Transaction @Query(""" SELECT a.* FROM gtfsrt_alerts a INNER JOIN alerts_informed_entities ie ON ie.alertId = a.id - WHERE ie.stopId = :stopId + WHERE ie.stopId = :stopGtfsId ORDER BY a.fetchedAt DESC """) - fun getAlertsForStop(stopId: String): LiveData> + fun getAlertsForStopGtfsId(stopGtfsId: String): LiveData> @Transaction @Query(""" SELECT al.* FROM gtfsrt_alerts al INNER JOIN alerts_informed_entities ie ON ie.alertId = al.id WHERE ie.routeId = :routeId OR ie.tripRouteId = :routeId ORDER BY al.fetchedAt DESC """) fun getAlertsForRoute(routeId: String): LiveData> // ---------- Delete ---------- @Query("DELETE FROM gtfsrt_alerts WHERE id = :id") suspend fun deleteAlert(id: String) @Delete suspend fun deleteAlerts(alerts: List) @Query("DELETE FROM gtfsrt_alerts") suspend fun deleteAll() /** * Cancella tutti gli alert ricevuti più di 48 ore fa. * Le CASCADE sulle FK puliscono automaticamente translations, * active_periods e informed_entities. * * @param now epoch millis "adesso" (default: System.currentTimeMillis()). * Esposto come parametro per facilitare i test. * @return numero di righe cancellate. */ @Query("DELETE FROM gtfsrt_alerts WHERE fetchedAt < :cutoff") suspend fun deleteOlderThan(cutoff: Long): Int //TODO use this to remove inactive alerts suspend fun deleteInactiveAlerts() { val alerts = getAllAlerts() val alertsRemove = ArrayList() val currentUnixTime = (System.currentTimeMillis()/1000).toInt() for (a in alerts) { var active = false for(p in a.activePeriods){ if(p.end==null || p.start==null) continue if (p.start <= currentUnixTime && p.end>=currentUnixTime) { active = true break } } if(!active) alertsRemove.add(a.alert) } deleteAlerts(alertsRemove) } suspend fun deleteOlderThan48h(now: Long = System.currentTimeMillis()): Int { val cutoff = now - 48L * 60L * 60L * 1000L return deleteOlderThan(cutoff) } suspend fun deleteOlderThanHours(hours: Long, now : Long = System.currentTimeMillis()): Int { val cutoff = now - hours *60L*60L*1000 return deleteOlderThan(cutoff) } } diff --git a/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsAlertsDBConverter.kt b/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsAlertsDBConverter.kt index 293e247..9e425be 100644 --- a/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsAlertsDBConverter.kt +++ b/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsAlertsDBConverter.kt @@ -1,135 +1,135 @@ package it.reyboz.bustorino.data.gtfs import com.google.transit.realtime.GtfsRealtime /** * Risultato del mapping di un singolo FeedEntity: * tutte le righe pronte per essere passate a [AlertDao.upsertAlert]. */ data class MappedAlert( val alert: GtfsAlertEntity, val translations: List, val activePeriods: List, val informedEntities: List ) public object GtfsAlertsDBConverter { /** * Converte un FeedEntity GTFS-RT (che contiene un Alert) nelle entity Room. * * @param entity il FeedEntity dal feed. Deve avere `hasAlert() == true`. * @param fetchedAtMillis epoch millis del momento di ricezione/salvataggio. * @return null se il FeedEntity non contiene un alert (es. è un TripUpdate). */ fun fromFeedEntity( entity: GtfsRealtime.FeedEntity, fetchedAtMillis: Long ): MappedAlert { if (!entity.hasAlert()) throw IllegalArgumentException("Alert entity can't be null") val al = entity.alert val alertId = entity.id val alert = GtfsAlertEntity( id = alertId, cause = al.cause, effect = al.effect, fetchedAt = fetchedAtMillis, userSeen = false ) val translations = buildList { // Header if (al.hasHeaderText()) { al.headerText.translationList.forEach { t -> add( GtfsAlertsTranslation( alertId = alertId, field = GtfsAlertsTranslation.FIELD_HEADER, language = if (t.hasLanguage()) t.language else null, text = t.text ) ) } } // Description if (al.hasDescriptionText()) { al.descriptionText.translationList.forEach { t -> add( GtfsAlertsTranslation( alertId = alertId, field = GtfsAlertsTranslation.FIELD_DESCRIPTION, language = if (t.hasLanguage()) t.language else null, text = t.text ) ) } } // URL (anche lui TranslatedString in GTFS-RT) if (al.hasUrl()) { al.url.translationList.forEach { t -> add( GtfsAlertsTranslation( alertId = alertId, field = GtfsAlertsTranslation.FIELD_URL, language = if (t.hasLanguage()) t.language else null, text = t.text ) ) } } } val activePeriods = al.activePeriodList.map { tr -> GtfsAlertsActivePeriods( alertId = alertId, start = if (tr.hasStart()) tr.start else null, end = if (tr.hasEnd()) tr.end else null ) } val informedEntities = al.informedEntityList.map { e -> val (tripId, tripRouteId, directionId) = if (e.hasTrip()) { val td = e.trip Triple( if (td.hasTripId()) "gtt:${td.tripId}" else null, if (td.hasRouteId()) "gtt:${td.routeId}" else null, if (td.hasDirectionId()) td.directionId else null ) } else { Triple(null, null, null) } GtfsAlertInformedEntity( alertId = alertId, //agencyId = if (e.hasAgencyId()) e.agencyId else null, routeId = if (e.hasRouteId()) "gtt:${e.routeId}" else null, routeType = if (e.hasRouteType()) e.routeType else null, - stopId = if (e.hasStopId()) e.stopId else null, + stopId = if (e.hasStopId()) "gtt:${e.stopId}" else null, tripId = tripId, tripRouteId = tripRouteId, directionId = directionId ) } return MappedAlert(alert, translations, activePeriods, informedEntities) } /** * Comodità: prende un intero FeedMessage e mappa solo i FeedEntity che sono alert, * ignorando TripUpdate e VehiclePosition. */ fun fromFeedMessage( feed: GtfsRealtime.FeedMessage, fetchedAtMillis: Long = System.currentTimeMillis() ): List { return feed.entityList.mapNotNull { fe -> // Salta gli entity marcati come deleted if (fe.isDeleted || !fe.hasAlert()) null else fromFeedEntity(fe, fetchedAtMillis) } } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/AlertsDialogFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/AlertsDialogFragment.kt index 585af4e..16ca5c8 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/AlertsDialogFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/AlertsDialogFragment.kt @@ -1,160 +1,174 @@ /* BusTO - Fragments components Copyright (C) 2026 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.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.TextView import android.widget.Toast import androidx.cardview.widget.CardView import androidx.fragment.app.DialogFragment import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.work.ExistingWorkPolicy import androidx.work.WorkManager import it.reyboz.bustorino.R import it.reyboz.bustorino.adapters.AlertLineFullAdapter import it.reyboz.bustorino.backend.gtfs.GtfsUtils import it.reyboz.bustorino.data.GtfsAlertDBDownloadWorker import it.reyboz.bustorino.data.gtfs.AlertWithDetails import it.reyboz.bustorino.data.gtfs.GtfsAlertsTranslation import it.reyboz.bustorino.viewmodels.ServiceAlertsViewModel import java.util.Locale import kotlin.getValue import kotlin.collections.HashMap -class AlertsDialogFragment(private val gtfsLineShow: String) : DialogFragment() { +class AlertsDialogFragment(private val gtfsLineShow: String, private val stopToShow: String) : DialogFragment() { private lateinit var titleTextView: TextView private lateinit var messageTextView: TextView private lateinit var statusCardView: CardView private lateinit var recyclerView: RecyclerView private val alertsViewModel: ServiceAlertsViewModel by activityViewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - Log.d(DEBUG_TAG, "created DialogFragment for line ${gtfsLineShow}") + Log.d(DEBUG_TAG, "created DialogFragment for line ${gtfsLineShow} and/or stop ${stopToShow}") } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { // Inflate the layout for this fragment val root = inflater.inflate(R.layout.fragment_dialog_alerts_line, container, false) titleTextView = root.findViewById(R.id.titleTextView) - titleTextView.setText(getString(R.string.alert_line_fill,GtfsUtils.lineNameDisplayFromGtfsID(gtfsLineShow))) + val text = if (gtfsLineShow.isNotEmpty()) + getString(R.string.alert_line_fill,GtfsUtils.lineNameDisplayFromGtfsID(gtfsLineShow)) + else if(stopToShow.isNotEmpty()){ + getString(R.string.alert_stop_fill,stopToShow) + } else{ + throw Exception("Either text or line has to be filled") + } + titleTextView.setText(text) recyclerView = root.findViewById(R.id.alertsRecyclerView) recyclerView.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) messageTextView = root.findViewById(R.id.alertMessageTextView) statusCardView = root.findViewById(R.id.statusCard) - alertsViewModel.alertsByRouteLiveData.observe(viewLifecycleOwner){ alerts -> - showAlerts(alerts) + if(gtfsLineShow.isNotEmpty()) + alertsViewModel.alertsByRouteLiveData.observe(viewLifecycleOwner){ alerts -> + showAlerts(alerts) + } + else if(stopToShow.isNotEmpty()){ + alertsViewModel.alertsByStopLiveData.observe(viewLifecycleOwner){ alerts -> showAlerts(alerts) } } val btnClose = root.findViewById(R.id.btnClose) btnClose.setOnClickListener { dismiss() } val btnRefresh = root.findViewById(R.id.btnRefresh) btnRefresh.setOnClickListener { val name = "manualUpdateAlerts" val req = GtfsAlertDBDownloadWorker.makeOneTimeRequest("manualUpdate$gtfsLineShow") WorkManager.getInstance(requireContext()).enqueueUniqueWork(name, ExistingWorkPolicy.KEEP,req) Toast.makeText(context, R.string.checking_alerts_update, Toast.LENGTH_SHORT).show() } return root } override fun onStart() { super.onStart() dialog?.window?.setLayout( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ) } private fun showAlerts(alerts: List) { val currentLang = Locale.getDefault().language val ms = "language : $currentLang" val langs_msg = HashMap() for (a in alerts) { for (tr in a.translations){ if(tr.field == GtfsAlertsTranslation.FIELD_HEADER){ tr.language?.let{ if(langs_msg.containsKey(it)){ langs_msg[it] = langs_msg[it]!! + 1 } else{ langs_msg[it] = 1 } } //found the title, stop break } } } Log.d(DEBUG_TAG, "Lang $currentLang, alerts: $langs_msg, of lang: ${langs_msg[currentLang]}") val msgInLang = langs_msg[currentLang]?: 0 val langShow = if (msgInLang > 0){ currentLang } else if("en" in langs_msg.keys){ "en" } else{ "it" } // if there are no messages with "it", then it's over val count = langs_msg[langShow] ?: 0 if (count == 0){ messageTextView.text = "ERROR: NO ALERTS TO SHOW" statusCardView.visibility = View.VISIBLE } else if(msgInLang == 0){ val msgShow = if(langShow == "en") getString(R.string.english) else getString(R.string.italian) messageTextView.text = getString(R.string.no_alerts_in_your_language_fill, msgShow) statusCardView.visibility = View.VISIBLE } // put them in the adapter if(count>0){ recyclerView.adapter = AlertLineFullAdapter(alerts, langShow) } } companion object { /** * Use this factory method to create a new instance of * this fragment using the provided parameters. * * @param gtfsLine Line To show. * @return A new instance of fragment LineAlertsDialogFragment. */ @JvmStatic - fun newInstance(gtfsLine: String) = - AlertsDialogFragment(gtfsLine) + fun newInstanceForLine(gtfsLine: String) = + AlertsDialogFragment(gtfsLine, "") + @JvmStatic + fun newInstanceForStop(stop: String) = + AlertsDialogFragment("", stop) private const val GTFS_LINE_ARG = "gtfsLine" private const val DEBUG_TAG = "BusTO-AlertsDialog" } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/AlertsFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/AlertsFragment.kt index 85234b1..b2915be 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/AlertsFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/AlertsFragment.kt @@ -1,173 +1,196 @@ /* BusTO - Fragments components Copyright (C) 2026 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.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.cardview.widget.CardView import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.recyclerview.widget.RecyclerView import com.google.transit.realtime.GtfsRealtime import it.reyboz.bustorino.R +import it.reyboz.bustorino.backend.Palina import it.reyboz.bustorino.viewmodels.ServiceAlertsViewModel import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.TimeZone /** * A simple [Fragment] subclass. * Use the [AlertsFragment.newInstance] factory method to * create an instance of this fragment. */ class AlertsFragment : ScreenBaseFragment() { private val alertsViewModel: ServiceAlertsViewModel by activityViewModels() private lateinit var textView: TextView + private lateinit var statusTextView: TextView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) arguments?.let { //param1 = it.getString(ARG_PARAM1) //param2 = it.getString(ARG_PARAM2) } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { // Inflate the layout for this fragment val root = inflater.inflate(R.layout.fragment_alerts, container, false) textView = root.findViewById(R.id.simpleTextView) + statusTextView = root.findViewById(R.id.statusTextView) alertsViewModel.allAlertsLiveData.observe(viewLifecycleOwner, { alerts -> - val sb = StringBuilder() - val unixTimestamp = (System.currentTimeMillis() / 1000) - for (x in alerts) { - sb.append(x.longPrint()) - sb.append("----- Alert active: ").append(x.isActive(unixTimestamp)).append("\n\n") + if(alerts==null){ + return@observe } + if(alerts.isEmpty()){ + textView.text = "No Alerts to show" + } else { + val sb = StringBuilder() + val unixTimestamp = (System.currentTimeMillis() / 1000) + for (x in alerts) { + sb.append(x.longPrint()) + sb.append("----- Alert active: ").append(x.isActive(unixTimestamp)).append("\n\n") + } - textView.text = sb.toString() + textView.text = sb.toString() + } }) + alertsViewModel.getDownloadStatusLiveData(requireContext()).observe(viewLifecycleOwner, { workinfos -> + val sb = StringBuilder() + var c = 1 + if(workinfos!=null && workinfos.isNotEmpty()){ + for (worki in workinfos){ + sb.append("$c - state: ${worki.state}, attempt ${worki.runAttemptCount}").append("\n") + c++ + } + } + statusTextView.text = sb.toString() + }) + + - alertsViewModel.setStopFilter("472") + //alertsViewModel.setStopFilter(Palina("472") /*alertsViewModel.alertsForStop.observe(viewLifecycleOwner){ Log.d(DEBUG_TAG, "Got ${it.size} alerts") it?.let { showAlerts(it) } } */ /* alertsViewModel.alertsByRouteLiveData.observe(viewLifecycleOwner) { map -> Log.d(DEBUG_TAG, "Alerts for routes: ${map.keys}") val keys = map.keys if(keys.isNotEmpty()){ val sb = StringBuilder() for (key in keys.sorted()) { sb.append(" ======== Route: $key =======").append("\n") sb.append(makeAlertListText(map[key]!!)).append("\n") Log.d(DEBUG_TAG, "Route: $key len: ${map[key]!!.size}") } textView.text = sb.toString() } } */ return root } override fun getBaseViewForSnackBar(): View? { TODO("Not yet implemented") } private fun makeAlertListText(alerts: List) : String{ val sb = StringBuilder() for (al in alerts) { sb.append("=========== Alert ===========\n") sb.append("Title:\n") for (t in al.headerText.translationList) { sb.append(t.language).append(": ").append(t.text).append("\n") } sb.append("Description:\n") val transl = al.descriptionText.translationList for (t in transl) { sb.append(t.language).append(": ").append(t.text).append("\n") } val infE = al.informedEntityList sb.append("--- Active periods count: ${al.activePeriodCount}\n") val timeActive = al.getActivePeriod(0) sb.append("Start: ").append(getTimeStampToString(timeActive.start)).append(" ") sb.append("End: ").append(getTimeStampToString(timeActive.end)).append("\n") sb.append("--- Cause:\n") sb.append(al.cause.name).append("\n") sb.append("--- Informed entities:\n") for (e in infE) { if(e.hasTrip()){ sb.append("Trip: ${e.trip.tripId} for route ${e.trip.routeId}, ") } else{ sb.append("No Trip, ") } sb.append("Stop: ${e.stopId}, Route: ${e.routeId}\n") } sb.append("\n") } return sb.toString() } companion object { /** * Use this factory method to create a new instance of * this fragment using the provided parameters. * * @return A new instance of fragment AlertsFragment. */ @JvmStatic fun newInstance() = AlertsFragment().apply { arguments = Bundle().apply { //putString(ARG_PARAM1, param1) //putString(ARG_PARAM2, param2) } } fun getTimeStampToString(timestamp: Long): String? { val date = Date(timestamp*1000) val sdf= SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) sdf.timeZone = TimeZone.getTimeZone("Europe/Rome") return sdf.format(date) } private const val DEBUG_TAG = "BusTO-AlertsFragment" } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/ArrivalsFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/ArrivalsFragment.kt index fcca08b..32737b9 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/ArrivalsFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/ArrivalsFragment.kt @@ -1,858 +1,885 @@ /* BusTO - Fragments components Copyright (C) 2018-2026 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.content.Context import android.database.Cursor 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.cardview.widget.CardView +import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.loader.app.LoaderManager import androidx.loader.content.CursorLoader import androidx.loader.content.Loader import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import it.reyboz.bustorino.R import it.reyboz.bustorino.adapters.PalinaAdapter import it.reyboz.bustorino.adapters.PalinaAdapter.PalinaClickListener import it.reyboz.bustorino.adapters.RouteAdapter import it.reyboz.bustorino.adapters.RouteOnlyLineAdapter import it.reyboz.bustorino.backend.* import it.reyboz.bustorino.backend.DBStatusManager.OnDBUpdateStatusChangeListener import it.reyboz.bustorino.backend.Passaggio.Source import it.reyboz.bustorino.data.AppDataProvider import it.reyboz.bustorino.data.NextGenDB import it.reyboz.bustorino.data.UserDB import it.reyboz.bustorino.middleware.CoroutineFavoriteAction import it.reyboz.bustorino.util.LinesNameSorter import it.reyboz.bustorino.viewmodels.ArrivalsViewModel +import it.reyboz.bustorino.viewmodels.ServiceAlertsViewModel import java.util.* class ArrivalsFragment : ResultBaseFragment(), LoaderManager.LoaderCallbacks { private var DEBUG_TAG = DEBUG_TAG_ALL private lateinit var stopID: String //private set private var stopName: String? = null private var prefs: DBStatusManager? = null private var listener: OnDBUpdateStatusChangeListener? = null private var justCreated = false private var lastUpdatedPalina: Palina? = null private var needUpdateOnAttach = false private var fetchersChangeRequestPending = false //Views protected lateinit var addToFavorites: ImageButton protected lateinit var openInMapButton: ImageButton protected lateinit var arrivalsSourceTextView: TextView private lateinit var messageTextView: TextView private lateinit var preMessageTextView: TextView // this hold the "Arrivals at: " text protected lateinit var arrivalsRecyclerView: RecyclerView private var mListAdapter: PalinaAdapter? = null private lateinit var resultsLayout : LinearLayout private lateinit var loadingMessageTextView: TextView private lateinit var progressBar: ProgressBar private lateinit var howDoesItWorkTextView: TextView private lateinit var hideHintButton: Button + private lateinit var alertsCardView : CardView //private NestedScrollView theScrollView; protected lateinit var noArrivalsRecyclerView: RecyclerView private var noArrivalsAdapter: RouteOnlyLineAdapter? = null private var noArrivalsTitleView: TextView? = null private var layoutManager: GridLayoutManager? = null //private View canaryEndView; private var fetchers: List = ArrayList() private val arrivalsViewModel : ArrivalsViewModel by viewModels() - + private val alertsViewModel: ServiceAlertsViewModel by activityViewModels() private var reloadOnResume = true private var routesNoPassages = listOf() fun getStopID() = stopID private val palinaClickListener: PalinaClickListener = object : PalinaClickListener { override fun showRouteFullDirection(route: Route) { var routeName = route.routeLongDisplayName Log.d(DEBUG_TAG, "Make toast for line " + route.name) if (context == null) Log.e(DEBUG_TAG, "Touched on a route but Context is null") else if (route.destinazione == null || route.destinazione.length == 0) { Toast.makeText( context, getString(R.string.route_towards_unknown, routeName), Toast.LENGTH_SHORT ).show() } else { Toast.makeText( context, getString(R.string.route_towards_destination, routeName, route.destinazione), Toast.LENGTH_SHORT ).show() } } override fun requestShowingRoute(route: Route) { showRoutesInLinesFragment(route) } } private fun showRoutesInLinesFragment(route: Route) { Log.d( DEBUG_TAG, """Need to show line for route: gtfsID ${route.gtfsId} name ${route.name}""" ) if (route.gtfsId != null) { mListener.openLineFromStop(route.gtfsId, stopID) } else { val gtfsID = FiveTNormalizer.getGtfsRouteID(route) Log.d(DEBUG_TAG, "GtfsID for route is: $gtfsID") mListener.openLineFromStop(gtfsID, stopID) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) stopID = requireArguments().getString(KEY_STOP_ID) ?: "" DEBUG_TAG = DEBUG_TAG_ALL + " " + stopID arrivalsViewModel.setStopId(stopID) //this might really be null stopName = requireArguments().getString(KEY_STOP_NAME) val arrivalsFragment = this listener = object : OnDBUpdateStatusChangeListener { override fun onDBStatusChanged(updating: Boolean) { if (!updating) { loaderManager.restartLoader( loaderFavId, arguments, arrivalsFragment ) } else { val lm = loaderManager lm.destroyLoader(loaderFavId) lm.destroyLoader(loaderStopId) } } override fun defaultStatusValue(): Boolean { return true } } prefs = DBStatusManager(requireContext().applicationContext, listener) justCreated = true } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { val root = inflater.inflate(R.layout.fragment_arrivals, container, false) messageTextView = root.findViewById(R.id.messageTextView) preMessageTextView = root.findViewById(R.id.arrivalsTextView) addToFavorites = root.findViewById(R.id.addToFavorites) openInMapButton = root.findViewById(R.id.openInMapButton) // "How does it work part" howDoesItWorkTextView = root.findViewById(R.id.howDoesItWorkTextView) hideHintButton = root.findViewById(R.id.hideHintButton) //TODO: Hide this layout at the beginning, show it later resultsLayout = root.findViewById(R.id.resultsLayout) loadingMessageTextView = root.findViewById(R.id.loadingMessageTextView) progressBar = root.findViewById(R.id.circularProgressBar) + alertsCardView = root.findViewById(R.id.alertsCardView) hideHintButton.setOnClickListener { v: View? -> this.onHideHint(v) } //theScrollView = root.findViewById(R.id.arrivalsScrollView); // recyclerview holding the arrival times arrivalsRecyclerView = root.findViewById(R.id.arrivalsRecyclerView) val manager = LinearLayoutManager(context) arrivalsRecyclerView.setLayoutManager(manager) val mDividerItemDecoration = DividerItemDecoration( arrivalsRecyclerView.context, manager.orientation ) arrivalsRecyclerView.addItemDecoration(mDividerItemDecoration) arrivalsSourceTextView = root.findViewById(R.id.timesSourceTextView) arrivalsSourceTextView.setOnLongClickListener { view: View? -> if (!fetchersChangeRequestPending) { rotateFetchers() //Show we are changing provider arrivalsSourceTextView.setText(R.string.arrival_source_changing) requestArrivalsForTheFragment() fetchersChangeRequestPending = true return@setOnLongClickListener true } false } arrivalsSourceTextView.setOnClickListener(View.OnClickListener { view: View? -> Toast.makeText( context, R.string.change_arrivals_source_message, Toast.LENGTH_SHORT ) .show() }) //Button addToFavorites.setClickable(true) addToFavorites.setOnClickListener(View.OnClickListener { v: View? -> // add/remove the stop in the favorites toggleStopFavorites() }) val displayName = requireArguments().getString(STOP_TITLE) if (displayName != null) setTextViewMessage( String.format( getString(R.string.passages_fill), displayName ) ) val probablemessage = requireArguments().getString(MESSAGE_TEXT_VIEW) if (probablemessage != null) { //Log.d("BusTO fragment " + this.getTag(), "We have a possible message here in the savedInstaceState: " + probablemessage); messageTextView.setText(probablemessage) messageTextView.setVisibility(View.VISIBLE) } //no arrivals stuff noArrivalsRecyclerView = root.findViewById(R.id.noArrivalsRecyclerView) /*layoutManager = GridLayoutManager(context, 60) layoutManager!!.spanSizeLookup = object : SpanSizeLookup() { override fun getSpanSize(position: Int): Int { return 12 } } */ noArrivalsRecyclerView.setLayoutManager(getFlexLayoutManager(requireContext())) noArrivalsTitleView = root.findViewById(R.id.noArrivalsMessageTextView) //canaryEndView = root.findViewById(R.id.canaryEndView); /*String sourcesTextViewData = getArguments().getString(SOURCES_TEXT); if (sourcesTextViewData!=null){ timesSourceTextView.setText(sourcesTextViewData); }*/ //need to do this when we recreate the fragment but we haven't updated the arrival times val tentPalina = arrivalsViewModel.palinaToShow.value if(lastUpdatedPalina == null && tentPalina != null) { //this updates lastUpdatedPalina and also shows the arrival source updateFragmentData(tentPalina) } //lastUpdatedPalina?.let { showArrivalsSources(it) } return root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) arrivalsViewModel.arrivalsRequestRunningLiveData.observe(viewLifecycleOwner, { running -> //UI CHANGES TO APPLY WHEN THE REQUEST IS RUNNING mListener.toggleSpinner(running) if(running){ //different way of setting this flag if(lastUpdatedPalina == null || lastUpdatedPalina?.totalNumberOfPassages==0) { showLoadingMessageForFirstTime() } } else{ //stopped running, we can show the palina //val uname = lastUpdatedPalina?.stopDisplayName if (lastUpdatedPalina == null || lastUpdatedPalina?.numRoutesWithArrivals == 0) { //no passages and result is not valid setUIForNoStopFound() } } }) arrivalsViewModel.palinaToShow.observe(viewLifecycleOwner){ Log.d(DEBUG_TAG, "New result palina observed, has coords: ${it.hasCoords()}, title ${it?.stopDisplayName}, number of passages: ${it.totalNumberOfPassages}") val palinaIsValid = it!=null && (it.totalNumberOfPassages>0 || it.stopDisplayName!=null) if (palinaIsValid){ updateFragmentData(it) } if(arrivalsViewModel.arrivalsRequestRunningLiveData.value ==false) { //finished loading if (palinaIsValid) { //the result is true hideLoadingMessageAndShowResults() } else { setUIForNoStopFound() } } } // this is only for the progress arrivalsViewModel.sourcesLiveData.observe(viewLifecycleOwner){ Log.d(DEBUG_TAG, "Using arrivals source: $it") val srcString = getDisplayArrivalsSource(it,requireContext()) loadingMessageTextView.text = getString(R.string.searching_arrivals_fmt, srcString) } arrivalsViewModel.resultLiveData.observe(viewLifecycleOwner){res -> val src = arrivalsViewModel.sourcesLiveData.value when (res) { Fetcher.Result.OK -> {} Fetcher.Result.CLIENT_OFFLINE -> showFetcherMessage(R.string.network_error, src) Fetcher.Result.SERVER_ERROR -> { if (utils.isConnected(context)) { showFetcherMessage(R.string.parsing_error, src) } else { showFetcherMessage(R.string.network_error, src) } showFetcherMessage(R.string.internal_error,src) } Fetcher.Result.PARSER_ERROR -> showFetcherMessage(R.string.internal_error, src) Fetcher.Result.QUERY_TOO_SHORT -> showFetcherMessage(R.string.query_too_short, src) Fetcher.Result.EMPTY_RESULT_SET -> showFetcherMessage(R.string.no_arrivals_stop, src) Fetcher.Result.NOT_FOUND -> showFetcherMessage(R.string.no_bus_stop_have_this_name, src) else -> showFetcherMessage(R.string.internal_error, src) } } arrivalsViewModel.stopInFavorites.observe(viewLifecycleOwner, { isFavorite -> updateStarIcon(isFavorite) }) + + alertsViewModel.alertsByStopLiveData.observe(viewLifecycleOwner) { alerts -> + if(alerts!=null && alerts.isNotEmpty()){ + alertsCardView.visibility = View.VISIBLE + alertsCardView.setOnClickListener { + AlertsDialogFragment.newInstanceForStop(stopID).show(parentFragmentManager, "AlertsDialogStop$stopID") + } + } else{ + alertsCardView.visibility = View.GONE + } + } } private fun showShortToast(id: Int) = showToastMessage(id,true) private fun showFetcherMessage(id: Int, source: Source?){ val srcString = source?.let{ getDisplayArrivalsSource(it,requireContext())} if (srcString!=null){ Toast.makeText(requireContext(), id, Toast.LENGTH_SHORT).show() } else{ val message = getString(id) Toast.makeText(requireContext(), "$srcString : $message", Toast.LENGTH_SHORT).show() } } /*private fun changeUIFirstSearchActive(yes: Boolean){ if(yes){ resultsLayout.visibility = View.GONE progressBar.visibility = View.VISIBLE loadingMessageTextView.visibility = View.VISIBLE } else{ resultsLayout.visibility = View.VISIBLE progressBar.visibility = View.GONE loadingMessageTextView.visibility = View.GONE } } */ private fun showLoadingMessageForFirstTime(){ resultsLayout.visibility = View.GONE progressBar.visibility = View.VISIBLE loadingMessageTextView.visibility = View.VISIBLE } private fun hideLoadingMessageAndShowResults(){ resultsLayout.visibility = View.VISIBLE progressBar.visibility = View.GONE loadingMessageTextView.visibility = View.GONE } private fun setUIForNoStopFound(){ progressBar.visibility=View.INVISIBLE // Avoid showing this ugly message if we have found the stop, clearly it exists but GTT doesn't provide arrival times if (stopName==null) loadingMessageTextView.text = getString(R.string.no_bus_stop_have_this_name) else loadingMessageTextView.text = getString(R.string.no_arrivals_stop) } override fun onResume() { super.onResume() val loaderManager = loaderManager Log.d(DEBUG_TAG, "OnResume, justCreated $justCreated, lastUpdatedPalina is: $lastUpdatedPalina") mListener.readyGUIfor(FragmentKind.ARRIVALS) //fix bug when the list adapter is null mListAdapter?.let { resetListAdapter(it) } if (noArrivalsAdapter != null) { noArrivalsRecyclerView.adapter = noArrivalsAdapter } if (stopID.isNotEmpty()) { if (!justCreated) { fetchers = utils.getDefaultArrivalsFetchers(context) adjustFetchersToSource() if (reloadOnResume) requestArrivalsForTheFragment() //mListener.requestArrivalsForStopID(stopID) } else { //start first search requestArrivalsForTheFragment() showLoadingMessageForFirstTime() justCreated = false } //start the loader if (prefs!!.isDBUpdating(true)) { prefs!!.registerListener() } else { Log.d(DEBUG_TAG, "Restarting loader for stop") loaderManager.restartLoader( loaderFavId, arguments, this ) } updateMessage() } if (ScreenBaseFragment.getOption(requireContext(), OPTION_SHOW_LEGEND, true)) { showHints() } } override fun onStart() { super.onStart() if (needUpdateOnAttach) { updateFragmentData(null) needUpdateOnAttach = false + } else{ + updateFragmentData(lastUpdatedPalina ?: Palina(stopID)) } } override fun onPause() { if (listener != null) prefs!!.unregisterListener() super.onPause() val loaderManager = loaderManager Log.d(DEBUG_TAG, "onPause, have running loaders: " + loaderManager.hasRunningLoaders()) loaderManager.destroyLoader(loaderFavId) } override fun onAttach(context: Context) { super.onAttach(context) //get fetchers fetchers = utils.getDefaultArrivalsFetchers(context) } fun reloadsOnResume(): Boolean { return reloadOnResume } fun setReloadOnResume(reloadOnResume: Boolean) { this.reloadOnResume = reloadOnResume } // HINT "HOW TO USE" private fun showHints() { howDoesItWorkTextView.visibility = View.VISIBLE hideHintButton.visibility = View.VISIBLE //actionHelpMenuItem.setVisible(false); } private fun hideHints() { howDoesItWorkTextView.visibility = View.GONE hideHintButton.visibility = View.GONE //actionHelpMenuItem.setVisible(true); } fun onHideHint(v: View?) { hideHints() setOption(requireContext(), OPTION_SHOW_LEGEND, false) } fun getCurrentFetchersAsArray(): Array { val r= fetchers.toTypedArray() //?: emptyArray() return r } private fun rotateFetchers() { Log.d(DEBUG_TAG, "Rotating fetchers, before: $fetchers") fetchers?.let { Collections.rotate(it, -1) } Log.d(DEBUG_TAG, "Rotating fetchers, afterwards: $fetchers") } /** * Update the UI with the new data * @param p the full Palina */ fun updateFragmentData(p: Palina?) { - if (p != null) lastUpdatedPalina = p + if (p != null) { + lastUpdatedPalina = p + //set the gtfsID for the alerts + if(isAdded){ + //alertsViewModel.setStopFilter(p) + } else{ + Log.w(DEBUG_TAG, "Cannot filter alerts for palina $p, the fragment is not added") + } + } + if (!isAdded) { //defer update at next show if (p == null) Log.w(DEBUG_TAG, "Asked to update the data, but we're not attached and the data is null") else needUpdateOnAttach = true } else { //set title if(stopName==null && p?.stopDisplayName != null){ stopName = p.stopDisplayName updateMessage() } val adapter = PalinaAdapter(context, lastUpdatedPalina, palinaClickListener, true) p?.let { //only update the sources if we have actual passaggi if (arrivalsViewModel.arrivalsRequestRunningLiveData.value == false) showArrivalsSources(lastUpdatedPalina!!) } resetListAdapter(adapter) lastUpdatedPalina?.let{ pal -> openInMapButton.setOnClickListener { if (pal.hasCoords()) mListener.showMapCenteredOnStop(pal) } } val routesWithNoPassages = lastUpdatedPalina!!.routesWithNoPassages if (routesWithNoPassages.isEmpty()) { //hide the views if there are no empty routes noArrivalsRecyclerView.visibility = View.GONE noArrivalsTitleView!!.visibility = View.GONE } else { val sorter = LinesNameSorter() this.routesNoPassages = routesWithNoPassages.sortedWith{ r1, r2 -> sorter.compare(r1.displayCode, r2.displayCode) } noArrivalsAdapter = RouteOnlyLineAdapter(routesNoPassages.map{r->r.displayCode}, ){ idx,_ -> val route = routesNoPassages[idx] showRoutesInLinesFragment(route) } noArrivalsRecyclerView.adapter = noArrivalsAdapter noArrivalsRecyclerView.visibility = View.VISIBLE noArrivalsTitleView!!.visibility = View.VISIBLE } //canaryEndView.setVisibility(View.VISIBLE); //check if canaryEndView is visible //boolean isCanaryVisibile = ViewUtils.Companion.isViewPartiallyVisibleInScroll(canaryEndView, theScrollView); //Log.d(DEBUG_TAG, "Canary view fully visibile: "+isCanaryVisibile); } } /** * Set the message of the arrival times source * @param p Palina with the arrival times */ protected fun showArrivalsSources(p: Palina) { val source = p.passaggiSourceIfAny val source_txt = getDisplayArrivalsSource(source, requireContext()) // val updatedFetchers = adjustFetchersToSource(source) if (!updatedFetchers) Log.w(DEBUG_TAG, "Tried to update the source fetcher but it didn't work") val base_message = getString(R.string.times_source_fmt, source_txt) arrivalsSourceTextView.text = base_message arrivalsSourceTextView.visibility = View.VISIBLE if (p.totalNumberOfPassages > 0) { arrivalsSourceTextView.visibility = View.VISIBLE } else { arrivalsSourceTextView.visibility = View.INVISIBLE } fetchersChangeRequestPending = false } protected fun adjustFetchersToSource(source: Source?): Boolean { if (source == null) return false var count = 0 if (source != Source.UNDETERMINED) while (source != fetchers[0]!!.sourceForFetcher && count < 200) { //we need to update the fetcher that is requested rotateFetchers() count++ } return count < 200 } protected fun adjustFetchersToSource(): Boolean { if (lastUpdatedPalina == null) return false val source = lastUpdatedPalina!!.passaggiSourceIfAny return adjustFetchersToSource(source) } /** * Update the stop title in the fragment */ private fun updateMessage() { var message = "" if (stopName != null && !stopName!!.isEmpty()) { message = ("$stopID - $stopName") } else if (stopID != null) { message = stopID } else { Log.e("ArrivalsFragm$tag", "NO ID FOR THIS FRAGMENT - something went horribly wrong") } if (message.isNotEmpty()) { //setTextViewMessage(getString(R.string.passages_fill, message)) setTextViewMessage(message) } } /** * Set the message textView * @param message the whole message to write in the textView */ fun setTextViewMessage(message: String?) { messageTextView.text = message messageTextView.visibility = View.VISIBLE } override fun onCreateLoader(id: Int, p1: Bundle?): Loader { val args = arguments //if (args?.getString(KEY_STOP_ID) == null) throw val stopID = args?.getString(KEY_STOP_ID) ?: "" val builder = AppDataProvider.getUriBuilderToComplete() val cl: CursorLoader when (id) { loaderFavId -> { builder.appendPath("favorites").appendPath(stopID) cl = CursorLoader(requireContext(), builder.build(), UserDB.FAVORITES_COLUMNS_ARRAY, null, null, null) } loaderStopId -> { builder.appendPath("stop").appendPath(stopID) cl = CursorLoader( requireContext(), builder.build(), arrayOf(NextGenDB.Contract.StopsTable.COL_NAME), null, null, null ) } else -> { cl = CursorLoader(requireContext(), builder.build(), null, null,null,null) Log.d(DEBUG_TAG, "This is probably going to crash") } } cl.setUpdateThrottle(500) return cl } override fun onLoadFinished(loader: Loader, data: Cursor) { /* when (loader.id) { loaderFavId -> { val colUserName = data.getColumnIndex(UserDB.FAVORITES_COLUMNS_ARRAY[1]) if (data.count > 0) { // IT'S IN FAVORITES data.moveToFirst() val probableName = data.getString(colUserName) stopIsInFavorites = true if (probableName != null && !probableName.isEmpty()) stopName = probableName //set the stop //update the message in the textview updateMessage() } else { stopIsInFavorites = false } updateStarIcon() if (stopName == null) { //stop is not inside the favorites and wasn't provided Log.d("ArrivalsFragment$tag", "Stop wasn't in the favorites and has no name, looking in the DB") loaderManager.restartLoader( loaderStopId, arguments, this ) } } loaderStopId -> if (data.count > 0) { data.moveToFirst() val index = data.getColumnIndex( NextGenDB.Contract.StopsTable.COL_NAME ) if (index == -1) { Log.e(DEBUG_TAG, "Index is -1, column not present. App may explode now...") } stopName = data.getString(index) updateMessage() } else { Log.w("ArrivalsFragment$tag", "Stop is not inside the database... CLOISTER BELL") } } */ } override fun onLoaderReset(loader: Loader) { //NOTHING TO DO } protected fun resetListAdapter(adapter: PalinaAdapter) { mListAdapter = adapter arrivalsRecyclerView.adapter = adapter arrivalsRecyclerView.visibility = View.VISIBLE } fun toggleStopFavorites() { val stop: Stop? = lastUpdatedPalina if (stop != null) { // toggle the status in background CoroutineFavoriteAction(requireContext().applicationContext, CoroutineFavoriteAction.Action.TOGGLE){ }.execute(stop) } else { // this case have no sense, but just immediately update the favorite icon //updateStarIconFromLastBusStop(true) Log.d(DEBUG_TAG, "Stop is null!") } } /* /** * Update the star "Add to favorite" icon */ fun updateStarIconFromLastBusStop(toggleDone: Boolean) { stopIsInFavorites = if (stopIsInFavorites) !toggleDone else toggleDone updateStarIcon() } */ /** * Update the star icon according to `stopIsInFavorites` */ fun updateStarIcon(stopIsInFavorites: Boolean) { // no favorites no party! // check if there is a last Stop if (stopID.isEmpty()) { addToFavorites.visibility = View.INVISIBLE } else { // filled or outline? if (stopIsInFavorites) { addToFavorites.setImageResource(R.drawable.ic_star_filled) } else { addToFavorites.setImageResource(R.drawable.ic_star_outline) } addToFavorites.visibility = View.VISIBLE } } override fun onDestroyView() { //arrivalsRecyclerView = null if (arguments != null) { requireArguments().putString(SOURCES_TEXT, arrivalsSourceTextView.text.toString()) requireArguments().putString(MESSAGE_TEXT_VIEW, messageTextView.text.toString()) } super.onDestroyView() } override fun getBaseViewForSnackBar(): View? { return null } fun isFragmentForTheSameStop(stopID: String) : Boolean{ return if (tag != null) tag == getFragmentTag(stopID) else false } fun isFragmentForTheSameStop(p: Palina): Boolean { return isFragmentForTheSameStop(p.ID) } /** * Request arrivals in the fragment */ fun requestArrivalsForTheFragment(){ // Run with previous fetchers context?.let { mListener.toggleSpinner(true) val fetcherSources = fetchers.map { f-> f?.sourceForFetcher?.name ?: "" } //val workRequest = ArrivalsWorker.buildWorkRequest(stopID, fetcherSources.toTypedArray()) //val workManager = WorkManager.getInstance(it) //workManager.enqueueUniqueWork(getArrivalsWorkID(stopID), ExistingWorkPolicy.REPLACE, workRequest) arrivalsViewModel.requestArrivalsForStop(stopID,fetcherSources.toTypedArray()) //prepareGUIForArrivals(); //new AsyncArrivalsSearcher(fragmentHelper,fetchers, getContext()).execute(ID); Log.d(DEBUG_TAG, "Started search for arrivals of stop $stopID") } } companion object { private const val OPTION_SHOW_LEGEND = "show_legend" private const val KEY_STOP_ID = "stopid" private const val KEY_STOP_NAME = "stopname" private const val DEBUG_TAG_ALL = "BUSTOArrivalsFragment" private const val loaderFavId = 2 private const val loaderStopId = 1 const val STOP_TITLE: String = "messageExtra" private const val SOURCES_TEXT = "sources_textview_message" @JvmStatic @JvmOverloads fun newInstance(stopID: String, stopName: String? = null): ArrivalsFragment { val fragment = ArrivalsFragment() val args = Bundle() args.putString(KEY_STOP_ID, stopID) //parameter for ResultListFragmentrequestArrivalsForStopID //args.putSerializable(LIST_TYPE,FragmentKind.ARRIVALS); if (stopName != null) { args.putString(KEY_STOP_NAME, stopName) } fragment.arguments = args return fragment } //return "palina_" + p.ID @JvmStatic fun getFragmentTag(stopID: String) = "palina_$stopID" @JvmStatic fun getFragmentTag(p: Palina) = getFragmentTag(p.ID) @JvmStatic fun getArrivalsWorkID(stopID: String) = "arrivals_search_$stopID" @JvmStatic fun getDisplayArrivalsSource(source: Source, context: Context): String{ return when (source) { Source.GTTJSON -> context.getString(R.string.gttjsonfetcher) Source.FiveTAPI -> context.getString(R.string.fivetapifetcher) Source.FiveTScraper -> context.getString(R.string.fivetscraper) Source.MatoAPI -> context.getString(R.string.source_mato) Source.UNDETERMINED -> //Don't show the view context.getString(R.string.undetermined_source) } } } } 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 bfec583..fd42089 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt @@ -1,1159 +1,1191 @@ /* 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.constraintlayout.widget.ConstraintLayout 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.VehicleUtils 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 var mapView: MapView? = null 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 lateinit var bottomSheetBehavior: BottomSheetBehavior protected var locationEngine: MapLibreLocationEngine? = null protected lateinit var locationProvider: FusedNativeLocationProvider protected var shownToastNoPosition = false protected 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() } } /** * What to do when requesting the permission, when it's ok, initialize the map location component */ 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 var bottomLayout: ConstraintLayout? = 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 lateinit var vehicleIcon: ImageView 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 listener once we have received the location 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) + protected fun initBottomSheet(view: View){ + 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) + linesPassingTextView = view.findViewById(R.id.descriptionTextView) arrivalsCard = view.findViewById(R.id.arrivalsCardButton) directionsCard = view.findViewById(R.id.directionsCardButton) - bottomrightImage = view.findViewById(R.id.rightmostImageView) + vehicleIcon = view.findViewById(R.id.vehicleIcon) bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet) + bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN + + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + //init bottom sheet + initBottomSheet(view) + + bottomrightImage = view.findViewById(R.id.rightmostImageView) extraBottomTextView = view.findViewById(R.id.extraBottomTextView) - bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN } override fun onResume() { super.onResume() mapView?.onResume() val newMapStyle = PreferencesHolder.getMapLibreStyleFile(requireContext()) Log.d(DEBUG_TAG, "onResume newMapStyle: $newMapStyle, lastMapStyle: $lastMapStyle") // TODO: reload style if user changed preferences //if(newMapStyle!=lastMapStyle){ // reloadMap() //} } 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 onStop() { mapView?.onStop() super.onStop() } override fun onPause() { mapView?.onPause() super.onPause() } override fun onDestroyView() { bottomLayout = null locationProvider.removeListener(deviceLocationStatusListener) mapInitialized = false locationInitialized = false 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) } */ } //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 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) if(BuildConfig.DEBUG) Log.d(DEBUG_TAG, "Initializing location, request initial position") startInitialPositionRequest() if(!locationEnabledOnDevice){ warnLocationNotEnabledOnDevice() }else { setLocationComponentEnabled(true) } locationInitialized = true onMapLocationComponentInitialized() } } @SuppressLint("MissingPermission") protected fun startInitialPositionRequest(){ locationEngine?.requestLocationUpdates(LocationEngineRequest.Builder(500).setDisplacement(20.0f).build(), mapLibreLocationCallback, null) } protected fun stopInitialPositionRequest(){ locationEngine?.removeLocationUpdates(mapLibreLocationCallback) } /** * 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) ) + // if you change this, remember to change the color of the vehicleIcon 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 + val update = data.posUpdate + val vehInfo = VehicleUtils.getTypeForLabel(update.vehicle) + if(vehInfo == null){ + vehicleIcon.visibility = View.GONE + } else{ + val ico = when(vehInfo.type){ + VehicleUtils.VehicleType.BUS -> R.drawable.ic_bus_small + VehicleUtils.VehicleType.ELECTRIC_BUS -> R.drawable.ic_bus_electric_small + VehicleUtils.VehicleType.TRAM -> R.drawable.ic_tram_24 + } + vehicleIcon.setImageDrawable(ResourcesCompat.getDrawable(resources, ico, activity?.theme)) + vehicleIcon.visibility = View.VISIBLE + + vehicleIcon.setOnClickListener { + val print = "${vehInfo.type.getName()} ${vehInfo.name}" + makeToast(print) + } + } + } 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)) + vehicleIcon.visibility = View.GONE + } //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) { if(!receivedFirstLocation){ //use case: the user has decided to disable the location before the first position arrived stopInitialPositionRequest() } // we have to disable it setMapLocationEnabled(false) } else if(deviceHasLocationProvider()) { if(Permissions.bothLocationPermissionsGranted(context)){ 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() } //adjust ui setLocationIconEnabled(false) } } /** * 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.locationUserActive.value = enabled onMapLocationEnabled(enabled) } /** * Function to run at the first time the fragment is opened * Check if we have the permissions, and then initialize the map location component * If we don't have it, request the permission */ protected fun checkInitMapLocation(mapReady: MapLibreMap,style: Style, context: Context) { //enable location 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) } if(!req) { setMapLocationEnabled(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 deviceHasLocationProvider(): Boolean{ val locManager = requireContext().getSystemService(LOCATION_SERVICE) as LocationManager return locManager.allProviders.isNotEmpty() } /** * 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 59bbe00..83118e7 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt @@ -1,1185 +1,1188 @@ /* 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 lineInfoButton.setOnClickListener { - AlertsDialogFragment(lineID).show(parentFragmentManager, "Alerts-Line$lineID") + AlertsDialogFragment.newInstanceForLine(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 } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) //reflect UI //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.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.newInstanceForLine(lineID).show(parentFragmentManager, "Alerts-Line$lineID") + } } // ------------- 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.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() 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() 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/MapLibreFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt index 30a0614..9b01873 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt @@ -1,756 +1,748 @@ /* 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.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.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) + initBottomSheet(rootView) 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() } Log.d(DEBUG_TAG, "Fragment View Created!") //TODO: Reshow last open stop when switching back to the map fragment return rootView } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) //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.locationUserActive.observe(viewLifecycleOwner){ setLocationIconEnabled(it)} mapStateViewModel.followingUserPosition.observe(viewLifecycleOwner){ updateFollowingIcon(it)} observeStatusLivePositions() } /** * 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.stopsToShow.value if(stopsInCache.isNullOrEmpty()) 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 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() Log.d(DEBUG_TAG, "Fragment paused") map?.let{ //if map is initialized mapStateViewModel.saveMapState(it) } try{ //save last location map?.locationComponent?.let{ if(locationInitialized && it.isLocationComponentActivated){ stopsViewModel.lastUserLocation = it.lastKnownLocation } } }catch (e: Exception){ Log.w(DEBUG_TAG, "Cannot save lastKnowLocation from map location component,error: ${e.message}") } mapStateViewModel.lastOpenStopID.postValue(shownStopInBottomSheet?.ID) if (livePositionsViewModel.useMQTTPositionsLiveData.value!!) livePositionsViewModel.stopMatoUpdates() } override fun onStop() { super.onStop() Log.d(DEBUG_TAG, "Fragment stopped!") } 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") 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,"Displaying ${features.size} stops") // 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){ val newLocation = LatLng(loc.latitude, loc.longitude) //center the position only if it is close enough if(newLocation.distanceTo(DEFAULT_LATLNG) < MAX_DIST_KM * 1000) map?.cameraPosition = CameraPosition.Builder().target(LatLng(loc.latitude, loc.longitude)).build() } else mapStateViewModel.locationToShow = loc } } override fun onFailure(p0: java.lang.Exception) { 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}",) } }) } if(locationEnabledOnDevice){ setFollowUserLocation(true) } } 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(DEFAULT_LATLNG) > 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.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.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/viewmodels/ServiceAlertsViewModel.kt b/app/src/main/java/it/reyboz/bustorino/viewmodels/ServiceAlertsViewModel.kt index 31fdad3..fbe3375 100644 --- a/app/src/main/java/it/reyboz/bustorino/viewmodels/ServiceAlertsViewModel.kt +++ b/app/src/main/java/it/reyboz/bustorino/viewmodels/ServiceAlertsViewModel.kt @@ -1,196 +1,210 @@ /* BusTO - View Model components Copyright (C) 2026 Fabio Mazza This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package it.reyboz.bustorino.viewmodels import android.app.Application +import android.content.Context import android.util.Log import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.map import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope import androidx.room.concurrent.AtomicBoolean import androidx.work.ExistingWorkPolicy +import androidx.work.WorkInfo import androidx.work.WorkManager import com.google.transit.realtime.GtfsRealtime.Alert import it.reyboz.bustorino.backend.NetworkVolleyManager +import it.reyboz.bustorino.backend.Stop import it.reyboz.bustorino.data.GtfsAlertDBDownloadWorker import it.reyboz.bustorino.data.GtfsRepository import it.reyboz.bustorino.data.gtfs.GtfsDatabase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.seconds class ServiceAlertsViewModel(app: Application) : AndroidViewModel(app) { private val gtfsRepo = GtfsRepository(app) private val volleyManager = NetworkVolleyManager.getInstance(app) private val alertsDao = GtfsDatabase.getGtfsDatabase(app).alertsDao() private val workManager = WorkManager.getInstance(app) //val alertsLiveData = MutableLiveData>(ArrayList()) - private val stopToFilter = MutableLiveData("") + private val stopGtfsIdToFilter = MutableLiveData() private val routeToFilter = MutableLiveData("") val lastTimeRunningDownload = MutableLiveData(0L) private val keepRunning = AtomicBoolean(false) private val waitingToRerun = AtomicBoolean(false) fun setRunningDownloadRequests(value: Boolean) { Log.d(DEBUG_TAG, "setRunningDownloadRequests: $value") keepRunning.set(value) } val alertsByRouteLiveData = routeToFilter.switchMap { val unixTimestamp = (System.currentTimeMillis()/1000) gtfsRepo.getAlertsByRouteID(it).map{ l -> l.filter { al->al.isActive(unixTimestamp) }} } - val alertsByStopLiveData = stopToFilter.switchMap { - gtfsRepo.alertsDao.getAlertsForStop(it) + val alertsByStopLiveData = stopGtfsIdToFilter.switchMap { + if(it.gtfsID!=null) gtfsRepo.alertsDao.getAlertsForStopGtfsId(it.gtfsID!!) else MutableLiveData() } val allAlertsLiveData = gtfsRepo.alertsDao.getAllAlertsLiveData() /* private val volleyErrorListener = Response.ErrorListener { err -> Log.e(DEBUG_TAG, "Error getting alerts: ${err.message}", err) } private var numTries = 0 private val responseListener = Response.Listener> { Log.d(DEBUG_TAG, "Received ${it.size} alerts") if (it.isEmpty()) { if(numTries<4){ numTries++; requestAlerts() Log.d(DEBUG_TAG, "Alerts requested again: $numTries") } } alertsLiveData.postValue(it.map { it.alert }) } private fun requestAlerts(){ val req = GtfsRtAlertsRequest(volleyErrorListener, responseListener) volleyManager.requestQueue.add(req) } */ - fun setStopFilter(stopId: String) { - stopToFilter.value = stopId + /// WE DO NOT KNOW HOW TO GET THE GTFS STOP ID, the one given by MaTO is the same as the stop CODE + /// but we need the ID from the stops.txt table of GTT GTFS data + /// DISABLING THIS FUNCTION + /*fun setStopFilter(stop: Stop) { + Log.d(DEBUG_TAG, "Setting stop to filter: ${stop.ID} - ${stop.stopDisplayName}, gtfsID: ${stop.gtfsID}") + stopGtfsIdToFilter.value = stop } + + */ fun setGtfsLineFilter(routeId: String) { routeToFilter.value = routeId } private fun downloadWorkIfTimePassed(){ val currentTime = System.currentTimeMillis() waitingToRerun.set(false) val diff = currentTime - lastTimeRunningDownload.value!! Log.d(DEBUG_TAG, "diff : ${diff/1000} s") val MINUTES_CHECK = 3 if (lastTimeRunningDownload.value == 0L || currentTime > lastTimeRunningDownload.value!! + MINUTES_CHECK*60*1000){ //actually enqueue request Log.d(DEBUG_TAG, "Launching request to download alerts") - val req = GtfsAlertDBDownloadWorker.makeOneTimeRequest("alertsrn") + val req = GtfsAlertDBDownloadWorker.makeOneTimeRequest(WORK_TAG) workManager.enqueueUniqueWork("AlertsDownloadsRun", ExistingWorkPolicy.KEEP, req) lastTimeRunningDownload.postValue(System.currentTimeMillis()) } viewModelScope.launch(Dispatchers.IO) { waitingToRerun.set(true) delay((61).seconds) if(keepRunning.get()) downloadWorkIfTimePassed() } } fun launchAlertsPeriodCheck(){ setRunningDownloadRequests(true) if(!waitingToRerun.get()) downloadWorkIfTimePassed() } - + fun getDownloadStatusLiveData(context: Context): LiveData>{ + val workManager = WorkManager.getInstance(context) + return workManager.getWorkInfosByTagLiveData(WORK_TAG) + } private fun filterAlertsForStop(stopId: String, alerts: ArrayList) : ArrayList{ val filteredAlerts = ArrayList() for (al in alerts) { for (ie in al.informedEntityList) { if (ie.stopId == stopId) { filteredAlerts.add(al) } } } return filteredAlerts } init{ /* requestAlerts() alertsByRouteLiveData.addSource(alertsLiveData){ alerts -> if(alerts.isEmpty()){ return@addSource } val routeMap = HashMap>() for (al in alerts){ for( ie in al.informedEntityList){ var routeID = "" if(ie.routeId.isNotEmpty()){ routeID = "gtt:${ie.routeId}" } else if(ie.trip?.routeId?.isNotEmpty() == true){ routeID = "gtt:${ie.trip?.routeId}" } if (routeID.isNotEmpty()) { if (!routeMap.containsKey(routeID)) { routeMap[routeID] = ArrayList() } routeMap[routeID]!!.add(al) } } } alertsByRouteLiveData.postValue(routeMap) } // Set transformations for stop alertsForStop.addSource(stopToFilter){ stopId -> alertsLiveData.value?.let{ alertsForStop.postValue(filterAlertsForStop(stopId,it)) } } alertsForStop.addSource(alertsLiveData){ alerts -> alertsForStop.postValue(filterAlertsForStop(stopToFilter.value!!,alerts)) } */ } companion object{ private const val DEBUG_TAG = "BusTO-GTFSRTAlerts" + public const val WORK_TAG = "AlertsDownloadWorker" } } \ No newline at end of file diff --git a/app/src/main/res/drawable/bus_marker.xml b/app/src/main/res/drawable/bus_marker.xml deleted file mode 100644 index 21d020d..0000000 --- a/app/src/main/res/drawable/bus_marker.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_bus_electric_small.xml b/app/src/main/res/drawable/ic_bus_electric_small.xml new file mode 100644 index 0000000..7df1b74 --- /dev/null +++ b/app/src/main/res/drawable/ic_bus_electric_small.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_bus_small.xml b/app/src/main/res/drawable/ic_bus_small.xml new file mode 100644 index 0000000..8d595f9 --- /dev/null +++ b/app/src/main/res/drawable/ic_bus_small.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_tram_24.xml b/app/src/main/res/drawable/ic_tram_24.xml new file mode 100644 index 0000000..ea3140c --- /dev/null +++ b/app/src/main/res/drawable/ic_tram_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/fragment_alerts.xml b/app/src/main/res/layout/fragment_alerts.xml index d4dffd9..e2e2bcd 100644 --- a/app/src/main/res/layout/fragment_alerts.xml +++ b/app/src/main/res/layout/fragment_alerts.xml @@ -1,29 +1,37 @@ - - + + android:layout_height="0dp" + android:layout_margin="10dp" + > \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_arrivals.xml b/app/src/main/res/layout/fragment_arrivals.xml index 069aaaa..99cc6b2 100644 --- a/app/src/main/res/layout/fragment_arrivals.xml +++ b/app/src/main/res/layout/fragment_arrivals.xml @@ -1,248 +1,318 @@ + android:layout_width="match_parent" + android:layout_height="wrap_content"> + +