diff --git a/app/build.gradle b/app/build.gradle index 0795577f4..facd25f36 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -44,10 +44,14 @@ android { buildTypes { debug { + resValue 'string', "mapbox_key", project.hasProperty('tasks_mapbox_key') ? tasks_mapbox_key : '' + resValue 'string', "google_key", project.hasProperty('tasks_google_key') ? tasks_google_key : '' multiDexEnabled true testCoverageEnabled true } release { + resValue 'string', "mapbox_key", tasks_mapbox_key + resValue 'string', "google_key", tasks_google_key minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard.pro' signingConfig signingConfigs.release @@ -175,8 +179,12 @@ dependencies { googleplayImplementation 'com.crashlytics.sdk.android:crashlytics:2.9.9' googleplayImplementation "com.google.firebase:firebase-core:16.0.7" googleplayImplementation "com.google.android.gms:play-services-location:16.0.0" + googleplayImplementation 'com.google.android.gms:play-services-maps:16.1.0' googleplayImplementation "com.google.android.gms:play-services-auth:16.0.1" - googleplayImplementation "com.google.android.gms:play-services-places:16.0.0" + googleplayImplementation 'com.google.android.libraries.places:places:1.0.0' + googleplayImplementation 'com.google.android.gms:play-services-maps:16.1.0' + googleplayImplementation 'androidx.appcompat:appcompat:1.0.2' + googleplayImplementation 'com.mapbox.mapboxsdk:mapbox-android-plugin-places-v7:0.7.0' amazonImplementation "com.google.android.gms:play-services-analytics:16.0.7" diff --git a/app/src/googleplay/AndroidManifest.xml b/app/src/googleplay/AndroidManifest.xml index c223e7b52..5dda9db95 100644 --- a/app/src/googleplay/AndroidManifest.xml +++ b/app/src/googleplay/AndroidManifest.xml @@ -24,6 +24,13 @@ + + + + @@ -36,10 +43,6 @@ android:name="com.google.android.gms.version" android:value="@integer/google_play_services_version"/> - - diff --git a/app/src/googleplay/java/org/tasks/gtasks/PlayServices.java b/app/src/googleplay/java/org/tasks/gtasks/PlayServices.java index ef07fadd1..ac4c8ea37 100644 --- a/app/src/googleplay/java/org/tasks/gtasks/PlayServices.java +++ b/app/src/googleplay/java/org/tasks/gtasks/PlayServices.java @@ -17,6 +17,7 @@ import com.todoroo.astrid.gtasks.auth.GtasksLoginActivity; import io.reactivex.Single; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; +import io.reactivex.internal.disposables.EmptyDisposable; import io.reactivex.schedulers.Schedulers; import java.io.IOException; import javax.inject.Inject; @@ -70,12 +71,27 @@ public class PlayServices { }); } + public Disposable checkMaps(Activity activity) { + if (preferences.useGooglePlaces() || preferences.useGoogleMaps()) { + return Single.fromCallable(this::refreshAndCheck) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(success -> { + if (!success) { + resolve(activity); + } + }); + } else { + return EmptyDisposable.INSTANCE; + } + } + public boolean refreshAndCheck() { refresh(); return isPlayServicesAvailable(); } - boolean isPlayServicesAvailable() { + public boolean isPlayServicesAvailable() { return getResult() == ConnectionResult.SUCCESS; } diff --git a/app/src/googleplay/java/org/tasks/location/GoogleMapFragment.java b/app/src/googleplay/java/org/tasks/location/GoogleMapFragment.java new file mode 100644 index 000000000..3cb8564cf --- /dev/null +++ b/app/src/googleplay/java/org/tasks/location/GoogleMapFragment.java @@ -0,0 +1,95 @@ +package org.tasks.location; + +import android.annotation.SuppressLint; +import android.content.Context; +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.GoogleMap.OnMarkerClickListener; +import com.google.android.gms.maps.OnMapReadyCallback; +import com.google.android.gms.maps.SupportMapFragment; +import com.google.android.gms.maps.UiSettings; +import com.google.android.gms.maps.model.CameraPosition; +import com.google.android.gms.maps.model.LatLng; +import com.google.android.gms.maps.model.MapStyleOptions; +import com.google.android.gms.maps.model.Marker; +import com.google.android.gms.maps.model.MarkerOptions; +import java.util.ArrayList; +import java.util.List; +import org.tasks.R; +import org.tasks.data.Place; + +public class GoogleMapFragment implements MapFragment, OnMapReadyCallback, OnMarkerClickListener { + + private final Context context; + private final MapFragmentCallback callbacks; + private final boolean dark; + private final List markers = new ArrayList<>(); + private GoogleMap map; + + GoogleMapFragment( + Context context, SupportMapFragment fragment, MapFragmentCallback callbacks, boolean dark) { + this.context = context; + this.callbacks = callbacks; + this.dark = dark; + fragment.getMapAsync(this); + } + + @Override + public MapPosition getMapPosition() { + CameraPosition cameraPosition = map.getCameraPosition(); + LatLng target = cameraPosition.target; + return new MapPosition(target.latitude, target.longitude, cameraPosition.zoom); + } + + @Override + public void movePosition(MapPosition mapPosition, boolean animate) { + CameraUpdate cameraUpdate = + CameraUpdateFactory.newCameraPosition( + CameraPosition.builder() + .target(new LatLng(mapPosition.getLatitude(), mapPosition.getLongitude())) + .zoom(mapPosition.getZoom()) + .build()); + if (animate) { + map.animateCamera(cameraUpdate); + } else { + map.moveCamera(cameraUpdate); + } + } + + @Override + public void setMarkers(List places) { + for (Marker marker : markers) { + marker.remove(); + } + markers.clear(); + for (Place place : places) { + Marker marker = + map.addMarker( + new MarkerOptions().position(new LatLng(place.getLatitude(), place.getLongitude()))); + marker.setTag(place); + markers.add(marker); + } + } + + @SuppressLint("MissingPermission") + @Override + public void onMapReady(GoogleMap googleMap) { + map = googleMap; + map.setMyLocationEnabled(true); + if (dark) { + map.setMapStyle(MapStyleOptions.loadRawResourceStyle(context, R.raw.mapstyle_night)); + } + UiSettings uiSettings = map.getUiSettings(); + uiSettings.setMyLocationButtonEnabled(false); + uiSettings.setRotateGesturesEnabled(false); + map.setOnMarkerClickListener(this); + callbacks.onMapReady(this); + } + + @Override + public boolean onMarkerClick(Marker marker) { + callbacks.onPlaceSelected((Place) marker.getTag()); + return true; + } +} diff --git a/app/src/googleplay/java/org/tasks/location/LocationPicker.java b/app/src/googleplay/java/org/tasks/location/LocationPicker.java new file mode 100644 index 000000000..7adbd96e5 --- /dev/null +++ b/app/src/googleplay/java/org/tasks/location/LocationPicker.java @@ -0,0 +1,523 @@ +package org.tasks.location; + +import static com.google.common.collect.Lists.newArrayList; +import static com.google.common.collect.Lists.transform; +import static com.todoroo.andlib.utility.AndroidUtilities.atLeastLollipop; +import static java.util.Arrays.asList; +import static org.tasks.data.Place.newPlace; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.location.Address; +import android.location.Geocoder; +import android.location.Location; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcelable; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnLayoutChangeListener; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.appcompat.widget.Toolbar.OnMenuItemClickListener; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.widget.ContentLoadingProgressBar; +import androidx.fragment.app.FragmentManager; +import androidx.recyclerview.widget.DefaultItemAnimator; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import butterknife.BindView; +import butterknife.ButterKnife; +import butterknife.OnClick; +import com.google.android.gms.common.api.Status; +import com.google.android.gms.location.FusedLocationProviderClient; +import com.google.android.gms.location.LocationServices; +import com.google.android.gms.maps.SupportMapFragment; +import com.google.android.gms.maps.model.LatLng; +import com.google.android.libraries.places.api.Places; +import com.google.android.libraries.places.api.model.Place; +import com.google.android.libraries.places.api.model.Place.Field; +import com.google.android.libraries.places.widget.Autocomplete; +import com.google.android.libraries.places.widget.AutocompleteActivity; +import com.google.android.libraries.places.widget.model.AutocompleteActivityMode; +import com.google.android.material.appbar.AppBarLayout; +import com.google.android.material.appbar.AppBarLayout.Behavior; +import com.google.common.base.Strings; +import com.mapbox.api.geocoding.v5.models.CarmenFeature; +import com.mapbox.geojson.Point; +import com.mapbox.mapboxsdk.Mapbox; +import com.mapbox.mapboxsdk.plugins.places.autocomplete.PlaceAutocomplete; +import com.mapbox.mapboxsdk.plugins.places.autocomplete.model.PlaceOptions; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.Schedulers; +import java.util.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.inject.Inject; +import org.tasks.R; +import org.tasks.billing.Inventory; +import org.tasks.data.LocationDao; +import org.tasks.data.PlaceUsage; +import org.tasks.gtasks.PlayServices; +import org.tasks.injection.ActivityComponent; +import org.tasks.injection.ForApplication; +import org.tasks.injection.InjectingAppCompatActivity; +import org.tasks.location.LocationPickerAdapter.OnLocationPicked; +import org.tasks.location.MapFragment.MapFragmentCallback; +import org.tasks.preferences.Preferences; +import org.tasks.themes.Theme; +import org.tasks.themes.ThemeColor; +import org.tasks.ui.MenuColorizer; +import org.tasks.ui.Toaster; +import timber.log.Timber; + +public class LocationPicker extends InjectingAppCompatActivity + implements OnMenuItemClickListener, MapFragmentCallback, OnLocationPicked { + + private static final String EXTRA_MAP_POSITION = "extra_map_position"; + private static final String EXTRA_APPBAR_OFFSET = "extra_appbar_offset"; + private static final String FRAG_TAG_MAP = "frag_tag_map"; + private static final int REQUEST_GOOGLE_AUTOCOMPLETE = 10101; + private static final int REQUEST_MAPBOX_AUTOCOMPLETE = 10102; + private static final Pattern pattern = Pattern.compile("(\\d+):(\\d+):(\\d+\\.\\d+)"); + + @BindView(R.id.toolbar) + Toolbar toolbar; + + @BindView(R.id.app_bar_layout) + AppBarLayout appBarLayout; + + @BindView(R.id.coordinator) + CoordinatorLayout coordinatorLayout; + + @BindView(R.id.search) + View searchView; + + @BindView(R.id.loading_indicator) + ContentLoadingProgressBar loadingIndicator; + + @BindView(R.id.choose_recent_location) + View chooseRecentLocation; + + @BindView(R.id.recent_locations) + RecyclerView recentLocations; + + @Inject @ForApplication Context context; + @Inject Theme theme; + @Inject Toaster toaster; + @Inject Inventory inventory; + @Inject PlayServices playServices; + @Inject Preferences preferences; + @Inject LocationDao locationDao; + + private MapFragment map; + private FusedLocationProviderClient fusedLocationProviderClient; + private CompositeDisposable disposables; + private MapPosition mapPosition; + private LocationPickerAdapter adapter = new LocationPickerAdapter(this); + private List places = Collections.emptyList(); + private int offset; + + private static String formatCoordinates(org.tasks.data.Place place) { + return String.format( + "%s %s", + formatCoordinate(place.getLatitude(), true), formatCoordinate(place.getLongitude(), false)); + } + + private static String formatCoordinate(double coordinates, boolean latitude) { + String output = Location.convert(Math.abs(coordinates), Location.FORMAT_SECONDS); + Matcher matcher = pattern.matcher(output); + if (matcher.matches()) { + return String.format( + "%s°%s'%s\"%s", + matcher.group(1), + matcher.group(2), + matcher.group(3), + latitude ? (coordinates > 0 ? "N" : "S") : (coordinates > 0 ? "E" : "W")); + } else { + return Double.toString(coordinates); + } + } + + private boolean canSearch() { + return atLeastLollipop() || inventory.hasPro(); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + theme.applyTheme(this); + setContentView(R.layout.activity_location_picker); + ButterKnife.bind(this); + + Configuration configuration = getResources().getConfiguration(); + if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + && configuration.smallestScreenWidthDp < 480) { + searchView.setVisibility(View.GONE); + } + + if (savedInstanceState != null) { + mapPosition = savedInstanceState.getParcelable(EXTRA_MAP_POSITION); + offset = savedInstanceState.getInt(EXTRA_APPBAR_OFFSET); + } + + toolbar.setNavigationIcon(R.drawable.ic_outline_arrow_back_24px); + toolbar.setNavigationOnClickListener(v -> collapseToolbar()); + if (canSearch()) { + toolbar.inflateMenu(R.menu.menu_location_picker); + toolbar.setOnMenuItemClickListener(this); + } else { + searchView.setVisibility(View.GONE); + } + + MenuColorizer.colorToolbar(this, toolbar); + ThemeColor themeColor = theme.getThemeColor(); + themeColor.applyToStatusBarIcons(this); + themeColor.applyToNavigationBar(this); + + if (preferences.useGoogleMaps()) { + initGoogleMaps(); + } else { + initMapboxMaps(); + } + + fusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(this); + + CoordinatorLayout.LayoutParams params = + (CoordinatorLayout.LayoutParams) appBarLayout.getLayoutParams(); + Behavior behavior = new Behavior(); + behavior.setDragCallback( + new AppBarLayout.Behavior.DragCallback() { + @Override + public boolean canDrag(@NonNull AppBarLayout appBarLayout) { + return false; + } + }); + params.setBehavior(behavior); + + appBarLayout.addOnOffsetChangedListener( + (appBarLayout, offset) -> { + this.offset = offset; + toolbar.setAlpha(Math.abs(offset / (float) appBarLayout.getTotalScrollRange())); + }); + + coordinatorLayout.addOnLayoutChangeListener( + new OnLayoutChangeListener() { + @Override + public void onLayoutChange( + View v, int l, int t, int r, int b, int ol, int ot, int or, int ob) { + coordinatorLayout.removeOnLayoutChangeListener(this); + updateAppbarLayout(); + } + }); + + if (offset != 0) { + appBarLayout.post(this::expandToolbar); + } + + adapter.setHasStableIds(true); + ((DefaultItemAnimator) recentLocations.getItemAnimator()).setSupportsChangeAnimations(false); + recentLocations.setLayoutManager(new LinearLayoutManager(this)); + recentLocations.setAdapter(adapter); + } + + private void initGoogleMaps() { + FragmentManager supportFragmentManager = getSupportFragmentManager(); + SupportMapFragment mapFragment = + (SupportMapFragment) supportFragmentManager.findFragmentByTag(FRAG_TAG_MAP); + if (mapFragment == null) { + mapFragment = new SupportMapFragment(); + supportFragmentManager.beginTransaction().replace(R.id.map, mapFragment).commit(); + } + new GoogleMapFragment(context, mapFragment, this, theme.getThemeBase().isDarkTheme(this)); + } + + private void initMapboxMaps() { + Mapbox.getInstance(this, getString(R.string.mapbox_key)); + + FragmentManager supportFragmentManager = getSupportFragmentManager(); + com.mapbox.mapboxsdk.maps.SupportMapFragment mapFragment = + (com.mapbox.mapboxsdk.maps.SupportMapFragment) + supportFragmentManager.findFragmentByTag(FRAG_TAG_MAP); + if (mapFragment == null) { + mapFragment = new com.mapbox.mapboxsdk.maps.SupportMapFragment(); + supportFragmentManager.beginTransaction().replace(R.id.map, mapFragment).commit(); + } + new MapboxMapFragment(context, mapFragment, this, theme.getThemeBase().isDarkTheme(this)); + } + + @Override + public void onMapReady(MapFragment mapFragment) { + map = mapFragment; + if (mapPosition != null) { + map.movePosition(mapPosition, false); + } else { + moveToCurrentLocation(false); + } + updateMarkers(); + } + + @Override + public void onPlaceSelected(org.tasks.data.Place place) { + returnPlace(place); + } + + @OnClick(R.id.current_location) + void onClick() { + moveToCurrentLocation(true); + } + + @OnClick(R.id.select_this_location) + void selectLocation() { + loadingIndicator.setVisibility(View.VISIBLE); + + MapPosition mapPosition = map.getMapPosition(); + disposables.add( + Single.fromCallable( + () -> { + Geocoder geocoder = new Geocoder(this); + return geocoder.getFromLocation( + mapPosition.getLatitude(), mapPosition.getLongitude(), 1); + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnError(e -> toaster.longToast(e.getMessage())) + .doFinally(() -> loadingIndicator.setVisibility(View.GONE)) + .subscribe( + addresses -> { + org.tasks.data.Place place = newPlace(); + if (addresses.isEmpty()) { + place.setLatitude(mapPosition.getLatitude()); + place.setLongitude(mapPosition.getLongitude()); + } else { + Address address = addresses.get(0); + place.setLatitude(address.getLatitude()); + place.setLongitude(address.getLongitude()); + StringBuilder stringBuilder = new StringBuilder(); + for (int i = 0; i <= address.getMaxAddressLineIndex(); i++) { + stringBuilder.append(address.getAddressLine(i)).append("\n"); + } + place.setPhone(address.getPhone()); + place.setAddress(stringBuilder.toString().trim()); + String url = address.getUrl(); + if (!Strings.isNullOrEmpty(url)) { + place.setUrl(url); + } + } + place.setName(formatCoordinates(place)); + returnPlace(place); + })); + } + + @OnClick(R.id.search) + void searchPlace() { + if (preferences.useGooglePlaces() && inventory.hasPro()) { + if (!Places.isInitialized()) { + Places.initialize(this, getString(R.string.google_key)); + } + + startActivityForResult( + new Autocomplete.IntentBuilder( + AutocompleteActivityMode.FULLSCREEN, + asList( + Field.ID, + Field.LAT_LNG, + Field.ADDRESS, + Field.WEBSITE_URI, + Field.NAME, + Field.PHONE_NUMBER)) + .build(this), + REQUEST_GOOGLE_AUTOCOMPLETE); + } else { + String token = getString(R.string.mapbox_key); + Mapbox.getInstance(this, token); + MapPosition mapPosition = map.getMapPosition(); + startActivityForResult( + new PlaceAutocomplete.IntentBuilder() + .accessToken(token) + .placeOptions( + PlaceOptions.builder() + .backgroundColor(getResources().getColor(R.color.white_100)) + .proximity( + Point.fromLngLat(mapPosition.getLongitude(), mapPosition.getLatitude())) + .build()) + .build(this), + REQUEST_MAPBOX_AUTOCOMPLETE); + } + } + + @SuppressLint("MissingPermission") + private void moveToCurrentLocation(boolean animate) { + fusedLocationProviderClient + .getLastLocation() + .addOnSuccessListener( + location -> { + if (location != null) { + map.movePosition( + new MapPosition(location.getLatitude(), location.getLongitude(), 15f), animate); + } + }); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + if (requestCode == REQUEST_GOOGLE_AUTOCOMPLETE) { + if (resultCode == Activity.RESULT_OK && data != null) { + returnPlace(Autocomplete.getPlaceFromIntent(data)); + } else if (resultCode == AutocompleteActivity.RESULT_ERROR && data != null) { + Status status = Autocomplete.getStatusFromIntent(data); + toaster.longToast(status.getStatusMessage()); + } + } else if (requestCode == REQUEST_MAPBOX_AUTOCOMPLETE) { + if (resultCode == Activity.RESULT_OK && data != null) { + returnPlace(PlaceAutocomplete.getPlace(data)); + } + } else { + super.onActivityResult(requestCode, resultCode, data); + } + } + + private void returnPlace(CarmenFeature place) { + org.tasks.data.Place result = newPlace(); + result.setName(place.placeName()); + result.setAddress(place.address()); + result.setLatitude(place.center().latitude()); + result.setLongitude(place.center().longitude()); + returnPlace(result); + } + + private void returnPlace(Place place) { + LatLng latLng = place.getLatLng(); + org.tasks.data.Place result = newPlace(); + result.setName(place.getName()); + CharSequence address = place.getAddress(); + if (address != null) { + result.setAddress(place.getAddress()); + } + CharSequence phoneNumber = place.getPhoneNumber(); + if (phoneNumber != null) { + result.setPhone(phoneNumber.toString()); + } + Uri uri = place.getWebsiteUri(); + if (uri != null) { + result.setUrl(uri.toString()); + } + result.setLatitude(latLng.latitude); + result.setLongitude(latLng.longitude); + returnPlace(result); + } + + private void returnPlace(org.tasks.data.Place place) { + if (place == null) { + Timber.e("Place is null"); + return; + } + if (place.getId() <= 0) { + org.tasks.data.Place existing = + locationDao.findPlace(place.getLatitude(), place.getLongitude()); + if (existing == null) { + long placeId = locationDao.insert(place); + place.setId(placeId); + } else { + existing.apply(place); + locationDao.update(existing); + place = existing; + } + } + setResult(RESULT_OK, new Intent().putExtra(PlacePicker.EXTRA_PLACE, (Parcelable) place)); + finish(); + } + + @Override + protected void onResume() { + super.onResume(); + + disposables = new CompositeDisposable(playServices.checkMaps(this)); + + locationDao.getPlaceUsage().observe(this, this::updatePlaces); + } + + private void updatePlaces(List places) { + this.places = places; + updateMarkers(); + adapter.submitList(places); + updateAppbarLayout(); + if (places.isEmpty()) { + collapseToolbar(); + } + } + + private void updateMarkers() { + if (map != null) { + map.setMarkers(newArrayList(transform(places, PlaceUsage::getPlace))); + } + } + + private void updateAppbarLayout() { + CoordinatorLayout.LayoutParams params = + (CoordinatorLayout.LayoutParams) appBarLayout.getLayoutParams(); + + if (places.isEmpty()) { + params.height = coordinatorLayout.getHeight(); + chooseRecentLocation.setVisibility(View.GONE); + } else { + params.height = (coordinatorLayout.getHeight() * 75) / 100; + chooseRecentLocation.setVisibility(View.VISIBLE); + } + } + + private void collapseToolbar() { + appBarLayout.setExpanded(true, true); + } + + private void expandToolbar() { + appBarLayout.setExpanded(false, false); + } + + @Override + protected void onPause() { + super.onPause(); + + disposables.dispose(); + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + outState.putParcelable(EXTRA_MAP_POSITION, map.getMapPosition()); + outState.putInt(EXTRA_APPBAR_OFFSET, offset); + } + + @Override + public void inject(ActivityComponent component) { + component.inject(this); + } + + @Override + public boolean onMenuItemClick(MenuItem item) { + if (item.getItemId() == R.id.menu_search) { + searchPlace(); + return true; + } else { + return false; + } + } + + @Override + public void picked(org.tasks.data.Place place) { + returnPlace(place); + } + + @Override + public void delete(org.tasks.data.Place place) { + locationDao.delete(place); + } +} diff --git a/app/src/googleplay/java/org/tasks/location/LocationPickerAdapter.java b/app/src/googleplay/java/org/tasks/location/LocationPickerAdapter.java new file mode 100644 index 000000000..207e51f20 --- /dev/null +++ b/app/src/googleplay/java/org/tasks/location/LocationPickerAdapter.java @@ -0,0 +1,93 @@ +package org.tasks.location; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil.ItemCallback; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; +import com.google.common.base.Strings; +import org.tasks.R; +import org.tasks.data.Place; +import org.tasks.data.PlaceUsage; +import org.tasks.location.LocationPickerAdapter.PlaceViewHolder; + +public class LocationPickerAdapter extends ListAdapter { + + private final OnLocationPicked callback; + + LocationPickerAdapter(OnLocationPicked callback) { + super(new DiffCallback()); + + this.callback = callback; + } + + @Override + public long getItemId(int position) { + return getItem(position).place.getId(); + } + + @NonNull + @Override + public PlaceViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new PlaceViewHolder( + LayoutInflater.from(parent.getContext()).inflate(R.layout.row_place, parent, false), + callback); + } + + @Override + public void onBindViewHolder(@NonNull PlaceViewHolder holder, int position) { + holder.bind(getItem(position)); + } + + public interface OnLocationPicked { + void picked(Place place); + + void delete(Place place); + } + + public static class PlaceViewHolder extends RecyclerView.ViewHolder { + private final TextView name; + private final TextView address; + private final View delete; + private Place place; + + PlaceViewHolder(@NonNull View itemView, OnLocationPicked onLocationPicked) { + super(itemView); + itemView.setOnClickListener(v -> onLocationPicked.picked(place)); + name = itemView.findViewById(R.id.name); + address = itemView.findViewById(R.id.address); + delete = itemView.findViewById(R.id.delete); + delete.setOnClickListener(v -> onLocationPicked.delete(place)); + } + + public void bind(PlaceUsage placeUsage) { + this.place = placeUsage.place; + String name = place.getDisplayName(); + String address = place.getAddress(); + this.name.setText(name); + if (Strings.isNullOrEmpty(address) || address.equals(name)) { + this.address.setVisibility(View.GONE); + } else { + this.address.setText(address); + this.address.setVisibility(View.VISIBLE); + } + delete.setVisibility(placeUsage.count > 0 ? View.GONE : View.VISIBLE); + } + } + + public static class DiffCallback extends ItemCallback { + + @Override + public boolean areItemsTheSame(@NonNull PlaceUsage oldItem, @NonNull PlaceUsage newItem) { + return oldItem.place.getUid().equals(newItem.place.getUid()); + } + + @Override + public boolean areContentsTheSame(@NonNull PlaceUsage oldItem, @NonNull PlaceUsage newItem) { + return oldItem.equals(newItem); + } + } +} diff --git a/app/src/googleplay/java/org/tasks/location/MapboxMapFragment.java b/app/src/googleplay/java/org/tasks/location/MapboxMapFragment.java new file mode 100644 index 000000000..5144f78c1 --- /dev/null +++ b/app/src/googleplay/java/org/tasks/location/MapboxMapFragment.java @@ -0,0 +1,103 @@ +package org.tasks.location; + +import android.annotation.SuppressLint; +import android.content.Context; +import androidx.annotation.NonNull; +import com.mapbox.mapboxsdk.annotations.Marker; +import com.mapbox.mapboxsdk.annotations.MarkerOptions; +import com.mapbox.mapboxsdk.camera.CameraPosition; +import com.mapbox.mapboxsdk.camera.CameraUpdate; +import com.mapbox.mapboxsdk.camera.CameraUpdateFactory; +import com.mapbox.mapboxsdk.geometry.LatLng; +import com.mapbox.mapboxsdk.location.LocationComponent; +import com.mapbox.mapboxsdk.location.modes.CameraMode; +import com.mapbox.mapboxsdk.location.modes.RenderMode; +import com.mapbox.mapboxsdk.maps.MapboxMap; +import com.mapbox.mapboxsdk.maps.MapboxMap.OnMarkerClickListener; +import com.mapbox.mapboxsdk.maps.OnMapReadyCallback; +import com.mapbox.mapboxsdk.maps.Style; +import com.mapbox.mapboxsdk.maps.SupportMapFragment; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.tasks.data.Place; + +public class MapboxMapFragment implements MapFragment, OnMapReadyCallback, OnMarkerClickListener { + + private final Context context; + private final MapFragmentCallback callbacks; + private final boolean dark; + private MapboxMap map; + private Map markers = new HashMap<>(); + + MapboxMapFragment( + Context context, SupportMapFragment fragment, MapFragmentCallback callbacks, boolean dark) { + this.context = context; + this.callbacks = callbacks; + this.dark = dark; + fragment.getMapAsync(this); + } + + @Override + public MapPosition getMapPosition() { + CameraPosition cameraPosition = map.getCameraPosition(); + LatLng target = cameraPosition.target; + return new MapPosition( + target.getLatitude(), target.getLongitude(), (float) cameraPosition.zoom); + } + + @Override + public void movePosition(MapPosition mapPosition, boolean animate) { + CameraUpdate cameraUpdate = + CameraUpdateFactory.newCameraPosition( + new CameraPosition.Builder() + .target(new LatLng(mapPosition.getLatitude(), mapPosition.getLongitude())) + .zoom(mapPosition.getZoom()) + .build()); + if (animate) { + map.animateCamera(cameraUpdate); + } else { + map.moveCamera(cameraUpdate); + } + } + + @Override + public void setMarkers(List places) { + for (Marker marker : map.getMarkers()) { + map.removeMarker(marker); + } + markers.clear(); + for (Place place : places) { + Marker marker = + map.addMarker( + new MarkerOptions() + .setPosition(new LatLng(place.getLatitude(), place.getLongitude()))); + markers.put(marker, place); + } + } + + @SuppressLint("MissingPermission") + @Override + public void onMapReady(@NonNull MapboxMap mapboxMap) { + map = mapboxMap; + map.getUiSettings().setRotateGesturesEnabled(false); + map.setStyle( + dark ? Style.DARK : Style.MAPBOX_STREETS, + style -> { + LocationComponent locationComponent = map.getLocationComponent(); + locationComponent.activateLocationComponent(context, style); + locationComponent.setLocationComponentEnabled(true); + locationComponent.setCameraMode(CameraMode.NONE); + locationComponent.setRenderMode(RenderMode.NORMAL); + }); + map.setOnMarkerClickListener(this); + callbacks.onMapReady(this); + } + + @Override + public boolean onMarkerClick(@NonNull Marker marker) { + Place place = markers.get(marker); + callbacks.onPlaceSelected(place); + return false; + } +} diff --git a/app/src/googleplay/java/org/tasks/location/PlacePicker.java b/app/src/googleplay/java/org/tasks/location/PlacePicker.java index db4c3dc46..3d72a8547 100644 --- a/app/src/googleplay/java/org/tasks/location/PlacePicker.java +++ b/app/src/googleplay/java/org/tasks/location/PlacePicker.java @@ -1,15 +1,7 @@ package org.tasks.location; import android.app.Activity; -import android.content.Context; import android.content.Intent; -import android.net.Uri; -import android.widget.Toast; -import com.google.android.gms.common.GooglePlayServicesNotAvailableException; -import com.google.android.gms.common.GooglePlayServicesRepairableException; -import com.google.android.gms.location.places.Place; -import com.google.android.gms.maps.model.LatLng; -import com.todoroo.astrid.helper.UUIDHelper; import org.tasks.R; import org.tasks.data.Geofence; import org.tasks.data.Location; @@ -18,26 +10,15 @@ import timber.log.Timber; public class PlacePicker { + static final String EXTRA_PLACE = "extra_place"; + public static Intent getIntent(Activity activity) { - com.google.android.gms.location.places.ui.PlacePicker.IntentBuilder builder = - new com.google.android.gms.location.places.ui.PlacePicker.IntentBuilder(); - try { - return builder.build(activity); - } catch (GooglePlayServicesRepairableException e) { - Timber.e(e); - activity.startActivity(e.getIntent()); - } catch (GooglePlayServicesNotAvailableException e) { - Timber.e(e); - Toast.makeText( - activity, R.string.common_google_play_services_notification_ticker, Toast.LENGTH_LONG) - .show(); - } - return null; + return new Intent(activity, LocationPicker.class); } - public static Location getPlace(Context context, Intent data, Preferences preferences) { - Place place = com.google.android.gms.location.places.ui.PlacePicker.getPlace(context, data); - LatLng latLng = place.getLatLng(); + public static Location getPlace(Intent data, Preferences preferences) { + org.tasks.data.Place result = data.getParcelableExtra(EXTRA_PLACE); + Geofence g = new Geofence(); g.setRadius(preferences.getInt(R.string.p_default_location_radius, 250)); int defaultReminders = @@ -45,25 +26,7 @@ public class PlacePicker { g.setArrival(defaultReminders == 1 || defaultReminders == 3); g.setDeparture(defaultReminders == 2 || defaultReminders == 3); - org.tasks.data.Place p = new org.tasks.data.Place(); - p.setUid(UUIDHelper.newUUID()); - p.setName(place.getName().toString()); - CharSequence address = place.getAddress(); - if (address != null) { - p.setAddress(place.getAddress().toString()); - } - CharSequence phoneNumber = place.getPhoneNumber(); - if (phoneNumber != null) { - p.setPhone(phoneNumber.toString()); - } - Uri uri = place.getWebsiteUri(); - if (uri != null) { - p.setUrl(uri.toString()); - } - p.setLatitude(latLng.latitude); - p.setLongitude(latLng.longitude); - - Location location = new Location(g, p); + Location location = new Location(g, result); Timber.i("Picked %s", location); return location; } diff --git a/app/src/googleplay/res/drawable/ic_map_marker_select_red_48dp.xml b/app/src/googleplay/res/drawable/ic_map_marker_select_red_48dp.xml new file mode 100644 index 000000000..339fce0a4 --- /dev/null +++ b/app/src/googleplay/res/drawable/ic_map_marker_select_red_48dp.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/googleplay/res/layout/activity_location_picker.xml b/app/src/googleplay/res/layout/activity_location_picker.xml new file mode 100644 index 000000000..a2528f094 --- /dev/null +++ b/app/src/googleplay/res/layout/activity_location_picker.xml @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/googleplay/res/layout/row_place.xml b/app/src/googleplay/res/layout/row_place.xml new file mode 100644 index 000000000..145d4bce0 --- /dev/null +++ b/app/src/googleplay/res/layout/row_place.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/googleplay/res/raw/mapstyle_night.json b/app/src/googleplay/res/raw/mapstyle_night.json new file mode 100644 index 000000000..d76ded1d8 --- /dev/null +++ b/app/src/googleplay/res/raw/mapstyle_night.json @@ -0,0 +1,193 @@ +// https://github.com/googlemaps/android-samples/blob/816307c077060b30a27adc125071d0debd478a98/ApiDemos/java/app/src/main/res/raw/mapstyle_night.json + +[ + { + "featureType": "all", + "elementType": "geometry", + "stylers": [ + { + "color": "#242f3e" + } + ] + }, + { + "featureType": "all", + "elementType": "labels.text.stroke", + "stylers": [ + { + "lightness": -80 + } + ] + }, + { + "featureType": "administrative", + "elementType": "labels.text.fill", + "stylers": [ + { + "color": "#746855" + } + ] + }, + { + "featureType": "administrative.locality", + "elementType": "labels.text.fill", + "stylers": [ + { + "color": "#d59563" + } + ] + }, + { + "featureType": "poi", + "elementType": "labels.text.fill", + "stylers": [ + { + "color": "#d59563" + } + ] + }, + { + "featureType": "poi.park", + "elementType": "geometry", + "stylers": [ + { + "color": "#263c3f" + } + ] + }, + { + "featureType": "poi.park", + "elementType": "labels.text.fill", + "stylers": [ + { + "color": "#6b9a76" + } + ] + }, + { + "featureType": "road", + "elementType": "geometry.fill", + "stylers": [ + { + "color": "#2b3544" + } + ] + }, + { + "featureType": "road", + "elementType": "labels.text.fill", + "stylers": [ + { + "color": "#9ca5b3" + } + ] + }, + { + "featureType": "road.arterial", + "elementType": "geometry.fill", + "stylers": [ + { + "color": "#38414e" + } + ] + }, + { + "featureType": "road.arterial", + "elementType": "geometry.stroke", + "stylers": [ + { + "color": "#212a37" + } + ] + }, + { + "featureType": "road.highway", + "elementType": "geometry.fill", + "stylers": [ + { + "color": "#746855" + } + ] + }, + { + "featureType": "road.highway", + "elementType": "geometry.stroke", + "stylers": [ + { + "color": "#1f2835" + } + ] + }, + { + "featureType": "road.highway", + "elementType": "labels.text.fill", + "stylers": [ + { + "color": "#f3d19c" + } + ] + }, + { + "featureType": "road.local", + "elementType": "geometry.fill", + "stylers": [ + { + "color": "#38414e" + } + ] + }, + { + "featureType": "road.local", + "elementType": "geometry.stroke", + "stylers": [ + { + "color": "#212a37" + } + ] + }, + { + "featureType": "transit", + "elementType": "geometry", + "stylers": [ + { + "color": "#2f3948" + } + ] + }, + { + "featureType": "transit.station", + "elementType": "labels.text.fill", + "stylers": [ + { + "color": "#d59563" + } + ] + }, + { + "featureType": "water", + "elementType": "geometry", + "stylers": [ + { + "color": "#17263c" + } + ] + }, + { + "featureType": "water", + "elementType": "labels.text.fill", + "stylers": [ + { + "color": "#515c6d" + } + ] + }, + { + "featureType": "water", + "elementType": "labels.text.stroke", + "stylers": [ + { + "lightness": -20 + } + ] + } +] \ No newline at end of file diff --git a/app/src/main/java/com/todoroo/andlib/utility/AndroidUtilities.java b/app/src/main/java/com/todoroo/andlib/utility/AndroidUtilities.java index 955808833..b6f448a2d 100644 --- a/app/src/main/java/com/todoroo/andlib/utility/AndroidUtilities.java +++ b/app/src/main/java/com/todoroo/andlib/utility/AndroidUtilities.java @@ -9,8 +9,6 @@ package com.todoroo.andlib.utility; import android.app.Activity; import android.content.Context; import android.os.Build; -import android.os.Build.VERSION; -import android.os.Build.VERSION_CODES; import android.os.Looper; import android.text.InputType; import android.util.DisplayMetrics; @@ -160,10 +158,6 @@ public class AndroidUtilities { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M; } - public static boolean atLeastOreoMR1() { - return VERSION.SDK_INT >= VERSION_CODES.O_MR1; - } - public static boolean atLeastNougat() { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N; } diff --git a/app/src/main/java/com/todoroo/astrid/backup/TasksXmlImporter.java b/app/src/main/java/com/todoroo/astrid/backup/TasksXmlImporter.java index e04d706e1..b7b197cf3 100755 --- a/app/src/main/java/com/todoroo/astrid/backup/TasksXmlImporter.java +++ b/app/src/main/java/com/todoroo/astrid/backup/TasksXmlImporter.java @@ -6,6 +6,8 @@ package com.todoroo.astrid.backup; +import static org.tasks.data.Place.newPlace; + import android.app.Activity; import android.app.ProgressDialog; import android.content.res.Resources; @@ -247,8 +249,7 @@ public class TasksXmlImporter { alarm.setTime(xml.readLong("value")); alarmDao.insert(alarm); } else if ("geofence".equals(key)) { - Place place = new Place(); - place.setUid(UUIDHelper.newUUID()); + Place place = newPlace(); place.setName(xml.readString("value")); place.setLatitude(xml.readDouble("value2")); place.setLongitude(xml.readDouble("value3")); diff --git a/app/src/main/java/org/tasks/backup/TasksJsonImporter.java b/app/src/main/java/org/tasks/backup/TasksJsonImporter.java index 4954c4312..3421b28ae 100644 --- a/app/src/main/java/org/tasks/backup/TasksJsonImporter.java +++ b/app/src/main/java/org/tasks/backup/TasksJsonImporter.java @@ -1,6 +1,7 @@ package org.tasks.backup; import static org.tasks.backup.TasksJsonExporter.UTF_8; +import static org.tasks.data.Place.newPlace; import android.app.Activity; import android.app.ProgressDialog; @@ -193,8 +194,7 @@ public class TasksJsonImporter { googleTaskDao.insert(googleTask); } for (LegacyLocation location : backup.locations) { - Place place = new Place(); - place.setUid(UUIDHelper.newUUID()); + Place place = newPlace(); place.setLongitude(location.longitude); place.setLatitude(location.latitude); place.setName(location.name); diff --git a/app/src/main/java/org/tasks/data/Location.java b/app/src/main/java/org/tasks/data/Location.java index 83faa1af9..86c0ef988 100644 --- a/app/src/main/java/org/tasks/data/Location.java +++ b/app/src/main/java/org/tasks/data/Location.java @@ -3,6 +3,7 @@ package org.tasks.data; import android.os.Parcel; import android.os.Parcelable; import androidx.room.Embedded; +import androidx.room.Ignore; import java.io.Serializable; public class Location implements Serializable, Parcelable { @@ -25,11 +26,13 @@ public class Location implements Serializable, Parcelable { public Location() {} + @Ignore public Location(Geofence geofence, Place place) { this.geofence = geofence; this.place = place; } + @Ignore protected Location(Parcel in) { geofence = in.readParcelable(Geofence.class.getClassLoader()); place = in.readParcelable(Place.class.getClassLoader()); diff --git a/app/src/main/java/org/tasks/data/LocationDao.java b/app/src/main/java/org/tasks/data/LocationDao.java index e40d9552b..eab7ce3c0 100644 --- a/app/src/main/java/org/tasks/data/LocationDao.java +++ b/app/src/main/java/org/tasks/data/LocationDao.java @@ -1,10 +1,12 @@ package org.tasks.data; +import androidx.lifecycle.LiveData; import androidx.room.Dao; import androidx.room.Delete; import androidx.room.Insert; import androidx.room.OnConflictStrategy; import androidx.room.Query; +import androidx.room.Update; import io.reactivex.Single; import java.util.List; @@ -15,7 +17,8 @@ public interface LocationDao { "SELECT * FROM geofences INNER JOIN places ON geofences.place = places.uid WHERE geofence_id = :id LIMIT 1") Location getGeofence(Long id); - @Query("SELECT * FROM geofences INNER JOIN places ON geofences.place = places.uid WHERE task = :taskId ORDER BY name ASC") + @Query( + "SELECT * FROM geofences INNER JOIN places ON geofences.place = places.uid WHERE task = :taskId ORDER BY name ASC") List getGeofences(long taskId); @Query( @@ -32,12 +35,18 @@ public interface LocationDao { @Delete void delete(Geofence location); + @Delete + void delete(Place place); + @Insert void insert(Geofence location); @Insert(onConflict = OnConflictStrategy.IGNORE) long insert(Place place); + @Update + void update(Place place); + @Query("SELECT * FROM places WHERE uid = :uid LIMIT 1") Place getByUid(String uid); @@ -46,4 +55,11 @@ public interface LocationDao { @Query("SELECT * FROM places") List getPlaces(); + + @Query( + "SELECT places.*, IFNULL(COUNT(geofence_id),0) AS count FROM places LEFT OUTER JOIN geofences ON geofences.place = places.uid GROUP BY uid ORDER BY COUNT(geofence_id) DESC") + LiveData> getPlaceUsage(); + + @Query("SELECT * FROM places WHERE latitude = :latitude AND longitude = :longitude LIMIT 1") + Place findPlace(double latitude, double longitude); } diff --git a/app/src/main/java/org/tasks/data/Place.java b/app/src/main/java/org/tasks/data/Place.java index caceef674..46c064d9c 100644 --- a/app/src/main/java/org/tasks/data/Place.java +++ b/app/src/main/java/org/tasks/data/Place.java @@ -8,6 +8,7 @@ import androidx.room.Entity; import androidx.room.Ignore; import androidx.room.PrimaryKey; import com.google.common.base.Strings; +import com.todoroo.astrid.helper.UUIDHelper; import java.io.Serializable; import java.util.regex.Pattern; @@ -55,6 +56,12 @@ public class Place implements Serializable, Parcelable { @ColumnInfo(name = "longitude") private double longitude; + public static Place newPlace() { + Place place = new Place(); + place.setUid(UUIDHelper.newUUID()); + return place; + } + public Place() {} @Ignore @@ -145,6 +152,18 @@ public class Place implements Serializable, Parcelable { this.url = url; } + public void apply(Place place) { + if (Strings.isNullOrEmpty(address)) { + address = place.address; + } + if (Strings.isNullOrEmpty(phone)) { + phone = place.phone; + } + if (Strings.isNullOrEmpty(url)) { + url = place.url; + } + } + public String getDisplayName() { if (Strings.isNullOrEmpty(address)) { return name; diff --git a/app/src/main/java/org/tasks/data/PlaceUsage.java b/app/src/main/java/org/tasks/data/PlaceUsage.java new file mode 100644 index 000000000..0b368cb4e --- /dev/null +++ b/app/src/main/java/org/tasks/data/PlaceUsage.java @@ -0,0 +1,41 @@ +package org.tasks.data; + +import androidx.room.Embedded; + +public class PlaceUsage { + @Embedded public Place place; + public int count; + + public Place getPlace() { + return place; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + PlaceUsage that = (PlaceUsage) o; + + if (count != that.count) { + return false; + } + return place != null ? place.equals(that.place) : that.place == null; + } + + @Override + public int hashCode() { + int result = place != null ? place.hashCode() : 0; + result = 31 * result + count; + return result; + } + + @Override + public String toString() { + return "PlaceUsage{" + "place=" + place + ", count=" + count + '}'; + } +} diff --git a/app/src/main/java/org/tasks/injection/ActivityComponent.java b/app/src/main/java/org/tasks/injection/ActivityComponent.java index 2d7b9009e..4fa164d7f 100644 --- a/app/src/main/java/org/tasks/injection/ActivityComponent.java +++ b/app/src/main/java/org/tasks/injection/ActivityComponent.java @@ -11,6 +11,7 @@ import com.todoroo.astrid.gcal.CalendarReminderActivity; import com.todoroo.astrid.gtasks.auth.GtasksLoginActivity; import com.todoroo.astrid.reminders.ReminderPreferences; import dagger.Subcomponent; +import org.tasks.location.LocationPicker; import org.tasks.activities.CalendarSelectionActivity; import org.tasks.activities.CameraActivity; import org.tasks.activities.ColorPickerActivity; @@ -140,4 +141,6 @@ public interface ActivityComponent { void inject(TaskEditActivity taskEditActivity); void inject(WidgetClickActivity widgetActivity); + + void inject(LocationPicker locationPicker); } diff --git a/app/src/main/java/org/tasks/locale/Locale.java b/app/src/main/java/org/tasks/locale/Locale.java index f380eb001..e90cf8288 100644 --- a/app/src/main/java/org/tasks/locale/Locale.java +++ b/app/src/main/java/org/tasks/locale/Locale.java @@ -171,6 +171,10 @@ public class Locale { return NumberFormat.getNumberInstance(appLocale).format(number); } + public String formatNumber(double number) { + return NumberFormat.getNumberInstance(appLocale).format(number); + } + public Integer parseInteger(String number) { try { return NumberFormat.getNumberInstance(appLocale).parse(number).intValue(); diff --git a/app/src/main/java/org/tasks/location/GeofenceService.java b/app/src/main/java/org/tasks/location/GeofenceService.java index 530c79432..e490254b7 100644 --- a/app/src/main/java/org/tasks/location/GeofenceService.java +++ b/app/src/main/java/org/tasks/location/GeofenceService.java @@ -28,7 +28,7 @@ public class GeofenceService { } public void setupGeofences() { - geofenceApi.register(getActiveGeofences()); + geofenceApi.register(locationDao.getActiveGeofences()); } public void setupGeofences(long taskId) { @@ -81,8 +81,8 @@ public class GeofenceService { // everything that remains shall be written for (Location location : locations) { Place place = location.place; - locationDao.insert(place); Geofence geofence = location.geofence; + geofence.setTask(taskId); geofence.setPlace(place.getUid()); locationDao.insert(geofence); dirty = true; @@ -91,10 +91,6 @@ public class GeofenceService { return dirty; } - private List getActiveGeofences() { - return locationDao.getActiveGeofences(); - } - private List getGeofencesForTask(long taskId) { return locationDao.getActiveGeofences(taskId); } diff --git a/app/src/main/java/org/tasks/location/MapFragment.java b/app/src/main/java/org/tasks/location/MapFragment.java new file mode 100644 index 000000000..6d6609362 --- /dev/null +++ b/app/src/main/java/org/tasks/location/MapFragment.java @@ -0,0 +1,19 @@ +package org.tasks.location; + +import java.util.List; +import org.tasks.data.Place; + +public interface MapFragment { + + MapPosition getMapPosition(); + + void movePosition(MapPosition mapPosition, boolean animate); + + void setMarkers(List places); + + interface MapFragmentCallback { + void onMapReady(MapFragment mapFragment); + + void onPlaceSelected(Place place); + } +} diff --git a/app/src/main/java/org/tasks/location/MapPosition.java b/app/src/main/java/org/tasks/location/MapPosition.java new file mode 100644 index 000000000..9bf9531c6 --- /dev/null +++ b/app/src/main/java/org/tasks/location/MapPosition.java @@ -0,0 +1,59 @@ +package org.tasks.location; + +import android.os.Parcel; +import android.os.Parcelable; + +public class MapPosition implements Parcelable { + + public static final Creator CREATOR = + new Creator() { + @Override + public MapPosition createFromParcel(Parcel in) { + return new MapPosition(in); + } + + @Override + public MapPosition[] newArray(int size) { + return new MapPosition[size]; + } + }; + private final double latitude; + private final double longitude; + private final float zoom; + + public MapPosition(double latitude, double longitude, float zoom) { + this.latitude = latitude; + this.longitude = longitude; + this.zoom = zoom; + } + + protected MapPosition(Parcel in) { + latitude = in.readDouble(); + longitude = in.readDouble(); + zoom = in.readFloat(); + } + + public double getLatitude() { + return latitude; + } + + public double getLongitude() { + return longitude; + } + + public float getZoom() { + return zoom; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeDouble(latitude); + dest.writeDouble(longitude); + dest.writeFloat(zoom); + } +} diff --git a/app/src/main/java/org/tasks/preferences/BasicPreferences.java b/app/src/main/java/org/tasks/preferences/BasicPreferences.java index 6e51a6423..70c1a92f9 100644 --- a/app/src/main/java/org/tasks/preferences/BasicPreferences.java +++ b/app/src/main/java/org/tasks/preferences/BasicPreferences.java @@ -2,6 +2,8 @@ package org.tasks.preferences; import static com.todoroo.andlib.utility.AndroidUtilities.atLeastJellybeanMR1; import static com.todoroo.andlib.utility.AndroidUtilities.atLeastLollipop; +import static com.todoroo.andlib.utility.AndroidUtilities.preLollipop; +import static java.util.Arrays.asList; import static org.tasks.dialogs.ExportTasksDialog.newExportTasksDialog; import static org.tasks.dialogs.ImportTasksDialog.newImportTasksDialog; import static org.tasks.files.FileHelper.newFilePickerIntent; @@ -17,9 +19,11 @@ import android.net.Uri; import android.os.Bundle; import android.preference.CheckBoxPreference; import android.preference.Preference; +import android.preference.PreferenceScreen; import com.google.common.base.Strings; import com.todoroo.astrid.core.OldTaskPreferences; import com.todoroo.astrid.reminders.ReminderPreferences; +import java.util.List; import javax.inject.Inject; import org.tasks.BuildConfig; import org.tasks.R; @@ -43,6 +47,8 @@ import org.tasks.themes.ThemeAccent; import org.tasks.themes.ThemeBase; import org.tasks.themes.ThemeCache; import org.tasks.themes.ThemeColor; +import org.tasks.ui.SingleCheckedArrayAdapter; +import org.tasks.ui.Toaster; public class BasicPreferences extends InjectingPreferenceActivity implements LocalePickerDialog.LocaleSelectionHandler { @@ -70,6 +76,7 @@ public class BasicPreferences extends InjectingPreferenceActivity @Inject BillingClient billingClient; @Inject Inventory inventory; @Inject PlayServices playServices; + @Inject Toaster toaster; private Bundle result; @@ -209,11 +216,91 @@ public class BasicPreferences extends InjectingPreferenceActivity }); } - findPreference(R.string.refresh_purchases).setOnPreferenceClickListener( + List choices = + asList( + getString(R.string.map_provider_mapbox), + getString(R.string.map_provider_google)); + SingleCheckedArrayAdapter singleCheckedArrayAdapter = + new SingleCheckedArrayAdapter(this, choices, themeAccent); + Preference mapProviderPreference = findPreference(R.string.p_map_provider); + mapProviderPreference.setOnPreferenceClickListener( preference -> { - billingClient.queryPurchases(); + dialogBuilder + .newDialog() + .setSingleChoiceItems( + singleCheckedArrayAdapter, + getMapProvider(), + (dialog, which) -> { + if (which == 0) { + if (preLollipop()) { + toaster.longToast(R.string.requires_android_version, 5.0); + dialog.dismiss(); + return; + } + } else if (which == 1) { + if (!playServices.isPlayServicesAvailable()) { + toaster.longToast(R.string.requires_google_play_services); + dialog.dismiss(); + return; + } + } + preferences.setInt(R.string.p_map_provider, which); + mapProviderPreference.setSummary(choices.get(which)); + dialog.dismiss(); + }) + .setNegativeButton(android.R.string.cancel, null) + .showThemedListView(); return false; }); + int mapProvider = getMapProvider(); + mapProviderPreference.setSummary( + mapProvider == -1 ? getString(R.string.none) : choices.get(mapProvider)); + + Preference placeProviderPreference = findPreference(R.string.p_place_provider); + placeProviderPreference.setOnPreferenceClickListener( + preference -> { + dialogBuilder + .newDialog() + .setSingleChoiceItems( + singleCheckedArrayAdapter, + getPlaceProvider(), + (dialog, which) -> { + if (which == 0) { + if (preLollipop()) { + toaster.longToast(R.string.requires_android_version, 5.0); + dialog.dismiss(); + return; + } + } else if (which == 1) { + if (!playServices.isPlayServicesAvailable()) { + toaster.longToast(R.string.requires_google_play_services); + dialog.dismiss(); + return; + } + if (!inventory.hasPro()) { + toaster.longToast(R.string.requires_pro_subscription); + dialog.dismiss(); + return; + } + } + preferences.setInt(R.string.p_place_provider, which); + placeProviderPreference.setSummary(choices.get(which)); + dialog.dismiss(); + }) + .setNegativeButton(android.R.string.cancel, null) + .showThemedListView(); + return false; + }); + int placeProvider = getPlaceProvider(); + placeProviderPreference.setSummary( + placeProvider == -1 ? getString(R.string.none) : choices.get(placeProvider)); + + findPreference(R.string.refresh_purchases) + .setOnPreferenceClickListener( + preference -> { + billingClient.queryPurchases(); + return false; + }); requires( R.string.settings_localization, @@ -233,6 +320,28 @@ public class BasicPreferences extends InjectingPreferenceActivity R.string.upgrade_to_pro, R.string.refresh_purchases); requires(R.string.privacy, false, R.string.p_collect_statistics); + ((PreferenceScreen) findPreference(getString(R.string.preference_screen))) + .removePreference(findPreference(getString(R.string.TEA_control_location))); + } + } + + private int getPlaceProvider() { + if (playServices.isPlayServicesAvailable()) { + if (preLollipop()) { + return inventory.hasPro() ? 1 : -1; + } else { + return inventory.hasPro() ? preferences.getInt(R.string.p_place_provider, 0) : 0; + } + } else { + return atLeastLollipop() ? 0 : -1; + } + } + + private int getMapProvider() { + if (playServices.isPlayServicesAvailable()) { + return preLollipop() ? 1 : preferences.getInt(R.string.p_map_provider, 0); + } else { + return preLollipop() ? -1 : 0; } } diff --git a/app/src/main/java/org/tasks/preferences/Preferences.java b/app/src/main/java/org/tasks/preferences/Preferences.java index 1f818788b..dde39f3de 100644 --- a/app/src/main/java/org/tasks/preferences/Preferences.java +++ b/app/src/main/java/org/tasks/preferences/Preferences.java @@ -4,6 +4,7 @@ import static android.content.SharedPreferences.Editor; import static com.google.common.collect.Iterables.transform; import static com.google.common.collect.Sets.newHashSet; import static com.todoroo.andlib.utility.AndroidUtilities.atLeastKitKat; +import static com.todoroo.andlib.utility.AndroidUtilities.preLollipop; import static java.util.Collections.emptySet; import android.content.ContentResolver; @@ -479,4 +480,12 @@ public class Preferences { public void setSyncOngoing(boolean value) { setBoolean(R.string.p_sync_ongoing, value); } + + public boolean useGoogleMaps() { + return preLollipop() || getInt(R.string.p_map_provider, 0) == 1; + } + + public boolean useGooglePlaces() { + return preLollipop() || getInt(R.string.p_place_provider, 0) == 1; + } } diff --git a/app/src/main/java/org/tasks/themes/ThemeColor.java b/app/src/main/java/org/tasks/themes/ThemeColor.java index def9bfe40..cd10cece6 100644 --- a/app/src/main/java/org/tasks/themes/ThemeColor.java +++ b/app/src/main/java/org/tasks/themes/ThemeColor.java @@ -141,11 +141,7 @@ public class ThemeColor implements ColorPickerDialog.Pickable { public void applyToSystemBars(Activity activity) { setStatusBarColor(activity); - if (atLeastMarshmallow()) { - View decorView = activity.getWindow().getDecorView(); - int systemUiVisibility = applyLightStatusBarFlag(decorView.getSystemUiVisibility()); - decorView.setSystemUiVisibility(systemUiVisibility); - } + applyToStatusBarIcons(activity); applyToNavigationBar(activity); } @@ -166,6 +162,14 @@ public class ThemeColor implements ColorPickerDialog.Pickable { } } + public void applyToStatusBarIcons(Activity activity) { + if (atLeastMarshmallow()) { + View decorView = activity.getWindow().getDecorView(); + int systemUiVisibility = applyLightStatusBarFlag(decorView.getSystemUiVisibility()); + decorView.setSystemUiVisibility(systemUiVisibility); + } + } + public void applyToNavigationBar(Activity activity) { if (atLeastLollipop()) { activity.getWindow().setNavigationBarColor(getPrimaryColor()); diff --git a/app/src/main/java/org/tasks/ui/LocationControlSet.java b/app/src/main/java/org/tasks/ui/LocationControlSet.java index 225bb3968..e092a7b27 100644 --- a/app/src/main/java/org/tasks/ui/LocationControlSet.java +++ b/app/src/main/java/org/tasks/ui/LocationControlSet.java @@ -6,7 +6,6 @@ import static org.tasks.PermissionUtil.verifyPermissions; import static org.tasks.dialogs.LocationDialog.newLocationDialog; import android.app.Activity; -import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Bundle; @@ -36,7 +35,6 @@ import org.tasks.R; import org.tasks.data.Location; import org.tasks.dialogs.DialogBuilder; import org.tasks.dialogs.LocationDialog; -import org.tasks.injection.ForActivity; import org.tasks.injection.FragmentComponent; import org.tasks.location.GeofenceService; import org.tasks.location.PlacePicker; @@ -59,7 +57,6 @@ public class LocationControlSet extends TaskEditControlFragment { @Inject GeofenceService geofenceService; @Inject FragmentPermissionRequestor permissionRequestor; @Inject Preferences preferences; - @Inject @ForActivity Context context; @Inject DialogBuilder dialogBuilder; @BindView(R.id.alert_container) @@ -149,8 +146,8 @@ public class LocationControlSet extends TaskEditControlFragment { public void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode == REQUEST_LOCATION_REMINDER) { if (resultCode == Activity.RESULT_OK) { - Location place = PlacePicker.getPlace(context, data, preferences); - locations.add(place); + locations.clear(); + locations.add(PlacePicker.getPlace(data, preferences)); setup(locations); } } else if (requestCode == REQUEST_LOCATION_DETAILS) { diff --git a/app/src/main/java/org/tasks/ui/Toaster.java b/app/src/main/java/org/tasks/ui/Toaster.java index 03bb2d893..9fbffc965 100644 --- a/app/src/main/java/org/tasks/ui/Toaster.java +++ b/app/src/main/java/org/tasks/ui/Toaster.java @@ -20,15 +20,19 @@ public class Toaster { this.locale = locale; } - public void longToast(@StringRes int resId) { - longToast(context.getString(resId)); + public void longToast(@StringRes int resId, int number) { + longToast(context.getString(resId, locale.formatNumber(number))); } - public void longToast(@StringRes int resId, int number) { + public void longToast(@StringRes int resId, double number) { longToast(context.getString(resId, locale.formatNumber(number))); } - private void longToast(String text) { + public void longToast(@StringRes int resId) { + longToast(context.getString(resId)); + } + + public void longToast(String text) { Toast.makeText(context, text, LENGTH_LONG).show(); } diff --git a/app/src/main/res/drawable/ic_outline_gps_fixed_24px.xml b/app/src/main/res/drawable/ic_outline_gps_fixed_24px.xml new file mode 100644 index 000000000..3c5842651 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_gps_fixed_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/menu/menu_location_picker.xml b/app/src/main/res/menu/menu_location_picker.xml new file mode 100644 index 000000000..97aa1f177 --- /dev/null +++ b/app/src/main/res/menu/menu_location_picker.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 6b3a32fde..89992c3f1 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -16,6 +16,7 @@ 0.38 8dp + 4dp 2dp 3dp 0dp diff --git a/app/src/main/res/values/keys.xml b/app/src/main/res/values/keys.xml index 2727b0737..728480958 100644 --- a/app/src/main/res/values/keys.xml +++ b/app/src/main/res/values/keys.xml @@ -305,4 +305,10 @@ show_full_description linkify_task_list show_list_indicators + Mapbox + Google + map_provider + place_provider + preference_screen + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 08fb0c189..7040d1994 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -873,4 +873,11 @@ File %1$s contained %2$s.\n\n Arrived at %s Departed %s Generating notifications + Choose a recent location + Select this location + Or choose a recent location + Map provider + Search provider + Requires Android %s + Requires Google Play Services diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index d90e04588..efa7985f9 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -1,5 +1,6 @@ - + @@ -65,6 +66,22 @@ + + + + + + + +