com.google.android.apps.santatracker.presentquest.MapsActivity.java Source code

Java tutorial

Introduction

Here is the source code for com.google.android.apps.santatracker.presentquest.MapsActivity.java

Source

/*
 * Copyright (C) 2016 Google Inc. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.google.android.apps.santatracker.presentquest;

import android.Manifest;
import android.content.DialogInterface;
import android.content.Intent;
import android.location.Location;
import android.os.Bundle;
import android.os.Handler;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.design.widget.FloatingActionButton;
import android.support.v4.app.ActivityOptionsCompat;
import android.support.v4.content.ContextCompat;
import android.support.v4.content.LocalBroadcastManager;
import android.support.v4.util.Pair;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.PopupMenu;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;

import com.google.android.apps.santatracker.presentquest.model.Messages;
import com.google.android.apps.santatracker.presentquest.model.Place;
import com.google.android.apps.santatracker.presentquest.model.Present;
import com.google.android.apps.santatracker.presentquest.model.User;
import com.google.android.apps.santatracker.presentquest.model.Workshop;
import com.google.android.apps.santatracker.presentquest.ui.CirclePulseAnimator;
import com.google.android.apps.santatracker.presentquest.ui.ClickMeAnimator;
import com.google.android.apps.santatracker.presentquest.ui.OnboardingView;
import com.google.android.apps.santatracker.presentquest.ui.PlayGameDialog;
import com.google.android.apps.santatracker.presentquest.ui.PlayJetpackDialog;
import com.google.android.apps.santatracker.presentquest.ui.PlayWorkshopDialog;
import com.google.android.apps.santatracker.presentquest.ui.ScoreTextAnimator;
import com.google.android.apps.santatracker.presentquest.ui.SlideAnimator;
import com.google.android.apps.santatracker.presentquest.util.Config;
import com.google.android.apps.santatracker.presentquest.util.FuzzyLocationUtil;
import com.google.android.apps.santatracker.presentquest.util.MarkerCache;
import com.google.android.apps.santatracker.presentquest.util.PreferencesUtil;
import com.google.android.apps.santatracker.presentquest.util.VibrationUtil;
import com.google.android.apps.santatracker.util.MapHelper;
import com.google.android.apps.santatracker.util.MeasurementManager;
import com.google.android.apps.santatracker.util.NetworkHelper;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.api.GoogleApiClient;
import com.google.android.gms.location.LocationListener;
import com.google.android.gms.location.LocationRequest;
import com.google.android.gms.location.LocationServices;
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.OnMapReadyCallback;
import com.google.android.gms.maps.SupportMapFragment;
import com.google.android.gms.maps.model.Circle;
import com.google.android.gms.maps.model.CircleOptions;
import com.google.android.gms.maps.model.LatLng;
import com.google.android.gms.maps.model.LatLngBounds;
import com.google.android.gms.maps.model.MapStyleOptions;
import com.google.android.gms.maps.model.Marker;
import com.google.android.gms.maps.model.MarkerOptions;
import com.google.firebase.analytics.FirebaseAnalytics;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;

import pub.devrel.easypermissions.AfterPermissionGranted;
import pub.devrel.easypermissions.AppSettingsDialog;
import pub.devrel.easypermissions.EasyPermissions;

public class MapsActivity extends AppCompatActivity implements GoogleMap.OnCameraMoveListener,
        GoogleMap.OnMarkerClickListener, GoogleMap.OnMapLongClickListener,
        GoogleMap.OnMyLocationButtonClickListener, LocationListener, OnMapReadyCallback, View.OnClickListener,
        GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener,
        PlayGameDialog.GameDialogListener, EasyPermissions.PermissionCallbacks {

    private static final String TAG = "PQ(MapsActivity)";

    // RequestCode for launching the Workshop game
    public static final int RC_WORKSHOP_GAME = 9004;

    // RequestCode for launching the Jetpack game
    public static final int RC_JETPACK_GAME = 9005;

    // Intent action for moving workshop mode
    public static final String ACTION_MOVE_WORKSHOP = "ACTION_MOVE_WORKSHOP";

    // Extras for moving workshop
    public static final String EXTRA_MOVE_WORKSHOP_ID = "move_workshop_id";

    // Zoom when we're automatically moving the map as location changes.
    private static final int FOLLOWING_ZOOM = 16;

    // Location permissions
    private static final int RC_PERMISSIONS = 101;
    private static final int RC_SETTINGS = 102;
    private static final String[] PERMISSIONS_REQUIRED = new String[] { Manifest.permission.ACCESS_FINE_LOCATION };

    // Map and location
    private GoogleApiClient mGoogleApiClient;
    private SupportMapFragment mSupportMapFragment;
    private GoogleMap mMap;

    // User's current location.
    private LatLng mCurrentLatLng;

    // How far has the user walked since we last recorded it?
    private int mMetersWalked;

    // Are we moving the map as the user moves.
    private boolean mFollowing = true;

    // View elements for workshop moving flow
    private Marker mMovingWorkshopMarker;
    private View mWorkshopView;

    // Current game user
    private User mUser;

    // DialogFragment that can launch the Workshop game
    private PlayWorkshopDialog mWorkshopDialog = new PlayWorkshopDialog();

    // DialogFragment that can launch the Jetpack game
    private PlayJetpackDialog mJetpackDialog = new PlayJetpackDialog();

    // Marker showing current location
    private Marker mLocationMarker;

    // Circle that pulses around current location
    private CirclePulseAnimator mActionHorizonAnimator;
    private Circle mActionHorizon;

    // Text and animator for showing game scores
    private TextView mScoreTextView;
    private ScoreTextAnimator mScoreTextAnimator;

    // Views showing current level/progress.
    private ImageView mAvatarView;
    private ClickMeAnimator mAvatarAnimator;

    // Views showing bag fullness
    private ImageView mBagView;
    private ClickMeAnimator mBagAnimator;

    // Offline scrim
    private ViewGroup mMapScrim;

    // Snackbar
    private ViewGroup mSnackbar;
    private TextView mSnackbarText;
    private ImageView mSnackbarButton;

    // True when this activity has been launched for the purposes of moving the workshop
    private boolean mIsInWorkshopMoveMode = false;

    // Cache for Marker resources
    private MarkerCache mMarkerCache;

    // Map of workshop ID --> Marker
    private Map<Long, Marker> mWorkshopMarkers = new HashMap<>();

    // Map of present ID --> Marker
    private Map<Long, Marker> mPresentMarkers = new HashMap<>();

    // List of known presents
    private List<Present> mPresents;
    private List<Present> mReachablePresents = new ArrayList<>();

    // List of known workshops
    private List<Workshop> mWorkshops;
    private List<Workshop> mReachableWorkshops = new ArrayList<>();

    // Shared Prefs
    private PreferencesUtil mPreferences;

    // Firebase Analytics
    private FirebaseAnalytics mAnalytics;

    // Firebase Analytics
    private Config mConfig;

    // General-purpose handler
    private Handler mHandler = new Handler();

    // Receiver for results from PlacesIntentService
    private PlacesIntentService.NearbyResultReceiver mNearbyReceiver = new PlacesIntentService.NearbyResultReceiver() {
        @Override
        public void onResult(LatLng result) {
            // Add present result
            Log.d(TAG, "nearbyRecever:onResult");
            addPresent(result, false);
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_maps);

        // [ANALYTICS]
        mAnalytics = FirebaseAnalytics.getInstance(this);
        MeasurementManager.recordScreenView(mAnalytics, getString(R.string.analytics_screen_pq_map));

        // Init marker cache
        mMarkerCache = new MarkerCache(this);

        // Init prefs
        mPreferences = new PreferencesUtil(this);

        // Init views
        mAvatarView = (ImageView) findViewById(R.id.map_user_image);
        mBagView = (ImageView) findViewById(R.id.image_bag);
        mWorkshopView = findViewById(R.id.workshop);
        mScoreTextView = (TextView) findViewById(R.id.map_text_big_score);

        mMapScrim = (ViewGroup) findViewById(R.id.offline_map_scrim);

        mSnackbar = (ViewGroup) findViewById(R.id.snackbar_container);
        mSnackbarText = (TextView) findViewById(R.id.snackbar_text);
        mSnackbarButton = (ImageView) findViewById(R.id.close_snackbar);

        // Onboarding
        OnboardingView onboardingView = (OnboardingView) findViewById(R.id.container_onboarding);
        onboardingView.setOnFinishListener(new OnboardingView.OnFinishListener() {
            @Override
            public void onFinish() {
                mPreferences.setHasOnboarded(true);
            }
        });

        if (mPreferences.getHasOnboarded()) {
            onboardingView.setVisibility(View.GONE);
        } else {
            onboardingView.setVisibility(View.VISIBLE);
        }

        // Animator(s)
        mScoreTextAnimator = new ScoreTextAnimator(mScoreTextView);
        mAvatarAnimator = new ClickMeAnimator(mAvatarView);
        mBagAnimator = new ClickMeAnimator(mBagView);

        // Dialog listeners
        mJetpackDialog.setListener(this);
        mWorkshopDialog.setListener(this);

        // Debug UI
        initializeDebugUI();

        // Set current level
        mUser = User.get();
        setUserProgress();

        // Firebase config
        mConfig = new Config();

        // Click listeners
        mWorkshopView.setOnClickListener(this);
        mSnackbarButton.setOnClickListener(this);
        mMapScrim.setOnClickListener(this);
        findViewById(R.id.blue_bar).setOnClickListener(this);
        findViewById(R.id.map_user_image).setOnClickListener(this);
        findViewById(R.id.fab_location).setOnClickListener(this);
        findViewById(R.id.fab_accept_workshop_move).setOnClickListener(this);
        findViewById(R.id.fab_cancel_workshop_move).setOnClickListener(this);

        if (getIntent() != null) {
            mIsInWorkshopMoveMode = ACTION_MOVE_WORKSHOP.equals(getIntent().getAction());
        }

        initializeMap();
    }

    @Override
    protected void onStart() {
        super.onStart();

        // Update workshops and presents
        mWorkshops = Workshop.listAll(Workshop.class);
        mPresents = Present.listAll(Present.class);

        // Register result receiver for nearby places
        LocalBroadcastManager.getInstance(this).registerReceiver(mNearbyReceiver,
                PlacesIntentService.getNearbySearchIntentFilter());

        // Start animations
        if (mActionHorizonAnimator != null && mActionHorizon != null) {
            mActionHorizonAnimator.start();
        }

        // Nudge the user to go to the profile
        if (mPreferences.getHasCollectedPresent() && !mPreferences.getHasVisitedProfile()) {
            mAvatarAnimator.start();
        } else {
            mAvatarAnimator.stop();
        }

        initializeUI();
    }

    @Override
    protected void onResume() {
        super.onResume();

        if (mMap != null && mCurrentLatLng != null) {
            mMap.setOnMapLoadedCallback(new GoogleMap.OnMapLoadedCallback() {
                @Override
                public void onMapLoaded() {
                    // Update workshops and draw markers
                    mWorkshops = Workshop.listAll(Workshop.class);
                    drawMarkers();
                }
            });
        }
    }

    @Override
    protected void onStop() {
        super.onStop();

        // Unregister result receiver for nearby places
        LocalBroadcastManager.getInstance(this).unregisterReceiver(mNearbyReceiver);

        // Stop animations
        if (mActionHorizonAnimator != null) {
            mActionHorizonAnimator.stop();
        }

        if (mAvatarAnimator != null) {
            mAvatarAnimator.stop();
        }

        if (mBagAnimator != null) {
            mBagAnimator.stop();
        }
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if (requestCode == RC_JETPACK_GAME) {
            // Get score of the game
            int score = data != null ? data.getIntExtra("jetpack_score", 0) : 0;
            onPresentsCollected(score);
        }

        if (requestCode == RC_WORKSHOP_GAME) {
            // Get score of the game
            int stars = data != null ? data.getIntExtra("presentDropStars", 1) : 1;
            onPresentsReturned(stars);
        }

        // User has returned from being sent to the Settings screen to enable permissions
        if (requestCode == RC_SETTINGS) {
            if (EasyPermissions.hasPermissions(this, PERMISSIONS_REQUIRED)) {
                // We have the permissions we need, start location tracking
                startLocationTracking();
            } else {
                // User did not complete the task, quit the game
                onCompletePermissionDenial();
            }
        }
    }

    @Override
    public void onBackPressed() {
        if (mIsInWorkshopMoveMode) {
            onCancelWorkshopMove();
            return;
        }

        super.onBackPressed();
    }

    private void initializeMap() {
        // Check for network and begin
        if (NetworkHelper.hasNetwork(this)) {
            // Kick off the map load, which kicks off the location update cycle which
            // is where all of the action happens
            mSupportMapFragment = (SupportMapFragment) getSupportFragmentManager().findFragmentById(R.id.map);
            mSupportMapFragment.getMapAsync(this);

            // Hide the offline overlay
            mMapScrim.setVisibility(View.GONE);
        } else {
            // Show "offline" overlay
            mMapScrim.setVisibility(View.VISIBLE);
        }
    }

    private void onPresentsCollected(int score) {
        if (score > 0) {
            // Show the game score after a 500ms delay
            showScoreText(score, 500);

            // Mark that the user has done a successful present collection
            mPreferences.setHasCollectedPresent(true);

            // [ANALYTICS]
            MeasurementManager.recordPresentsCollected(mAnalytics, score);
        }

        // Update number of presents collected
        mUser.collectPresents(score);
        setUserProgress();

        // If the user's bag is full (or almost full) show a message
        if (mUser.getBagFillPercentage() >= 100) {
            showSnackbar(Messages.UNLOAD_BAG);
        } else if (mUser.getBagFillPercentage() >= 75) {
            showSnackbar(Messages.BAG_ALMOST_FULL);
        }
    }

    private void onPresentsReturned(int stars) {
        // Returning present works like this:
        // 0 stars - return 50% of bag
        // 1 stars - return 70% of bag
        // 2 stars - return 90% of bag
        // 3 stars - return 100% of bag

        float factor;
        int presentsReturned;
        switch (stars) {
        case 3:
            factor = 1.0f;
            break;
        case 2:
            factor = 0.90f;
            break;
        case 1:
            factor = 0.70f;
            break;
        default:
            factor = 0.50f;
            break;
        }

        // Multiply number of presents collected by return factor
        presentsReturned = (int) (mUser.presentsCollected * factor);

        if (presentsReturned > 0) {
            // Show the game score after a 500ms delay
            showScoreText(presentsReturned, 500);

            // Mark that the user has done a successful present return
            mPreferences.setHasReturnedPresent(true);

            // [ANALYTICS]
            MeasurementManager.recordPresentsReturned(mAnalytics, presentsReturned);
        }

        // Update number of presents returned
        int previousLevel = mUser.getLevel();
        mUser.returnPresentsAndEmpty(presentsReturned);
        setUserProgress();

        int currentLevel = mUser.getLevel();
        if (currentLevel != previousLevel) {
            // [ANALYTICS]
            MeasurementManager.recordPresentQuestLevel(mAnalytics, currentLevel);

            // Start level up animation
            onLevelUp();
        }
    }

    private void setUserProgress() {
        // Set level text
        ((TextView) findViewById(R.id.text_current_level)).setText(String.valueOf(mUser.getLevel()));

        // Set level progress
        ((ProgressBar) findViewById(R.id.progress_level)).setProgress(mUser.getLevelProgress());

        // Set avatar image
        mAvatarView.setImageDrawable(ContextCompat.getDrawable(this, mUser.getAvatar()));

        // Set bag fullness in text and image
        int percentFull = mUser.getBagFillPercentage();
        int bagImageId;
        if (percentFull >= 100) {
            bagImageId = R.drawable.bag_6;
        } else if (percentFull >= 75) {
            bagImageId = R.drawable.bag_5;
        } else if (percentFull >= 50) {
            bagImageId = R.drawable.bag_4;
        } else if (percentFull >= 25) {
            bagImageId = R.drawable.bag_3;
        } else if (percentFull > 0) {
            bagImageId = R.drawable.bag_2;
        } else {
            bagImageId = R.drawable.bag_1;
        }

        // Show fullness as percentage (0 to 100%)
        String percentString = String.valueOf(percentFull) + "%";
        ((TextView) findViewById(R.id.text_bag_level)).setText(percentString);

        // Show fullness image
        mBagView.setImageResource(bagImageId);

        // Pulse the bag if it's 100% full
        if (percentFull >= 100) {
            mBagAnimator.start();
        } else {
            mBagAnimator.stop();
        }
    }

    private void launchProfileActivity() {
        Intent intent = ProfileActivity.getIntent(MapsActivity.this, false, mCurrentLatLng);

        Pair<View, String> imagePair = new Pair<>(findViewById(R.id.map_user_image), "user_image");
        Pair<View, String> levelTextPair = new Pair<>(findViewById(R.id.layout_level_text), "level_text");

        ActivityOptionsCompat options = ActivityOptionsCompat.makeSceneTransitionAnimation(MapsActivity.this,
                imagePair, levelTextPair);
        startActivity(intent, options.toBundle());
    }

    private void onLevelUp() {
        mHandler.postDelayed(new Runnable() {
            @Override
            public void run() {
                Intent intent = ProfileActivity.getIntent(MapsActivity.this, true, mCurrentLatLng);
                startActivity(intent);
                overridePendingTransition(R.anim.fade_in, R.anim.fade_out);
            }
        }, 500);

    }

    @Override
    public void onMapReady(GoogleMap googleMap) {
        mMap = googleMap;
        mMap.setIndoorEnabled(false);
        mMap.setOnCameraMoveListener(this);
        mMap.setOnMarkerClickListener(this);
        mMap.setOnMapLongClickListener(this);
        mMap.setMapStyle(MapStyleOptions.loadRawResourceStyle(this, R.raw.map_style));

        // Min and max zoom
        mMap.setMaxZoomPreference(FOLLOWING_ZOOM + 1.0f);
        mMap.setMinZoomPreference(FOLLOWING_ZOOM - 3.0f);

        // Only enable showing user location if in workshop edit mode
        //noinspection MissingPermission
        mMap.setMyLocationEnabled(mIsInWorkshopMoveMode);

        mMap.getUiSettings().setMapToolbarEnabled(false);
        mMap.getUiSettings().setIndoorLevelPickerEnabled(false);

        if (mIsInWorkshopMoveMode) {
            drawMarkerForWorkshop(getMovingWorkshopId());
            startWorkshopMove(getMovingWorkshopId());
        } else {
            startLocationTracking();
        }
    }

    @AfterPermissionGranted(RC_PERMISSIONS)
    private void startLocationTracking() {
        // Check for location permissions
        if (!EasyPermissions.hasPermissions(this, PERMISSIONS_REQUIRED)) {
            // Request permissions
            EasyPermissions.requestPermissions(this, getString(R.string.perm_location_rationale), RC_PERMISSIONS,
                    PERMISSIONS_REQUIRED);
        }

        if (mGoogleApiClient == null) {
            mGoogleApiClient = new GoogleApiClient.Builder(this).addConnectionCallbacks(this)
                    .enableAutoManage(this, this).addApi(LocationServices.API).build();
        } else if (mGoogleApiClient.isConnected()) {
            requestLocationUpdates();
        }
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
        // Forward results to EasyPermissions
        EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults, this);
    }

    @SuppressWarnings("MissingPermission")
    private void requestLocationUpdates() {
        if (!EasyPermissions.hasPermissions(this, PERMISSIONS_REQUIRED)) {
            return;
        }

        Location location = LocationServices.FusedLocationApi.getLastLocation(mGoogleApiClient);
        if (location != null) {
            onLocationChanged(location);
        }

        LocationRequest request = new LocationRequest();
        request.setInterval(mConfig.LOCATION_REQUEST_INTERVAL_MS);
        request.setFastestInterval(mConfig.LOCATION_REQUEST_INTERVAL_FASTEST_MS);
        request.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY);
        LocationServices.FusedLocationApi.requestLocationUpdates(mGoogleApiClient, request, this);
    }

    @Override
    public void onLocationChanged(Location location) {
        if (location == null) {
            Log.w(TAG, "onLocationChanged: null location");
            return;
        }

        // Check if no workshop exists, create one if the user has ever collected a present
        if (mWorkshops.isEmpty() && mUser.getPresentsCollectedAllTime() > 0) {
            Workshop workshop = new Workshop();

            // Put the workshop very close to current location but slightly offset
            LatLng latLng = new LatLng(location.getLatitude(), location.getLongitude());
            workshop.setLatLng(FuzzyLocationUtil.fuzz(latLng));
            workshop.save();

            // Reload workshops
            mWorkshops = Workshop.listAll(Workshop.class);
        }

        // If this is the first run, we'll draw markers.
        boolean firstRun = mCurrentLatLng == null;

        // Update our current location only if we've moved at least a metre, to avoid
        // jitter due to lack of accuracy in FusedLocationApi.
        LatLng newLatLng = new LatLng(location.getLatitude(), location.getLongitude());
        if (firstRun || Distance.between(mCurrentLatLng, newLatLng) > 1) {
            updateMetersWalked(mCurrentLatLng, newLatLng);

            mCurrentLatLng = newLatLng;
            if (mFollowing) {
                if (firstRun) {
                    safeMoveCamera(mMap, CameraUpdateFactory.newLatLngZoom(mCurrentLatLng, FOLLOWING_ZOOM));
                } else {
                    safeAnimateCamera(mMap, CameraUpdateFactory.newLatLngZoom(mCurrentLatLng, FOLLOWING_ZOOM));
                }
            }
        }

        if (firstRun && !mPresents.isEmpty()) {
            animateToNearbyPresents();
        }

        // Draw all markers on the map
        drawMarkers();
        drawMarkerLabels();

        // Update reachable presents and workshops, nudge the user to click on things when appropriate
        int oldPresentsReachable = mReachablePresents.size();
        mReachablePresents = getPresentsReachable();
        if ((mReachablePresents.size() > oldPresentsReachable) && mUser.getBagFillPercentage() < 100) {
            // New present reachable and bag is not full, show prompt
            showSnackbar(Messages.CLICK_PRESENT);
        }

        int oldWorkshopsReachable = mReachableWorkshops.size();
        mReachableWorkshops = getWorkshopsReachable();
        if ((mReachableWorkshops.size() > oldWorkshopsReachable) && mUser.getBagFillPercentage() > 0) {
            // New workshop reachable and bag is not empty, show prompt
            showSnackbar(Messages.CLICK_WORKSHOP);
        }

        // Ask the places service for a new place if too few nearby.
        int near = getPresentsNearby().size();
        if (near < mConfig.MIN_NEARBY_PRESENTS) {
            // [DEBUG ONLY] log the user's position
            if (isDebug()) {
                Log.d(TAG, "Searching for places near: " + mCurrentLatLng);
            }
            PlacesIntentService.startNearbySearch(this, mCurrentLatLng, mConfig.NEARBY_RADIUS_METERS);
        }

        // Action horizon
        initCurrentLocationMarkers();

        // Update locations
        mLocationMarker.setPosition(mCurrentLatLng);
        mActionHorizon.setCenter(mCurrentLatLng);
    }

    private void initCurrentLocationMarkers() {
        // Show current location
        MarkerOptions options = mMarkerCache.getElfMarker().position(mCurrentLatLng);
        if (mLocationMarker == null) {
            mLocationMarker = mMap.addMarker(options);
        } else {
            MarkerCache.updateMarker(mLocationMarker, options);
        }

        // Create circle around current location
        if (mActionHorizon == null) {
            mActionHorizon = mMap.addCircle(new CircleOptions().center(mCurrentLatLng).radius(10.0f)
                    .strokeColor(ContextCompat.getColor(this, R.color.action_horizon_stroke))
                    .fillColor(ContextCompat.getColor(this, R.color.action_horizon_fill)));
        }

        // Create animations for circle around current location
        if (mActionHorizonAnimator == null) {
            mActionHorizonAnimator = new CirclePulseAnimator(this, mActionHorizon, mConfig.REACHABLE_RADIUS_METERS);
            mActionHorizonAnimator.start();
        }
    }

    private void addPresent(LatLng latLng, boolean force) {
        if (!isNearLatLng(latLng) || force) { // Don't add if too close.

            float chanceIsLarge = mUser.getLargePresentChance();
            float randomChance = new Random().nextFloat();
            boolean isLarge = (randomChance <= chanceIsLarge);

            Present newPresent = new Present(latLng, isLarge);
            newPresent.save();

            // Show a message about the new present
            showSnackbar(Messages.NEW_PRESENT);

            // [ANALYTICS]
            MeasurementManager.recordPresentDropped(mAnalytics, newPresent.isLarge);

            // Reload the presents
            mPresents = getPresentsSorted();

            // If adding the present exceeds MAX_PRESENTS, delete the farthest.
            if (mPresents.size() > mConfig.MAX_PRESENTS) {
                Present removePresent = mPresents.get(mPresents.size() - 1);
                deletePresent(removePresent);
            }

            animateToNearbyPresents();
            drawMarkers();
            drawMarkerLabels();
        }
    }

    private void animateToNearbyPresents() {
        // Animate to bounds showing presents and current location.
        LatLngBounds.Builder bounds = LatLngBounds.builder().include(mCurrentLatLng);
        List<Present> nearbyPresents = getPresentsNearby();
        if (nearbyPresents.size() == 0) {
            // If no nearby presents, add last one
            nearbyPresents.add(Present.last(Present.class));
        } else {
            for (Present present : nearbyPresents) {
                bounds.include(present.getLatLng());
            }
        }
        safeAnimateCamera(mMap,
                CameraUpdateFactory.newLatLngBounds(bounds.build(), MapHelper.getMapPadding(mSupportMapFragment)));
    }

    private boolean isNearLatLng(LatLng latLng) {
        if (latLng == null) {
            return false;
        }

        return Distance.between(mCurrentLatLng, latLng) <= mConfig.REACHABLE_RADIUS_METERS;
    }

    private void initializeUI() {
        if (mIsInWorkshopMoveMode) {
            findViewById(R.id.fab_location).setVisibility(View.GONE);
            findViewById(R.id.fab_accept_workshop_move).setVisibility(View.VISIBLE);
            findViewById(R.id.fab_cancel_workshop_move).setVisibility(View.VISIBLE);
        } else {
            findViewById(R.id.fab_location).setVisibility(View.VISIBLE);
            findViewById(R.id.fab_accept_workshop_move).setVisibility(View.GONE);
            findViewById(R.id.fab_cancel_workshop_move).setVisibility(View.GONE);
        }
    }

    private void initializeDebugUI() {
        if (!isDebug()) {
            return;
        }

        FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab_debug);
        fab.setVisibility(View.VISIBLE);

        final PopupMenu popup = new PopupMenu(this, fab);
        MenuInflater inflater = popup.getMenuInflater();
        inflater.inflate(R.menu.menu_debug_popup, popup.getMenu());

        // Show the menu when clicked
        fab.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                popup.show();
            }
        });

        // Handle debug menu clicks
        popup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
            @Override
            public boolean onMenuItemClick(MenuItem item) {
                final int id = item.getItemId();
                if (id == R.id.item_drop_present) {
                    // This should be about 20m away, so within the action radius
                    LatLng fuzzyCurrent = FuzzyLocationUtil.fuzz(mCurrentLatLng);
                    addPresent(fuzzyCurrent, true);
                }

                if (id == R.id.item_delete_present) {
                    if (!mPresents.isEmpty()) {
                        deletePresent(mPresents.get(0));
                        drawMarkers();
                        drawMarkerLabels();
                    }
                }

                if (id == R.id.item_play_jetpack) {
                    showPlayJetpackDialog(null);
                }

                if (id == R.id.item_play_present_toss) {
                    showPlayWorkshopDialog();
                }

                if (id == R.id.item_collect_20) {
                    onPresentsCollected(20);
                }

                if (id == R.id.item_return) {
                    onPresentsReturned(3);
                }

                if (id == R.id.item_downlevel) {
                    mUser.downlevel();
                    setUserProgress();
                }

                if (id == R.id.reset_prefs) {
                    // Reset prefs
                    mPreferences.resetAll();

                    // Reset user
                    mUser.presentsCollected = 0;
                    mUser.presentsReturned = 0;
                    mUser.save();

                    // Delete all workshops
                    Workshop.deleteAll(Workshop.class);

                    // Delete all places and presents
                    Present.deleteAll(Present.class);
                    Place.deleteAll(Place.class);

                    // Restart the activity
                    recreate();
                }

                return true;
            }
        });
    }

    private void drawMarkers() {
        // Workshop markers
        for (Workshop workshop : mWorkshops) {
            boolean isNear = isNearLatLng(workshop.getLatLng());
            Marker workshopMarker = mWorkshopMarkers.get(workshop.getId());

            // Get workshop marker options
            MarkerOptions options = mMarkerCache.getWorkshopMarker(isNear).position(workshop.getLatLng())
                    .visible(false);

            // Add or update marker
            if (workshopMarker == null) {
                workshopMarker = mMap.addMarker(options);
            } else {
                MarkerCache.updateMarker(workshopMarker, options);
            }

            // Tag marker with workshop
            workshopMarker.setTag(workshop);

            // Hide workshop markers for moving workshop
            workshopMarker.setVisible(getMovingWorkshopId() != workshop.getId());

            // Cache
            mWorkshopMarkers.put(workshop.getId(), workshopMarker);
        }

        // Present markers, only drawn when not in workshop moving mode
        if (!mIsInWorkshopMoveMode) {
            for (Present present : mPresents) {

                boolean isNear = isNearLatLng(present.getLatLng());
                Marker presentMarker = mPresentMarkers.get(present.getId());

                // Get present marker options
                MarkerOptions options = mMarkerCache.getPresentMarker(present, isNear)
                        .position(present.getLatLng());

                // Add or update marker
                if (presentMarker == null) {
                    presentMarker = mMap.addMarker(options);
                } else {
                    MarkerCache.updateMarker(presentMarker, options);
                }

                // Tag marker with present
                presentMarker.setTag(present);

                // Cache
                mPresentMarkers.put(present.getId(), presentMarker);
            }
        }
    }

    /**
     * Draws a single Workshop marker, always in non-pin mode.
     * @param workshopId the ID of the workshop to draw.
     */
    private void drawMarkerForWorkshop(long workshopId) {
        Workshop workshop = Workshop.findById(Workshop.class, workshopId);

        Marker workshopMarker = mMap
                .addMarker(mMarkerCache.getWorkshopMarker(true).position(workshop.getLatLng()).visible(false));

        workshopMarker.setTag(workshop);
        workshopMarker.setVisible(getMovingWorkshopId() != workshop.getId());

        // Cache
        mWorkshopMarkers.put(workshopId, workshopMarker);
    }

    /**
     * Draw helpful labels on top of map markers to nudge the player in the right direction.
     */
    private void drawMarkerLabels() {
        // Show marker label if the user has never collected a present
        if (!mPreferences.getHasCollectedPresent()) {
            // Get first present marker
            long presentId = mPresents.isEmpty() ? -1 : mPresents.get(0).getId();
            Marker presentMarker = mPresentMarkers.get(presentId);

            // Show marker label
            if (presentMarker != null) {
                presentMarker.setTitle(getString(R.string.go_here));
                presentMarker.showInfoWindow();
            }
        } else {
            // If the user has collected a present, hide all marker labels on presents
            for (Marker presentMarker : mPresentMarkers.values()) {
                presentMarker.setTitle(null);
                presentMarker.hideInfoWindow();
            }
        }

        // Show marker label if the user has collected presents but never returned
        if (mUser.getBagFillPercentage() > 0 && !mPreferences.getHasReturnedPresent()) {
            // Get workshop marker
            long workshopId = mWorkshops.isEmpty() ? -1 : mWorkshops.get(0).getId();
            Marker workshopMarker = mWorkshopMarkers.get(workshopId);

            // Show marker label
            if (workshopMarker != null) {
                workshopMarker.setTitle(getString(R.string.go_here));
                workshopMarker.showInfoWindow();
            }
        } else {
            // Hide the workshop marker labels
            for (Marker workshopMarker : mWorkshopMarkers.values()) {
                workshopMarker.setTitle(null);
                workshopMarker.hideInfoWindow();
            }
        }
    }

    private long getMovingWorkshopId() {
        if (!mIsInWorkshopMoveMode) {
            return 0;
        } else {
            return getIntent().getLongExtra(EXTRA_MOVE_WORKSHOP_ID, 1L);
        }
    }

    private void toast(String message) {
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
    }

    /**
     * Move the camera.  If the map is not yet loaded, catch the exception and try the move again
     * after it loads. This helps to avoid a rare race condition on very low-spec devices.
     */
    private void safeMoveCamera(final GoogleMap map, final CameraUpdate cameraUpdate) {
        try {
            map.moveCamera(cameraUpdate);
        } catch (IllegalStateException e) {
            map.setOnMapLoadedCallback(new GoogleMap.OnMapLoadedCallback() {
                @Override
                public void onMapLoaded() {
                    map.moveCamera(cameraUpdate);
                }
            });
        }
    }

    /**
     * Animate the camera.  If the map is not yet loaded, catch the exception and try the animation
     * again after it loads. This helps to avoid a rare race condition on very low-spec devices.
     */
    private void safeAnimateCamera(final GoogleMap map, final CameraUpdate cameraUpdate) {
        try {
            map.animateCamera(cameraUpdate);
        } catch (IllegalStateException e) {
            map.setOnMapLoadedCallback(new GoogleMap.OnMapLoadedCallback() {
                @Override
                public void onMapLoaded() {
                    map.animateCamera(cameraUpdate);
                }
            });
        }
    }

    @Override
    public boolean onMarkerClick(Marker marker) {
        Object object = marker.getTag();
        if (object instanceof Workshop) {
            return onWorkshopMarkerClick((Workshop) object, marker);
        } else if (object instanceof Present) {
            return onPresentMarkerClick((Present) object, marker);
        } else {
            return false;
        }
    }

    private boolean onWorkshopMarkerClick(Workshop workshop, Marker marker) {
        if (isDebug() && !workshop.isMovable()) {
            workshop.updated = 0;
            workshop.save();
            toast("DEBUG: Workshop now movable");
        }

        if (!isNearLatLng(marker.getPosition())) {
            showSnackbar(Messages.WORKSHOP_TOO_FAR);
            VibrationUtil.vibratePattern(this, VibrationUtil.PATTERN_BAD);
        } else if (mUser.presentsCollected == 0) {
            showSnackbar(Messages.NO_PRESENTS_COLLECTED);
            VibrationUtil.vibratePattern(this, VibrationUtil.PATTERN_BAD);
        } else {
            showPlayWorkshopDialog();
        }

        return true;
    }

    private void startWorkshopMove(long workshopId) {
        if (mMovingWorkshopMarker != null) {
            return;
        }

        Workshop workshop = Workshop.findById(Workshop.class, workshopId);
        Marker workshopMarker = mWorkshopMarkers.get(workshopId);

        // Go to workshop location, or near the last workshops if this is a new workshop
        LatLng target = workshop.getLatLng();
        if (Workshop.NULL_LATLNG.equals(target)) {
            Workshop firstWorkshop = Workshop.first(Workshop.class);
            LatLng firstLatLng = firstWorkshop.getLatLng();
            target = FuzzyLocationUtil.fuzz(firstLatLng);
        }

        safeAnimateCamera(mMap, CameraUpdateFactory.newLatLngZoom(target, FOLLOWING_ZOOM));
        mWorkshopView.setVisibility(View.VISIBLE);
        mMovingWorkshopMarker = workshopMarker;
        mMovingWorkshopMarker.setVisible(false);
    }

    private boolean onPresentMarkerClick(Present present, Marker marker) {
        if (isNearLatLng(present.getLatLng())) {
            if (mUser.getBagFillPercentage() >= 100) {
                // Bag is full, warn the user but allow them to get more presents
                showSnackbar(Messages.BAG_IS_FULL);
                VibrationUtil.vibratePattern(this, VibrationUtil.PATTERN_BAD);
            }

            // Near the present, play the jetpack game
            showPlayJetpackDialog(present);

            // TODO: Only delete if they play the game
            deletePresent(present);
        } else {
            // Too far from the presents
            showSnackbar(Messages.PRESENT_TOO_FAR);
            VibrationUtil.vibratePattern(this, VibrationUtil.PATTERN_BAD);
        }

        return true;
    }

    private void showPlayWorkshopDialog() {
        mWorkshopDialog.show(getSupportFragmentManager(), PlayWorkshopDialog.TAG);
    }

    private void showPlayJetpackDialog(Present present) {
        mJetpackDialog.show(getSupportFragmentManager(), PlayJetpackDialog.TAG);
        if (present != null) {
            mJetpackDialog.setPresent(present);
        }
    }

    private void showScoreText(final int score, long delayMs) {
        mScoreTextView.postDelayed(new Runnable() {
            @Override
            public void run() {
                mScoreTextAnimator.start("+" + String.valueOf(score));
            }
        }, delayMs);
    }

    private void showSnackbar(Messages.Message message) {
        // Don't show messages if already showing the snackbar
        if (mSnackbar.getVisibility() == View.VISIBLE) {
            return;
        }

        // Record how many times a message is displayed, and don't display it too many times.
        int numTimesDisplayed = mPreferences.getMessageTimesDisplayed(message);
        if (numTimesDisplayed >= message.timesToShow) {
            Log.d(TAG, "showSnackbar: not showing " + message.key);
            return;
        } else {
            mPreferences.incrementMessageTimesDisplayed(message);
        }

        // Hide location button, it can get in the way
        findViewById(R.id.fab_location).setVisibility(View.GONE);

        // Set text and slide up
        mSnackbarText.setText(getString(message.stringId));
        SlideAnimator.slideUp(mSnackbar);

        // Slide back down after some delay
        mSnackbar.postDelayed(new Runnable() {
            @Override
            public void run() {
                hideSnackbar();
            }
        }, 3000);
    }

    private void hideSnackbar() {
        // Don't play the hide animation if the snackbar is already invisible
        if (mSnackbar.getVisibility() != View.VISIBLE) {
            return;
        }

        // Show location button
        findViewById(R.id.fab_location).setVisibility(View.VISIBLE);

        // Hide snackbar
        SlideAnimator.slideDown(mSnackbar);
    }

    private void onCancelWorkshopMove() {
        Workshop workshop = (Workshop) mMovingWorkshopMarker.getTag();
        if (Workshop.NULL_LATLNG.equals(workshop.getLatLng())) {
            workshop.delete();
        }

        setResult(RESULT_CANCELED);
        finish();
    }

    private void endWorkshopMove() {
        // Workshop view was clicked - workshop placement mode exited, so save the new
        // workshop location, hide the workshop view, and redraw markers (to reflect the
        // new workshop location).
        LatLng latLng = mMap.getCameraPosition().target;
        Workshop workshop = (Workshop) mMovingWorkshopMarker.getTag();
        workshop.setLatLng(latLng);
        workshop.saveWithTimestamp();

        mWorkshopView.setVisibility(View.INVISIBLE);
        mMovingWorkshopMarker = null;
    }

    @Override
    public boolean onMyLocationButtonClick() {
        if (!mFollowing && mCurrentLatLng != null) {
            safeAnimateCamera(mMap, CameraUpdateFactory.newLatLngZoom(mCurrentLatLng, FOLLOWING_ZOOM));
            mFollowing = true;
        }
        return true;
    }

    @Override
    public void onCameraMove() {
        mFollowing = false;
    }

    private void updateMetersWalked(LatLng oldLocation, LatLng newLocation) {
        if (oldLocation == null || newLocation == null) {
            return;
        }

        int distance = Distance.between(oldLocation, newLocation);
        mMetersWalked += distance;

        // Record distances in increments of 100 meters walked
        while (mMetersWalked >= 100) {
            MeasurementManager.recordHundredMetersWalked(mAnalytics);
            mMetersWalked -= 100;
        }
    }

    private List<Present> getPresentsSorted() {
        List<Present> presents = Present.listAll(Present.class);
        Collections.sort(presents, new PresentComparator());
        return presents;
    }

    private List<Present> getPresentsNearby() {
        return getPresentsInRadius(mConfig.NEARBY_RADIUS_METERS);
    }

    private List<Present> getPresentsReachable() {
        return getPresentsInRadius(mConfig.REACHABLE_RADIUS_METERS);
    }

    private List<Present> getPresentsInRadius(int radius) {
        if (mCurrentLatLng == null) {
            return new ArrayList<>();
        }

        List<Present> presents = new ArrayList<>();
        for (Present present : mPresents) {
            if (Distance.between(mCurrentLatLng, present.getLatLng()) <= radius) {
                presents.add(present);
            }
        }
        return presents;
    }

    private List<Workshop> getWorkshopsReachable() {
        if (mCurrentLatLng == null) {
            return new ArrayList<>();
        }

        List<Workshop> workshops = new ArrayList<>();
        for (Workshop workshop : mWorkshops) {
            if (Distance.between(mCurrentLatLng, workshop.getLatLng()) <= mConfig.REACHABLE_RADIUS_METERS) {
                workshops.add(workshop);
            }
        }
        return workshops;
    }

    @Override
    public void onClick(View v) {
        int id = v.getId();
        if (id == R.id.map_user_image || id == R.id.blue_bar) {
            mAvatarAnimator.stop();
            mPreferences.setHasVisitedProfile(true);
            launchProfileActivity();
        } else if (id == R.id.workshop) {
            // No-op
        } else if (id == R.id.fab_location) {
            onMyLocationButtonClick();
        } else if (id == R.id.fab_accept_workshop_move) {
            endWorkshopMove();
            setResult(RESULT_OK);
            finish();
        } else if (id == R.id.fab_cancel_workshop_move) {
            onCancelWorkshopMove();
        } else if (id == R.id.close_snackbar) {
            hideSnackbar();
        } else if (id == R.id.offline_map_scrim) {
            initializeMap();
        }
    }

    @Override
    public void onConnected(@Nullable Bundle bundle) {
        requestLocationUpdates();
    }

    @Override
    public void onConnectionSuspended(int i) {
    }

    @Override
    public void onConnectionFailed(@NonNull ConnectionResult connectionResult) {
        Log.w(TAG, "onConnectionFailed:" + connectionResult);
    }

    @Override
    public void onDialogShow() {
        if (mActionHorizonAnimator != null) {
            mActionHorizonAnimator.stop();
        }
    }

    @Override
    public void onDialogDismiss() {
        if (mActionHorizonAnimator != null) {
            mActionHorizonAnimator.start();
        }
    }

    @Override
    public void onPermissionsGranted(int requestCode, List<String> perms) {
        Log.d(TAG, "onPermissionsGranted:" + perms);
        startLocationTracking();
    }

    @Override
    public void onPermissionsDenied(int requestCode, List<String> perms) {
        Log.d(TAG, "onPermissionsDenied:" + perms);

        if (!EasyPermissions.somePermissionPermanentlyDenied(this, perms)) {
            // If they deny it but non-permanently, we have to ask again
            startLocationTracking();
        } else {
            // This game will not work without location permissions, so we need to detect
            // if permissions are permanently denied and send the user to the settings screen
            new AppSettingsDialog.Builder(this, getString(R.string.perm_go_to_settings))
                    .setTitle(getString(R.string.perm_required)).setPositiveButton(getString(android.R.string.ok))
                    .setNegativeButton(getString(android.R.string.cancel), new DialogInterface.OnClickListener() {
                        @Override
                        public void onClick(DialogInterface dialog, int which) {
                            onCompletePermissionDenial();
                        }
                    }).setRequestCode(RC_SETTINGS).build().show();
        }
    }

    private void onCompletePermissionDenial() {
        // Show Toast message
        Toast.makeText(this, getString(R.string.required_permissions_missing), Toast.LENGTH_SHORT).show();

        // Finish the Activity
        finish();
    }

    private class PresentComparator implements Comparator<Present> {

        @Override
        public int compare(Present a, Present b) {
            int distA = Distance.between(mCurrentLatLng, a.getLatLng());
            int distB = Distance.between(mCurrentLatLng, b.getLatLng());
            return distA - distB;
        }
    }

    private void deletePresent(Present present) {
        Marker marker = mPresentMarkers.remove(present.getId());
        if (marker != null) {
            marker.remove();
        }

        present.delete();
        mPresents = getPresentsSorted(); // Reload.
    }

    @Override
    public void onMapLongClick(LatLng location) {
        if (isDebug()) {
            // Add a present where the click was
            addPresent(location, true);
        }
    }

    private boolean isDebug() {
        return getPackageName().contains("debug");
    }

}