/* * Copyright (C) 2011 - 2013 Niall 'Rivernile' Scott * * This software is provided 'as-is', without any express or implied * warranty. In no event will the authors or contributors be held liable for * any damages arising from the use of this software. * * The aforementioned copyright holder(s) hereby grant you a * non-transferrable right to use this software for any purpose (including * commercial applications), and to modify it and redistribute it, subject to * the following conditions: * * 1. This notice may not be removed or altered from any file it appears in. * * 2. Any modifications made to this software, except those defined in * clause 3 of this agreement, must be released under this license, and * the source code of any modifications must be made available on a * publically accessible (and locateable) website, or sent to the * original author of this software. * * 3. Software modifications that do not alter the functionality of the * software but are simply adaptations to a specific environment are * exempt from clause 2. */ package uk.org.rivernile.edinburghbustracker.android.fragments.general; import android.app.Activity; import android.content.Context; import android.content.SharedPreferences; import android.content.pm.ResolveInfo; import android.database.Cursor; import android.location.Location; import android.location.LocationListener; import android.location.LocationManager; import android.os.Bundle; import android.support.v4.app.ListFragment; import android.support.v4.app.LoaderManager; import android.support.v4.content.Loader; import android.text.Html; import android.text.Spanned; import android.view.ContextMenu; import android.view.ContextMenu.ContextMenuInfo; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.AdapterView.AdapterContextMenuInfo; import android.widget.ArrayAdapter; import android.widget.ListView; import android.widget.TextView; import java.util.ArrayList; import java.util.Collections; import java.util.List; import uk.org.rivernile.android.utils.GenericUtils; import uk.org.rivernile.android.utils.LocationUtils; import uk.org.rivernile.android.utils.SimpleResultLoader; import uk.org.rivernile.edinburghbustracker.android.BusStopDatabase; import uk.org.rivernile.edinburghbustracker.android.PreferencesActivity; import uk.org.rivernile.edinburghbustracker.android.R; import uk.org.rivernile.edinburghbustracker.android.SettingsDatabase; import uk.org.rivernile.edinburghbustracker.android.fragments.dialogs .ServicesChooserDialogFragment; import uk.org.rivernile.edinburghbustracker.android.fragments.dialogs .TurnOnGpsDialogFragment; /** * Show a list of the nearest bus stops to the handset. If a location could not * be found or the user is too far away, an error message will be shown. * The user is able to filter the shown bus stops by what bus services stop * there. Long pressing on a bus stop shows a context menu where the user can * perform various actions on that stop. Tapping the stop shows bus times for * that stop. * * @author Niall Scott */ public class NearestStopsFragment extends ListFragment implements LoaderManager.LoaderCallbacks< ArrayList<NearestStopsFragment.SearchResult>>, LocationListener, ServicesChooserDialogFragment.Callbacks { private static final String ARG_CHOSEN_SERVICES = "chosenServices"; private static final int REQUEST_PERIOD = 10000; private static final float MIN_DISTANCE = 3.0f; private Callbacks callbacks; private LocationManager locMan; private SharedPreferences sp; private BusStopDatabase bsd; private SettingsDatabase sd; private Location lastLocation; private SearchResult selectedStop; private NearestStopsArrayAdapter ad; private String[] services; private String[] chosenServices; /** * {@inheritDoc} */ @Override public void onAttach(final Activity activity) { super.onAttach(activity); try { callbacks = (Callbacks) activity; } catch (ClassCastException e) { throw new IllegalStateException(activity.getClass().getName() + " does not implement " + Callbacks.class.getName()); } } /** * {@inheritDoc} */ @Override public void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); final Activity activity = getActivity(); // Get references to required resources. locMan = (LocationManager)activity .getSystemService(Context.LOCATION_SERVICE); sp = activity.getSharedPreferences(PreferencesActivity.PREF_FILE, 0); bsd = BusStopDatabase.getInstance(activity.getApplicationContext()); sd = SettingsDatabase.getInstance(activity.getApplicationContext()); // Create the ArrayAdapter for the ListView. ad = new NearestStopsArrayAdapter(activity); // Initialise the services chooser Dialog. services = bsd.getBusServiceList(); if (savedInstanceState != null) { chosenServices = savedInstanceState .getStringArray(ARG_CHOSEN_SERVICES); } else { // Check to see if GPS is enabled then check to see if the GPS // prompt dialog has been disabled. if(!locMan.isProviderEnabled(LocationManager.GPS_PROVIDER) && !sp.getBoolean(PreferencesActivity.PREF_DISABLE_GPS_PROMPT, false)) { // Get the list of Activities which can handle the enabling of // location services. final List<ResolveInfo> packages = activity.getPackageManager() .queryIntentActivities( TurnOnGpsDialogFragment.TURN_ON_GPS_INTENT, 0); // If the list is not empty, this means Activities do exist. // Show Dialog asking users if they want to turn on GPS. if(packages != null && !packages.isEmpty()) { callbacks.onAskTurnOnGps(); } } } // Tell the underlying Activity that this Fragment contains an options // menu. setHasOptionsMenu(true); } /** * {@inheritDoc} */ @Override public View onCreateView(final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { return inflater.inflate(R.layout.neareststops, container, false); } /** * {@inheritDoc} */ @Override public void onActivityCreated(final Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); // The ListView items can show a context menu. registerForContextMenu(getListView()); // Set the ListView adapter. setListAdapter(ad); // Initialise the lastLocation to the best known location. lastLocation = LocationUtils.getBestInitialLocation(locMan); // Force an update to initially show data. doUpdate(true); } /** * {@inheritDoc} */ @Override public void onResume() { super.onResume(); // Start the location providers if they are enabled. if(locMan.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { locMan.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, REQUEST_PERIOD, MIN_DISTANCE, this); } if(locMan.isProviderEnabled(LocationManager.GPS_PROVIDER)) { locMan.requestLocationUpdates(LocationManager.GPS_PROVIDER, REQUEST_PERIOD, MIN_DISTANCE, this); } } /** * {@inheritDoc} */ @Override public void onPause() { super.onPause(); // When the Activity is being paused, cancel location updates. locMan.removeUpdates(this); } /** * {@inheritDoc} */ @Override public void onSaveInstanceState(final Bundle outState) { super.onSaveInstanceState(outState); outState.putStringArray(ARG_CHOSEN_SERVICES, chosenServices); } /** * {@inheritDoc} */ @Override public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { // Inflate the menu. inflater.inflate(R.menu.neareststops_option_menu, menu); } /** * {@inheritDoc} */ @Override public void onPrepareOptionsMenu(final Menu menu) { super.onPrepareOptionsMenu(menu); final MenuItem item = menu .findItem(R.id.neareststops_option_menu_filter); item.setEnabled(services != null && services.length > 0); } /** * {@inheritDoc} */ @Override public boolean onOptionsItemSelected(final MenuItem item) { switch(item.getItemId()) { case R.id.neareststops_option_menu_filter: callbacks.onShowServicesChooser(services, chosenServices, getString(R.string.neareststops_service_chooser_title)); return true; default: return super.onOptionsItemSelected(item); } } /** * {@inheritDoc} */ @Override public void onCreateContextMenu(final ContextMenu menu, final View v, final ContextMenuInfo menuInfo) { super.onCreateContextMenu(menu, v, menuInfo); // Get the MenuInflater. final MenuInflater inflater = getActivity().getMenuInflater(); // Cast the menuInfo object to something we understand. final AdapterContextMenuInfo info = (AdapterContextMenuInfo)menuInfo; // Get the item relating to the selected item. selectedStop = ad.getItem(info.position); final String name; if(selectedStop.locality != null) { name = getString(R.string.busstop_locality, selectedStop.stopName, selectedStop.locality, selectedStop.stopCode); } else { name = getString(R.string.busstop, selectedStop.stopName, selectedStop.stopCode); } // Set the title of the context menu. menu.setHeaderTitle(name); // Inflate the menu from XML. inflater.inflate(R.menu.neareststops_context_menu, menu); // Title depends on whether it's already a favourite or not. MenuItem item = menu.findItem(R.id.neareststops_context_menu_favourite); if(sd.getFavouriteStopExists(selectedStop.stopCode)) { item.setTitle(R.string.neareststops_context_remasfav); } else { item.setTitle(R.string.neareststops_context_addasfav); } // Title depends on whether a proximity alert has already been added or // not. item = menu.findItem(R.id.neareststops_context_menu_prox_alert); if(sd.isActiveProximityAlert(selectedStop.stopCode)) { item.setTitle(R.string.neareststops_menu_prox_rem); } else { item.setTitle(R.string.neareststops_menu_prox_add); } // Title depends on whether a time alert has already been added or not. item = menu.findItem(R.id.neareststops_context_menu_time_alert); if(sd.isActiveTimeAlert(selectedStop.stopCode)) { item.setTitle(R.string.neareststops_menu_time_rem); } else { item.setTitle(R.string.neareststops_menu_time_add); } // If the Google Play Services is not available, then don't show the // option to show the stop on the map. item = menu.findItem(R.id.neareststops_context_menu_showonmap); if(!GenericUtils.isGoogleMapsAvailable(getActivity())) { item.setVisible(false); } } /** * {@inheritDoc} */ @Override public boolean onContextItemSelected(final MenuItem item) { // Make sure that the selectedStop exists. if(selectedStop == null) return false; switch(item.getItemId()) { case R.id.neareststops_context_menu_favourite: // See if this stop exists as a favourite already. if(sd.getFavouriteStopExists(selectedStop.stopCode)) { callbacks.onShowConfirmFavouriteDeletion( selectedStop.stopCode); } else { // If it doesn't exist, show the Add Favourite Stop // interface. callbacks.onShowAddFavouriteStop(selectedStop.stopCode, selectedStop.locality != null ? selectedStop.stopName + ", " + selectedStop.locality : selectedStop.stopName); } return true; case R.id.neareststops_context_menu_prox_alert: // See if this stop exists as a proximity alert. if(sd.isActiveProximityAlert(selectedStop.stopCode)) { callbacks.onShowConfirmDeleteProximityAlert(); } else { callbacks.onShowAddProximityAlert(selectedStop.stopCode); } return true; case R.id.neareststops_context_menu_time_alert: // See if this stop exists as a time alert. if(sd.isActiveTimeAlert(selectedStop.stopCode)) { callbacks.onShowConfirmDeleteTimeAlert(); } else { callbacks.onShowAddTimeAlert(selectedStop.stopCode); } return true; case R.id.neareststops_context_menu_showonmap: // Start the BusStopMapActivity, giving it a stopCode. callbacks.onShowBusStopMapWithStopCode(selectedStop.stopCode); return true; default: return super.onContextItemSelected(item); } } /** * {@inheritDoc} */ @Override public Loader<ArrayList<SearchResult>> onCreateLoader(final int id, final Bundle args) { // Create a new Loader. return new NearestStopsLoader(getActivity(), args); } /** * {@inheritDoc} */ @Override public void onLoadFinished(final Loader<ArrayList<SearchResult>> loader, final ArrayList<SearchResult> results) { if (isAdded()) { final ListView lv = getListView(); // Get the first visible position so the scroll position is restored // later. int currentIndex = lv.getFirstVisiblePosition(); final View v = lv.getChildAt(0); // When loading has finished, clear the ArrayAdapter and add in all // the new results. ad.clear(); ad.addAll(results); final int lastIndex = results.size() - 1; if(lastIndex < currentIndex) { // If the final index is less than the index before, then scroll // to the new final index. lv.setSelectionFromTop(lastIndex, 0); } else { // Otherwise, go to the previous exact scroll position. lv.setSelectionFromTop(currentIndex, (v == null) ? 0 : v.getTop()); } } } /** * {@inheritDoc} */ @Override public void onLoaderReset(final Loader<ArrayList<SearchResult>> loader) { // If the Loader has been reset, clear the SearchResults. ad.clear(); } /** * {@inheritDoc} */ @Override public void onLocationChanged(final Location location) { // When the location has changed, cache the new location and force an // update. if(LocationUtils.isBetterLocation(location, lastLocation)) { lastLocation = location; doUpdate(false); } } /** * {@inheritDoc} */ @Override public void onStatusChanged(final String provider, final int status, final Bundle extras) { // Nothing to do here. } /** * {@inheritDoc} */ @Override public void onProviderEnabled(final String provider) { if (LocationManager.GPS_PROVIDER.equals(provider) || LocationManager.NETWORK_PROVIDER.equals(provider)) { locMan.requestLocationUpdates(provider, REQUEST_PERIOD, MIN_DISTANCE, this); } } /** * {@inheritDoc} */ @Override public void onProviderDisabled(final String provider) { // Nothing to do here. } /** * {@inheritDoc} */ @Override public void onServicesChosen(final String[] chosenServices) { this.chosenServices = chosenServices; // The user has been in the services chooser Dialog, so force an update // incase anything has changed. doUpdate(false); } /** * {@inheritDoc} */ @Override public void onListItemClick(final ListView l, final View v, final int position, final long id) { // Ensure that the position is within range. if(position < ad.getCount()) { // Show the DisplayStopDataActivity. callbacks.onShowBusTimes(ad.getItem(position).stopCode); } } /** * Cause the data to refresh. The refresh happens asynchronously in another * thread. * * @param isFirst Is this the first load? */ private void doUpdate(final boolean isFirst) { if (lastLocation == null || !isAdded()) { return; } // Stuff the arguments Bundle. final Bundle args = new Bundle(); args.putDouble(NearestStopsLoader.ARG_LATITUDE, lastLocation.getLatitude()); args.putDouble(NearestStopsLoader.ARG_LONGITUDE, lastLocation.getLongitude()); // Only put this argument in if chosen services exist. if(chosenServices != null && chosenServices.length > 0) args.putStringArray(NearestStopsLoader.ARG_FILTERED_SERVICES, chosenServices); if (isFirst) { getLoaderManager().initLoader(0, args, this); } else { getLoaderManager().restartLoader(0, args, this); } } /** * A SearchResult is essentially bean object to hold the values returned for * the database for a particular bus stop. The fields are intentially public * for quick execution speed within this class. */ public static class SearchResult implements Comparable<SearchResult> { public String stopCode; public String stopName; public Spanned services; public float distance; public int orientation; public String locality; /** * Create a new SearchResult instance, specifying default values. * * @param stopCode The bus stop code. * @param stopName The bus stop name. * @param services A String denoting which services stop at this bus * stop. * @param distance The distance from the handset to this bus stop. * @param point The location of this bus stop. * @param orientation The direction the bus stop faces. * @param locality The locality for this bus stop. */ public SearchResult(final String stopCode, final String stopName, final Spanned services, final float distance, final int orientation, final String locality) { this.stopCode = stopCode; this.stopName = stopName; this.services = services; this.distance = distance; this.orientation = orientation; this.locality = locality; } /** * {@inheritDoc} */ @Override public int compareTo(final SearchResult item) { if(distance == item.distance) return 0; // Sort the distance in ascending order. return distance > item.distance ? 1 : -1; } } /** * The NearestStopsLoader accepts an argument Bundle which contains the * handset\'s current latitude and longitude and optionally a list of * filtered bus services. It will then query the bus stop database to result * a list of matching results. */ private static class NearestStopsLoader extends SimpleResultLoader<ArrayList<SearchResult>> { /** The latitude argument. */ public static final String ARG_LATITUDE = "latitude"; /** The longitude argument. */ public static final String ARG_LONGITUDE = "longitude"; /** The filtered services argument. */ public static final String ARG_FILTERED_SERVICES = "filteredServices"; /** If modifying for another city, check that this value is correct. */ private static final double LATITUDE_SPAN = 0.004499; /** If modifying for another city, check that this value is correct. */ private static final double LONGITUDE_SPAN = 0.008001; private final BusStopDatabase bsd; private final Bundle args; /** * Create a new instance of the NearestStopsLoader. An argument Bundle * MUST be supplied. * * @param context A Context object. * @param args The argument Bundle. */ public NearestStopsLoader(final Context context, final Bundle args) { super(context); // Make sure the argument Bundle exists. if(args == null) throw new IllegalArgumentException("The args cannot be null."); // Set the class fields. bsd = BusStopDatabase.getInstance(context.getApplicationContext()); this.args = args; } /** * {@inheritDoc} */ @Override public ArrayList<SearchResult> loadInBackground() { // Create the List where the results will be placed. If no results // have been found, this list will be empty. final ArrayList<SearchResult> result = new ArrayList<SearchResult>(); // Cannot continue without coordinates. Return the empty list. if(!args.containsKey(ARG_LATITUDE) || !args.containsKey(ARG_LONGITUDE)) { return result; } // Get the latitude and longitude arguments. final double latitude = args.getDouble(ARG_LATITUDE); final double longitude = args.getDouble(ARG_LONGITUDE); // Calculate the bounds. final double minX = latitude - LATITUDE_SPAN; final double minY = longitude - LONGITUDE_SPAN; final double maxX = latitude + LATITUDE_SPAN; final double maxY = longitude + LONGITUDE_SPAN; // Do not let anything else touch the stop database while we are // querying it. synchronized(bsd) { Cursor c; // What query is executed depends on whether services are being // filtered or not. if(args.containsKey(ARG_FILTERED_SERVICES)) { c = bsd.getFilteredStopsByCoords(minX, minY, maxX, maxY, args.getStringArray(ARG_FILTERED_SERVICES)); } else { c = bsd.getBusStopsByCoords(minX, minY, maxX, maxY); } // Defensive programming! if(c != null) { // We don't care about the bearings so a float array of only // 1 in size is required. final float[] distance = new float[1]; distance[0] = 0f; // Loop through all results. while(c.moveToNext()) { // Use the Location class in the Android framework to // compute the distance between the handset and the bus // stop. Location.distanceBetween(latitude, longitude, c.getDouble(2), c.getDouble(3), distance); final String stopCode = c.getString(0); // Create a new SearchResult and add it to the results // list. result.add(new SearchResult(stopCode, c.getString(1), BusStopDatabase.getColouredServiceListString( bsd.getBusServicesForStopAsString(stopCode)), distance[0], c.getInt(4), c.getString(5))); } // Cursor is no longer needed, free the resource. c.close(); } } // Sort the bus stop results in order of distance from the handset. Collections.sort(result); return result; } } /** * This ArrayAdapter holds a collection of SearchResult objects which * describe each bus stop to show. This class is necessary because a custom * layout is required for each row item. */ private static class NearestStopsArrayAdapter extends ArrayAdapter<SearchResult> { private LayoutInflater vi; /** * Create a new NearestStopsArrayAdapter. * * @param context A Context object. */ public NearestStopsArrayAdapter(final Context context) { super(context, R.layout.neareststops_list_item, android.R.id.text1); // Get a LayoutInflater. vi = LayoutInflater.from(context); } /** * This is a convenience method which adds all the items from an * ArrayList to this adapter. It calls through to the add() method in * the parent class. * * @param collection The collection to add to the end of this adapter. */ public void addAll(final ArrayList<SearchResult> collection) { for(SearchResult sr : collection) { add(sr); } } /** * {@inheritDoc} */ @Override public View getView(final int position, final View convertView, final ViewGroup parent) { final View row; // If a previous row exists, use that so that XML doesn't need to // be inflated. if(convertView != null) { row = convertView; } else { row = vi.inflate(R.layout.neareststops_list_item, null); } // Get the TextView objects. final TextView distance = (TextView)row.findViewById( R.id.txtNearestDistance); final TextView stopDetails = (TextView)row.findViewById( android.R.id.text1); final TextView buses = (TextView)row.findViewById( android.R.id.text2); // Get the SearchResult at position. final SearchResult sr = getItem(position); // Set the distance text. distance.setText((int)sr.distance + " m"); final String name; if(sr.locality != null) { name = getContext().getString( R.string.busstop_locality_coloured, sr.stopName, sr.locality, sr.stopCode); } else { name = getContext().getString( R.string.busstop_coloured, sr.stopName, sr.stopCode); } stopDetails.setText(Html.fromHtml(name)); buses.setText(sr.services); // Set the bus stop marker icon. switch(sr.orientation) { case 0: distance.setCompoundDrawablesWithIntrinsicBounds(0, R.drawable.mapmarker_n, 0, 0); break; case 1: distance.setCompoundDrawablesWithIntrinsicBounds(0, R.drawable.mapmarker_ne, 0, 0); break; case 2: distance.setCompoundDrawablesWithIntrinsicBounds(0, R.drawable.mapmarker_e, 0, 0); break; case 3: distance.setCompoundDrawablesWithIntrinsicBounds(0, R.drawable.mapmarker_se, 0, 0); break; case 4: distance.setCompoundDrawablesWithIntrinsicBounds(0, R.drawable.mapmarker_s, 0, 0); break; case 5: distance.setCompoundDrawablesWithIntrinsicBounds(0, R.drawable.mapmarker_sw, 0, 0); break; case 6: distance.setCompoundDrawablesWithIntrinsicBounds(0, R.drawable.mapmarker_w, 0, 0); break; case 7: distance.setCompoundDrawablesWithIntrinsicBounds(0, R.drawable.mapmarker_nw, 0, 0); break; default: distance.setCompoundDrawablesWithIntrinsicBounds(0, R.drawable.mapmarker, 0, 0); break; } return row; } } /** * Any Activities which host this Fragment must implement this interface to * handle navigation events. */ public static interface Callbacks { /** * This is called when the user should be asked if they want to turn on * GPS or not. */ public void onAskTurnOnGps(); /** * This is called when it should be confirmed with the user that they * want to delete a favourite bus stop. * * @param stopCode The bus stop that the user may want to delete. */ public void onShowConfirmFavouriteDeletion(String stopCode); /** * This is called when it should be confirmed with the user that they * want to delete the proximity alert. */ public void onShowConfirmDeleteProximityAlert(); /** * This is called when it should be confirmed with the user that they * want to delete the time alert. */ public void onShowConfirmDeleteTimeAlert(); /** * This is called when the user wishes to select services, for example, * for filtering. * * @param services The services to choose from. * @param selectedServices Any services that should be selected by * default. * @param title A title to show on the chooser. */ public void onShowServicesChooser(String[] services, String[] selectedServices, String title); /** * This is called when the user wants to add a new favourite bus stop. * * @param stopCode The stop code of the bus stop to add. * @param stopName The default name to use for the bus stop. */ public void onShowAddFavouriteStop(String stopCode, String stopName); /** * This is called when the user wants to view the interface to add a new * proximity alert. * * @param stopCode The stopCode the proximity alert should be added for. */ public void onShowAddProximityAlert(String stopCode); /** * This is called when the user wants to view the interface to add a new * time alert. * * @param stopCode The stopCode the time alert should be added for. */ public void onShowAddTimeAlert(String stopCode); /** * This is called when the user wants to view the bus stop map centered * on a specific bus stop. * * @param stopCode The stopCode that the map should center on. */ public void onShowBusStopMapWithStopCode(String stopCode); /** * This is called when the user wishes to view bus stop times. * * @param stopCode The bus stop to view times for. */ public void onShowBusTimes(String stopCode); } }