diff --git a/app/build.gradle b/app/build.gradle index 90c1337..ee1dea5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,186 +1,188 @@ plugins { id 'com.google.protobuf' id 'org.jetbrains.kotlin.android' id 'com.android.application' id 'com.google.devtools.ksp' } android { compileSdk 36 namespace "it.reyboz.bustorino" defaultConfig { applicationId "it.reyboz.bustorino" minSdkVersion 24 //noinspection EditedTargetSdkVersion targetSdkVersion 36 buildToolsVersion = '36.0.0' versionCode 72 versionName "2.4.9" vectorDrawables.useSupportLibrary = true multiDexEnabled true javaCompileOptions { annotationProcessorOptions { arguments = ["room.schemaLocation": "$projectDir/assets/schemas/".toString()] } } testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } testOptions { unitTests.returnDefaultValues = true } sourceSets { androidTest.assets.srcDirs += files("$projectDir/assets/schemas/".toString()) } buildTypes { debug { applicationIdSuffix ".debug" versionNameSuffix "-dev" } gitpull{ applicationIdSuffix ".gitdev" versionNameSuffix "-gitdev" } } repositories { mavenCentral() mavenLocal() } dependencies { //new libraries } compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } kotlin { jvmToolchain 17 } lint { abortOnError false } androidResources { generateLocaleConfig true } buildFeatures{ buildConfig = true } splits{ abi{ enable true reset() // Specifies a list of ABIs for Gradle to create APKs for. include "x86", "x86_64","armeabi-v7a", "arm64-v8a" universalApk true } } packagingOptions { resources.excludes.add("META-INF/*") } } protobuf { protoc { artifact = 'com.google.protobuf:protoc:3.22.3' } generateProtoTasks { all().each { task -> task.builtins { java { option 'lite' } } } } } ksp { arg("room.schemaLocation", "$projectDir/assets/schemas") } dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" implementation 'androidx.legacy:legacy-support-v4:1.0.0' // Guava implementation for DBUpdateWorker implementation 'com.google.guava:guava:33.5.0-android' + + implementation 'com.google.android.flexbox:flexbox:3.0.0' implementation "androidx.fragment:fragment-ktx:$fragment_version" implementation "androidx.activity:activity-ktx:$activity_version" implementation "androidx.annotation:annotation:1.9.1" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation "androidx.appcompat:appcompat:$appcompat_version" implementation "androidx.appcompat:appcompat-resources:$appcompat_version" implementation "androidx.preference:preference-ktx:$preference_version" implementation "androidx.work:work-runtime:$work_version" implementation "androidx.work:work-runtime-ktx:$work_version" implementation "com.google.android.material:material:1.13.0" implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation "androidx.coordinatorlayout:coordinatorlayout:1.3.0" implementation 'org.jsoup:jsoup:1.22.1' implementation 'com.readystatesoftware.sqliteasset:sqliteassethelper:2.0.1' implementation 'com.android.volley:volley:1.2.1' //maplibre implementation 'org.maplibre.gl:android-sdk:12.0.1' implementation 'org.maplibre.gl:android-sdk-turf:6.0.1' implementation 'org.maplibre.gl:android-plugin-annotation-v9:3.0.2' // remember to enable maven repo jitpack.io when wanting to use osmbonuspack //implementation 'com.github.MKergall:osmbonuspack:6.9.0' // ACRA implementation "ch.acra:acra-mail:$acra_version" implementation "ch.acra:acra-dialog:$acra_version" // google transit realtime implementation 'com.google.protobuf:protoc:4.34.1' implementation 'com.google.protobuf:protobuf-javalite:4.34.1' // mqtt library //implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5' //implementation 'com.github.hannesa2:paho.mqtt.android:4.4' implementation("com.hivemq:hivemq-mqtt-client:1.3.13") implementation(platform("com.hivemq:hivemq-mqtt-client-websocket:1.3.13")) // ViewModel implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" // LiveData implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" // Lifecycles only (without ViewModel or LiveData) implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" // Legacy implementation 'androidx.legacy:legacy-support-v4:1.0.0' // Room components implementation "androidx.room:room-runtime:$room_version" implementation "androidx.room:room-ktx:$room_version" ksp "androidx.room:room-compiler:$room_version" //multidex - we need this to build the app implementation "androidx.multidex:multidex:$multidex_version" implementation 'de.siegmar:fastcsv:4.2.0' testImplementation 'junit:junit:4.13.2' androidTestImplementation "androidx.test.ext:junit:1.3.0" androidTestImplementation "androidx.test:core:$androidXTestVersion" androidTestImplementation "androidx.test:runner:$androidXTestVersion" androidTestImplementation "androidx.test:rules:$androidXTestVersion" androidTestImplementation "androidx.room:room-testing:$room_version" } diff --git a/app/src/main/java/it/reyboz/bustorino/adapters/PalinaAdapter.java b/app/src/main/java/it/reyboz/bustorino/adapters/PalinaAdapter.java index 416f3ca..76e3f0c 100644 --- a/app/src/main/java/it/reyboz/bustorino/adapters/PalinaAdapter.java +++ b/app/src/main/java/it/reyboz/bustorino/adapters/PalinaAdapter.java @@ -1,272 +1,295 @@ /* BusTO (backend components) Copyright (C) 2016 Ludovico Pavesi 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.adapters; import android.content.Context; import android.content.res.Resources; import androidx.annotation.NonNull; import androidx.cardview.widget.CardView; import androidx.core.content.res.ResourcesCompat; import androidx.preference.PreferenceManager; import android.content.SharedPreferences; +import android.view.Gravity; import android.os.Build; import android.util.TypedValue; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.PopupMenu; import android.widget.TextView; import java.util.*; import androidx.recyclerview.widget.RecyclerView; import it.reyboz.bustorino.R; import it.reyboz.bustorino.backend.Palina; import it.reyboz.bustorino.backend.Passaggio; import it.reyboz.bustorino.backend.Route; import it.reyboz.bustorino.backend.utils; import it.reyboz.bustorino.util.PassaggiSorter; import it.reyboz.bustorino.util.RouteSorterByArrivalTime; import org.jetbrains.annotations.NotNull; /** * This once was a ListView Adapter for BusLine[]. * * Thanks to Framentos developers for the guide: * http://www.framentos.com/en/android-tutorial/2012/07/16/listview-in-android-using-custom-listadapter-and-viewcache/# * * @author Valerio Bozzolan * @author Ludovico Pavesi * @author Fabio Mazza */ public class PalinaAdapter extends RecyclerView.Adapter implements SharedPreferences.OnSharedPreferenceChangeListener { private static final int ROW_LAYOUT = R.layout.entry_bus_line_passage; private static final int metroBg = R.drawable.route_background_metro; private static final int busBg = R.drawable.route_background_bus; private static final int extraurbanoBg = R.drawable.route_background_bus_long_distance; private static final int busIcon = R.drawable.bus; private static final int trainIcon = R.drawable.subway; private static final int tramIcon = R.drawable.tram; private final String KEY_CAPITALIZE; private Capitalize capit; private final List mRoutes; private final PalinaClickListener mRouteListener; @NonNull @NotNull @Override public PalinaViewHolder onCreateViewHolder(@NonNull @NotNull ViewGroup parent, int viewType) { View view = LayoutInflater.from(parent.getContext()) .inflate(ROW_LAYOUT, parent, false); return new PalinaViewHolder(view); } @Override public void onBindViewHolder(@NonNull @NotNull PalinaViewHolder vh, int position) { final Route route = mRoutes.get(position); final Context con = vh.itemView.getContext(); final Resources res = con.getResources(); vh.routeIDTextView.setText(route.getDisplayCode()); vh.routeCard.setOnClickListener(view -> mRouteListener.requestShowingRoute(route)); + + // Clicking anywhere on the row shows a popup menu + vh.itemView.setOnClickListener(view -> + openPopupMenuDetails(con,view, route) + ); //vh.rowRouteDestination.getVisibility() == View.VISIBLE ? vh.rowRouteDestination : vh.itemView + if(route.destinazione==null || route.destinazione.length() == 0) { vh.rowRouteDestination.setVisibility(View.GONE); // move around the route timetable final ViewGroup.MarginLayoutParams pars = (ViewGroup.MarginLayoutParams) vh.rowRouteTimetable.getLayoutParams(); if (pars!=null){ pars.topMargin = 16; if(Build.VERSION.SDK_INT >= 17) pars.setMarginStart(20); pars.leftMargin = 20; } } else { // View Holder Pattern(R) renders each element from a previous one: if the other one had an invisible rowRouteDestination, we need to make it visible. vh.rowRouteDestination.setVisibility(View.VISIBLE); String dest = route.destinazione; switch (capit){ case ALL: dest = route.destinazione.toUpperCase(Locale.ROOT); break; case FIRST: dest = utils.toTitleCase(route.destinazione, true); break; case DO_NOTHING: default: } vh.rowRouteDestination.setText(dest); - - //set click listener - vh.itemView.setOnClickListener(view -> { - mRouteListener.showRouteFullDirection(route); - }); } switch (route.type) { //UNKNOWN = BUS for the moment case UNKNOWN: case BUS: default: // convertView could contain another background, reset it //vh.rowStopIcon.setBackgroundResource(busBg); vh.rowRouteDestination.setCompoundDrawablesWithIntrinsicBounds(busIcon, 0, 0, 0); break; case LONG_DISTANCE_BUS: //vh.rowStopIcon.setBackgroundResource(extraurbanoBg); vh.routeCard.setCardBackgroundColor(ResourcesCompat.getColor(res, R.color.extraurban_bus_bg, null)); vh.rowRouteDestination.setCompoundDrawablesWithIntrinsicBounds(busIcon, 0, 0, 0); break; case METRO: //vh.rowStopIcon.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); //vh.rowStopIcon.setBackgroundResource(metroBg); vh.routeIDTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); vh.routeCard.setCardBackgroundColor(ResourcesCompat.getColor(res, R.color.metro_bg, null)); vh.rowRouteDestination.setCompoundDrawablesWithIntrinsicBounds(trainIcon, 0, 0, 0); break; case RAILWAY: //vh.rowStopIcon.setBackgroundResource(busBg); vh.rowRouteDestination.setCompoundDrawablesWithIntrinsicBounds(trainIcon, 0, 0, 0); break; case TRAM: // never used but whatever. //vh.rowStopIcon.setBackgroundResource(busBg); vh.rowRouteDestination.setCompoundDrawablesWithIntrinsicBounds(tramIcon, 0, 0, 0); break; } List passaggi = route.passaggi; //TODO: Sort the passaggi with realtime first if source is GTTJSONFetcher if(passaggi.size() == 0) { vh.rowRouteTimetable.setText(R.string.no_passages); } else { vh.rowRouteTimetable.setText(route.getPassaggiToString()); } } @Override public int getItemCount() { return mRoutes.size(); } //private static final int cityIcon = R.drawable.city; // hey look, a pattern! public static class PalinaViewHolder extends RecyclerView.ViewHolder { //final TextView rowStopIcon; final TextView routeIDTextView; final CardView routeCard; final TextView rowRouteDestination; final TextView rowRouteTimetable; public PalinaViewHolder(@NonNull @NotNull View view) { super(view); /* convertView.findViewById(R.id.routeID); vh.rowRouteDestination = (TextView) convertView.findViewById(R.id.routeDestination); vh.rowRouteTimetable = (TextView) convertView.findViewById(R.id.routesThatStopHere); */ //rowStopIcon = view.findViewById(R.id.routeID); routeIDTextView = view.findViewById(R.id.routeNameTextView); routeCard = view.findViewById(R.id.routeCard); rowRouteDestination = view.findViewById(R.id.routeDestination); rowRouteTimetable = view.findViewById(R.id.routesThatStopHere); } } private static Capitalize getCapitalize(SharedPreferences shPr, String key){ String capitalize = shPr.getString(key, ""); switch (capitalize.trim()){ case "KEEP": return Capitalize.DO_NOTHING; case "CAPITALIZE_ALL": return Capitalize.ALL; case "CAPITALIZE_FIRST": return Capitalize.FIRST; } return Capitalize.DO_NOTHING; } + private void openPopupMenuDetails(Context con, View view, Route route){ + PopupMenu popup = new PopupMenu(con, view, Gravity.END); + popup.inflate(R.menu.menu_arrivals_line_item); + if (route.destinazione == null || route.destinazione.isEmpty()) { + popup.getMenu().findItem(R.id.action_show_direction).setVisible(false); + } + popup.setOnMenuItemClickListener(item -> { + int id = item.getItemId(); + if (id == R.id.action_open_line) { + mRouteListener.requestShowingRoute(route); + return true; + } else if (id == R.id.action_show_direction) { + mRouteListener.showRouteFullDirection(route); + return true; + } + return false; + }); + popup.show(); + } + public PalinaAdapter(Context context, Palina p, PalinaClickListener listener, boolean hideEmptyRoutes) { Comparator sorter = null; if (p.getPassaggiSourceIfAny()== Passaggio.Source.GTTJSON){ sorter = new PassaggiSorter(); } final List routes; if (hideEmptyRoutes){ // build the routes by filtering them routes = new ArrayList<>(); for(Route r: p.queryAllRoutes()){ //add only if there is at least one passage if (r.numPassaggi()>0){ routes.add(r); } } } else routes = p.queryAllRoutes(); for(Route r: routes){ if (sorter==null) Collections.sort(r.passaggi); else Collections.sort(r.passaggi, sorter); } Collections.sort(routes,new RouteSorterByArrivalTime()); mRoutes = routes; KEY_CAPITALIZE = context.getString(R.string.pref_arrival_times_capit); SharedPreferences defSharPref = PreferenceManager.getDefaultSharedPreferences(context); defSharPref.registerOnSharedPreferenceChangeListener(this); this.capit = getCapitalize(defSharPref, KEY_CAPITALIZE); this.mRouteListener = listener; } @Override public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { if(key.equals(KEY_CAPITALIZE)){ capit = getCapitalize(sharedPreferences, KEY_CAPITALIZE); notifyDataSetChanged(); } } enum Capitalize{ DO_NOTHING, ALL, FIRST } public interface PalinaClickListener{ /** * Simple click listener for the whole line (show info) * @param route for toast */ void showRouteFullDirection(Route route); /** * Show the line with all the stops in the line screen * @param route partial line info */ void requestShowingRoute(Route route); } } diff --git a/app/src/main/java/it/reyboz/bustorino/adapters/RouteAdapter.kt b/app/src/main/java/it/reyboz/bustorino/adapters/RouteAdapter.kt index 5ccdf9c..372de99 100644 --- a/app/src/main/java/it/reyboz/bustorino/adapters/RouteAdapter.kt +++ b/app/src/main/java/it/reyboz/bustorino/adapters/RouteAdapter.kt @@ -1,59 +1,59 @@ package it.reyboz.bustorino.adapters import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.cardview.widget.CardView import androidx.recyclerview.widget.RecyclerView import it.reyboz.bustorino.R import it.reyboz.bustorino.data.gtfs.GtfsRoute import java.lang.ref.WeakReference class RouteAdapter(val routes: List, click: ItemClicker, - private val layoutId: Int = R.layout.entry_line_num_descr) : + private val layoutId: Int = R.layout.entry_line_name_description) : RecyclerView.Adapter() { val clickreference: WeakReference init { clickreference = WeakReference(click) } class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { val descrptionTextView: TextView val nameTextView : TextView val innerCardView : CardView? init { // Define click listener for the ViewHolder's View nameTextView = view.findViewById(R.id.lineShortNameTextView) descrptionTextView = view.findViewById(R.id.lineDirectionTextView) innerCardView = view.findViewById(R.id.innerCardView) } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val view = LayoutInflater.from(parent.context) .inflate(layoutId, parent, false) return ViewHolder(view) } override fun getItemCount() = routes.size override fun onBindViewHolder(holder: ViewHolder, position: Int) { // Get element from your dataset at this position and replace the // contents of the view with that element val route = routes[position] - holder.nameTextView.text = route.shortName + holder.nameTextView.text = route.getShortNameDisplay() holder.descrptionTextView.text = route.longName holder.itemView.setOnClickListener{ clickreference.get()?.onRouteItemClicked(route) } } fun interface ItemClicker{ fun onRouteItemClicked(gtfsRoute: GtfsRoute) } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/adapters/RouteOnlyLineAdapter.kt b/app/src/main/java/it/reyboz/bustorino/adapters/RouteOnlyLineAdapter.kt index bce4356..d12e0e8 100644 --- a/app/src/main/java/it/reyboz/bustorino/adapters/RouteOnlyLineAdapter.kt +++ b/app/src/main/java/it/reyboz/bustorino/adapters/RouteOnlyLineAdapter.kt @@ -1,62 +1,64 @@ package it.reyboz.bustorino.adapters import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import it.reyboz.bustorino.R +import it.reyboz.bustorino.backend.FiveTNormalizer import it.reyboz.bustorino.backend.Palina import java.lang.ref.WeakReference class RouteOnlyLineAdapter (val routeNames: List, onItemClick: OnClick?) : RecyclerView.Adapter() { private val clickreference: WeakReference? init { clickreference = if(onItemClick!=null) WeakReference(onItemClick) else null } /** * Provide a reference to the type of views that you are using * (custom ViewHolder) */ class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { val textView: TextView init { // Define click listener for the ViewHolder's View textView = view.findViewById(R.id.routeBallID) } } constructor(palina: Palina, showOnlyEmpty: Boolean): this(palina.routesNamesWithNoPassages, null) // Create new views (invoked by the layout manager) override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder { // Create a new view, which defines the UI of the list item val view = LayoutInflater.from(viewGroup.context) .inflate(R.layout.round_line_header, viewGroup, false) return ViewHolder(view) } // Replace the contents of a view (invoked by the layout manager) override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { // Get element from your dataset at this position and replace the // contents of the view with that element - viewHolder.textView.text = routeNames[position] + // SHOW "STAR" as "ST" + viewHolder.textView.text = FiveTNormalizer.filterFullStarName(routeNames[position]) viewHolder.itemView.setOnClickListener{ clickreference?.get()?.onItemClick(position, routeNames[position]) } } // Return the size of your dataset (invoked by the layout manager) override fun getItemCount() = routeNames.size fun interface OnClick{ fun onItemClick(index: Int, name: String) } } diff --git a/app/src/main/java/it/reyboz/bustorino/backend/FiveTNormalizer.java b/app/src/main/java/it/reyboz/bustorino/backend/FiveTNormalizer.java index c95696d..059b9ff 100644 --- a/app/src/main/java/it/reyboz/bustorino/backend/FiveTNormalizer.java +++ b/app/src/main/java/it/reyboz/bustorino/backend/FiveTNormalizer.java @@ -1,383 +1,393 @@ /* BusTO (backend components) Copyright (C) 2016 Ludovico Pavesi 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; import android.util.Log; /** * Converts some weird stop IDs found on the 5T website to the form used everywhere else (including GTT website). *

* A stop ID in normalized form is:
* - a string containing a number without leading zeros
* - a string beginning with "ST" and then a number
* - whatever the GTT website uses.
*

* A bus route ID in normalized form is:
* - a string containing a number and optionally: "B", "CS", "CD", "N", "S", "C" at the end
* - the string "METRO"
* - "ST1" or "ST2" (Star 1 and Star 2)
* - a string beginning with E, N, S or W, followed by two digits (with a leading zero)
* - "RV2", "OB1"
* - "CAN" or "FTC" (railway lines)
* - ...screw it, let's just hope all the websites and APIs return something sane as route IDs.
*

* This class exists because Java doesn't support traits.
*
* Note: this class also just became useless, as 5T now uses the same format as GTT website. */ public abstract class FiveTNormalizer { public static String FiveTNormalizeRoute(String RouteID) { while (RouteID.startsWith("0")) { RouteID = RouteID.substring(1); } return RouteID; } // public static String FiveTNormalizeStop(String StopID) { // StopID = FiveTNormalizeRoute(StopID); // // is this faster than a regex? // if (StopID.length() == 5 && StopID.startsWith("ST") && Character.isLetter(StopID.charAt(2)) && Character.isLetter(StopID.charAt(3)) && Character.isLetter(StopID.charAt(4))) { // switch (StopID) { // case "STFER": // return "8210"; // case "STPAR": // return "8211"; // case "STMAR": // return "8212"; // case "STMAS": // return "8213"; // case "STPOS": // return "8214"; // case "STMGR": // return "8215"; // case "STRIV": // return "8216"; // case "STRAC": // return "8217"; // case "STBER": // return "8218"; // case "STPDA": // return "8219"; // case "STDOD": // return "8220"; // case "STPSU": // return "8221"; // case "STVIN": // return "8222"; // case "STREU": // return "8223"; // case "STPNU": // return "8224"; // case "STMCI": // return "8225"; // case "STNIZ": // return "8226"; // case "STDAN": // return "8227"; // case "STCAR": // return "8228"; // case "STSPE": // return "8229"; // case "STLGO": // return "8230"; // } // } // return StopID; // } // // public static String NormalizedToFiveT(final String StopID) { // if(StopID.startsWith("82") && StopID.length() == 4) { // switch (StopID) { // case "8230": // return "STLGO"; // case "8229": // return "STSPE"; // case "8228": // return "STCAR"; // case "8227": // return "STDAN"; // case "8226": // return "STNIZ"; // case "8225": // return "STMCI"; // case "8224": // return "STPNU"; // case "8223": // return "STREU"; // case "8222": // return "STVIN"; // case "8221": // return "STPSU"; // case "8220": // return "STDOD"; // case "8219": // return "STPDA"; // case "8218": // return "STBER"; // case "8217": // return "STRAC"; // case "8216": // return "STRIV"; // case "8215": // return "STMGR"; // case "8214": // return "STPOS"; // case "8213": // return "STMAS"; // case "8212": // return "STMAR"; // case "8211": // return "STPAR"; // case "8210": // return "STFER"; // } // } // // return StopID; // } public static Route.Type decodeType(final String routename, final String bacino) { if(routename.equals("METRO")) { return Route.Type.METRO; } else if(routename.equals("79")) { return Route.Type.RAILWAY; } switch (bacino) { case "U": return Route.Type.BUS; case "F": return Route.Type.RAILWAY; case "E": return Route.Type.LONG_DISTANCE_BUS; default: return Route.Type.BUS; } } /** * Converts a route ID from internal format to display format, returns null if it has the same name. * * @param routeID ID in "internal" and normalized format * @return string with display name, null if unchanged */ public static String routeInternalToDisplay(final String routeID) { if(routeID.length() == 3 && routeID.charAt(2) == 'B') { return routeID.substring(0,2).concat("/"); } + //TODO: Decide what to do about the "+" lines (68+, 13+) switch(routeID) { case "1C": return "1 Chieri"; case "1N": return "1 Nichelino"; case "OB1": return "1 Orbassano"; case "2C": return "2 Chieri"; case "RV2": return "2 Rivalta"; case "CO1": return "Circolare Collegno"; case "SE1": // I wonder why GTT calls this "SE1" while other absurd names have a human readable name too. return "1 Settimo"; case "16CD": return "16 CD"; case "16CS": return "16 CS"; case "79": return "Cremagliera Sassi-Superga"; case "W01": return "Night Buster 1 Arancio"; case "N10": return "Night Buster 10 Gialla"; case "W15": return "Night Buster 15 Rosa"; case "S18": return "Night Buster 18 Blu"; case "S04": return "Night Buster 4 Azzurra"; case "N4": return "Night Buster 4 Rossa"; case "N57": return "Night Buster 57 Oro"; case "W60": return "Night Buster 60 Argento"; case "E68": return "Night Buster 68 Verde"; case "S05": return "Night Buster 5 Viola"; case "ST1": return "Star 1"; case "ST2": return "Star 2"; case "4N": return "4 Navetta"; case "10N": return "10 Navetta"; case "13N": return "13 Navetta"; case "35N": return "35 Navetta"; case "36N": return "36 Navetta"; case "36S": return "36 Speciale"; case "38S": return "38 Speciale"; case "44S": return "44 Scolastico"; case "46N": return "46 Navetta"; case "M1S": return "MetroBus sostitutivo"; default: return null; } } public static String fixShortNameForDisplay(String routeID, boolean withBarratoSpace) { /*if (routeID.length() == 3 && routeID.charAt(2) == 'B') { return routeID.substring(0, 2).concat("/"); } else if (routeID.charAt(routeID.length() - 1) == '/' && routeID.charAt(routeID.length() - 2) == ' ') { //remove last space return routeID.substring(0, routeID.length() - 2).concat("/"); } else return routeID; */ int len = routeID.length(); final boolean isBarrato = (routeID.charAt(len-1) == 'B') || (routeID.charAt(len-1) == '/'); if(isBarrato) { String output; if ((routeID.charAt(len - 2) == ' ')) output = routeID.substring(0, len - 2); else output = routeID.substring(0, len - 1); if(withBarratoSpace) output = output.concat(" /"); else output = output.concat("/"); return output; } else return routeID; } public static String fixShortNameForDisplay(String routeID){ return fixShortNameForDisplay(routeID, false); } public static String routeDisplayToInternal(String displayName){ String name = displayName.trim(); if(name.charAt(displayName.length()-1)=='/'){ return displayName.replace(" ","").replace("/","B"); } switch (name.toLowerCase()){ //DEFAULT CASES case "star 1": return "ST1"; case "star 2": return "ST2"; case "night buster 1 arancio": return "W01"; case "night buster 10 gialla": return "N10"; case "night buster 15 rosa": return "W15"; case "night buster 18 blu": return "S18"; case "night buster 4 azzurra": return "S04"; case "night buster 4 rossa": return "N4"; case "night buster 57 oro": return "N57"; case "night buster 60 argento": return "W60"; case "night buster 68 verde": return "E68"; case "night buster 5 viola": return "S05"; case "1 nichelino": return "1N"; case "1 chieri": return "1C"; case "1 orbassano": return "OB1"; case "2 chieri": return "2C"; case "2 rivalta": return "RV2"; default: // return displayName.trim(); } String[] arr = name.toLowerCase().split("\\s+"); try { if (arr.length == 2 && arr[1].trim().equals("navetta") && Integer.decode(arr[0]) > 0) return arr[0].trim().concat("N"); } catch (NumberFormatException e){ //It's not "# navetta" Log.w("FivetNorm","checking number when it's not"); } if(name.toLowerCase().contains("night buster")){ if(name.toLowerCase().contains("viola")) return "S05"; else if(name.toLowerCase().contains("verde")) return "E68"; } //Everything failed, let's at least compact the the (probable) code return name.replace(" ",""); } /** * Create the line name in GTFS format (e.g., "gtt:10U") from a more human readable name ("10") * @param route the route object * @return the code for the line in GTFS format */ public static String getGtfsRouteID(Route route){ String routeName = route.getName(); String cutName = routeName.replace("\\s", ""); int len = cutName.length(); StringBuilder sb = new StringBuilder("gtt:"); if (cutName.charAt(len-1) == '/'){ sb.append(cutName.substring(0, len-2)); sb.append("B"); //cutName = cutName.substring(0, len-2).concat("B"); } else { sb.append(cutName); } //determine service kind switch (route.type){ case UNKNOWN: case BUS: case TRAM: //tourist lines have "U" in the routeid sb.append("U"); break; case RAILWAY: sb.append("F"); break; case LONG_DISTANCE_BUS: sb.append("E"); } return sb.toString(); } + + public static String filterFullStarName(String name){ + String outName = name; + if(name.contains("STAR ")){ + //FIX FOR THE MaTO data + outName = outName.replace("STAR ","ST"); + } + return outName; + } } diff --git a/app/src/main/java/it/reyboz/bustorino/backend/Palina.java b/app/src/main/java/it/reyboz/bustorino/backend/Palina.java index 7811de6..8b5c807 100644 --- a/app/src/main/java/it/reyboz/bustorino/backend/Palina.java +++ b/app/src/main/java/it/reyboz/bustorino/backend/Palina.java @@ -1,475 +1,533 @@ /* BusTO (backend components) Copyright (C) 2016 Ludovico Pavesi + 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.backend; import android.os.Parcel; import android.os.Parcelable; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.io.Serializable; import java.util.ArrayList; import java.util.Calendar; import java.util.Collections; import java.util.Comparator; import java.util.List; import it.reyboz.bustorino.util.LinesNameSorter; /** * Timetable for multiple routes.
*
* Apparently "palina" and a bunch of other terms can't really be translated into English.
* Not in a way that makes sense and keeps the code readable, at least. */ public class Palina extends Stop implements Parcelable { private ArrayList routes = new ArrayList<>(); private boolean routesModified = false; private Passaggio.Source allSource = null; public Palina(String stopID) { super(stopID); } public Palina(Stop s){ super(s.ID,s.getStopDefaultName(),s.getStopUserName(),s.location,s.type, s.getRoutesThatStopHere(),s.getLatitude(),s.getLongitude(), null); } public Palina(@NonNull String ID, @Nullable String name, @Nullable String userName, @Nullable String location, @Nullable Double lat, @Nullable Double lon, @Nullable String gtfsID) { super(ID, name, userName, location, null, null, lat, lon, gtfsID); } public Palina(@Nullable String name, @NonNull String ID, @Nullable String location, @Nullable Route.Type type, @Nullable List routesThatStopHere) { super(name, ID, location, type, routesThatStopHere); } /** * Adds a timetable entry to a route. * * @param TimeGTT time in GTT format (e.g. "11:22*") * @param arrayIndex position in the array for this route (returned by addRoute) */ public void addPassaggio(String TimeGTT, Passaggio.Source src,int arrayIndex) { this.routes.get(arrayIndex).addPassaggio(TimeGTT,src); routesModified = true; } /** * Count routes with missing directions * @return number */ public int countRoutesWithMissingDirections(){ int i = 0; for (Route r : routes){ if(r.destinazione==null||r.destinazione.equals("")) i++; } return i; } /** * Adds a route to the timetable. * * @param routeID name * @param type bus, underground, railway, ... * @param destinazione end of line\terminus (underground stations have the same ID for both directions) * @return array index for this route */ public int addRoute(String routeID, String destinazione, Route.Type type) { return addRoute(new Route(routeID, destinazione, type, new ArrayList<>(6))); } public int addRoute(Route r){ this.routes.add(r); routesModified = true; buildRoutesString(); return this.routes.size()-1; // last inserted element and pray that direct access to ArrayList elements really is direct } public void setRoutes(List routeList){ routes = new ArrayList<>(routeList); } @Nullable @Override protected String buildRoutesString() { // no routes => no string if(routes == null || routes.size() == 0) { return ""; } /*final StringBuilder sb = new StringBuilder(); final LinesNameSorter nameSorter = new LinesNameSorter(); Collections.sort(routes, (o1, o2) -> nameSorter.compare(o1.getName().trim(), o2.getName().trim())); int i, lenMinusOne = routes.size() - 1; for (i = 0; i < lenMinusOne; i++) { sb.append(routes.get(i).getName().trim()).append(", "); } // last one: sb.append(routes.get(i).getName()); */ ArrayList names = new ArrayList<>(); for (Route r: routes){ names.add(r.getName()); } final String routesThatStopHere = buildRoutesStringFromNames(names); setRoutesThatStopHereString(routesThatStopHere); return routesThatStopHereToString(); } /** * Sort the names of the routes for the string "routes stopping here" and make the string * @param names of the Routes that pass in the stop * @return the full string of routes stopping (eg, "10, 13, 42" ecc) */ public static String buildRoutesStringFromNames(List names){ final StringBuilder sb = new StringBuilder(); final LinesNameSorter nameSorter = new LinesNameSorter(); Collections.sort(names, nameSorter); int i, lenMinusOne = names.size() - 1; for (i = 0; i < lenMinusOne; i++) { sb.append(names.get(i).trim()).append(", "); } //last one sb.append(names.get(i).trim()); return sb.toString(); } protected void checkPassaggi(){ Passaggio.Source mSource = null; for (Route r: routes){ for(Passaggio pass: r.passaggi){ if (mSource == null) { mSource = pass.source; } else if (mSource != pass.source){ Log.w("BusTO-CheckPassaggi", "Cannot determine the source, have got "+mSource +" so far, the next one is "+pass.source ); mSource = Passaggio.Source.UNDETERMINED; break; } } if(mSource == Passaggio.Source.UNDETERMINED) break; } // if the Source is still null, set undetermined if (mSource == null) mSource = Passaggio.Source.UNDETERMINED; //finished with the check, setting flags routesModified = false; allSource = mSource; } @NonNull public Passaggio.Source getPassaggiSourceIfAny(){ if(allSource==null || routesModified){ checkPassaggi(); } assert allSource != null; return allSource; } /** * Gets every route and its timetable. * * @return routes and timetables. */ public List queryAllRoutes() { return this.routes; } public void sortRoutes() { Collections.sort(this.routes); } /** * Add info about the routes already found from another source * @param additionalRoutes ArrayList of routes to get the info from * @return the number of routes modified */ public int addInfoFromRoutes(List additionalRoutes){ if(routes == null || routes.size()==0) { this.routes = new ArrayList<>(additionalRoutes); buildRoutesString(); return routes.size(); } int count=0; final Calendar c = Calendar.getInstance(); final int todaysInt = c.get(Calendar.DAY_OF_WEEK); for(Route r:routes) { int j = 0; boolean correct = false; Route selected = null; //TODO: rewrite this as a simple loop //MADNESS begins here while (!correct) { //find the correct route to merge to // scan routes and find the first which has the same name while (j < additionalRoutes.size() && !r.getName().equals(additionalRoutes.get(j).getName())) { j++; } if (j == additionalRoutes.size()) break; //no match has been found //should have found the first occurrence of the line selected = additionalRoutes.get(j); //move forward j++; if (selected.serviceDays != null && selected.serviceDays.length > 0) { //check if it is in service for (int d : selected.serviceDays) { if (d == todaysInt) { correct = true; break; } } } else if (r.festivo != null) { switch (r.festivo) { case FERIALE: //Domenica = 1 --> Saturday=7 if (todaysInt <= 7 && todaysInt > 1) correct = true; break; case FESTIVO: if (todaysInt == 1) correct = true; //TODO: implement way to recognize all holidays break; case UNKNOWN: correct = true; } } else { //case a: there is no info because the line is always active //case b: there is no info because the information is missing correct = true; } } if (!correct || selected == null) { Log.w("Palina_mergeRoutes","Cannot match the route with name "+r.getName()); continue; //we didn't find any match } //found the correct correspondance //MERGE INFO if(r.mergeRouteWithAnother(selected)) count++; } if (count> 0) buildRoutesString(); return count; } // /** // * Route with terminus (destinazione) and timetables (passaggi), internal implementation. // * // * Contains mostly the same data as the Route public class, but methods are quite different and extending Route doesn't really work, here. // */ // private final class RouteInternal { // public final String name; // public final String destinazione; // private boolean updated; // private List passaggi; // // /** // * Creates a new route and marks it as "updated", since it's new. // * // * @param routeID name // * @param destinazione end of line\terminus // */ // public RouteInternal(String routeID, String destinazione) { // this.name = routeID; // this.destinazione = destinazione; // this.passaggi = new LinkedList<>(); // this.updated = true; // } // // /** // * Adds a time (passaggio) to the timetable for this route // * // * @param TimeGTT time in GTT format (e.g. "11:22*") // */ // public void addPassaggio(String TimeGTT) { // this.passaggi.add(new Passaggio(TimeGTT)); // } // // /** // * Deletes al times (passaggi) from the timetable. // */ // public void deletePassaggio() { // this.passaggi = new LinkedList<>(); // this.updated = true; // } // // /** // * Sets the "updated" flag to false. // * // * @return previous state // */ // public boolean unupdateFlag() { // if(this.updated) { // this.updated = false; // return true; // } else { // return false; // } // } // // /** // * Sets the "updated" flag to true. // * // * @return previous state // */ // public boolean updateFlag() { // if(this.updated) { // return true; // } else { // this.updated = true; // return false; // } // } // // /** // * Exactly what it says on the tin. // * // * @return times from the timetable // */ // public List getPassaggi() { // return this.passaggi; // } // } //remove duplicates public void mergeDuplicateRoutes(int startidx){ //ArrayList routesCopy = new ArrayList<>(routes); //for if(routes.size()<=1|| startidx >= routes.size()) //we have finished return; Route routeCheck = routes.get(startidx); boolean found = false; for(int i=startidx+1; i0) min = Math.min(min,r.numPassaggi()); } if (min == Integer.MAX_VALUE) return 0; else return min; } public ArrayList getRoutesNamesWithNoPassages(){ ArrayList mList = new ArrayList<>(); if(routes==null || routes.size() == 0){ return mList; } for(Route r: routes){ if(r.numPassaggi()==0) mList.add(r.getDisplayCode()); } return mList; } - //private void mergeRoute + + private static String pick(String a, String b) { + return (a != null && !a.isEmpty()) ? a : b; + } + + /** + * Merge two Palinas, including information from both + * @param p1 the first one, which has priority + * @param p2 the second one + * @return the merged Palina data + */ + public static @Nullable Palina mergePaline(@Nullable Palina p1, @Nullable Palina p2) { + if (p1 == null) return p2; + if (p2 == null) return p1; + + // --- Campi base (Stop) --- + String id = p1.ID; // assumiamo stesso ID + + String name = pick(p1.getStopDefaultName(), p2.getStopDefaultName()); + String userName = pick(p1.getStopUserName(), p2.getStopUserName()); + String location = pick(p1.location, p2.location); + + Double lat = p1.getLatitude() != null ? p1.getLatitude() : p2.getLatitude(); + Double lon = p1.getLongitude() != null ? p1.getLongitude() : p2.getLongitude(); + + String gtfsID = pick(p1.gtfsID, p2.gtfsID); + + Palina result = new Palina(id, name, userName, location, lat, lon, gtfsID); + + // --- Routes --- + List mergedRoutes = new ArrayList<>(); + boolean addFromSecond = false; + + if (p1.queryAllRoutes() != null) + mergedRoutes.addAll(p1.routes); + + else if (p2.queryAllRoutes() != null) + mergedRoutes.addAll(p2.routes); + else { + //assume the first one has more important imformation + mergedRoutes.addAll(p1.routes); + addFromSecond = true; + + } + + result.setRoutes(mergedRoutes); + if(addFromSecond){ + result.addInfoFromRoutes(p2.routes); + } + + // Unisci eventuali duplicati (stesso routeID) + result.mergeDuplicateRoutes(0); + + // Aggiorna stringa routes + result.buildRoutesString(); + + return result; + } /// ------- Parcelable stuff --- protected Palina(Parcel in) { super(in); routes = in.createTypedArrayList(Route.CREATOR); routesModified = in.readByte() != 0; allSource = in.readByte() == 0 ? null : Passaggio.Source.valueOf(in.readString()); } @Override public void writeToParcel(Parcel dest, int flags) { super.writeToParcel(dest, flags); dest.writeTypedList(routes); dest.writeByte((byte) (routesModified ? 1 : 0)); if (allSource == null) { dest.writeByte((byte) 0); } else { dest.writeByte((byte) 1); dest.writeString(allSource.name()); } } public static final Creator CREATOR = new Creator() { @Override public Palina createFromParcel(Parcel in) { return new Palina(in); } @Override public Palina[] newArray(int size) { return new Palina[size]; } }; @Override public int describeContents() { return 0; } // Methods using the parcelable public byte[] asByteArray(){ final Parcel p = Parcel.obtain(); writeToParcel(p,0); final byte[] b = p.marshall(); p.recycle(); return b; } public static Palina fromByteArray(byte[] data){ final Parcel p = Parcel.obtain(); p.unmarshall(data, 0, data.length); p.setDataPosition(0); final Palina palina = Palina.CREATOR.createFromParcel(p); p.recycle(); return palina; } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/backend/Route.java b/app/src/main/java/it/reyboz/bustorino/backend/Route.java index 4628d61..8a13092 100644 --- a/app/src/main/java/it/reyboz/bustorino/backend/Route.java +++ b/app/src/main/java/it/reyboz/bustorino/backend/Route.java @@ -1,501 +1,510 @@ /* BusTO (backend components) Copyright (C) 2016 Ludovico Pavesi 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; import android.os.Parcel; import android.os.Parcelable; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.ArrayList; import java.util.Calendar; import java.util.Collections; import java.util.List; public class Route implements Comparable, Parcelable { final static int[] reduced_week = {Calendar.MONDAY,Calendar.TUESDAY,Calendar.WEDNESDAY,Calendar.THURSDAY,Calendar.FRIDAY}; //final static int[] feriali = {Calendar.MONDAY,Calendar.TUESDAY,Calendar.WEDNESDAY,Calendar.THURSDAY,Calendar.FRIDAY,Calendar.SATURDAY}; final static int[] weekend = {Calendar.SUNDAY,Calendar.SATURDAY}; private final static int BRANCHID_MISSING = -1; private final String name; private @Nullable String displayCode = null; public String destinazione; public final List passaggi; //create a copy of the list, so that private List sortedPassaggi; public final Type type; public String description; //ordered list of stops, from beginning to end of line private List stopsList = null; public int branchid = BRANCHID_MISSING; public int[] serviceDays ={}; //0=>feriale, 1=>festivo -2=>unknown public FestiveInfo festivo = FestiveInfo.UNKNOWN; private @Nullable String gtfsId; public enum Type { // "long distance" sono gli extraurbani. BUS(1), LONG_DISTANCE_BUS(2), METRO(3), RAILWAY(4), TRAM(5), UNKNOWN(-2); //TODO: decide to give some special parameter to each field private final int code; Type(int code){ this.code = code; } public int getCode(){ return this.code; } @Nullable public static Type fromCode(int i){ return switch (i) { case 1 -> BUS; case 2 -> LONG_DISTANCE_BUS; case 3 -> METRO; case 4 -> RAILWAY; case 5 -> TRAM; case -2 -> UNKNOWN; default -> null; }; } } public enum FestiveInfo{ FESTIVO(1),FERIALE(0),UNKNOWN(-2); private final int code; FestiveInfo(int code){ this.code = code; } public int getCode() { return code; } public static FestiveInfo fromCode(int i){ return switch (i) { case -2 -> UNKNOWN; case 0 -> FERIALE; case 1 -> FESTIVO; default -> UNKNOWN; }; } } /** * Constructor. * * @param name route ID * @param destinazione terminus\end of line * @param type bus, long distance bus, underground, and so on * @param passaggi timetable, a good choice is an ArrayList of size 6 * @param description the description of the line, usually given by the FiveTAPIFetcher * @see Palina Palina.addRoute() method */ public Route(String name, String destinazione, List passaggi, Type type, String description) { this.name = name; this.destinazione = parseDestinazione(destinazione); this.passaggi = passaggi; this.type = type; this.description = description; } /** * Constructor used in GTTJSONFetcher, see above */ public Route(String name, String destinazione, Type type, List passaggi) { this(name,destinazione,passaggi,type,null); } /** * Constructor used by the FiveTAPIFetcher * @param name stop Name * @param t optional type * @param description line rough description */ public Route(String name,Type t,String description){ this(name,null,new ArrayList<>(),t,description); } /** * Constructor used by the FiveTAPIFetcher * @param name stop Name * @param t optional type * @param description line rough description */ public Route(String name,String destinazione, String description, Type t){ this(name,destinazione,new ArrayList<>(),t,description); } /** * Exactly what it says on the tin. * * @return times from the timetable */ public List getPassaggi() { return this.passaggi; } public void setStopsList(List stopsList) { this.stopsList = Collections.unmodifiableList(stopsList); } public List getStopsList(){ return this.stopsList; } /** * Adds a time (passaggio) to the timetable for this route * * @param TimeGTT time in GTT format (e.g. "11:22*") */ public void addPassaggio(String TimeGTT, Passaggio.Source source) { this.passaggi.add(new Passaggio(TimeGTT, source)); } //Overloaded public void addPassaggio(int hour, int minutes, boolean realtime, Passaggio.Source source) { this.passaggi.add(new Passaggio(hour, minutes, realtime, source)); } public static Route.Type getTypeFromSymbol(String route) { switch (route) { case "M": return Route.Type.METRO; case "T": return Route.Type.RAILWAY; } // default with case "B" return Route.Type.BUS; } private String parseDestinazione(String direzione){ if(direzione==null) return null; //trial to add space to the parenthesis String[] exploded = direzione.split("\\("); if(exploded.length>1){ StringBuilder sb = new StringBuilder(); sb.append(exploded[0]); for(int i=1; i arrivals; int max; if(sort){ if(sortedPassaggi==null){ sortedPassaggi = new ArrayList<>(passaggi.size()); sortedPassaggi.addAll(passaggi); Collections.sort(sortedPassaggi); } arrivals = sortedPassaggi; } else arrivals = passaggi; max = Math.min(start_idx + number, arrivals.size()); for(int j= start_idx; j0){ this.passaggi.addAll(other.passaggi); } if(this.destinazione == null && other.destinazione!=null) { this.destinazione = other.destinazione; adjusted = true; } if(!this.isBranchIdValid() && other.isBranchIdValid()) { this.branchid = other.branchid; adjusted = true; } if(this.festivo == Route.FestiveInfo.UNKNOWN && other.festivo!= Route.FestiveInfo.UNKNOWN){ this.festivo = other.festivo; adjusted = true; } if(other.description!=null && (this.description==null || (this.festivo == FestiveInfo.FERIALE && this.description.contains("festivo")) || (this.festivo == FestiveInfo.FESTIVO && this.description.contains("feriale")) ) ) { this.description = other.description; } return adjusted; } + public String getRouteLongDisplayName() { + + String routeName = FiveTNormalizer.routeInternalToDisplay(this.name); + if (routeName == null) { + routeName = this.displayCode; + } + return routeName; + } + // ---- Parcelable implem --- protected Route(Parcel in) { name = in.readString(); displayCode = in.readByte() == 0 ? null : in.readString(); destinazione = in.readString(); passaggi = in.createTypedArrayList(Passaggio.CREATOR); type = Type.valueOf(in.readString()); description = in.readString(); if (in.readByte() == 0) { stopsList = null; } else { stopsList = in.createStringArrayList(); } branchid = in.readInt(); serviceDays = in.createIntArray(); festivo = FestiveInfo.valueOf(in.readString()); gtfsId = in.readByte() == 0 ? null : in.readString(); } @Override public void writeToParcel(Parcel dest, int flags) { dest.writeString(name); if (displayCode == null) { dest.writeByte((byte) 0); } else { dest.writeByte((byte) 1); dest.writeString(displayCode); } dest.writeString(destinazione); dest.writeTypedList(passaggi); dest.writeString(type.name()); dest.writeString(description); if (stopsList == null) { dest.writeByte((byte) 0); } else { dest.writeByte((byte) 1); dest.writeStringList(stopsList); } dest.writeInt(branchid); dest.writeIntArray(serviceDays); dest.writeString(festivo.name()); if (gtfsId == null) { dest.writeByte((byte) 0); } else { dest.writeByte((byte) 1); dest.writeString(gtfsId); } } @Override public int describeContents() { return 0; } public static final Parcelable.Creator CREATOR = new Creator<>() { @Override public Route createFromParcel(Parcel in) { return new Route(in); } @Override public Route[] newArray(int size) { return new Route[size]; } }; } diff --git a/app/src/main/java/it/reyboz/bustorino/backend/Stop.java b/app/src/main/java/it/reyboz/bustorino/backend/Stop.java index 2ae77a7..0e3a181 100644 --- a/app/src/main/java/it/reyboz/bustorino/backend/Stop.java +++ b/app/src/main/java/it/reyboz/bustorino/backend/Stop.java @@ -1,429 +1,434 @@ /* BusTO (backend components) Copyright (C) 2016 Ludovico Pavesi 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; import android.location.Location; import android.os.Bundle; import android.os.Parcel; import android.os.Parcelable; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import it.reyboz.bustorino.util.LinesNameSorter; import org.jetbrains.annotations.NotNull; import java.net.URLEncoder; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; public class Stop implements Comparable, Parcelable { // remove "final" in case you need to set these from outside the parser\scrapers\fetchers public final @NonNull String ID; private @Nullable String name; private @Nullable String username; public @Nullable String location; public @Nullable Route.Type type; private @Nullable List routesThatStopHere; private final @Nullable Double lat; private final @Nullable Double lon; // leave this non-final private @Nullable String routesThatStopHereString = null; private @Nullable String absurdGTTPlaceName = null; // public @Nullable String gtfsID = null; /** * Hey, look, method overloading! */ public Stop(final @Nullable String name, final @NonNull String ID, @Nullable final String location, @Nullable final Route.Type type, @Nullable final List routesThatStopHere) { this.ID = ID; this.name = name; this.username = null; this.location = (location != null && location.length() == 0) ? null : location; this.type = type; this.routesThatStopHere = routesThatStopHere; this.lat = null; this.lon = null; } /** * Hey, look, method overloading! */ public Stop(final @NonNull String ID) { this.ID = ID; this.name = null; this.username = null; this.location = null; this.type = null; this.routesThatStopHere = null; this.lat = null; this.lon = null; } /** * Constructor that sets EVERYTHING. */ public Stop(@NonNull String ID, @Nullable String name, @Nullable String userName, @Nullable String location, @Nullable Route.Type type, @Nullable List routesThatStopHere, @Nullable Double lat, @Nullable Double lon, @Nullable String gtfsID) { this.ID = ID; this.name = name; this.username = userName; this.location = location; this.type = type; this.routesThatStopHere = routesThatStopHere; this.lat = lat; this.lon = lon; this.gtfsID = gtfsID; } public @Nullable String routesThatStopHereToString() { // M E M O I Z A T I O N if(this.routesThatStopHereString != null) { return this.routesThatStopHereString; } // no string yet? build it! return buildRoutesString(); } @Nullable public String getAbsurdGTTPlaceName() { return absurdGTTPlaceName; } public void setAbsurdGTTPlaceName(@NonNull String absurdGTTPlaceName) { this.absurdGTTPlaceName = absurdGTTPlaceName; } public void setRoutesThatStopHere(@Nullable List routesThatStopHere) { this.routesThatStopHere = routesThatStopHere; } protected void setRoutesThatStopHereString(String routesStopping){ this.routesThatStopHereString = routesStopping; } public int getNumRoutesStopping(){ if(this.routesThatStopHere == null) { return 0; } else { return this.routesThatStopHere.size(); } } @Nullable protected List getRoutesThatStopHere(){ return routesThatStopHere; } protected @Nullable String buildRoutesString() { // no routes => no string if(this.routesThatStopHere == null || this.routesThatStopHere.size() == 0) { return null; } StringBuilder sb = new StringBuilder(); Collections.sort(routesThatStopHere,new LinesNameSorter()); int i, lenMinusOne = routesThatStopHere.size() - 1; for (i = 0; i < lenMinusOne; i++) { sb.append(routesThatStopHere.get(i)).append(", "); } // last one: sb.append(routesThatStopHere.get(i)); this.routesThatStopHereString = sb.toString(); return this.routesThatStopHereString; } @Override public int compareTo(@NonNull Stop other) { int res; int thisAsInt = networkTools.failsafeParseInt(this.ID); int otherAsInt = networkTools.failsafeParseInt(other.ID); // numeric stop IDs if(thisAsInt != 0 && otherAsInt != 0) { return thisAsInt - otherAsInt; } else { // non-numeric res = this.ID.compareTo(other.ID); if (res != 0) { return res; } } // try with name, then if(this.name != null && other.name != null) { res = this.name.compareTo(other.name); } // and give up return res; } /** * Sets a name. * * @param name stop name as string (not null) */ public final void setStopName(@NonNull String name) { this.name = name; } /** * Sets user name. Empty string is converted to null. * * @param name a string of non-zero length, or null */ public final void setStopUserName(@Nullable String name) { if(name == null) { this.username = null; } else if(name.length() == 0) { this.username = null; } else { this.username = name; } } /** * Returns stop name or username (if set).
- * - empty string means "already searched everywhere, can't find it"
- * - null means "didn't search, yet. Maybe you should try."
- * - string means "here's the name.", obviously.
+ * If null, we should try to look somewhere else. * * @return string if known, null if still unknown */ public final @Nullable String getStopDisplayName() { if(this.username == null) { return this.name; } else { return this.username; } } /** * Same as getStopDisplayName, only returns default name.
* I'd use an @see tag, but Android Studio is incapable of understanding that getStopDefaultName * refers to the method exactly above this one and not some arcane and esoteric unknown symbol. */ public final @Nullable String getStopDefaultName() { return this.name; } /** * Same as getStopDisplayName, only returns user name.
* Also, never an empty string. */ public final @Nullable String getStopUserName() { return this.username; } /** * Gets username and name from other stop if they exist, sets itself accordingly. * * @param other another Stop * @return did we actually set/change anything? */ public final boolean mergeNameFrom(Stop other) { boolean ret = false; if(other.name != null) { if(this.name == null || !this.name.equals(other.name)) { this.name = other.name; ret = true; } } if(other.username != null) { if(this.username == null || !this.username.equals(other.username)) { this.username = other.username; ret = true; } } return ret; } public final @Nullable String getGeoURL() { if(this.lat == null || this.lon == null) { return null; } // Android documentation suggests US for machine readable output (use dot as decimal separator) return String.format(Locale.US, "geo:%f,%f", this.lat, this.lon); } + /** + * Check if the stop contains the coordinates + * @return true if both the latitude and the longitude are not null + */ + public final boolean hasCoords(){ + return (this.lat!=null)&&(this.lon!=null); + } public final @Nullable String getGeoURLWithAddress() { String url = getGeoURL(); if(url == null) { return null; } if(this.location != null) { try { String addThis = "?q=".concat(URLEncoder.encode(this.location, "utf-8")); return url.concat(addThis); } catch (Exception ignored) {} } return url; } @Override public String toString(){ return "id:"+ID+" { name: "+this.name+", lines: "+this.routesThatStopHereToString()+"}"; } @Nullable public Double getLatitude() { return lat; } @Nullable public Double getLongitude() { return lon; } public Double getDistanceFromLocation(@NotNull GPSPoint loc){ return getDistanceFromLocation(loc.getLatitude(), loc.getLongitude()); } public Double getDistanceFromLocation(double latitude, double longitude){ if(this.lat!=null && this.lon !=null) return utils.measuredistanceBetween(this.lat,this.lon,latitude, longitude); else return Double.POSITIVE_INFINITY; } public Bundle toBundle(Bundle bundle) { //Bundle bundle = new Bundle(); if(bundle==null) return null; bundle.putString("ID", ID); bundle.putString("name", name); bundle.putString("username", username); bundle.putString("location", location); bundle.putString("type", (type != null) ? type.name() : null); bundle.putStringArrayList("routesThatStopHere", (routesThatStopHere != null) ? new ArrayList<>(routesThatStopHere) : null); if (lat != null) bundle.putDouble("lat", lat); if (lon != null) bundle.putDouble("lon", lon); if (gtfsID !=null) bundle.putString("gtfsID", gtfsID); return bundle; } public Bundle toBundle(){ return toBundle(new Bundle()); } @Nullable public static Stop fromBundle(Bundle bundle) { String ID = bundle.getString("ID"); if (ID == null) return null; //throw new IllegalArgumentException("ID cannot be null"); String name = bundle.getString("name"); String username = bundle.getString("username"); String location = bundle.getString("location"); String typeStr = bundle.getString("type"); Route.Type type = (typeStr != null) ? Route.Type.valueOf(typeStr) : null; List routesThatStopHere = bundle.getStringArrayList("routesThatStopHere"); Double lat = bundle.containsKey("lat") ? bundle.getDouble("lat") : null; Double lon = bundle.containsKey("lon") ? bundle.getDouble("lon") : null; String gtfsId = bundle.getString("gtfsID"); return new Stop(ID, name, username, location, type, routesThatStopHere, lat, lon, gtfsId); } /// ----- Parcelable implementation ---- protected Stop(Parcel in) { ID = in.readString(); name = in.readByte() == 0 ? null : in.readString(); username = in.readByte() == 0 ? null : in.readString(); location = in.readByte() == 0 ? null : in.readString(); type = in.readByte() == 0 ? null : Route.Type.valueOf(in.readString()); routesThatStopHere = in.readByte() == 0 ? null : in.createStringArrayList(); lat = in.readByte() == 0 ? null : in.readDouble(); lon = in.readByte() == 0 ? null : in.readDouble(); routesThatStopHereString = in.readByte() == 0 ? null : in.readString(); absurdGTTPlaceName = in.readByte() == 0 ? null : in.readString(); gtfsID = in.readByte() == 0 ? null : in.readString(); } @Override public void writeToParcel(Parcel dest, int flags) { dest.writeString(ID); dest.writeByte((byte) (name == null ? 0 : 1)); if (name != null) dest.writeString(name); dest.writeByte((byte) (username == null ? 0 : 1)); if (username != null) dest.writeString(username); dest.writeByte((byte) (location == null ? 0 : 1)); if (location != null) dest.writeString(location); dest.writeByte((byte) (type == null ? 0 : 1)); if (type != null) dest.writeString(type.name()); dest.writeByte((byte) (routesThatStopHere == null ? 0 : 1)); if (routesThatStopHere != null) dest.writeStringList(routesThatStopHere); dest.writeByte((byte) (lat == null ? 0 : 1)); if (lat != null) dest.writeDouble(lat); dest.writeByte((byte) (lon == null ? 0 : 1)); if (lon != null) dest.writeDouble(lon); dest.writeByte((byte) (routesThatStopHereString == null ? 0 : 1)); if (routesThatStopHereString != null) dest.writeString(routesThatStopHereString); dest.writeByte((byte) (absurdGTTPlaceName == null ? 0 : 1)); if (absurdGTTPlaceName != null) dest.writeString(absurdGTTPlaceName); dest.writeByte((byte) (gtfsID == null ? 0 : 1)); if (gtfsID != null) dest.writeString(gtfsID); } @Override public int describeContents() { return 0; } public static final Creator CREATOR = new Creator<>() { @Override public Stop createFromParcel(Parcel in) { return new Stop(in); } @Override public Stop[] newArray(int size) { return new Stop[size]; } }; } 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 2a48571..4dbcb15 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,72 +1,82 @@ /* 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 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/backend/mato/MatoAPIFetcher.kt b/app/src/main/java/it/reyboz/bustorino/backend/mato/MatoAPIFetcher.kt index 29cbb6f..3dc8fd9 100644 --- a/app/src/main/java/it/reyboz/bustorino/backend/mato/MatoAPIFetcher.kt +++ b/app/src/main/java/it/reyboz/bustorino/backend/mato/MatoAPIFetcher.kt @@ -1,419 +1,419 @@ /* BusTO - Backend components Copyright (C) 2021 Fabio Mazza This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package it.reyboz.bustorino.backend.mato import android.content.Context import android.util.Log import com.android.volley.DefaultRetryPolicy import com.android.volley.toolbox.RequestFuture import it.reyboz.bustorino.BuildConfig import it.reyboz.bustorino.backend.* import it.reyboz.bustorino.data.gtfs.GtfsAgency import it.reyboz.bustorino.data.gtfs.GtfsFeed import it.reyboz.bustorino.data.gtfs.GtfsRoute import it.reyboz.bustorino.data.gtfs.MatoPattern import org.json.JSONArray import org.json.JSONException import org.json.JSONObject import java.util.* import java.util.concurrent.ExecutionException import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException import java.util.concurrent.atomic.AtomicReference import kotlin.collections.ArrayList open class MatoAPIFetcher( private val minNumPassaggi: Int ) : ArrivalsFetcher { var appContext: Context? = null set(value) { field = value!!.applicationContext } constructor(): this(DEF_MIN_NUMPASSAGGI) override fun ReadArrivalTimesAll(stopID: String?, res: AtomicReference?): Palina { stopID!! val now = Calendar.getInstance().time var numMinutes = 30 var palina = Palina(stopID) var numPassaggi = 0 var trials = 0 val numDepartures = 8 while (numPassaggi < minNumPassaggi && trials < 2) { //numDepartures+=2 numMinutes += 20 val future = RequestFuture.newFuture() val request = MapiArrivalRequest(stopID, now, numMinutes * 60, numDepartures, res, future, future) if (appContext == null || res == null) { Log.e("BusTO:MatoAPIFetcher", "ERROR: Given null context or null result ref") return Palina(stopID) } val requestQueue = NetworkVolleyManager.getInstance(appContext).requestQueue request.setTag(getVolleyReqTag(MatoQueries.QueryType.ARRIVALS)) requestQueue.add(request) try { val palinaResult = future.get(5, TimeUnit.SECONDS) if (palinaResult!=null) { /*if (BuildConfig.DEBUG) for (r in palinaResult.queryAllRoutes()){ Log.d(DEBUG_TAG, "route " + r.gtfsId + " has " + r.passaggi.size + " passaggi: "+ r.passaggiToString) }*/ palina = palinaResult numPassaggi = palina.minNumberOfPassages } else{ Log.d(DEBUG_TAG, "Result palina is null") } } catch (e: InterruptedException) { e.printStackTrace() res.set(Fetcher.Result.PARSER_ERROR) } catch (e: ExecutionException) { e.printStackTrace() if (res.get() == Fetcher.Result.OK) res.set(Fetcher.Result.SERVER_ERROR) } catch (e: TimeoutException) { res.set(Fetcher.Result.CONNECTION_ERROR) e.printStackTrace() } trials++ } return palina } override fun getSourceForFetcher(): Passaggio.Source { return Passaggio.Source.MatoAPI } companion object{ const val VOLLEY_TAG = "MatoAPIFetcher" const val DEBUG_TAG = "BusTO:MatoAPIFetcher" const val DEF_MIN_NUMPASSAGGI=2 val REQ_PARAMETERS = mapOf( "Content-Type" to "application/json; charset=utf-8", "DNT" to "1", "Host" to "mapi.5t.torino.it") private val longRetryPolicy = DefaultRetryPolicy(10000,5,DefaultRetryPolicy.DEFAULT_BACKOFF_MULT) fun getVolleyReqTag(type: MatoQueries.QueryType): String{ return when (type){ MatoQueries.QueryType.ALL_STOPS -> VOLLEY_TAG +"_AllStops" MatoQueries.QueryType.ARRIVALS -> VOLLEY_TAG+"_Arrivals" MatoQueries.QueryType.FEEDS -> VOLLEY_TAG +"_Feeds" MatoQueries.QueryType.ROUTES -> VOLLEY_TAG +"_AllRoutes" MatoQueries.QueryType.PATTERNS_FOR_ROUTES -> VOLLEY_TAG + "_PatternsForRoute" MatoQueries.QueryType.TRIP -> VOLLEY_TAG+"_Trip" } } /** * Get stops from the MatoAPI, set [res] accordingly */ fun getAllStopsGTT(context: Context, res: AtomicReference?): List{ val requestQueue = NetworkVolleyManager.getInstance(context).requestQueue val future = RequestFuture.newFuture>() val request = VolleyAllStopsRequest(future, future) request.tag = getVolleyReqTag(MatoQueries.QueryType.ALL_STOPS) request.retryPolicy = longRetryPolicy requestQueue.add(request) var palinaList:List = mutableListOf() try { palinaList = future.get(120, TimeUnit.SECONDS) res?.set(Fetcher.Result.OK) }catch (e: InterruptedException) { e.printStackTrace() res?.set(Fetcher.Result.PARSER_ERROR) } catch (e: ExecutionException) { e.printStackTrace() res?.set(Fetcher.Result.SERVER_ERROR) } catch (e: TimeoutException) { res?.set(Fetcher.Result.CONNECTION_ERROR) e.printStackTrace() } return palinaList } /* fun makeRequest(type: QueryType?, variables: JSONObject) : String{ type.let { val requestData = JSONObject() when (it){ QueryType.ARRIVALS ->{ requestData.put("operationName","AllStopsDirect") requestData.put("variables", variables) requestData.put("query", MatoQueries.QUERY_ARRIVALS) } else -> { //TODO all other cases } } //todo make the request... //https://pablobaxter.github.io/volley-docs/com/android/volley/toolbox/RequestFuture.html //https://stackoverflow.com/questions/16904741/can-i-do-a-synchronous-request-with-volley } return "" } */ fun parseStopJSON(jsonStop: JSONObject): Palina{ val latitude = jsonStop.getDouble("lat") val longitude = jsonStop.getDouble("lon") val palina = Palina( jsonStop.getString("code"), jsonStop.getString("name"), null, null, latitude, longitude, jsonStop.getString("gtfsId") ) val routesStoppingJSON = jsonStop.getJSONArray("routes") val baseRoutes = mutableListOf() // get all the possible routes for (i in 0 until routesStoppingJSON.length()){ val routeBaseInfo = routesStoppingJSON.getJSONObject(i) val r = Route(routeBaseInfo.getString("shortName"), Route.Type.UNKNOWN,"") - r.setGtfsId(routeBaseInfo.getString("gtfsId").trim()) + r.gtfsId = routeBaseInfo.getString("gtfsId").trim() baseRoutes.add(r) } if (jsonStop.has("desc")){ palina.location = jsonStop.getString("desc") } //there is also "zoneId" which is the zone of the stop (0-> city, etc) if(jsonStop.has("stoptimesForPatterns")) { val routesStopTimes = jsonStop.getJSONArray("stoptimesForPatterns") for (i in 0 until routesStopTimes.length()) { val patternJSON = routesStopTimes.getJSONObject(i) val mRoute = parseRouteStoptimesJSON(patternJSON) //Log.d("BusTO-MapiFetcher") //val directionId = patternJSON.getJSONObject("pattern").getInt("directionId") //TODO: use directionId palina.addRoute(mRoute) for (r in baseRoutes) { if (mRoute.gtfsId != null && r.gtfsId.equals(mRoute.gtfsId)) { baseRoutes.remove(r) break } } } } for (noArrivalRoute in baseRoutes){ palina.addRoute(noArrivalRoute) } //val gtfsRoutes = mutableListOf<>() return palina } - fun parseRouteStoptimesJSON(jsonPatternWithStops: JSONObject): Route{ + private fun parseRouteStoptimesJSON(jsonPatternWithStops: JSONObject): Route{ val patternJSON = jsonPatternWithStops.getJSONObject("pattern") val routeJSON = patternJSON.getJSONObject("route") val passaggiJSON = jsonPatternWithStops.getJSONArray("stoptimes") val gtfsId = routeJSON.getString("gtfsId").trim() val passages = mutableListOf() for( i in 0 until passaggiJSON.length()){ val stoptime = passaggiJSON.getJSONObject(i) val scheduledTime = stoptime.getInt("scheduledArrival") val realtimeTime = stoptime.getInt("realtimeArrival") val realtime = stoptime.getBoolean("realtime") passages.add( Passaggio(realtimeTime,realtime, realtimeTime-scheduledTime, Passaggio.Source.MatoAPI) ) } var routeType = Route.Type.UNKNOWN if (gtfsId[gtfsId.length-1] == 'E') routeType = Route.Type.LONG_DISTANCE_BUS else when( routeJSON.getString("mode").trim()){ "BUS" -> routeType = Route.Type.BUS "TRAM" -> routeType = Route.Type.TRAM } val route = Route( - routeJSON.getString("shortName"), + FiveTNormalizer.filterFullStarName(routeJSON.getString("shortName")), patternJSON.getString("headsign"), routeType, passages, ) route.setGtfsId(gtfsId) return route } fun makeRequestParameters(requestName:String, variables: JSONObject, query: String): JSONObject{ val data = JSONObject() data.put("operationName", requestName) data.put("variables", variables) data.put("query", query) return data } fun getFeedsAndAgencies(context: Context, res: AtomicReference?): Pair, ArrayList> { val requestQueue = NetworkVolleyManager.getInstance(context).requestQueue val future = RequestFuture.newFuture() val request = MatoVolleyJSONRequest(MatoQueries.QueryType.FEEDS, JSONObject(), future, future) request.setRetryPolicy(longRetryPolicy) request.tag = getVolleyReqTag(MatoQueries.QueryType.FEEDS) requestQueue.add(request) val feeds = ArrayList() val agencies = ArrayList() var outObj = "" try { val resObj = future.get(120,TimeUnit.SECONDS) outObj = resObj.toString(1) val feedsJSON = resObj.getJSONArray("feeds") for (i in 0 until feedsJSON.length()){ val resTup = ResponseParsing.parseFeedJSON(feedsJSON.getJSONObject(i)) feeds.add(resTup.first) agencies.addAll(resTup.second) } } catch (e: InterruptedException) { e.printStackTrace() res?.set(Fetcher.Result.PARSER_ERROR) } catch (e: ExecutionException) { e.printStackTrace() res?.set(Fetcher.Result.SERVER_ERROR) } catch (e: TimeoutException) { res?.set(Fetcher.Result.CONNECTION_ERROR) e.printStackTrace() } catch (e: JSONException){ e.printStackTrace() res?.set(Fetcher.Result.PARSER_ERROR) Log.e(DEBUG_TAG, "Downloading feeds: $outObj") } return Pair(feeds,agencies) } fun getRoutes(context: Context, res: AtomicReference?): ArrayList{ val requestQueue = NetworkVolleyManager.getInstance(context).requestQueue val future = RequestFuture.newFuture() val params = JSONObject() params.put("feeds","gtt") val request = MatoVolleyJSONRequest(MatoQueries.QueryType.ROUTES, params, future, future) request.tag = getVolleyReqTag(MatoQueries.QueryType.ROUTES) request.retryPolicy = longRetryPolicy requestQueue.add(request) val routes = ArrayList() var outObj = "" try { val resObj = future.get(120,TimeUnit.SECONDS) outObj = resObj.toString(1) val routesJSON = resObj.getJSONArray("routes") for (i in 0 until routesJSON.length()){ val route = ResponseParsing.parseRouteJSON(routesJSON.getJSONObject(i)) routes.add(route) } } catch (e: InterruptedException) { e.printStackTrace() res?.set(Fetcher.Result.PARSER_ERROR) } catch (e: ExecutionException) { e.printStackTrace() res?.set(Fetcher.Result.SERVER_ERROR) } catch (e: TimeoutException) { res?.set(Fetcher.Result.CONNECTION_ERROR) e.printStackTrace() } catch (e: JSONException){ e.printStackTrace() res?.set(Fetcher.Result.PARSER_ERROR) Log.e(DEBUG_TAG, "Downloading feeds: $outObj") } return routes } fun getPatternsWithStops(context: Context, routesGTFSIds: MutableCollection, res: AtomicReference?): ArrayList{ val requestQueue = NetworkVolleyManager.getInstance(context).requestQueue val future = RequestFuture.newFuture() val params = JSONObject() for (r in routesGTFSIds){ if(r.isEmpty()) routesGTFSIds.remove(r) } val routes = JSONArray(routesGTFSIds) params.put("routes",routes) val request = MatoVolleyJSONRequest(MatoQueries.QueryType.PATTERNS_FOR_ROUTES, params, future, future) request.retryPolicy = longRetryPolicy request.tag = getVolleyReqTag(MatoQueries.QueryType.PATTERNS_FOR_ROUTES) requestQueue.add(request) val patterns = ArrayList() var resObj = JSONObject() try { resObj = future.get(60,TimeUnit.SECONDS) //outObj = resObj.toString(1) val routesJSON = resObj.getJSONArray("routes") for (i in 0 until routesJSON.length()){ val patternList = ResponseParsing.parseRoutePatternsStopsJSON(routesJSON.getJSONObject(i)) patterns.addAll(patternList) } } catch (e: InterruptedException) { e.printStackTrace() res?.set(Fetcher.Result.PARSER_ERROR) } catch (e: ExecutionException) { e.printStackTrace() res?.set(Fetcher.Result.SERVER_ERROR) } catch (e: TimeoutException) { res?.set(Fetcher.Result.CONNECTION_ERROR) e.printStackTrace() } catch (e: JSONException){ e.printStackTrace() res?.set(Fetcher.Result.PARSER_ERROR) Log.e(DEBUG_TAG, "Got result: $resObj") } return patterns } } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsRoute.kt b/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsRoute.kt index e68dcb9..0675c19 100644 --- a/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsRoute.kt +++ b/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsRoute.kt @@ -1,83 +1,88 @@ /* BusTO - Data components Copyright (C) 2021 Fabio Mazza This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package it.reyboz.bustorino.data.gtfs import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey +import it.reyboz.bustorino.backend.FiveTNormalizer @Entity(tableName=GtfsRoute.DB_TABLE) data class GtfsRoute( @PrimaryKey @ColumnInfo(name = COL_ROUTE_ID) val gtfsId: String, @ColumnInfo(name = COL_AGENCY_ID) val agencyID: String, @ColumnInfo(name = "route_short_name") val shortName: String, @ColumnInfo(name = "route_long_name") val longName: String, @ColumnInfo(name = "route_desc") val description: String, @ColumnInfo(name = COL_MODE) val mode: GtfsMode, //@ColumnInfo(name ="route_url") //val url: String, @ColumnInfo(name = COL_COLOR) val color: String, @ColumnInfo(name = COL_TEXT_COLOR) val textColor: String, ): GtfsTable { constructor(valuesByColumn: Map) : this( valuesByColumn[COL_ROUTE_ID]!!, valuesByColumn["agency_id"]!!, valuesByColumn["route_short_name"]!!, valuesByColumn["route_long_name"]!!, valuesByColumn["route_desc"]!!, valuesByColumn["route_type"]?.toInt()?.let { GtfsMode.getByValue(it) }!!, valuesByColumn[COL_COLOR]!!, valuesByColumn[COL_TEXT_COLOR]!!, ) companion object { const val DB_TABLE: String="routes_table" const val COL_SORT_ORDER: String="route_sort_order" const val COL_AGENCY_ID = "agency_id" const val COL_ROUTE_ID = "route_id" const val COL_MODE ="route_mode" const val COL_COLOR="route_color" const val COL_TEXT_COLOR="route_text_color" val COLUMNS = arrayOf(COL_ROUTE_ID, COL_AGENCY_ID, "route_short_name", "route_long_name", "route_desc", "route_type", "route_color", "route_text_color", COL_SORT_ORDER ) //const val CREATE_SQL = "" } override fun getColumns(): Array { return COLUMNS } + + fun getShortNameDisplay(): String { + return FiveTNormalizer.filterFullStarName(shortName) + } } 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 a6ef86e..29919c1 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/ArrivalsFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/ArrivalsFragment.kt @@ -1,792 +1,805 @@ /* BusTO - Fragments components Copyright (C) 2018 Fabio Mazza This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package it.reyboz.bustorino.fragments import android.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.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.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.AsyncStopFavoriteAction import it.reyboz.bustorino.util.LinesNameSorter import it.reyboz.bustorino.viewmodels.ArrivalsViewModel 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 private var stopIsInFavorites = false //Views protected lateinit var addToFavorites: ImageButton + protected lateinit var openInMapButton: ImageButton protected lateinit var timesSourceTextView: TextView - protected lateinit var messageTextView: 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 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 var reloadOnResume = true fun getStopID() = stopID private val palinaClickListener: PalinaClickListener = object : PalinaClickListener { override fun showRouteFullDirection(route: Route) { - var routeName: String? + var routeName = route.routeLongDisplayName Log.d(DEBUG_TAG, "Make toast for line " + route.name) - - - routeName = FiveTNormalizer.routeInternalToDisplay(route.name) - if (routeName == null) { - routeName = route.displayCode - } 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) { 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 //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) 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) timesSourceTextView = root.findViewById(R.id.timesSourceTextView) timesSourceTextView.setOnLongClickListener { view: View? -> if (!fetchersChangeRequestPending) { rotateFetchers() //Show we are changing provider timesSourceTextView.setText(R.string.arrival_source_changing) //mListener.requestArrivalsForStopID(stopID) requestArrivalsForTheFragment() fetchersChangeRequestPending = true return@setOnLongClickListener true } false } timesSourceTextView.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 toggleLastStopToFavorites() }) val displayName = requireArguments().getString(STOP_TITLE) if (displayName != null) setTextViewMessage( String.format( - getString(R.string.passages), displayName + 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(layoutManager) 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 lastUpdatedPalina?.let { showArrivalsSources(it) } /*if (lastUpdatedPalina?.queryAllRoutes() != null && lastUpdatedPalina!!.queryAllRoutes()!!.size >0){ showArrivalsSources(lastUpdatedPalina!!) } else{ Log.d(DEBUG_TAG, "No routes names") } */ arrivalsViewModel.palinaLiveData.observe(viewLifecycleOwner){ mListener.toggleSpinner(false) + Log.d(DEBUG_TAG, "New result palina observed, has coords: ${it.hasCoords()}") if(arrivalsViewModel.resultLiveData.value==Fetcher.Result.OK){ //the result is true changeUIFirstSearchActive(false) updateFragmentData(it) } else{ 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) } } 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 -> when (res) { Fetcher.Result.OK -> {} Fetcher.Result.CLIENT_OFFLINE -> showToastMessage(R.string.network_error, true) Fetcher.Result.SERVER_ERROR -> { if (utils.isConnected(context)) { showToastMessage(R.string.parsing_error, true) } else { showToastMessage(R.string.network_error, true) } showToastMessage(R.string.internal_error,true) } Fetcher.Result.PARSER_ERROR -> showShortToast(R.string.internal_error) Fetcher.Result.QUERY_TOO_SHORT -> showShortToast(R.string.query_too_short) Fetcher.Result.EMPTY_RESULT_SET -> showShortToast(R.string.no_arrivals_stop) Fetcher.Result.NOT_FOUND -> showShortToast(R.string.no_bus_stop_have_this_name) else -> showShortToast(R.string.internal_error) } } return root } private fun showShortToast(id: Int) = showToastMessage(id,true) 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 } } override fun onResume() { super.onResume() val loaderManager = loaderManager Log.d(DEBUG_TAG, "OnResume, justCreated $justCreated, lastUpdatedPalina is: $lastUpdatedPalina") /*if(needUpdateOnAttach){ updateFragmentData(null); needUpdateOnAttach=false; }*/ /*if(lastUpdatedPalina!=null){ updateFragmentData(null); showArrivalsSources(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() changeUIFirstSearchActive(true) 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 } } 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) } /*val currentFetchersAsArray: Array get() { val arr = arrayOfNulls(fetchers!!.size) fetchers!!.toArray(arr) return arr } */ 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 (!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) showArrivalsSources(lastUpdatedPalina!!) resetListAdapter(adapter) + lastUpdatedPalina?.let{ pal -> + openInMapButton.setOnClickListener { + if (pal.hasCoords()) + mListener.showMapCenteredOnStop(pal) + } + } + val routesWithNoPassages = lastUpdatedPalina!!.routesNamesWithNoPassages if (routesWithNoPassages.isEmpty()) { //hide the views if there are no empty routes - noArrivalsRecyclerView!!.visibility = View.GONE + noArrivalsRecyclerView.visibility = View.GONE noArrivalsTitleView!!.visibility = View.GONE } else { Collections.sort(routesWithNoPassages, LinesNameSorter()) noArrivalsAdapter = RouteOnlyLineAdapter(routesWithNoPassages, null) - noArrivalsRecyclerView!!.adapter = noArrivalsAdapter + noArrivalsRecyclerView.adapter = noArrivalsAdapter - noArrivalsRecyclerView!!.visibility = View.VISIBLE + 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) timesSourceTextView.text = base_message timesSourceTextView.visibility = View.VISIBLE if (p.totalNumberOfPassages > 0) { timesSourceTextView.visibility = View.VISIBLE } else { timesSourceTextView.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 message in the fragment - * - * It may eventually change the "Add to Favorite" icon + * 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, message)) + //setTextViewMessage(getString(R.string.passages_fill, message)) + setTextViewMessage(message) } + } - // whatever is the case, update the star icon - //updateStarIconFromLastBusStop(); + /** + * 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.getFavoritesColumnNamesAsArray, 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.getFavoritesColumnNamesAsArray[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 ) } + 6 + */ } - + /* 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 } - /** - * 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 - } - fun toggleLastStopToFavorites() { val stop: Stop? = lastUpdatedPalina if (stop != null) { // toggle the status in background AsyncStopFavoriteAction( requireContext().applicationContext, AsyncStopFavoriteAction.Action.TOGGLE ) { v: Boolean -> updateStarIconFromLastBusStop(v) }.execute(stop) } else { // this case have no sense, but just immediately update the favorite icon updateStarIconFromLastBusStop(true) } } /** * Update the star "Add to favorite" icon */ fun updateStarIconFromLastBusStop(toggleDone: Boolean) { stopIsInFavorites = if (stopIsInFavorites) !toggleDone else toggleDone updateStarIcon() // check if there is a last Stop /* if (stopID == null) { addToFavorites.setVisibility(View.INVISIBLE); } else { // filled or outline? if (isStopInFavorites(stopID)) { addToFavorites.setImageResource(R.drawable.ic_star_filled); } else { addToFavorites.setImageResource(R.drawable.ic_star_outline); } addToFavorites.setVisibility(View.VISIBLE); } */ } /** * Update the star icon according to `stopIsInFavorites` */ fun updateStarIcon() { // 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, timesSourceTextView.text.toString()) requireArguments().putString(MESSAGE_TEXT_VIEW, messageTextView.text.toString()) } super.onDestroyView() } override fun getBaseViewForSnackBar(): View? { return null } fun isFragmentForTheSameStop(p: Palina): Boolean { return if (tag != null) tag == getFragmentTag(p) else false } /** * Request arrivals in the fragment */ fun requestArrivalsForTheFragment(){ // Run with previous fetchers //fragment.getCurrentFetchers().toArray() //AsyncArrivalsSearcher(, getCurrentFetchersAsArray(), context).execute(stopID) 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 } @JvmStatic fun getFragmentTag(p: Palina): String { return "palina_" + 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 b00d0de..f0e8829 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt @@ -1,946 +1,955 @@ package it.reyboz.bustorino.fragments 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 androidx.cardview.widget.CardView import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat import androidx.core.view.ViewCompat import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.gson.JsonObject import it.reyboz.bustorino.R import it.reyboz.bustorino.backend.FiveTNormalizer import it.reyboz.bustorino.backend.LivePositionTripPattern import it.reyboz.bustorino.backend.LivePositionsServiceStatus import it.reyboz.bustorino.backend.Stop import it.reyboz.bustorino.backend.gtfs.GtfsUtils import it.reyboz.bustorino.backend.gtfs.LivePositionUpdate import it.reyboz.bustorino.backend.utils import it.reyboz.bustorino.data.PreferencesHolder import it.reyboz.bustorino.data.gtfs.TripAndPatternWithStops import it.reyboz.bustorino.map.MapLibreUtils 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.LocationComponentOptions import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.MapView import org.maplibre.android.maps.OnMapReadyCallback import org.maplibre.android.maps.Style import org.maplibre.android.plugins.annotation.Symbol import org.maplibre.android.plugins.annotation.SymbolManager import org.maplibre.android.plugins.annotation.SymbolOptions import org.maplibre.android.style.expressions.Expression import org.maplibre.android.style.layers.Property.ICON_ANCHOR_CENTER import org.maplibre.android.style.layers.Property.ICON_ROTATION_ALIGNMENT_MAP import org.maplibre.android.style.layers.Property.TEXT_ANCHOR_CENTER import org.maplibre.android.style.layers.Property.TEXT_ROTATION_ALIGNMENT_VIEWPORT import org.maplibre.android.style.layers.PropertyFactory import org.maplibre.android.style.layers.SymbolLayer import org.maplibre.android.style.sources.GeoJsonSource import org.maplibre.geojson.Feature import org.maplibre.geojson.FeatureCollection import org.maplibre.geojson.Point abstract class GeneralMapLibreFragment: ScreenBaseFragment(), OnMapReadyCallback { protected var map: MapLibreMap? = null protected var shownStopInBottomSheet : Stop? = null //protected var savedMapStateOnPause : Bundle? = null protected var fragmentListener: CommonFragmentListener? = null // Declare a variable for MapView protected lateinit var mapView: MapView protected lateinit var mapStyle: Style protected lateinit var stopsSource: GeoJsonSource protected lateinit var busesSource: GeoJsonSource protected lateinit var selectedStopSource: GeoJsonSource protected lateinit var selectedBusSource: GeoJsonSource //= GeoJsonSource(SEL_BUS_SOURCE) protected lateinit var sharedPreferences: SharedPreferences protected lateinit var bottomSheetBehavior: BottomSheetBehavior private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener(){ pref, key -> /*when(key){ SettingsFragment.LIBREMAP_STYLE_PREF_KEY -> reloadMap() } */ if(key == SettingsFragment.LIBREMAP_STYLE_PREF_KEY){ Log.d(DEBUG_TAG,"ASKING RELOAD OF MAP") reloadMap() } } //Bottom sheet behavior in GeneralMapLibreFragment protected var bottomLayout: RelativeLayout? = null protected lateinit var stopTitleTextView: TextView protected lateinit var stopNumberTextView: TextView protected lateinit var linesPassingTextView: TextView protected lateinit var extraBottomTextView: TextView protected lateinit var arrivalsCard: CardView protected lateinit var directionsCard: CardView protected lateinit var bottomrightImage: ImageView protected lateinit var locationComponent: LocationComponent protected lateinit var busPositionsIconButton: ImageButton protected var lastLocation : Location? = null private var lastMapStyle ="" //BUS POSITIONS protected val updatesByVehDict = HashMap(5) protected val animatorsByVeh = HashMap() protected var vehShowing = "" protected var lastUpdateTime:Long = -2 private val lifecycleOwnerLiveData = getViewLifecycleOwnerLiveData() //extra items to use the LibreMap protected var symbolManager : SymbolManager? = null protected var stopActiveSymbol: Symbol? = null protected var stopsLayerStarted = false protected val livePositionsViewModel : LivePositionsViewModel by activityViewModels() //private lateinit var symbolManager: SymbolManager protected val mapStateViewModel: MapStateViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) //sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) lastMapStyle = PreferencesHolder.getMapLibreStyleFile(requireContext()) //init map MapLibre.getInstance(requireContext()) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { lastMapStyle = PreferencesHolder.getMapLibreStyleFile(requireContext()) Log.d(DEBUG_TAG, "onCreateView lastMapStyle: $lastMapStyle") return super.onCreateView(inflater, container, savedInstanceState) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) //init bottom sheet val bottomSheet = view.findViewById(R.id.bottom_sheet) bottomLayout = bottomSheet stopTitleTextView = view.findViewById(R.id.stopTitleTextView) stopNumberTextView = view.findViewById(R.id.stopNumberTextView) linesPassingTextView = view.findViewById(R.id.linesPassingTextView) arrivalsCard = view.findViewById(R.id.arrivalsCardButton) directionsCard = view.findViewById(R.id.directionsCardButton) bottomrightImage = view.findViewById(R.id.rightmostImageView) bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet) extraBottomTextView = view.findViewById(R.id.extraBottomTextView) bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN } override fun onResume() { mapView.onResume() super.onResume() val newMapStyle = PreferencesHolder.getMapLibreStyleFile(requireContext()) Log.d(DEBUG_TAG, "onResume newMapStyle: $newMapStyle, lastMapStyle: $lastMapStyle") if(newMapStyle!=lastMapStyle){ reloadMap() } } @Deprecated("Deprecated in Java") override fun onLowMemory() { mapView.onLowMemory() super.onLowMemory() } override fun onStart() { super.onStart() mapView.onStart() } override fun onDestroy() { mapView.onDestroy() Log.d(DEBUG_TAG, "Destroyed mapView Fragment!!") super.onDestroy() } override fun onDestroyView() { bottomLayout = null super.onDestroyView() } protected fun reloadMap(){ /*map?.let { Log.d("GeneralMapFragment", "RELOADING MAP") //save map state savedMapStateOnPause = saveMapStateInBundle() onMapDestroy() //Destroy and recreate MAP mapView.onDestroy() mapView.onCreate(null) mapView.getMapAsync(this) } */ //TODO figure out how to switch map safely } //For extra stuff to do when the map is destroyed abstract fun onMapDestroy() override fun onAttach(context: Context) { super.onAttach(context) if(context is CommonFragmentListener){ fragmentListener = context } else throw RuntimeException("$context must implement CommonFragmentListener") } /* protected fun restoreMapStateFromBundle(bundle: Bundle): Boolean{ val nullDouble = -10_000.0 var boundsRestored =false val latCenter = bundle.getDouble("center_map_lat", nullDouble) val lonCenter = bundle.getDouble("center_map_lon",nullDouble) val zoom = bundle.getDouble("map_zoom", nullDouble) val bearing = bundle.getDouble("map_bearing", nullDouble) val tilt = bundle.getDouble("map_tilt", nullDouble) if(lonCenter!=nullDouble &&latCenter!=nullDouble) map?.let { val center = LatLng(latCenter, lonCenter) val newPos = CameraPosition.Builder().target(center) if(zoom>0) newPos.zoom(zoom) if(bearing!=nullDouble) newPos.bearing(bearing) if(tilt != nullDouble) newPos.tilt(tilt) it.cameraPosition=newPos.build() Log.d(DEBUG_TAG, "Restored map state from Bundle, center: $center, zoom: $zoom, bearing $bearing, tilt $tilt") boundsRestored =true } else{ Log.d(DEBUG_TAG, "Not restoring map state, center: $latCenter,$lonCenter; zoom: $zoom, bearing: $bearing, tilt $tilt") } val mStop = bundle.getBundle("shown_stop")?.let { Stop.fromBundle(it) } mStop?.let { openStopInBottomSheet(it) } return boundsRestored } protected fun saveMapStateBeforePause(bundle: Bundle){ map?.let { val newBbox = it.projection.visibleRegion.latLngBounds val cp = it.cameraPosition bundle.putDouble("center_map_lat", newBbox.center.latitude) bundle.putDouble("center_map_lon", newBbox.center.longitude) it.cameraPosition.zoom.let { z-> bundle.putDouble("map_zoom",z) } bundle.putDouble("map_bearing",cp.bearing) bundle.putDouble("map_tilt", cp.tilt) val locationComponent = it.locationComponent bundle.putBoolean(KEY_LOCATION_ENABLED,locationComponent.isLocationComponentEnabled) bundle.putParcelable("last_location", locationComponent.lastKnownLocation) } shownStopInBottomSheet?.let { bundle.putBundle("shown_stop", it.toBundle()) } } protected fun saveMapStateInBundle(): Bundle { val b = Bundle() saveMapStateBeforePause(b) return b } */ protected fun stopToGeoJsonFeature(s: Stop): Feature{ return Feature.fromGeometry( Point.fromLngLat(s.longitude!!, s.latitude!!), JsonObject().apply { addProperty("id", s.ID) addProperty("name", s.stopDefaultName) //addProperty("routes", s.routesThatStopHereToString()) // Add routes array to JSON object } ) } protected fun isPointInsideVisibleRegion(p: LatLng, other: Boolean): Boolean{ val bounds = map?.projection?.visibleRegion?.latLngBounds var inside = other bounds?.let { inside = it.contains(p) } return inside } protected fun isPointInsideVisibleRegion(lat: Double, lon: Double, other: Boolean): Boolean{ val p = LatLng(lat, lon) return isPointInsideVisibleRegion(p, other) } protected fun removeVehiclesData(vehs: List){ for(v in vehs){ if (updatesByVehDict.contains(v)) { updatesByVehDict.remove(v) if (animatorsByVeh.contains(v)){ animatorsByVeh[v]?.cancel() animatorsByVeh.remove(v) } } if (vehShowing==v){ hideStopOrBusBottomSheet() } } } // Hide the bottom sheet and remove extra symbol - protected fun hideStopOrBusBottomSheet(){ + 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)) } /** * Initialize the map location, but do not enable the component */ @SuppressLint("MissingPermission") protected fun initMapUserLocation(style: Style, map: MapLibreMap, context: Context){ locationComponent = map.locationComponent val locationComponentOptions = LocationComponentOptions.builder(context) .pulseEnabled(false) .build() val locationComponentActivationOptions = MapLibreUtils.buildLocationComponentActivationOptions(style, locationComponentOptions, context) locationComponent.activateLocationComponent(locationComponentActivationOptions) locationComponent.isLocationComponentEnabled = false lastLocation?.let { if (it.accuracy < 200) locationComponent.forceLocationUpdate(it) } } /** * Update function for the bus positions * Takes the processed updates and saves them accordingly * Unified version that works with both fragments * * @param incomingData Map of updates with optional trip and pattern information * @param checkCoordinateValidity If true, validates that coordinates are positive (default: false) * @param hasVehicleTracking If true, checks if vehShowing is updated and calls callback (default: true) * @param trackVehicleCallback Optional callback to show vehicle details when vehShowing is updated */ protected fun updateBusPositionsInMap( incomingData: HashMap>, hasVehicleTracking: Boolean = false, trackVehicleCallback: ((String) -> Unit)? = null ) { val vehsNew = HashSet(incomingData.values.map { up -> up.first.vehicle }) val vehsOld = HashSet(updatesByVehDict.keys) Log.d(DEBUG_TAG, "In fragment, have ${incomingData.size} updates to show") var countUpds = 0 var createdVehs = 0 for (upsWithTrp in incomingData.values) { val newPos = upsWithTrp.first val patternStops = upsWithTrp.second val vehID = newPos.vehicle // Validate coordinates if (!vehsOld.contains(vehID)) { if (newPos.latitude <= 0 || newPos.longitude <= 0) { Log.w(DEBUG_TAG, "Update ignored for veh $vehID on line ${newPos.routeID}, lat: ${newPos.latitude}, lon ${newPos.longitude}") continue } } if (vehsOld.contains(vehID)) { // Changing the location of an existing bus val oldPosData = updatesByVehDict[vehID]!! val oldPos = oldPosData.posUpdate val oldPattern = oldPosData.pattern var avoidShowingUpdateBecauseIsImpossible = false // Check for impossible route changes if (oldPos.routeID != newPos.routeID) { val dist = LatLng(oldPos.latitude, oldPos.longitude).distanceTo( LatLng(newPos.latitude, newPos.longitude) ) val speed = dist * 3.6 / (newPos.timestamp - oldPos.timestamp) // km/h Log.w(DEBUG_TAG, "Vehicle $vehID changed route from ${oldPos.routeID} to ${newPos.routeID}, distance: $dist, speed: $speed") if (speed > 120 || speed < 0) { avoidShowingUpdateBecauseIsImpossible = true } } if (avoidShowingUpdateBecauseIsImpossible) { Log.w(DEBUG_TAG, "Update for vehicle $vehID skipped") continue } // Check if position actually changed val samePosition = (oldPos.latitude == newPos.latitude) && (oldPos.longitude == newPos.longitude) val setPattern = (oldPattern == null) && (patternStops != null) // Copy old bearing if new one is missing if (newPos.bearing == null && oldPos.bearing != null) { newPos.bearing = oldPos.bearing } if (!samePosition || setPattern) { val newOrOldPosInBounds = isPointInsideVisibleRegion( newPos.latitude, newPos.longitude, true ) || isPointInsideVisibleRegion(oldPos.latitude, oldPos.longitude, true) if (newOrOldPosInBounds) { // Update pattern data if available patternStops?.let { updatesByVehDict[vehID]!!.pattern = it.pattern } // Animate the position change animateNewPositionMove(newPos) } else { // Update position without animation updatesByVehDict[vehID] = LivePositionTripPattern( newPos, patternStops?.pattern ) } } countUpds++ } else { // New vehicle - create entry updatesByVehDict[vehID] = LivePositionTripPattern( newPos, patternStops?.pattern ) createdVehs++ } // Update vehicle details if this is the shown/tracked vehicle if (hasVehicleTracking && vehShowing.isNotEmpty() && vehID == vehShowing) { trackVehicleCallback?.invoke(vehID) } } // Remove old positions Log.d(DEBUG_TAG, "Updated $countUpds vehicles, created $createdVehs vehicles") vehsOld.removeAll(vehsNew) // Clean up stale vehicles (not updated for 2 minutes) val currentTimeStamp = System.currentTimeMillis() / 1000 for (vehID in vehsOld) { val posData = updatesByVehDict[vehID]!! if (currentTimeStamp - posData.posUpdate.timestamp > 2 * 60) { // Remove the bus updatesByVehDict.remove(vehID) // Cancel and remove animator if exists animatorsByVeh[vehID]?.cancel() animatorsByVeh.remove(vehID) } } // Update UI updatePositionsIcons(false) } /** * 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) -> Unit + 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), true + 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 ?: "") + onDirectionsClick(pat?.code ?: "", veh) } directionsCard.visibility = View.VISIBLE bottomrightImage.setImageDrawable( ResourcesCompat.getDrawable(resources, R.drawable.ic_magnifying_glass, activity?.theme) ) val colorBlue = ResourcesCompat.getColor(resources, R.color.blue_500, activity?.theme) ViewCompat.setBackgroundTintList(directionsCard, ColorStateList.valueOf(colorBlue)) linesPassingTextView.text = getString(R.string.vehicle_fill, data.posUpdate.vehicle) arrivalsCard.visibility = View.GONE extraBottomTextView.text = getString(R.string.updated_fill, utils.unixTimestampToLocalTime(data.posUpdate.timestamp)) extraBottomTextView.visibility = View.VISIBLE } vehShowing = veh bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED updatePositionsIcons(true) Log.d(DEBUG_TAG, "Shown vehicle $veh in bottom sheet") } /** * Update the bus positions displayed on the map, from the existing data * * @param forced If true, forces immediate update ignoring the 60ms throttle */ protected fun updatePositionsIcons(forced: Boolean) { // Avoid frequent updates - throttle to max once per 60ms val currentTime = System.currentTimeMillis() if (!forced && currentTime - lastUpdateTime < 60) { // Schedule delayed update if(lifecycleOwnerLiveData.value != null) viewLifecycleOwner.lifecycleScope.launch { delay(200) updatePositionsIcons(forced) } return } val busFeatures = ArrayList() val selectedBusFeatures = ArrayList() for (dat in updatesByVehDict.values) { val pos = dat.posUpdate val point = Point.fromLngLat(pos.longitude, pos.latitude) val newFeature = Feature.fromGeometry( point, JsonObject().apply { addProperty("veh", pos.vehicle) addProperty("trip", pos.tripID) addProperty("bearing", pos.bearing ?: 0.0f) addProperty("line", pos.routeID.substringBeforeLast('U')) } ) // Separate selected vehicle from others if (vehShowing.isNotEmpty() && vehShowing == dat.posUpdate.vehicle) { selectedBusFeatures.add(newFeature) } else { busFeatures.add(newFeature) } } busesSource.setGeoJson(FeatureCollection.fromFeatures(busFeatures)) selectedBusSource.setGeoJson(FeatureCollection.fromFeatures(selectedBusFeatures)) lastUpdateTime = System.currentTimeMillis() } /** * Animates the transition of a vehicle from its current position to a new position * This is the tricky part - we need to set the new positions with the data and redraw them all * * @param positionUpdate The new position update to animate to */ protected fun animateNewPositionMove(positionUpdate: LivePositionUpdate) { val vehID = positionUpdate.vehicle // Check if vehicle exists in our tracking dictionary if (vehID !in updatesByVehDict.keys) { return } val currentUpdate = updatesByVehDict[vehID] ?: run { Log.e(DEBUG_TAG, "Have to run animation for veh $vehID but not in the dict") return } // Cancel any current animation for this vehicle animatorsByVeh[vehID]?.cancel() val posUp = currentUpdate.posUpdate val currentPos = LatLng(posUp.latitude, posUp.longitude) val newPos = LatLng(positionUpdate.latitude, positionUpdate.longitude) // Create animator for smooth transition val valueAnimator = ValueAnimator.ofObject( MapLibreUtils.LatLngEvaluator(), currentPos, newPos ) valueAnimator.addUpdateListener { animation -> val latLng = animation.animatedValue as LatLng // Update position during animation updatesByVehDict[vehID]?.let { update -> update.posUpdate.latitude = latLng.latitude update.posUpdate.longitude = latLng.longitude updatePositionsIcons(false) } ?: run { Log.w(DEBUG_TAG, "The bus position to animate has been removed, but the animator is still running!") } } // Set the new position as current but keep old coordinates for animation start positionUpdate.latitude = posUp.latitude positionUpdate.longitude = posUp.longitude updatesByVehDict[vehID]!!.posUpdate = positionUpdate // Configure and start animation valueAnimator.duration = 300 valueAnimator.interpolator = LinearInterpolator() valueAnimator.start() // Store animator for potential cancellation animatorsByVeh[vehID] = valueAnimator } /// STOP OPENING abstract fun showOpenStopWithSymbolLayer(): Boolean /** * Update the bottom sheet with the stop information */ protected fun openStopInBottomSheet(stop: Stop){ bottomLayout?.let { //lay.findViewById(R.id.stopTitleTextView).text ="${stop.ID} - ${stop.stopDefaultName}" val stopName = stop.stopUserName ?: stop.stopDefaultName stopTitleTextView.text = stopName//stop.stopDefaultName stopNumberTextView.text = getString(R.string.stop_fill,stop.ID) stopTitleTextView.visibility = View.VISIBLE val string_show = if (stop.numRoutesStopping==0) "" else requireContext().getString(R.string.lines_fill, stop.routesThatStopHereToString()) linesPassingTextView.text = string_show + linesPassingTextView.visibility = View.VISIBLE //SET ON CLICK LISTENER arrivalsCard.setOnClickListener{ fragmentListener?.requestArrivalsForStopID(stop.ID) } arrivalsCard.visibility = View.VISIBLE directionsCard.visibility = View.VISIBLE directionsCard.setOnClickListener { ViewUtils.openStopInOutsideApp(stop, context) } context?.let { val colorIcon = ViewUtils.getColorFromTheme(it, android.R.attr.colorAccent)//ResourcesCompat.getColor(resources,R.attr.colorAccent,activity?.theme) ViewCompat.setBackgroundTintList(directionsCard, ColorStateList.valueOf(colorIcon)) } bottomrightImage.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.navigation_right, activity?.theme)) } //add stop marker if (stop.latitude!=null && stop.longitude!=null) { Log.d(DEBUG_TAG, "Showing stop: ${stop.ID}") if (showOpenStopWithSymbolLayer()) { stopActiveSymbol = symbolManager?.create( SymbolOptions() .withLatLng(LatLng(stop.latitude!!, stop.longitude!!)) .withIconImage(STOP_ACTIVE_IMG) .withIconAnchor(ICON_ANCHOR_CENTER) ) } else { val list = ArrayList() list.add(stopToGeoJsonFeature(stop)) selectedStopSource.setGeoJson( FeatureCollection.fromFeatures(list) ) } } Log.d(DEBUG_TAG, "Shown stop $stop in bottom sheet") shownStopInBottomSheet = stop bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED } protected fun stopAnimations(){ for(anim in animatorsByVeh.values){ anim.cancel() } } protected fun addImagesStyle(style: Style){ style.addImage( STOP_IMAGE_ID, ResourcesCompat.getDrawable(resources,R.drawable.bus_stop_new, activity?.theme)!!) style.addImage(STOP_ACTIVE_IMG, ResourcesCompat.getDrawable(resources, R.drawable.bus_stop_new_highlight, activity?.theme)!!) style.addImage("ball",ResourcesCompat.getDrawable(resources, R.drawable.ball, activity?.theme)!!) style.addImage(BUS_IMAGE_ID,ResourcesCompat.getDrawable(resources, R.drawable.map_bus_position_icon, activity?.theme)!!) style.addImage(BUS_SEL_IMAGE_ID, ResourcesCompat.getDrawable(resources, R.drawable.map_bus_position_icon_sel, activity?.theme)!!) val polyIconArrow = ResourcesCompat.getDrawable(resources, R.drawable.arrow_up_box_fill, activity?.theme)!! style.addImage(POLY_ARROW, polyIconArrow) } protected fun initStopsLayer(style: Style, stopsFeatures: FeatureCollection?){ //determine default layer var layerAbove = "" if (lastMapStyle == MapLibreUtils.STYLE_OSM_RASTER){ layerAbove = "osm-raster" } else if (lastMapStyle == MapLibreUtils.STYLE_VECTOR){ layerAbove = "symbol-transit-airfield" } initStopsLayer(style, stopsFeatures, layerAbove) } protected fun initStopsLayer(style: Style, stopsFeatures: FeatureCollection?, stopsLayerAbove: String){ stopsSource = GeoJsonSource(STOPS_SOURCE_ID,stopsFeatures ?: FeatureCollection.fromFeatures(ArrayList())) style.addSource(stopsSource) // Stops layer val stopsLayer = SymbolLayer(STOPS_LAYER_ID, STOPS_SOURCE_ID) stopsLayer.withProperties( PropertyFactory.iconImage(STOP_IMAGE_ID), PropertyFactory.iconAnchor(ICON_ANCHOR_CENTER), PropertyFactory.iconAllowOverlap(true), PropertyFactory.iconIgnorePlacement(true) ) style.addLayerAbove(stopsLayer, stopsLayerAbove ) //"label_country_1") this with OSM Bright selectedStopSource = GeoJsonSource(SEL_STOP_SOURCE, FeatureCollection.fromFeatures(ArrayList())) style.addSource(selectedStopSource) val selStopLayer = SymbolLayer(SEL_STOP_LAYER, SEL_STOP_SOURCE) selStopLayer.withProperties( PropertyFactory.iconImage(STOP_ACTIVE_IMG), PropertyFactory.iconAllowOverlap(true), PropertyFactory.iconIgnorePlacement(true), PropertyFactory.iconAnchor(ICON_ANCHOR_CENTER), ) style.addLayerAbove(selStopLayer, STOPS_LAYER_ID) stopsLayerStarted = true } /** * Setup the Map Layers */ protected fun setupBusLayer(style: Style, withLabels: Boolean =false, busIconsScale: Float = 1.0f) { // Buses source busesSource = GeoJsonSource(BUSES_SOURCE_ID) style.addSource(busesSource) //style.addImage("bus_symbol",ResourcesCompat.getDrawable(resources, R.drawable.map_bus_position_icon, activity?.theme)!!) selectedBusSource = GeoJsonSource(SEL_BUS_SOURCE) style.addSource(selectedBusSource) // Buses layer val busesLayer = SymbolLayer(BUSES_LAYER_ID, BUSES_SOURCE_ID).apply { withProperties( PropertyFactory.iconImage(BUS_IMAGE_ID), PropertyFactory.iconSize(busIconsScale), PropertyFactory.iconAllowOverlap(true), PropertyFactory.iconIgnorePlacement(true), PropertyFactory.iconRotate(Expression.get("bearing")), PropertyFactory.iconRotationAlignment(ICON_ROTATION_ALIGNMENT_MAP) ) if (withLabels){ withProperties(PropertyFactory.textAnchor(TEXT_ANCHOR_CENTER), PropertyFactory.textAllowOverlap(true), PropertyFactory.textField(Expression.get("line")), PropertyFactory.textColor(Color.WHITE), PropertyFactory.textRotationAlignment(TEXT_ROTATION_ALIGNMENT_VIEWPORT), PropertyFactory.textSize(12f), PropertyFactory.textFont(arrayOf("noto_sans_regular"))) } } style.addLayerAbove(busesLayer, STOPS_LAYER_ID) val selectedBusLayer = SymbolLayer(SEL_BUS_LAYER, SEL_BUS_SOURCE).apply { withProperties( PropertyFactory.iconImage(BUS_SEL_IMAGE_ID), PropertyFactory.iconSize(busIconsScale), PropertyFactory.iconAllowOverlap(true), PropertyFactory.iconIgnorePlacement(true), PropertyFactory.iconRotate(Expression.get("bearing")), PropertyFactory.iconRotationAlignment(ICON_ROTATION_ALIGNMENT_MAP) ) if (withLabels){ withProperties(PropertyFactory.textAnchor(TEXT_ANCHOR_CENTER), PropertyFactory.textAllowOverlap(true), PropertyFactory.textField(Expression.get("line")), PropertyFactory.textColor(Color.WHITE), PropertyFactory.textRotationAlignment(TEXT_ROTATION_ALIGNMENT_VIEWPORT), PropertyFactory.textSize(12f), PropertyFactory.textFont(arrayOf("noto_sans_regular"))) } } style.addLayerAbove(selectedBusLayer, BUSES_LAYER_ID) } protected fun isBottomSheetShowing(): Boolean { return bottomSheetBehavior.state == BottomSheetBehavior.STATE_EXPANDED } protected fun deviceHasGpsProvider(): Boolean{ val locManager = requireContext().getSystemService(LOCATION_SERVICE) as LocationManager return locManager.allProviders.contains(LocationManager.GPS_PROVIDER) } /** * 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() + } + companion object{ private const val DEBUG_TAG="GeneralMapLibreFragment" const val BUSES_SOURCE_ID = "buses-source" const val BUSES_LAYER_ID = "buses-layer" const val SEL_STOP_SOURCE="selected-stop-source" const val SEL_STOP_LAYER = "selected-stop-layer" const val SEL_BUS_SOURCE = "sel_bus_source" const val SEL_BUS_LAYER = "sel_bus_layer" const val KEY_LOCATION_ENABLED="location_enabled" protected const val STOPS_SOURCE_ID = "stops-source" protected const val STOPS_LAYER_ID = "stops-layer" protected const val STOP_IMAGE_ID = "stop-img" protected const val STOP_ACTIVE_IMG = "stop_active_img" protected const val BUS_IMAGE_ID = "bus_symbol" protected const val BUS_SEL_IMAGE_ID = "sel_bus_symbol" protected const val POLYLINE_LAYER = "polyline-layer" protected const val POLYLINE_SOURCE = "polyline-source" protected const val POLY_ARROWS_LAYER = "arrows-layer" protected const val POLY_ARROWS_SOURCE = "arrows-source" protected const val POLY_ARROW ="poly-arrow-img" } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt index 492e86a..6c0366b 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt @@ -1,1146 +1,1187 @@ /* BusTO - Fragments components Copyright (C) 2023 Fabio Mazza This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package it.reyboz.bustorino.fragments import android.Manifest import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.content.Context import android.content.SharedPreferences -import android.content.res.ColorStateList 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.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat -import androidx.core.view.ViewCompat import androidx.fragment.app.viewModels import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.gson.JsonObject import it.reyboz.bustorino.R import it.reyboz.bustorino.adapters.NameCapitalize import it.reyboz.bustorino.adapters.StopAdapterListener import it.reyboz.bustorino.adapters.StopRecyclerAdapter -import it.reyboz.bustorino.backend.FiveTNormalizer import it.reyboz.bustorino.backend.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.middleware.LocationUtils import it.reyboz.bustorino.util.Permissions import it.reyboz.bustorino.viewmodels.LinesViewModel import it.reyboz.bustorino.viewmodels.MapStateViewModel 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 import java.util.concurrent.atomic.AtomicBoolean class LinesDetailFragment() : GeneralMapLibreFragment() { private var lineID = "" 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 var firstInit = true + //private var firstInit = true private var pausedFragment = false private lateinit var switchButton: ImageButton private var favoritesButton: ImageButton? = null private var locationIcon: ImageButton? = null private var isLineInFavorite = false private var appContext: Context? = null private var isLocationPermissionOK = false private val lineSharedPrefMonitor = SharedPreferences.OnSharedPreferenceChangeListener { pref, keychanged -> if(keychanged!=PreferencesHolder.PREF_FAVORITE_LINES || lineID.isEmpty()) return@OnSharedPreferenceChangeListener val newFavorites = pref.getStringSet(PreferencesHolder.PREF_FAVORITE_LINES, HashSet()) newFavorites?.let {favorites-> isLineInFavorite = favorites.contains(lineID) //if the button has been intialized, change the icon accordingly favoritesButton?.let { button-> //avoid crashes if fragment not attached if(context==null) return@let if(isLineInFavorite) { button.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_star_filled, null)) appContext?.let { Toast.makeText(it,R.string.favorites_line_add,Toast.LENGTH_SHORT).show()} } else { button.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_star_outline, null)) appContext?.let {Toast.makeText(it,R.string.favorites_line_remove,Toast.LENGTH_SHORT).show()} } } } } private lateinit var stopsRecyclerView: RecyclerView private lateinit var descripTextView: TextView private var stopIDFromToShow = "" 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 val showUserPositionRequestLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions(), ActivityResultCallback { result -> if (result == null) { Log.w(DEBUG_TAG, "Got asked permission but request is null, doing nothing?") } else if (java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_COARSE_LOCATION] && java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_FINE_LOCATION]) { // We can use the position, restart location overlay if (context == null || requireContext().getSystemService(Context.LOCATION_SERVICE) == null) return@ActivityResultCallback ///@registerForActivityResult setMapUserLocationEnabled(true, true, enablingPositionFromClick) } else Log.w(DEBUG_TAG, "No location permission") }) //private var stopPosList = ArrayList() //fragment actions private 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.set(false) val rootView = inflater.inflate(R.layout.fragment_lines_detail, container, false) //lineID = requireArguments().getString(LINEID_KEY, "") arguments?.let { lineID = it.getString(LINEID_KEY, "") 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) 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)+" "+FiveTNormalizer.fixShortNameForDisplay( - GtfsUtils.getLineNameFromGtfsID(lineID), true) + 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 -> if(!LocationUtils.isLocationEnabled(requireContext()) || !Permissions.anyLocationPermissionsGranted(requireContext())) setLocationIconEnabled(false) //set click Listener view.setOnClickListener(this::onPositionIconButtonClick) } busPositionsIconButton.setOnClickListener { LivePositionsDialogFragment().show(parentFragmentManager, "LivePositionsDialog") } //set //INITIALIZE VIEW MODELS viewModel.setRouteIDQuery(lineID) livePositionsViewModel.setGtfsLineToFilterPos(lineID, null) //observe the change, clear buses when switching position livePositionsViewModel.useMQTTPositionsLiveData.observe(viewLifecycleOwner){ useMQTT-> //Log.d(DEBUG_TAG, "Changed MQTT positions, now have to use MQTT: $useMQTT") if (isResumed) { //Log.d(DEBUG_TAG, "Deciding to switch, the current source is using MQTT: $usingMQTTPositions") if(useMQTT!=usingMQTTPositions){ // we have to switch val clearPos = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("positions_clear_on_switch_pref", true) livePositionsViewModel.clearOldPositionsUpdates() if(useMQTT){ //switching to MQTT, the GTFS positions are disabled automatically livePositionsViewModel.requestMatoPosUpdates(GtfsUtils.getLineNameFromGtfsID(lineID)) } else{ //switching to GTFS RT: stop Mato, launch first request livePositionsViewModel.stopMatoUpdates() livePositionsViewModel.requestGTFSUpdates() } Log.d(DEBUG_TAG, "Should clear positions: $clearPos") if (clearPos) { livePositionsViewModel.clearAllPositions() //force clear of the viewed data if(vehShowing.isNotEmpty()) hideStopOrBusBottomSheet() clearAllBusPositionsInMap() } } } usingMQTTPositions = useMQTT } val keySourcePositions = getString(R.string.pref_positions_source) usingMQTTPositions = PreferenceManager.getDefaultSharedPreferences(requireContext()) .getString(keySourcePositions, "mqtt").contentEquals("mqtt") - viewModel.patternsWithStopsByRouteLiveData.observe(viewLifecycleOwner){ - patterns -> savePatternsToShow(patterns) - } + viewModel.patternsWithStopsByRouteLiveData.observe(viewLifecycleOwner, this::savePatternsToShow) /* */ viewModel.stopsForPatternLiveData.observe(viewLifecycleOwner) { stops -> - if(mapView.visibility ==View.VISIBLE) - patternShown?.let{ - // 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 - displayPatternWithStopsOnMap(it,stops, true) - } ?:{ - Log.w(DEBUG_TAG, "The viewingPattern is null!") - } - else{ - if(stopsRecyclerView.visibility==View.VISIBLE) + 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 } /* */ Log.d(DEBUG_TAG,"Data ${viewModel.stopsForPatternLiveData.value}") //listeners patternsSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(p0: AdapterView<*>?, p1: View?, position: Int, p3: Long) { val currentShownPattern = patternShown?.pattern val patternWithStops = currentPatterns[position] Log.d(DEBUG_TAG, "request stops for pattern ${patternWithStops.pattern.code}") setPatternAndReqStops(patternWithStops) if(mapView.visibility == View.VISIBLE) { //Clear buses if we are changing direction currentShownPattern?.let { patt -> if(patt.directionId != patternWithStops.pattern.directionId){ stopAnimations() updatesByVehDict.clear() updatePositionsIcons(true) livePositionsViewModel.retriggerPositionUpdate() } + if (shownStopInBottomSheet!=null){ + //check if the stop is inside the new pattern + /*val s = shownStopInBottomSheet!! + val newPatternStops = patternWithStops.stopsIndices + val filterPStops = newPatternStops.filter { ps -> ps.stopGtfsId == "gtt:${s.ID}" } + if (filterPStops.isEmpty()){ + hideStopOrBusBottomSheet() + } + */ + // do another thing, just close the stop when the pattern is changed + if (patt.code != patternWithStops.pattern.code){ + hideStopOrBusBottomSheet() + } + } } } livePositionsViewModel.setGtfsLineToFilterPos(lineID, patternWithStops.pattern) } override fun onNothingSelected(p0: AdapterView<*>?) { } } Log.d(DEBUG_TAG, "Views created!") observeStatusLivePositions() return rootView } // ------------- UI switch stuff --------- private fun hideMapAndShowStopList(){ mapView.visibility = View.GONE stopsRecyclerView.visibility = View.VISIBLE locationIcon?.visibility = View.GONE busPositionsIconButton?.visibility = View.GONE viewModel.setMapShowing(false) if(usingMQTTPositions) livePositionsViewModel.stopMatoUpdates() //map.overlayManager.remove(busPositionsOverlay) switchButton.setImageDrawable(AppCompatResources.getDrawable(requireContext(), R.drawable.ic_map_white_30)) hideStopOrBusBottomSheet() if(locationComponent.isLocationComponentEnabled){ locationComponent.isLocationComponentEnabled = 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 && Permissions.bothLocationPermissionsGranted(requireContext())){ locationComponent.isLocationComponentEnabled = true } } private fun setLocationIconEnabled(setTrue: Boolean){ if(setTrue) locationIcon?.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_red)) else locationIcon?.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_grey)) } /** * Handles logic of enabling the user location on the map */ @SuppressLint("MissingPermission") private fun setMapUserLocationEnabled(enabled: Boolean, assumePermissions: Boolean, fromClick: Boolean) { if (enabled) { val permissionOk = assumePermissions || Permissions.bothLocationPermissionsGranted(requireContext()) if (permissionOk) { Log.d(DEBUG_TAG, "Permission OK, starting location component, assumed: $assumePermissions") locationComponent.isLocationComponentEnabled = true //locationComponent.cameraMode = CameraMode.TRACKING //CameraMode.TRACKING setLocationIconEnabled(true) if (fromClick) Toast.makeText(context, R.string.location_enabled, Toast.LENGTH_SHORT).show() } else { if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)) { //TODO: show dialog for permission rationale Toast.makeText(activity, R.string.enable_position_message_map, Toast.LENGTH_SHORT).show() } Log.d(DEBUG_TAG, "Requesting permission to show user location") enablingPositionFromClick = fromClick showUserPositionRequestLauncher.launch(Permissions.LOCATION_PERMISSIONS) } } else{ locationComponent.isLocationComponentEnabled = false setLocationIconEnabled(false) if (fromClick) { Toast.makeText(requireContext(), R.string.location_disabled, Toast.LENGTH_SHORT).show() //TODO: Cancel the request for the enablement of the position if needed } } } /** * Switch position icon from activ */ private fun onPositionIconButtonClick(view: View){ if(locationComponent.isLocationComponentEnabled) setMapUserLocationEnabled(false, false, true) else{ setMapUserLocationEnabled(true, false, true) } } // ------------- Map Code ------------------------- /** * This method sets up the map and the layers */ override fun onMapReady(mapReady: MapLibreMap) { this.map = mapReady 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) // Start observing data initMapUserLocation(style, mapReady, requireContext()) //if(!stopsLayerStarted) initPolylineStopsLayers(style, null) setupBusLayer(style) initSymbolManager(mapReady, style) toRunWhenMapReady?.run() toRunWhenMapReady = null mapInitialized.set(true) if(patternShown!=null){ viewModel.stopsForPatternLiveData.value?.let { Log.d(DEBUG_TAG, "Show stops from the cache") displayPatternWithStopsOnMap(patternShown!!, it, true) //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 features = mapReady.queryRenderedFeatures(screenPoint, STOPS_LAYER_ID) + val stopsNearby = mapReady.queryRenderedFeatures(screenPoint, STOPS_LAYER_ID) val busNearby = mapReady.queryRenderedFeatures(screenPoint, BUSES_LAYER_ID) - if (features.isNotEmpty()) { - val feature = features[0] + //Log.d(DEBUG_TAG, "onMapClick, stopsNearby: $stopsNearby \nstopShown: $shownStopInBottomSheet \nbusNearby: $busNearby,") + + if (stopsNearby.isNotEmpty()) { + val feature = stopsNearby[0] val id = feature.getStringProperty("id") - val name = feature.getStringProperty("name") - //Toast.makeText(requireContext(), "Clicked on $name ($id)", Toast.LENGTH_SHORT).show() val stop = viewModel.getStopByID(id) stop?.let { - if (isBottomSheetShowing() || vehShowing.isNotEmpty()){ + 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] - val vehid = feature.getStringProperty("veh") - val route = feature.getStringProperty("line") - if(isBottomSheetShowing()) - hideStopOrBusBottomSheet() - //if(context!=null){ - // Toast.makeText(context, "Veh $vehid on route ${route.slice(0..route.length-2)}", Toast.LENGTH_SHORT).show() - //} - showVehicleTripInBottomSheet(vehid) - updatesByVehDict[vehid]?.let { - //if (it.posUpdate.latitude != null && it.longitude != null) - mapReady.animateCamera( - CameraUpdateFactory.newLatLng(LatLng(it.posUpdate.latitude, it.posUpdate.longitude)), - 750 - ) - } + 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() + mapReady.cameraPosition = savedCameraPosition ?:CameraPosition.Builder().target(latlngTarget).zoom(zoom).build() savedCameraPosition = null if(shouldMapLocationBeReactivated) setMapUserLocationEnabled(true, false, false) } override fun showOpenStopWithSymbolLayer(): Boolean { return true } - private fun observeBusPositionUpdates(){ - + /** + * 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 -> + 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) { - Toast.makeText(context, R.string.showing_same_direction, Toast.LENGTH_SHORT).show() + //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 currentPatterns) { + 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 currentPatterns) { + 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}") } - - stopIDFromToShow = "" + // 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(patterns) + 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 } - patternShown = patternWithStops viewModel.requestStopsForPatternWithStops(patternWithStops) } private fun showPattern(patternWs: MatoPatternWithStops){ //Log.d(DEBUG_TAG, "Finding pattern to show: ${patternWs.pattern.code}") var pos = -2 val code = patternWs.pattern.code.trim() for (k in currentPatterns.indices) { if (currentPatterns[k].pattern.code.trim() == code) { pos = k break } } Log.d(DEBUG_TAG, "Requesting stops fro pattern $code in position: $pos") + // 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!!") - //request pattern stops from DB - //setPatternAndReqStops(patternWs) } /** * 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.get()){ //set the runnable and do nothing else Log.d(DEBUG_TAG, "Delaying pattern display to when map is Ready: ${patternWs.pattern.code}") toRunWhenMapReady = Runnable { displayPatternWithStopsOnMap(patternWs, stopsToSort, zoomToPattern) } return } Log.d(DEBUG_TAG, "Got the stops: ${stopsToSort.map { s->s.gtfsID }}}") patternShown = patternWs //Problem: stops are not sorted val stopOrderD = patternWs.stopsIndices.withIndex().associate{it.value.stopGtfsId to it.index} val stopsSorted = stopsToSort.sortedBy { s-> stopOrderD[s.gtfsID] } val pattern = patternWs.pattern val pointsList = PolylineParser.decodePolyline(pattern.patternGeometryPoly, pattern.patternGeometryLength) val pointsToShow = pointsList.map { Point.fromLngLat(it.longitude, it.latitude) } Log.d(DEBUG_TAG, "The polyline has ${pointsToShow.size} points to display") polyline = LineString.fromLngLats(pointsToShow) val lineFeature = Feature.fromGeometry(polyline) //Log.d(DEBUG_TAG, "Polyline in JSON is: ${lineFeature.toJson()}") // --- STOPS--- val features = ArrayList() for (s in stopsSorted){ if (s.latitude!=null && s.longitude!=null) { val loc = if (showOnTopOfLine) findOptimalPosition(s, pointsList) else LatLng(s.latitude!!, s.longitude!!) features.add( Feature.fromGeometry( Point.fromLngLat(loc.longitude, loc.latitude), JsonObject().apply { addProperty("id", s.ID) addProperty("name", s.stopDefaultName) //addProperty("routes", s.routesThatStopHereToString()) // Add routes array to JSON object } ) ) } } // -- ARROWS -- //val splitPolyline = MapLibreUtils.splitPolyWhenDistanceTooBig(pointsList, 200.0) val arrowFeatures = ArrayList() val pointsIndexToShowIcon = MapLibreUtils.findPointsToPutDirectionMarkers(pointsList, stopsSorted, 750.0) for (idx in pointsIndexToShowIcon){ val pnow = pointsList[idx] val otherp = if(idx>1) pointsList[idx-1] else pointsList[idx+1] val bearing = if (idx>1) MapLibreUtils.getBearing(pointsList[idx-1], pnow) else MapLibreUtils.getBearing(pnow, pointsList[idx+1]) arrowFeatures.add(Feature.fromGeometry( Point.fromLngLat((pnow.longitude+otherp.longitude)/2, (pnow.latitude+otherp.latitude)/2 ), //average JsonObject().apply { addProperty("bearing", bearing) } )) } Log.d(DEBUG_TAG,"Have put ${features.size} stops to display") // if the layer is already started, substitute the stops inside, otherwise start it if (stopsLayerStarted) { stopsSource.setGeoJson(FeatureCollection.fromFeatures(features)) polylineSource.setGeoJson(lineFeature) polyArrowSource.setGeoJson(FeatureCollection.fromFeatures(arrowFeatures)) lastStopsSizeShown = features.size } else map?.let { Log.d(DEBUG_TAG, "Map stop layer is not started yet, init layer") 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() - //POINTS LIST IS NOT IN ORDER ANY MORE - //if(!map.overlayManager.contains(stopsOverlay)){ - // map.overlayManager.add(stopsOverlay) - //} - if(zoomToPattern) zoomToCurrentPattern() } private fun initializeRecyclerView(){ val llManager = LinearLayoutManager(context) llManager.orientation = LinearLayoutManager.VERTICAL stopsRecyclerView.layoutManager = llManager } private fun showStopsInRecyclerView(stops: List){ Log.d(DEBUG_TAG, "Setting stops from: "+viewModel.currentPatternStops.value) val orderBy = viewModel.currentPatternStops.value!!.withIndex().associate{it.value.stopGtfsId to it.index} val stopsSorted = stops.sortedBy { s -> orderBy[s.gtfsID] } val numStops = stopsSorted.size Log.d(DEBUG_TAG, "RecyclerView adapter is: ${stopsRecyclerView.adapter}") val setNewAdapter = true if(setNewAdapter){ stopsRecyclerView.adapter = StopRecyclerAdapter( stopsSorted, stopAdapterListener, StopRecyclerAdapter.Use.LINES, NameCapitalize.FIRST ) } } /** * This method fixes the display of the pattern, to be used when clicking on a bus */ private fun showPatternWithCode(patternId: String){ //var index = 0 Log.d(DEBUG_TAG, "Showing pattern with code $patternId ") for (i in currentPatterns.indices){ val pattStop = currentPatterns[i] if(pattStop.pattern.code == patternId){ Log.d(DEBUG_TAG, "Pattern found in position $i") //setPatternAndReqStops(pattStop) patternsSpinner.setSelection(i) break } } } override fun onResume() { super.onResume() Log.d(DEBUG_TAG, "Resetting paused from onResume") pausedFragment = false val keySourcePositions = getString(R.string.pref_positions_source) usingMQTTPositions = PreferenceManager.getDefaultSharedPreferences(requireContext()) .getString(keySourcePositions, "mqtt").contentEquals("mqtt") //separate paths if(usingMQTTPositions) livePositionsViewModel.requestMatoPosUpdates(GtfsUtils.getLineNameFromGtfsID(lineID)) else livePositionsViewModel.requestGTFSUpdates() //initialize GUI here fragmentListener?.readyGUIfor(FragmentKind.LINES) } override fun onPause() { super.onPause() mapView.onPause() if(usingMQTTPositions) livePositionsViewModel.stopMatoUpdates() pausedFragment = true //save map map?.let{ //if map is initialized mapStateViewModel.saveMapState(it) } mapStateViewModel.lastOpenStopID.postValue(shownStopInBottomSheet?.ID) } override fun onStop() { super.onStop() mapView.onStop() shouldMapLocationBeReactivated = locationComponent.isLocationComponentEnabled } override fun onDestroyView() { map?.run { Log.d(DEBUG_TAG, "Saving camera position") savedCameraPosition = cameraPosition } super.onDestroyView() Log.d(DEBUG_TAG, "Destroying the views") /*mapStyle.removeLayer(STOPS_LAYER_ID) mapStyle?.removeSource(STOPS_SOURCE_ID) mapStyle.removeLayer(POLYLINE_LAYER) mapStyle.removeSource(POLYLINE_SOURCE) */ //stopsLayerStarted = false } override fun onMapDestroy() { mapStyle.removeLayer(STOPS_LAYER_ID) mapStyle.removeSource(STOPS_SOURCE_ID) mapStyle.removeLayer(POLYLINE_LAYER) mapStyle.removeSource(POLYLINE_SOURCE) mapStyle.removeLayer(BUSES_LAYER_ID) mapStyle.removeSource(BUSES_SOURCE_ID) map?.locationComponent?.isLocationComponentEnabled = false } override fun getBaseViewForSnackBar(): View? { return null } companion object { private const val LINEID_KEY="lineID" private const val STOPID_FROM_KEY="stopID" private const val 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/LinesGridShowingFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/LinesGridShowingFragment.kt index a30662a..8799cc4 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/LinesGridShowingFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/LinesGridShowingFragment.kt @@ -1,430 +1,445 @@ package it.reyboz.bustorino.fragments import android.content.Context import android.os.Bundle import android.util.Log import android.view.* import android.view.animation.Animation import android.view.animation.LinearInterpolator import android.view.animation.RotateAnimation import android.widget.ImageView import android.widget.TextView import androidx.appcompat.widget.SearchView import androidx.core.view.MenuHost import androidx.core.view.MenuProvider import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.recyclerview.widget.RecyclerView import androidx.work.WorkInfo import androidx.work.WorkManager +import com.google.android.flexbox.FlexDirection +import com.google.android.flexbox.FlexboxLayoutManager +import com.google.android.flexbox.JustifyContent import it.reyboz.bustorino.R import it.reyboz.bustorino.adapters.RouteAdapter import it.reyboz.bustorino.adapters.RouteOnlyLineAdapter import it.reyboz.bustorino.adapters.StringListAdapter import it.reyboz.bustorino.backend.utils import it.reyboz.bustorino.data.DBUpdateWorker import it.reyboz.bustorino.data.PreferencesHolder import it.reyboz.bustorino.data.gtfs.GtfsRoute import it.reyboz.bustorino.middleware.AutoFitGridLayoutManager import it.reyboz.bustorino.util.LinesNameSorter import it.reyboz.bustorino.util.ViewUtils import it.reyboz.bustorino.viewmodels.LinesGridShowingViewModel - class LinesGridShowingFragment : ScreenBaseFragment() { private val viewModel: LinesGridShowingViewModel by viewModels() //private lateinit var gridLayoutManager: AutoFitGridLayoutManager private lateinit var favoritesRecyclerView: RecyclerView private lateinit var urbanRecyclerView: RecyclerView private lateinit var extraurbanRecyclerView: RecyclerView private lateinit var touristRecyclerView: RecyclerView private lateinit var favoritesTitle: TextView private lateinit var urbanLinesTitle: TextView private lateinit var extrurbanLinesTitle: TextView private lateinit var touristLinesTitle: TextView private lateinit var updateMessageTextView: TextView //private lateinit var searchBar: SearchView private var routesByAgency = HashMap>() /*hashMapOf( AG_URBAN to ArrayList(), AG_EXTRAURB to ArrayList(), AG_TOUR to ArrayList() )*/ private lateinit var fragmentListener: CommonFragmentListener private val linesNameSorter = LinesNameSorter() private val linesComparator = Comparator { a,b -> return@Comparator linesNameSorter.compare(a.shortName, b.shortName) } + private val linesPriorityComparator = Comparator> { pa, pb -> + if (pa.second != pb.second){ + return@Comparator pa.second - pb.second + } else{ + return@Comparator linesNameSorter.compare(pa.first.shortName, pb.first.shortName) + } + } private val routeClickListener = RouteAdapter.ItemClicker { fragmentListener.openLineFromStop(it.gtfsId, null) } private val arrows = HashMap() private val durations = HashMap() //private val recyclerViewAdapters= HashMap() private val lastQueryEmptyForAgency = HashMap(3) private var openRecyclerView = "AG_URBAN" + private fun getFlexLayoutManager(context: Context): FlexboxLayoutManager{ + val layoutManager = FlexboxLayoutManager(context) + layoutManager.flexDirection = FlexDirection.ROW + layoutManager.justifyContent = JustifyContent.FLEX_START + + return layoutManager + } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { val rootView = inflater.inflate(R.layout.fragment_lines_grid, container, false) favoritesRecyclerView = rootView.findViewById(R.id.favoritesRecyclerView) urbanRecyclerView = rootView.findViewById(R.id.urbanLinesRecyclerView) extraurbanRecyclerView = rootView.findViewById(R.id.extraurbanLinesRecyclerView) touristRecyclerView = rootView.findViewById(R.id.touristLinesRecyclerView) updateMessageTextView = rootView.findViewById(R.id.updateMessageTextView) favoritesTitle = rootView.findViewById(R.id.favoritesTitleView) urbanLinesTitle = rootView.findViewById(R.id.urbanLinesTitleView) extrurbanLinesTitle = rootView.findViewById(R.id.extraurbanLinesTitleView) touristLinesTitle = rootView.findViewById(R.id.touristLinesTitleView) arrows[AG_URBAN] = rootView.findViewById(R.id.arrowUrb) arrows[AG_TOUR] = rootView.findViewById(R.id.arrowTourist) arrows[AG_EXTRAURB] = rootView.findViewById(R.id.arrowExtraurban) arrows[AG_FAV] = rootView.findViewById(R.id.arrowFavorites) //show urban expanded by default val recViews = listOf(urbanRecyclerView, extraurbanRecyclerView, touristRecyclerView) for (recyView in recViews) { val gridLayoutManager = AutoFitGridLayoutManager( requireContext().applicationContext, (utils.convertDipToPixels(context, COLUMN_WIDTH_DP.toFloat())).toInt() ) recyView.layoutManager = gridLayoutManager } //init favorites recyclerview - val gridLayoutManager = AutoFitGridLayoutManager( - requireContext().applicationContext, - (utils.convertDipToPixels(context, 70f)).toInt() - ) - favoritesRecyclerView.layoutManager = gridLayoutManager + favoritesRecyclerView.layoutManager = getFlexLayoutManager(requireContext()) + + viewModel.getLinesLiveData().observe(viewLifecycleOwner){ rL -> - viewModel.getLinesLiveData().observe(viewLifecycleOwner){ - //routesList = ArrayList(it) - //routesList.sortWith(linesComparator) routesByAgency.clear() for (k in AGENCIES){ routesByAgency[k] = ArrayList() } - - for(route in it){ + val routesPrioByAg = HashMap>>() + for (ag in AGENCIES){ + routesPrioByAg[ag] = ArrayList() + } + for(p in rL){ + val route = p.first val agency = route.agencyID if(agency !in routesByAgency.keys){ - Log.e(DEBUG_TAG, "The agency $agency is not present in the predefined agencies (${routesByAgency.keys})") + Log.e(DEBUG_TAG, "The agency $agency for route ${p.first.gtfsId} is not in the predefined agencies (${routesByAgency.keys})") } routesByAgency[agency]?.add(route) + routesPrioByAg[agency]?.add(p) // I would print a debug here, but it's the same as above } //zip agencies and recyclerviews - Companion.AGENCIES.zip(recViews) { ag, recView -> - routesByAgency[ag]?.let { routeList -> - if (routeList.size > 0) { - routeList.sortWith(linesComparator) - //val adapter = RouteOnlyLineAdapter(it.map { rt -> rt.shortName }) - val adapter = RouteAdapter(routeList, routeClickListener) + AGENCIES.zip(recViews) { ag, recView -> + routesPrioByAg[ag]?.let { routePrioList -> + if (routePrioList.isNotEmpty()) { + routePrioList.sortWith(linesPriorityComparator) + val adapter = RouteAdapter(routePrioList.map { it.first }, routeClickListener) val lastQueryEmpty = if(ag in lastQueryEmptyForAgency.keys) lastQueryEmptyForAgency[ag]!! else true if (lastQueryEmpty) recView.adapter = adapter else recView.swapAdapter(adapter, false) lastQueryEmptyForAgency[ag] = false } else { val messageString = if(viewModel.getLineQueryValue().isNotEmpty()) getString(R.string.no_lines_found_query) else getString(R.string.no_lines_found) val extraAdapter = StringListAdapter(listOf(messageString)) recView.adapter = extraAdapter lastQueryEmptyForAgency[ag] = true } - durations[ag] = if(routeList.size < 20) ViewUtils.DEF_DURATION else 1000 + durations[ag] = if(routePrioList.size < 20) ViewUtils.DEF_DURATION else 1000 } } } viewModel.favoritesLines.observe(viewLifecycleOwner){ routes-> val routesNames = routes.map { it.shortName } //create new item click listener every time val adapter = RouteOnlyLineAdapter(routesNames){ pos, _ -> val r = routes[pos] fragmentListener.openLineFromStop(r.gtfsId, null) } favoritesRecyclerView.adapter = adapter } //onClicks urbanLinesTitle.setOnClickListener { openLinesAndCloseOthersIfNeeded(AG_URBAN) } extrurbanLinesTitle.setOnClickListener { openLinesAndCloseOthersIfNeeded(AG_EXTRAURB) } touristLinesTitle.setOnClickListener { openLinesAndCloseOthersIfNeeded(AG_TOUR) } favoritesTitle.setOnClickListener { closeOpenFavorites() } arrows[AG_FAV]?.setOnClickListener { closeOpenFavorites() } //arrows onClicks for(k in Companion.AGENCIES){ //k is either AG_TOUR, AG_EXTRAURBAN, AG_URBAN arrows[k]?.setOnClickListener { openLinesAndCloseOthersIfNeeded(k) } } // watch for the db update WorkManager.getInstance(requireContext()).getWorkInfosForUniqueWorkLiveData(DBUpdateWorker.DEBUG_TAG).observe(viewLifecycleOwner){ workInfoList -> if (workInfoList == null || workInfoList.isEmpty()) { return@observe } var showProgress = false for (workInfo in workInfoList) { if (workInfo.state == WorkInfo.State.RUNNING) { updateMessageTextView.visibility = View.VISIBLE } else{ updateMessageTextView.visibility = View.GONE } break } } return rootView } fun setUserSearch(textSearch:String){ viewModel.setLineQuery(textSearch) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val menuHost: MenuHost = requireActivity() // Add menu items without using the Fragment Menu APIs // Note how we can tie the MenuProvider to the viewLifecycleOwner // and an optional Lifecycle.State (here, RESUMED) to indicate when // the menu should be visible menuHost.addMenuProvider(object : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { // Add menu items here menuInflater.inflate(R.menu.menu_search, menu) val search = menu.findItem(R.id.searchMenuItem).actionView as SearchView search.setOnQueryTextListener(object : SearchView.OnQueryTextListener{ override fun onQueryTextSubmit(query: String?): Boolean { setUserSearch(query ?: "") return true } override fun onQueryTextChange(query: String?): Boolean { setUserSearch(query ?: "") return true } }) search.queryHint = getString(R.string.search_box_lines_suggestion_filter) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { // Handle the menu selection if (menuItem.itemId == R.id.searchMenuItem){ Log.d(DEBUG_TAG, "Clicked on search menu") } else{ Log.d(DEBUG_TAG, "Clicked on something else") } return false } }, viewLifecycleOwner, Lifecycle.State.RESUMED) } private fun closeOpenFavorites(){ if(favoritesRecyclerView.visibility == View.VISIBLE){ //close it favoritesRecyclerView.visibility = View.GONE setOpen(arrows[AG_FAV]!!, false) viewModel.favoritesExpanded.value = false } else{ favoritesRecyclerView.visibility = View.VISIBLE setOpen(arrows[AG_FAV]!!, true) viewModel.favoritesExpanded.value = true } } private fun openLinesAndCloseOthersIfNeeded(agency: String){ if(openRecyclerView!="" && openRecyclerView!= agency) { switchRecyclerViewStatus(openRecyclerView) } switchRecyclerViewStatus(agency) } private fun switchRecyclerViewStatus(agency: String){ val recyclerView = when(agency){ AG_TOUR -> touristRecyclerView AG_EXTRAURB -> extraurbanRecyclerView AG_URBAN -> urbanRecyclerView else -> throw IllegalArgumentException("$DEBUG_TAG: Agency Invalid") } val expandedLiveData = when(agency){ AG_TOUR -> viewModel.isTouristExpanded AG_URBAN -> viewModel.isUrbanExpanded AG_EXTRAURB -> viewModel.isExtraUrbanExpanded else -> throw IllegalArgumentException("$DEBUG_TAG: Agency Invalid") } val duration = durations[agency] val arrow = arrows[agency] val durArrow = if(duration == null || duration==ViewUtils.DEF_DURATION) 500 else duration if(duration!=null&&arrow!=null) when (recyclerView.visibility){ View.GONE -> { Log.d(DEBUG_TAG, "Open recyclerview $agency") //val a =ViewUtils.expand(recyclerView, duration, 0) recyclerView.visibility = View.VISIBLE expandedLiveData.value = true Log.d(DEBUG_TAG, "Arrow for $agency has rotation: ${arrow.rotation}") setOpen(arrow, true) //arrow.startAnimation(rotateArrow(true,durArrow)) openRecyclerView = agency } View.VISIBLE -> { Log.d(DEBUG_TAG, "Close recyclerview $agency") //ViewUtils.collapse(recyclerView, duration) recyclerView.visibility = View.GONE expandedLiveData.value = false //arrow.rotation = 90f Log.d(DEBUG_TAG, "Arrow for $agency has rotation ${arrow.rotation} pre-rotate") setOpen(arrow, false) //arrow.startAnimation(rotateArrow(false,durArrow)) openRecyclerView = "" } View.INVISIBLE -> { TODO() } } } override fun onAttach(context: Context) { super.onAttach(context) if(context is CommonFragmentListener){ fragmentListener = context } else throw RuntimeException("$context must implement CommonFragmentListener") } override fun getBaseViewForSnackBar(): View? { return null } override fun onResume() { super.onResume() val pref = PreferencesHolder.getMainSharedPreferences(requireContext()) val res = pref.getStringSet(PreferencesHolder.PREF_FAVORITE_LINES, HashSet()) res?.let { viewModel.setFavoritesLinesIDs(HashSet(it))} //restore state viewModel.favoritesExpanded.value?.let { if(!it){ //close it favoritesRecyclerView.visibility = View.GONE setOpen(arrows[AG_FAV]!!, false) } else{ favoritesRecyclerView.visibility = View.VISIBLE setOpen(arrows[AG_FAV]!!, true) } } viewModel.isUrbanExpanded.value?.let { if(it) { urbanRecyclerView.visibility = View.VISIBLE arrows[AG_URBAN]?.rotation= 90f openRecyclerView = AG_URBAN Log.d(DEBUG_TAG, "RecyclerView gtt:U is expanded") } else { urbanRecyclerView.visibility = View.GONE arrows[AG_URBAN]?.rotation= 0f } } viewModel.isTouristExpanded.value?.let { val recview = touristRecyclerView if(it) { recview.visibility = View.VISIBLE arrows[AG_TOUR]?.rotation=90f openRecyclerView = AG_TOUR } else { recview.visibility = View.GONE arrows[AG_TOUR]?.rotation= 0f } } viewModel.isExtraUrbanExpanded.value?.let { val recview = extraurbanRecyclerView if(it) { openRecyclerView = AG_EXTRAURB recview.visibility = View.VISIBLE arrows[AG_EXTRAURB]?.rotation=90f } else { recview.visibility = View.GONE arrows[AG_EXTRAURB]?.rotation=0f } } fragmentListener.readyGUIfor(FragmentKind.LINES) } companion object { - private const val COLUMN_WIDTH_DP=200 + private const val COLUMN_WIDTH_DP=250 private const val AG_FAV = "fav" private const val AG_URBAN = "gtt:U" private const val AG_EXTRAURB ="gtt:E" private const val AG_TOUR ="gtt:T" private const val DEBUG_TAG ="BusTO-LinesGridFragment" const val FRAGMENT_TAG = "LinesGridShowingFragment" private val AGENCIES = listOf(AG_URBAN, AG_EXTRAURB, AG_TOUR) fun newInstance() = LinesGridShowingFragment() @JvmStatic fun setOpen(imageView: ImageView, value: Boolean){ if(value) imageView.rotation = 90f else imageView.rotation = 0f } @JvmStatic fun rotateArrow(toOpen: Boolean, duration: Long): RotateAnimation{ val start = if (toOpen) 0f else 90f val stop = if(toOpen) 90f else 0f Log.d(DEBUG_TAG, "Rotate arrow from $start to $stop") val rotate = RotateAnimation(start, stop, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f) rotate.duration = duration rotate.interpolator = LinearInterpolator() //rotate.fillAfter = true rotate.fillBefore = false return rotate } } override fun showSnackbarOnDBUpdate(): Boolean { return false } } \ 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 b1ec456..5cb180f 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt @@ -1,785 +1,800 @@ package it.reyboz.bustorino.fragments import android.Manifest import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList import android.location.Location import android.location.LocationListener import android.location.LocationManager import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ImageButton import android.widget.RelativeLayout import android.widget.Toast import it.reyboz.bustorino.backend.FiveTNormalizer import it.reyboz.bustorino.backend.gtfs.GtfsUtils import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat import androidx.core.view.ViewCompat 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.MapLibreStyles import it.reyboz.bustorino.util.Permissions 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.modes.CameraMode 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 // TODO: Rename parameter arguments, choose names that match // the fragment initialization parameters, e.g. ARG_ITEM_NUMBER private const val STOP_TO_SHOW = "stoptoshow" /** * A simple [Fragment] subclass. * Use the [MapLibreFragment.newInstance] factory method to * create an instance of this fragment. */ class MapLibreFragment : GeneralMapLibreFragment() { 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 mapInitCompleted =false private var stopsRedrawnTimes = 0 //bottom Sheet behavior in GeneralMapLibreFragment //private var stopActiveSymbol: Symbol? = null // Location stuff private lateinit var locationManager: LocationManager private lateinit var showUserPositionButton: ImageButton private lateinit var centerUserButton: ImageButton private lateinit var followUserButton: ImageButton private var followingUserLocation = false private var pendingLocationActivation = false private var ignoreCameraMovementForFollowing = true private var enablingPositionFromClick = false private var restoredMapCamera = AtomicBoolean() private var permissionsGranted = false //TODO: Rewrite this mess using LocationEngineProvider in MapLibre private val positionRequestLauncher = registerForActivityResult, Map>( ActivityResultContracts.RequestMultiplePermissions(), ActivityResultCallback { result -> if (result == null) { Log.w(DEBUG_TAG, "Got asked permission but request is null, doing nothing?") }else if(!pendingLocationActivation){ /// SHOULD DO NOTHING HERE Log.d(DEBUG_TAG, "Requested location but now there is no pendingLocationActivation") } else if (java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_COARSE_LOCATION] && java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_FINE_LOCATION]) { // We can use the position, restart location overlay permissionsGranted = true if (context == null || requireContext().getSystemService(Context.LOCATION_SERVICE) == null) return@ActivityResultCallback val locationManager = requireContext().getSystemService(Context.LOCATION_SERVICE) as LocationManager var lastLoc = stopsViewModel.lastUserLocation @SuppressLint("MissingPermission") if(lastLoc==null) lastLoc = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) else Log.d(DEBUG_TAG, "Got last location from cache") //FIRST CASE: I have no GPS if( !locationManager.allProviders.contains(LocationManager.GPS_PROVIDER) ){ setMapLocationEnabled(false, false,false) } else if (lastLoc != null) { if(LatLng(lastLoc.latitude, lastLoc.longitude).distanceTo(DEFAULT_LATLNG) <= MAX_DIST_KM*1000){ Log.d(DEBUG_TAG, "Showing the user position") setMapLocationEnabled(true, true, false) } else{ setMapLocationEnabled(false, false,false) context?.let{Toast.makeText(it,R.string.too_far_not_showing_location, Toast.LENGTH_SHORT).show()} } } else requestInitialUserLocation() } else{ Toast.makeText(requireContext(),R.string.location_disabled, Toast.LENGTH_SHORT).show() Log.w(DEBUG_TAG, "No location permission") } }) private val showUserPositionRequestLauncher = registerForActivityResult, Map>( ActivityResultContracts.RequestMultiplePermissions(), ActivityResultCallback { result -> if (result == null) { Log.w(DEBUG_TAG, "Got asked permission but request is null, doing nothing?") } else if (java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_COARSE_LOCATION] && java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_FINE_LOCATION]) { // We can use the position, restart location overlay if (context == null || requireContext().getSystemService(Context.LOCATION_SERVICE) == null) return@ActivityResultCallback ///@registerForActivityResult setMapLocationEnabled(true, true, enablingPositionFromClick) } else Log.w(DEBUG_TAG, "No location permission") }) //BUS POSITIONS private var usingMQTTPositions = true // THIS IS INSIDE VIEW MODEL NOW private val 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) showUserPositionButton = rootView.findViewById(R.id.locationEnableIcon) showUserPositionButton.setOnClickListener(this::switchUserLocationStatus) followUserButton = rootView.findViewById(R.id.followUserImageButton) centerUserButton = rootView.findViewById(R.id.centerMapImageButton) busPositionsIconButton = rootView.findViewById(R.id.busPositionsImageButton) busPositionsIconButton.setOnClickListener { LivePositionsDialogFragment().show(parentFragmentManager, "LivePositionsDialog") } bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet) bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN arrivalsCard.setOnClickListener { if(context!=null){ Toast.makeText(context,"ARRIVALS", Toast.LENGTH_SHORT).show() } } centerUserButton.setOnClickListener { if(context!=null && locationComponent.isLocationComponentEnabled) { val location = locationComponent.lastKnownLocation location?.let { mapView.getMapAsync { map -> map.animateCamera(CameraUpdateFactory.newCameraPosition( CameraPosition.Builder().target(LatLng(location.latitude, location.longitude)).build()), 500) } } } } followUserButton.setOnClickListener { // onClick user following button if(context!=null && locationComponent.isLocationComponentEnabled){ if(followingUserLocation) locationComponent.cameraMode = CameraMode.NONE else locationComponent.cameraMode = CameraMode.TRACKING // CameraMode.TRACKING makes the camera move and jump to the location setFollowingUser(!followingUserLocation) } } locationManager = requireActivity().getSystemService(Context.LOCATION_SERVICE) as LocationManager if (Permissions.bothLocationPermissionsGranted(requireContext()) && deviceHasGpsProvider()) { requestInitialUserLocation() } else{ if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)) { //TODO: show dialog for permission rationale Toast.makeText(activity, R.string.enable_position_message_map, Toast.LENGTH_SHORT) .show() } // PERMISSIONS REQUESTED AFTER MAP SETUP } // Setup close button rootView.findViewById(R.id.btnClose).setOnClickListener { hideStopOrBusBottomSheet() } observeStatusLivePositions() //observe change in source of the live positions livePositionsViewModel.useMQTTPositionsLiveData.observe(viewLifecycleOwner){ useMQTT-> //Log.d(DEBUG_TAG, "Changed MQTT positions, now have to use MQTT: $useMQTT") if (showBusLayer && isResumed) { //Log.d(DEBUG_TAG, "Deciding to switch, the current source is using MQTT: $usingMQTTPositions") if(useMQTT!=usingMQTTPositions){ // we have to switch val clearPos = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("positions_clear_on_switch_pref", true) livePositionsViewModel.clearOldPositionsUpdates() if(useMQTT){ //switching to MQTT, the GTFS positions are disabled automatically livePositionsViewModel.requestMatoPosUpdates(MQTTMatoClient.LINES_ALL) } else{ //switching to GTFS RT: stop Mato, launch first request livePositionsViewModel.stopMatoUpdates() livePositionsViewModel.requestGTFSUpdates() } Log.d(DEBUG_TAG, "Should clear positions: $clearPos") if (clearPos) { livePositionsViewModel.clearAllPositions() //force clear of the viewed data if(vehShowing.isNotEmpty()) hideStopOrBusBottomSheet() clearAllBusPositionsInMap() } } } usingMQTTPositions = useMQTT } Log.d(DEBUG_TAG, "Fragment View Created!") //TODO: Reshow last open stop when switching back to the map fragment return rootView } /** * This method sets up the map and the layers */ override fun onMapReady(mapReady: MapLibreMap) { this.map = mapReady val context = requireContext() val mjson = MapLibreStyles.getJsonStyleFromAsset(context, PreferencesHolder.getMapLibreStyleFile(context)) val builder = Style.Builder().fromJson(mjson!!) mapReady.setStyle(builder) { style -> mapStyle = style //setupLayers(style) addImagesStyle(style) initMapUserLocation(style, mapReady, requireContext()) //init stop layer with this val stopsInCache = stopsViewModel.getAllStopsLoaded() if(stopsInCache.isEmpty()) initStopsLayer(style, null) else displayStops(stopsInCache) if(showBusLayer) setupBusLayer(style, withLabels = true, busIconsScale = 1.2f) // Start observing data now that everything is set up observeStops() } mapReady.addOnCameraIdleListener { isUserMovingCamera = false map?.let { val newBbox = it.projection.visibleRegion.latLngBounds if ((newBbox.center==lastBBox.center) && (newBbox.latitudeSpan==lastBBox.latitudeSpan) && (newBbox.longitudeSpan==lastBBox.latitudeSpan)){ //do nothing } else { stopsViewModel.loadStopsInLatLngBounds(newBbox) lastBBox = newBbox } } } mapReady.addOnCameraMoveStartedListener { v-> if(v== MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE){ //the user is moving the map isUserMovingCamera = true } } mapReady.addOnMapClickListener { point -> onMapClickReact(point) } mapInitCompleted = true // we start requesting the bus positions now observeBusPositionUpdates() //Restoring data if (initialStopToShow!=null){ val s = initialStopToShow!! - mapReady.cameraPosition = CameraPosition.Builder().target( - LatLng(s.latitude!!, s.longitude!!) - ).zoom(DEFAULT_ZOOM).build() + 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){ mapReady.cameraPosition = CameraPosition.Builder().target( LatLng(DEFAULT_CENTER_LAT, DEFAULT_CENTER_LON) ).zoom(DEFAULT_ZOOM).build() } restoredMapCamera.set(boundsRestored) } pendingLocationActivation = true positionRequestLauncher.launch(Permissions.LOCATION_PERMISSIONS) } private fun onMapClickReact(point: LatLng): Boolean{ map?.let { mapReady -> val screenPoint = mapReady.projection.toScreenLocation(point) val stopsFeatures = mapReady.queryRenderedFeatures(screenPoint, STOPS_LAYER_ID) val busNearby = mapReady.queryRenderedFeatures(screenPoint, BUSES_LAYER_ID) Log.d(DEBUG_TAG, "Clicked on stops: $stopsFeatures \n and buses: $busNearby") if (stopsFeatures.isNotEmpty()) { val feature = stopsFeatures[0] val id = feature.getStringProperty("id") val name = feature.getStringProperty("name") //Toast.makeText(requireContext(), "Clicked on $name ($id)", Toast.LENGTH_SHORT).show() val stop = stopsViewModel.getStopByID(id) Log.d(DEBUG_TAG, "Decided click is on stop with id $id : $stop") stop?.let { newstop -> val sameStopClicked = shownStopInBottomSheet?.let { newstop.ID==it.ID } ?: false Log.d(DEBUG_TAG, "Hiding clicked stop: $sameStopClicked") if (isBottomSheetShowing()) { hideStopOrBusBottomSheet() } if(!sameStopClicked){ openStopInBottomSheet(newstop) //isBottomSheetShowing = true //move camera if (newstop.latitude != null && newstop.longitude != null) //mapReady.cameraPosition = CameraPosition.Builder().target(LatLng(it.latitude!!, it.longitude!!)).build() mapReady.animateCamera( CameraUpdateFactory.newLatLng(LatLng(newstop.latitude!!, newstop.longitude!!)), 750 ) } } return true } else if (busNearby.isNotEmpty()) { val feature = busNearby[0] val vehid = feature.getStringProperty("veh") if (isBottomSheetShowing()) hideStopOrBusBottomSheet() showVehicleTripInBottomSheet(vehid) //move camera to center on vehicle updatesByVehDict[vehid]?.let { dat -> mapReady.animateCamera( CameraUpdateFactory.newLatLng(LatLng(dat.posUpdate.latitude, dat.posUpdate.longitude)), 750 ) } return true } } return false } override fun showOpenStopWithSymbolLayer(): Boolean { return false } + override fun hideStopOrBusBottomSheet(){ + if (shownStopInBottomSheet?.ID == initialStopToShow?.ID){ + initialStopToShow = null + } + super.hideStopOrBusBottomSheet() + } override fun onAttach(context: Context) { super.onAttach(context) fragmentListener = if (context is CommonFragmentListener) { context } else { throw RuntimeException( context.toString() + " must implement FragmentListenerMain" ) } } override fun onDetach() { super.onDetach() fragmentListener = null } override fun onStart() { super.onStart() } override fun onResume() { super.onResume() //mapView.onResume() handled in GeneralMapLibreFragment if(showBusLayer) { //first, clean up all the old positions livePositionsViewModel.clearOldPositionsUpdates() if (livePositionsViewModel.useMQTTPositionsLiveData.value!!){ livePositionsViewModel.requestMatoPosUpdates(MQTTMatoClient.LINES_ALL) usingMQTTPositions = true } else { livePositionsViewModel.requestGTFSUpdates() usingMQTTPositions = false } livePositionsViewModel.isLastWorkResultGood.observe(this) { d: Boolean -> Log.d( DEBUG_TAG, "Last trip download result is $d" ) } livePositionsViewModel.tripsGtfsIDsToQuery.observe(this) { dat: List -> Log.i(DEBUG_TAG, "Have these trips IDs missing from the DB, to be queried: $dat") livePositionsViewModel.downloadTripsFromMato(dat) } } fragmentListener?.readyGUIfor(FragmentKind.MAP) } override fun onPause() { super.onPause() mapView.onPause() Log.d(DEBUG_TAG, "Fragment paused") map?.let{ //if map is initialized mapStateViewModel.saveMapState(it) } mapStateViewModel.lastOpenStopID.postValue(shownStopInBottomSheet?.ID) if (livePositionsViewModel.useMQTTPositionsLiveData.value!!) livePositionsViewModel.stopMatoUpdates() } override fun onStop() { super.onStop() mapView.onStop() Log.d(DEBUG_TAG, "Fragment stopped!") /* stopsViewModel.savedState = Bundle().let { mapView.onSaveInstanceState(it) it } */ //save last location map?.locationComponent?.lastKnownLocation?.let{ stopsViewModel.lastUserLocation = it } } override fun onMapDestroy() { mapStyle.removeLayer(STOPS_LAYER_ID) mapStyle.removeSource(STOPS_SOURCE_ID) mapStyle.removeLayer(BUSES_LAYER_ID) mapStyle.removeSource(BUSES_SOURCE_ID) //map?.locationComponent?.isLocationComponentEnabled = false } override fun getBaseViewForSnackBar(): View? { return mapView } private fun showVehicleTripInBottomSheet(veh: String) { val data = updatesByVehDict[veh] ?: return - super.showVehicleTripInBottomSheet(veh) { patternCode -> + 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 })) { - openStopInBottomSheet(s) + val stopToShow = stopsShowing.first { it.ID == s.ID } + openStopInBottomSheet(stopToShow) initialStopShown = true } } } } /** * Add the stops to the layers */ private fun displayStops(stops: List?) { if (stops.isNullOrEmpty()) return if (stops.size==lastStopsSizeShown){ Log.d(DEBUG_TAG, "Not updating, have same number of stops. After 3 times") return } /*if(stops.size> lastStopsSizeShown){ stopsRedrawnTimes = 0 } else{ stopsRedrawnTimes++ } */ val features = ArrayList()//stops.mapNotNull { stop -> //stop.latitude?.let { lat -> // stop.longitude?.let { lon -> for (s in stops){ if (s.latitude!=null && s.longitude!=null) features.add(stopToGeoJsonFeature(s)) } Log.d(DEBUG_TAG,"Have put ${features.size} stops to display") // if the layer is already started, substitute the stops inside, otherwise start it if (stopsLayerStarted) { stopsSource.setGeoJson(FeatureCollection.fromFeatures(features)) lastStopsSizeShown = features.size } else map?.let { Log.d(DEBUG_TAG, "Map stop layer is not started yet, init layer") initStopsLayer(mapStyle, FeatureCollection.fromFeatures(features)) Log.d(DEBUG_TAG,"Started stops layer on map") lastStopsSizeShown = features.size stopsLayerStarted = true } } // --------------- BUS LOCATIONS STUFF -------------------------- /** * Start requesting position updates */ private fun observeBusPositionUpdates() { livePositionsViewModel.updatesWithTripAndPatterns.observe(viewLifecycleOwner) { data: HashMap> -> Log.d( DEBUG_TAG, "Have " + data.size + " trip updates, has Map start finished: " + mapInitCompleted ) if (mapInitCompleted) updateBusPositionsInMap(data, hasVehicleTracking = true) { veh -> showVehicleTripInBottomSheet(veh) } if (!isDetached && !livePositionsViewModel.useMQTTPositionsLiveData.value!!) livePositionsViewModel.requestDelayedGTFSUpdates( 3000 ) } } // ------ LOCATION STUFF ----- @SuppressLint("MissingPermission") private fun requestInitialUserLocation() { val provider : String = LocationManager.GPS_PROVIDER//getBestLocationProvider() //provider.let { setLocationIconEnabled(true) Toast.makeText(requireContext(), R.string.position_searching_message, Toast.LENGTH_SHORT).show() locationManager.requestSingleUpdate(provider, object : LocationListener { override fun onLocationChanged(location: Location) { val userLatLng = LatLng(location.latitude, location.longitude) val distanceToTarget = userLatLng.distanceTo(DEFAULT_LATLNG) if (distanceToTarget <= MAX_DIST_KM*1000.0) { map?.let{ // if we are still waiting for the position to enable if(pendingLocationActivation) setMapLocationEnabled(true, true, false) } } else { Toast.makeText(context, R.string.too_far_not_showing_location, Toast.LENGTH_SHORT).show() setMapLocationEnabled(false,false, false) } } override fun onProviderDisabled(provider: String) {} override fun onProviderEnabled(provider: String) {} @Deprecated("Deprecated in Java") override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} }, null) } /** * Handles logic of enabling the user location on the map */ @SuppressLint("MissingPermission") private fun setMapLocationEnabled(enabled: Boolean, assumePermissions: Boolean, fromClick: Boolean) { if (enabled) { val permissionOk = assumePermissions || Permissions.bothLocationPermissionsGranted(requireContext()) if (permissionOk) { Log.d(DEBUG_TAG, "Permission OK, starting location component, assumed: $assumePermissions, fromClick: $fromClick") locationComponent.isLocationComponentEnabled = true if (!restoredMapCamera.get()) { locationComponent.cameraMode = CameraMode.TRACKING //CameraMode.TRACKING setFollowingUser(true) } setLocationIconEnabled(true) if (fromClick) Toast.makeText(context, R.string.location_enabled, Toast.LENGTH_SHORT).show() pendingLocationActivation =false //locationComponent.locationEngine.requestLocationUpdates() } else { if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)) { //TODO: show dialog for permission rationale Toast.makeText(activity, R.string.enable_position_message_map, Toast.LENGTH_SHORT).show() } Log.d(DEBUG_TAG, "Requesting permission to show user location") showUserPositionRequestLauncher.launch(Permissions.LOCATION_PERMISSIONS) } } else{ locationComponent.isLocationComponentEnabled = false setFollowingUser(false) setLocationIconEnabled(false) if (fromClick) { Toast.makeText(requireContext(), R.string.location_disabled, Toast.LENGTH_SHORT).show() if(pendingLocationActivation) pendingLocationActivation=false //Cancel the request for the enablement of the position } } } private fun setLocationIconEnabled(enabled: Boolean){ if (enabled) showUserPositionButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_red)) else showUserPositionButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_grey)) } private fun updateFollowingIcon(enabled: Boolean){ if(enabled) followUserButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.walk_circle_active)) else followUserButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.walk_circle_inactive)) } private fun setFollowingUser(following: Boolean){ updateFollowingIcon(following) followingUserLocation = following if(following) ignoreCameraMovementForFollowing = true } /** * Method used for enabling / disabling the location */ private fun switchUserLocationStatus(view: View?){ if(pendingLocationActivation || locationComponent.isLocationComponentEnabled) setMapLocationEnabled(false, false, true) else{ if(locationManager.allProviders.contains(LocationManager.GPS_PROVIDER)) { pendingLocationActivation = true Log.d(DEBUG_TAG, "Request enable location") setMapLocationEnabled(true, false, true) } else{ Log.w(DEBUG_TAG, "Cannot find location, no GPS") } } } 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 MAX_DIST_KM = 90.0 private const val DEBUG_TAG = "BusTO-MapLibreFrag" private const val STOP_ACTIVE_IMG = "Stop-active" const val FRAGMENT_TAG = "BusTOMapFragment" private const val LOCATION_PERMISSION_REQUEST_CODE = 981202 /** * Use this factory method to create a new instance of * this fragment using the provided parameters. * * @param stop Eventual stop to center the map into * @return A new instance of fragment MapLibreFragment. */ @JvmStatic fun newInstance(stop: Stop?) = MapLibreFragment().apply { arguments = Bundle().let { // Cannot use Parcelable as it requires higher version of Android //stop?.let{putParcelable(STOP_TO_SHOW, it)} stop?.toBundle(it) } } } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/ResultListFragment.java b/app/src/main/java/it/reyboz/bustorino/fragments/ResultListFragment.java index 216464c..5c79087 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/ResultListFragment.java +++ b/app/src/main/java/it/reyboz/bustorino/fragments/ResultListFragment.java @@ -1,297 +1,296 @@ /* BusTO - Fragments components Copyright (C) 2016 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.sqlite.SQLiteDatabase; import android.os.Bundle; import android.os.Parcelable; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.*; -import com.google.android.material.floatingactionbutton.FloatingActionButton; import it.reyboz.bustorino.R; import it.reyboz.bustorino.backend.FiveTNormalizer; import it.reyboz.bustorino.backend.Palina; import it.reyboz.bustorino.backend.Route; import it.reyboz.bustorino.backend.Stop; import it.reyboz.bustorino.data.UserDB; /** * This is a generalized fragment that can be used both for * * */ public class ResultListFragment extends Fragment{ // the fragment initialization parameters, e.g. ARG_ITEM_NUMBER static final String LIST_TYPE = "list-type"; protected static final String LIST_STATE = "list_state"; protected static final String MESSAGE_TEXT_VIEW = "message_text_view"; private FragmentKind adapterKind; protected FragmentListenerMain mListener; protected TextView messageTextView; protected ListView resultsListView; private ListAdapter mListAdapter = null; boolean listShown; private Parcelable mListInstanceState = null; public ResultListFragment() { // Required empty public constructor } public ListView getResultsListView() { return resultsListView; } /** * Use this factory method to create a new instance of * this fragment using the provided parameters. * * @param listType whether the list is used for STOPS or LINES (Orari) * @return A new instance of fragment ResultListFragment. */ public static ResultListFragment newInstance(FragmentKind listType, String eventualStopTitle) { ResultListFragment fragment = new ResultListFragment(); Bundle args = new Bundle(); args.putSerializable(LIST_TYPE, listType); if (eventualStopTitle != null) { args.putString(ArrivalsFragment.STOP_TITLE, eventualStopTitle); } fragment.setArguments(args); return fragment; } public static ResultListFragment newInstance(FragmentKind listType) { return newInstance(listType, null); } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (getArguments() != null) { adapterKind = (FragmentKind) getArguments().getSerializable(LIST_TYPE); } } /** * Check if the last Bus Stop is in the favorites * @return true if it iss */ public boolean isStopInFavorites(String busStopId) { boolean found = false; // no stop no party if(busStopId != null) { SQLiteDatabase userDB = new UserDB(getContext()).getReadableDatabase(); found = UserDB.isStopInFavorites(userDB, busStopId); } return found; } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View root = inflater.inflate(R.layout.fragment_list_view, container, false); messageTextView = (TextView) root.findViewById(R.id.messageTextView); if (adapterKind != null) { resultsListView = (ListView) root.findViewById(R.id.resultsListView); switch (adapterKind) { case STOPS: resultsListView.setOnItemClickListener(new AdapterView.OnItemClickListener() { @Override public void onItemClick(AdapterView parent, View view, int position, long id) { /* * Casting because of Javamerda * @url http://stackoverflow.com/questions/30549485/androids-list-view-parameterized-type-in-adapterview-onitemclicklistener */ Stop busStop = (Stop) parent.getItemAtPosition(position); mListener.requestArrivalsForStopID(busStop.ID); } }); // set the textviewMessage setTextViewMessage(getString(R.string.results)); break; case ARRIVALS: resultsListView.setOnItemClickListener(new AdapterView.OnItemClickListener() { @Override public void onItemClick(AdapterView parent, View view, int position, long id) { String routeName; Route r = (Route) parent.getItemAtPosition(position); routeName = FiveTNormalizer.routeInternalToDisplay(r.getName()); if (routeName == null) { routeName = r.getDisplayCode(); } if (r.destinazione == null || r.destinazione.length() == 0) { Toast.makeText(getContext(), getString(R.string.route_towards_unknown, routeName), Toast.LENGTH_SHORT).show(); } else { Toast.makeText(getContext(), getString(R.string.route_towards_destination, routeName, r.destinazione), Toast.LENGTH_SHORT).show(); } } }); String displayName = getArguments().getString(ArrivalsFragment.STOP_TITLE); setTextViewMessage(String.format( - getString(R.string.passages), displayName)); + getString(R.string.passages_fill), displayName)); break; default: throw new IllegalStateException("Argument passed was not of a supported type"); } String probablemessage = getArguments().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); } } else Log.d(getString(R.string.list_fragment_debug), "No content root for fragment"); return root; } public boolean isFragmentForTheSameStop(Palina p) { if (!adapterKind.equals(FragmentKind.ARRIVALS)) return false; if (getTag() != null) return getTag().equals(getFragmentTag(p)); else return false; } public static String getFragmentTag(Palina p) { return "palina_"+p.ID; } @Override public void onResume() { super.onResume(); //Log.d(getString(R.string.list_fragment_debug),"Fragment restored, saved listAdapter is "+(mListAdapter)); if (mListAdapter != null) { ListAdapter adapter = mListAdapter; mListAdapter = null; resetListAdapter(adapter); } if (mListInstanceState != null) { Log.d("resultsListView", "trying to restore instance state"); resultsListView.onRestoreInstanceState(mListInstanceState); } switch (adapterKind) { case ARRIVALS: resultsListView.setOnScrollListener(new CommonScrollListener(mListener, true)); mListener.showFloatingActionButton(true); break; case STOPS: resultsListView.setOnScrollListener(new CommonScrollListener(mListener, false)); break; default: //NONE } mListener.readyGUIfor(adapterKind); } @Override public void onPause() { if (adapterKind.equals(FragmentKind.ARRIVALS)) { SwipeRefreshLayout reflay = getActivity().findViewById(R.id.listRefreshLayout); reflay.setEnabled(false); Log.d("BusTO Fragment " + this.getTag(), "RefreshLayout disabled"); } super.onPause(); } @Override public void onAttach(@NonNull Context context) { super.onAttach(context); if (context instanceof FragmentListenerMain) { mListener = (FragmentListenerMain) context; } else { throw new RuntimeException(context.toString() + " must implement ResultFragmentListener"); } } @Override public void onDetach() { mListener.showFloatingActionButton(false); mListener = null; super.onDetach(); } @Override public void onDestroyView() { resultsListView = null; //Log.d(getString(R.string.list_fragment_debug), "called onDestroyView"); getArguments().putString(MESSAGE_TEXT_VIEW, messageTextView.getText().toString()); super.onDestroyView(); } @Override public void onViewStateRestored(@Nullable Bundle savedInstanceState) { super.onViewStateRestored(savedInstanceState); Log.d("ResultListFragment", "onViewStateRestored"); if (savedInstanceState != null) { mListInstanceState = savedInstanceState.getParcelable(LIST_STATE); Log.d("ResultListFragment", "listInstanceStatePresent :" + mListInstanceState); } } protected void resetListAdapter(ListAdapter adapter) { boolean hadAdapter = mListAdapter != null; mListAdapter = adapter; if (resultsListView != null) { resultsListView.setAdapter(adapter); resultsListView.setVisibility(View.VISIBLE); } } /** * Set the message textView * @param message the whole message to write in the textView */ public void setTextViewMessage(String message) { messageTextView.setText(message); messageTextView.setVisibility(View.VISIBLE); } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/ScreenBaseFragment.java b/app/src/main/java/it/reyboz/bustorino/fragments/ScreenBaseFragment.java index bb14687..5647038 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/ScreenBaseFragment.java +++ b/app/src/main/java/it/reyboz/bustorino/fragments/ScreenBaseFragment.java @@ -1,107 +1,105 @@ package it.reyboz.bustorino.fragments; import android.Manifest; import android.content.Context; import android.content.SharedPreferences; -import android.os.Bundle; -import android.view.Gravity; import android.view.View; import android.view.inputmethod.InputMethodManager; import android.widget.Toast; import androidx.activity.result.ActivityResultCallback; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import com.google.android.material.snackbar.Snackbar; import it.reyboz.bustorino.BuildConfig; import java.util.Map; import static android.content.Context.MODE_PRIVATE; public abstract class ScreenBaseFragment extends Fragment { protected final static String PREF_FILE= BuildConfig.APPLICATION_ID+".fragment_prefs"; protected void setOption(String optionName, boolean value) { Context mContext = getContext(); SharedPreferences.Editor editor = mContext.getSharedPreferences(PREF_FILE, MODE_PRIVATE).edit(); editor.putBoolean(optionName, value); editor.commit(); } protected boolean getOption(String optionName, boolean optDefault) { Context mContext = getContext(); assert mContext != null; return getOption(mContext, optionName, optDefault); } protected void showToastMessage(int messageID, boolean short_lenght) { final int length = short_lenght ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG; final Context context = getContext(); if(context!=null) Toast.makeText(context, messageID, length).show(); } public void hideKeyboard() { if (getActivity()==null) return; View view = getActivity().getCurrentFocus(); if (view != null) { ((InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE)) .hideSoftInputFromWindow(view.getWindowToken(), InputMethodManager.HIDE_NOT_ALWAYS); } } /** * Find the view on which the snackbar should be shown * @return a view or null if you don't want the snackbar shown */ @Nullable public abstract View getBaseViewForSnackBar(); /** * Empty method to override properties of the Snackbar before showing it * @param snackbar the Snackbar to be possibly modified */ public void setSnackbarPropertiesBeforeShowing(Snackbar snackbar){ } public boolean showSnackbarOnDBUpdate() { return true; } public static boolean getOption(Context context, String optionName, boolean optDefault){ SharedPreferences preferences = context.getSharedPreferences(PREF_FILE, MODE_PRIVATE); return preferences.getBoolean(optionName, optDefault); } public static void setOption(Context context,String optionName, boolean value) { SharedPreferences.Editor editor = context.getSharedPreferences(PREF_FILE, MODE_PRIVATE).edit(); editor.putBoolean(optionName, value); editor.apply(); } public ActivityResultLauncher getPositionRequestLauncher(LocationRequestListener listener){ return registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), new ActivityResultCallback<>() { @Override public void onActivityResult(Map result) { if (result == null) return; if (result.get(Manifest.permission.ACCESS_COARSE_LOCATION) == null || result.get(Manifest.permission.ACCESS_FINE_LOCATION) == null) return; final boolean coarseGranted = Boolean.TRUE.equals(result.get(Manifest.permission.ACCESS_COARSE_LOCATION)); final boolean fineGranted = Boolean.TRUE.equals(result.get(Manifest.permission.ACCESS_FINE_LOCATION)); listener.onPermissionResult(coarseGranted, fineGranted); } }); } public interface LocationRequestListener{ void onPermissionResult(boolean isCoarseGranted, boolean isFineGranted); } } diff --git a/app/src/main/java/it/reyboz/bustorino/viewmodels/ArrivalsViewModel.kt b/app/src/main/java/it/reyboz/bustorino/viewmodels/ArrivalsViewModel.kt index 43f407f..20d73f5 100644 --- a/app/src/main/java/it/reyboz/bustorino/viewmodels/ArrivalsViewModel.kt +++ b/app/src/main/java/it/reyboz/bustorino/viewmodels/ArrivalsViewModel.kt @@ -1,184 +1,225 @@ package it.reyboz.bustorino.viewmodels import android.app.Application import android.content.Context import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import it.reyboz.bustorino.backend.* import it.reyboz.bustorino.backend.mato.MatoAPIFetcher import it.reyboz.bustorino.data.NextGenDB +import it.reyboz.bustorino.data.OldDataRepository import it.reyboz.bustorino.middleware.RecursionHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicReference class ArrivalsViewModel(application: Application): AndroidViewModel(application) { // Arrivals of palina val appContext: Context - init { - appContext = application.applicationContext - } val palinaLiveData = MediatorLiveData() val sourcesLiveData = MediatorLiveData() val resultLiveData = MediatorLiveData() val currentFetchers = MediatorLiveData>() + /// OLD REPO for stops instance + private val executor = Executors.newFixedThreadPool(2) + private val oldRepo = OldDataRepository(executor, NextGenDB.getInstance(application)) + + private var stopIdRequested = "" + private val stopFromDB = MutableLiveData() + private val oldRepoStopCallback = OldDataRepository.Callback>{ stopListRes -> + if(stopIdRequested.isEmpty()) return@Callback + + if(stopListRes.isSuccess) { + val stopF = stopListRes.result!!.filter { s -> s.ID == stopIdRequested } + if (stopF.isEmpty()) { + Log.w(DEBUG_TAG, "Requested stop $stopIdRequested but is not in the list from database: ${stopListRes.result}") + } else{ + stopFromDB.postValue(stopF[0]) + Log.d(DEBUG_TAG, "Setting new stop ${stopF[0]} from database") + } + } else{ + Log.e(DEBUG_TAG, "Requested stop ${stopIdRequested} from database but error occured: ${stopListRes.exception}") + } + } + + init { + appContext = application.applicationContext + palinaLiveData.addSource(stopFromDB){ + s -> + val hasSource = palinaLiveData.value?.passaggiSourceIfAny + Log.d(DEBUG_TAG, "Have current palina ${palinaLiveData.value!=null}, source passaggi $hasSource, new incoming stop $s from database") + val newp = Palina.mergePaline(palinaLiveData.value, Palina(s)) + newp?.let { palinaLiveData.value = it } + } + } + + fun requestArrivalsForStop(stopId: String, fetchers: List){ val context = appContext //application.applicationContext currentFetchers.value = fetchers + //request stop from the DB + stopIdRequested = stopId + oldRepo.requestStopsWithGtfsIDs(listOf("gtt:$stopId"), oldRepoStopCallback) + viewModelScope.launch(Dispatchers.IO){ runArrivalsFetching(stopId, fetchers, context) } } fun requestArrivalsForStop(stopId: String, fetchersSources: Array){ val fetchers = constructFetchersFromStrList(fetchersSources) requestArrivalsForStop(stopId, fetchers) } private suspend fun runArrivalsFetching(stopId: String, fetchers: List, appContext: Context) { if (fetchers.isEmpty()) { //do nothing return } // Equivalente del doInBackground nell'AsyncTask val recursionHelper = RecursionHelper(fetchers.toTypedArray()) var resultPalina : Palina? = null val stringBuilder = StringBuilder() for (f in fetchers) { stringBuilder.append("") stringBuilder.append(f.javaClass.simpleName) stringBuilder.append("; ") } Log.d(DEBUG_TAG, "Using fetchers: $stringBuilder") val resultRef = AtomicReference() while (recursionHelper.valid()) { val fetcher = recursionHelper.getAndMoveForward() sourcesLiveData.postValue(fetcher.sourceForFetcher) if (fetcher is MatoAPIFetcher) { fetcher.appContext = appContext } Log.d(DEBUG_TAG, "Using the ArrivalsFetcher: ${fetcher.javaClass}") // Verifica se è un fetcher per MetroStop da saltare try { if (fetcher is FiveTAPIFetcher && stopId.toInt() >= 8200) { continue } } catch (ex: NumberFormatException) { Log.e(DEBUG_TAG, "The stop number is not a valid integer, expect failures") } // Legge i tempi di arrivo val palina = fetcher.ReadArrivalTimesAll(stopId, resultRef) Log.d(DEBUG_TAG, "Arrivals fetcher: $fetcher\n\tProgress: ${resultRef.get()}") // Gestione del FiveTAPIFetcher per ottenere le direzioni if (fetcher is FiveTAPIFetcher) { val branchResultRef = AtomicReference() val branches = fetcher.getDirectionsForStop(stopId, branchResultRef) Log.d(DEBUG_TAG, "FiveTArrivals fetcher: $fetcher\n\tDetails req: ${branchResultRef.get()}") if (branchResultRef.get() == Fetcher.Result.OK) { palina.addInfoFromRoutes(branches) // Inserisce i dati nel database viewModelScope.launch(Dispatchers.IO) { //modify the DB in another coroutine in the background NextGenDB.insertBranchesIntoDB(appContext,branches) } } else { resultRef.set(Fetcher.Result.NOT_FOUND) } } // Unisce percorsi duplicati palina.mergeDuplicateRoutes(0) if (resultRef.get() == Fetcher.Result.OK && palina.getTotalNumberOfPassages() == 0) { resultRef.set(Fetcher.Result.EMPTY_RESULT_SET) Log.d(DEBUG_TAG, "Setting empty results") } //reportProgress resultLiveData.postValue(resultRef.get()) // Se è un MatoAPIFetcher con risultati validi, salviamo i dati if (resultPalina == null && fetcher is MatoAPIFetcher && palina.queryAllRoutes().size > 0) { resultPalina = palina } // Se abbiamo un risultato OK, restituiamo la palina if (resultRef.get() == Fetcher.Result.OK) { - //set data - resultLiveData.postValue(Fetcher.Result.OK) - palinaLiveData.postValue(palina) + setResultAndPalinaFromFetchers(palina, Fetcher.Result.OK) //TODO: Rotate the fetchers appropriately return } //end Fetchers loop } // Se arriviamo qui, tutti i fetcher hanno fallito //failedAll = true // Se abbiamo comunque una palina, la restituiamo - if (resultPalina != null) { - resultLiveData.postValue(resultRef.get()) - palinaLiveData.postValue(resultPalina) + resultPalina?.let { + setResultAndPalinaFromFetchers(it, resultRef.get()) } } + private fun setResultAndPalinaFromFetchers(palina: Palina, fetcherResult: Fetcher.Result) { + resultLiveData.postValue(fetcherResult) + Log.d(DEBUG_TAG, "Have new result palina for stop ${palina.ID}, source ${palina.passaggiSourceIfAny} has coords: ${palina.hasCoords()}") + Log.d(DEBUG_TAG, "Old palina liveData is: ${palinaLiveData.value?.stopDisplayName}, has Coords ${palinaLiveData.value?.hasCoords()}") + palinaLiveData.postValue(Palina.mergePaline(palina, palinaLiveData.value)) + } companion object{ const val DEBUG_TAG="BusTO-ArrivalsViMo" @JvmStatic fun getFetcherFromStrSource(src:String): ArrivalsFetcher?{ val srcEnum = Passaggio.Source.valueOf(src) val fe: ArrivalsFetcher? = when(srcEnum){ Passaggio.Source.FiveTAPI -> FiveTAPIFetcher() Passaggio.Source.GTTJSON -> GTTJSONFetcher() Passaggio.Source.FiveTScraper -> FiveTScraperFetcher() Passaggio.Source.MatoAPI -> MatoAPIFetcher() Passaggio.Source.UNDETERMINED -> null null -> null } return fe } @JvmStatic fun constructFetchersFromStrList(sources: Array): List{ val fetchers = mutableListOf() for(s in sources){ val fe = getFetcherFromStrSource(s) if(fe!=null){ fetchers.add(fe) } else{ Log.d(DEBUG_TAG, "Cannot convert fetcher source $s to a fetcher") } } return fetchers } } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/viewmodels/LinesGridShowingViewModel.kt b/app/src/main/java/it/reyboz/bustorino/viewmodels/LinesGridShowingViewModel.kt index 21eab54..150dd9f 100644 --- a/app/src/main/java/it/reyboz/bustorino/viewmodels/LinesGridShowingViewModel.kt +++ b/app/src/main/java/it/reyboz/bustorino/viewmodels/LinesGridShowingViewModel.kt @@ -1,83 +1,95 @@ package it.reyboz.bustorino.viewmodels import android.app.Application import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.map import it.reyboz.bustorino.data.GtfsRepository import it.reyboz.bustorino.data.gtfs.GtfsDatabase import it.reyboz.bustorino.data.gtfs.GtfsRoute import it.reyboz.bustorino.util.LinesNameSorter class LinesGridShowingViewModel(application: Application) : AndroidViewModel(application) { private val linesNameSorter = LinesNameSorter() private val linesComparator = Comparator { a,b -> return@Comparator linesNameSorter.compare(a.shortName, b.shortName) } private val gtfsRepo: GtfsRepository private val routesLiveData: LiveData> //= gtfsRepo.getAllRoutes() val isUrbanExpanded = MutableLiveData(true) val isExtraUrbanExpanded = MutableLiveData(false) val isTouristExpanded = MutableLiveData(false) val favoritesExpanded = MutableLiveData(true) val favoritesLinesIDs = MutableLiveData>() private val queryLiveData = MutableLiveData("") fun setLineQuery(query: String){ if(query!=queryLiveData.value) queryLiveData.value = query } fun getLineQueryValue():String{ return queryLiveData.value ?: "" } - private val filteredLinesLiveData = MediatorLiveData>() - fun getLinesLiveData(): LiveData> { - return filteredLinesLiveData - } + private val filteredLinesLiveData = MediatorLiveData>>() + fun getLinesLiveData() = filteredLinesLiveData - private fun filterLinesForQuery(lines: List, query: String): List{ - val result= lines.filter { r-> query.lowercase() in r.shortName.lowercase() } + private fun filterLinesForQuery(lines: List, query: String): ArrayList>{ + var result= lines.filter { r-> query.lowercase() in r.shortName.lowercase() } + //EXCLUDE gtt:F - ferrovie (luckily, gtt does not run rail service anymore) + result = result.filter { r -> r.agencyID != "gtt:F" } - return result + val out = ArrayList>() + for (r in result){ + out.add(Pair(r,1)) + } + // add those matching the query in the description + for (r: GtfsRoute in lines) { + if (query.lowercase() in r.description.lowercase()) { + if (r !in result){ + out.add(Pair(r,2)) + } + } + } + return out } init { val gtfsDao = GtfsDatabase.getGtfsDatabase(application).gtfsDao() gtfsRepo = GtfsRepository(gtfsDao) routesLiveData = gtfsRepo.getAllRoutes() filteredLinesLiveData.addSource(routesLiveData){ filteredLinesLiveData.value = filterLinesForQuery(it,queryLiveData.value ?: "" ) } filteredLinesLiveData.addSource(queryLiveData){ routesLiveData.value?.let { routes -> filteredLinesLiveData.value = filterLinesForQuery(routes, it) } } } fun setFavoritesLinesIDs(linesIds: HashSet){ favoritesLinesIDs.value = linesIds } val favoritesLines = favoritesLinesIDs.map {lineIds -> val linesList = ArrayList() if (lineIds.size == 0 || routesLiveData.value==null) return@map linesList for(line in routesLiveData.value!!){ if(lineIds.contains(line.gtfsId)) linesList.add(line) } linesList.sortWith(linesComparator) return@map linesList } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_star_filled.xml b/app/src/main/res/drawable/ic_star_filled.xml index 2a9554e..349b77f 100644 --- a/app/src/main/res/drawable/ic_star_filled.xml +++ b/app/src/main/res/drawable/ic_star_filled.xml @@ -1,9 +1,9 @@ diff --git a/app/src/main/res/drawable/ic_star_outline.xml b/app/src/main/res/drawable/ic_star_outline.xml index dc062f0..a70ae94 100644 --- a/app/src/main/res/drawable/ic_star_outline.xml +++ b/app/src/main/res/drawable/ic_star_outline.xml @@ -1,9 +1,9 @@ diff --git a/app/src/main/res/drawable/road_map_line.xml b/app/src/main/res/drawable/road_map_line.xml new file mode 100644 index 0000000..4c3c6f9 --- /dev/null +++ b/app/src/main/res/drawable/road_map_line.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/layout/entry_bus_line_passage.xml b/app/src/main/res/layout/entry_bus_line_passage.xml index befd454..b829ed9 100644 --- a/app/src/main/res/layout/entry_bus_line_passage.xml +++ b/app/src/main/res/layout/entry_bus_line_passage.xml @@ -1,79 +1,82 @@ \ No newline at end of file diff --git a/app/src/main/res/layout/entry_line_num_descr.xml b/app/src/main/res/layout/entry_line_name_description.xml similarity index 81% rename from app/src/main/res/layout/entry_line_num_descr.xml rename to app/src/main/res/layout/entry_line_name_description.xml index e7d4338..6c056b2 100644 --- a/app/src/main/res/layout/entry_line_num_descr.xml +++ b/app/src/main/res/layout/entry_line_name_description.xml @@ -1,65 +1,70 @@ - \ 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 af085a2..0feaa0e 100644 --- a/app/src/main/res/layout/fragment_arrivals.xml +++ b/app/src/main/res/layout/fragment_arrivals.xml @@ -1,207 +1,248 @@ - - - - + android:layout_height="wrap_content"> + + + + + +