diff --git a/app/src/main/java/it/reyboz/bustorino/ActivityPrincipal.java b/app/src/main/java/it/reyboz/bustorino/ActivityPrincipal.java index 7cbb465..75fe625 100644 --- a/app/src/main/java/it/reyboz/bustorino/ActivityPrincipal.java +++ b/app/src/main/java/it/reyboz/bustorino/ActivityPrincipal.java @@ -1,790 +1,797 @@ /* BusTO - Arrival times for Turin public transport. 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; import android.Manifest; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.content.res.Configuration; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBarDrawerToggle; import androidx.appcompat.widget.Toolbar; import androidx.core.view.GravityCompat; import androidx.drawerlayout.widget.DrawerLayout; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; import androidx.preference.PreferenceManager; import androidx.work.WorkInfo; import androidx.work.WorkManager; import com.google.android.material.navigation.NavigationView; import com.google.android.material.snackbar.Snackbar; import java.util.Arrays; import java.util.HashSet; import java.util.Set; import it.reyboz.bustorino.backend.Stop; import it.reyboz.bustorino.backend.utils; import it.reyboz.bustorino.data.DBUpdateWorker; import it.reyboz.bustorino.data.DatabaseUpdate; import it.reyboz.bustorino.data.PreferencesHolder; import it.reyboz.bustorino.data.gtfs.GtfsDatabase; import it.reyboz.bustorino.fragments.*; import it.reyboz.bustorino.middleware.GeneralActivity; import static it.reyboz.bustorino.backend.utils.getBusStopIDFromUri; import static it.reyboz.bustorino.backend.utils.openIceweasel; public class ActivityPrincipal extends GeneralActivity implements FragmentListenerMain { private DrawerLayout mDrawer; private NavigationView mNavView; private ActionBarDrawerToggle drawerToggle; private final static String DEBUG_TAG="BusTO Act Principal"; private final static String TAG_FAVORITES="favorites_frag"; private Snackbar snackbar; private boolean showingMainFragmentFromOther = false; private boolean onCreateComplete = false; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.d(DEBUG_TAG, "onCreate, savedInstanceState is: "+savedInstanceState); setContentView(R.layout.activity_principal); boolean showingArrivalsFromIntent = false; Toolbar mToolbar = findViewById(R.id.default_toolbar); setSupportActionBar(mToolbar); if (getSupportActionBar()!=null) getSupportActionBar().setDisplayHomeAsUpEnabled(true); else Log.w(DEBUG_TAG, "NO ACTION BAR"); mToolbar.setOnMenuItemClickListener(new ToolbarItemClickListener(this)); mDrawer = findViewById(R.id.drawer_layout); drawerToggle = setupDrawerToggle(mToolbar); // Setup toggle to display hamburger icon with nice animation drawerToggle.setDrawerIndicatorEnabled(true); drawerToggle.syncState(); mDrawer.addDrawerListener(drawerToggle); mDrawer.addDrawerListener(new DrawerLayout.DrawerListener() { @Override public void onDrawerSlide(@NonNull View drawerView, float slideOffset) { } @Override public void onDrawerOpened(@NonNull View drawerView) { hideKeyboard(); } @Override public void onDrawerClosed(@NonNull View drawerView) { } @Override public void onDrawerStateChanged(int newState) { } }); mNavView = findViewById(R.id.nvView); setupDrawerContent(mNavView); /*View header = mNavView.getHeaderView(0); */ //mNavView.getMenu().findItem(R.id.versionFooter). /// LEGACY CODE //---------------------------- START INTENT CHECK QUEUE ------------------------------------ // Intercept calls from URL intent boolean tryedFromIntent = false; String busStopID = null; Uri data = getIntent().getData(); if (data != null) { busStopID = getBusStopIDFromUri(data); Log.d(DEBUG_TAG, "Opening Intent: busStopID: "+busStopID); tryedFromIntent = true; } // Intercept calls from other activities if (!tryedFromIntent) { Bundle b = getIntent().getExtras(); if (b != null) { busStopID = b.getString("bus-stop-ID"); /* * I'm not very sure if you are coming from an Intent. * Some launchers work in strange ways. */ tryedFromIntent = busStopID != null; } } //---------------------------- END INTENT CHECK QUEUE -------------------------------------- if (busStopID == null) { // Show keyboard if can't start from intent // JUST DON'T // showKeyboard(); // You haven't obtained anything... from an intent? if (tryedFromIntent) { // This shows a luser warning Toast.makeText(getApplicationContext(), R.string.insert_bus_stop_number_error, Toast.LENGTH_SHORT).show(); } } else { // If you are here an intent has worked successfully //setBusStopSearchByIDEditText(busStopID); //Log.d(DEBUG_TAG, "Requesting arrivals for stop "+busStopID+" from intent"); requestArrivalsForStopID(busStopID); //this shows the fragment, too showingArrivalsFromIntent = true; } //database check GtfsDatabase gtfsDB = GtfsDatabase.Companion.getGtfsDatabase(this); final int db_version = gtfsDB.getOpenHelper().getReadableDatabase().getVersion(); boolean dataUpdateRequested = false; final SharedPreferences theShPr = getMainSharedPreferences(); final int old_version = PreferencesHolder.getGtfsDBVersion(theShPr); Log.d(DEBUG_TAG, "GTFS Database: old version is "+old_version+ ", new version is "+db_version); if (old_version < db_version){ //decide update conditions in the future if(old_version < 2 && db_version >= 2) { dataUpdateRequested = true; DatabaseUpdate.requestDBUpdateWithWork(this, true, true); } PreferencesHolder.setGtfsDBVersion(theShPr, db_version); } //Try (hopefully) database update if(!dataUpdateRequested) DatabaseUpdate.requestDBUpdateWithWork(this, false, false); /* Watch for database update */ final WorkManager workManager = WorkManager.getInstance(this); workManager.getWorkInfosForUniqueWorkLiveData(DBUpdateWorker.DEBUG_TAG) .observe(this, workInfoList -> { // If there are no matching work info, do nothing if (workInfoList == null || workInfoList.isEmpty()) { return; } Log.d(DEBUG_TAG, "WorkerInfo: "+workInfoList); boolean showProgress = false; for (WorkInfo workInfo : workInfoList) { if (workInfo.getState() == WorkInfo.State.RUNNING) { showProgress = true; break; } } if (showProgress) { createDefaultSnackbar(); } else { if(snackbar!=null) { snackbar.dismiss(); snackbar = null; } } }); // show the main fragment Fragment f = getSupportFragmentManager().findFragmentById(R.id.mainActContentFrame); Log.d(DEBUG_TAG, "OnCreate the fragment is "+f); String vl = PreferenceManager.getDefaultSharedPreferences(this).getString(SettingsFragment.PREF_KEY_STARTUP_SCREEN, ""); //if (vl.length() == 0 || vl.equals("arrivals")) { // showMainFragment(); Log.d(DEBUG_TAG, "The default screen to open is: "+vl); if (showingArrivalsFromIntent){ //do nothing but exclude a case }else if (savedInstanceState==null) { //we are not restarting the activity from nothing if (vl.equals("map")) { requestMapFragment(false); } else if (vl.equals("favorites")) { checkAndShowFavoritesFragment(getSupportFragmentManager(), false); } else if (vl.equals("lines")) { showLinesFragment(getSupportFragmentManager(), false, null); } else { showMainFragment(false); } } onCreateComplete = true; //last but not least, set the good default values manageDefaultValuesForSettings(); //check if first run activity (IntroActivity) has been started once or not boolean hasIntroRun = theShPr.getBoolean(PreferencesHolder.PREF_INTRO_ACTIVITY_RUN,false); if(!hasIntroRun){ startIntroductionActivity(); } } private ActionBarDrawerToggle setupDrawerToggle(Toolbar toolbar) { // NOTE: Make sure you pass in a valid toolbar reference. ActionBarDrawToggle() does not require it // and will not render the hamburger icon without it. return new ActionBarDrawerToggle(this, mDrawer, toolbar, R.string.drawer_open, R.string.drawer_close); } /** * Setup drawer actions * @param navigationView the navigation view on which to set the callbacks */ private void setupDrawerContent(NavigationView navigationView) { navigationView.setNavigationItemSelectedListener( menuItem -> { if (menuItem.getItemId() == R.id.drawer_action_settings) { Log.d("MAINBusTO", "Pressed button preferences"); closeDrawerIfOpen(); startActivity(new Intent(ActivityPrincipal.this, ActivitySettings.class)); return true; } else if(menuItem.getItemId() == R.id.nav_favorites_item){ closeDrawerIfOpen(); //get Fragment checkAndShowFavoritesFragment(getSupportFragmentManager(), true); return true; } else if(menuItem.getItemId() == R.id.nav_arrivals){ closeDrawerIfOpen(); showMainFragment(true); return true; } else if(menuItem.getItemId() == R.id.nav_map_item){ closeDrawerIfOpen(); requestMapFragment(true); return true; } else if (menuItem.getItemId() == R.id.nav_lines_item) { closeDrawerIfOpen(); showLinesFragment(getSupportFragmentManager(), true,null); return true; } else if(menuItem.getItemId() == R.id.drawer_action_info) { closeDrawerIfOpen(); startActivity(new Intent(ActivityPrincipal.this, ActivityAbout.class)); return true; } //selectDrawerItem(menuItem); Log.d(DEBUG_TAG, "pressed item "+menuItem); return true; }); } private void closeDrawerIfOpen(){ if (mDrawer.isDrawerOpen(GravityCompat.START)) mDrawer.closeDrawer(GravityCompat.START); } // `onPostCreate` called when activity start-up is complete after `onStart()` // NOTE 1: Make sure to override the method with only a single `Bundle` argument // Note 2: Make sure you implement the correct `onPostCreate(Bundle savedInstanceState)` method. // There are 2 signatures and only `onPostCreate(Bundle state)` shows the hamburger icon. @Override protected void onPostCreate(Bundle savedInstanceState) { super.onPostCreate(savedInstanceState); // Sync the toggle state after onRestoreInstanceState has occurred. drawerToggle.syncState(); } @Override public void onConfigurationChanged(@NonNull Configuration newConfig) { super.onConfigurationChanged(newConfig); // Pass any configuration change to the drawer toggles drawerToggle.onConfigurationChanged(newConfig); } @Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.principal_menu, menu); MenuItem experimentsMenuItem = menu.findItem(R.id.action_experiments); SharedPreferences shPr = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); boolean exper_On = shPr.getBoolean(getString(R.string.pref_key_experimental), false); experimentsMenuItem.setVisible(exper_On); return super.onCreateOptionsMenu(menu); } //requesting permissions @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode==STORAGE_PERMISSION_REQ){ final String storagePerm = Manifest.permission.WRITE_EXTERNAL_STORAGE; if (permissionDoneRunnables.containsKey(storagePerm)) { Runnable toRun = permissionDoneRunnables.get(storagePerm); if (toRun != null) toRun.run(); permissionDoneRunnables.remove(storagePerm); } if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { Log.d(DEBUG_TAG, "Permissions check: " + Arrays.toString(permissions)); if (permissionDoneRunnables.containsKey(storagePerm)) { Runnable toRun = permissionDoneRunnables.get(storagePerm); if (toRun != null) toRun.run(); permissionDoneRunnables.remove(storagePerm); } } else { //permission denied showToastMessage(R.string.permission_storage_maps_msg, false); } } } @Override public boolean onOptionsItemSelected(@NonNull MenuItem item) { int[] cases = {R.id.nav_arrivals, R.id.nav_favorites_item}; Log.d(DEBUG_TAG, "Item pressed"); if (item.getItemId() == android.R.id.home) { mDrawer.openDrawer(GravityCompat.START); return true; } if (drawerToggle.onOptionsItemSelected(item)) { return true; } return super.onOptionsItemSelected(item); } @Override public void onBackPressed() { boolean foundFragment = false; Fragment shownFrag = getSupportFragmentManager().findFragmentById(R.id.mainActContentFrame); if (mDrawer.isDrawerOpen(GravityCompat.START)) mDrawer.closeDrawer(GravityCompat.START); else if(shownFrag != null && shownFrag.isVisible() && shownFrag.getChildFragmentManager().getBackStackEntryCount() > 0){ //if we have been asked to show a stop from another fragment, we should go back even in the main if(shownFrag instanceof MainScreenFragment){ //we have to stop the arrivals reload ((MainScreenFragment) shownFrag).cancelReloadArrivalsIfNeeded(); } shownFrag.getChildFragmentManager().popBackStack(); if(showingMainFragmentFromOther && getSupportFragmentManager().getBackStackEntryCount() > 0){ getSupportFragmentManager().popBackStack(); Log.d(DEBUG_TAG, "Popping main back stack also"); } } else if (getSupportFragmentManager().getBackStackEntryCount() > 0) { getSupportFragmentManager().popBackStack(); Log.d(DEBUG_TAG, "Popping main frame backstack for fragments"); } else super.onBackPressed(); } /** * Create and show the SnackBar with the message */ private void createDefaultSnackbar() { View baseView = null; final Fragment frag = getSupportFragmentManager().findFragmentById(R.id.mainActContentFrame); if (frag instanceof ScreenBaseFragment){ baseView = ((ScreenBaseFragment) frag).getBaseViewForSnackBar(); } if (baseView == null) baseView = findViewById(R.id.mainActContentFrame); if (baseView == null) Log.e(DEBUG_TAG, "baseView null for default snackbar, probably exploding now"); snackbar = Snackbar.make(baseView, R.string.database_update_msg_inapp, Snackbar.LENGTH_INDEFINITE); snackbar.show(); } /** * Show the fragment by adding it to the backstack * @param fraMan the fragmentManager * @param fragment the fragment */ private static void showMainFragment(FragmentManager fraMan, MainScreenFragment fragment, boolean addToBackStack){ FragmentTransaction ft = fraMan.beginTransaction() .replace(R.id.mainActContentFrame, fragment, MainScreenFragment.FRAGMENT_TAG) .setReorderingAllowed(false) /*.setCustomAnimations( R.anim.slide_in, // enter R.anim.fade_out, // exit R.anim.fade_in, // popEnter R.anim.slide_out // popExit )*/ .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE); if (addToBackStack) ft.addToBackStack(null); ft.commit(); } /** * Show the fragment by adding it to the backstack * @param fraMan the fragmentManager * @param arguments args for the fragment */ private static void createShowMainFragment(FragmentManager fraMan,@Nullable Bundle arguments, boolean addToBackStack){ FragmentTransaction ft = fraMan.beginTransaction() .replace(R.id.mainActContentFrame, MainScreenFragment.class, arguments, MainScreenFragment.FRAGMENT_TAG) .setReorderingAllowed(false) /*.setCustomAnimations( R.anim.slide_in, // enter R.anim.fade_out, // exit R.anim.fade_in, // popEnter R.anim.slide_out // popExit )*/ .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE); if (addToBackStack) ft.addToBackStack(null); ft.commit(); } private void requestMapFragment(final boolean allowReturn){ // starting from Android 11, we don't need to have the STORAGE permission anymore for the map cache /*if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R){ //nothing to do Log.d(DEBUG_TAG, "Build codes allow the showing of the map"); createAndShowMapFragment(null, allowReturn); return; } final String permission = Manifest.permission.WRITE_EXTERNAL_STORAGE; int result = askForPermissionIfNeeded(permission, STORAGE_PERMISSION_REQ); Log.d(DEBUG_TAG, "Permission for storage: "+result); switch (result) { case PERMISSION_OK: createAndShowMapFragment(null, allowReturn); break; case PERMISSION_ASKING: permissionDoneRunnables.put(permission, () -> createAndShowMapFragment(null, allowReturn)); break; case PERMISSION_NEG_CANNOT_ASK: String storage_perm = getString(R.string.storage_permission); String text = getString(R.string.too_many_permission_asks, storage_perm); Toast.makeText(getApplicationContext(),text, Toast.LENGTH_LONG).show(); } */ //The permissions are handled in the MapLibreFragment instead createAndShowMapFragment(null, allowReturn); } private static void checkAndShowFavoritesFragment(FragmentManager fragmentManager, boolean addToBackStack){ FragmentTransaction ft = fragmentManager.beginTransaction(); Fragment fragment = fragmentManager.findFragmentByTag(TAG_FAVORITES); if(fragment!=null){ ft.replace(R.id.mainActContentFrame, fragment, TAG_FAVORITES); }else{ //use new method ft.replace(R.id.mainActContentFrame,FavoritesFragment.class,null,TAG_FAVORITES); } if (addToBackStack) ft.addToBackStack("favorites_main"); ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) .setReorderingAllowed(false); ft.commit(); } private static void showLinesFragment(@NonNull FragmentManager fragmentManager, boolean addToBackStack, @Nullable Bundle fragArgs){ FragmentTransaction ft = fragmentManager.beginTransaction(); Fragment f = fragmentManager.findFragmentByTag(LinesGridShowingFragment.FRAGMENT_TAG); if(f!=null){ ft.replace(R.id.mainActContentFrame, f, LinesGridShowingFragment.FRAGMENT_TAG); }else{ //use new method ft.replace(R.id.mainActContentFrame,LinesGridShowingFragment.class,fragArgs, LinesGridShowingFragment.FRAGMENT_TAG); } if (addToBackStack) ft.addToBackStack("linesGrid"); ft.setReorderingAllowed(true) .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) .commit(); } private void showMainFragment(boolean addToBackStack){ FragmentManager fraMan = getSupportFragmentManager(); Fragment fragment = fraMan.findFragmentByTag(MainScreenFragment.FRAGMENT_TAG); final MainScreenFragment mainScreenFragment; if (fragment==null | !(fragment instanceof MainScreenFragment)){ createShowMainFragment(fraMan, null, addToBackStack); } else if(!fragment.isVisible()){ mainScreenFragment = (MainScreenFragment) fragment; showMainFragment(fraMan, mainScreenFragment, addToBackStack); Log.d(DEBUG_TAG, "Found the main fragment"); } else{ mainScreenFragment = (MainScreenFragment) fragment; } //return mainScreenFragment; } @Nullable private MainScreenFragment getMainFragmentIfVisible(){ FragmentManager fraMan = getSupportFragmentManager(); Fragment fragment = fraMan.findFragmentByTag(MainScreenFragment.FRAGMENT_TAG); if (fragment!= null && fragment.isVisible()) return (MainScreenFragment) fragment; else return null; } @Override public void showFloatingActionButton(boolean yes) { //TODO } /* public void setDrawerSelectedItem(String fragmentTag){ switch (fragmentTag){ case MainScreenFragment.FRAGMENT_TAG: mNavView.setCheckedItem(R.id.nav_arrivals); break; case MapFragment.FRAGMENT_TAG: break; case FavoritesFragment.FRAGMENT_TAG: mNavView.setCheckedItem(R.id.nav_favorites_item); break; } }*/ @Override public void readyGUIfor(FragmentKind fragmentType) { MainScreenFragment mainFragmentIfVisible = getMainFragmentIfVisible(); if (mainFragmentIfVisible!=null){ mainFragmentIfVisible.readyGUIfor(fragmentType); } int titleResId; switch (fragmentType){ case MAP: mNavView.setCheckedItem(R.id.nav_map_item); titleResId = R.string.map; break; case FAVORITES: mNavView.setCheckedItem(R.id.nav_favorites_item); titleResId = R.string.nav_favorites_text; break; case ARRIVALS: titleResId = R.string.nav_arrivals_text; mNavView.setCheckedItem(R.id.nav_arrivals); break; case STOPS: titleResId = R.string.stop_search_view_title; mNavView.setCheckedItem(R.id.nav_arrivals); break; case MAIN_SCREEN_FRAGMENT: case NEARBY_STOPS: case NEARBY_ARRIVALS: titleResId=R.string.app_name_full; mNavView.setCheckedItem(R.id.nav_arrivals); break; case LINES: titleResId=R.string.lines; mNavView.setCheckedItem(R.id.nav_lines_item); break; default: titleResId = 0; } if(getSupportActionBar()!=null && titleResId!=0) getSupportActionBar().setTitle(titleResId); } @Override public void requestArrivalsForStopID(String ID) { //register if the request came from the main fragment or not MainScreenFragment probableFragment = getMainFragmentIfVisible(); showingMainFragmentFromOther = (probableFragment==null); if (showingMainFragmentFromOther){ FragmentManager fraMan = getSupportFragmentManager(); Fragment fragment = fraMan.findFragmentByTag(MainScreenFragment.FRAGMENT_TAG); Log.d(DEBUG_TAG, "Requested main fragment, not visible. Search by TAG returned: "+fragment); if(fragment!=null){ //the fragment is there but not shown probableFragment = (MainScreenFragment) fragment; // set the flag probableFragment.setSuppressArrivalsReload(true); showMainFragment(fraMan, probableFragment, true); probableFragment.requestArrivalsForStopID(ID); } else { // we have no fragment final Bundle args = new Bundle(); args.putString(MainScreenFragment.PENDING_STOP_SEARCH, ID); //if onCreate is complete, then we are not asking for the first showing fragment boolean addtobackstack = onCreateComplete; createShowMainFragment(fraMan, args ,addtobackstack); } } else { //the MainScreeFragment is shown, nothing to do probableFragment.requestArrivalsForStopID(ID); } mNavView.setCheckedItem(R.id.nav_arrivals); } @Override public void showLineOnMap(String routeGtfsId){ readyGUIfor(FragmentKind.LINES); FragmentTransaction tr = getSupportFragmentManager().beginTransaction(); tr.replace(R.id.mainActContentFrame, LinesDetailFragment.class, LinesDetailFragment.Companion.makeArgs(routeGtfsId)); tr.addToBackStack("LineonMap-"+routeGtfsId); tr.commit(); } @Override public void toggleSpinner(boolean state) { MainScreenFragment probableFragment = getMainFragmentIfVisible(); if (probableFragment!=null){ probableFragment.toggleSpinner(state); } } @Override public void enableRefreshLayout(boolean yes) { MainScreenFragment probableFragment = getMainFragmentIfVisible(); if (probableFragment!=null){ probableFragment.enableRefreshLayout(yes); } } @Override public void showMapCenteredOnStop(Stop stop) { createAndShowMapFragment(stop, true); } //Map Fragment stuff void createAndShowMapFragment(@Nullable Stop stop, boolean addToBackStack){ final FragmentManager fm = getSupportFragmentManager(); final FragmentTransaction ft = fm.beginTransaction(); final MapLibreFragment fragment = MapLibreFragment.Companion.newInstance(stop); ft.replace(R.id.mainActContentFrame, fragment, MapFragmentKt.FRAGMENT_TAG); if (addToBackStack) ft.addToBackStack(null); ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE); ft.commit(); } void startIntroductionActivity(){ Intent intent = new Intent(ActivityPrincipal.this, ActivityIntro.class); intent.putExtra(ActivityIntro.RESTART_MAIN, false); startActivity(intent); } class ToolbarItemClickListener implements Toolbar.OnMenuItemClickListener{ private final Context activityContext; public ToolbarItemClickListener(Context activityContext) { this.activityContext = activityContext; } @Override public boolean onMenuItemClick(MenuItem item) { final int id = item.getItemId(); if(id == R.id.action_about){ startActivity(new Intent(ActivityPrincipal.this, ActivityAbout.class)); return true; } else if (id == R.id.action_hack) { openIceweasel(getString(R.string.hack_url), activityContext); return true; } else if (id == R.id.action_source){ openIceweasel("https://gitpull.it/source/libre-busto/", activityContext); return true; } else if (id == R.id.action_licence){ openIceweasel("https://www.gnu.org/licenses/gpl-3.0.html", activityContext); return true; } else if (id == R.id.action_experiments) { startActivity(new Intent(ActivityPrincipal.this, ActivityExperiments.class)); return true; } else if (id == R.id.action_tutorial) { startIntroductionActivity(); return true; } return false; } } /** * Adjust setting to match the default ones */ private void manageDefaultValuesForSettings(){ SharedPreferences mainSharedPref = PreferenceManager.getDefaultSharedPreferences(this); SharedPreferences.Editor editor = mainSharedPref.edit(); //Main fragment to show String screen = mainSharedPref.getString(SettingsFragment.PREF_KEY_STARTUP_SCREEN, ""); boolean edit = false; if (screen.isEmpty()){ editor.putString(SettingsFragment.PREF_KEY_STARTUP_SCREEN, "arrivals"); edit=true; } //Fetchers final Set setSelected = mainSharedPref.getStringSet(SettingsFragment.KEY_ARRIVALS_FETCHERS_USE, new HashSet<>()); if (setSelected.isEmpty()){ String[] defaultVals = getResources().getStringArray(R.array.arrivals_sources_values_default); editor.putStringSet(SettingsFragment.KEY_ARRIVALS_FETCHERS_USE, utils.convertArrayToSet(defaultVals)); edit=true; } //Live bus positions final String keySourcePositions=getString(R.string.pref_positions_source); final String positionsSource = mainSharedPref.getString(keySourcePositions, ""); if(positionsSource.isEmpty()){ String[] defaultVals = getResources().getStringArray(R.array.positions_source_values); editor.putString(keySourcePositions, defaultVals[0]); edit=true; } + //Map style + final String mapStylePref = mainSharedPref.getString(SettingsFragment.LIBREMAP_STYLE_PREF_KEY, ""); + if(mapStylePref.isEmpty()){ + final String[] defaultVals = getResources().getStringArray(R.array.map_style_pref_values); + editor.putString(SettingsFragment.LIBREMAP_STYLE_PREF_KEY, defaultVals[0]); + edit=true; + } if (edit){ editor.commit(); } } } diff --git a/app/src/main/java/it/reyboz/bustorino/data/PreferencesHolder.java b/app/src/main/java/it/reyboz/bustorino/data/PreferencesHolder.java index 82c832e..d0b8c6c 100644 --- a/app/src/main/java/it/reyboz/bustorino/data/PreferencesHolder.java +++ b/app/src/main/java/it/reyboz/bustorino/data/PreferencesHolder.java @@ -1,94 +1,107 @@ /* 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; import android.content.Context; import android.content.SharedPreferences; +import androidx.annotation.NonNull; import it.reyboz.bustorino.R; import static android.content.Context.MODE_PRIVATE; import androidx.preference.PreferenceManager; +import it.reyboz.bustorino.fragments.SettingsFragment; +import it.reyboz.bustorino.map.MapLibreUtils; import java.util.HashSet; import java.util.Set; /** * Static class for commonly used SharedPreference operations */ public abstract class PreferencesHolder { public static final String PREF_GTFS_DB_VERSION = "gtfs_db_version"; public static final String PREF_INTRO_ACTIVITY_RUN ="pref_intro_activity_run"; public static final String DB_GTT_VERSION_KEY = "NextGenDB.GTTVersion"; public static final String DB_LAST_UPDATE_KEY = "NextGenDB.LastDBUpdate"; public static final String PREF_FAVORITE_LINES = "pref_favorite_lines"; public static final Set KEYS_MERGE_SET = Set.of(PREF_FAVORITE_LINES); public static final Set IGNORE_KEYS_LOAD_MAIN = Set.of(PREF_GTFS_DB_VERSION, PREF_INTRO_ACTIVITY_RUN, DB_GTT_VERSION_KEY, DB_LAST_UPDATE_KEY); public static SharedPreferences getMainSharedPreferences(Context context){ return context.getSharedPreferences(context.getString(R.string.mainSharedPreferences), MODE_PRIVATE); } public static SharedPreferences getAppPreferences(Context con){ return PreferenceManager.getDefaultSharedPreferences(con); } public static int getGtfsDBVersion(SharedPreferences pref){ return pref.getInt(PREF_GTFS_DB_VERSION,-1); } public static void setGtfsDBVersion(SharedPreferences pref,int version){ SharedPreferences.Editor ed = pref.edit(); ed.putInt(PREF_GTFS_DB_VERSION,version); ed.apply(); } /** * Check if the introduction activity has been run at least one * @param con the context needed * @return true if it has been run */ public static boolean hasIntroFinishedOneShot(Context con){ final SharedPreferences pref = getMainSharedPreferences(con); return pref.getBoolean(PREF_INTRO_ACTIVITY_RUN, false); } public static boolean addOrRemoveLineToFavorites(Context con, String gtfsLineId, boolean addToFavorites){ final SharedPreferences pref = getMainSharedPreferences(con); final HashSet favorites = new HashSet<>(pref.getStringSet(PREF_FAVORITE_LINES, new HashSet<>())); boolean modified = true; if(addToFavorites) favorites.add(gtfsLineId); else if(favorites.contains(gtfsLineId)) favorites.remove(gtfsLineId); else modified = false; // we are not changing anything if(modified) { final SharedPreferences.Editor editor = pref.edit(); editor.putStringSet(PREF_FAVORITE_LINES, favorites); editor.apply(); } return modified; } - public static HashSet getFavoritesLinesGtfsIDs(Context con){ + public static HashSet getFavoritesLinesGtfsIDs(@NonNull Context con){ final SharedPreferences pref = getMainSharedPreferences(con); return new HashSet<>(pref.getStringSet(PREF_FAVORITE_LINES, new HashSet<>())); } + + public static String getMapLibreStyleFile(Context con){ + final SharedPreferences pref = getAppPreferences(con); + final String mapStyle_val = pref.getString(SettingsFragment.LIBREMAP_STYLE_PREF_KEY, ""); + return switch (mapStyle_val) { + //MUST MATCH IN keys.xml -> map_style_pref_values + case "versatiles_c" -> MapLibreUtils.STYLE_VERSATILES_COLORFUL_JSON; + default -> MapLibreUtils.STYLE_BRIGHT_DEFAULT_JSON; //default is "bright" + }; + } } 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 0fcc0c8..e180e92 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt @@ -1,1438 +1,1438 @@ /* BusTO - Fragments components Copyright (C) 2023 Fabio Mazza This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package it.reyboz.bustorino.fragments import android.Manifest import android.animation.ObjectAnimator import android.animation.ValueAnimator import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.location.Location import android.net.Uri import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.animation.LinearInterpolator import android.widget.* import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.content.res.AppCompatResources import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat import androidx.fragment.app.viewModels import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.gson.JsonObject import it.reyboz.bustorino.R import it.reyboz.bustorino.adapters.NameCapitalize import it.reyboz.bustorino.adapters.StopAdapterListener import it.reyboz.bustorino.adapters.StopRecyclerAdapter import it.reyboz.bustorino.backend.FiveTNormalizer import it.reyboz.bustorino.backend.LivePositionTripPattern import it.reyboz.bustorino.backend.Stop import it.reyboz.bustorino.backend.gtfs.GtfsUtils import it.reyboz.bustorino.backend.gtfs.LivePositionUpdate import it.reyboz.bustorino.backend.gtfs.PolylineParser import it.reyboz.bustorino.backend.utils import it.reyboz.bustorino.data.MatoTripsDownloadWorker import it.reyboz.bustorino.data.PreferencesHolder import it.reyboz.bustorino.data.gtfs.MatoPatternWithStops import it.reyboz.bustorino.data.gtfs.TripAndPatternWithStops import it.reyboz.bustorino.map.* import it.reyboz.bustorino.map.CustomInfoWindow.TouchResponder import it.reyboz.bustorino.middleware.LocationUtils import it.reyboz.bustorino.util.Permissions import it.reyboz.bustorino.viewmodels.LinesViewModel import it.reyboz.bustorino.viewmodels.LivePositionsViewModel import org.maplibre.android.MapLibre import org.maplibre.android.camera.CameraPosition import org.maplibre.android.camera.CameraUpdateFactory import org.maplibre.android.geometry.LatLng import org.maplibre.android.geometry.LatLngBounds import org.maplibre.android.location.LocationComponent import org.maplibre.android.location.LocationComponentOptions import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.MapView import org.maplibre.android.maps.OnMapReadyCallback import org.maplibre.android.maps.Style import org.maplibre.android.plugins.annotation.Symbol import org.maplibre.android.plugins.annotation.SymbolManager import org.maplibre.android.plugins.annotation.SymbolOptions import org.maplibre.android.style.expressions.Expression import org.maplibre.android.style.layers.LineLayer import org.maplibre.android.style.layers.Property import org.maplibre.android.style.layers.Property.ICON_ANCHOR_CENTER import org.maplibre.android.style.layers.Property.ICON_ROTATION_ALIGNMENT_MAP import org.maplibre.android.style.layers.PropertyFactory import org.maplibre.android.style.layers.SymbolLayer import org.maplibre.android.style.sources.GeoJsonSource import org.maplibre.geojson.Feature import org.maplibre.geojson.FeatureCollection import org.maplibre.geojson.LineString import org.maplibre.geojson.Point class LinesDetailFragment() : ScreenBaseFragment(), OnMapReadyCallback { private var lineID = "" private lateinit var patternsSpinner: Spinner private var patternsAdapter: ArrayAdapter? = null //Bottom sheet behavior private lateinit var bottomSheetBehavior: BottomSheetBehavior private var bottomLayout: RelativeLayout? = null private lateinit var stopTitleTextView: TextView private lateinit var stopNumberTextView: TextView private lateinit var linesPassingTextView: TextView private lateinit var arrivalsCard: CardView private lateinit var directionsCard: CardView private lateinit var bottomrightImage: ImageView private var isBottomSheetShowing = false private var shouldMapLocationBeReactivated = true //private var patternsSpinnerState: Parcelable? = null private lateinit var currentPatterns: List //private lateinit var map: MapView private var patternShown: MatoPatternWithStops? = null private val viewModel: LinesViewModel by viewModels() private val mapViewModel: MapViewModel by viewModels() private var firstInit = true private var pausedFragment = false private lateinit var switchButton: ImageButton private var favoritesButton: ImageButton? = null private var locationIcon: ImageButton? = null private var isLineInFavorite = false private var appContext: Context? = null private var isLocationPermissionOK = false private val lineSharedPrefMonitor = SharedPreferences.OnSharedPreferenceChangeListener { pref, keychanged -> if(keychanged!=PreferencesHolder.PREF_FAVORITE_LINES || lineID.isEmpty()) return@OnSharedPreferenceChangeListener val newFavorites = pref.getStringSet(PreferencesHolder.PREF_FAVORITE_LINES, HashSet()) newFavorites?.let {favorites-> isLineInFavorite = favorites.contains(lineID) //if the button has been intialized, change the icon accordingly favoritesButton?.let { button-> //avoid crashes if fragment not attached if(context==null) return@let if(isLineInFavorite) { button.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_star_filled, null)) appContext?.let { Toast.makeText(it,R.string.favorites_line_add,Toast.LENGTH_SHORT).show()} } else { button.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_star_outline, null)) appContext?.let {Toast.makeText(it,R.string.favorites_line_remove,Toast.LENGTH_SHORT).show()} } } } } private lateinit var stopsRecyclerView: RecyclerView private lateinit var descripTextView: TextView //adapter for recyclerView private val stopAdapterListener= object : StopAdapterListener { override fun onTappedStop(stop: Stop?) { if(viewModel.shouldShowMessage) { Toast.makeText(context, R.string.long_press_stop_4_options, Toast.LENGTH_SHORT).show() viewModel.shouldShowMessage=false } stop?.let { fragmentListener.requestArrivalsForStopID(it.ID) } if(stop == null){ Log.e(DEBUG_TAG,"Passed wrong stop") } if(fragmentListener == null){ Log.e(DEBUG_TAG, "Fragment listener is null") } } override fun onLongPressOnStop(stop: Stop?): Boolean { TODO("Not yet implemented") } } private val patternsSorter = Comparator{ p1: MatoPatternWithStops, p2: MatoPatternWithStops -> if(p1.pattern.directionId != p2.pattern.directionId) return@Comparator p1.pattern.directionId - p2.pattern.directionId else return@Comparator -1*(p1.stopsIndices.size - p2.stopsIndices.size) } //map data private lateinit var mapView: MapView private lateinit var locationComponent: LocationComponent private lateinit var mapStyle: Style protected var map: MapLibreMap? = null private lateinit var stopsSource: GeoJsonSource private lateinit var busesSource: GeoJsonSource private lateinit var polylineSource: GeoJsonSource private var savedCameraPosition: CameraPosition? = null private var vehShowing = "" private var stopsLayerStarted = false private var lastStopsSizeShown = 0 private var lastUpdateTime:Long = -2 //BUS POSITIONS private val updatesByVehDict = HashMap(5) private val animatorsByVeh = HashMap() private var lastLocation : Location? = null private var enablingPositionFromClick = false private var polyline: LineString? = null private val showUserPositionRequestLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions(), ActivityResultCallback { result -> if (result == null) { Log.w(DEBUG_TAG, "Got asked permission but request is null, doing nothing?") } else if (java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_COARSE_LOCATION] && java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_FINE_LOCATION]) { // We can use the position, restart location overlay if (context == null || requireContext().getSystemService(Context.LOCATION_SERVICE) == null) return@ActivityResultCallback ///@registerForActivityResult setMapUserLocationEnabled(true, true, enablingPositionFromClick) } else Log.w(DEBUG_TAG, "No location permission") }) //private var stopPosList = ArrayList() //fragment actions private lateinit var fragmentListener: CommonFragmentListener private val stopTouchResponder = TouchResponder { stopID, stopName -> Log.d(DEBUG_TAG, "Asked to show arrivals for stop ID: $stopID") fragmentListener.requestArrivalsForStopID(stopID) } private var showOnTopOfLine = false private var recyclerInitDone = false private var useMQTTPositions = true //position of live markers private val tripMarkersAnimators = HashMap() private val liveBusViewModel: LivePositionsViewModel by viewModels() //extra items to use the LibreMap private lateinit var symbolManager : SymbolManager private var stopActiveSymbol: Symbol? = null private var shownStopInBottomSheet : Stop? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lineID = requireArguments().getString(LINEID_KEY,"") MapLibre.getInstance(requireContext()) } @SuppressLint("SetTextI18n") override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { //reset statuses isBottomSheetShowing = false //stopsLayerStarted = false lastStopsSizeShown = 0 val rootView = inflater.inflate(R.layout.fragment_lines_detail, container, false) //lineID = requireArguments().getString(LINEID_KEY, "") arguments?.let { lineID = it.getString(LINEID_KEY, "") } switchButton = rootView.findViewById(R.id.switchImageButton) locationIcon = rootView.findViewById(R.id.locationEnableIcon) favoritesButton = rootView.findViewById(R.id.favoritesButton) stopsRecyclerView = rootView.findViewById(R.id.patternStopsRecyclerView) descripTextView = rootView.findViewById(R.id.lineDescripTextView) descripTextView.visibility = View.INVISIBLE //map stuff mapView = rootView.findViewById(R.id.lineMap) mapView.getMapAsync(this) //init bottom sheet val bottomSheet = rootView.findViewById(R.id.bottom_sheet) bottomLayout = bottomSheet stopTitleTextView = bottomSheet.findViewById(R.id.stopTitleTextView) stopNumberTextView = bottomSheet.findViewById(R.id.stopNumberTextView) linesPassingTextView = bottomSheet.findViewById(R.id.linesPassingTextView) arrivalsCard = bottomSheet.findViewById(R.id.arrivalsCardButton) directionsCard = bottomSheet.findViewById(R.id.directionsCardButton) bottomrightImage = bottomSheet.findViewById(R.id.rightmostImageView) bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet) // Setup close button rootView.findViewById(R.id.btnClose).setOnClickListener { hideStopBottomSheet() } val titleTextView = rootView.findViewById(R.id.titleTextView) titleTextView.text = getString(R.string.line)+" "+FiveTNormalizer.fixShortNameForDisplay( GtfsUtils.getLineNameFromGtfsID(lineID), true) favoritesButton?.isClickable = true favoritesButton?.setOnClickListener { if(lineID.isNotEmpty()) PreferencesHolder.addOrRemoveLineToFavorites(requireContext(),lineID,!isLineInFavorite) } val preferences = PreferencesHolder.getMainSharedPreferences(requireContext()) val favorites = preferences.getStringSet(PreferencesHolder.PREF_FAVORITE_LINES, HashSet()) if(favorites!=null && favorites.contains(lineID)){ favoritesButton?.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_star_filled, null)) isLineInFavorite = true } appContext = requireContext().applicationContext preferences.registerOnSharedPreferenceChangeListener(lineSharedPrefMonitor) patternsSpinner = rootView.findViewById(R.id.patternsSpinner) patternsAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, ArrayList()) patternsSpinner.adapter = patternsAdapter initializeRecyclerView() switchButton.setOnClickListener{ if(mapView.visibility == View.VISIBLE){ hideMapAndShowStopList() } else{ hideStopListAndShowMap() } } locationIcon?.let {view -> if(!LocationUtils.isLocationEnabled(requireContext()) || !Permissions.anyLocationPermissionsGranted(requireContext())) setLocationIconEnabled(false) //set click Listener view.setOnClickListener(this::onPositionIconButtonClick) } //set //INITIALIZE VIEW MODELS viewModel.setRouteIDQuery(lineID) liveBusViewModel.setGtfsLineToFilterPos(lineID, null) val keySourcePositions = getString(R.string.pref_positions_source) useMQTTPositions = PreferenceManager.getDefaultSharedPreferences(requireContext()) .getString(keySourcePositions, "mqtt").contentEquals("mqtt") viewModel.patternsWithStopsByRouteLiveData.observe(viewLifecycleOwner){ patterns -> savePatternsToShow(patterns) } /* */ viewModel.stopsForPatternLiveData.observe(viewLifecycleOwner) { stops -> if(mapView.visibility ==View.VISIBLE) patternShown?.let{ // We have the pattern and the stops here, time to display them displayPatternWithStopsOnMap(it,stops, true) } ?:{ Log.w(DEBUG_TAG, "The viewingPattern is null!") } else{ if(stopsRecyclerView.visibility==View.VISIBLE) showStopsAsList(stops) } } viewModel.gtfsRoute.observe(viewLifecycleOwner){route-> if(route == null){ //need to close the fragment activity?.supportFragmentManager?.popBackStack() return@observe } descripTextView.text = route.longName descripTextView.visibility = View.VISIBLE } /* */ Log.d(DEBUG_TAG,"Data ${viewModel.stopsForPatternLiveData.value}") //listeners patternsSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(p0: AdapterView<*>?, p1: View?, position: Int, p3: Long) { val currentShownPattern = patternShown?.pattern val patternWithStops = currentPatterns.get(position) //viewModel.setPatternToDisplay(patternWithStops) setPatternAndReqStops(patternWithStops) Log.d(DEBUG_TAG, "item Selected, cleaning bus markers") if(mapView.visibility == View.VISIBLE) { //Clear buses if we are changing direction currentShownPattern?.let { patt -> if(patt.directionId != patternWithStops.pattern.directionId){ stopAnimations() updatesByVehDict.clear() updatePositionsIcons(true) liveBusViewModel.retriggerPositionUpdate() } } } liveBusViewModel.setGtfsLineToFilterPos(lineID, patternWithStops.pattern) } override fun onNothingSelected(p0: AdapterView<*>?) { } } Log.d(DEBUG_TAG, "Views created!") return rootView } // ------------- UI switch stuff --------- private fun hideMapAndShowStopList(){ mapView.visibility = View.GONE stopsRecyclerView.visibility = View.VISIBLE locationIcon?.visibility = View.GONE viewModel.setMapShowing(false) if(useMQTTPositions) liveBusViewModel.stopMatoUpdates() //map.overlayManager.remove(busPositionsOverlay) switchButton.setImageDrawable(AppCompatResources.getDrawable(requireContext(), R.drawable.ic_map_white_30)) hideStopBottomSheet() if(locationComponent.isLocationComponentEnabled){ locationComponent.isLocationComponentEnabled = false shouldMapLocationBeReactivated = true } else shouldMapLocationBeReactivated = false } private fun hideStopListAndShowMap(){ stopsRecyclerView.visibility = View.GONE mapView.visibility = View.VISIBLE locationIcon?.visibility = View.VISIBLE viewModel.setMapShowing(true) //map.overlayManager.add(busPositionsOverlay) //map. if(useMQTTPositions) liveBusViewModel.requestMatoPosUpdates(GtfsUtils.getLineNameFromGtfsID(lineID)) else liveBusViewModel.requestGTFSUpdates() switchButton.setImageDrawable(AppCompatResources.getDrawable(requireContext(), R.drawable.ic_list_30)) if(shouldMapLocationBeReactivated && Permissions.bothLocationPermissionsGranted(requireContext())){ locationComponent.isLocationComponentEnabled = true } } private fun setLocationIconEnabled(setTrue: Boolean){ if(setTrue) locationIcon?.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_red)) else locationIcon?.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_grey)) } /** * Handles logic of enabling the user location on the map */ @SuppressLint("MissingPermission") private fun setMapUserLocationEnabled(enabled: Boolean, assumePermissions: Boolean, fromClick: Boolean) { if (enabled) { val permissionOk = assumePermissions || Permissions.bothLocationPermissionsGranted(requireContext()) if (permissionOk) { Log.d(DEBUG_TAG, "Permission OK, starting location component, assumed: $assumePermissions") locationComponent.isLocationComponentEnabled = true //locationComponent.cameraMode = CameraMode.TRACKING //CameraMode.TRACKING setLocationIconEnabled(true) if (fromClick) Toast.makeText(context, R.string.location_enabled, Toast.LENGTH_SHORT).show() } else { if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)) { //TODO: show dialog for permission rationale Toast.makeText(activity, R.string.enable_position_message_map, Toast.LENGTH_SHORT).show() } Log.d(DEBUG_TAG, "Requesting permission to show user location") enablingPositionFromClick = fromClick showUserPositionRequestLauncher.launch(Permissions.LOCATION_PERMISSIONS) } } else{ locationComponent.isLocationComponentEnabled = false setLocationIconEnabled(false) if (fromClick) { Toast.makeText(requireContext(), R.string.location_disabled, Toast.LENGTH_SHORT).show() //TODO: Cancel the request for the enablement of the position if needed } } } /** * Switch position icon from activ */ private fun onPositionIconButtonClick(view: View){ if(locationComponent.isLocationComponentEnabled) setMapUserLocationEnabled(false, false, true) else{ setMapUserLocationEnabled(true, false, true) } } // ------------- Map Code ------------------------- /** * This method sets up the map and the layers */ override fun onMapReady(mapReady: MapLibreMap) { this.map = mapReady - val mjson = Styles.getJsonStyleFromAsset(requireContext(), "map_style_good_noshops.json") - //ViewUtils.loadJsonFromAsset(requireContext(),"map_style_good.json") + val context = requireContext() + val mjson = Styles.getJsonStyleFromAsset(context, PreferencesHolder.getMapLibreStyleFile(context)) //ViewUtils.loadJsonFromAsset(requireContext(),"map_style_good.json") activity?.run { val builder = Style.Builder().fromJson(mjson!!) mapReady.setStyle(builder) { style -> mapStyle = style //setupLayers(style) symbolManager = SymbolManager(mapView,mapReady,style) symbolManager.iconAllowOverlap = true symbolManager.textAllowOverlap = false symbolManager.addClickListener{ _ -> if (stopActiveSymbol!=null){ hideStopBottomSheet() return@addClickListener true } else return@addClickListener false } // Start observing data initMapUserLocation(style, mapReady, requireContext()) //if(!stopsLayerStarted) initStopsPolyLineLayers(style, FeatureCollection.fromFeatures(ArrayList()), null) if(patternShown!=null){ viewModel.stopsForPatternLiveData.value?.let { Log.d(DEBUG_TAG, "Show stops from the cache") displayPatternWithStopsOnMap(patternShown!!, it, true) } } /*if(!stopsLayerStarted) { Log.d(DEBUG_TAG, "Stop layer is not started yet") initStopsPolyLineLayers(style, FeatureCollection.fromFeatures(ArrayList()), null) } */ setupBusLayer(style) mapViewModel.stopShowing?.let { openStopInBottomSheet(it) } mapViewModel.stopShowing = null } mapReady.addOnMapClickListener { point -> val screenPoint = mapReady.projection.toScreenLocation(point) val features = mapReady.queryRenderedFeatures(screenPoint, STOPS_LAYER_ID) val busNearby = mapReady.queryRenderedFeatures(screenPoint, BUSES_LAYER_ID) if (features.isNotEmpty()) { val feature = features[0] val id = feature.getStringProperty("id") val name = feature.getStringProperty("name") //Toast.makeText(requireContext(), "Clicked on $name ($id)", Toast.LENGTH_SHORT).show() val stop = viewModel.getStopByID(id) stop?.let { if (isBottomSheetShowing){ hideStopBottomSheet() } openStopInBottomSheet(it) isBottomSheetShowing = true //move camera if(it.latitude!=null && it.longitude!=null) mapReady.animateCamera(CameraUpdateFactory.newLatLng(LatLng(it.latitude!!,it.longitude!!)),750) } return@addOnMapClickListener true } else if (busNearby.isNotEmpty()){ val feature = busNearby[0] val vehid = feature.getStringProperty("veh") val route = feature.getStringProperty("line") if(isBottomSheetShowing && shownStopInBottomSheet!=null) hideStopBottomSheet() //if(context!=null){ // Toast.makeText(context, "Veh $vehid on route ${route.slice(0..route.length-2)}", Toast.LENGTH_SHORT).show() //} showVehicleTripInBottomSheet(vehid) updatesByVehDict[vehid]?.let { //if (it.posUpdate.latitude != null && it.longitude != null) mapReady.animateCamera( CameraUpdateFactory.newLatLng(LatLng(it.posUpdate.latitude, it.posUpdate.longitude)), 750 ) } return@addOnMapClickListener true } false } // we start requesting the bus positions now observeBusPositionUpdates() } /*savedMapStateOnPause?.let{ restoreMapStateFromBundle(it) pendingLocationActivation = false Log.d(DEBUG_TAG, "Restored map state from the saved bundle") } */ val zoom = 12.0 val latlngTarget = LatLng(MapLibreFragment.DEFAULT_CENTER_LAT, MapLibreFragment.DEFAULT_CENTER_LON) mapReady.cameraPosition = savedCameraPosition ?:CameraPosition.Builder().target(latlngTarget).zoom(zoom).build() savedCameraPosition = null if(shouldMapLocationBeReactivated) setMapUserLocationEnabled(true, false, false) } private fun observeBusPositionUpdates(){ //live bus positions liveBusViewModel.filteredLocationUpdates.observe(viewLifecycleOwner){ updates -> //Log.d(DEBUG_TAG, "Received ${updates.size} updates for the positions") if(mapView.visibility == View.GONE || patternShown ==null){ //DO NOTHING Log.w(DEBUG_TAG, "not doing anything because map is not visible") return@observe } updateBusPositionsInMap(updates) //if not using MQTT positions if(!useMQTTPositions){ liveBusViewModel.requestDelayedGTFSUpdates(2000) } } //download missing tripIDs liveBusViewModel.tripsGtfsIDsToQuery.observe(viewLifecycleOwner){ //gtfsPosViewModel.downloadTripsFromMato(dat); MatoTripsDownloadWorker.requestMatoTripsDownload( it, requireContext().applicationContext, "BusTO-MatoTripDownload" ) } } /** * Initialize the map location, but do not enable the component */ @SuppressLint("MissingPermission") private fun initMapUserLocation(style: Style, map: MapLibreMap, context: Context){ locationComponent = map.locationComponent val locationComponentOptions = LocationComponentOptions.builder(context) .pulseEnabled(false) .build() val locationComponentActivationOptions = - MapUtils.buildLocationComponentActivationOptions(style, locationComponentOptions, context) + MapLibreUtils.buildLocationComponentActivationOptions(style, locationComponentOptions, context) locationComponent.activateLocationComponent(locationComponentActivationOptions) locationComponent.isLocationComponentEnabled = false lastLocation?.let { if (it.accuracy < 200) locationComponent.forceLocationUpdate(it) } } /** * Update the bottom sheet with the stop information */ private 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 = stop.ID stopTitleTextView.visibility = View.VISIBLE val string_show = if (stop.numRoutesStopping==0) "" else if (stop.numRoutesStopping <= 1) requireContext().getString(R.string.line_fill, stop.routesThatStopHereToString()) else requireContext().getString(R.string.lines_fill, stop.routesThatStopHereToString()) linesPassingTextView.text = string_show //SET ON CLICK LISTENER arrivalsCard.setOnClickListener{ fragmentListener?.requestArrivalsForStopID(stop.ID) } arrivalsCard.visibility = View.VISIBLE directionsCard.setOnClickListener { if(stop.latitude==null || stop.longitude==null){ //TODO: show message Log.e(DEBUG_TAG, "Navigate to stop but longitude and/or latitude are null") }else{ val uri = "geo:?q=${stop.latitude},${stop.longitude}(${stop.ID} - $stopName)" val intent =Intent(Intent.ACTION_VIEW, Uri.parse(uri)) context?.run{ if(intent.resolveActivity(packageManager)!=null){ startActivity(intent) } } } } bottomrightImage.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.navigation_right, activity?.theme)) } //add stop marker if (stop.latitude!=null && stop.longitude!=null) { stopActiveSymbol = symbolManager.create( SymbolOptions() .withLatLng(LatLng(stop.latitude!!, stop.longitude!!)) .withIconImage(STOP_ACTIVE_IMG) .withIconAnchor(ICON_ANCHOR_CENTER) ) } shownStopInBottomSheet = stop bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED } // Hide the bottom sheet and remove extra symbol private fun hideStopBottomSheet(){ if (stopActiveSymbol!=null){ symbolManager.delete(stopActiveSymbol) stopActiveSymbol = null } bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN isBottomSheetShowing = false //remove initial stop //if(initialStopToShow!=null){ // initialStopToShow = null //} shownStopInBottomSheet = null vehShowing = "" } private fun showVehicleTripInBottomSheet(veh: String){ val data = updatesByVehDict[veh] if(data==null) return bottomLayout?.let { val lineName = FiveTNormalizer.fixShortNameForDisplay( GtfsUtils.getLineNameFromGtfsID(data.posUpdate.routeID), true) stopNumberTextView.text = requireContext().getString(R.string.line_fill, lineName) data.pattern?.let { pat -> stopTitleTextView.text = pat.headsign stopTitleTextView.visibility = View.VISIBLE } ?:{ stopTitleTextView.visibility = View.GONE } linesPassingTextView.text = data.posUpdate.vehicle } arrivalsCard.visibility=View.GONE bottomrightImage.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_magnifying_glass, activity?.theme)) directionsCard.setOnClickListener { data.pattern?.let { showPatternWithCode(it.code) } //TODO // ?: { // context?.let { ctx -> Toast.makeText(ctx,"") } //} } vehShowing = veh bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED } // ------- MAP LAYERS INITIALIZE ---- /** * Initialize the map layers for the stops */ private fun initStopsPolyLineLayers(style: Style, stopFeatures:FeatureCollection, lineFeature: Feature?){ Log.d(DEBUG_TAG, "INIT STOPS CALLED") stopsSource = GeoJsonSource(STOPS_SOURCE_ID) style.addSource(stopsSource) //val context = requireContext() val stopIcon = ResourcesCompat.getDrawable(resources,R.drawable.ball, activity?.theme)!! val imgStop = ResourcesCompat.getDrawable(resources,R.drawable.bus_stop_new, activity?.theme)!! //set the image tint //DrawableCompat.setTint(imgBus,ContextCompat.getColor(context,R.color.line_drawn_poly)) // add icon style.addImage(STOP_IMAGE_ID,stopIcon) style.addImage(STOP_ACTIVE_IMG, ResourcesCompat.getDrawable(resources, R.drawable.bus_stop_new_highlight, activity?.theme)!!) // Stops layer val stopsLayer = SymbolLayer(STOPS_LAYER_ID, STOPS_SOURCE_ID) stopsLayer.withProperties( PropertyFactory.iconImage(STOP_IMAGE_ID), PropertyFactory.iconAllowOverlap(true), PropertyFactory.iconIgnorePlacement(true) ) polylineSource = 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) ) style.addLayerBelow(lineLayer,"label_country_1") style.addLayerAbove(stopsLayer, POLYLINE_LAYER) stopsLayerStarted = true } /** * Setup the Map Layers */ private fun setupBusLayer(style: Style) { // Buses source busesSource = GeoJsonSource(BUSES_SOURCE_ID) style.addSource(busesSource) style.addImage("bus_symbol",ResourcesCompat.getDrawable(resources, R.drawable.map_bus_position_icon, activity?.theme)!!) // Buses layer val busesLayer = SymbolLayer(BUSES_LAYER_ID, BUSES_SOURCE_ID).apply { withProperties( PropertyFactory.iconImage("bus_symbol"), //PropertyFactory.iconSize(1.2f), PropertyFactory.iconAllowOverlap(true), PropertyFactory.iconIgnorePlacement(true), PropertyFactory.iconRotate(Expression.get("bearing")), PropertyFactory.iconRotationAlignment(ICON_ROTATION_ALIGNMENT_MAP) ) } style.addLayerAbove(busesLayer, STOPS_LAYER_ID) } override fun onAttach(context: Context) { super.onAttach(context) if(context is CommonFragmentListener){ fragmentListener = context } else throw RuntimeException("$context must implement CommonFragmentListener") } private fun stopAnimations(){ for(anim in animatorsByVeh.values){ anim.cancel() } } private fun savePatternsToShow(patterns: List){ currentPatterns = patterns.sortedWith(patternsSorter) patternsAdapter?.let { it.clear() it.addAll(currentPatterns.map { p->"${p.pattern.directionId} - ${p.pattern.headsign}" }) it.notifyDataSetChanged() } patternShown?.let { showPattern(it) } } /** * Called when the position of the spinner is updated */ private fun setPatternAndReqStops(patternWithStops: MatoPatternWithStops){ Log.d(DEBUG_TAG, "Requesting stops for pattern ${patternWithStops.pattern.code}") viewModel.selectedPatternLiveData.value = patternWithStops viewModel.currentPatternStops.value = patternWithStops.stopsIndices.sortedBy { i-> i.order } patternShown = patternWithStops viewModel.requestStopsForPatternWithStops(patternWithStops) } private fun showPattern(patternWs: MatoPatternWithStops){ Log.d(DEBUG_TAG, "Finding pattern to show: ${patternWs.pattern.code}") var pos = -2 val code = patternWs.pattern.code.trim() for(k in currentPatterns.indices){ if(currentPatterns[k].pattern.code.trim() == code){ pos = k break } } Log.d(DEBUG_TAG, "Found pattern $code in position: $pos") if(pos>=0) patternsSpinner.setSelection(pos) //set pattern setPatternAndReqStops(patternWs) } private fun zoomToCurrentPattern(){ if(polyline==null) return val NULL_VALUE = -4000.0 var maxLat = NULL_VALUE var minLat = NULL_VALUE var minLong = NULL_VALUE var maxLong = NULL_VALUE polyline?.let { for(p in it.coordinates()){ val lat = p.latitude() val lon = p.longitude() // get max latitude if(maxLat == NULL_VALUE) maxLat =lat else if (maxLat < lat) maxLat = lat // find min latitude if (minLat ==NULL_VALUE) minLat = lat else if (minLat > lat) minLat = lat if(maxLong == NULL_VALUE || maxLong < lon ) maxLong = lon if (minLong == NULL_VALUE || minLong > lon) minLong = lon } val padding = 100 // Pixel di padding intorno ai limiti Log.d(DEBUG_TAG, "Setting limits of bounding box of line: $minLat -> $maxLat, $minLong -> $maxLong") val bbox = LatLngBounds.from(maxLat,maxLong, minLat, minLong) //map.zoomToBoundingBox(BoundingBox(maxLat+del, maxLong+del, minLat-del, minLong-del), false) map?.animateCamera(CameraUpdateFactory.newLatLngBounds(bbox, padding)) } } private fun displayPatternWithStopsOnMap(patternWs: MatoPatternWithStops, stops: List, zoomToPattern: Boolean){ Log.d(DEBUG_TAG, "Got the stops: ${stops.map { s->s.gtfsID }}}") //if(viewingPattern==null || map == null) return if (map==null) return patternShown = patternWs val pattern = patternWs.pattern val pointsList = PolylineParser.decodePolyline(pattern.patternGeometryPoly, pattern.patternGeometryLength) val pointsToShow = pointsList.map { Point.fromLngLat(it.longitude, it.latitude) } Log.d(DEBUG_TAG, "The polyline has ${pointsToShow.size} points to display") polyline = LineString.fromLngLats(pointsToShow) val lineFeature = Feature.fromGeometry(polyline) //Log.d(DEBUG_TAG, "Polyline in JSON is: ${lineFeature.toJson()}") // --- STOPS--- val features = ArrayList()//stops.mapNotNull { stop -> //stop.latitude?.let { lat -> // stop.longitude?.let { lon -> for (s in stops){ if (s.latitude!=null && s.longitude!=null) { val loc = if (showOnTopOfLine) findOptimalPosition(s, pointsList) else LatLng(s.latitude!!, s.longitude!!) features.add( Feature.fromGeometry( Point.fromLngLat(loc.longitude, loc.latitude), JsonObject().apply { addProperty("id", s.ID) addProperty("name", s.stopDefaultName) //addProperty("routes", s.routesThatStopHereToString()) // Add routes array to JSON object } ) ) } } Log.d(DEBUG_TAG,"Have put ${features.size} stops to display") // if the layer is already started, substitute the stops inside, otherwise start it if (stopsLayerStarted) { stopsSource.setGeoJson(FeatureCollection.fromFeatures(features)) polylineSource.setGeoJson(lineFeature) lastStopsSizeShown = features.size } else map?.let { Log.d(DEBUG_TAG, "Map stop layer is not started yet, init layer") initStopsPolyLineLayers(mapStyle, FeatureCollection.fromFeatures(features),lineFeature) Log.d(DEBUG_TAG,"Started stops layer on map") lastStopsSizeShown = features.size stopsLayerStarted = true } /* OLD CODE for(s in stops){ val gp = val marker = MarkerUtils.makeMarker( gp, s.ID, s.stopDefaultName, s.routesThatStopHereToString(), map,stopTouchResponder, stopIcon, R.layout.linedetail_stop_infowindow, R.color.line_drawn_poly ) marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) stopsOverlay.add(marker) } */ //POINTS LIST IS NOT IN ORDER ANY MORE //if(!map.overlayManager.contains(stopsOverlay)){ // map.overlayManager.add(stopsOverlay) //} if(zoomToPattern) zoomToCurrentPattern() //map.invalidate() } private fun initializeRecyclerView(){ val llManager = LinearLayoutManager(context) llManager.orientation = LinearLayoutManager.VERTICAL stopsRecyclerView.layoutManager = llManager } private fun showStopsAsList(stops: List){ Log.d(DEBUG_TAG, "Setting stops from: "+viewModel.currentPatternStops.value) val orderBy = viewModel.currentPatternStops.value!!.withIndex().associate{it.value.stopGtfsId to it.index} val stopsSorted = stops.sortedBy { s -> orderBy[s.gtfsID] } val numStops = stopsSorted.size Log.d(DEBUG_TAG, "RecyclerView adapter is: ${stopsRecyclerView.adapter}") val setNewAdapter = true if(setNewAdapter){ stopsRecyclerView.adapter = StopRecyclerAdapter( stopsSorted, stopAdapterListener, StopRecyclerAdapter.Use.LINES, NameCapitalize.FIRST ) } } /** * This method fixes the display of the pattern, to be used when clicking on a bus */ private fun 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 } } } /** * Update function for the bus positions * Takes the processed updates and saves them accordingly * Copied from MapLibreFragment, removing the labels */ private fun updateBusPositionsInMap(incomingData: HashMap>){ val vehsNew = HashSet(incomingData.values.map { up -> up.first.vehicle }) val vehsOld = HashSet(updatesByVehDict.keys) Log.d(DEBUG_TAG, "In fragment, have ${incomingData.size} updates to show") var countUpds = 0 //val symbolsToUpdate = ArrayList() for (upsWithTrp in incomingData.values){ val pos = upsWithTrp.first val patternStops = upsWithTrp.second val vehID = pos.vehicle var animate = false if (vehsOld.contains(vehID)){ //update position only if the starting or the stopping position of the animation are in the view val oldPos = updatesByVehDict[vehID]?.posUpdate var avoidShowingUpdateBecauseIsImpossible = false oldPos?.let{ if(it.routeID!=pos.routeID) { val dist = LatLng(it.latitude, it.longitude).distanceTo(LatLng(pos.latitude, pos.longitude)) val speed = dist*3.6 / (pos.timestamp - it.timestamp) //this should be in km/h Log.w(DEBUG_TAG, "Vehicle $vehID changed route from ${oldPos.routeID} to ${pos.routeID}, distance: $dist, speed: $speed") if (speed > 120 || speed < 0){ avoidShowingUpdateBecauseIsImpossible = true } } } if (avoidShowingUpdateBecauseIsImpossible){ // DO NOT SHOW THIS SHIT Log.w(DEBUG_TAG, "Update for vehicle $vehID skipped") continue } val samePosition = oldPos?.let { (it.latitude==pos.latitude)&&(it.longitude == pos.longitude) }?:false if(!samePosition) { //val isPositionInBounds = isInsideVisibleRegion( // pos.latitude, pos.longitude, true //) || (oldPos?.let { isInsideVisibleRegion(it.latitude,it.longitude,true) } ?: false) val skip = true if (skip) { //animate = true //this moves both the icon and the label moveVehicleToNewPosition(pos) } else { //update updatesByVehDict[vehID] = LivePositionTripPattern(pos,patternStops?.pattern) /*busLabelSymbolsByVeh[vehID]?.let { it.latLng = LatLng(pos.latitude, pos.longitude) symbolsToUpdate.add(it) }*/ //if(vehShowing==vehID) // map?.animateCamera(CameraUpdateFactory.newLatLng(LatLng(pos.latitude, pos.longitude)),500) //TODO: Follow the vehicle } } countUpds++ } else{ //not inside // update it simply updatesByVehDict[vehID] = LivePositionTripPattern(pos, patternStops?.pattern) //createLabelForVehicle(pos) //if(vehShowing==vehID) // map?.animateCamera(CameraUpdateFactory.newLatLng(LatLng(pos.latitude, pos.longitude)),500) } if (vehID == vehShowing){ //update the data showVehicleTripInBottomSheet(vehID) } } //symbolManager.update(symbolsToUpdate) //remove old positions Log.d(DEBUG_TAG, "Updated $countUpds vehicles") vehsOld.removeAll(vehsNew) //now vehsOld contains the vehicles id for those that have NOT been updated val currentTimeStamp = System.currentTimeMillis() /1000 for(vehID in vehsOld){ //remove after 2 minutes of inactivity if (updatesByVehDict[vehID]!!.posUpdate.timestamp - currentTimeStamp > 2*60){ updatesByVehDict.remove(vehID) //removeVehicleLabel(vehID) } } //update UI updatePositionsIcons(false) } /** * This is the tricky part, animating the transitions * Basically, we need to set the new positions with the data and redraw them all */ private fun moveVehicleToNewPosition(positionUpdate: LivePositionUpdate){ if (positionUpdate.vehicle !in updatesByVehDict.keys) return val vehID = positionUpdate.vehicle val currentUpdate = updatesByVehDict[positionUpdate.vehicle] currentUpdate?.let { it -> //cancel current animation on vehicle animatorsByVeh[vehID]?.cancel() val posUp = it.posUpdate val currentPos = LatLng(posUp.latitude, posUp.longitude) val newPos = LatLng(positionUpdate.latitude, positionUpdate.longitude) - val valueAnimator = ValueAnimator.ofObject(MapUtils.LatLngEvaluator(), currentPos, newPos) + val valueAnimator = ValueAnimator.ofObject(MapLibreUtils.LatLngEvaluator(), currentPos, newPos) valueAnimator.addUpdateListener(object : ValueAnimator.AnimatorUpdateListener { private var latLng: LatLng? = null override fun onAnimationUpdate(animation: ValueAnimator) { latLng = animation.animatedValue as LatLng //update position on animation val update = updatesByVehDict[positionUpdate.vehicle]!! latLng?.let { ll-> update.posUpdate.latitude = ll.latitude update.posUpdate.longitude = ll.longitude updatePositionsIcons(false) } } }) /*valueAnimator.addListener(object : AnimatorListenerAdapter() { override fun onAnimationStart(animation: Animator) { super.onAnimationStart(animation) //val update = positionsByVehDict[positionUpdate.vehicle]!! //remove the label at the start of the animation //removeVehicleLabel(vehID) val annot = busLabelSymbolsByVeh[vehID] annot?.let { sym -> sym.textOpacity = 0.0f symbolsToUpdate.add(sym) } } override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) /*val annot = busLabelSymbolsByVeh[vehID] annot?.let { sym -> sym.textOpacity = 1.0f sym.latLng = newPos //LatLng(newPos) symbolsToUpdate.add(sym) } */ } }) */ animatorsByVeh[vehID]?.cancel() //set the new position as the current one but with the old lat and lng positionUpdate.latitude = posUp.latitude positionUpdate.longitude = posUp.longitude updatesByVehDict[vehID]!!.posUpdate = positionUpdate valueAnimator.duration = 300 valueAnimator.interpolator = LinearInterpolator() valueAnimator.start() animatorsByVeh[vehID] = valueAnimator } ?: { Log.e(DEBUG_TAG, "Have to run animation for veh ${positionUpdate.vehicle} but not in the dict, adding") //updatesByVehDict[positionUpdate.vehicle] = positionUpdate } } /** * Update the bus positions displayed on the map, from the existing data */ private fun updatePositionsIcons(forced: Boolean){ //avoid frequent updates val currentTime = System.currentTimeMillis() if(!forced && currentTime - lastUpdateTime < 60){ //DO NOT UPDATE THE MAP return } val features = ArrayList()//stops.mapNotNull { stop -> //stop.latitude?.let { lat -> // stop.longitude?.let { lon -> for (dat in updatesByVehDict.values){ //if (s.latitude!=null && s.longitude!=null) val pos = dat.posUpdate val point = Point.fromLngLat(pos.longitude, pos.latitude) features.add( Feature.fromGeometry( point, JsonObject().apply { addProperty("veh", pos.vehicle) addProperty("trip", pos.tripID) addProperty("bearing", pos.bearing ?:0.0f) addProperty("line", pos.routeID) } ) ) /*busLabelSymbolsByVeh[pos.vehicle]?.let { it.latLng = LatLng(pos.latitude, pos.longitude) symbolsToUpdate.add(it) } */ } busesSource.setGeoJson(FeatureCollection.fromFeatures(features)) //update labels, clear cache to be used //symbolManager.update(symbolsToUpdate) //symbolsToUpdate.clear() lastUpdateTime = System.currentTimeMillis() } override fun onResume() { super.onResume() Log.d(DEBUG_TAG, "Resetting paused from onResume") mapView.onResume() pausedFragment = false val keySourcePositions = getString(R.string.pref_positions_source) useMQTTPositions = PreferenceManager.getDefaultSharedPreferences(requireContext()) .getString(keySourcePositions, "mqtt").contentEquals("mqtt") //separate paths if(useMQTTPositions) liveBusViewModel.requestMatoPosUpdates(GtfsUtils.getLineNameFromGtfsID(lineID)) else liveBusViewModel.requestGTFSUpdates() if(mapViewModel.currentLat.value!=MapViewModel.INVALID) { Log.d(DEBUG_TAG, "mapViewModel posi: ${mapViewModel.currentLat.value}, ${mapViewModel.currentLong.value}"+ " zoom ${mapViewModel.currentZoom.value}") //THIS WAS A FIX FOR THE OLD OSMDROID MAP /*val controller = map.controller viewLifecycleOwner.lifecycleScope.launch { delay(100) Log.d(DEBUG_TAG, "zooming back to point") controller.animateTo(GeoPoint(mapViewModel.currentLat.value!!, mapViewModel.currentLong.value!!), mapViewModel.currentZoom.value!!,null,null) //controller.setCenter(GeoPoint(mapViewModel.currentLat.value!!, mapViewModel.currentLong.value!!)) //controller.setZoom(mapViewModel.currentZoom.value!!) } */ } //initialize GUI here fragmentListener.readyGUIfor(FragmentKind.LINES) } override fun onPause() { super.onPause() mapView.onPause() if(useMQTTPositions) liveBusViewModel.stopMatoUpdates() pausedFragment = true //save map val camera = map?.cameraPosition camera?.let {cam-> mapViewModel.currentLat.value = cam.target?.latitude ?: -400.0 mapViewModel.currentLong.value = cam.target?.longitude ?: -400.0 mapViewModel.currentZoom.value = cam.zoom } } override fun onStart() { super.onStart() mapView.onStart() } override fun onDestroy() { super.onDestroy() mapView.onDestroy() } override fun onStop() { super.onStop() mapView.onStop() shownStopInBottomSheet?.let { mapViewModel.stopShowing = it } shouldMapLocationBeReactivated = locationComponent.isLocationComponentEnabled } override fun onDestroyView() { map?.run { Log.d(DEBUG_TAG, "Saving camera position") savedCameraPosition = cameraPosition } super.onDestroyView() Log.d(DEBUG_TAG, "Destroying the views") /*mapStyle.removeLayer(STOPS_LAYER_ID) mapStyle?.removeSource(STOPS_SOURCE_ID) mapStyle.removeLayer(POLYLINE_LAYER) mapStyle.removeSource(POLYLINE_SOURCE) */ //stopsLayerStarted = false } override fun getBaseViewForSnackBar(): View? { return null } companion object { private const val LINEID_KEY="lineID" private const val STOPS_SOURCE_ID = "stops-source" private const val STOPS_LAYER_ID = "stops-layer" private const val BUSES_SOURCE_ID = "buses-source" private const val BUSES_LAYER_ID = "buses-layer" private const val STOP_ACTIVE_IMG = "stop_active_img" private const val STOP_IMAGE_ID = "stop-img" private const val POLYLINE_LAYER = "polyline-layer" private const val POLYLINE_SOURCE = "polyline-source" private const val DEBUG_TAG="BusTO-LineDetailFragment" fun makeArgs(lineID: String): Bundle{ val b = Bundle() b.putString(LINEID_KEY, lineID) return b } fun newInstance(lineID: String?) = LinesDetailFragment().apply { lineID?.let { arguments = makeArgs(it) } } @JvmStatic private fun findOptimalPosition(stop: Stop, pointsList: MutableList): LatLng{ if(stop.latitude==null || stop.longitude ==null|| pointsList.isEmpty()) throw IllegalArgumentException() val sLat = stop.latitude!! val sLong = stop.longitude!! if(pointsList.size < 2) return pointsList[0] pointsList.sortBy { utils.measuredistanceBetween(sLat, sLong, it.latitude, it.longitude) } val p1 = pointsList[0] val p2 = pointsList[1] if (p1.longitude == p2.longitude){ //Log.e(DEBUG_TAG, "Same longitude") return LatLng(sLat, p1.longitude) } else if (p1.latitude == p2.latitude){ //Log.d(DEBUG_TAG, "Same latitude") return LatLng(p2.latitude,sLong) } val m = (p1.latitude - p2.latitude) / (p1.longitude - p2.longitude) val minv = (p1.longitude-p2.longitude)/(p1.latitude - p2.latitude) val cR = p1.latitude - p1.longitude * m val longNew = (minv * sLong + sLat -cR ) / (m+minv) val latNew = (m*longNew + cR) //Log.d(DEBUG_TAG,"Stop ${stop.ID} old pos: ($sLat, $sLong), new pos ($latNew,$longNew)") return LatLng(latNew,longNew) } private const val DEFAULT_CENTER_LAT = 45.12 private const val DEFAULT_CENTER_LON = 7.6858 } enum class BottomShowing{ STOP, VEHICLE } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt index 28c1db0..ecc8d1b 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt @@ -1,1155 +1,1152 @@ package it.reyboz.bustorino.fragments import android.Manifest import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.ValueAnimator import android.annotation.SuppressLint import android.content.Context import android.content.Intent -import android.content.pm.PackageManager import android.location.Location import android.location.LocationListener import android.location.LocationManager import android.net.Uri import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.animation.LinearInterpolator import android.widget.ImageButton import android.widget.RelativeLayout import android.widget.TextView import android.widget.Toast import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.cardview.widget.CardView -import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.preference.PreferenceManager import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.gson.Gson import com.google.gson.JsonObject 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.fragments.SettingsFragment.LIVE_POSITIONS_PREF_MQTT_VALUE -import it.reyboz.bustorino.map.MapUtils +import it.reyboz.bustorino.map.MapLibreUtils import it.reyboz.bustorino.map.Styles import it.reyboz.bustorino.util.Permissions import it.reyboz.bustorino.viewmodels.LivePositionsViewModel import it.reyboz.bustorino.viewmodels.StopsMapViewModel import org.maplibre.android.MapLibre import org.maplibre.android.camera.CameraPosition import org.maplibre.android.camera.CameraUpdateFactory import org.maplibre.android.geometry.LatLng import org.maplibre.android.geometry.LatLngBounds import org.maplibre.android.location.LocationComponent -import org.maplibre.android.location.LocationComponentActivationOptions import org.maplibre.android.location.LocationComponentOptions -import org.maplibre.android.location.engine.LocationEngineRequest import org.maplibre.android.location.modes.CameraMode 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.* import org.maplibre.android.style.layers.PropertyFactory import org.maplibre.android.style.layers.SymbolLayer import org.maplibre.android.style.sources.GeoJsonSource import org.maplibre.geojson.Feature import org.maplibre.geojson.FeatureCollection import org.maplibre.geojson.Point // TODO: Rename parameter arguments, choose names that match // the fragment initialization parameters, e.g. ARG_ITEM_NUMBER private const val STOP_TO_SHOW = "stoptoshow" /** * A simple [Fragment] subclass. * Use the [MapLibreFragment.newInstance] factory method to * create an instance of this fragment. */ class MapLibreFragment : ScreenBaseFragment(), OnMapReadyCallback { protected var fragmentListener: CommonFragmentListener? = null //private var param1: String? = null //private var param2: String? = null // Declare a variable for MapView private lateinit var mapView: MapView private lateinit var locationComponent: LocationComponent private var lastLocation: Location? = null private val stopsViewModel: StopsMapViewModel by viewModels() private val gson = Gson() private var stopsShowing = ArrayList(0) private var isBottomSheetShowing = false private lateinit var symbolManager: SymbolManager protected var map: MapLibreMap? = null // Sources for stops and buses private lateinit var stopsSource: GeoJsonSource private lateinit var busesSource: GeoJsonSource private var stopsLayerStarted = false private var lastStopsSizeShown = 0 private var lastBBox = LatLngBounds.from(2.0, 2.0, 1.0,1.0) private lateinit var mapStyle: Style private var mapInitCompleted =false private var stopsRedrawnTimes = 0 //bottom Sheet behavior private lateinit var bottomSheetBehavior: BottomSheetBehavior private var bottomLayout: RelativeLayout? = null private lateinit var stopTitleTextView: TextView private lateinit var stopNumberTextView: TextView private lateinit var linesPassingTextView: TextView private lateinit var arrivalsCard: CardView private lateinit var directionsCard: CardView private var stopActiveSymbol: Symbol? = null // Location stuff private lateinit var locationManager: LocationManager private lateinit var showUserPositionButton: ImageButton private lateinit var centerUserButton: ImageButton private lateinit var followUserButton: ImageButton private var followingUserLocation = false private var pendingLocationActivation = false private var ignoreCameraMovementForFollowing = true private var enablingPositionFromClick = false private val positionRequestLauncher = registerForActivityResult, Map>( ActivityResultContracts.RequestMultiplePermissions(), ActivityResultCallback { result -> if (result == null) { Log.w(DEBUG_TAG, "Got asked permission but request is null, doing nothing?") } else if (java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_COARSE_LOCATION] && java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_FINE_LOCATION]) { // We can use the position, restart location overlay Log.d(DEBUG_TAG, "HAVE THE PERMISSIONS") if (context == null || requireContext().getSystemService(Context.LOCATION_SERVICE) == null) return@ActivityResultCallback ///@registerForActivityResult val locationManager = requireContext().getSystemService(Context.LOCATION_SERVICE) as LocationManager @SuppressLint("MissingPermission") val userLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) if (userLocation != null) { if(LatLng(userLocation.latitude, userLocation.longitude).distanceTo(DEFAULT_LATLNG) >= MAX_DIST_KM*1000){ setMapLocationEnabled(true, true, false) } } else requestInitialUserLocation() } else{ Toast.makeText(requireContext(),"User 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 useMQTTViewModel = true private val livePositionsViewModel : LivePositionsViewModel by viewModels() private val positionsByVehDict = HashMap(5) private val animatorsByVeh = HashMap() private var lastUpdateTime : Long = -1 private var busLabelSymbolsByVeh = HashMap() private val symbolsToUpdate = ArrayList() private var initialStopToShow : Stop? = null private var initialStopShown = false //shown stuff private var savedStateOnStop : Bundle? = null private var savedMapStateOnPause : Bundle? = null private var shownStopInBottomSheet : Stop? = null private val showBusLayer = true override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) arguments?.let { initialStopToShow = Stop.fromBundle(arguments) } MapLibre.getInstance(requireContext()) } 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) if(savedStateOnStop!=null){ mapView.onCreate(savedStateOnStop) } else mapView.onCreate(savedInstanceState) mapView.getMapAsync(this) //{ //map -> //map.setStyle("https://demotiles.maplibre.org/style.json") } //init bottom sheet val bottomSheet = rootView.findViewById(R.id.bottom_sheet) bottomLayout = bottomSheet stopTitleTextView = bottomSheet.findViewById(R.id.stopTitleTextView) stopNumberTextView = bottomSheet.findViewById(R.id.stopNumberTextView) linesPassingTextView = bottomSheet.findViewById(R.id.linesPassingTextView) arrivalsCard = bottomSheet.findViewById(R.id.arrivalsCardButton) directionsCard = bottomSheet.findViewById(R.id.directionsCardButton) showUserPositionButton = rootView.findViewById(R.id.locationEnableIcon) showUserPositionButton.setOnClickListener(this::switchUserLocationStatus) followUserButton = rootView.findViewById(R.id.followUserImageButton) centerUserButton = rootView.findViewById(R.id.centerMapImageButton) bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet) bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN arrivalsCard.setOnClickListener { if(context!=null){ Toast.makeText(context,"ARRIVALS", Toast.LENGTH_SHORT).show() } } centerUserButton.setOnClickListener { if(context!=null && locationComponent.isLocationComponentEnabled) { val location = locationComponent.lastKnownLocation location?.let { mapView.getMapAsync { map -> map.animateCamera(CameraUpdateFactory.newCameraPosition( CameraPosition.Builder().target(LatLng(location.latitude, location.longitude)).build()), 500) } } } } followUserButton.setOnClickListener { // onClick user following button if(context!=null && locationComponent.isLocationComponentEnabled){ if(followingUserLocation) locationComponent.cameraMode = CameraMode.NONE else locationComponent.cameraMode = CameraMode.TRACKING // CameraMode.TRACKING makes the camera move and jump to the location setFollowingUser(!followingUserLocation) } } locationManager = requireActivity().getSystemService(Context.LOCATION_SERVICE) as LocationManager if (Permissions.bothLocationPermissionsGranted(requireContext())) { requestInitialUserLocation() } else{ if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)) { //TODO: show dialog for permission rationale Toast.makeText(activity, R.string.enable_position_message_map, Toast.LENGTH_SHORT) .show() } positionRequestLauncher.launch(Permissions.LOCATION_PERMISSIONS) } // Setup close button rootView.findViewById(R.id.btnClose).setOnClickListener { hideStopBottomSheet() } 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 mjson = Styles.getJsonStyleFromAsset(requireContext(), "map_style_good_noshops.json") + val context = requireContext() + val mjson = Styles.getJsonStyleFromAsset(context, PreferencesHolder.getMapLibreStyleFile(context)) //ViewUtils.loadJsonFromAsset(requireContext(),"map_style_good.json") activity?.run { val builder = Style.Builder().fromJson(mjson!!) mapReady.setStyle(builder) { style -> mapStyle = style //setupLayers(style) symbolManager = SymbolManager(mapView,mapReady,style) symbolManager.iconAllowOverlap = true symbolManager.textAllowOverlap = false symbolManager.addClickListener{ _ -> if (stopActiveSymbol!=null){ hideStopBottomSheet() return@addClickListener true } else return@addClickListener false } // Start observing data observeStops() initMapLocation(style, mapReady, requireContext()) //init stop layer with this val stopsInCache = stopsViewModel.getAllStopsLoaded() if(stopsInCache.isEmpty()) initStopsLayer(style, FeatureCollection.fromFeatures(ArrayList())) else displayStops(stopsInCache) if(showBusLayer) setupBusLayer(style) } mapReady.addOnCameraIdleListener { map?.let { val newBbox = it.projection.visibleRegion.latLngBounds if ((newBbox.center==lastBBox.center) && (newBbox.latitudeSpan==lastBBox.latitudeSpan) && (newBbox.longitudeSpan==lastBBox.latitudeSpan)){ //do nothing } else { stopsViewModel.loadStopsInLatLngBounds(newBbox) lastBBox = newBbox } } } mapReady.addOnCameraMoveStartedListener { map?.let { setFollowingUser(it.locationComponent.cameraMode == CameraMode.TRACKING) } //setFollowingUser() } mapReady.addOnMapClickListener { point -> val screenPoint = mapReady.projection.toScreenLocation(point) val features = mapReady.queryRenderedFeatures(screenPoint, STOPS_LAYER_ID) val busNearby = mapReady.queryRenderedFeatures(screenPoint, BUSES_LAYER_ID) if (features.isNotEmpty()) { val feature = features[0] val id = feature.getStringProperty("id") val name = feature.getStringProperty("name") //Toast.makeText(requireContext(), "Clicked on $name ($id)", Toast.LENGTH_SHORT).show() val stop = stopsViewModel.getStopByID(id) stop?.let { if (isBottomSheetShowing){ hideStopBottomSheet() } openStopInBottomSheet(it) isBottomSheetShowing = true //move camera if(it.latitude!=null && it.longitude!=null) //mapReady.cameraPosition = CameraPosition.Builder().target(LatLng(it.latitude!!, it.longitude!!)).build() mapReady.animateCamera(CameraUpdateFactory.newLatLng(LatLng(it.latitude!!,it.longitude!!)),750) } return@addOnMapClickListener true } else if (busNearby.isNotEmpty()){ val feature = busNearby[0] val vehid = feature.getStringProperty("veh") val route = feature.getStringProperty("line") if(context!=null){ Toast.makeText(context, "Veh $vehid on route $route", Toast.LENGTH_SHORT).show() } return@addOnMapClickListener true } false } mapInitCompleted = true // we start requesting the bus positions now startRequestingPositions() } savedMapStateOnPause?.let{ restoreMapStateFromBundle(it) pendingLocationActivation = false Log.d(DEBUG_TAG, "Restored map state from the saved bundle") } //reset saved State at the end if( savedMapStateOnPause == null) { //set initial position val zoom = 15.0 //center position val latlngTarget = initialStopToShow?.let { LatLng(it.latitude!!, it.longitude!!) } ?: LatLng(DEFAULT_CENTER_LAT, DEFAULT_CENTER_LON) mapReady.cameraPosition = CameraPosition.Builder().target(latlngTarget).zoom(zoom).build() } //reset saved state savedMapStateOnPause = null } private fun initStopsLayer(style: Style, features:FeatureCollection){ stopsSource = GeoJsonSource(STOPS_SOURCE_ID,features) style.addSource(stopsSource) // add icon style.addImage(STOP_IMAGE_ID, ResourcesCompat.getDrawable(resources,R.drawable.bus_stop_new, activity?.theme)!!) style.addImage(STOP_ACTIVE_IMG, ResourcesCompat.getDrawable(resources, R.drawable.bus_stop_new_highlight, activity?.theme)!!) // Stops layer val stopsLayer = SymbolLayer(STOPS_LAYER_ID, STOPS_SOURCE_ID) stopsLayer.withProperties( PropertyFactory.iconImage(STOP_IMAGE_ID), PropertyFactory.iconAllowOverlap(true), PropertyFactory.iconIgnorePlacement(true) ) style.addLayerBelow(stopsLayer, "label_country_1") stopsLayerStarted = true } /** * Setup the Map Layers */ private fun setupBusLayer(style: Style) { // Buses source busesSource = GeoJsonSource(BUSES_SOURCE_ID) style.addSource(busesSource) style.addImage("bus_symbol",ResourcesCompat.getDrawable(resources, R.drawable.map_bus_position_icon, activity?.theme)!!) // Buses layer val busesLayer = SymbolLayer(BUSES_LAYER_ID, BUSES_SOURCE_ID).apply { withProperties( PropertyFactory.iconImage("bus_symbol"), PropertyFactory.iconSize(1.2f), PropertyFactory.iconAllowOverlap(true), PropertyFactory.iconIgnorePlacement(true), PropertyFactory.iconRotate(Expression.get("bearing")), PropertyFactory.iconRotationAlignment(ICON_ROTATION_ALIGNMENT_MAP) ) } style.addLayerAbove(busesLayer, STOPS_LAYER_ID) //Line names layer /*vehiclesLabelsSource = GeoJsonSource(LABELS_SOURCE) style.addSource(vehiclesLabelsSource) val textLayer = SymbolLayer(LABELS_LAYER_ID, LABELS_SOURCE).apply { withProperties( PropertyFactory.textField("label"), PropertyFactory.textSize(30f), PropertyFactory.textColor(Color.BLACK), //PropertyFactory.textHaloColor(Color.BLACK), //PropertyFactory.textHaloWidth(1f), PropertyFactory.textAnchor(TEXT_ANCHOR_CENTER), PropertyFactory.textAllowOverlap(true), PropertyFactory.textRotationAlignment(TEXT_ROTATION_ALIGNMENT_VIEWPORT) ) } style.addLayerAbove(textLayer, BUSES_LAYER_ID) */ } /** * Update the bottom sheet with the stop information */ private fun openStopInBottomSheet(stop: Stop){ bottomLayout?.let { //lay.findViewById(R.id.stopTitleTextView).text ="${stop.ID} - ${stop.stopDefaultName}" val stopName = stop.stopUserName ?: stop.stopDefaultName stopTitleTextView.text = stopName//stop.stopDefaultName stopNumberTextView.text = stop.ID val string_show = if (stop.numRoutesStopping==0) "" else if (stop.numRoutesStopping <= 1) requireContext().getString(R.string.line_fill, stop.routesThatStopHereToString()) else requireContext().getString(R.string.lines_fill, stop.routesThatStopHereToString()) linesPassingTextView.text = string_show //SET ON CLICK LISTENER arrivalsCard.setOnClickListener{ fragmentListener?.requestArrivalsForStopID(stop.ID) } directionsCard.setOnClickListener { if(stop.latitude==null || stop.longitude==null){ //TODO: show message Log.e(DEBUG_TAG, "Navigate to stop but longitude and/or latitude are null") }else{ val uri = "geo:?q=${stop.latitude},${stop.longitude}(${stop.ID} - $stopName)" val intent =Intent(Intent.ACTION_VIEW, Uri.parse(uri)) context?.run{ if(intent.resolveActivity(packageManager)!=null){ startActivity(intent) } } } } } //add stop marker if (stop.latitude!=null && stop.longitude!=null) { stopActiveSymbol = symbolManager.create( SymbolOptions() .withLatLng(LatLng(stop.latitude!!, stop.longitude!!)) .withIconImage(STOP_ACTIVE_IMG) .withIconAnchor(ICON_ANCHOR_CENTER) ) } shownStopInBottomSheet = stop bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED } 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() mapView.onStart() } override fun onResume() { super.onResume() mapView.onResume() val keySourcePositions = getString(R.string.pref_positions_source) if(showBusLayer) { useMQTTViewModel = PreferenceManager.getDefaultSharedPreferences(requireContext()) .getString(keySourcePositions, LIVE_POSITIONS_PREF_MQTT_VALUE) .contentEquals(LIVE_POSITIONS_PREF_MQTT_VALUE) if (useMQTTViewModel) livePositionsViewModel.requestMatoPosUpdates(MQTTMatoClient.LINES_ALL) else livePositionsViewModel.requestGTFSUpdates() //mapViewModel.testCascade(); livePositionsViewModel.isLastWorkResultGood.observe(this) { d: Boolean -> Log.d( DEBUG_TAG, "Last trip download result is $d" ) } livePositionsViewModel.tripsGtfsIDsToQuery.observe(this) { dat: List -> Log.i(DEBUG_TAG, "Have these trips IDs missing from the DB, to be queried: $dat") livePositionsViewModel.downloadTripsFromMato(dat) } } fragmentListener?.readyGUIfor(FragmentKind.MAP) } override fun onPause() { super.onPause() mapView.onPause() Log.d(DEBUG_TAG, "Fragment paused") savedMapStateOnPause = saveMapStateInBundle() if (useMQTTViewModel) livePositionsViewModel.stopMatoUpdates() } override fun onStop() { super.onStop() mapView.onStop() Log.d(DEBUG_TAG, "Fragment stopped!") savedStateOnStop = Bundle().let { mapView.onSaveInstanceState(it) it } } override fun onLowMemory() { super.onLowMemory() mapView.onLowMemory() } override fun onDestroy() { super.onDestroy() mapView.onDestroy() Log.d(DEBUG_TAG, "Destroyed map Fragment!!") } override fun getBaseViewForSnackBar(): View? { return mapView } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) mapView.onSaveInstanceState(outState) Log.d(DEBUG_TAG, "Saved instanceState") } 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) { openStopInBottomSheet(s) initialStopShown = true } } } } /** * Add the stops to the layers */ private fun displayStops(stops: List?) { if (stops.isNullOrEmpty()) return if (stops.size==lastStopsSizeShown){ Log.d(DEBUG_TAG, "Not updating, have same number of stops. After 3 times") return } /*if(stops.size> lastStopsSizeShown){ stopsRedrawnTimes = 0 } else{ stopsRedrawnTimes++ } */ val features = ArrayList()//stops.mapNotNull { stop -> //stop.latitude?.let { lat -> // stop.longitude?.let { lon -> for (s in stops){ if (s.latitude!=null && s.longitude!=null) features.add( 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 } ) ) } Log.d(DEBUG_TAG,"Have put ${features.size} stops to display") // if the layer is already started, substitute the stops inside, otherwise start it if (stopsLayerStarted) { stopsSource.setGeoJson(FeatureCollection.fromFeatures(features)) lastStopsSizeShown = features.size } else map?.let { Log.d(DEBUG_TAG, "Map stop layer is not started yet, init layer") initStopsLayer(mapStyle, FeatureCollection.fromFeatures(features)) Log.d(DEBUG_TAG,"Started stops layer on map") lastStopsSizeShown = features.size stopsLayerStarted = true } } // Hide the bottom sheet and remove extra symbol private fun hideStopBottomSheet(){ if (stopActiveSymbol!=null){ symbolManager.delete(stopActiveSymbol) stopActiveSymbol = null } bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN isBottomSheetShowing = false //remove initial stop if(initialStopToShow!=null){ initialStopToShow = null } shownStopInBottomSheet = null } // --------------- BUS LOCATIONS STUFF -------------------------- /** * Start requesting position updates */ private fun startRequestingPositions() { livePositionsViewModel.updatesWithTripAndPatterns.observe(viewLifecycleOwner) { data: HashMap> -> Log.d( DEBUG_TAG, "Have " + data.size + " trip updates, has Map start finished: " + mapInitCompleted ) if (mapInitCompleted) updateBusPositionsInMap(data) if (!isDetached && !useMQTTViewModel) livePositionsViewModel.requestDelayedGTFSUpdates( 3000 ) } } private fun isInsideVisibleRegion(latitude: Double, longitude: Double, nullValue: Boolean): Boolean{ var isInside = nullValue val visibleRegion = map?.projection?.visibleRegion visibleRegion?.let { val bounds = it.latLngBounds isInside = bounds.contains(LatLng(latitude, longitude)) } return isInside } private fun createLabelForVehicle(positionUpdate: LivePositionUpdate){ val newSymbol = symbolManager.create(SymbolOptions() .withLatLng(LatLng(positionUpdate.latitude, positionUpdate.longitude)) .withTextColor("#ffffff") .withTextField(positionUpdate.routeID.substringBeforeLast('U')) .withTextSize(13f) .withTextAnchor(TEXT_ANCHOR_CENTER) ) busLabelSymbolsByVeh[positionUpdate.vehicle] = newSymbol } private fun removeVehicleLabel(vehicle: String){ busLabelSymbolsByVeh[vehicle]?.let { symbolManager.delete(it) busLabelSymbolsByVeh.remove(vehicle) } } /** * Update function for the bus positions * Takes the processed updates and saves them accordingly */ private fun updateBusPositionsInMap(incomingData: HashMap>){ val vehsNew = HashSet(incomingData.values.map { up -> up.first.vehicle }) val vehsOld = HashSet(positionsByVehDict.keys) val symbolsToUpdate = ArrayList() for (upsWithTrp in incomingData.values){ val pos = upsWithTrp.first val vehID = pos.vehicle var animate = false if (vehsOld.contains(vehID)){ //update position only if the starting or the stopping position of the animation are in the view val oldPos = positionsByVehDict[vehID] var avoidShowingUpdateBecauseIsImpossible = false oldPos?.let{ if(oldPos.routeID!=pos.routeID) { val dist = LatLng(it.latitude, it.longitude).distanceTo(LatLng(pos.latitude, pos.longitude)) val speed = dist*3.6 / (pos.timestamp - it.timestamp) //this should be in km/h Log.w(DEBUG_TAG, "Vehicle $vehID changed route from ${oldPos.routeID} to ${pos.routeID}, distance: $dist, speed: $speed") if (speed > 120 || speed < 0){ avoidShowingUpdateBecauseIsImpossible = true } } } if (avoidShowingUpdateBecauseIsImpossible){ // DO NOT SHOW THIS SHIT Log.w(DEBUG_TAG, "Update for vehicle $vehID skipped") continue } val samePosition = oldPos?.let { (oldPos.latitude==pos.latitude)&&(oldPos.longitude == pos.longitude) }?:false if(!samePosition) { val isPositionInBounds = isInsideVisibleRegion( pos.latitude, pos.longitude, true ) || (oldPos?.let { isInsideVisibleRegion(it.latitude,it.longitude,true) } ?: false) if (isPositionInBounds) { //animate = true //this moves both the icon and the label moveVehicleToNewPosition(pos) } else { positionsByVehDict[vehID] = pos busLabelSymbolsByVeh[vehID]?.let { it.latLng = LatLng(pos.latitude, pos.longitude) symbolsToUpdate.add(it) } } } } else{ // update it simply positionsByVehDict[vehID] = pos createLabelForVehicle(pos) } } symbolManager.update(symbolsToUpdate) //remove old positions vehsOld.removeAll(vehsNew) //now vehsOld contains the vehicles id for those that have NOT been updated val currentTimeStamp = System.currentTimeMillis() /1000 for(vehID in vehsOld){ //remove after 2 minutes of inactivity if (positionsByVehDict[vehID]!!.timestamp - currentTimeStamp > 2*60){ positionsByVehDict.remove(vehID) removeVehicleLabel(vehID) } } //update UI updatePositionsIcons() } /** * This is the tricky part, animating the transitions * Basically, we need to set the new positions with the data and redraw them all */ private fun moveVehicleToNewPosition(positionUpdate: LivePositionUpdate){ if (positionUpdate.vehicle !in positionsByVehDict.keys) return val vehID = positionUpdate.vehicle val currentUpdate = positionsByVehDict[positionUpdate.vehicle] currentUpdate?.let { it -> //cancel current animation on vehicle animatorsByVeh[vehID]?.cancel() val currentPos = LatLng(it.latitude, it.longitude) val newPos = LatLng(positionUpdate.latitude, positionUpdate.longitude) - val valueAnimator = ValueAnimator.ofObject(MapUtils.LatLngEvaluator(), currentPos, newPos) + val valueAnimator = ValueAnimator.ofObject(MapLibreUtils.LatLngEvaluator(), currentPos, newPos) valueAnimator.addUpdateListener(object : ValueAnimator.AnimatorUpdateListener { private var latLng: LatLng? = null override fun onAnimationUpdate(animation: ValueAnimator) { latLng = animation.animatedValue as LatLng //update position on animation val update = positionsByVehDict[positionUpdate.vehicle]!! latLng?.let { ll-> update.latitude = ll.latitude update.longitude = ll.longitude updatePositionsIcons() } } }) valueAnimator.addListener(object : AnimatorListenerAdapter() { override fun onAnimationStart(animation: Animator) { super.onAnimationStart(animation) //val update = positionsByVehDict[positionUpdate.vehicle]!! //remove the label at the start of the animation //removeVehicleLabel(vehID) val annot = busLabelSymbolsByVeh[vehID] annot?.let { sym -> sym.textOpacity = 0.0f symbolsToUpdate.add(sym) } } override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) //recreate the label at the end of the animation //createLabelForVehicle(positionUpdate) val annot = busLabelSymbolsByVeh[vehID] annot?.let { sym -> sym.textOpacity = 1.0f sym.latLng = newPos //LatLng(newPos) symbolsToUpdate.add(sym) } } }) //set the new position as the current one but with the old lat and lng positionUpdate.latitude = currentUpdate.latitude positionUpdate.longitude = currentUpdate.longitude positionsByVehDict[vehID] = positionUpdate valueAnimator.duration = 300 valueAnimator.interpolator = LinearInterpolator() valueAnimator.start() animatorsByVeh[vehID] = valueAnimator } ?: { Log.e(DEBUG_TAG, "Have to run animation for veh ${positionUpdate.vehicle} but not in the dict, adding") positionsByVehDict[positionUpdate.vehicle] = positionUpdate } } /** * Update the bus positions displayed on the map, from the existing data */ private fun updatePositionsIcons(){ //avoid frequent updates val currentTime = System.currentTimeMillis() if(currentTime - lastUpdateTime < 60){ //DO NOT UPDATE THE MAP return } val features = ArrayList()//stops.mapNotNull { stop -> //stop.latitude?.let { lat -> // stop.longitude?.let { lon -> for (pos in positionsByVehDict.values){ //if (s.latitude!=null && s.longitude!=null) val point = Point.fromLngLat(pos.longitude, pos.latitude) features.add( Feature.fromGeometry( point, JsonObject().apply { addProperty("veh", pos.vehicle) addProperty("trip", pos.tripID) addProperty("bearing", pos.bearing ?:0.0f) addProperty("line", pos.routeID) } ) ) /*busLabelSymbolsByVeh[pos.vehicle]?.let { it.latLng = LatLng(pos.latitude, pos.longitude) symbolsToUpdate.add(it) } */ } busesSource.setGeoJson(FeatureCollection.fromFeatures(features)) //update labels, clear cache to be used symbolManager.update(symbolsToUpdate) symbolsToUpdate.clear() lastUpdateTime = System.currentTimeMillis() } // ------ 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() pendingLocationActivation = true 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, "You are too far, not showing the position", Toast.LENGTH_SHORT).show() } } override fun onProviderDisabled(provider: String) {} override fun onProviderEnabled(provider: String) {} @Deprecated("Deprecated in Java") override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} }, null) } /** * Initialize the map location, but do not enable the component */ @SuppressLint("MissingPermission") private fun initMapLocation(style: Style, map: MapLibreMap, context: Context){ locationComponent = map.locationComponent val locationComponentOptions = LocationComponentOptions.builder(context) .pulseEnabled(true) .build() val locationComponentActivationOptions = - MapUtils.buildLocationComponentActivationOptions(style, locationComponentOptions, context) + MapLibreUtils.buildLocationComponentActivationOptions(style, locationComponentOptions, context) locationComponent.activateLocationComponent(locationComponentActivationOptions) locationComponent.isLocationComponentEnabled = false lastLocation?.let { if (it.accuracy < 200) locationComponent.forceLocationUpdate(it) } } /** * 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") locationComponent.isLocationComponentEnabled = true if (initialStopToShow==null) { locationComponent.cameraMode = CameraMode.TRACKING //CameraMode.TRACKING setFollowingUser(true) } setLocationIconEnabled(true) if (fromClick) Toast.makeText(context, R.string.location_enabled, Toast.LENGTH_SHORT).show() } else { if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)) { //TODO: show dialog for permission rationale Toast.makeText(activity, R.string.enable_position_message_map, Toast.LENGTH_SHORT).show() } Log.d(DEBUG_TAG, "Requesting permission to show user location") enablingPositionFromClick = fromClick showUserPositionRequestLauncher.launch(Permissions.LOCATION_PERMISSIONS) } } else{ locationComponent.isLocationComponentEnabled = false setFollowingUser(false) setLocationIconEnabled(false) if (fromClick) { Toast.makeText(requireContext(), R.string.location_disabled, Toast.LENGTH_SHORT).show() if(pendingLocationActivation) pendingLocationActivation=false //Cancel the request for the enablement of the position } } } private fun setLocationIconEnabled(enabled: Boolean){ if (enabled) showUserPositionButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_red)) else showUserPositionButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_grey)) } /** * Helper method for GUI */ private fun updateFollowingIcon(enabled: Boolean){ if(enabled) followUserButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_follow_me_on)) else followUserButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_follow_me)) } private fun setFollowingUser(following: Boolean){ updateFollowingIcon(following) followingUserLocation = following if(following) ignoreCameraMovementForFollowing = true } private fun switchUserLocationStatus(view: View?){ if(pendingLocationActivation || locationComponent.isLocationComponentEnabled) setMapLocationEnabled(false, false, true) else{ Log.d(DEBUG_TAG, "Request enable location") setMapLocationEnabled(true, false, true) } } private fun saveMapStateBeforePause(bundle: Bundle){ map?.let { val newBbox = it.projection.visibleRegion.latLngBounds 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) } } shownStopInBottomSheet?.let { bundle.putBundle("shown_stop", it.toBundle()) } } private fun saveMapStateInBundle(): Bundle { val b = Bundle() saveMapStateBeforePause(b) return b } private fun restoreMapStateFromBundle(bundle: Bundle){ val latCenter = bundle.getDouble("center_map_lat", -10.0) val lonCenter = bundle.getDouble("center_map_lon",-10.0) val zoom = bundle.getDouble("map_zoom", -10.0) if(lonCenter>=0 &&latCenter>=0) map?.let { val newPos = CameraPosition.Builder().target(LatLng(latCenter,lonCenter)) if(zoom>0) newPos.zoom(zoom) it.cameraPosition=newPos.build() } val mStop = bundle.getBundle("shown_stop")?.let { Stop.fromBundle(it) } mStop?.let { openStopInBottomSheet(it) } } companion object { private const val STOPS_SOURCE_ID = "stops-source" private const val STOPS_LAYER_ID = "stops-layer" private const val STOPS_LAYER_SEL_ID ="stops-layer-selected" private const val BUSES_SOURCE_ID = "buses-source" private const val BUSES_LAYER_ID = "buses-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 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 ACCESS_TOKEN="KxO8lF4U3kiO63m0c7lzqDCDrMUVg1OA2JVzRXxxmYSyjugr1xpe4W4Db5rFNvbQ" //private const val MAPLIBRE_URL = "https://api.jawg.io/styles/" private const val DEBUG_TAG = "BusTO-MapLibreFrag" private const val STOP_ACTIVE_IMG = "Stop-active" 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) } } //private fun makeStyleUrl(style: String = "jawg-streets") = // "${MAPLIBRE_URL+ style}.json?access-token=${ACCESS_TOKEN}" private fun makeStyleMapBoxUrl(dark: Boolean) = if(dark) "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json" else //"https://basemaps.cartocdn.com/gl/positron-gl-style/style.json" "https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json" const val OPENFREEMAP_LIBERY = "https://tiles.openfreemap.org/styles/liberty" const val OPENFREEMAP_BRIGHT = "https://tiles.openfreemap.org/styles/bright" } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/SettingsFragment.java b/app/src/main/java/it/reyboz/bustorino/fragments/SettingsFragment.java index 2ac5f0c..944b588 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/SettingsFragment.java +++ b/app/src/main/java/it/reyboz/bustorino/fragments/SettingsFragment.java @@ -1,232 +1,234 @@ /* BusTO - Fragments components Copyright (C) 2020 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.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; import android.os.Handler; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.lifecycle.Observer; import androidx.preference.*; import androidx.work.OneTimeWorkRequest; import androidx.work.WorkInfo; import androidx.work.WorkManager; import it.reyboz.bustorino.ActivityBackup; import it.reyboz.bustorino.R; import it.reyboz.bustorino.data.DatabaseUpdate; import it.reyboz.bustorino.data.GtfsMaintenanceWorker; import org.jetbrains.annotations.NotNull; import java.lang.ref.WeakReference; import java.util.HashSet; import java.util.List; public class SettingsFragment extends PreferenceFragmentCompat implements SharedPreferences.OnSharedPreferenceChangeListener { private static final String TAG = SettingsFragment.class.getName(); private static final String DIALOG_FRAGMENT_TAG = "androidx.preference.PreferenceFragment.DIALOG"; //private static final Handler mHandler; + // Matching preferences.xml public final static String PREF_KEY_STARTUP_SCREEN="startup_screen_to_show"; public final static String KEY_ARRIVALS_FETCHERS_USE = "arrivals_fetchers_use_setting"; public final static String LIVE_POSITIONS_PREF_MQTT_VALUE="mqtt"; + public final static String LIBREMAP_STYLE_PREF_KEY = "libremap_style"; private boolean setSummaryStartupPref = false; @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { mHandler = new Handler(); return super.onCreateView(inflater, container, savedInstanceState); } @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { //getPreferenceManager().setSharedPreferencesName(getString(R.string.mainSharedPreferences)); convertStringPrefToIntIfNeeded(getString(R.string.pref_key_num_recents), getContext()); getPreferenceManager().getSharedPreferences().registerOnSharedPreferenceChangeListener(this); setPreferencesFromResource(R.xml.preferences,rootKey); /*EditTextPreference editPref = findPreference(getString(R.string.pref_key_num_recents)); editPref.setOnBindEditTextListener(editText -> { editText.setInputType(InputType.TYPE_CLASS_NUMBER); editText.setSelection(0,editText.getText().length()); }); */ ListPreference startupScreenPref = findPreference(PREF_KEY_STARTUP_SCREEN); if(startupScreenPref !=null){ if (startupScreenPref.getValue()==null){ startupScreenPref.setSummary(getString(R.string.nav_arrivals_text)); setSummaryStartupPref = true; } } //Log.d("BusTO-PrefFrag","startup screen pref is "+startupScreenPref.getValue()); Preference dbUpdateNow = findPreference("pref_db_update_now"); if (dbUpdateNow!=null) dbUpdateNow.setOnPreferenceClickListener( preference -> { //trigger update if(getContext()!=null) { DatabaseUpdate.requestDBUpdateWithWork(getContext().getApplicationContext(), true, true); Toast.makeText(getContext(),R.string.requesting_db_update,Toast.LENGTH_SHORT).show(); return true; } return false; } ); //set click listener on backup item final Preference backupPref = findPreference("pref_backup_open"); if (backupPref!=null) backupPref.setOnPreferenceClickListener( preference -> { if(getActivity()!=null){ startActivity( new Intent(getActivity().getApplicationContext(), ActivityBackup.class) ); return true; } else { return false; } } ); else { Log.e("BusTO-Preferences", "Cannot find db update preference"); } Preference clearGtfsTrips = findPreference("pref_clear_gtfs_trips"); if (clearGtfsTrips != null) { clearGtfsTrips.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { @Override public boolean onPreferenceClick(@NonNull @NotNull Preference preference) { if (getContext() != null) { OneTimeWorkRequest requ = GtfsMaintenanceWorker.Companion.makeOneTimeRequest(GtfsMaintenanceWorker.CLEAR_GTFS_TRIPS); WorkManager.getInstance(getContext()).enqueue(requ); WorkManager.getInstance(getContext()).getWorkInfosByTagLiveData(GtfsMaintenanceWorker.CLEAR_GTFS_TRIPS).observe(getViewLifecycleOwner(), (Observer>) workInfos -> { if(workInfos.isEmpty()) return; if(workInfos.get(0).getState()==(WorkInfo.State.SUCCEEDED)){ Toast.makeText( getContext(), R.string.all_trips_removed, Toast.LENGTH_SHORT ).show(); } }); return true; } return false; } }); } } @Override public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { Preference pref = findPreference(key); Log.d(TAG,"Preference key "+key+" changed"); if (key.equals(SettingsFragment.KEY_ARRIVALS_FETCHERS_USE)){ Log.d(TAG, "New value is: "+sharedPreferences.getStringSet(key, new HashSet<>())); } //sometimes this happens if(getContext()==null) return; if(key.equals(PREF_KEY_STARTUP_SCREEN) && setSummaryStartupPref && pref !=null){ ListPreference listPref = (ListPreference) pref; pref.setSummary(listPref.getEntry()); } /* THIS CODE STAYS COMMENTED FOR FUTURE REFERENCES if (key.equals(getString(R.string.pref_key_num_recents))){ //check that is it an int String value = sharedPreferences.getString(key,""); boolean valid = value.length() != 0; try{ Integer intValue = Integer.parseInt(value); } catch (NumberFormatException ex){ valid = false; } if (!valid){ Toast.makeText(getContext(), R.string.invalid_number, Toast.LENGTH_SHORT).show(); if(pref instanceof EditTextPreference){ EditTextPreference prefEdit = (EditTextPreference) pref; //Intent intent = prefEdit.getIntent(); Log.d(TAG, "opening preference, dialog showing "+ (getParentFragmentManager().findFragmentByTag(DIALOG_FRAGMENT_TAG)!=null) ); //getPreferenceManager().showDialog(pref); //onDisplayPreferenceDialog(prefEdit); mHandler.postDelayed(new DelayedDisplay(prefEdit), 500); } } } */ Log.d("BusTO Settings", "changed "+key+"\n "+sharedPreferences.getAll()); } private void convertStringPrefToIntIfNeeded(String preferenceKey, Context con){ if (con == null) return; SharedPreferences defaultSharedPref = PreferenceManager.getDefaultSharedPreferences(con); try{ Integer val = defaultSharedPref.getInt(preferenceKey, 0); } catch (NumberFormatException | ClassCastException ex){ //convert the preference //final String preferenceNumRecents = getString(R.string.pref_key_num_recents); Log.d("Preference - BusTO", "Converting to integer the string preference "+preferenceKey); String currentValue = defaultSharedPref.getString(preferenceKey, "10"); int newValue; try{ newValue = Integer.parseInt(currentValue); } catch (NumberFormatException e){ newValue = 10; } final SharedPreferences.Editor editor = defaultSharedPref.edit(); editor.remove(preferenceKey); editor.putInt(preferenceKey, newValue); editor.apply(); } } class DelayedDisplay implements Runnable{ private final WeakReference preferenceWeakReference; public DelayedDisplay(DialogPreference preference) { this.preferenceWeakReference = new WeakReference<>(preference); } @Override public void run() { if(preferenceWeakReference.get()==null) return; getPreferenceManager().showDialog(preferenceWeakReference.get()); } } } diff --git a/app/src/main/java/it/reyboz/bustorino/map/MapUtils.kt b/app/src/main/java/it/reyboz/bustorino/map/MapLibreUtils.kt similarity index 89% rename from app/src/main/java/it/reyboz/bustorino/map/MapUtils.kt rename to app/src/main/java/it/reyboz/bustorino/map/MapLibreUtils.kt index 0bd3b47..3f6a0bc 100644 --- a/app/src/main/java/it/reyboz/bustorino/map/MapUtils.kt +++ b/app/src/main/java/it/reyboz/bustorino/map/MapLibreUtils.kt @@ -1,50 +1,56 @@ package it.reyboz.bustorino.map import android.animation.TypeEvaluator import android.content.Context +import androidx.preference.PreferenceManager import org.maplibre.android.geometry.LatLng import org.maplibre.android.location.LocationComponentActivationOptions import org.maplibre.android.location.LocationComponentOptions import org.maplibre.android.location.engine.LocationEngineRequest import org.maplibre.android.maps.Style -class MapUtils { +class MapLibreUtils { companion object{ + const val STYLE_BRIGHT_DEFAULT_JSON = "map_style_good_noshops.json" + const val STYLE_VERSATILES_COLORFUL_JSON = "versatiles_colorful_light.json" + @JvmStatic fun shortestRotation(from: Float, to: Float): Float { var delta = (to - from) % 360 if (delta > 180) delta -= 360 if (delta < -180) delta += 360 return from + delta } @JvmStatic fun buildLocationComponentActivationOptions( style: Style, locationComponentOptions: LocationComponentOptions, context: Context ): LocationComponentActivationOptions { return LocationComponentActivationOptions .builder(context, style) .locationComponentOptions(locationComponentOptions) .useDefaultLocationEngine(true) .locationEngineRequest( LocationEngineRequest.Builder(750) .setFastestInterval(750) .setPriority(LocationEngineRequest.PRIORITY_HIGH_ACCURACY) .build() ) .build() } } //TODO: Do the same for LatLng and bearing, if possible class LatLngEvaluator : TypeEvaluator { private val latLng = LatLng() override fun evaluate(fraction: Float, startValue: LatLng, endValue: LatLng): LatLng { latLng.latitude = startValue.latitude + (endValue.latitude - startValue.latitude) * fraction latLng.longitude = startValue.longitude + (endValue.longitude - startValue.longitude) * fraction return latLng } } + + } \ No newline at end of file diff --git a/app/src/main/res/values/keys.xml b/app/src/main/res/values/keys.xml index 38a9ea6..433691a 100644 --- a/app/src/main/res/values/keys.xml +++ b/app/src/main/res/values/keys.xml @@ -1,33 +1,39 @@ layout_pref pref_update_db_now + libremap_style arrivals favorites map lines matofetcher fivetapifetcher gttjsonfetcher fivetscraper matofetcher gttjsonfetcher fivetscraper pref_positions_source mqtt gtfsrt + + bright + versatiles_c + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dabc127..5ea91e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,338 +1,345 @@ BusTO Libre BusTO BusTO dev BusTO git You\'re using the latest in technology when it comes to respecting your privacy. Search Scan QR Code Yes No Next Previous Install Barcode Scanner? This application requires an app to scan the QR codes. Would you like to install Barcode Scanner now? Bus stop number Bus stop name Insert bus stop number Insert bus stop name %1$s towards %2$s %s (unknown destination) Verify your Internet connection! Seems that no bus stop have this name No arrivals found for this stop Error parsing the 5T/GTT website (damn site!) Name too short, type more characters and retry Arrivals at: %1$s Choose the bus stop… Line Lines Urban lines Extra urban lines Tourist lines Destination: Lines: %1$s Line: %1$s No timetable found No QR code found, try using another app to scan Unexpected internal error, cannot extract data from GTT/5T website Help About the app More about Contribute https://gitpull.it/w/librebusto/en/ Source code Licence11 Meet the author Bus stop is now in your favorites Bus stop removed from your favorites Added line to favorites Remove line from favorites Favorites Favorites Favorites Map No favorites? Arghh! Press on a bus stop star to populate this list! Delete Rename Rename the bus stop Reset About the app Tap the star to add the bus stop to the favourites\n\nHow to read timelines:\n   12:56* Real-time arrivals\n   12:56   Scheduled arrivals\n\nPull down to refresh the timetable \n Long press on Arrivals source to change the source of the arrival times GOT IT! Arrival times No arrivals found for lines: Welcome!

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


Why use this app?

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


Introductory tutorial

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

]]>
News and Updates

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

]]>
How does it work?

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


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


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

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


Notes

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

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

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

Now you can hack public transport, too! :)

]]>
Cannot add to favorites (storage full or corrupted database?)! View on a map Cannot find any application to show it in Cannot find the position of the stop ListFragment - BusTO it.reyboz.bustorino.preferences db_is_updating Nearby stops Nearby connections App version The number of stops to show in the recent stops is invalid Invalid value, put a valid number Finding location No stops nearby Minimum number of stops Preferences Settings Settings General Experimental features Maximum distance (meters) Recent stops General settings Database management Launch manual database update Allow access to location to show it on the map Allow access to location to show stops nearby Please enable location on the device Database update in progress… Updating the database Force database update Touch to update the app database now is arriving at at the stop %1$s - %2$s Show arrivals Show stops Join Telegram channel Show introduction Center on my location Follow me Enable or disable location Location enabled Location disabled Location is disabled on device Arrivals source: %1$s GTT App GTT Website 5T Torino website Muoversi a Torino app Undetermined Changing arrival times source… Long press to change the source of arrivals @string/source_mato @string/fivetapifetcher @string/gttjsonfetcher @string/fivetscraper Sources of arrival times Select which sources of arrival times to use Default Default channel for notifications Database operations Updates of the app database BusTO - live position service Live positions Showing activity related to the live positions service MaTO live bus positions service is running Downloading trips from MaTO server Asked for %1$s permission too many times Cannot use the map with the storage permission! storage The application has crashed because you encountered a bug. \nIf you want, you can help the developers by sending the crash report via email. \nNote that no sensitive data is contained in the report, just small bits of info on your phone and app configuration/state. The application crashed and the crash report is in the attachments. Please describe what you were doing before the crash: \n Arrivals Map Favorites Open navigation drawer Close navigation drawer Experiments Buy us a coffee Map Search by stop Launching database update Downloading data from MaTO server Capitalize directions Do not change arrivals directions Capitalize everything Capitalize only first letter KEEP CAPITALIZE_ALL CAPITALIZE_FIRST Section to show on startup Touch to change it Show arrivals touching on stop Enable experiments Long press the stop for options @string/nav_arrivals_text @string/nav_favorites_text @string/nav_map_text @string/lines Source of real time positions for buses and trams MaTO (updated more frequently, might be offline) GTFS RT (more stable, less frequently updated) + Style of the map + OSM Bright + Versatiles + + @string/map_style_osm_bright + @string/map_style_versatiles + Remove trips data (free up space) All GTFS trips have been removed from the database Show tutorial open source app for Turin public transport. This is an independent app, with no ads and no tracking whatsoever.]]> favorites by touching the star next to its name]]> blue)]]> Settings to customize the app behaviour, and in the About the app section if you want to know more about the app and the developers.]]> Notifications permission to show the information about background processing. Press the button below to grant it]]> Grant location permission Location permission granted Location permission has not been granted OK, close the tutorial Close the tutorial Enable notifications Notifications enabled Backup and restore Import/export preferences Data saved Backup to file Import data from backup Backup has been imported Check at least one item to import! Import favorites from backup Import preferences from backup Hello blank fragment
diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 9f663b1..5338d24 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -1,116 +1,131 @@ + + + + + android:summary="%s" + + /> +