diff --git a/app/build.gradle b/app/build.gradle index 271ba45c..bf1b748f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,10 +16,9 @@ repositories { //} ext { - androidMapsUtilsVersion = '0.3.4' assertjVersion = '3.9.0' butterknifeVersion = '8.8.1' - constraintLayoutVersion = '1.1.0' + constraintLayoutVersion = '1.1.2' daggerVersion = '2.13' gsonVersion = '2.8.1' guavaVersion = '20.0' @@ -27,11 +26,13 @@ ext { junitVersion = '4.12' materialDialogsVersion = '0.9.6.0' // Depends on the android support lib. Their version must match ours. multidexVersion = '1.0.3' + osmbonuspackVersion = '6.5.2' + osmdroidVersion = '6.0.2' picassoVersion = '2.5.2' playServicesVersion = '11.6.2' retrofitVersion = '2.3.0' robolectricVersion = '3.7' - rxAndroidVersion = '2.0.1' + rxAndroidVersion = '2.0.2' rxJavaVersion = '2.1.7' supportLibVersion = '27.0.1' } @@ -49,15 +50,13 @@ dependencies { implementation "com.android.support:multidex:$multidexVersion" implementation "com.android.support:preference-v14:$supportLibVersion" implementation "com.android.support:support-v4:$supportLibVersion" + implementation "com.github.MKergall:osmbonuspack:$osmbonuspackVersion" implementation "com.google.android.gms:play-services-analytics:$playServicesVersion" - implementation "com.google.android.gms:play-services-location:$playServicesVersion" - implementation "com.google.android.gms:play-services-maps:$playServicesVersion" implementation "com.google.code.gson:gson:$gsonVersion" implementation "com.google.dagger:dagger:$daggerVersion" implementation "com.google.dagger:dagger-android:$daggerVersion" implementation "com.google.dagger:dagger-android-support:$daggerVersion" implementation "com.google.guava:guava:$guavaVersion" - implementation "com.google.maps.android:android-maps-utils:$androidMapsUtilsVersion" implementation "com.jakewharton:butterknife:$butterknifeVersion" implementation "com.squareup.picasso:picasso:$picassoVersion" implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofitVersion" @@ -65,13 +64,14 @@ dependencies { implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" implementation "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion" implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion" + implementation "org.osmdroid:osmdroid-android:$osmdroidVersion" + testImplementation "junit:junit:$junitVersion" testImplementation "org.assertj:assertj-core:$assertjVersion" testImplementation "org.json:json:$jsonVersion" testImplementation "org.robolectric:robolectric:$robolectricVersion" testImplementation "org.robolectric:shadows-multidex:$robolectricVersion" testImplementation "org.robolectric:shadows-supportv4:$robolectricVersion" - testImplementation "junit:junit:$junitVersion" } android { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 01eb6fff..af906f65 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,6 +17,12 @@ + + @@ -28,8 +34,6 @@ android:label="@string/app_name" android:theme="@style/wsAppTheme"> - - - diff --git a/app/src/main/java/fi/bitrite/android/ws/api/model/ApiUser.java b/app/src/main/java/fi/bitrite/android/ws/api/model/ApiUser.java index 97e7d2b5..2af882f3 100644 --- a/app/src/main/java/fi/bitrite/android/ws/api/model/ApiUser.java +++ b/app/src/main/java/fi/bitrite/android/ws/api/model/ApiUser.java @@ -1,8 +1,9 @@ package fi.bitrite.android.ws.api.model; -import com.google.android.gms.maps.model.LatLng; import com.google.gson.annotations.SerializedName; +import org.osmdroid.util.GeoPoint; + import java.util.Date; import fi.bitrite.android.ws.model.SimpleUser; @@ -85,7 +86,7 @@ public static class CommentNotifySettings { public User toUser() { return new User(id, name, fullname, street, additionalAddress, city, province, postalCode, - countryCode, new LatLng(latitude, longitude), mobilePhone, homePhone, workPhone, + countryCode, new GeoPoint(latitude, longitude), mobilePhone, homePhone, workPhone, comments, preferredNotice, maximalCyclistCount, distanceToMotel, distanceToCampground, distanceToBikeshop, hasStorage, hasShower, hasKitchen, hasLawnspace, hasSag, hasBed, hasLaundry, hasFood, spokenLanguages, diff --git a/app/src/main/java/fi/bitrite/android/ws/api/response/UserSearchByLocationResponse.java b/app/src/main/java/fi/bitrite/android/ws/api/response/UserSearchByLocationResponse.java index 0cd7da66..df1bf896 100644 --- a/app/src/main/java/fi/bitrite/android/ws/api/response/UserSearchByLocationResponse.java +++ b/app/src/main/java/fi/bitrite/android/ws/api/response/UserSearchByLocationResponse.java @@ -1,8 +1,9 @@ package fi.bitrite.android.ws.api.response; -import com.google.android.gms.maps.model.LatLng; import com.google.gson.annotations.SerializedName; +import org.osmdroid.util.GeoPoint; + import java.util.Date; import java.util.List; @@ -59,7 +60,7 @@ public static class User { public SimpleUser toSimpleUser() { return new SimpleUser(id, name, fullname, street, city, province, postalCode, - countryCode, new LatLng(latitude, longitude), !notCurrentlyAvailable, + countryCode, new GeoPoint(latitude, longitude), !notCurrentlyAvailable, new SimpleUser.Picture(profilePictureUrl_179x200, profilePictureUrl_400x400), created, lastAccess); } diff --git a/app/src/main/java/fi/bitrite/android/ws/model/SimpleUser.java b/app/src/main/java/fi/bitrite/android/ws/model/SimpleUser.java index 30ff9dee..68e8ba2d 100644 --- a/app/src/main/java/fi/bitrite/android/ws/model/SimpleUser.java +++ b/app/src/main/java/fi/bitrite/android/ws/model/SimpleUser.java @@ -2,7 +2,7 @@ import android.text.TextUtils; -import com.google.android.gms.maps.model.LatLng; +import org.osmdroid.api.IGeoPoint; import java.util.Date; @@ -34,7 +34,7 @@ public String getLargeUrl() { public final String province; public final String postalCode; public final String countryCode; - public final LatLng location; + public final IGeoPoint location; public final boolean isCurrentlyAvailable; public final Picture profilePicture; @@ -43,7 +43,7 @@ public String getLargeUrl() { public final Date lastAccess; public SimpleUser(int id, String name, String fullname, String street, String city, - String province, String postalCode, String countryCode, LatLng location, + String province, String postalCode, String countryCode, IGeoPoint location, boolean isCurrentlyAvailable, Picture profilePicture, Date created, Date lastAccess) { this.id = id; diff --git a/app/src/main/java/fi/bitrite/android/ws/model/User.java b/app/src/main/java/fi/bitrite/android/ws/model/User.java index 75a9afb7..1bf76719 100644 --- a/app/src/main/java/fi/bitrite/android/ws/model/User.java +++ b/app/src/main/java/fi/bitrite/android/ws/model/User.java @@ -4,7 +4,7 @@ import android.content.res.Resources; import android.text.TextUtils; -import com.google.android.gms.maps.model.LatLng; +import org.osmdroid.api.IGeoPoint; import java.util.Date; @@ -37,7 +37,7 @@ public class User extends SimpleUser { public User(int id, String name, String fullname, String street, String additionalAddress, String city, String province, String postalCode, String countryCode, - LatLng location, String mobilePhone, String homePhone, String workPhone, + IGeoPoint location, String mobilePhone, String homePhone, String workPhone, String comments, String preferredNotice, int maximalCyclistCount, String distanceToMotel, String distanceToCampground, String distanceToBikeshop, boolean hasStorage, boolean hasShower, boolean hasKitchen, boolean hasLawnspace, diff --git a/app/src/main/java/fi/bitrite/android/ws/model/ZoomedLocation.java b/app/src/main/java/fi/bitrite/android/ws/model/ZoomedLocation.java new file mode 100644 index 00000000..c6ef480e --- /dev/null +++ b/app/src/main/java/fi/bitrite/android/ws/model/ZoomedLocation.java @@ -0,0 +1,17 @@ +package fi.bitrite.android.ws.model; + +import org.osmdroid.api.IGeoPoint; +import org.osmdroid.util.GeoPoint; + +public class ZoomedLocation { + public final IGeoPoint location; + public final double zoom; + + public ZoomedLocation(double lat, double lon, double zoom) { + this(new GeoPoint(lat, lon), zoom); + } + public ZoomedLocation(IGeoPoint location, double zoom) { + this.location = location; + this.zoom = zoom; + } +} diff --git a/app/src/main/java/fi/bitrite/android/ws/persistence/UserDao.java b/app/src/main/java/fi/bitrite/android/ws/persistence/UserDao.java index f3273544..cb7c471d 100644 --- a/app/src/main/java/fi/bitrite/android/ws/persistence/UserDao.java +++ b/app/src/main/java/fi/bitrite/android/ws/persistence/UserDao.java @@ -5,7 +5,7 @@ import android.database.sqlite.SQLiteDatabase; import android.support.annotation.NonNull; -import com.google.android.gms.maps.model.LatLng; +import org.osmdroid.util.GeoPoint; import java.util.ArrayList; import java.util.List; @@ -144,8 +144,8 @@ public void save(SQLiteDatabase db, User user) { cv.put("created", DateConverter.dateToLong(user.created)); cv.put("currently_available", user.isCurrentlyAvailable); cv.put("spoken_languages", user.spokenLanguages); - cv.put("latitude", user.location.latitude); - cv.put("longitude", user.location.longitude); + cv.put("latitude", user.location.getLatitude()); + cv.put("longitude", user.location.getLongitude()); cv.put("profile_picture_small", user.profilePicture.getSmallUrl()); cv.put("profile_picture_large", user.profilePicture.getLargeUrl()); @@ -170,7 +170,7 @@ private static User getUserFromCursor(@NonNull Cursor c) { c.getString(COL_IDX_PROVINCE), c.getString(COL_IDX_POSTAL_CODE), c.getString(COL_IDX_COUNTRY_CODE), - new LatLng(c.getDouble(COL_IDX_LATITUDE), c.getDouble(COL_IDX_LONGITUDE)), + new GeoPoint(c.getDouble(COL_IDX_LATITUDE), c.getDouble(COL_IDX_LONGITUDE)), c.getString(COL_IDX_MOBILE_PHONE), c.getString(COL_IDX_HOME_PHONE), c.getString(COL_IDX_WORK_PHONE), diff --git a/app/src/main/java/fi/bitrite/android/ws/repository/SettingsRepository.java b/app/src/main/java/fi/bitrite/android/ws/repository/SettingsRepository.java index 1e999232..b50ce985 100644 --- a/app/src/main/java/fi/bitrite/android/ws/repository/SettingsRepository.java +++ b/app/src/main/java/fi/bitrite/android/ws/repository/SettingsRepository.java @@ -6,13 +6,13 @@ import android.preference.PreferenceManager; import android.text.TextUtils; -import com.google.android.gms.maps.model.CameraPosition; -import com.google.android.gms.maps.model.LatLng; +import org.osmdroid.tileprovider.tilesource.TileSourceFactory; import javax.inject.Inject; import fi.bitrite.android.ws.R; import fi.bitrite.android.ws.di.AppScope; +import fi.bitrite.android.ws.model.ZoomedLocation; @AppScope public class SettingsRepository { @@ -29,11 +29,13 @@ public enum DistanceUnit { private final static String KEYSUFFIX_LOCATION_ZOOM = "_zoom"; private final String mKeyDistanceUnit; + private final String mKeyTileSource; private final String mKeyMessageRefreshInterval; private final String mKeyGaCollectStats; private final String mKeyDevSimulateNoNetwork; private final String mDefaultDistanceUnit; + private final String mDefaultTileSource = TileSourceFactory.DEFAULT_TILE_SOURCE.name(); private final int mDefaultMessageRefreshInterval; private final boolean mDefaultGaCollectStats; private final boolean mDefaultDevSimulateNoNetwork; @@ -55,6 +57,7 @@ public enum DistanceUnit { final Resources res = context.getResources(); mKeyDistanceUnit = res.getString(R.string.prefs_distance_unit_key); + mKeyTileSource = res.getString(R.string.prefs_tile_source_key); mKeyMessageRefreshInterval = res.getString(R.string.prefs_message_refresh_interval_min_key); mKeyGaCollectStats = res.getString(R.string.prefs_ga_collect_stats_key); mKeyDevSimulateNoNetwork = res.getString(R.string.prefs_dev_simulate_no_network_key); @@ -113,17 +116,21 @@ public String getDistanceUnitShort() { } } - private void setLocation(String key, CameraPosition position) { + public String getTileSourceStr() { + return mSharedPreferences.getString(mKeyTileSource, mDefaultTileSource); + } + + private void setLocation(String key, ZoomedLocation position) { mSharedPreferences .edit() - .putFloat(key + KEYSUFFIX_LOCATION_LATITUDE, (float) position.target.latitude) - .putFloat(key + KEYSUFFIX_LOCATION_LONGITUDE, (float) position.target.longitude) - .putFloat(key + KEYSUFFIX_LOCATION_ZOOM, position.zoom) + .putFloat(key + KEYSUFFIX_LOCATION_LATITUDE, (float) position.location.getLatitude()) + .putFloat(key + KEYSUFFIX_LOCATION_LONGITUDE, (float) position.location.getLongitude()) + .putFloat(key + KEYSUFFIX_LOCATION_ZOOM, (float) position.zoom) .apply(); } - public CameraPosition getLastMapLocation(boolean defaultIfNone) { + public ZoomedLocation getLastMapLocation(boolean defaultIfNone) { if (!mSharedPreferences.contains(KEY_MAP_LAST_LOCATION + KEYSUFFIX_LOCATION_LATITUDE) && !defaultIfNone) { return null; @@ -135,9 +142,9 @@ public CameraPosition getLastMapLocation(boolean defaultIfNone) { float zoom = mSharedPreferences.getFloat( KEY_MAP_LAST_LOCATION + KEYSUFFIX_LOCATION_ZOOM, mDefaultMapLocationZoom); - return new CameraPosition(new LatLng(latitude, longitude), zoom, 0, 0); + return new ZoomedLocation(latitude, longitude, zoom); } - public void setLastMapLocation(CameraPosition position) { + public void setLastMapLocation(ZoomedLocation position) { setLocation(KEY_MAP_LAST_LOCATION, position); } diff --git a/app/src/main/java/fi/bitrite/android/ws/repository/UserRepository.java b/app/src/main/java/fi/bitrite/android/ws/repository/UserRepository.java index 76a02484..7a125cd1 100644 --- a/app/src/main/java/fi/bitrite/android/ws/repository/UserRepository.java +++ b/app/src/main/java/fi/bitrite/android/ws/repository/UserRepository.java @@ -2,7 +2,7 @@ import android.support.annotation.NonNull; -import com.google.android.gms.maps.model.LatLng; +import org.osmdroid.util.BoundingBox; import java.util.ArrayList; import java.util.Collection; @@ -60,9 +60,9 @@ public Observable> searchByKeyword(String keyword) { return getAppUserRepository().searchByKeyword(keyword); } - public Observable> searchByLocation(LatLng northEast, - LatLng southWest) { - return getAppUserRepository().searchByLocation(northEast, southWest); + public Observable> searchByLocation( + BoundingBox boundingBox) { + return getAppUserRepository().searchByLocation(boundingBox); } /** @@ -151,13 +151,13 @@ Observable> searchByKeyword(String keyword) { }); } - Observable> searchByLocation(LatLng northEast, - LatLng southWest) { + Observable> searchByLocation( + BoundingBox boundingBox) { - final double minLat = southWest.latitude; - final double minLon = southWest.longitude; - final double maxLat = northEast.latitude; - final double maxLon = northEast.longitude; + final double minLat = boundingBox.getLatSouth(); + final double minLon = boundingBox.getLonWest(); + final double maxLat = boundingBox.getLatNorth(); + final double maxLon = boundingBox.getLonEast(); final double centerLat = (minLat + maxLat) / 2.0f; final double centerLon = (minLon + maxLon) / 2.0f; diff --git a/app/src/main/java/fi/bitrite/android/ws/ui/BaseFragment.java b/app/src/main/java/fi/bitrite/android/ws/ui/BaseFragment.java index 0a593bd9..3667fec8 100644 --- a/app/src/main/java/fi/bitrite/android/ws/ui/BaseFragment.java +++ b/app/src/main/java/fi/bitrite/android/ws/ui/BaseFragment.java @@ -93,4 +93,4 @@ CompositeDisposable getStartStopDisposable() { CompositeDisposable getResumePauseDisposable() { return mResumePauseDisposable.get(); } -} \ No newline at end of file +} diff --git a/app/src/main/java/fi/bitrite/android/ws/ui/FavoriteUsersFragment.java b/app/src/main/java/fi/bitrite/android/ws/ui/FavoriteUsersFragment.java index 65617a2f..c5b63867 100644 --- a/app/src/main/java/fi/bitrite/android/ws/ui/FavoriteUsersFragment.java +++ b/app/src/main/java/fi/bitrite/android/ws/ui/FavoriteUsersFragment.java @@ -22,6 +22,7 @@ import butterknife.ButterKnife; import butterknife.OnItemClick; import fi.bitrite.android.ws.R; +import fi.bitrite.android.ws.model.SimpleUser; import fi.bitrite.android.ws.model.User; import fi.bitrite.android.ws.repository.FavoriteRepository; import fi.bitrite.android.ws.repository.Resource; @@ -74,7 +75,7 @@ public void onResume() { @OnItemClick(R.id.favorites_lst_users) public void onUserClicked(int position) { - User selectedUser = mUserListAdapter.getUser(position); + SimpleUser selectedUser = mUserListAdapter.getUser(position); getNavigationController().navigateToUser(selectedUser.id); } @@ -82,7 +83,7 @@ public void onUserClicked(int position) { public void onCreateContextMenu(ContextMenu menu, View view, ContextMenuInfo menuInfo) { if (view.getId() == mLstUsers.getId()) { AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuInfo; - User user = mUserListAdapter.getUser(info.position); + SimpleUser user = mUserListAdapter.getUser(info.position); menu.setHeaderTitle(user.fullname); menu.add(Menu.NONE, CONTEXT_MENU_DELETE, 0, R.string.delete); @@ -96,7 +97,7 @@ public boolean onContextItemSelected(MenuItem item) { switch (item.getItemId()) { case CONTEXT_MENU_DELETE: - User user = mUserListAdapter.getUser(info.position); + SimpleUser user = mUserListAdapter.getUser(info.position); mFavoriteRepository.remove(user.id); updateFavoriteUsersList(); return true; diff --git a/app/src/main/java/fi/bitrite/android/ws/ui/MapFragment.java b/app/src/main/java/fi/bitrite/android/ws/ui/MapFragment.java index ee0ce6d2..e6cbe78c 100644 --- a/app/src/main/java/fi/bitrite/android/ws/ui/MapFragment.java +++ b/app/src/main/java/fi/bitrite/android/ws/ui/MapFragment.java @@ -1,24 +1,22 @@ package fi.bitrite.android.ws.ui; import android.Manifest; -import android.annotation.SuppressLint; -import android.app.Dialog; +import android.app.AlertDialog; import android.app.SearchManager; import android.content.Context; -import android.content.DialogInterface; import android.content.SharedPreferences; import android.content.pm.PackageManager; +import android.content.res.ColorStateList; +import android.graphics.drawable.Drawable; import android.location.Location; import android.os.Bundle; +import android.preference.PreferenceManager; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.StringRes; +import android.support.design.widget.FloatingActionButton; import android.support.v4.app.ActivityCompat; -import android.support.v4.app.DialogFragment; -import android.support.v7.app.AlertDialog; import android.support.v7.widget.SearchView; -import android.text.Html; -import android.text.TextUtils; import android.util.Log; import android.util.SparseArray; import android.view.LayoutInflater; @@ -26,69 +24,63 @@ import android.view.MenuInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.LinearLayout; import android.widget.TextView; import android.widget.Toast; -import com.google.android.gms.common.GooglePlayServicesUtil; -import com.google.android.gms.location.FusedLocationProviderClient; -import com.google.android.gms.location.LocationCallback; -import com.google.android.gms.location.LocationRequest; -import com.google.android.gms.location.LocationResult; -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.SupportMapFragment; -import com.google.android.gms.maps.model.BitmapDescriptor; -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 com.google.maps.android.clustering.Cluster; -import com.google.maps.android.clustering.ClusterManager; -import com.google.maps.android.clustering.algo.PreCachingAlgorithmDecorator; -import com.google.maps.android.clustering.view.DefaultClusterRenderer; -import com.google.maps.android.ui.IconGenerator; +import com.google.common.collect.Lists; + +import org.osmdroid.api.IGeoPoint; +import org.osmdroid.api.IMapController; +import org.osmdroid.bonuspack.clustering.StaticCluster; +import org.osmdroid.config.Configuration; +import org.osmdroid.events.MapListener; +import org.osmdroid.events.ScrollEvent; +import org.osmdroid.events.ZoomEvent; +import org.osmdroid.tileprovider.tilesource.TileSourceFactory; +import org.osmdroid.util.BoundingBox; +import org.osmdroid.util.GeoPoint; +import org.osmdroid.views.MapView; +import org.osmdroid.views.overlay.Marker; +import org.osmdroid.views.overlay.mylocation.IMyLocationConsumer; +import org.osmdroid.views.overlay.mylocation.IMyLocationProvider; +import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay; import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; import java.util.List; -import java.util.concurrent.ConcurrentSkipListSet; +import java.util.concurrent.TimeUnit; import javax.inject.Inject; +import butterknife.BindColor; +import butterknife.BindDrawable; import butterknife.BindView; import butterknife.ButterKnife; +import butterknife.OnClick; import butterknife.Unbinder; import fi.bitrite.android.ws.R; import fi.bitrite.android.ws.api.response.UserSearchByLocationResponse; +import fi.bitrite.android.ws.model.SimpleUser; import fi.bitrite.android.ws.model.User; +import fi.bitrite.android.ws.model.ZoomedLocation; import fi.bitrite.android.ws.repository.FavoriteRepository; +import fi.bitrite.android.ws.repository.Resource; import fi.bitrite.android.ws.repository.SettingsRepository; import fi.bitrite.android.ws.repository.UserRepository; -import fi.bitrite.android.ws.ui.model.ClusterUser; +import fi.bitrite.android.ws.ui.listadapter.UserListAdapter; +import fi.bitrite.android.ws.ui.util.NavigationController; +import fi.bitrite.android.ws.ui.util.UserMarker; +import fi.bitrite.android.ws.ui.util.UserMarkerClusterer; +import fi.bitrite.android.ws.util.LocationManager; import fi.bitrite.android.ws.util.LoggedInUserHelper; import fi.bitrite.android.ws.util.Tools; -import fi.bitrite.android.ws.util.WSNonHierarchicalDistanceBasedAlgorithm; +import io.reactivex.Completable; import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; -import io.reactivex.subjects.CompletableSubject; - -public class MapFragment extends BaseFragment implements - ClusterManager.OnClusterClickListener, - ClusterManager.OnClusterInfoWindowClickListener, - ClusterManager.OnClusterItemClickListener, - ClusterManager.OnClusterItemInfoWindowClickListener, - GoogleMap.OnCameraChangeListener { +import io.reactivex.subjects.BehaviorSubject; +public class MapFragment extends BaseFragment { private static final String KEY_MAP_TARGET_LAT_LNG = "map_target_lat_lng"; - private static final int REQUEST_RESOLVE_ERROR = 1001; - private static final String DIALOG_ERROR = "dialog_error"; private static final String TAG = "MapFragment"; @Inject LoggedInUserHelper mLoggedInUserHelper; @@ -96,16 +88,23 @@ public class MapFragment extends BaseFragment implements @Inject FavoriteRepository mFavoriteRepository; @Inject SettingsRepository mSettingsRepository; - private Unbinder mUnbinder; + @BindColor(R.color.primaryColor) int mColorPrimary; + @BindColor(R.color.primaryWhite) int mColorPrimaryWhite; + @BindColor(R.color.primaryButtonDisable) int mColorPrimaryButtonDisable; + @BindDrawable(R.drawable.ic_my_location_white_24dp) Drawable mIcMyLocationWhite; + @BindDrawable(R.drawable.ic_my_location_grey600_24dp) Drawable mIcMyLocationGrey; + @BindView(R.id.map) MapView mMap; + @BindView(R.id.map_btn_goto_current_location) FloatingActionButton mBtnGotoCurrentLocation; + private IMapController mMapController; - private GoogleMap mMap; // Might be null if Google Play services APK is not available. - private ConcurrentSkipListSet mClusteredUsers = new ConcurrentSkipListSet<>(); - private ClusterManager mClusterManager; - private Cluster mLastClickedCluster; + private Unbinder mUnbinder; - private boolean mIsOffline = false; + private SparseArray mClusteredUsers = new SparseArray<>(); + private UserMarkerClusterer mMarkerClusterer; private Disposable mLoadOfflineUserDisposable; + private final List mOfflineUserIds = new ArrayList<>(); + private Toast mLastToast = null; private SettingsRepository.DistanceUnit mDistanceUnit; @@ -123,26 +122,14 @@ public class MapFragment extends BaseFragment implements private static final int POSITION_PRIORITY_LAST_STORED = 2; private static final int POSITION_PRIORITY_FORCED = 100; - private int mLastPositionType = -1; - private CameraPosition mLastCameraPosition = null; - private FusedLocationProviderClient mFusedLocationClient; - private Location mLastDeviceLocation; - private final LocationCallback mLocationCallback = new LocationCallback() { - @Override - public void onLocationResult(LocationResult locationResult) { - if (locationResult == null) { - return; - } + private int mLastPositionType; + private ZoomedLocation mLastPosition; + private boolean mOsmdroidBug_suppressCallbacks; + private final LocationManager mLocationManager = new LocationManager(); - mLastDeviceLocation = locationResult.getLastLocation(); - // FIXME(saemy): Clear cluster info window cache as the distance to this distance is not - // re-calculated. - - // As we know more location details, we do (another) initial map move. This does not - // affect the current location, if we already moved to a more detailed location. - doInitialMapMove(); - } - }; + private boolean mHasEnabledLocationProviders; + private final BehaviorSubject mLastDeviceLocation = BehaviorSubject.create(); + private MyLocationNewOverlay mDeviceLocationOverlay; public static MapFragment create() { MapFragment mapFragment = new MapFragment(); @@ -150,9 +137,10 @@ public static MapFragment create() { mapFragment.setArguments(bundle); return mapFragment; } - public static MapFragment create(LatLng latLng) { + public static MapFragment create(IGeoPoint latLng) { MapFragment mapFragment = create(); - mapFragment.getArguments().putParcelable(KEY_MAP_TARGET_LAT_LNG, latLng); + mapFragment.getArguments().putParcelable(KEY_MAP_TARGET_LAT_LNG, + new GeoPoint(latLng.getLatitude(), latLng.getLongitude())); return mapFragment; } @@ -162,17 +150,85 @@ public void onCreate(Bundle savedInstanceState) { setHasOptionsMenu(true); - mFusedLocationClient = LocationServices.getFusedLocationProviderClient(getContext()); + UserMarkerClusterer.MarkerFactory singleLocationMarkerFactory = + new UserMarkerClusterer.MarkerFactory( + getResources().getDrawable(R.drawable.map_markers_multiple)); + singleLocationMarkerFactory.setTextAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_TOP); + singleLocationMarkerFactory.setTextPadding(0, 15); + + UserMarkerClusterer.MarkerFactory multiLocationMarkerFactory = + new UserMarkerClusterer.MarkerFactory( + getResources().getDrawable(R.drawable.ic_cluster_multi_location_38dp)); + + mMarkerClusterer = new UserMarkerClusterer(getContext()); + mMarkerClusterer.setSingleLocationMarkerFactory(singleLocationMarkerFactory); + mMarkerClusterer.setMultiLocationMarkerFactory(multiLocationMarkerFactory); + mMarkerClusterer.setOnClusterClickListener(this::onClusterClick); + + loadOfflineUsers(); } @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + Context context = getContext(); + Configuration.getInstance().load( + context, PreferenceManager.getDefaultSharedPreferences(context)); + //setting this before the layout is inflated is a good idea + //it 'should' ensure that the map has a writable location for the map cache, even without permissions + //if no tiles are displayed, you can try overriding the cache path using Configuration.getInstance().setCachePath + //see also StorageUtils + //note, the load method also sets the HTTP User Agent to your application's package name, abusing osm's tile servers will get you banned based on this string + View view = inflater.inflate(R.layout.fragment_map, container, false); mUnbinder = ButterKnife.bind(this, view); + mMapController = mMap.getController(); + + mLastPositionType = -1; + mLastPosition = null; + + mMap.setVerticalMapRepetitionEnabled(false); + mMap.setBuiltInZoomControls(false); + mMap.setMultiTouchControls(true); + + String tileSourceStr = mSettingsRepository.getTileSourceStr(); + if (!TileSourceFactory.containsTileSource(tileSourceStr)) { + tileSourceStr = TileSourceFactory.DEFAULT_TILE_SOURCE.name(); + } + mMap.setTileSource(TileSourceFactory.getTileSource(tileSourceStr)); + + if (hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) { + handleAccessFineLocationGranted(); + // The else case is handled in onResume. + } + + mMap.getOverlays().add(mMarkerClusterer); - setUpMapIfNeeded(); + mMap.addOnFirstLayoutListener((v, left, top, right, bottom) -> { + // We add this only here due to issues in osmdroid's setCenter/setZoom when the first + // layout of the map is not yet done. + mMap.addMapListener(new MapListener() { + @Override + public boolean onScroll(ScrollEvent event) { + if (mOsmdroidBug_suppressCallbacks) { + return false; + } + onPositionChange(mMap.getZoomLevelDouble()); + return true; + } + @Override + public boolean onZoom(ZoomEvent event) { + if (mOsmdroidBug_suppressCallbacks) { + return false; + } + onPositionChange(event.getZoomLevel()); + return true; + } + }); + + doInitialMapMove(); + }); return view; } @@ -181,30 +237,52 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c public void onResume() { super.onResume(); + mMap.onResume(); + // Register the settings change listener. That does an initial call to the handler. mSettingsRepository.registerOnChangeListener(mOnSettingsChangeListener); - LocationRequest locationRequest = new LocationRequest() - .setInterval(60000) - .setFastestInterval(60000) - .setPriority(LocationRequest.PRIORITY_LOW_POWER); - mFusedLocationClient.requestLocationUpdates(locationRequest, mLocationCallback, null); + // Adds a button to navigate to the current GPS position. + if (!hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) { + requestPermissions(new String[]{ Manifest.permission.ACCESS_FINE_LOCATION }, 0); + } else { + startLocationManager(); + } + + getResumePauseDisposable().add(mLocationManager.getHasEnabledProviders() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(hasEnabledProviders -> { + mHasEnabledLocationProviders = hasEnabledProviders; + setGotoCurrentLocationStatus(); + })); + getResumePauseDisposable().add(mLocationManager.getBestLocation() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(location -> { + mLastDeviceLocation.onNext(location); + // FIXME(saemy): Clear cluster info window cache as the distance to this distance is not + // re-calculated. + + setGotoCurrentLocationStatus(); - setUpMapIfNeeded(); + // As we know more location details, we do (another) initial map move. This does not + // affect the current location, if we already moved to a more detailed location. + doInitialMapMove(); + })); } @Override public void onPause() { - mFusedLocationClient.removeLocationUpdates(mLocationCallback); + mLocationManager.stop(); mSettingsRepository.unregisterOnChangeListener(mOnSettingsChangeListener); + mMap.onPause(); super.onPause(); } @Override public void onStop() { - if (mLastCameraPosition != null) { - mSettingsRepository.setLastMapLocation(mLastCameraPosition); + if (mLastPosition != null) { + mSettingsRepository.setLastMapLocation(mLastPosition); } super.onStop(); @@ -216,65 +294,6 @@ public void onDestroyView() { mUnbinder.unbind(); } - /** - * Sets up the map if it is possible to do so (i.e., the Google Play services APK is correctly - * installed) and the map has not already been instantiated. This will ensure that we only ever - * call {@link #setUpMap()} once when {@link #mMap} is null. - *

- * If it isn't installed {@link SupportMapFragment} (and - * {@link com.google.android.gms.maps.MapView MapView}) will show a prompt for the user to - * install/update the Google Play services APK on their device. - *

- * A user can return to this FragmentActivity after following the prompt and correctly - * installing/updating/enabling the Google Play services. Since the FragmentActivity may not - * have been completely destroyed during this process (it is likely that it would only be - * stopped or paused), {@link #onCreate(android.os.Bundle)} may not be called again so we should - * call this method in {@link #onResume()} to guarantee that it will be called. - */ - private void setUpMapIfNeeded() { - // Do a null check to confirm that we have not already instantiated the map. - if (mMap == null) { - SupportMapFragment supportMapFragment = - (SupportMapFragment) getChildFragmentManager().findFragmentById(R.id.map); - supportMapFragment.getMapAsync(map -> { - mMap = map; - setUpMap(); - }); - } - } - - private void setUpMap() { - // Rotate gestures probably aren't needed here and can be disorienting for some of our users. - mMap.getUiSettings().setRotateGesturesEnabled(false); - - // Adds a button to navigate to the current GPS position. - if (hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) { - mMap.setMyLocationEnabled(true); // Requires ACCESS_FINE_LOCATION - } else { - requestPermissions(new String[]{ Manifest.permission.ACCESS_FINE_LOCATION }, 0); - } - - mMap.setOnCameraChangeListener(this); - - doInitialMapMove(); - - mClusterManager = new ClusterManager<>(getContext(), mMap); - mClusterManager.setAlgorithm(new PreCachingAlgorithmDecorator<>( - new WSNonHierarchicalDistanceBasedAlgorithm<>(getContext()))); - mMap.setOnMarkerClickListener(mClusterManager); - mMap.setOnInfoWindowClickListener(mClusterManager); - mClusterManager.setOnClusterClickListener(this); - mClusterManager.setOnClusterInfoWindowClickListener(this); - mClusterManager.setOnClusterItemClickListener(this); - mClusterManager.setOnClusterItemInfoWindowClickListener(this); - mClusterManager.setRenderer(new UserRenderer()); - mMap.setInfoWindowAdapter(mClusterManager.getMarkerManager()); - mClusterManager.getClusterMarkerCollection() - .setOnInfoWindowAdapter(new ClusterInfoWindowAdapter(getLayoutInflater())); - mClusterManager.getMarkerCollection() - .setOnInfoWindowAdapter(new SingleUserInfoWindowAdapter(getLayoutInflater())); - } - private boolean hasPermission(String permission) { return PackageManager.PERMISSION_GRANTED == ActivityCompat.checkSelfPermission(getContext(), permission); @@ -284,50 +303,101 @@ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permis @NonNull int[] grantResults) { if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { // ACCESS_FINE_LOCATION is granted. - if (mMap != null) { - mMap.setMyLocationEnabled(true); // Requires ACCESS_FINE_LOCATION - } + handleAccessFineLocationGranted(); + } + } + private void handleAccessFineLocationGranted() { + mDeviceLocationOverlay = new MyLocationNewOverlay(mLocationProvider, mMap); + mDeviceLocationOverlay.enableMyLocation(); + mDeviceLocationOverlay.setDrawAccuracyEnabled(true); + mDeviceLocationOverlay.getEnableAutoStop(); // Stop following location on map move by user. + mDeviceLocationOverlay.disableFollowLocation(); // Initially do not follow the current location. + mDeviceLocationOverlay.setOptionsMenuEnabled(true); + mMap.getOverlays().add(mDeviceLocationOverlay); + + startLocationManager(); + } + + private void startLocationManager() { + mLocationManager.start((android.location.LocationManager) getActivity().getSystemService( + Context.LOCATION_SERVICE)); + } + + @OnClick(R.id.map_btn_goto_current_location) + void onGotoCurrentLocationClicked() { + mDeviceLocationOverlay.enableFollowLocation(); // Follow the current location. + if (mLastDeviceLocation.getValue() == null) { + Toast.makeText(getContext(), R.string.unknown_location, Toast.LENGTH_SHORT) + .show(); + } else { + double zoom = Math.max(13, Math.min(17, mMap.getZoomLevelDouble())); // zoom \in [13,17] + moveMapToLocation(Tools.locationToLatLng(mLastDeviceLocation.getValue()), zoom, + POSITION_PRIORITY_FORCED); } } + private void setGotoCurrentLocationStatus() { + mBtnGotoCurrentLocation.setEnabled(mLastDeviceLocation.getValue() != null + || mHasEnabledLocationProviders); + + int fillColor; + Drawable icon; + if (mLastDeviceLocation.getValue() != null) { + icon = mIcMyLocationWhite; + fillColor = mColorPrimary; + } else if (mHasEnabledLocationProviders) { + icon = mIcMyLocationGrey; + fillColor = mColorPrimaryWhite; + } else { + icon = mIcMyLocationWhite; + fillColor = mColorPrimaryButtonDisable; + } + mBtnGotoCurrentLocation.setImageDrawable(icon); + mBtnGotoCurrentLocation.setBackgroundTintList(ColorStateList.valueOf(fillColor)); + } /** * Moves the map to the given location if the given priority is newer than the current one. * A default value for zoom is used if the given value is less than zero. */ - private void moveMapToLocation(LatLng latLng, float zoom, int positionPriority) { - if (mMap == null || latLng == null) { + private void moveMapToLocation(IGeoPoint center, double zoom, int positionPriority) { + if (center == null) { return; } - if (mLastPositionType < positionPriority) { + if (mLastPositionType < positionPriority || positionPriority == POSITION_PRIORITY_FORCED) { mLastPositionType = positionPriority; if (zoom < 0) { zoom = getResources().getInteger(R.integer.prefs_map_location_zoom_default); } - mMap.moveCamera(CameraUpdateFactory.newLatLngZoom(latLng, zoom)); + // FIXME(saemy): osmdroid does not provide a possibility to just move to a center+zoom + // which issues the callbacks only once. So we suppress any spurious callback calls. + mOsmdroidBug_suppressCallbacks = true; + mMapController.setZoom(zoom); + mOsmdroidBug_suppressCallbacks = false; + mMapController.setCenter(center); } } private void doInitialMapMove() { float showUserZoom = getResources().getInteger(R.integer.map_showuser_zoom); // If we were launched with an intent asking us to zoom to a member - LatLng targetLatLng = getArguments().getParcelable(KEY_MAP_TARGET_LAT_LNG); + IGeoPoint targetLatLng = getArguments().getParcelable(KEY_MAP_TARGET_LAT_LNG); if (targetLatLng != null) { moveMapToLocation(targetLatLng, showUserZoom, POSITION_PRIORITY_FORCED); return; } // Fetches the last location from the settings (but no default yet). - CameraPosition savedLocation = mSettingsRepository.getLastMapLocation(false); + ZoomedLocation savedLocation = mSettingsRepository.getLastMapLocation(false); if (savedLocation != null) { moveMapToLocation( - savedLocation.target, savedLocation.zoom, POSITION_PRIORITY_LAST_STORED); + savedLocation.location, savedLocation.zoom, POSITION_PRIORITY_LAST_STORED); return; } - if (mLastDeviceLocation != null) { - moveMapToLocation(Tools.locationToLatLng(mLastDeviceLocation), showUserZoom, + if (mLastDeviceLocation.getValue() != null) { + moveMapToLocation(Tools.locationToLatLng(mLastDeviceLocation.getValue()), showUserZoom, POSITION_PRIORITY_LAST_DEVICE_POSITION); return; } @@ -341,95 +411,99 @@ private void doInitialMapMove() { // Fetches the default last location from the settings. savedLocation = mSettingsRepository.getLastMapLocation(true); - moveMapToLocation(savedLocation.target, savedLocation.zoom, POSITION_PRIORITY_ESTIMATE); + moveMapToLocation(savedLocation.location, savedLocation.zoom, POSITION_PRIORITY_ESTIMATE); } - @Override - public void onCameraChange(CameraPosition position) { - mLastCameraPosition = position; + private final static long FETCH_USERS_DELAY_MS = 700; + private Disposable mDelayedUserFetchDisposable; + private void onPositionChange(double zoom) { + IGeoPoint mapCenter = mMap.getMapCenter(); + mLastPosition = new ZoomedLocation(mapCenter.getLatitude(), mapCenter.getLongitude(), zoom); - // If not connected, we'll switch to offline/starred user mode + // If not connected, we'll switch to offline/starred users mode if (!Tools.isNetworkConnected(getContext())) { sendMessage(R.string.map_network_not_connected); - // If we already knew we were offline, return - if (mIsOffline) { - return; - } - // Otherwise, set state to offline and load only offline user - mIsOffline = true; - loadOfflineUsers(); return; } - // If we were offline, switch back on, but remove the offline markers - if (mIsOffline) { - mIsOffline = false; - - // Stop listening for the favorite users. - if (mLoadOfflineUserDisposable != null) { - mLoadOfflineUserDisposable.dispose(); - mLoadOfflineUserDisposable = null; - } - - mClusterManager.clearItems(); - mClusterManager.getMarkerCollection().clear(); - mClusteredUsers.clear(); + // We delay the execution for some time as we get a burst of updates once the user moves the + // map. + if (mDelayedUserFetchDisposable != null) { + mDelayedUserFetchDisposable.dispose(); } - - // And get standard user list for region from server - if (position.zoom < getResources().getInteger(R.integer.map_zoom_min_load)) { + mDelayedUserFetchDisposable = Completable.complete() + .delay(FETCH_USERS_DELAY_MS, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::fetchUsersForCurrentMapPosition); + getResumePauseDisposable().add(mDelayedUserFetchDisposable); + } + private void fetchUsersForCurrentMapPosition() { + if (mLastPosition.zoom < getResources().getInteger(R.integer.map_zoom_min_load)) { sendMessage(R.string.users_dont_load); } else { sendMessage(R.string.loading_users); - LatLngBounds curScreen = mMap.getProjection().getVisibleRegion().latLngBounds; getResumePauseDisposable().add( - mUserRepository.searchByLocation(curScreen.northeast, curScreen.southwest) + mUserRepository.searchByLocation(mMap.getBoundingBox()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(searchResult -> { if (searchResult.isEmpty()) { - sendMessage(R.string.no_results); + return; } for (UserSearchByLocationResponse.User user : searchResult) { - boolean isNew = mClusteredUsers.add(user.id); - // Only add to the cluster if it wasn't before. - if (isNew) { - mClusterManager.addItem(ClusterUser.from(user)); - } + addUserToCluster(user.toSimpleUser()); } - mClusterManager.cluster(); + mMarkerClusterer.invalidate(); + mMap.invalidate(); }, throwable -> { // TODO(saemy): Error handling. Log.e(TAG, throwable.getMessage()); })); } + } private void loadOfflineUsers() { - mClusterManager.clearItems(); - mClusterManager.getMarkerCollection().clear(); - mClusteredUsers.clear(); - - // We'll use the starred users when network is offline. - List loadedUserIds = new ArrayList<>(); + // We'll show the starred users until we load a fresh version of them. mLoadOfflineUserDisposable = Observable.merge(mFavoriteRepository.getFavorites()) + .filter(Resource::hasData) + .map(userResource -> userResource.data) .observeOn(AndroidSchedulers.mainThread()) - .subscribe(userResource -> { - if (userResource.hasData() && mIsOffline) { - // Users pop up twice as one is the error since we cannot load it from the - // network. - User user = userResource.data; - if (loadedUserIds.contains(user.id)) { - return; - } - loadedUserIds.add(user.id); - - mClusterManager.addItem(ClusterUser.from(user)); - mClusterManager.cluster(); + .subscribe(user -> { + // Users pop up twice as one is the error since we might not be able to load it + // from the network. + boolean added = mOfflineUserIds.add(user.id); + if (!added) { + return; } + + addUserToCluster(user); + mMarkerClusterer.invalidate(); }); - getResumePauseDisposable().add(mLoadOfflineUserDisposable); + getCreateDestroyDisposable().add(mLoadOfflineUserDisposable); + } + + private void addUserToCluster(SimpleUser user) { + // Only add to the cluster if it wasn't before or when its location changed. + final Marker existingMarker = mClusteredUsers.get(user.id); + boolean isNew = existingMarker == null + || !existingMarker.getPosition().equals(user.location); + if (isNew) { + if (existingMarker != null) { + mMarkerClusterer.remove(existingMarker); + } + + UserMarker marker = new UserMarker(getContext(), mMap, user); + marker.setAnchor(UserMarker.ANCHOR_CENTER, UserMarker.ANCHOR_BOTTOM); + marker.setIcon(getResources().getDrawable(R.drawable.map_markers_single)); + marker.setOnMarkerClickListener((m, mapView) -> { + new MultiUserSelectDialog().show(Lists.newArrayList(user)); + return true; + }); + mMarkerClusterer.add(marker); + mClusteredUsers.put(user.id, marker); + } } /** @@ -438,104 +512,40 @@ private void loadOfflineUsers() { * - If the bounds are empty (all users at same place) then let it pop the info window * - Otherwise, move the camera to show the bounds of the map */ - @Override - public boolean onClusterClick(Cluster cluster) { - mLastClickedCluster = cluster; // remember for use later in the Adapter - + public boolean onClusterClick(MapView mapView, StaticCluster cluster) { // Find out the bounds of the users currently in cluster - LatLngBounds.Builder builder = new LatLngBounds.Builder(); - for (ClusterUser user : cluster.getItems()) { - builder.include(user.latLng); + List users = new ArrayList<>(cluster.getSize()); + List locations = new ArrayList<>(cluster.getSize()); + for (int i = 0; i < cluster.getSize(); ++i) { + UserMarker userMarker = (UserMarker) cluster.getItem(i); + users.add(userMarker.getUser()); + locations.add(userMarker.getPosition()); } - LatLngBounds bounds = builder.build(); + BoundingBox bounds = BoundingBox.fromGeoPoints(locations); // If the users are not all at the same location, then change bounds of map. - if (!bounds.southwest.equals(bounds.northeast)) { + if (bounds.getDiagonalLengthInMeters() > 0) { // TODO(saemy): something bigger than 0? // Offset from edge of map in pixels when exploding cluster - View mapView = getChildFragmentManager().findFragmentById(R.id.map).getView(); int padding_percent = getResources().getInteger(R.integer.cluster_explode_padding_percent); int padding = Math.min(mapView.getHeight(), mapView.getWidth()) * padding_percent / 100; - CameraUpdate cu = CameraUpdateFactory.newLatLngBounds(bounds, mapView.getWidth(), - mapView.getHeight(), padding); - mMap.animateCamera(cu); - return true; - } - showMultiUserSelectDialog((ArrayList) cluster.getItems()); - return true; - } - - /** - * Start the Search tab with the members we have at this exact location. - */ - @Override - public void onClusterInfoWindowClick(Cluster cluster) { - ArrayList userIds = new ArrayList<>(cluster.getSize()); - for (ClusterUser user : cluster.getItems()) { - userIds.add(user.id); + mapView.zoomToBoundingBox(bounds, true, padding); + } else { + new MultiUserSelectDialog().show(users); } - getNavigationController().navigateToUserList(userIds); - } - - @Override - public boolean onClusterItemClick(ClusterUser user) { - return false; - } - - @Override - public void onClusterItemInfoWindowClick(ClusterUser user) { - getNavigationController().navigateToUser(user.id); - } - /* Creates a dialog for an error message */ - private void showErrorDialog(int errorCode) { - ErrorDialogFragment dialogFragment = ErrorDialogFragment.create(errorCode); - dialogFragment.show(getChildFragmentManager(), "errordialog"); + return true; } /** * Returns the distance to given point or -1 if the current position is unknown. */ - private double calculateDistanceTo(LatLng latLng) { - return mLastDeviceLocation != null + private double calculateDistanceTo(IGeoPoint latLng) { + return mLastDeviceLocation.getValue() != null ? Tools.calculateDistanceBetween( - Tools.latLngToLocation(latLng), mLastDeviceLocation, mDistanceUnit) + Tools.latLngToLocation(latLng), mLastDeviceLocation.getValue(), mDistanceUnit) : -1; } - - public void showMultiUserSelectDialog(final ArrayList users) { - String[] mPossibleItems = new String[users.size()]; - - double distance = calculateDistanceTo(users.get(0).latLng); - String distanceSummary = - getString(R.string.distance_from_current, (int) distance, mDistanceUnitShort); - - LinearLayout customTitleView = (LinearLayout) getLayoutInflater().inflate( - R.layout.view_multiuser_dialog_header, null); - TextView titleView = customTitleView.findViewById(R.id.title); - titleView.setText(getResources().getQuantityString(R.plurals.users_at_location, - users.size(), users.size(), users.get(0).getStreetCityAddressStr())); - - TextView distanceView = customTitleView.findViewById(R.id.distance_from_current); - distanceView.setText(distance >= 0 ? distanceSummary : ""); - - for (int i = 0; i < users.size(); i++) { - mPossibleItems[i] = users.get(i).fullname; - } - AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(getContext()); - alertDialogBuilder.setCustomTitle(customTitleView); - - alertDialogBuilder - .setNegativeButton(R.string.ok, (dialog, which) -> { - }) - .setItems(mPossibleItems, (dialog, index) -> { - ClusterUser user = users.get(index); - getNavigationController().navigateToUser(user.id); - }); - AlertDialog alertDialog = alertDialogBuilder.create(); - alertDialog.show(); - } - private void sendMessage(@StringRes final int messageId) { if (mLastToast != null) { mLastToast.cancel(); @@ -564,226 +574,73 @@ protected CharSequence getTitle() { return getString(R.string.app_name); } - enum ClusterStatus {none, some, all} - - /* A fragment to display an error dialog */ - public static class ErrorDialogFragment extends DialogFragment { - private final CompletableSubject mCompletable = CompletableSubject.create(); - - public static ErrorDialogFragment create(int errorCode) { - Bundle args = new Bundle(); - args.putInt(DIALOG_ERROR, errorCode); - - ErrorDialogFragment dialogFragment = new ErrorDialogFragment(); - dialogFragment.setArguments(args); - return dialogFragment; - } - - @Override - @NonNull - public Dialog onCreateDialog(Bundle savedInstanceState) { - // Get the error code and retrieve the appropriate dialog - int errorCode = getArguments().getInt(DIALOG_ERROR); - return GooglePlayServicesUtil.getErrorDialog( - errorCode, getActivity(), REQUEST_RESOLVE_ERROR); - } - - public CompletableSubject getCompletable() { - return mCompletable; - } - - @Override - public void onDismiss(DialogInterface dialog) { - mCompletable.onComplete(); - } - } - - /** - * Add the title and snippet to the marker so that infoWindow can be rendered. - */ - private class UserRenderer extends DefaultClusterRenderer { - private final IconGenerator mSingleLocationClusterIconGenerator = - new IconGenerator(getActivity().getApplicationContext()); - private final IconGenerator mSingleUserIconGenerator = - new IconGenerator(getActivity().getApplicationContext()); - private SparseArray mIcons = new SparseArray<>(); - private BitmapDescriptor mSingleUserBitmapDescriptor; - - public UserRenderer() { - super(getActivity().getApplicationContext(), mMap, mClusterManager); - - View sameLocationMultiUserClusterView = - getLayoutInflater().inflate(R.layout.marker_location_cluster, null); - View singleUserMarkerView = getLayoutInflater().inflate(R.layout.marker_location, null); - mSingleLocationClusterIconGenerator.setContentView(sameLocationMultiUserClusterView); - mSingleLocationClusterIconGenerator.setBackground(null); - mSingleUserIconGenerator.setContentView(singleUserMarkerView); - mSingleUserIconGenerator.setBackground(null); - mSingleUserBitmapDescriptor = - BitmapDescriptorFactory.fromBitmap(mSingleUserIconGenerator.makeIcon()); - - } - - @Override - protected void onBeforeClusterRendered(Cluster cluster, - MarkerOptions markerOptions) { - - if (clusterLocationStatus(cluster) == ClusterStatus.all) { - int size = cluster.getSize(); - BitmapDescriptor descriptor = mIcons.get(size); - if (descriptor == null) { - // Cache new bitmaps - descriptor = BitmapDescriptorFactory.fromBitmap( - mSingleLocationClusterIconGenerator.makeIcon(String.valueOf(size))); - mIcons.put(size, descriptor); - } - markerOptions.icon(descriptor); - } else { - super.onBeforeClusterRendered(cluster, markerOptions); - } - } - - @Override - protected void onBeforeClusterItemRendered(ClusterUser user, MarkerOptions markerOptions) { - StringBuilder snippet = new StringBuilder(); - if (!TextUtils.isEmpty(user.street)) { - snippet.append(user.street).append("
"); - } - snippet.append(user.city).append(", ").append(user.province.toUpperCase()); - - double distance = calculateDistanceTo(user.latLng); - if (distance >= 0) { - snippet.append("
").append(getString( - R.string.distance_from_current, (int) distance, mDistanceUnitShort)); - } - - markerOptions.title(user.fullname).snippet(snippet.toString()); - markerOptions.icon(mSingleUserBitmapDescriptor); - } - - @Override - protected boolean shouldRenderAsCluster(Cluster cluster) { - // Render as a cluster if all the items are at the exact same location, or if there are more than - // min_cluster_size in the cluster. - ClusterStatus status = clusterLocationStatus(cluster); - return status == ClusterStatus.all || status == ClusterStatus.some - || cluster.getSize() >= getResources().getInteger(R.integer.min_cluster_size); - } - - /* - * Attempt to determine the location status of items in the cluster, whether all in one location - * or in a variety of locations. - */ - protected ClusterStatus clusterLocationStatus(Cluster cluster) { - HashSet latLngs = new HashSet<>(); - for (ClusterUser item : cluster.getItems()) { - latLngs.add(item.latLng.toString()); + class MultiUserSelectDialog { + @BindView(R.id.title) TextView mTxtTitle; + @BindView(R.id.distance_from_current) TextView mTxtDistanceFromCurrent; + + void show(final List users) { + AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(getContext()); + Context dialogContext = dialogBuilder.getContext(); + + View titleView = null; + if (users.size() > 1) { + titleView = LayoutInflater.from(dialogContext).inflate( + R.layout.view_multiuser_dialog_header, null, false); + ButterKnife.bind(this, titleView); + + final SimpleUser representative = users.get(0); + mTxtTitle.setText(getResources().getQuantityString(R.plurals.users_at_location, + users.size(), users.size(), representative.getStreetCityAddress())); + + double distance = calculateDistanceTo(representative.location); + String distanceSummary = getString( + R.string.distance_from_current, (int) distance, mDistanceUnitShort); + mTxtDistanceFromCurrent.setText(distanceSummary); + mTxtDistanceFromCurrent.setVisibility(distance >= 0 ? View.VISIBLE : View.GONE); } - // if cluster size and latLngs size are same, all are unique locations, so 'none' - if (cluster.getSize() == latLngs.size()) { - return ClusterStatus.none; - } - // If there is only one unique location, then all are in same location. - else if (latLngs.size() == 1) { - return ClusterStatus.all; - } - // Otherwise it's a mix of same and other location - return ClusterStatus.some; + UserListAdapter userListAdapter = new UserListAdapter( + dialogContext, UserListAdapter.COMPERATOR_FULLNAME_ASC, null); + userListAdapter.resetDataset(users); + // Remember the navigationController here as sometimes the activity is no longer set + // in the listener. + final NavigationController navigationController = getNavigationController(); + dialogBuilder + .setCustomTitle(titleView) + .setNegativeButton(R.string.ok, (dialog, which) -> {}) + .setAdapter(userListAdapter, (dialog, index) -> { + SimpleUser user = users.get(index); + navigationController.navigateToUser(user.id); + }) + .create() + .show(); } } - class ClusterInfoWindowAdapter implements GoogleMap.InfoWindowAdapter { - private View mPopup = null; - private LayoutInflater mInflater; - - ClusterInfoWindowAdapter(LayoutInflater inflater) { - this.mInflater = inflater; - } + private IMyLocationProvider mLocationProvider = new IMyLocationProvider() { + private Disposable mDisposable; @Override - public View getInfoWindow(Marker marker) { - return null; - } - - @Override - public View getInfoContents(Marker marker) { - StringBuilder userList = new StringBuilder(); - if (mPopup == null) { - mPopup = mInflater.inflate(R.layout.view_user_info_multiple, null); + public boolean startLocationProvider(IMyLocationConsumer locationConsumer) { + if (locationConsumer == null) { + return false; } - TextView tv = mPopup.findViewById(R.id.title); - - if (mLastClickedCluster != null) { - double distance = calculateDistanceTo(marker.getPosition()); - TextView distance_tv = mPopup.findViewById(R.id.distance_from_current); - distance_tv.setText(distance >= 0 - ? Html.fromHtml(getString( - R.string.distance_from_current, (int) distance, mDistanceUnitShort)) - : ""); - - ArrayList users = - (ArrayList) mLastClickedCluster.getItems(); - Collections.sort(users, (left, right) -> { - int ncaLeft = left.isCurrentlyAvailable ? 0 : 1; - int ncaRight = right.isCurrentlyAvailable ? 0 : 1; - - return ncaLeft != ncaRight - ? ncaLeft - ncaRight - : left.fullname.compareTo(right.fullname); - - }); - - for (ClusterUser user : users) { - userList.append(user.fullname).append("
"); - } - userList.append(getString(R.string.click_to_view_all)); - - String title = getResources().getQuantityString(R.plurals.users_at_location, - users.size(), users.size(), users.get(0).getLocationStr()); - - tv.setText(Html.fromHtml(title)); - tv = mPopup.findViewById(R.id.snippet); - tv.setText(Html.fromHtml(userList.toString())); - } - - return (mPopup); + mDisposable = mLastDeviceLocation + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(location -> locationConsumer.onLocationChanged(location, this)); + return true; } - } - - /** - * InfoWindowAdapter to present info about a single user marker. - * Implemented here so we can have multiple lines, which the maps-provided one prevents. - */ - class SingleUserInfoWindowAdapter implements GoogleMap.InfoWindowAdapter { - @BindView(R.id.title) TextView mLblTitle; - @BindView(R.id.snippet) TextView mLblSnippet; - - private View mPopup = null; - private LayoutInflater mInflater; - - SingleUserInfoWindowAdapter(LayoutInflater inflater) { - mInflater = inflater; + @Override + public void stopLocationProvider() { + mDisposable.dispose(); } - @Override - public View getInfoWindow(Marker marker) { - return null; + public Location getLastKnownLocation() { + return mLastDeviceLocation.getValue(); } - - @SuppressLint("InflateParams") @Override - public View getInfoContents(Marker marker) { - if (mPopup == null) { - mPopup = mInflater.inflate(R.layout.view_user_info_single, null); - } - ButterKnife.bind(this, mPopup); - - mLblTitle.setText(marker.getTitle()); - mLblSnippet.setText(Html.fromHtml(marker.getSnippet())); - - return mPopup; + public void destroy() { } - } + }; } diff --git a/app/src/main/java/fi/bitrite/android/ws/ui/SearchFragment.java b/app/src/main/java/fi/bitrite/android/ws/ui/SearchFragment.java index 0d6d5ad3..0eb9970f 100644 --- a/app/src/main/java/fi/bitrite/android/ws/ui/SearchFragment.java +++ b/app/src/main/java/fi/bitrite/android/ws/ui/SearchFragment.java @@ -28,6 +28,7 @@ import butterknife.OnItemClick; import fi.bitrite.android.ws.R; import fi.bitrite.android.ws.WSAndroidApplication; +import fi.bitrite.android.ws.model.SimpleUser; import fi.bitrite.android.ws.model.User; import fi.bitrite.android.ws.repository.Resource; import fi.bitrite.android.ws.repository.UserRepository; @@ -175,7 +176,7 @@ protected CharSequence getTitle() { return title; } - private final static Comparator mComparator = (left, right) -> { + private final static Comparator mComparator = (left, right) -> { int caLeft = left.isCurrentlyAvailable ? 1 : 0; int caRight = right.isCurrentlyAvailable ? 1 : 0; diff --git a/app/src/main/java/fi/bitrite/android/ws/ui/SettingsFragment.java b/app/src/main/java/fi/bitrite/android/ws/ui/SettingsFragment.java index 1cc5b601..5ef9808e 100644 --- a/app/src/main/java/fi/bitrite/android/ws/ui/SettingsFragment.java +++ b/app/src/main/java/fi/bitrite/android/ws/ui/SettingsFragment.java @@ -5,10 +5,17 @@ import android.os.Bundle; import android.support.v4.app.DialogFragment; import android.support.v4.app.Fragment; +import android.support.v7.preference.ListPreference; import android.support.v7.preference.Preference; import android.support.v7.preference.PreferenceFragmentCompat; import android.text.TextUtils; +import org.osmdroid.tileprovider.tilesource.ITileSource; +import org.osmdroid.tileprovider.tilesource.TileSourceFactory; + +import java.util.LinkedList; +import java.util.List; + import javax.inject.Inject; import butterknife.BindString; @@ -27,6 +34,7 @@ public class SettingsFragment extends PreferenceFragmentCompat implements Inject @Inject SettingsRepository mSettingsRepository; @BindString(R.string.prefs_distance_unit_key) String mKeyDistanceUnit; + @BindString(R.string.prefs_tile_source_key) String mTileMapSource; @BindString(R.string.prefs_message_refresh_interval_min_key) String mKeyMessageRefreshInterval; public static Fragment create() { @@ -79,6 +87,28 @@ private void setSummary() { R.string.prefs_distance_unit_summary, mSettingsRepository.getDistanceUnitLong())); + // Sets the available map sources. + final ListPreference tileSourcePreference = (ListPreference) findPreference(mTileMapSource); + tileSourcePreference.setSummary(getString( + R.string.prefs_tile_source_summary, + mSettingsRepository.getTileSourceStr())); + final List tileSourceNames = new LinkedList<>(); + for (ITileSource tileSource : TileSourceFactory.getTileSources()) { + if(tileSource == TileSourceFactory.PUBLIC_TRANSPORT + || tileSource == TileSourceFactory.ChartbundleENRL + || tileSource == TileSourceFactory.ChartbundleENRH + || tileSource == TileSourceFactory.ChartbundleWAC) { + // Blacklisted tile sources. + continue; + } + tileSourceNames.add(tileSource.name()); + } + CharSequence[] tileSourceNamesCS = + tileSourceNames.toArray(new CharSequence[tileSourceNames.size()]); + tileSourcePreference.setEntries(tileSourceNamesCS); + tileSourcePreference.setEntryValues(tileSourceNamesCS); + tileSourcePreference.setDefaultValue(TileSourceFactory.DEFAULT_TILE_SOURCE.name()); + Resources res = getResources(); int intervalMin = mSettingsRepository.getMessageRefreshIntervalMin(); findPreference(mKeyMessageRefreshInterval).setSummary(intervalMin > 0 diff --git a/app/src/main/java/fi/bitrite/android/ws/ui/UserFragment.java b/app/src/main/java/fi/bitrite/android/ws/ui/UserFragment.java index ea9d4892..9886d594 100644 --- a/app/src/main/java/fi/bitrite/android/ws/ui/UserFragment.java +++ b/app/src/main/java/fi/bitrite/android/ws/ui/UserFragment.java @@ -324,8 +324,8 @@ private void showUserOnMap(@NonNull User user) { * Send a geo intent so that we can view the user on external maps application */ public void sendGeoIntent(@NonNull User user) { - String lat = Double.toString(user.location.latitude); - String lng = Double.toString(user.location.longitude); + String lat = Double.toString(user.location.getLatitude()); + String lng = Double.toString(user.location.getLongitude()); String query = Uri.encode(lat + "," + lng + "(" + user.fullname + ")"); Uri uri = Uri.parse("geo:" + lat + "," + lng + "?q=" + query); diff --git a/app/src/main/java/fi/bitrite/android/ws/ui/listadapter/UserListAdapter.java b/app/src/main/java/fi/bitrite/android/ws/ui/listadapter/UserListAdapter.java index 322d94e5..ee630c6a 100644 --- a/app/src/main/java/fi/bitrite/android/ws/ui/listadapter/UserListAdapter.java +++ b/app/src/main/java/fi/bitrite/android/ws/ui/listadapter/UserListAdapter.java @@ -34,15 +34,15 @@ import io.reactivex.disposables.Disposable; import io.reactivex.subjects.BehaviorSubject; -public class UserListAdapter extends ArrayAdapter { +public class UserListAdapter extends ArrayAdapter { public final static Comparator COMPERATOR_FULLNAME_ASC = (left, right) -> left.fullname.compareTo(right.fullname); - private final Comparator mComparator; + private final Comparator mComparator; private final Decorator mDecorator; - private BehaviorSubject> mUsers = BehaviorSubject.create(); + private BehaviorSubject> mUsers = BehaviorSubject.create(); @BindView(R.id.user_list_layout) LinearLayout mLayout; @BindView(R.id.user_list_icon) UserCircleImageView mIcon; @@ -50,7 +50,8 @@ public class UserListAdapter extends ArrayAdapter { @BindView(R.id.user_list_lbl_location) TextView mLblLocation; @BindView(R.id.user_list_lbl_member_info) TextView mMemberInfo; - public UserListAdapter(@NonNull Context context, @Nullable Comparator comparator, + public UserListAdapter(@NonNull Context context, + @Nullable Comparator comparator, @Nullable Decorator decorator) { super(context, R.layout.item_user_list); @@ -77,7 +78,7 @@ public void resetDataset(List>> users, Object ignored) }); } - public void resetDataset(List users) { + public void resetDataset(List users) { if (mComparator != null) { Collections.sort(users, mComparator); } @@ -90,7 +91,7 @@ public void resetDataset(List users) { @NonNull @Override public View getView(int position, View convertView, @NonNull ViewGroup parent) { - final User user = getItem(position); + final SimpleUser user = getItem(position); if (convertView == null) { convertView = LayoutInflater.from(getContext()) @@ -125,13 +126,13 @@ public View getView(int position, View convertView, @NonNull ViewGroup parent) { return convertView; } - public BehaviorSubject> getUsers() { + public BehaviorSubject> getUsers() { return mUsers; } @Nullable - public User getUser(int pos) { - List users = mUsers.getValue(); + public SimpleUser getUser(int pos) { + List users = mUsers.getValue(); return users != null && users.size() > pos ? users.get(pos) : null; diff --git a/app/src/main/java/fi/bitrite/android/ws/ui/model/ClusterUser.java b/app/src/main/java/fi/bitrite/android/ws/ui/model/ClusterUser.java deleted file mode 100644 index 26eac4a9..00000000 --- a/app/src/main/java/fi/bitrite/android/ws/ui/model/ClusterUser.java +++ /dev/null @@ -1,98 +0,0 @@ -package fi.bitrite.android.ws.ui.model; - - -import android.text.TextUtils; - -import com.google.android.gms.maps.model.LatLng; -import com.google.maps.android.clustering.ClusterItem; - -import fi.bitrite.android.ws.api.response.UserSearchByLocationResponse; -import fi.bitrite.android.ws.model.User; - -/** - * This entity is used in the {@link fi.bitrite.android.ws.ui.MapFragment}. It acts as a compatible - * type to all the different representations of a user that we are given when using the REST API. - * - * TODO(saemy): Eventually remove this or make it a child of User (while still implementing ClusterItem). - */ -public class ClusterUser implements ClusterItem { - public final int id; - public final String fullname; - - public final String street; - public final String additionalAddress; - public final String postalCode; - public final String city; - public final String province; - public final String countryCode; - public final LatLng latLng; - - public final boolean isCurrentlyAvailable; - - public ClusterUser( - int id, String fullname, String street, String additionalAddress, String postalCode, - String city, String province, String countryCode, LatLng latLng, - boolean isCurrentlyAvailable) { - this.id = id; - this.fullname = fullname; - this.street = street; - this.additionalAddress = additionalAddress; - this.postalCode = postalCode; - this.city = city; - this.province = province; - this.countryCode = countryCode; - this.latLng = latLng; - this.isCurrentlyAvailable = isCurrentlyAvailable; - } - - public static ClusterUser from(User user) { - return new ClusterUser( - user.id, user.fullname, user.street, user.additionalAddress, user.postalCode, - user.city, user.province, user.countryCode, user.location, - user.isCurrentlyAvailable); - } - public static ClusterUser from(UserSearchByLocationResponse.User user) { - return new ClusterUser( - user.id, user.fullname, user.street, "", user.postalCode, user.city, user.province, - user.countryCode, new LatLng(user.latitude, user.longitude), - !user.notCurrentlyAvailable); - } - - public String getLocationStr() { - StringBuilder location = new StringBuilder(); - if (!TextUtils.isEmpty(street)) { - location.append(street).append('\n'); - } - - if (!TextUtils.isEmpty(additionalAddress)) { - location.append(additionalAddress).append('\n'); - } - - location.append(city).append(", ").append(province.toUpperCase()); - if (!TextUtils.isEmpty(postalCode)) { - location.append(' ').append(postalCode); - } - - if (!TextUtils.isEmpty(countryCode)) { - location.append(", ").append(countryCode.toUpperCase()); - } - - return location.toString(); - } - - public String getStreetCityAddressStr() { - StringBuilder result = new StringBuilder(); - - if (!TextUtils.isEmpty(street)) { - result.append(street).append(", "); - } - result.append(city).append(", ").append(province.toUpperCase()); - - return result.toString(); - } - - @Override - public LatLng getPosition() { - return latLng; - } -} diff --git a/app/src/main/java/fi/bitrite/android/ws/ui/util/NavigationController.java b/app/src/main/java/fi/bitrite/android/ws/ui/util/NavigationController.java index 4d7201fa..4d37a14c 100644 --- a/app/src/main/java/fi/bitrite/android/ws/ui/util/NavigationController.java +++ b/app/src/main/java/fi/bitrite/android/ws/ui/util/NavigationController.java @@ -5,7 +5,7 @@ import android.support.v4.app.FragmentManager.BackStackEntry; import android.support.v4.app.FragmentTransaction; -import com.google.android.gms.maps.model.LatLng; +import org.osmdroid.api.IGeoPoint; import java.util.ArrayList; @@ -114,7 +114,7 @@ public void navigateToMap() { // This is the main fragment. navigateTo(NAVIGATION_TAG_MAP, MapFragment.create(), false, true); } - public void navigateToMap(LatLng latLng) { + public void navigateToMap(IGeoPoint latLng) { navigateTo(NAVIGATION_TAG_MAP, MapFragment.create(latLng), false); } diff --git a/app/src/main/java/fi/bitrite/android/ws/ui/util/UserMarker.java b/app/src/main/java/fi/bitrite/android/ws/ui/util/UserMarker.java new file mode 100644 index 00000000..467f4ca2 --- /dev/null +++ b/app/src/main/java/fi/bitrite/android/ws/ui/util/UserMarker.java @@ -0,0 +1,24 @@ +package fi.bitrite.android.ws.ui.util; + +import android.content.Context; + +import org.osmdroid.util.GeoPoint; +import org.osmdroid.views.MapView; +import org.osmdroid.views.overlay.Marker; + +import fi.bitrite.android.ws.model.SimpleUser; + +public class UserMarker extends Marker { + private final SimpleUser mUser; + + public UserMarker(Context context, MapView mapView, SimpleUser user) { + super(mapView, context); + + mUser = user; + setPosition(new GeoPoint(user.location.getLatitude(), user.location.getLongitude())); + } + + public SimpleUser getUser() { + return mUser; + } +} diff --git a/app/src/main/java/fi/bitrite/android/ws/ui/util/UserMarkerClusterer.java b/app/src/main/java/fi/bitrite/android/ws/ui/util/UserMarkerClusterer.java new file mode 100644 index 00000000..b6a17ebe --- /dev/null +++ b/app/src/main/java/fi/bitrite/android/ws/ui/util/UserMarkerClusterer.java @@ -0,0 +1,221 @@ +package fi.bitrite.android.ws.ui.util; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.PorterDuff; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.support.annotation.ColorInt; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import org.osmdroid.api.IGeoPoint; +import org.osmdroid.bonuspack.clustering.RadiusMarkerClusterer; +import org.osmdroid.bonuspack.clustering.StaticCluster; +import org.osmdroid.util.GeoPoint; +import org.osmdroid.views.MapView; +import org.osmdroid.views.overlay.Marker; + +public class UserMarkerClusterer extends RadiusMarkerClusterer { + public interface OnClusterClickListener{ + boolean onClusterClick(MapView mapView, StaticCluster cluster); + } + private OnClusterClickListener mOnClusterClickListener; + + private MarkerFactory mSingleLocationMarkerFactory; + private MarkerFactory mMultiLocationMarkerFactory; + + public UserMarkerClusterer(Context ctx) { + super(ctx); + } + + /** + * Sets the marker factory for when a cluster contains markers with different locations. + */ + public void setSingleLocationMarkerFactory(MarkerFactory markerFactory) { + mSingleLocationMarkerFactory = markerFactory; + } + /** + * Sets the marker factory for when a cluster contains markers with the same location. + */ + public void setMultiLocationMarkerFactory(MarkerFactory markerFactory) { + mMultiLocationMarkerFactory = markerFactory; + } + + public boolean remove(Marker marker){ + return mItems.remove(marker); + } + + @Override + public Marker buildClusterMarker(StaticCluster cluster, MapView mapView) { + boolean isSingleLocationCluster = true; + IGeoPoint firstPos = cluster.getItem(0).getPosition(); + for (int i = 1; i < cluster.getSize() && isSingleLocationCluster; ++i) { + isSingleLocationCluster = firstPos.equals(cluster.getItem(i).getPosition()); + } + + MarkerFactory markerFactory = isSingleLocationCluster + ? mSingleLocationMarkerFactory + : mMultiLocationMarkerFactory; + Marker marker = markerFactory.createMarker( + mapView, cluster.getPosition(), cluster.getSize(), !isSingleLocationCluster); + + marker.setOnMarkerClickListener((m, mv) -> + mOnClusterClickListener != null + && mOnClusterClickListener.onClusterClick(mv, cluster)); + return marker; + } + + public void setOnClusterClickListener(OnClusterClickListener onClusterClickListener) { + mOnClusterClickListener = onClusterClickListener; + } + + public static class MarkerFactory { + private static final int[] BUCKETS = {10, 20, 50, 100, 200, 500, 1000}; + + private float mAnchorH = Marker.ANCHOR_CENTER; + private float mAnchorV = Marker.ANCHOR_CENTER; + private float mTextAnchorH = Marker.ANCHOR_CENTER; + private float mTextAnchorV = Marker.ANCHOR_CENTER; + private int mTextPaddingX = 0; + private int mTextPaddingY = 0; + + private Drawable mIconDrawable; + + public MarkerFactory(Drawable iconDrawable) { + mIconDrawable = iconDrawable; + } + + public void setMarkerAnchor(float anchorH, float anchorV) { + mAnchorH = anchorH; + mAnchorV = anchorV; + } + + public void setTextAnchor(float textAnchorH, float textAnchorV) { + mTextAnchorH = textAnchorH; + mTextAnchorV = textAnchorV; + } + + public void setTextPadding(int paddingX, int paddingY) { + mTextPaddingX = paddingX; + mTextPaddingY = paddingY; + } + + Marker createMarker(MapView mapView, GeoPoint position, int clusterSize, boolean useBuckets) { + Marker marker = new Marker(mapView); + marker.setPosition(position); + marker.setInfoWindow(null); + marker.setAnchor(mAnchorH, mAnchorV); + + String iconText; + Drawable iconDrawable; + if (useBuckets) { + int bucket = getBucket(clusterSize); + iconDrawable = mIconDrawable.getConstantState().newDrawable().mutate(); + iconDrawable.setColorFilter(getClusterColor(bucket), PorterDuff.Mode.SRC); + iconText = getClusterText(bucket); + } else { + iconText = Integer.toString(clusterSize); + iconDrawable = mIconDrawable; + } + Drawable textDrawable = new TextDrawable(mapView.getContext(), iconText); + Drawable[] layers = { iconDrawable, textDrawable }; + marker.setIcon(new LayerDrawable(layers)); + return marker; + } + + /** + * Gets the "bucket" for a particular cluster. By default, uses the number of points within + * the cluster, bucketed to some set points. + */ + private int getBucket(int size) { + if (size <= BUCKETS[0]) { + return size; + } + for (int i = 0; i < BUCKETS.length - 1; ++i) { + if (size < BUCKETS[i + 1]) { + return BUCKETS[i]; + } + } + return BUCKETS[BUCKETS.length - 1]; + } + + @ColorInt + private int getClusterColor(int clusterSize) { + final float hueRange = 220; + final float sizeRange = 300; + final float size = Math.min(clusterSize, sizeRange); + final float hue = + (sizeRange - size)*(sizeRange - size) / (sizeRange*sizeRange) * hueRange; + return Color.HSVToColor(new float[]{ hue, 1f, .6f }); + } + + private String getClusterText(int bucket) { + if (bucket < BUCKETS[0]) { + return String.valueOf(bucket); + } + return String.valueOf(bucket) + "+"; + } + + private class TextDrawable extends Drawable { + private static final int TEXT_PADDING = 32; + + private final String mText; + + private final Paint mPaint; + private final Rect mTextBounds = new Rect(); + + TextDrawable(Context context, String text) { + mText = text; + + mPaint = new Paint(); + mPaint.setColor(Color.WHITE); + mPaint.setTextSize(15 * context.getResources().getDisplayMetrics().density); + mPaint.setFakeBoldText(true); + mPaint.setTextAlign(Paint.Align.LEFT); + mPaint.setAntiAlias(true); + mPaint.getTextBounds(mText, 0, mText.length(), mTextBounds); + } + + @Override + public void draw(@NonNull Canvas canvas) { + final Rect bounds = getBounds(); + float x = bounds.left + mTextPaddingX + + mTextAnchorH * (bounds.width() - mTextBounds.width()) + - mTextBounds.left; + float y = bounds.top + mTextBounds.height() + mTextPaddingY + + mTextAnchorV*(bounds.height() - mTextBounds.height()) + - mTextBounds.bottom; + canvas.drawText(mText, x, y, mPaint); + } + + @Override + public int getIntrinsicWidth() { + return getIntrinsicSize(); + } + @Override + public int getIntrinsicHeight() { + return getIntrinsicSize(); + } + private int getIntrinsicSize() { + return Math.max(mTextBounds.width(), mTextBounds.height()) + TEXT_PADDING; + } + + @Override + public void setAlpha(int alpha) { + } + @Override + public void setColorFilter(@Nullable ColorFilter colorFilter) { + } + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } + } + } +} diff --git a/app/src/main/java/fi/bitrite/android/ws/util/LocationManager.java b/app/src/main/java/fi/bitrite/android/ws/util/LocationManager.java new file mode 100644 index 00000000..1a34e45f --- /dev/null +++ b/app/src/main/java/fi/bitrite/android/ws/util/LocationManager.java @@ -0,0 +1,143 @@ +package fi.bitrite.android.ws.util; + +import android.location.Location; +import android.os.Bundle; + +import java.util.HashSet; +import java.util.Set; + +import io.reactivex.subjects.BehaviorSubject; + +public class LocationManager { + private static final int MIN_TIME_BETWEEN_LOCATION_UPDATES_MS = 1000 * 20; // 20s + private static final int TWO_MINUTES_MS = 1000 * 60 * 2; + + private android.location.LocationManager mAndroidLocationManager; + + private final BehaviorSubject mCurrentBestLocation = BehaviorSubject.create(); + private final BehaviorSubject mHasEnabledProviders = + BehaviorSubject.createDefault(false); + + public LocationManager() { + } + + public void start(android.location.LocationManager androidLocationManager) { + mAndroidLocationManager = androidLocationManager; + startProvider(android.location.LocationManager.NETWORK_PROVIDER); + startProvider(android.location.LocationManager.GPS_PROVIDER); + } + public void stop() { + mAndroidLocationManager.removeUpdates(mLocationListener); + mAndroidLocationManager = null; + } + + public BehaviorSubject getBestLocation() { + return mCurrentBestLocation; + } + public BehaviorSubject getHasEnabledProviders() { + return mHasEnabledProviders; + } + private void startProvider(String provider) { + updateLocationIfBetter(mAndroidLocationManager.getLastKnownLocation(provider)); + mAndroidLocationManager.requestLocationUpdates( + provider, MIN_TIME_BETWEEN_LOCATION_UPDATES_MS, 0, mLocationListener); + boolean isEnabled = mAndroidLocationManager.isProviderEnabled(provider); + if (isEnabled) { + mLocationListener.onProviderEnabled(provider); + } else { + mLocationListener.onProviderDisabled(provider); + } + } + + private final android.location.LocationListener mLocationListener = + new android.location.LocationListener() { + private Set mEnabledProviders = new HashSet<>(); + + @Override + public void onLocationChanged(Location location) { + updateLocationIfBetter(location); + } + @Override + public void onStatusChanged(String provider, int status, Bundle extras) { + } + @Override + public void onProviderEnabled(String provider) { + boolean wasOff = mEnabledProviders.add(provider); + if (wasOff) { + mHasEnabledProviders.onNext(!mEnabledProviders.isEmpty()); + } + } + @Override + public void onProviderDisabled(String provider) { + boolean wasOn = mEnabledProviders.remove(provider); + if (wasOn) { + mHasEnabledProviders.onNext(!mEnabledProviders.isEmpty()); + } + } + }; + + private boolean updateLocationIfBetter(Location newLocation) { + boolean isBetter = isBetterLocation(newLocation, mCurrentBestLocation.getValue()); + if (isBetter) { + mCurrentBestLocation.onNext(newLocation); + } + return isBetter; + } + + /** Determines whether one Location reading is better than the current Location fix + * @param location The new Location that you want to evaluate + * @param currentBestLocation The current Location fix, to which you want to compare the new one + */ + private static boolean isBetterLocation(Location location, Location currentBestLocation) { + if (location == null) { + return false; + } + if (currentBestLocation == null) { + // A new location is always better than no location + return true; + } + + // Check whether the new location fix is newer or older + long timeDelta = location.getTime() - currentBestLocation.getTime(); + boolean isSignificantlyNewer = timeDelta > TWO_MINUTES_MS; + boolean isSignificantlyOlder = timeDelta < -TWO_MINUTES_MS; + boolean isNewer = timeDelta > 0; + + // If it's been more than two minutes since the current location, use the new location + // because the user has likely moved + if (isSignificantlyNewer) { + return true; + // If the new location is more than two minutes older, it must be worse + } else if (isSignificantlyOlder) { + return false; + } + + // Check whether the new location fix is more or less accurate + int accuracyDelta = (int) (location.getAccuracy() - currentBestLocation.getAccuracy()); + boolean isLessAccurate = accuracyDelta > 0; + boolean isMoreAccurate = accuracyDelta < 0; + boolean isSignificantlyLessAccurate = accuracyDelta > 200; + + // Check if the old and new location are from the same provider + boolean isFromSameProvider = + isSameProvider(location.getProvider(), currentBestLocation.getProvider()); + + // Determine location quality using a combination of timeliness and accuracy + if (isMoreAccurate) { + return true; + } else if (isNewer && !isLessAccurate) { + return true; + } else if (isNewer && !isSignificantlyLessAccurate && isFromSameProvider) { + return true; + } + return false; + } + + /** Checks whether two providers are the same */ + private static boolean isSameProvider(String provider1, String provider2) { + if (provider1 == null) { + return provider2 == null; + } + return provider1.equals(provider2); + } +} diff --git a/app/src/main/java/fi/bitrite/android/ws/util/Tools.java b/app/src/main/java/fi/bitrite/android/ws/util/Tools.java index 8f1cfe1e..23c13dcb 100644 --- a/app/src/main/java/fi/bitrite/android/ws/util/Tools.java +++ b/app/src/main/java/fi/bitrite/android/ws/util/Tools.java @@ -21,7 +21,9 @@ import com.google.android.gms.analytics.HitBuilders; import com.google.android.gms.analytics.Tracker; -import com.google.android.gms.maps.model.LatLng; + +import org.osmdroid.api.IGeoPoint; +import org.osmdroid.util.GeoPoint; import java.util.Locale; @@ -53,21 +55,24 @@ static public int calculateDistanceBetween(Location l1, Location l2, float meters = l1.distanceTo(l2); return (int) (meters / factor); } - static public int calculateDistanceBetween(LatLng l1, LatLng l2, + static public int calculateDistanceBetween(IGeoPoint l1, IGeoPoint l2, SettingsRepository.DistanceUnit distanceUnit) { return calculateDistanceBetween(latLngToLocation(l1), latLngToLocation(l2), distanceUnit); } - static public LatLng locationToLatLng(Location location) { - return new LatLng(location.getLatitude(), location.getLongitude()); + static public GeoPoint locationToLatLng(Location location) { + if (location == null) { + return null; + } + return new GeoPoint(location.getLatitude(), location.getLongitude()); } - static public Location latLngToLocation(LatLng latLng) { + static public Location latLngToLocation(IGeoPoint latLng) { if (latLng == null) { return null; } Location location = new Location("fromlatlng"); - location.setLatitude(latLng.latitude); - location.setLongitude(latLng.longitude); + location.setLatitude(latLng.getLatitude()); + location.setLongitude(latLng.getLongitude()); return location; } diff --git a/app/src/main/java/fi/bitrite/android/ws/util/WSNonHierarchicalDistanceBasedAlgorithm.java b/app/src/main/java/fi/bitrite/android/ws/util/WSNonHierarchicalDistanceBasedAlgorithm.java deleted file mode 100644 index 1f46c2e3..00000000 --- a/app/src/main/java/fi/bitrite/android/ws/util/WSNonHierarchicalDistanceBasedAlgorithm.java +++ /dev/null @@ -1,207 +0,0 @@ -package fi.bitrite.android.ws.util; - -import android.content.Context; - -import com.google.android.gms.maps.model.LatLng; -import com.google.maps.android.clustering.Cluster; -import com.google.maps.android.clustering.ClusterItem; -import com.google.maps.android.clustering.algo.Algorithm; -import com.google.maps.android.clustering.algo.StaticCluster; -import com.google.maps.android.geometry.Bounds; -import com.google.maps.android.geometry.Point; -import com.google.maps.android.projection.SphericalMercatorProjection; -import com.google.maps.android.quadtree.PointQuadTree; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import fi.bitrite.android.ws.R; - -/** - * This is just copied from the provided NonHierarchicalDistanceBasedAlgorithm, changing - * MAX_DISTANCE_AT_ZOOM. - *

- * A simple clustering algorithm with O(nlog n) performance. Resulting clusters are not - * hierarchical. - *

- * High level algorithm:
- * 1. Iterate over items in the order they were added (candidate clusters).
- * 2. Create a cluster with the center of the item.
- * 3. Add all items that are within a certain distance to the cluster.
- * 4. Move any items out of an existing cluster if they are closer to another cluster.
- * 5. Remove those items from the list of candidate clusters. - *

- * Clusters have the center of the first element (not the centroid of the items within it). - */ -public class WSNonHierarchicalDistanceBasedAlgorithm - implements Algorithm { - private Context mContext; - - // Turning this down makes for more markers and less clusters - // 20 seems to make too many clusters. 100 was the original default. - private static int MAX_DISTANCE_AT_ZOOM = 80; - - public WSNonHierarchicalDistanceBasedAlgorithm(Context context) { - mContext = context; - MAX_DISTANCE_AT_ZOOM = - mContext.getResources().getInteger(R.integer.map_algo_max_distance_at_zoom); - } - - /** - * Any modifications should be synchronized on mQuadTree. - */ - private final Collection> mItems = new ArrayList<>(); - - /** - * Any modifications should be synchronized on mQuadTree. - */ - private final PointQuadTree> mQuadTree = new PointQuadTree<>(0, 1, 0, 1); - - private static final SphericalMercatorProjection PROJECTION = - new SphericalMercatorProjection(1); - - @Override - public void addItem(T item) { - final QuadItem quadItem = new QuadItem<>(item); - synchronized (mQuadTree) { - mItems.add(quadItem); - mQuadTree.add(quadItem); - } - } - - @Override - public void addItems(Collection items) { - for (T item : items) { - addItem(item); - } - } - - @Override - public void clearItems() { - synchronized (mQuadTree) { - mItems.clear(); - mQuadTree.clear(); - } - } - - @Override - public void removeItem(T item) { - throw new UnsupportedOperationException( - "NonHierarchicalDistanceBasedAlgorithm.remove not implemented"); - } - - @Override - public Set> getClusters(double zoom) { - final int discreteZoom = (int) zoom; - - final double zoomSpecificSpan = MAX_DISTANCE_AT_ZOOM / Math.pow(2, discreteZoom) / 256; - - final Set> visitedCandidates = new HashSet<>(); - final Set> results = new HashSet<>(); - final Map, Double> distanceToCluster = new HashMap<>(); - final Map, StaticCluster> itemToCluster = new HashMap<>(); - - synchronized (mQuadTree) { - for (QuadItem candidate : mItems) { - if (visitedCandidates.contains(candidate)) { - // Candidate is already part of another cluster. - continue; - } - - Bounds searchBounds = createBoundsFromSpan(candidate.getPoint(), zoomSpecificSpan); - Collection> clusterItems; - clusterItems = mQuadTree.search(searchBounds); - if (clusterItems.size() == 1) { - // Only the current marker is in range. Just add the single item to the results. - results.add(candidate); - visitedCandidates.add(candidate); - distanceToCluster.put(candidate, 0d); - continue; - } - StaticCluster cluster = - new StaticCluster<>(candidate.mClusterItem.getPosition()); - results.add(cluster); - - for (QuadItem clusterItem : clusterItems) { - Double existingDistance = distanceToCluster.get(clusterItem); - double distance = distanceSquared(clusterItem.getPoint(), candidate.getPoint()); - if (existingDistance != null) { - // Item already belongs to another cluster. Check if it's closer to this cluster. - if (existingDistance < distance) { - continue; - } - // Move item to the closer cluster. - itemToCluster.get(clusterItem).remove(clusterItem.mClusterItem); - } - distanceToCluster.put(clusterItem, distance); - cluster.add(clusterItem.mClusterItem); - itemToCluster.put(clusterItem, cluster); - } - visitedCandidates.addAll(clusterItems); - } - } - return results; - } - - @Override - public Collection getItems() { - final List items = new ArrayList<>(); - synchronized (mQuadTree) { - for (QuadItem quadItem : mItems) { - items.add(quadItem.mClusterItem); - } - } - return items; - } - - private double distanceSquared(Point a, Point b) { - return (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y); - } - - private Bounds createBoundsFromSpan(Point p, double span) { - double halfSpan = span / 2; - return new Bounds( - p.x - halfSpan, p.x + halfSpan, - p.y - halfSpan, p.y + halfSpan); - } - - private static class QuadItem implements PointQuadTree.Item, Cluster { - private final T mClusterItem; - private final Point mPoint; - private final LatLng mPosition; - private Set singletonSet; - - private QuadItem(T item) { - mClusterItem = item; - mPosition = item.getPosition(); - mPoint = PROJECTION.toPoint(mPosition); - singletonSet = Collections.singleton(mClusterItem); - } - - @Override - public Point getPoint() { - return mPoint; - } - - @Override - public LatLng getPosition() { - return mPosition; - } - - @Override - public Set getItems() { - return singletonSet; - } - - @Override - public int getSize() { - return 1; - } - } -} diff --git a/app/src/main/res/drawable-hdpi/ic_cluster_multi_location_38dp.xml b/app/src/main/res/drawable-hdpi/ic_cluster_multi_location_38dp.xml new file mode 100644 index 00000000..835d4364 --- /dev/null +++ b/app/src/main/res/drawable-hdpi/ic_cluster_multi_location_38dp.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/drawable-mdpi/ic_my_location_grey600_24dp.xml b/app/src/main/res/drawable-mdpi/ic_my_location_grey600_24dp.xml new file mode 100644 index 00000000..520d56aa --- /dev/null +++ b/app/src/main/res/drawable-mdpi/ic_my_location_grey600_24dp.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_my_location_white_24dp.xml b/app/src/main/res/drawable/ic_my_location_white_24dp.xml new file mode 100644 index 00000000..373237bf --- /dev/null +++ b/app/src/main/res/drawable/ic_my_location_white_24dp.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/layout/fragment_map.xml b/app/src/main/res/layout/fragment_map.xml index 9582f42c..37dc86ca 100644 --- a/app/src/main/res/layout/fragment_map.xml +++ b/app/src/main/res/layout/fragment_map.xml @@ -1,26 +1,26 @@ - - + android:layout_width="fill_parent" + android:layout_height="fill_parent" /> - + + + diff --git a/app/src/main/res/layout/view_user_info_multiple.xml b/app/src/main/res/layout/view_user_info_multiple.xml index dbc48a50..1acf20da 100644 --- a/app/src/main/res/layout/view_user_info_multiple.xml +++ b/app/src/main/res/layout/view_user_info_multiple.xml @@ -1,6 +1,7 @@ + android:textStyle="bold" + tools:text="Demo text" /> - - + android:textSize="12sp" + tools:text="1500 miles from here" /> diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 79d06a8b..9c1ac913 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -100,6 +100,9 @@ km mi + Kartentyp + %1$s + Aktualisierungsintervall für Nachrichten Jede Minute @@ -237,4 +240,6 @@ Anzahl Meldungen Mitglieder Mitglied + + Position noch nicht bekannt diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index deb72f94..871dae2c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -223,4 +223,6 @@ Error al obtener mensajes. Error al crear una nueva conversación. El comentario no fue enviado. + + Ubicación desconocida diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 99737bc0..bfc57db5 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -229,4 +229,6 @@ Erreur lors de l\'obtention de messages. Erreur lors de la création d’une nouvelle conversation. L’envoi du commentaire a échoué. + + Position inconnue diff --git a/app/src/main/res/values/google_maps_api.xml b/app/src/main/res/values/google_maps_api.xml deleted file mode 100644 index cad8eb51..00000000 --- a/app/src/main/res/values/google_maps_api.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - AIzaSyDCE2v0T3GIRk7QFVkq1aW4x6QbGfdEjOg - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f13b163c..6f8e47be 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -104,6 +104,10 @@ km mi + tile_source + Map type + %1$s + message_reload_interval_min Messages poll frequency @@ -246,4 +250,6 @@ Show profiles Enabled + + Position not yet known diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 751b1ae1..86a43ed2 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -10,6 +10,11 @@ android:summary="@string/prefs_distance_unit_summary" android:title="@string/prefs_distance_unit_title" /> + +