uk.org.rivernile.edinburghbustracker.android.fragments.general.BusStopMapFragment.java Source code

Java tutorial

Introduction

Here is the source code for uk.org.rivernile.edinburghbustracker.android.fragments.general.BusStopMapFragment.java

Source

/*
 * Copyright (C) 2012 - 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.app.SearchManager;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.provider.SearchRecentSuggestions;
import android.support.v4.app.LoaderManager;
import android.support.v4.content.Loader;
import android.support.v4.view.MenuItemCompat;
import android.support.v7.widget.SearchView;
import android.util.TypedValue;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.widget.Toast;
import com.google.android.gms.maps.CameraUpdate;
import com.google.android.gms.maps.CameraUpdateFactory;
import com.google.android.gms.maps.GoogleMap;
import com.google.android.gms.maps.SupportMapFragment;
import com.google.android.gms.maps.UiSettings;
import com.google.android.gms.maps.model.CameraPosition;
import com.google.android.gms.maps.model.LatLng;
import com.google.android.gms.maps.model.LatLngBounds;
import com.google.android.gms.maps.model.Marker;
import com.google.android.gms.maps.model.MarkerOptions;
import com.google.android.gms.maps.model.Polyline;
import com.google.android.gms.maps.model.PolylineOptions;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import uk.org.rivernile.edinburghbustracker.android.BusStopDatabase;
import uk.org.rivernile.edinburghbustracker.android.MapSearchSuggestionsProvider;
import uk.org.rivernile.edinburghbustracker.android.PreferencesActivity;
import uk.org.rivernile.edinburghbustracker.android.R;
import uk.org.rivernile.edinburghbustracker.android.fragments.dialogs.IndeterminateProgressDialogFragment;
import uk.org.rivernile.edinburghbustracker.android.fragments.dialogs.MapTypeChooserDialogFragment;
import uk.org.rivernile.edinburghbustracker.android.fragments.dialogs.ServicesChooserDialogFragment;
import uk.org.rivernile.edinburghbustracker.android.maps.BusStopMarkerLoader;
import uk.org.rivernile.edinburghbustracker.android.maps.GeoSearchLoader;
import uk.org.rivernile.edinburghbustracker.android.maps.MapInfoWindow;
import uk.org.rivernile.edinburghbustracker.android.maps.RouteLineLoader;

/**
 * The BusStopMapFragment shows a Google Maps v2 MapView and depending on the
 * location of the camera, the zoom level and service filter, it shows bus stop
 * icons on the map. The user can tap on a bus stop icon to show the info
 * window (bubble). If the user taps on the info window, then the
 * BusStopDetailsFragment is shown.
 * 
 * The user can also select the type of map these wish to see and search for bus
 * stops and places.
 * 
 * @author Niall Scott
 */
public class BusStopMapFragment extends SupportMapFragment
        implements GoogleMap.OnCameraChangeListener, GoogleMap.OnMarkerClickListener,
        GoogleMap.OnInfoWindowClickListener, LoaderManager.LoaderCallbacks, ServicesChooserDialogFragment.Callbacks,
        MapTypeChooserDialogFragment.Callbacks, IndeterminateProgressDialogFragment.Callbacks {

    /** The stopCode argument. */
    public static final String ARG_STOPCODE = "stopCode";
    /** The latitude argument. */
    public static final String ARG_LATITUDE = "latitude";
    /** The longitude argument. */
    public static final String ARG_LONGITUDE = "longitude";
    /** The search argument. */
    public static final String ARG_SEARCH = "searchTerm";

    private static final String ARG_CHOSEN_SERVICES = "chosenServices";

    /** The default latitude. */
    public static final double DEFAULT_LAT = 55.948611;
    /** The default longitude. */
    public static final double DEFAULT_LONG = -3.199811;
    /** The default zoom. */
    public static final float DEFAULT_ZOOM = 11f;
    /** The default search zoom. */
    public static final float DEFAULT_SEARCH_ZOOM = 16f;

    private static final Pattern STOP_CODE_PATTERN = Pattern.compile("(\\d{8})\\)$");
    private static final Pattern STOP_CODE_SEARCH_PATTERN = Pattern.compile("^\\d{8}$");

    private static final String LOADER_ARG_MIN_X = "minX";
    private static final String LOADER_ARG_MIN_Y = "minY";
    private static final String LOADER_ARG_MAX_X = "maxX";
    private static final String LOADER_ARG_MAX_Y = "maxY";
    private static final String LOADER_ARG_ZOOM = "zoom";
    private static final String LOADER_ARG_FILTERED_SERVICES = "filteredServices";
    private static final String LOADER_ARG_QUERY = "query";

    private static final int LOADER_ID_BUS_STOPS = 0;
    private static final int LOADER_ID_GEO_SEARCH = 1;
    private static final int LOADER_ID_ROUTE_LINES = 2;

    private Callbacks callbacks;
    private BusStopDatabase bsd;
    private GoogleMap map;
    private SharedPreferences sp;
    private SearchManager searchMan;

    private final HashMap<String, Marker> busStopMarkers = new HashMap<String, Marker>();
    private final HashMap<String, LinkedList<Polyline>> routeLines = new HashMap<String, LinkedList<Polyline>>();
    private HashSet<Marker> geoSearchMarkers = new HashSet<Marker>();
    private String searchedBusStop = null;
    private String[] services;
    private String[] chosenServices;
    private int actionBarHeight;

    /**
     * Create a new instance of the BusStopMapFragment, setting the initial
     * location to that of the stopCode provided.
     * 
     * @param stopCode The stopCode to go to.
     * @return A new instance of this Fragment.
     */
    public static BusStopMapFragment newInstance(final String stopCode) {
        final BusStopMapFragment f = new BusStopMapFragment();
        final Bundle b = new Bundle();
        b.putString(ARG_STOPCODE, stopCode);
        f.setArguments(b);

        return f;
    }

    /**
     * Create a new instance of the BusStopMapFragment, setting the initial
     * location specified by latitude and longitude.
     * 
     * @param latitude The latitude to go to.
     * @param longitude The longitude to go to.
     * @return A new instance of this Fragment.
     */
    public static BusStopMapFragment newInstance(final double latitude, final double longitude) {
        final BusStopMapFragment f = new BusStopMapFragment();
        final Bundle b = new Bundle();
        b.putDouble(ARG_LATITUDE, latitude);
        b.putDouble(ARG_LONGITUDE, longitude);
        f.setArguments(b);

        return f;
    }

    /**
     * Create a new instance of the BusStopMapFragment, specifying a search
     * term. The item will be searched as soon as the Fragment is ready.
     * 
     * @param searchTerm The search term.
     * @return A new instance of this Fragment.
     */
    public static BusStopMapFragment newInstanceWithSearch(final String searchTerm) {
        final BusStopMapFragment f = new BusStopMapFragment();
        final Bundle b = new Bundle();
        b.putString(ARG_SEARCH, searchTerm);
        f.setArguments(b);

        return f;
    }

    /**
     * {@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);

        // Retain the instance to this Fragment.
        setRetainInstance(true);

        final Context context = getActivity();
        bsd = BusStopDatabase.getInstance(context.getApplicationContext());
        sp = context.getSharedPreferences(PreferencesActivity.PREF_FILE, 0);
        searchMan = (SearchManager) context.getSystemService(Context.SEARCH_SERVICE);

        services = bsd.getBusServiceList();

        if (savedInstanceState != null) {
            chosenServices = savedInstanceState.getStringArray(ARG_CHOSEN_SERVICES);
        }

        // Get the height of the ActionBar from the assigned attribute in the
        // appcompat project theme.
        final TypedValue value = new TypedValue();
        getActivity().getTheme().resolveAttribute(android.support.v7.appcompat.R.attr.actionBarSize, value, true);
        actionBarHeight = getResources().getDimensionPixelSize(value.resourceId);

        // This Fragment shows an options menu.
        setHasOptionsMenu(true);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onActivityCreated(final Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);

        if (map == null) {
            map = getMap();

            if (map != null) {
                getActivity().supportInvalidateOptionsMenu();

                final UiSettings uiSettings = map.getUiSettings();
                uiSettings.setRotateGesturesEnabled(false);
                uiSettings.setCompassEnabled(false);
                uiSettings.setMyLocationButtonEnabled(true);

                map.setInfoWindowAdapter(new MapInfoWindow(getActivity()));
                map.setOnCameraChangeListener(this);
                map.setOnMarkerClickListener(this);
                map.setOnInfoWindowClickListener(this);
                map.setMapType(sp.getInt(PreferencesActivity.PREF_MAP_LAST_MAP_TYPE, GoogleMap.MAP_TYPE_NORMAL));
                map.setPadding(0, actionBarHeight, 0, 0);
                moveCameraToInitialLocation();

                refreshBusStops(null);

                // Check to see if a search is to be done.
                final Bundle args = getArguments();
                if (args != null && args.containsKey(ARG_SEARCH)) {
                    onSearch(args.getString(ARG_SEARCH));
                    args.remove(ARG_SEARCH);
                }
            }
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onResume() {
        super.onResume();

        if (map != null) {
            final SharedPreferences sharedPrefs = getActivity().getSharedPreferences(PreferencesActivity.PREF_FILE,
                    0);
            map.setMyLocationEnabled(sharedPrefs.getBoolean(PreferencesActivity.PREF_AUTO_LOCATION, true));
            map.getUiSettings()
                    .setZoomControlsEnabled(sharedPrefs.getBoolean(PreferencesActivity.PREF_ZOOM_BUTTONS, true));
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onPause() {
        super.onPause();

        if (map != null) {
            // Save the camera location to SharedPreferences, so the user is
            // shown this location when they load the map again.
            final SharedPreferences.Editor edit = sp.edit();
            final CameraPosition position = map.getCameraPosition();
            final LatLng latLng = position.target;

            edit.putString(PreferencesActivity.PREF_MAP_LAST_LATITUDE, String.valueOf(latLng.latitude));
            edit.putString(PreferencesActivity.PREF_MAP_LAST_LONGITUDE, String.valueOf(latLng.longitude));
            edit.putFloat(PreferencesActivity.PREF_MAP_LAST_ZOOM, position.zoom);
            edit.putInt(PreferencesActivity.PREF_MAP_LAST_MAP_TYPE, map.getMapType());
            edit.commit();
        }
    }

    /**
     * {@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) {
        inflater.inflate(R.menu.busstopmap_option_menu, menu);

        final MenuItem searchItem = menu.findItem(R.id.busstopmap_option_menu_search);
        final SearchView searchView = (SearchView) MenuItemCompat.getActionView(searchItem);
        searchView.setSearchableInfo(searchMan.getSearchableInfo(getActivity().getComponentName()));
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onPrepareOptionsMenu(final Menu menu) {
        super.onPrepareOptionsMenu(menu);

        MenuItem item = menu.findItem(R.id.busstopmap_option_menu_trafficview);
        if (map != null && map.isTrafficEnabled()) {
            item.setTitle(R.string.map_menu_mapoverlay_trafficviewoff);
        } else {
            item.setTitle(R.string.map_menu_mapoverlay_trafficviewon);
        }

        item.setEnabled(map != null);

        item = menu.findItem(R.id.busstopmap_option_menu_services);
        item.setEnabled(services != null && services.length > 0);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public boolean onOptionsItemSelected(final MenuItem item) {
        switch (item.getItemId()) {
        case R.id.busstopmap_option_menu_services:
            callbacks.onShowServicesChooser(services, chosenServices,
                    getString(R.string.busstopmapfragment_service_chooser_title));
            return true;
        case R.id.busstopmap_option_menu_maptype:
            callbacks.onShowMapTypeSelection();
            return true;
        case R.id.busstopmap_option_menu_trafficview:
            // Toggle the traffic view.
            if (map != null) {
                map.setTrafficEnabled(!map.isTrafficEnabled());
                getActivity().supportInvalidateOptionsMenu();
            }

            return true;
        default:
            return super.onOptionsItemSelected(item);
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onCameraChange(final CameraPosition position) {
        // If the camera has changed, force a refresh of the bus stop markers.
        refreshBusStops(position);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public boolean onMarkerClick(final Marker marker) {
        final String snippet = marker.getSnippet();

        if (busStopMarkers.containsValue(marker) && (snippet == null || snippet.length() == 0)) {
            final Matcher matcher = STOP_CODE_PATTERN.matcher(marker.getTitle());
            if (matcher.find()) {
                final String stopCode = matcher.group(1);
                marker.setSnippet(bsd.getBusServicesForStopAsString(stopCode));
            }
        }

        return false;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onInfoWindowClick(final Marker marker) {
        if (busStopMarkers.containsValue(marker)) {
            final Matcher matcher = STOP_CODE_PATTERN.matcher(marker.getTitle());
            if (matcher.find()) {
                callbacks.onShowBusStopDetails(matcher.group(1));
            }
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Loader onCreateLoader(final int i, final Bundle bundle) {
        switch (i) {
        case LOADER_ID_BUS_STOPS:
            if (bundle.containsKey(LOADER_ARG_FILTERED_SERVICES)) {
                return new BusStopMarkerLoader(getActivity(), bundle.getDouble(LOADER_ARG_MIN_X),
                        bundle.getDouble(LOADER_ARG_MIN_Y), bundle.getDouble(LOADER_ARG_MAX_X),
                        bundle.getDouble(LOADER_ARG_MAX_Y), bundle.getFloat(LOADER_ARG_ZOOM),
                        bundle.getStringArray(LOADER_ARG_FILTERED_SERVICES));
            } else {
                return new BusStopMarkerLoader(getActivity(), bundle.getDouble(LOADER_ARG_MIN_X),
                        bundle.getDouble(LOADER_ARG_MIN_Y), bundle.getDouble(LOADER_ARG_MAX_X),
                        bundle.getDouble(LOADER_ARG_MAX_Y), bundle.getFloat(LOADER_ARG_ZOOM));
            }
        case LOADER_ID_GEO_SEARCH:
            String query = bundle.getString(LOADER_ARG_QUERY);
            // Make sure the query arg is not null.
            if (query == null) {
                query = "";
            }

            return new GeoSearchLoader(getActivity(), query);
        case LOADER_ID_ROUTE_LINES:
            return new RouteLineLoader(getActivity(), bundle.getStringArray(LOADER_ARG_FILTERED_SERVICES));
        default:
            return null;
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onLoadFinished(final Loader loader, final Object d) {
        if (isAdded()) {
            switch (loader.getId()) {
            case LOADER_ID_BUS_STOPS:
                addBusStopMarkers((HashMap<String, MarkerOptions>) d);
                break;
            case LOADER_ID_GEO_SEARCH:
                addGeoSearchResults((HashSet<MarkerOptions>) d);
                break;
            case LOADER_ID_ROUTE_LINES:
                addRouteLines((HashMap<String, LinkedList<PolylineOptions>>) d);
                break;
            default:
                break;
            }
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onLoaderReset(final Loader loader) {
        // Nothing to do.
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onServicesChosen(final String[] chosenServices) {
        this.chosenServices = chosenServices;

        // If the user has chosen services in the services filter, force a
        // refresh of the marker icons.
        refreshBusStops(null);

        final LinkedList<String> tempList = new LinkedList<String>();
        boolean found;

        // Loop through the existing route lines. If a service doesn't exist in
        // the chosen services list, add it to the to-be-removed list.
        for (String key : routeLines.keySet()) {
            found = false;

            for (String fs : chosenServices) {
                if (key.equals(fs)) {
                    found = true;
                    break;
                }
            }

            if (!found) {
                tempList.add(key);
            }
        }

        LinkedList<Polyline> polyLines;
        // Loop through the to-be-removed list and remove the Polylines and the
        // entry from the routeLines HashMap.
        for (String toRemove : tempList) {
            polyLines = routeLines.get(toRemove);
            routeLines.remove(toRemove);

            for (Polyline pl : polyLines) {
                pl.remove();
            }
        }

        // The tempList is going to be reused, so clear it out.
        tempList.clear();

        // Loop through the filteredServices array. If the element does not
        // appear in the existing route lines, then add it to the to-be-added
        // list.
        for (String fs : chosenServices) {
            if (!routeLines.containsKey(fs)) {
                tempList.add(fs);
            }
        }

        final int size = tempList.size();
        // Execute the load if there are routes to be loaded.
        if (size > 0) {
            final String[] servicesToLoad = new String[size];
            tempList.toArray(servicesToLoad);

            final Bundle b = new Bundle();
            b.putStringArray(LOADER_ARG_FILTERED_SERVICES, servicesToLoad);
            getLoaderManager().restartLoader(LOADER_ID_ROUTE_LINES, b, this);
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onMapTypeChosen(final int mapType) {
        // When the user selects a new map type, change the map type.
        if (map != null) {
            map.setMapType(mapType);
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onProgressCancel() {
        // If the user cancels the search progress dialog, then cancel the
        // search loader.
        getLoaderManager().destroyLoader(LOADER_ID_GEO_SEARCH);
    }

    /**
     * This is called when the back button is pressed. It is called by the
     * underlying Activity.
     * 
     * @return true if the back event was handled here, false if not.
     */
    public boolean onBackPressed() {
        if (!geoSearchMarkers.isEmpty()) {
            for (Marker m : geoSearchMarkers) {
                if (m.isInfoWindowShown()) {
                    m.hideInfoWindow();
                    return true;
                }
            }

            for (Marker m : geoSearchMarkers) {
                m.remove();
            }

            geoSearchMarkers.clear();
            return true;
        }

        // Loop through all the bus stop markers, and if any have an info
        // window shown, hide it then prevent the default back button
        // behaviour.
        for (Marker m : busStopMarkers.values()) {
            if (m.isInfoWindowShown()) {
                m.hideInfoWindow();
                return true;
            }
        }

        return false;
    }

    /**
     * This method is called by the underlying Activity when a search has been
     * initiated.
     * 
     * @param searchTerm What to search for.
     */
    public void onSearch(final String searchTerm) {
        if (map == null) {
            return;
        }

        final Matcher m = STOP_CODE_SEARCH_PATTERN.matcher(searchTerm);

        if (m.matches()) {
            // If the searchTerm is a stop code, then move the camera to the bus
            // stop.
            moveCameraToBusStop(searchTerm);
        } else {
            // If it's not a stop code, then do a geo search.
            final Bundle b = new Bundle();
            b.putString(LOADER_ARG_QUERY, searchTerm);

            // Save the search term as a search suggestion.
            final SearchRecentSuggestions suggestions = new SearchRecentSuggestions(getActivity(),
                    MapSearchSuggestionsProvider.AUTHORITY, MapSearchSuggestionsProvider.MODE);
            suggestions.saveRecentQuery(searchTerm, null);

            // Start the search loader.
            getLoaderManager().restartLoader(LOADER_ID_GEO_SEARCH, b, this);

            callbacks.onShowSearchProgress(getString(R.string.busstopmapfragment_progress_message, searchTerm));
        }
    }

    /**
     * Move the camera to a given stopCode, and show the info window for that
     * stopCode when the camera gets there.
     * 
     * @param stopCode The stopCode to move to.
     */
    public void moveCameraToBusStop(final String stopCode) {
        if (stopCode == null || stopCode.length() == 0) {
            return;
        }

        searchedBusStop = stopCode;
        moveCameraToLocation(bsd.getLatLngForStopCode(stopCode), DEFAULT_SEARCH_ZOOM, true);
    }

    /**
     * Move the camera to a given LatLng location.
     * 
     * @param location Where to move the camera to.
     * @param zoomLevel The zoom level of the camera.
     * @param animate Whether the transition should be animated or not.
     */
    public void moveCameraToLocation(final LatLng location, final float zoomLevel, final boolean animate) {
        if (location == null) {
            return;
        }

        final CameraUpdate update = CameraUpdateFactory.newLatLngZoom(location, zoomLevel);

        if (animate) {
            map.animateCamera(update);
        } else {
            map.moveCamera(update);
        }
    }

    /**
     * Refresh the bus stop marker icons on the map. This may be because the
     * camera has moved, a configuration change has happened or the user has
     * selected services to filter by.
     * 
     * @param position If a CameraPosition is available, send it in so that it
     * doesn't need to be looked up again. If it's not available, use null.
     */
    private void refreshBusStops(CameraPosition position) {
        if (map == null || !isAdded()) {
            return;
        }

        // Populate the CameraPosition if it wasn't given.
        if (position == null) {
            position = map.getCameraPosition();
        }

        // Get the visible bounds.
        final LatLngBounds lastVisibleBounds = map.getProjection().getVisibleRegion().latLngBounds;
        final Bundle b = new Bundle();

        // Populate the Bundle of arguments for the bus stops Loader.
        b.putDouble(LOADER_ARG_MIN_X, lastVisibleBounds.southwest.latitude);
        b.putDouble(LOADER_ARG_MIN_Y, lastVisibleBounds.southwest.longitude);
        b.putDouble(LOADER_ARG_MAX_X, lastVisibleBounds.northeast.latitude);
        b.putDouble(LOADER_ARG_MAX_Y, lastVisibleBounds.northeast.longitude);
        b.putFloat(LOADER_ARG_ZOOM, position.zoom);

        // If there are chosen services, then set the filtered services
        // argument.
        if (chosenServices != null && chosenServices.length > 0) {
            b.putStringArray(LOADER_ARG_FILTERED_SERVICES, chosenServices);
        }

        // Start the bus stops Loader.
        getLoaderManager().restartLoader(LOADER_ID_BUS_STOPS, b, this);
    }

    /**
     * This method is called when the bus stops Loader has finished loading bus
     * stops and has data ready to be populated on the map.
     * 
     * @param result The data to be populated on the map.
     */
    private void addBusStopMarkers(final HashMap<String, MarkerOptions> result) {
        if (map == null) {
            return;
        }

        // Get an array of the stopCodes that are currently on the map. This is
        // given to us as an array of Objects, which cannot be cast to an array
        // of Strings.
        final Object[] currentStops = busStopMarkers.keySet().toArray();
        Marker marker;
        for (Object existingStop : currentStops) {
            marker = busStopMarkers.get((String) existingStop);

            // If the new data does not contain the given stopCode, and the
            // marker for that bus stop doesn't have an info window shown, then
            // remove it.
            if (!result.containsKey((String) existingStop) && !marker.isInfoWindowShown()) {
                marker.remove();
                busStopMarkers.remove((String) existingStop);
            } else {
                // Otherwise, remove the bus stop from the new data as it is
                // already populated on the map and doesn't need to be
                // re-populated. This is a performance enhancement.
                result.remove((String) existingStop);
            }
        }

        // Loop through all the new bus stops, and add them to the map. Bus
        // stops common to the existing collection and the new collection will
        // not be touched.
        for (String newStop : result.keySet()) {
            busStopMarkers.put(newStop, map.addMarker(result.get(newStop)));
        }

        // If map has been moved to this location because the user searched for
        // a specific bus stop...
        if (searchedBusStop != null) {
            marker = busStopMarkers.get(searchedBusStop);

            // If the marker has been found...
            if (marker != null) {
                // Get the snippet text for the marker and if it does not exist,
                // populate it with the bus services list.
                final String snippet = marker.getSnippet();
                if (snippet == null || snippet.length() == 0) {
                    marker.setSnippet(bsd.getBusServicesForStopAsString(searchedBusStop));
                }

                // Show the info window of the marker to highlight it.
                marker.showInfoWindow();
                // Set this to null to make sure the stop isn't highlighted
                // again, until the user initiates another search.
                searchedBusStop = null;
            }
        }
    }

    /**
     * This method is called when the search Loader has finished loading and
     * data is to be populated on the map.
     * 
     * @param result The data to be populated on the map.
     */
    private void addGeoSearchResults(final HashSet<MarkerOptions> result) {
        if (!isAdded()) {
            return;
        }

        // If there is a progress Dialog, get rid of it.
        callbacks.onDismissSearchProgress();

        if (map == null) {
            return;
        }

        // Remove all of the existing search markers from the map.
        for (Marker m : geoSearchMarkers) {
            m.remove();
        }

        // ...and because they've been cleared from the map, remove them all
        // from the collection.
        geoSearchMarkers.clear();

        // If there are no results, show a Toast notification to the user.
        if (result == null || result.isEmpty()) {
            Toast.makeText(getActivity(), R.string.busstopmapfragment_nosearchresults, Toast.LENGTH_LONG).show();
            return;
        }

        Marker marker;
        boolean isFirst = true;

        for (MarkerOptions mo : result) {
            // Add the new marker to the map.
            marker = map.addMarker(mo);

            // Make sure the item does not already exist in the marker list. If
            // it does, remove it from the map again.
            if (!geoSearchMarkers.add(marker)) {
                marker.remove();
            } else if (isFirst) {
                // If it's the first icon to be added, move the camera to that
                // bus stop marker.
                isFirst = false;

                moveCameraToLocation(marker.getPosition(), DEFAULT_SEARCH_ZOOM, true);
                marker.showInfoWindow();
            }
        }
    }

    /**
     * Add route lines to the Map. This is called when the route lines loader
     * has finished loading the route lines.
     * 
     * @param result A HashMap, mapping the service name to a LinkedList of
     * PolylineOptions objects. This is a LinkedList because a service may have
     * more than one Polyline.
     */
    private void addRouteLines(final HashMap<String, LinkedList<PolylineOptions>> result) {
        if (map == null) {
            return;
        }

        LinkedList<PolylineOptions> polyLineOptions;
        LinkedList<Polyline> newPolyLines;

        // Loop through all services in the HashMap.
        for (String service : result.keySet()) {
            polyLineOptions = result.get(service);
            // Create the LinkedList that the Polylines will be stored in.
            newPolyLines = new LinkedList<Polyline>();
            // Add the LinkedList to the routeLines HashMap.
            routeLines.put(service, newPolyLines);

            // Loop through all the PolylineOptions for this service, and add
            // them to the map and the Polyline LinkedList.
            for (PolylineOptions plo : polyLineOptions) {
                newPolyLines.add(map.addPolyline(plo));
            }
        }
    }

    /**
     * Move the camera to the initial location. The initial location is
     * determined by the following order;
     * 
     * - If the args contains a stopCode, go there.
     * - If the args contains a latitude AND a longitude, go there.
     * - If the SharedPreferences have mappings for a previous location, then
     *   go there.
     * - Otherwise, go to the default map location, as defined by
     *   {@link #DEFAULT_LAT} and {@link #DEFAULT_LONG) at
     *   {@link #DEFAULT_ZOOM}.
     */
    private void moveCameraToInitialLocation() {
        final Bundle args = getArguments();

        if (args != null && args.containsKey(ARG_STOPCODE)) {
            moveCameraToBusStop(args.getString(ARG_STOPCODE));
            args.remove(ARG_STOPCODE);
        } else if (args != null && args.containsKey(ARG_LATITUDE) && args.containsKey(ARG_LONGITUDE)) {
            moveCameraToLocation(new LatLng(args.getDouble(ARG_LATITUDE), args.getDouble(ARG_LONGITUDE)),
                    DEFAULT_SEARCH_ZOOM, false);
            args.remove(ARG_LATITUDE);
            args.remove(ARG_LONGITUDE);
        } else if (map != null) {
            // The Lat/Lons have to be treated as Strings because
            // SharedPreferences has no support for doubles.
            final String latitude = sp.getString(PreferencesActivity.PREF_MAP_LAST_LATITUDE,
                    String.valueOf(DEFAULT_LAT));
            final String longitude = sp.getString(PreferencesActivity.PREF_MAP_LAST_LONGITUDE,
                    String.valueOf(DEFAULT_LONG));
            final float zoom = sp.getFloat(PreferencesActivity.PREF_MAP_LAST_ZOOM, DEFAULT_ZOOM);

            try {
                moveCameraToLocation(new LatLng(Double.parseDouble(latitude), Double.parseDouble(longitude)), zoom,
                        false);
            } catch (NumberFormatException e) {
                moveCameraToLocation(new LatLng(DEFAULT_LAT, DEFAULT_LONG), DEFAULT_ZOOM, false);
            }
        }
    }

    /**
     * Any Activities which host this Fragment must implement this interface to
     * handle navigation events.
     */
    public static interface Callbacks {

        /**
         * This is called when the user wishes to select their preferred map
         * type.
         */
        public void onShowMapTypeSelection();

        /**
         * 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 has initiated a search and progress
         * should be shown.
         * 
         * @param message The message to show the user.
         */
        public void onShowSearchProgress(String message);

        /**
         * This is called when the search progress should be dimissed.
         */
        public void onDismissSearchProgress();

        /**
         * This is called when the user wants to see details about a bus stop.
         * 
         * @param stopCode The bus stop code the user wants to see details for.
         */
        public void onShowBusStopDetails(String stopCode);
    }
}