/* * ExpeditionMode.java * Copyright (C) 2015 Nicholas Killewald * * This file is distributed under the terms of the BSD license. * The source package should have a LICENSE file at the toplevel. */ package net.exclaimindustries.geohashdroid.util; import android.app.FragmentManager; import android.app.FragmentTransaction; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.graphics.BitmapFactory; import android.graphics.Point; import android.graphics.Rect; import android.location.Location; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.preference.PreferenceManager; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.util.DisplayMetrics; import android.util.Log; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.RelativeLayout; import android.widget.Toast; import com.google.android.gms.common.api.GoogleApiClient; 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.Projection; import com.google.android.gms.maps.model.BitmapDescriptorFactory; 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 net.exclaimindustries.geohashdroid.R; import net.exclaimindustries.geohashdroid.activities.CentralMap; import net.exclaimindustries.geohashdroid.activities.DetailedInfoActivity; import net.exclaimindustries.geohashdroid.fragments.CentralMapExtraFragment; import net.exclaimindustries.geohashdroid.fragments.NearbyGraticuleDialogFragment; import net.exclaimindustries.geohashdroid.services.StockService; import net.exclaimindustries.geohashdroid.widgets.ErrorBanner; import net.exclaimindustries.geohashdroid.widgets.InfoBox; import net.exclaimindustries.geohashdroid.widgets.ZoomButtons; import net.exclaimindustries.tools.AndroidUtil; import net.exclaimindustries.tools.DateTools; import net.exclaimindustries.tools.LocationUtil; import java.text.DateFormat; import java.util.Calendar; import java.util.HashMap; import java.util.Map; /** * <code>ExpeditionMode</code> is the "main" mode, where it follows one point, * maybe shows eight close points, and allows for wiki mode or whatnot. */ public class ExpeditionMode extends CentralMap.CentralMapMode implements GoogleMap.OnInfoWindowClickListener, GoogleMap.OnCameraChangeListener, NearbyGraticuleDialogFragment.NearbyGraticuleClickedCallback, CentralMapExtraFragment.CloseListener, ZoomButtons.ZoomButtonListener { private static final String DEBUG_TAG = "ExpeditionMode"; private static final String NEARBY_DIALOG = "nearbyDialog"; private static final String EXTRA_FRAGMENT_BACK_STACK = "ExtraFragment"; public static final String DO_INITIAL_START = "doInitialStart"; private boolean mReplacingFragment = false; private boolean mVictoryReported = false; // This will hold all the nearby points we come up with. They'll be // removed any time we get a new Info in. It's a map so that we have a // quick way to switch to a new Info without having to call StockService. private final Map<Marker, Info> mNearbyPoints = new HashMap<>(); private Info mCurrentInfo; private DisplayMetrics mMetrics; // We want to remember these long-term. If a stock lookup fails in a start- // from-last-used-graticule situation, we'll want to re-use these if the // user switches the date. private Graticule mStartingGraticule; private boolean mStartingGlobalHash; // This is only used in really weird startup cases. Otherwise, we'll be // explicitly using the Calendar from changeCalendar() or implicitly using // it from mCurrentInfo. private Calendar mInitialCalendar; private Location mInitialCheckLocation; private InfoBox mInfoBox; private CentralMapExtraFragment mExtraFragment; private ZoomButtons mZoomButtons; // These booleans tell us that the location handler is waiting to act on a // result in some manner other than the victory listener or updating the // InfoBox. private boolean mWaitingOnInitialZoom = false; private boolean mWaitingOnEmptyStart = false; private boolean mWaitingOnZoomToUser = false; // Then there's this one empty start boolean. private boolean mWaitingOnEmptyStartInfo = false; private Rect mMarkerDimens = new Rect(); private Rect mInfoBoxDimens = new Rect(); private int mMarkerWidth = -1; private int mMarkerHeight = -1; private View.OnClickListener mInfoBoxClicker = new View.OnClickListener() { @Override public void onClick(View v) { launchExtraFragment(CentralMapExtraFragment.FragmentType.DETAILS); } }; @Override public void setCentralMap(@NonNull CentralMap centralMap) { super.setCentralMap(centralMap); // Get the size of the marker. We'll need this for later. BitmapFactory.Options opts = new BitmapFactory.Options(); opts.inJustDecodeBounds = true; BitmapFactory.decodeResource(centralMap.getResources(), R.drawable.final_destination, opts); mMarkerWidth = opts.outWidth; mMarkerHeight = opts.outHeight; // Build up our metrics, too. mMetrics = new DisplayMetrics(); centralMap.getWindowManager().getDefaultDisplay().getMetrics(mMetrics); } @Override public void init(@Nullable Bundle bundle) { // We listen to the map. A lot. For many, many reasons. mMap.setOnInfoWindowClickListener(this); mMap.setOnCameraChangeListener(this); // Set a title to begin with. We'll get a new one soon, hopefully. setTitle(R.string.app_name); // Do we have a Bundle to un-Bundlify? if(bundle != null) { // And if we DO have a Bundle, does that Bundle have an Info? mCurrentInfo = bundle.getParcelable(INFO); if(mCurrentInfo != null) { // Info! Yay! We can request a stock based on that! Okay, // technically the Info should already have the stock we need, // but this also lets us get the nearby points if need be. requestStock(mCurrentInfo.getGraticule(), mCurrentInfo.getCalendar(), StockService.FLAG_USER_INITIATED | (needsNearbyPoints() ? StockService.FLAG_INCLUDE_NEARBY_POINTS : 0)); } else if((bundle.containsKey(GRATICULE) || bundle.containsKey(GLOBALHASH)) && bundle.containsKey(CALENDAR)) { // We've got a request to make! Chances are, StockService will // have this in cache. mStartingGraticule = bundle.getParcelable(GRATICULE); mStartingGlobalHash = bundle.getBoolean(GLOBALHASH, false); Calendar cal = (Calendar) bundle.getSerializable(CALENDAR); // We only go through with this if we have a Calendar and // either a globalhash or a Graticule. if(cal != null && (mStartingGlobalHash || mStartingGraticule != null)) { requestStock((mStartingGlobalHash ? null : mStartingGraticule), cal, StockService.FLAG_USER_INITIATED | (needsNearbyPoints() ? StockService.FLAG_INCLUDE_NEARBY_POINTS : 0)); } } else if(bundle.getBoolean(DO_INITIAL_START, false) && !arePermissionsDenied()) { // If we didn't get an Info, well, maybe there's an initial // start to fire off? doEmptyStart(); } } // Also, let's get that InfoBox taken care of. mInfoBox = new InfoBox(mCentralMap); RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT); params.addRule(RelativeLayout.ALIGN_PARENT_RIGHT); if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) params.addRule(RelativeLayout.ALIGN_PARENT_END); params.addRule(RelativeLayout.ALIGN_PARENT_TOP); ((RelativeLayout)mCentralMap.findViewById(R.id.map_content)).addView(mInfoBox, params); // Start things in motion IF the preference says to do so. if(showInfoBox()) { mInfoBox.animateInfoBoxVisible(true); } mInfoBox.setOnClickListener(mInfoBoxClicker); // Check for the extra fragment container first. If the screen's too // small for it to fit, Android will remove it, mostly by shifting to // the smaller-form layout, which is really super convenient for us in // the case of multi-window stuff. What's more, that also changes all // the actions related to picking the fragments to go to the activities // anyway. Lucky us! View fragmentContainer = mCentralMap.findViewById(R.id.extra_fragment_container); if(fragmentContainer != null) { // Plus, if the detailed info fragment's already there, make its // container go visible, too. FragmentManager manager = mCentralMap.getFragmentManager(); mExtraFragment = (CentralMapExtraFragment) manager.findFragmentById(R.id.extra_fragment_container); if(mExtraFragment != null) { fragmentContainer.setVisibility(View.VISIBLE); mExtraFragment.setCloseListener(this); } } // The zoom buttons also need to go in. mZoomButtons = new ZoomButtons(mCentralMap); params = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT); params.addRule(RelativeLayout.ALIGN_PARENT_LEFT); params.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); ((RelativeLayout)mCentralMap.findViewById(R.id.map_content)).addView(mZoomButtons, params); mZoomButtons.setListener(this); mZoomButtons.showMenu(false); mZoomButtons.setButtonEnabled(ZoomButtons.ZOOM_DESTINATION, false); mZoomButtons.setButtonEnabled(ZoomButtons.ZOOM_FIT_BOTH, false); permissionsDenied(arePermissionsDenied()); mInitComplete = true; } @Override public void cleanUp() { super.cleanUp(); // First, get rid of the listens. if(mMap != null) { mMap.setOnInfoWindowClickListener(null); mMap.setOnCameraChangeListener(null); } // Remove the nearby points, too. The superclass took care of the final // destination marker for us. removeNearbyPoints(); // The InfoBox should also go away at this point. if(mInfoBox != null) { mInfoBox.animateInfoBoxOutWithEndAction(new Runnable() { @Override public void run() { ((ViewGroup) mCentralMap.findViewById(R.id.map_content)).removeView(mInfoBox); } }); } // Plus, any bonus fragment we might have. if(mExtraFragment != null) extraFragmentClosing(mExtraFragment); // Zoom buttons, you go away, too. In this case, we animate the entire // block away ourselves and remove it when done with a callback. if(mZoomButtons != null) { mZoomButtons.animate().translationX(-mZoomButtons.getWidth()).withEndAction(new Runnable() { @Override public void run() { ((ViewGroup) mCentralMap.findViewById(R.id.map_content)).removeView(mZoomButtons); } }); } } @Override public void onSaveInstanceState(@NonNull Bundle bundle) { // At instance save time, stash away the last Info we knew about. If we // have anything at all, it'll always be an Info. If we don't have one, // we weren't displaying anything, and thus don't need to stash a // Calendar, Graticule, etc. bundle.putParcelable(INFO, mCurrentInfo); // Also, if we were in the middle of waiting on the empty start, write // that out to the bundle. It'll come back in and we can start the // whole process anew. if(mWaitingOnEmptyStart) bundle.putBoolean(DO_INITIAL_START, true); } @Override public void pause() { // Hey, wow, we're not doing anything here anymore! } @Override public void resume() { if(!mInitComplete) return; // If need be, start listening again! if(mWaitingOnInitialZoom) doInitialZoom(); else doReloadZoom(); // Also if need be, try that empty start again! if(mWaitingOnEmptyStart) doEmptyStart(); if(showInfoBox()) { mInfoBox.animateInfoBoxVisible(true); } else { mInfoBox.animateInfoBoxVisible(false); } // Re-check the nearby points pref. If that changed, we need to either // remove or add the points. Actually, to keep it simple, just wipe // the old points anyway and only draw them back if the pref says so. // Remember, since this isn't really an Activity lifecycle, resume() is // NOT called immediately after init(), so this will only happen when // we're coming back from somewhere else (like, say, Preferences, where // this sort of thing might change). if(mCurrentInfo != null) { removeNearbyPoints(); if(needsNearbyPoints()) { // Hey, let's use the little-used FLAG_AUTO_INITIATED! That'll // do as a flag that tells us we're ONLY waiting on nearby // points! requestStock(mCurrentInfo.getGraticule(), mCurrentInfo.getCalendar(), StockService.FLAG_INCLUDE_NEARBY_POINTS | StockService.FLAG_AUTO_INITIATED); } } permissionsDenied(arePermissionsDenied()); } @Override public void onCreateOptionsMenu(Context c, MenuInflater inflater, Menu menu) { inflater.inflate(R.menu.centralmap_expedition, menu); // Maps? You there? Intent i = new Intent(Intent.ACTION_VIEW); i.setData(Uri.parse("geo:0,0?q=loc:0,0")); if(!AndroidUtil.isIntentAvailable(c, i)) menu.removeItem(R.id.action_send_to_maps); // Make sure radar is removed if there's no radar to radar our radar. // Radar radar radar radar radar. if(!AndroidUtil.isIntentAvailable(c, GHDConstants.ACTION_SHOW_RADAR)) menu.removeItem(R.id.action_send_to_radar); // If we don't have any Info yet, we can't have things that depend on // it, such as wiki, details, Send To Maps, or Send To Radar. if(mCurrentInfo == null) { menu.removeItem(R.id.action_send_to_maps); menu.removeItem(R.id.action_send_to_radar); menu.removeItem(R.id.action_details); menu.removeItem(R.id.action_wiki); menu.removeItem(R.id.action_try_tomorrow); } } @Override public boolean onOptionsItemSelected(MenuItem item) { switch(item.getItemId()) { case R.id.action_selectagraticule: { // It's Select-A-Graticule Mode! At long last! mCentralMap.enterSelectAGraticuleMode(); return true; } case R.id.action_details: { // Here, the user's pressed the menu item for details, probably // either because they don't have the infobox visible on the // main display or they were poking every option and wanted to // see what this would do. Here's what it do: launchExtraFragment(CentralMapExtraFragment.FragmentType.DETAILS); return true; } case R.id.action_wiki: { // Same as with details, but with the wiki instead. launchExtraFragment(CentralMapExtraFragment.FragmentType.WIKI); return true; } case R.id.action_send_to_maps: { // Juuuuuuust like in DetailedInfoActivity... if(mCurrentInfo != null) { // To the map! Intent i = new Intent(); i.setAction(Intent.ACTION_VIEW); String location = mCurrentInfo.getLatitude() + "," + mCurrentInfo.getLongitude(); i.setData(Uri.parse("geo:0,0?q=loc:" + location + "(" + mCentralMap.getString( R.string.send_to_maps_point_name, DateFormat.getDateInstance(DateFormat.LONG).format( mCurrentInfo.getCalendar().getTime())) + ")&z=15")); mCentralMap.startActivity(i); } else { Toast.makeText(mCentralMap, R.string.error_no_data_to_maps, Toast.LENGTH_LONG).show(); } return true; } case R.id.action_send_to_radar: { // Someone actually picked radar! How 'bout that? if(mCurrentInfo != null) { Intent i = new Intent(GHDConstants.ACTION_SHOW_RADAR); i.putExtra("latitude", (float) mCurrentInfo.getLatitude()); i.putExtra("longitude", (float) mCurrentInfo.getLongitude()); mCentralMap.startActivity(i); } else { Toast.makeText(mCentralMap, R.string.error_no_data_to_radar, Toast.LENGTH_LONG).show(); } return true; } case R.id.action_try_tomorrow: { // Trying tomorrow is easy: Just get today, make it tomorrow, // and try it. What's more, we've already got the mechanisms in // place to handle what happens if tomorrow doesn't exist yet. if(mCurrentInfo != null) { Calendar cal = (Calendar)mCurrentInfo.getCalendar().clone(); cal.add(Calendar.DATE, 1); changeCalendar(cal); } } } return false; } @Override public void handleInfo(Info info, Info[] nearby, int flags) { // PULL! if(mInitComplete) { mCentralMap.getErrorBanner().animateBanner(false); if((flags & FLAG_FROM_NOTIFICATION) != 0) { // We've got an Info here. We're also (at least) paused. So // let's just try inserting it into the usual flow, minus the // point where we check for the closest Info... if(!info.isGlobalHash()) requestStock(info.getGraticule(), info.getCalendar(), StockService.FLAG_USER_INITIATED | (needsNearbyPoints() ? StockService.FLAG_INCLUDE_NEARBY_POINTS : 0)); else { setInfo(info); doNearbyPoints(null); } } else if(mWaitingOnEmptyStartInfo && !info.isGlobalHash()) { mWaitingOnEmptyStartInfo = false; // Coming in from the initial setup, we might have nearbys. Get // the closest one. Info inf = Info.measureClosest(mInitialCheckLocation, info, nearby); // Presto! We've got our Graticule AND Calendar! Now, to make // sure we've got all the nearbys set properly, ask StockService // for the data again, this time using the best one. We'll get // it back in the else field quickly, as it's cached now. requestStock(inf.getGraticule(), inf.getCalendar(), StockService.FLAG_USER_INITIATED | (needsNearbyPoints() ? StockService.FLAG_INCLUDE_NEARBY_POINTS : 0)); } else { if((flags & StockService.FLAG_AUTO_INITIATED) == 0) { setInfo(info); } doNearbyPoints(nearby); } } } @Override public void handleLookupFailure(int reqFlags, int responseCode) { // Nothing here yet. } private void addNearbyPoint(@NonNull Info info) { final Graticule g = info.getGraticule(); if(g == null) return; // This will get called repeatedly up to eight times (in rare cases, // five times) when we ask for nearby points. All we need to do is put // those points on the map, and stuff them in the map. Two different // varieties of map. synchronized(mNearbyPoints) { // The title might be a wee bit unwieldy, as it also has to include // the graticule's location. We DO know that this isn't a // Globalhash, though. String title; String gratString = g.getLatitudeString(false) + " " + g.getLongitudeString(false); // We have strings for today, tomorrow, and the day after tomorrow. // If it's none of those (i.e. either a retro hash or we have stock // data more than two days out, like for holidays), go with the // date. Calendar cal = Calendar.getInstance(); Calendar infoCal = info.getCalendar(); if(DateTools.isSameDate(infoCal, cal)) { title = mCentralMap.getString(R.string.marker_title_nearby_today_hashpoint, gratString); } else if(DateTools.isTomorrow(infoCal, cal)) { title = mCentralMap.getString(R.string.marker_title_nearby_tomorrow_hashpoint, gratString); } else if(DateTools.isDayAfterTomorrow(infoCal, cal)) { title = mCentralMap.getString(R.string.marker_title_nearby_doubletomorrow_hashpoint, gratString); } else { title = mCentralMap.getString(R.string.marker_title_nearby_retro_hashpoint, DateFormat.getDateInstance(DateFormat.LONG).format(info.getDate()), gratString); } // Snippet! Snippet good. String snippet = UnitConverter.makeFullCoordinateString(mCentralMap, info.getFinalLocation(), false, UnitConverter.OUTPUT_LONG); Marker nearby = mMap.addMarker(new MarkerOptions() .position(info.getFinalDestinationLatLng()) .icon(BitmapDescriptorFactory.fromResource(R.drawable.final_destination_disabled)) .anchor(0.5f, 1.0f) .title(title) .snippet(snippet)); mNearbyPoints.put(nearby, info); // Finally, make sure it should be visible. Do this per-marker, as // we're not always sure we've got the full set of eight (edge case // involving the poles) or if all of them will come in at the same // time (edge cases involving 30W or 180E/W). checkMarkerVisibility(nearby); } } private void checkMarkerVisibility(Marker m) { // On a camera change, we need to determine if the nearby markers // (assuming they exist to begin with) need to be drawn. If they're too // far away, they'll get in a jumbled mess with the final destination // flag, and we don't want that. This is more or less similar to the // clustering support in the Google Maps API v2 utilities, but since we // always know the markers will be in a very specific cluster, we can // just simplify it all into this. // First, if we're not in the middle of an expedition, don't worry about // it. if(mCurrentInfo != null) { // Figure out how far this marker is from the final point. Hooray // for Pythagoras! Point dest = mMap.getProjection().toScreenLocation(mDestination.getPosition()); Point mark = mMap.getProjection().toScreenLocation(m.getPosition()); // toScreenLocation gives us values as screen pixels, not display // pixels. Let's convert that to display pixels for sanity's sake. double dist = Math.sqrt(Math.pow((dest.x - mark.x), 2) + Math.pow(dest.y - mark.y, 2)) / mMetrics.density; boolean visible = true; // 50dp should be roughly enough. If I need to change this later, // it's going to be because the images will scale by pixel density. if(dist < 50) visible = false; m.setVisible(visible); } } private void checkInfoBoxFading() { if(mCurrentInfo == null) return; boolean fade; mInfoBox.getLocationRect(mInfoBoxDimens); // First, check the final destination marker. The pin on the flag is // where the point is, so we would want to check against the entire // height of it to see if it crashes into the InfoBox. Projection proj = mMap.getProjection(); Point p = proj.toScreenLocation(mCurrentInfo.getFinalDestinationLatLng()); mMarkerDimens.set(p.x - (mMarkerWidth / 2), p.y - mMarkerHeight, p.x + (mMarkerWidth / 2), p.y); fade = Rect.intersects(mMarkerDimens, mInfoBoxDimens); if(!fade) { // Continue with current location checking. We'll use the same // bounds as the final destination marker, just for convenience. Location loc = getLastKnownLocation(); if(LocationUtil.isLocationNewEnough(loc)) { p = proj.toScreenLocation(new LatLng(loc.getLatitude(), loc.getLongitude())); // Except, remember, the current location marker is pinned at // the CENTER of the image. Tricky! mMarkerDimens.set(p.x - (mMarkerWidth / 2), p.y - (mMarkerHeight / 2), p.x + (mMarkerWidth / 2), p.y + (mMarkerHeight / 2)); fade = Rect.intersects(mMarkerDimens, mInfoBoxDimens); } } mInfoBox.fadeOutInfoBox(fade); } private void doNearbyPoints(@Nullable Info[] nearby) { removeNearbyPoints(); // We should just be able to toss one point in for each Info here. if(nearby != null) { for(Info info : nearby) addNearbyPoint(info); } } private void removeNearbyPoints() { synchronized(mNearbyPoints) { for(Marker m : mNearbyPoints.keySet()) { m.remove(); } mNearbyPoints.clear(); } } private void setInfo(final Info info) { mCurrentInfo = info; mVictoryReported = false; // Redraw the menu as need be, too. mCentralMap.invalidateOptionsMenu(); // Set the infobox in motion as well. if(showInfoBox()) { mInfoBox.animateInfoBoxVisible(true); } else { mInfoBox.animateInfoBoxVisible(false); } if(!mInitComplete) return; removeDestinationPoint(); // The InfoBox ALWAYS gets the Info. mInfoBox.setInfo(info); // Zoom needs updating, too. setZoomButtonsEnabled(); // As does the detail fragment, if it's there. if(mExtraFragment != null) mExtraFragment.setInfo(info); // I suppose a null Info MIGHT come in. I don't know how yet, but sure, // let's assume a null Info here means we just don't render anything. if(mCurrentInfo != null) { mCentralMap.runOnUiThread(new Runnable() { @Override public void run() { // Marker! addDestinationPoint(info); // With an Info in hand, we can also change the title. StringBuilder newTitle = new StringBuilder(); Graticule g = mCurrentInfo.getGraticule(); if(g == null) newTitle.append(mCentralMap.getString(R.string.title_part_globalhash)); else { newTitle.append(g.getLatitudeString(false)).append(' ').append(g.getLongitudeString(false)); } newTitle.append(", "); newTitle.append(DateFormat.getDateInstance(DateFormat.MEDIUM).format(mCurrentInfo.getDate())); setTitle(newTitle.toString()); // Now, the Mercator projection that the map uses clips at // around 85 degrees north and south. If that's where the // point is (if that's the Globalhash or if the user // legitimately lives in Antarctica), we'll still try to // draw it, but we'll throw up a warning that the marker // might not show up. Sure is a good thing an extreme south // Globalhash showed up when I was testing this, else I // honestly might've forgot. ErrorBanner banner = mCentralMap.getErrorBanner(); if(Math.abs(mCurrentInfo.getLatitude()) > 85) { banner.setErrorStatus(ErrorBanner.Status.WARNING); banner.setText(mCentralMap.getString(R.string.warning_outside_of_projection)); banner.animateBanner(true); } // Finally, try to zoom the map to where it needs to be, // assuming we're connected to the APIs and have a location. // This is why you make sure things are ready before you // call init. doInitialZoom(); } }); } else { // Otherwise, make sure the title's back to normal. setTitle(R.string.app_name); } } private void zoomToIdeal(Location current) { // We can't do an ideal zoom if we don't have permissions! if(arePermissionsDenied()) { Log.i(DEBUG_TAG, "Tried to do an ideal zoom after permissions were denied, ignoring..."); return; } // Where "current" means the user's current location, and we're zooming // relative to the final destination, if we have it yet. Let's check // that latter part first. if(mCurrentInfo == null) { Log.i(DEBUG_TAG, "zoomToIdeal was called before an Info was set, ignoring..."); return; } // As a side note, yes, I COULD probably mash this all down to one line, // but I want this to be readable later without headaches. LatLngBounds bounds = LatLngBounds.builder() .include(new LatLng(current.getLatitude(), current.getLongitude())) .include(mCurrentInfo.getFinalDestinationLatLng()) .build(); CameraUpdate cam = CameraUpdateFactory.newLatLngBounds(bounds, mCentralMap.getResources().getDimensionPixelSize(R.dimen.map_zoom_padding)); mMap.animateCamera(cam); } private void zoomToPoint(Location loc) { LatLng dest = new LatLng(loc.getLatitude(), loc.getLongitude()); CameraUpdate cam = CameraUpdateFactory.newLatLngZoom(dest, 15.0f); mMap.animateCamera(cam); } private void zoomToInitialCurrentLocation(Location loc) { // This is called during initial lookup, just to make sure the map's at // a location OTHER than dead zero while we potentially wait for a stock // value to come in. The zoom will be to half a degree around the // current point, just to grab an entire graticule's space. LatLngBounds.Builder builder = new LatLngBounds.Builder(); builder.include(new LatLng(loc.getLatitude() - .5, loc.getLongitude() - .5)); builder.include(new LatLng(loc.getLatitude() - .5, loc.getLongitude() + .5)); builder.include(new LatLng(loc.getLatitude() + .5, loc.getLongitude() - .5)); builder.include(new LatLng(loc.getLatitude() + .5, loc.getLongitude() + .5)); CameraUpdate cam = CameraUpdateFactory.newLatLngBounds(builder.build(), mCentralMap.getResources().getDimensionPixelSize(R.dimen.map_zoom_padding)); try { // And don't worry, when the stock comes in, that'll fire off a new // animateCamera() call, which in turn will cancel this one. mMap.animateCamera(cam); } catch(IllegalStateException ise) { // I really hope it's ready to go by now... Log.w(DEBUG_TAG, "The map isn't ready for animating yet!"); } } private void doReloadZoom() { // This happens on every resume(). The only real difference is that // this is protected by a preference, while initial zoom happens any // time. SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(mCentralMap); boolean autoZoom = prefs.getBoolean(GHDConstants.PREF_AUTOZOOM, true); if(autoZoom) doInitialZoom(); } private void doInitialZoom() { // We can't do the initial zoom if we don't have permissions! if(arePermissionsDenied()) { Log.i(DEBUG_TAG, "Tried to do an initial zoom after permissions were denied, ignoring..."); return; } GoogleApiClient gClient = getGoogleClient(); if(gClient == null) { Log.w(DEBUG_TAG, "Tried calling doInitialZoom() when the Google API client was null or not connected!"); return; } // We want the last known location to be at least SANELY recent. Location loc = getLastKnownLocation(); if(LocationUtil.isLocationNewEnough(loc)) { zoomToIdeal(loc); } else { // Otherwise, wait for the first update and use that for an initial // zoom. ErrorBanner banner = mCentralMap.getErrorBanner(); banner.setErrorStatus(ErrorBanner.Status.NORMAL); banner.setText(mCentralMap.getText(R.string.search_label).toString()); banner.setCloseVisible(false); banner.animateBanner(true); mWaitingOnInitialZoom = true; // While we wait, though, zoom in on the destination point, if we // have one. if(mCurrentInfo != null) { zoomToInitialCurrentLocation(mCurrentInfo.getFinalLocation()); } } } private void doEmptyStart() { // We can't do the empty start if we don't have permissions! if(arePermissionsDenied()) { Log.i(DEBUG_TAG, "Tried to do an empty start after permissions were denied, ignoring..."); return; } Log.d(DEBUG_TAG, "Here comes the empty start..."); // For an initial start, first things first, we ask for the current // location. If it's new enough, we can go with that, as usual. Location loc = getLastKnownLocation(); if(LocationUtil.isLocationNewEnough(loc)) { mInitialCheckLocation = loc; mWaitingOnEmptyStartInfo = true; zoomToInitialCurrentLocation(loc); requestStock(new Graticule(loc), Calendar.getInstance(), StockService.FLAG_USER_INITIATED | StockService.FLAG_FIND_CLOSEST); } else { // Otherwise, it's off to the races. ErrorBanner banner = mCentralMap.getErrorBanner(); banner.setErrorStatus(ErrorBanner.Status.NORMAL); banner.setText(mCentralMap.getText(R.string.search_label).toString()); banner.setCloseVisible(false); banner.animateBanner(true); mWaitingOnEmptyStart = true; } } @Override public void onInfoWindowClick(Marker marker) { // If a nearby marker's info window was clicked, that means we can // switch to another point. if(mNearbyPoints.containsKey(marker)) { final Info newInfo = mNearbyPoints.get(marker); // Get the last-known location (if possible) and prompt the user // with a distance. Then, we've got a fragment that'll do this sort // of work for us. NearbyGraticuleDialogFragment frag = NearbyGraticuleDialogFragment.newInstance(newInfo, getLastKnownLocation()); frag.setCallback(this); frag.show(mCentralMap.getFragmentManager(), NEARBY_DIALOG); } } @Override public void onCameraChange(CameraPosition cameraPosition) { // We're going to check visibility on each marker individually. This // might make some of them vanish while others remain on, owing to our // good friend the Pythagorean Theorem and neat Mercator projection // tricks. for(Marker m : mNearbyPoints.keySet()) checkMarkerVisibility(m); // Also, let's get the infobox faded as need be. checkInfoBoxFading(); } @Override public void nearbyGraticuleClicked(Info info) { // Info! requestStock(info.getGraticule(), info.getCalendar(), StockService.FLAG_USER_INITIATED | (needsNearbyPoints() ? StockService.FLAG_INCLUDE_NEARBY_POINTS : 0)); } @Override public void changeCalendar(@NonNull Calendar newDate) { // New Calendar! That means we ask for more stock data! It doesn't // necessarily mean a new point is coming in, but it does mean we're // making a request, at least. The StockService broadcast will let us // know what's going on later. Graticule g; // It should be pretty safe to just change it like this every time. mInitialCalendar = newDate; // The Graticule we use is either the one in our current Info (thus // recycling our current position), whatever the initial check came up // with, or the last-used graticule if we're in that startup mode. The // latter two are in case we never came up with a valid Info if, for // instance, the check was made before the opening of the DJIA and the // user decided to pick a previous day. boolean isGlobalHash = false; if(mCurrentInfo != null) { // If we have a current info, use its graticule to make a new stock // out of the calendar. g = mCurrentInfo.getGraticule(); isGlobalHash = mCurrentInfo.isGlobalHash(); } else if(mInitialCheckLocation != null) { // If not, we might have an initial check location, so we can get // started from there. g = new Graticule(mInitialCheckLocation); } else { // If not, we're in Last Used Graticule mode, we failed the first // stock lookup, and we're changing the date. Use the known // starting graticule. g = mStartingGraticule; isGlobalHash = mStartingGlobalHash; } // If we didn't get a Graticule back from any of that (AND this isn't a // Globalhash), then we're clearly not ready to make stock requests and // are currently waiting for an initial location (or for the user to // switch to SelectAGraticuleMode instead). if(g != null || isGlobalHash) requestStock(g, newDate, StockService.FLAG_USER_INITIATED | (needsNearbyPoints() ? StockService.FLAG_INCLUDE_NEARBY_POINTS : 0)); } private boolean needsNearbyPoints() { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(mCentralMap); return prefs.getBoolean(GHDConstants.PREF_NEARBY_POINTS, true); } private boolean showInfoBox() { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(mCentralMap); return mCurrentInfo != null && prefs.getBoolean(GHDConstants.PREF_INFOBOX, true); } private void launchExtraFragment(@NonNull CentralMapExtraFragment.FragmentType type) { // First off, ignore this if there's no Info yet. if(mCurrentInfo == null) return; // Ask CentralMap if there's a fragment container in this layout. // If so (tablet layouts), add it to the current screen. If not // (phone layouts), jump off to the dedicated activity. View container = mCentralMap.findViewById(R.id.extra_fragment_container); if(container == null) { // To the Activity! Intent i = CentralMapExtraFragment.makeIntentForType(mCentralMap, type); i.putExtra(DetailedInfoActivity.INFO, mCurrentInfo); mCentralMap.startActivity(i); } else { // Check to see if the fragment's already there. FragmentManager manager = mCentralMap.getFragmentManager(); CentralMapExtraFragment f; try { f = (CentralMapExtraFragment) manager.findFragmentById(R.id.extra_fragment_container); } catch(ClassCastException cce) { f = null; } // Make the bundle arguments. We want to argue a whole bundle. Bundle args = new Bundle(); args.putParcelable(CentralMapExtraFragment.INFO, mCurrentInfo); args.putBoolean(CentralMapExtraFragment.PERMISSIONS_DENIED, arePermissionsDenied()); if(f == null) { // It's not there! Make it be there! mExtraFragment = CentralMapExtraFragment.makeFragmentForType(type); mExtraFragment.setArguments(args); mExtraFragment.setCloseListener(this); FragmentTransaction trans = manager.beginTransaction(); trans.replace(R.id.extra_fragment_container, mExtraFragment, EXTRA_FRAGMENT_BACK_STACK); trans.addToBackStack(EXTRA_FRAGMENT_BACK_STACK); trans.commit(); // Also, due to how the layout works, the container also needs // to go visible now. container.setVisibility(View.VISIBLE); } else { // Okay, something's already there. Is it the same type of // fragment we're trying to launch? if(type == f.getType()) { // It is! Just dismiss it, then. clearExtraFragment(); } else { // It isn't. Well, that means we need to replace the old // one. However, we also need to make sure the destroy // call won't trigger the hide-the-container code in here. // So... mReplacingFragment = true; mExtraFragment = CentralMapExtraFragment.makeFragmentForType(type); mExtraFragment.setArguments(args); mExtraFragment.setCloseListener(this); FragmentTransaction trans = manager.beginTransaction(); trans.replace(R.id.extra_fragment_container, mExtraFragment, EXTRA_FRAGMENT_BACK_STACK); trans.addToBackStack(EXTRA_FRAGMENT_BACK_STACK); trans.commit(); } } } } private void clearExtraFragment() { // This simply clears out the extra fragment. FragmentManager manager = mCentralMap.getFragmentManager(); try { manager.popBackStack(EXTRA_FRAGMENT_BACK_STACK, FragmentManager.POP_BACK_STACK_INCLUSIVE); } catch(IllegalStateException ise) { // We might find ourselves here during shutdown time. CentralMap // triggers its onSaveInstanceState before onDestroy, onDestroy // calls cleanUp, cleanUp comes here, and FragmentManager throws a // fit if you try to pop the back stack AFTER onSaveInstanceState on // an Activity. In lieu of making more methods in CentralMapMode to // implement, we'll just catch the exception and ignore it. } } @Override public void extraFragmentClosing(CentralMapExtraFragment fragment) { // On the close button, pop the back stack. clearExtraFragment(); } @Override public void extraFragmentDestroying(CentralMapExtraFragment fragment) { // And now that it's being destroyed, hide the container, unless it's // being replaced. if(mReplacingFragment) { mReplacingFragment = false; } else { View container = mCentralMap.findViewById(R.id.extra_fragment_container); if(container != null) container.setVisibility(View.GONE); else Log.w(DEBUG_TAG, "We got extraFragmentDestroying when there's no container in CentralMap for it! The hell?"); mExtraFragment = null; } } @Override public void zoomButtonPressed(View container, int which) { // BEEP. switch(which) { case ZoomButtons.ZOOM_FIT_BOTH: doInitialZoom(); break; case ZoomButtons.ZOOM_DESTINATION: // Assuming we already have the destination... if(mCurrentInfo == null) { Log.e(DEBUG_TAG, "Tried to zoom to the destination when there is no destination set!"); } else { zoomToPoint(mCurrentInfo.getFinalLocation()); } break; case ZoomButtons.ZOOM_USER: GoogleApiClient gClient = getGoogleClient(); if(gClient == null) { Log.e(DEBUG_TAG, "Tried to zoom to current location when Google API Client was null or not connected!"); return; } // Hopefully the user's already got a valid location. Else... Location loc = getLastKnownLocation(); if(LocationUtil.isLocationNewEnough(loc)) { zoomToPoint(loc); } else { // Otherwise, wait for the first update and use that for the // user's location. ErrorBanner banner = mCentralMap.getErrorBanner(); banner.setErrorStatus(ErrorBanner.Status.NORMAL); banner.setText(mCentralMap.getText(R.string.search_label).toString()); banner.setCloseVisible(false); banner.animateBanner(true); mWaitingOnZoomToUser = true; } break; } } @Override public void onLocationChanged(Location location) { // This listener handles all listening duties. We've got a few // booleans that tell us what's waiting to be done. if(mWaitingOnInitialZoom) { mWaitingOnInitialZoom = false; if(!isCleanedUp()) { mCentralMap.getErrorBanner().animateBanner(false); zoomToIdeal(location); } } if(mWaitingOnEmptyStart) { mWaitingOnEmptyStart = false; mWaitingOnEmptyStartInfo = true; if(!isCleanedUp()) { mInitialCheckLocation = location; // First, zoom to the location. This'll at least give us // something other than the center of the map until the // hashpoint comes in. zoomToInitialCurrentLocation(location); // Second, ask for a stock using that location. if(mInitialCalendar == null) mInitialCalendar = Calendar.getInstance(); requestStock(new Graticule(location), mInitialCalendar, StockService.FLAG_USER_INITIATED | StockService.FLAG_FIND_CLOSEST); } } if(mWaitingOnZoomToUser) { mWaitingOnZoomToUser = false; if(!isCleanedUp()) { mCentralMap.getErrorBanner().animateBanner(false); zoomToPoint(location); } } // Next, do the victory observer. We're not using the built-in // geofencing capabilities because we want to use the current GPS // accuracy as our fencing radius. The built-in one requires a // single radius that doesn't change, which, to be honest, is // perfectly fine for MOST situations. This just isn't most // situations. if(mCurrentInfo != null && !mVictoryReported) { float accuracy = location.getAccuracy(); // The accuracy can't be zero, at least not in real-world // circumstances. The only way it'll be zero is if we're using // the emulator or there's otherwise a mock location coming in. // In that case, treat it as 5m, just so victory can be achieved // without being EXACTLY on the point. if(accuracy == 0.0f) accuracy = 5.0f; if(accuracy < GHDConstants.LOW_ACCURACY_THRESHOLD && mCurrentInfo.getDistanceInMeters(location) < accuracy) { // VICTORY! ErrorBanner banner = mCentralMap.getErrorBanner(); banner.setErrorStatus(ErrorBanner.Status.VICTORY); banner.setText(mCentralMap.getString(R.string.toast_close_enough)); banner.setCloseVisible(true); banner.animateBanner(true); mVictoryReported = true; } } // Update the InfoBox, too. Fortunately, that takes care of // everything in and of itself. mInfoBox.onLocationChanged(location); // Plus, update the fragment, if there is one. if(mExtraFragment != null) mExtraFragment.onLocationChanged(location); } @Override public void permissionsDenied(boolean denied) { // Make sure the zoom buttons are updated right away. setZoomButtonsEnabled(); // Also, get rid of the banner if we're denied. if(denied) mCentralMap.getErrorBanner().animateBanner(false); // The InfoBox also goes to unavailable mode. mInfoBox.setUnavailable(denied); // The fragment needs to know to shut off location bits if need be. if(mExtraFragment != null) mExtraFragment.permissionsDenied(denied); // All of the various updates for which we've got booleans will just sit // around and not do anything if we never get updates, which we won't if // the user denied permissions. } private void setZoomButtonsEnabled() { // Zoom to user is always on if permissions aren't denied. mZoomButtons.setButtonEnabled(ZoomButtons.ZOOM_USER, !arePermissionsDenied()); // Zoom to destination is only on if we have a valid info. mZoomButtons.setButtonEnabled(ZoomButtons.ZOOM_DESTINATION, mCurrentInfo != null); // Zoom to both is only on if we have a valid info AND permissions // aren't denied. mZoomButtons.setButtonEnabled(ZoomButtons.ZOOM_FIT_BOTH, mCurrentInfo != null && !arePermissionsDenied()); } @Nullable @Override protected Info getActiveInfo() { return mCurrentInfo; } }