/* * Copyright (C) 2010-2016 Stichting Akvo (Akvo Foundation) * * This file is part of Akvo Flow. * * Akvo Flow is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Akvo Flow is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Akvo Flow. If not, see <http://www.gnu.org/licenses/>. */ package org.akvo.flow.ui.fragment; import android.app.Activity; import android.content.Context; import android.database.Cursor; import android.location.Criteria; import android.location.Location; import android.location.LocationManager; import android.os.Bundle; import android.support.annotation.Nullable; import android.support.v4.app.LoaderManager.LoaderCallbacks; import android.support.v4.content.Loader; import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.GoogleMap.OnInfoWindowClickListener; import com.google.android.gms.maps.OnMapReadyCallback; import com.google.android.gms.maps.SupportMapFragment; 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.maps.android.clustering.Cluster; import com.google.maps.android.clustering.ClusterManager; import com.google.maps.android.clustering.view.DefaultClusterRenderer; import org.akvo.flow.R; import org.akvo.flow.activity.RecordActivity; import org.akvo.flow.activity.SurveyActivity; import org.akvo.flow.data.loader.SurveyedLocaleLoader; import org.akvo.flow.data.database.SurveyDbAdapter; import org.akvo.flow.domain.SurveyGroup; import org.akvo.flow.domain.SurveyedLocale; import org.akvo.flow.util.ConstantUtil; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.List; import timber.log.Timber; //TODO: separate single data point and multiple into different classes for clarity public class MapFragment extends SupportMapFragment implements LoaderCallbacks<Cursor>, OnInfoWindowClickListener, OnMapReadyCallback { public static final int MAP_ZOOM_LEVEL = 10; private SurveyGroup mSurveyGroup; private SurveyDbAdapter mDatabase; private RecordListListener mListener; private String mRecordId; // If set, load a single record private List<SurveyedLocale> mItems; private boolean mSingleRecord = false; @Nullable private GoogleMap mMap; private ClusterManager<SurveyedLocale> mClusterManager; public static MapFragment newInstance(SurveyGroup surveyGroup, String dataPointId) { MapFragment fragment = new MapFragment(); Bundle args = new Bundle(); args.putSerializable(SurveyActivity.EXTRA_SURVEY_GROUP, surveyGroup); args.putString(RecordActivity.EXTRA_RECORD_ID, dataPointId); fragment.setArguments(args); return fragment; } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); mItems = new ArrayList<>(); mSurveyGroup = (SurveyGroup) getArguments() .getSerializable(SurveyActivity.EXTRA_SURVEY_GROUP); mRecordId = getArguments().getString(RecordActivity.EXTRA_RECORD_ID); mSingleRecord = !TextUtils.isEmpty(mRecordId);// Single datapoint mode? } @Override public void onAttach(Activity activity) { super.onAttach(activity); // This makes sure that the container activity has implemented // the callback interface. If not, it throws an exception try { mListener = (RecordListListener) activity; } catch (ClassCastException e) { throw new ClassCastException( activity.toString() + " must implement SurveyedLocalesFragmentListener"); } } @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); mDatabase = new SurveyDbAdapter(getActivity()); getMapAsync(this); } @Override public void onMapReady(GoogleMap googleMap) { mMap = googleMap; configMap(); refresh(); } private void configMap() { if (mMap != null) { mMap.setMyLocationEnabled(true); mMap.setOnInfoWindowClickListener(this); mClusterManager = new ClusterManager<>(getActivity(), mMap); mClusterManager.setRenderer(new PointRenderer(mMap, getActivity(), mClusterManager)); mMap.setOnMarkerClickListener(mClusterManager); mMap.setOnCameraChangeListener(new GoogleMap.OnCameraChangeListener() { @Override public void onCameraChange(CameraPosition cameraPosition) { cluster(); } }); centerMap(null); } } private void cluster() { if (mMap == null) { return; } final LatLngBounds bounds = mMap.getProjection().getVisibleRegion().latLngBounds; LatLng ne = bounds.northeast, sw = bounds.southwest; double latDst = Math.abs(ne.latitude - sw.latitude); double lonDst = Math.abs(ne.longitude - sw.longitude); final double scale = 1d; LatLngBounds newBounds = bounds.including( new LatLng(ne.latitude + latDst / scale, ne.longitude + lonDst / scale)) .including(new LatLng(sw.latitude - latDst / scale, ne.longitude + lonDst / scale)) .including(new LatLng(sw.latitude - latDst / scale, sw.longitude - lonDst / scale)) .including(new LatLng(ne.latitude + latDst / scale, sw.longitude - lonDst / scale)); mClusterManager.clearItems(); for (SurveyedLocale item : mItems) { if (item.getPosition() != null && newBounds.contains(item.getPosition())) { mClusterManager.addItem(item); } } mClusterManager.cluster(); } /** * Center the map in the given record's coordinates. If no record is provided, * the user's location will be used. */ private void centerMap(@Nullable SurveyedLocale record) { if (mMap == null) { return; // Not ready yet } LatLng position = null; if (record != null && record.getLatitude() != null && record.getLongitude() != null) { // Center the map in the data point position = new LatLng(record.getLatitude(), record.getLongitude()); } else { // When multiple points are shown, center the map in user's location LocationManager manager = (LocationManager) getActivity() .getSystemService(Context.LOCATION_SERVICE); Criteria criteria = new Criteria(); criteria.setAccuracy(Criteria.ACCURACY_FINE); String provider = manager.getBestProvider(criteria, true); if (provider != null) { Location location = manager.getLastKnownLocation(provider); if (location != null) { position = new LatLng(location.getLatitude(), location.getLongitude()); } } } if (position != null) { mMap.animateCamera(CameraUpdateFactory.newLatLngZoom(position, MAP_ZOOM_LEVEL)); } } @Override public void onResume() { super.onResume(); mDatabase.open(); if (mItems.isEmpty()) { // Make sure we only fetch the data and center the map once refresh(); } } @Override public void onPause() { super.onPause(); mDatabase.close(); } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View mapView = super.onCreateView(inflater, container, savedInstanceState); View v = inflater.inflate(R.layout.map_fragment, container, false); FrameLayout layout = (FrameLayout) v.findViewById(R.id.map_container); layout.addView(mapView, 0); return v; } public void refresh(SurveyGroup surveyGroup) { mSurveyGroup = surveyGroup; refresh(); } /** * Ideally, we should build a ContentProvider, so this notifications are handled * automatically, and the loaders restarted without this explicit dependency. */ public void refresh() { if (isResumed()) { if (mSingleRecord) { // Just get it from the DB updateSingleRecord(); } else { getLoaderManager().restartLoader(0, null, this); } } } private void updateSingleRecord() { SurveyedLocale record = mDatabase.getSurveyedLocale(mRecordId); if (mMap != null && record != null && record.getLatitude() != null && record.getLongitude() != null) { mMap.clear(); mMap.addMarker(new MarkerOptions() .position(new LatLng(record.getLatitude(), record.getLongitude())) .title(record.getDisplayName(getActivity())) .snippet(record.getId())); centerMap(record); } } @Override public void onInfoWindowClick(Marker marker) { if (mSingleRecord) { return; // Do nothing. We are already inside the record Activity } final String surveyedLocaleId = marker.getSnippet(); mListener.onRecordSelected(surveyedLocaleId); } // ==================================== // // ========= Loader Callbacks ========= // // ==================================== // @Override public Loader<Cursor> onCreateLoader(int id, Bundle args) { long surveyId = mSurveyGroup != null ? mSurveyGroup.getId() : SurveyGroup.ID_NONE; return new SurveyedLocaleLoader(getActivity(), mDatabase, surveyId, ConstantUtil.ORDER_BY_NONE); } @Override public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) { if (cursor == null) { Timber.w("onFinished() - Loader returned no data"); return; } if (cursor.moveToFirst()) { mItems.clear(); do { SurveyedLocale item = SurveyDbAdapter.getSurveyedLocale(cursor); mItems.add(item); } while (cursor.moveToNext()); } cursor.close(); cluster(); } @Override public void onLoaderReset(Loader<Cursor> loader) { } /** * This custom renderer overrides original 'bucketed' names, in order to display the accurate * number of markers within a cluster. */ private static class PointRenderer extends DefaultClusterRenderer<SurveyedLocale> { private final WeakReference<Context> activityContextWeakRef; public PointRenderer(GoogleMap map, Context context, ClusterManager<SurveyedLocale> clusterManager) { super(context, map, clusterManager); this.activityContextWeakRef = new WeakReference<>(context); } @Override protected void onBeforeClusterItemRendered(SurveyedLocale item, MarkerOptions markerOptions) { Context context = activityContextWeakRef.get(); if (context != null) { markerOptions.title(item.getDisplayName(context)).snippet(item.getId()); } } @Override protected int getBucket(Cluster<SurveyedLocale> cluster) { return cluster.getSize(); } @Override protected String getClusterText(int bucket) { return String.valueOf(bucket); } } }