Show location search in location picker activity

pull/795/head
Alex Baker 6 years ago
parent 54fcad1aae
commit 8cdbe66a19

@ -184,7 +184,8 @@ dependencies {
googleplayImplementation 'com.google.android.libraries.places:places:1.0.0' googleplayImplementation 'com.google.android.libraries.places:places:1.0.0'
googleplayImplementation 'com.google.android.gms:play-services-maps:16.1.0' googleplayImplementation 'com.google.android.gms:play-services-maps:16.1.0'
googleplayImplementation 'androidx.appcompat:appcompat:1.0.2' googleplayImplementation 'androidx.appcompat:appcompat:1.0.2'
googleplayImplementation 'com.mapbox.mapboxsdk:mapbox-android-plugin-places-v7:0.7.0' googleplayImplementation 'com.mapbox.mapboxsdk:mapbox-android-sdk:7.3.0'
googleplayImplementation 'com.mapbox.mapboxsdk:mapbox-sdk-services:4.5.0'
amazonImplementation "com.google.android.gms:play-services-analytics:16.0.7" amazonImplementation "com.google.android.gms:play-services-analytics:16.0.7"

@ -28,8 +28,7 @@
android:name="com.google.android.geo.API_KEY" android:name="com.google.android.geo.API_KEY"
android:value="@string/google_key"/> android:value="@string/google_key"/>
<activity <activity android:name=".location.LocationPickerActivity"/>
android:name=".location.LocationPicker"/>
<meta-data <meta-data
android:name="firebase_crashlytics_collection_enabled" android:name="firebase_crashlytics_collection_enabled"

@ -0,0 +1,130 @@
package org.tasks.location;
import static java.util.Arrays.asList;
import static org.tasks.data.Place.newPlace;
import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import com.google.android.gms.maps.model.LatLng;
import com.google.android.gms.maps.model.LatLngBounds;
import com.google.android.libraries.places.api.Places;
import com.google.android.libraries.places.api.model.AutocompletePrediction;
import com.google.android.libraries.places.api.model.AutocompleteSessionToken;
import com.google.android.libraries.places.api.model.Place.Field;
import com.google.android.libraries.places.api.model.RectangularBounds;
import com.google.android.libraries.places.api.net.FetchPlaceRequest;
import com.google.android.libraries.places.api.net.FetchPlaceResponse;
import com.google.android.libraries.places.api.net.FindAutocompletePredictionsRequest;
import com.google.android.libraries.places.api.net.PlacesClient;
import java.util.ArrayList;
import java.util.List;
import org.tasks.Callback;
import org.tasks.R;
import org.tasks.data.Place;
public class GooglePlacesSearchProvider implements PlaceSearchProvider {
private static final String EXTRA_SESSION_TOKEN = "extra_session_token";
private final Context context;
private AutocompleteSessionToken token;
private PlacesClient placesClient;
public GooglePlacesSearchProvider(Context context) {
this.context = context;
}
@Override
public void restoreState(Bundle savedInstanceState) {
token = savedInstanceState.getParcelable(EXTRA_SESSION_TOKEN);
}
@Override
public void saveState(Bundle outState) {}
@Override
public void search(
String query,
MapPosition bias,
Callback<List<PlaceSearchResult>> onSuccess,
Callback<String> onError) {
if (!Places.isInitialized()) {
Places.initialize(context, context.getString(R.string.google_key));
}
if (placesClient == null) {
placesClient = Places.createClient(context);
}
if (token == null) {
token = AutocompleteSessionToken.newInstance();
}
placesClient
.findAutocompletePredictions(
FindAutocompletePredictionsRequest.builder()
.setSessionToken(token)
.setQuery(query)
.setLocationBias(
RectangularBounds.newInstance(
LatLngBounds.builder()
.include(new LatLng(bias.getLatitude(), bias.getLongitude()))
.build()))
.build())
.addOnSuccessListener(
response -> onSuccess.call(toSearchResults(response.getAutocompletePredictions())))
.addOnFailureListener(e -> onError.call(e.getMessage()));
}
@Override
public void fetch(
PlaceSearchResult placeSearchResult, Callback<Place> onSuccess, Callback<String> onError) {
placesClient
.fetchPlace(
FetchPlaceRequest.builder(
placeSearchResult.getId(),
asList(
Field.ID,
Field.LAT_LNG,
Field.ADDRESS,
Field.WEBSITE_URI,
Field.NAME,
Field.PHONE_NUMBER))
.setSessionToken(token)
.build())
.addOnSuccessListener(result -> onSuccess.call(toPlace(result)))
.addOnFailureListener(e -> onError.call(e.getMessage()));
}
private List<PlaceSearchResult> toSearchResults(List<AutocompletePrediction> predictions) {
List<PlaceSearchResult> results = new ArrayList<>();
for (AutocompletePrediction prediction : predictions) {
results.add(
new PlaceSearchResult(
prediction.getPlaceId(),
prediction.getPrimaryText(null).toString(),
prediction.getSecondaryText(null).toString()));
}
return results;
}
private Place toPlace(FetchPlaceResponse fetchPlaceResponse) {
com.google.android.libraries.places.api.model.Place place = fetchPlaceResponse.getPlace();
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());
}
LatLng latLng = place.getLatLng();
result.setLatitude(latLng.latitude);
result.setLongitude(latLng.longitude);
return result;
}
}

@ -3,64 +3,56 @@ package org.tasks.location;
import static com.google.common.collect.Lists.newArrayList; import static com.google.common.collect.Lists.newArrayList;
import static com.google.common.collect.Lists.transform; import static com.google.common.collect.Lists.transform;
import static com.todoroo.andlib.utility.AndroidUtilities.atLeastLollipop; import static com.todoroo.andlib.utility.AndroidUtilities.atLeastLollipop;
import static java.util.Arrays.asList; import static com.todoroo.andlib.utility.AndroidUtilities.hideKeyboard;
import static org.tasks.data.Place.newPlace; import static org.tasks.data.Place.newPlace;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.res.Configuration; import android.content.res.Configuration;
import android.location.Address; import android.location.Address;
import android.location.Geocoder; import android.location.Geocoder;
import android.location.Location; import android.location.Location;
import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.os.Parcelable; import android.os.Parcelable;
import android.view.Menu;
import android.view.MenuItem; import android.view.MenuItem;
import android.view.MenuItem.OnActionExpandListener;
import android.view.View; import android.view.View;
import android.view.View.OnLayoutChangeListener; import android.view.View.OnLayoutChangeListener;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.appcompat.widget.SearchView;
import androidx.appcompat.widget.SearchView.OnQueryTextListener;
import androidx.appcompat.widget.Toolbar; import androidx.appcompat.widget.Toolbar;
import androidx.appcompat.widget.Toolbar.OnMenuItemClickListener; import androidx.appcompat.widget.Toolbar.OnMenuItemClickListener;
import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.coordinatorlayout.widget.CoordinatorLayout;
import androidx.core.widget.ContentLoadingProgressBar; import androidx.core.widget.ContentLoadingProgressBar;
import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentManager;
import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import butterknife.BindView; import butterknife.BindView;
import butterknife.ButterKnife; import butterknife.ButterKnife;
import butterknife.OnClick; import butterknife.OnClick;
import com.google.android.gms.common.api.Status;
import com.google.android.gms.location.FusedLocationProviderClient; import com.google.android.gms.location.FusedLocationProviderClient;
import com.google.android.gms.location.LocationServices; import com.google.android.gms.location.LocationServices;
import com.google.android.gms.maps.SupportMapFragment; 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;
import com.google.android.material.appbar.AppBarLayout.Behavior; import com.google.android.material.appbar.AppBarLayout.Behavior;
import com.google.common.base.Strings; 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.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.Single;
import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable; import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.schedulers.Schedulers; import io.reactivex.schedulers.Schedulers;
import io.reactivex.subjects.PublishSubject;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import javax.inject.Inject; import javax.inject.Inject;
import org.tasks.Event;
import org.tasks.R; import org.tasks.R;
import org.tasks.billing.Inventory; import org.tasks.billing.Inventory;
import org.tasks.data.LocationDao; import org.tasks.data.LocationDao;
@ -70,6 +62,7 @@ import org.tasks.injection.ActivityComponent;
import org.tasks.injection.ForApplication; import org.tasks.injection.ForApplication;
import org.tasks.injection.InjectingAppCompatActivity; import org.tasks.injection.InjectingAppCompatActivity;
import org.tasks.location.LocationPickerAdapter.OnLocationPicked; import org.tasks.location.LocationPickerAdapter.OnLocationPicked;
import org.tasks.location.LocationSearchAdapter.OnPredictionPicked;
import org.tasks.location.MapFragment.MapFragmentCallback; import org.tasks.location.MapFragment.MapFragmentCallback;
import org.tasks.preferences.Preferences; import org.tasks.preferences.Preferences;
import org.tasks.themes.Theme; import org.tasks.themes.Theme;
@ -78,15 +71,19 @@ import org.tasks.ui.MenuColorizer;
import org.tasks.ui.Toaster; import org.tasks.ui.Toaster;
import timber.log.Timber; import timber.log.Timber;
public class LocationPicker extends InjectingAppCompatActivity public class LocationPickerActivity extends InjectingAppCompatActivity
implements OnMenuItemClickListener, MapFragmentCallback, OnLocationPicked { implements OnMenuItemClickListener,
MapFragmentCallback,
OnLocationPicked,
OnQueryTextListener,
OnPredictionPicked,
OnActionExpandListener {
private static final String EXTRA_MAP_POSITION = "extra_map_position"; private static final String EXTRA_MAP_POSITION = "extra_map_position";
private static final String EXTRA_APPBAR_OFFSET = "extra_appbar_offset"; private static final String EXTRA_APPBAR_OFFSET = "extra_appbar_offset";
private static final String FRAG_TAG_MAP = "frag_tag_map"; 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+)"); private static final Pattern pattern = Pattern.compile("(\\d+):(\\d+):(\\d+\\.\\d+)");
private static final int SEARCH_DEBOUNCE_TIMEOUT = 300;
@BindView(R.id.toolbar) @BindView(R.id.toolbar)
Toolbar toolbar; Toolbar toolbar;
@ -107,7 +104,7 @@ public class LocationPicker extends InjectingAppCompatActivity
View chooseRecentLocation; View chooseRecentLocation;
@BindView(R.id.recent_locations) @BindView(R.id.recent_locations)
RecyclerView recentLocations; RecyclerView recyclerView;
@Inject @ForApplication Context context; @Inject @ForApplication Context context;
@Inject Theme theme; @Inject Theme theme;
@ -116,14 +113,19 @@ public class LocationPicker extends InjectingAppCompatActivity
@Inject PlayServices playServices; @Inject PlayServices playServices;
@Inject Preferences preferences; @Inject Preferences preferences;
@Inject LocationDao locationDao; @Inject LocationDao locationDao;
@Inject PlaceSearchProvider searchProvider;
private MapFragment map; private MapFragment map;
private FusedLocationProviderClient fusedLocationProviderClient; private FusedLocationProviderClient fusedLocationProviderClient;
private CompositeDisposable disposables; private CompositeDisposable disposables;
private MapPosition mapPosition; private MapPosition mapPosition;
private LocationPickerAdapter adapter = new LocationPickerAdapter(this); private LocationPickerAdapter recentsAdapter = new LocationPickerAdapter(this);
private LocationSearchAdapter searchAdapter = new LocationSearchAdapter(this);
private List<PlaceUsage> places = Collections.emptyList(); private List<PlaceUsage> places = Collections.emptyList();
private int offset; private int offset;
private MenuItem search;
private PublishSubject<String> searchSubject = PublishSubject.create();
private PlaceSearchViewModel viewModel;
private static String formatCoordinates(org.tasks.data.Place place) { private static String formatCoordinates(org.tasks.data.Place place) {
return String.format( return String.format(
@ -157,6 +159,9 @@ public class LocationPicker extends InjectingAppCompatActivity
setContentView(R.layout.activity_location_picker); setContentView(R.layout.activity_location_picker);
ButterKnife.bind(this); ButterKnife.bind(this);
viewModel = ViewModelProviders.of(this).get(PlaceSearchViewModel.class);
viewModel.setSearchProvider(searchProvider);
Configuration configuration = getResources().getConfiguration(); Configuration configuration = getResources().getConfiguration();
if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
&& configuration.smallestScreenWidthDp < 480) { && configuration.smallestScreenWidthDp < 480) {
@ -166,12 +171,17 @@ public class LocationPicker extends InjectingAppCompatActivity
if (savedInstanceState != null) { if (savedInstanceState != null) {
mapPosition = savedInstanceState.getParcelable(EXTRA_MAP_POSITION); mapPosition = savedInstanceState.getParcelable(EXTRA_MAP_POSITION);
offset = savedInstanceState.getInt(EXTRA_APPBAR_OFFSET); offset = savedInstanceState.getInt(EXTRA_APPBAR_OFFSET);
viewModel.restoreState(savedInstanceState);
} }
toolbar.setNavigationIcon(R.drawable.ic_outline_arrow_back_24px); toolbar.setNavigationIcon(R.drawable.ic_outline_arrow_back_24px);
toolbar.setNavigationOnClickListener(v -> collapseToolbar()); toolbar.setNavigationOnClickListener(v -> collapseToolbar());
if (canSearch()) { if (canSearch()) {
toolbar.inflateMenu(R.menu.menu_location_picker); toolbar.inflateMenu(R.menu.menu_location_picker);
Menu menu = toolbar.getMenu();
search = menu.findItem(R.id.menu_search);
search.setOnActionExpandListener(this);
((SearchView) search.getActionView()).setOnQueryTextListener(this);
toolbar.setOnMenuItemClickListener(this); toolbar.setOnMenuItemClickListener(this);
} else { } else {
searchView.setVisibility(View.GONE); searchView.setVisibility(View.GONE);
@ -204,6 +214,10 @@ public class LocationPicker extends InjectingAppCompatActivity
appBarLayout.addOnOffsetChangedListener( appBarLayout.addOnOffsetChangedListener(
(appBarLayout, offset) -> { (appBarLayout, offset) -> {
if (offset == 0 && this.offset != 0) {
closeSearch();
hideKeyboard(this);
}
this.offset = offset; this.offset = offset;
toolbar.setAlpha(Math.abs(offset / (float) appBarLayout.getTotalScrollRange())); toolbar.setAlpha(Math.abs(offset / (float) appBarLayout.getTotalScrollRange()));
}); });
@ -219,13 +233,12 @@ public class LocationPicker extends InjectingAppCompatActivity
}); });
if (offset != 0) { if (offset != 0) {
appBarLayout.post(this::expandToolbar); appBarLayout.post(() -> expandToolbar(false));
} }
adapter.setHasStableIds(true); recentsAdapter.setHasStableIds(true);
((DefaultItemAnimator) recentLocations.getItemAnimator()).setSupportsChangeAnimations(false); recyclerView.setLayoutManager(new LinearLayoutManager(this));
recentLocations.setLayoutManager(new LinearLayoutManager(this)); recyclerView.setAdapter(search.isActionViewExpanded() ? searchAdapter : recentsAdapter);
recentLocations.setAdapter(adapter);
} }
private void initGoogleMaps() { private void initGoogleMaps() {
@ -264,6 +277,24 @@ public class LocationPicker extends InjectingAppCompatActivity
updateMarkers(); updateMarkers();
} }
@Override
public void onBackPressed() {
if (closeSearch()) {
return;
}
if (offset != 0) {
collapseToolbar();
return;
}
super.onBackPressed();
}
private boolean closeSearch() {
return search.isActionViewExpanded() && search.collapseActionView();
}
@Override @Override
public void onPlaceSelected(org.tasks.data.Place place) { public void onPlaceSelected(org.tasks.data.Place place) {
returnPlace(place); returnPlace(place);
@ -318,39 +349,9 @@ public class LocationPicker extends InjectingAppCompatActivity
@OnClick(R.id.search) @OnClick(R.id.search)
void searchPlace() { void searchPlace() {
if (preferences.useGooglePlaces() && inventory.hasPro()) { mapPosition = map.getMapPosition();
if (!Places.isInitialized()) { expandToolbar(true);
Places.initialize(this, getString(R.string.google_key)); search.expandActionView();
}
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") @SuppressLint("MissingPermission")
@ -366,54 +367,6 @@ public class LocationPicker extends InjectingAppCompatActivity
}); });
} }
@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) { private void returnPlace(org.tasks.data.Place place) {
if (place == null) { if (place == null) {
Timber.e("Place is null"); Timber.e("Place is null");
@ -431,6 +384,7 @@ public class LocationPicker extends InjectingAppCompatActivity
place = existing; place = existing;
} }
} }
hideKeyboard(this);
setResult(RESULT_OK, new Intent().putExtra(PlacePicker.EXTRA_PLACE, (Parcelable) place)); setResult(RESULT_OK, new Intent().putExtra(PlacePicker.EXTRA_PLACE, (Parcelable) place));
finish(); finish();
} }
@ -439,15 +393,30 @@ public class LocationPicker extends InjectingAppCompatActivity
protected void onResume() { protected void onResume() {
super.onResume(); super.onResume();
viewModel.observe(this, searchAdapter::submitList, this::returnPlace, this::handleError);
disposables = new CompositeDisposable(playServices.checkMaps(this)); disposables = new CompositeDisposable(playServices.checkMaps(this));
disposables.add(
searchSubject
.debounce(SEARCH_DEBOUNCE_TIMEOUT, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(query -> viewModel.query(query, mapPosition)));
locationDao.getPlaceUsage().observe(this, this::updatePlaces); locationDao.getPlaceUsage().observe(this, this::updatePlaces);
} }
private void handleError(Event<String> error) {
String message = error.getIfUnhandled();
if (!Strings.isNullOrEmpty(message)) {
toaster.longToast(message);
}
}
private void updatePlaces(List<PlaceUsage> places) { private void updatePlaces(List<PlaceUsage> places) {
this.places = places; this.places = places;
updateMarkers(); updateMarkers();
adapter.submitList(places); recentsAdapter.submitList(places);
updateAppbarLayout(); updateAppbarLayout();
if (places.isEmpty()) { if (places.isEmpty()) {
collapseToolbar(); collapseToolbar();
@ -477,8 +446,8 @@ public class LocationPicker extends InjectingAppCompatActivity
appBarLayout.setExpanded(true, true); appBarLayout.setExpanded(true, true);
} }
private void expandToolbar() { private void expandToolbar(boolean animate) {
appBarLayout.setExpanded(false, false); appBarLayout.setExpanded(false, animate);
} }
@Override @Override
@ -494,6 +463,7 @@ public class LocationPicker extends InjectingAppCompatActivity
outState.putParcelable(EXTRA_MAP_POSITION, map.getMapPosition()); outState.putParcelable(EXTRA_MAP_POSITION, map.getMapPosition());
outState.putInt(EXTRA_APPBAR_OFFSET, offset); outState.putInt(EXTRA_APPBAR_OFFSET, offset);
viewModel.saveState(outState);
} }
@Override @Override
@ -520,4 +490,36 @@ public class LocationPicker extends InjectingAppCompatActivity
public void delete(org.tasks.data.Place place) { public void delete(org.tasks.data.Place place) {
locationDao.delete(place); locationDao.delete(place);
} }
@Override
public boolean onQueryTextSubmit(String query) {
return false;
}
@Override
public boolean onQueryTextChange(String query) {
searchSubject.onNext(query);
return true;
}
@Override
public void picked(PlaceSearchResult prediction) {
viewModel.fetch(prediction);
}
@Override
public boolean onMenuItemActionExpand(MenuItem item) {
searchAdapter.submitList(Collections.emptyList());
recyclerView.setAdapter(searchAdapter);
return true;
}
@Override
public boolean onMenuItemActionCollapse(MenuItem item) {
recyclerView.setAdapter(recentsAdapter);
if (places.isEmpty()) {
collapseToolbar();
}
return true;
}
} }

@ -0,0 +1,83 @@
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.location.LocationSearchAdapter.SearchViewHolder;
public class LocationSearchAdapter extends ListAdapter<PlaceSearchResult, SearchViewHolder> {
private final OnPredictionPicked callback;
LocationSearchAdapter(OnPredictionPicked callback) {
super(new DiffCallback());
this.callback = callback;
}
@NonNull
@Override
public SearchViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new SearchViewHolder(
LayoutInflater.from(parent.getContext()).inflate(R.layout.row_place, parent, false),
callback);
}
@Override
public void onBindViewHolder(@NonNull SearchViewHolder holder, int position) {
holder.bind(getItem(position));
}
public interface OnPredictionPicked {
void picked(PlaceSearchResult prediction);
}
public static class SearchViewHolder extends RecyclerView.ViewHolder {
private final TextView name;
private final TextView address;
private PlaceSearchResult prediction;
SearchViewHolder(@NonNull View itemView, OnPredictionPicked onPredictionPicked) {
super(itemView);
itemView.setOnClickListener(v -> onPredictionPicked.picked(prediction));
name = itemView.findViewById(R.id.name);
address = itemView.findViewById(R.id.address);
itemView.findViewById(R.id.place_icon).setVisibility(View.INVISIBLE);
}
public void bind(PlaceSearchResult prediction) {
this.prediction = prediction;
CharSequence name = prediction.getName();
CharSequence address = prediction.getAddress();
this.name.setText(name);
if (address == null || Strings.isNullOrEmpty(address.toString()) || address.toString().equals(name.toString())) {
this.address.setVisibility(View.GONE);
} else {
this.address.setText(address);
this.address.setVisibility(View.VISIBLE);
}
}
}
public static class DiffCallback extends ItemCallback<PlaceSearchResult> {
@Override
public boolean areItemsTheSame(
@NonNull PlaceSearchResult oldItem, @NonNull PlaceSearchResult newItem) {
return oldItem.getId().equals(newItem.getId());
}
@Override
public boolean areContentsTheSame(
@NonNull PlaceSearchResult oldItem, @NonNull PlaceSearchResult newItem) {
return oldItem.equals(newItem);
}
}
}

@ -0,0 +1,103 @@
package org.tasks.location;
import static com.mapbox.api.geocoding.v5.GeocodingCriteria.TYPE_ADDRESS;
import static org.tasks.data.Place.newPlace;
import android.content.Context;
import android.os.Bundle;
import com.mapbox.api.geocoding.v5.MapboxGeocoding;
import com.mapbox.api.geocoding.v5.models.CarmenFeature;
import com.mapbox.api.geocoding.v5.models.GeocodingResponse;
import com.mapbox.geojson.Point;
import com.mapbox.mapboxsdk.Mapbox;
import java.util.ArrayList;
import java.util.List;
import org.tasks.Callback;
import org.tasks.R;
import org.tasks.data.Place;
import retrofit2.Call;
import retrofit2.Response;
public class MapboxSearchProvider implements PlaceSearchProvider {
private final Context context;
private MapboxGeocoding.Builder builder;
public MapboxSearchProvider(Context context) {
this.context = context;
}
@Override
public void restoreState(Bundle savedInstanceState) {}
@Override
public void saveState(Bundle outState) {}
@Override
public void search(
String query,
MapPosition bias,
Callback<List<PlaceSearchResult>> onSuccess,
Callback<String> onError) {
if (builder == null) {
String token = context.getString(R.string.mapbox_key);
Mapbox.getInstance(context, token);
builder =
MapboxGeocoding.builder()
.autocomplete(true)
.accessToken(token)
.proximity(Point.fromLngLat(bias.getLongitude(), bias.getLatitude()));
}
builder
.query(query)
.build()
.enqueueCall(
new retrofit2.Callback<GeocodingResponse>() {
@Override
public void onResponse(
Call<GeocodingResponse> call, Response<GeocodingResponse> response) {
List<PlaceSearchResult> results = new ArrayList<>();
results.clear();
for (CarmenFeature feature : response.body().features()) {
results.add(toSearchResult(feature));
}
onSuccess.call(results);
}
@Override
public void onFailure(Call<GeocodingResponse> call, Throwable t) {
onError.call(t.getMessage());
}
});
}
@Override
public void fetch(
PlaceSearchResult placeSearchResult, Callback<Place> onSuccess, Callback<String> onError) {
CarmenFeature carmenFeature = (CarmenFeature) placeSearchResult.getTag();
org.tasks.data.Place place = newPlace();
place.setName(placeSearchResult.getName());
place.setAddress(placeSearchResult.getAddress());
place.setLatitude(carmenFeature.center().latitude());
place.setLongitude(carmenFeature.center().longitude());
onSuccess.call(place);
}
private PlaceSearchResult toSearchResult(CarmenFeature feature) {
String name = getName(feature);
String address = feature.placeName();
String replace = String.format("%s, ", name);
if (address != null && address.startsWith(replace)) {
address = address.replace(replace, "");
}
return new PlaceSearchResult(feature.id(), name, address, feature);
}
private String getName(CarmenFeature feature) {
List<String> types = feature.placeType();
return types != null && types.contains(TYPE_ADDRESS)
? String.format("%s %s", feature.address(), feature.text())
: feature.text();
}
}

@ -13,7 +13,7 @@ public class PlacePicker {
static final String EXTRA_PLACE = "extra_place"; static final String EXTRA_PLACE = "extra_place";
public static Intent getIntent(Activity activity) { public static Intent getIntent(Activity activity) {
return new Intent(activity, LocationPicker.class); return new Intent(activity, LocationPickerActivity.class);
} }
public static Location getPlace(Intent data, Preferences preferences) { public static Location getPlace(Intent data, Preferences preferences) {

@ -0,0 +1,21 @@
package org.tasks.location;
import android.os.Bundle;
import java.util.List;
import org.tasks.Callback;
import org.tasks.data.Place;
public interface PlaceSearchProvider {
void restoreState(Bundle savedInstanceState);
void saveState(Bundle outState);
void search(
String query,
MapPosition bias,
Callback<List<PlaceSearchResult>> onSuccess,
Callback<String> onError);
void fetch(
PlaceSearchResult placeSearchResult, Callback<Place> onSuccess, Callback<String> onError);
}

@ -0,0 +1,85 @@
package org.tasks.location;
public class PlaceSearchResult {
private final String id;
private final String name;
private final String address;
private final Object tag;
PlaceSearchResult(String id, String name, String address) {
this(id, name, address, null);
}
PlaceSearchResult(String id, String name, String address, Object tag) {
this.id = id;
this.name = name;
this.address = address;
this.tag = tag;
}
public String getId() {
return id;
}
public String getName() {
return name;
}
public String getAddress() {
return address;
}
public Object getTag() {
return tag;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
PlaceSearchResult that = (PlaceSearchResult) o;
if (id != null ? !id.equals(that.id) : that.id != null) {
return false;
}
if (name != null ? !name.equals(that.name) : that.name != null) {
return false;
}
if (address != null ? !address.equals(that.address) : that.address != null) {
return false;
}
return tag != null ? tag.equals(that.tag) : that.tag == null;
}
@Override
public int hashCode() {
int result = id != null ? id.hashCode() : 0;
result = 31 * result + (name != null ? name.hashCode() : 0);
result = 31 * result + (address != null ? address.hashCode() : 0);
result = 31 * result + (tag != null ? tag.hashCode() : 0);
return result;
}
@Override
public String toString() {
return "PlaceSearchResult{"
+ "id='"
+ id
+ '\''
+ ", name='"
+ name
+ '\''
+ ", address='"
+ address
+ '\''
+ ", tag="
+ tag
+ '}';
}
}

@ -0,0 +1,62 @@
package org.tasks.location;
import static com.todoroo.andlib.utility.AndroidUtilities.assertMainThread;
import android.os.Bundle;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModel;
import com.google.common.base.Strings;
import java.util.Collections;
import java.util.List;
import org.tasks.Event;
import org.tasks.data.Place;
public class PlaceSearchViewModel extends ViewModel {
private PlaceSearchProvider searchProvider;
private MutableLiveData<List<PlaceSearchResult>> searchResults = new MutableLiveData<>();
private MutableLiveData<Event<String>> error = new MutableLiveData<>();
private MutableLiveData<Place> selection = new MutableLiveData<>();
void setSearchProvider(PlaceSearchProvider searchProvider) {
this.searchProvider = searchProvider;
}
void observe(
LifecycleOwner owner,
Observer<List<PlaceSearchResult>> onResults,
Observer<Place> onSelection,
Observer<Event<String>> onError) {
searchResults.observe(owner, onResults);
selection.observe(owner, onSelection);
error.observe(owner, onError);
}
void saveState(Bundle outState) {
searchProvider.saveState(outState);
}
void restoreState(Bundle savedInstanceState) {
searchProvider.restoreState(savedInstanceState);
}
public void query(String query, MapPosition bias) {
assertMainThread();
if (Strings.isNullOrEmpty(query)) {
searchResults.postValue(Collections.emptyList());
} else {
searchProvider.search(query, bias, searchResults::setValue, this::setError);
}
}
public void fetch(PlaceSearchResult result) {
searchProvider.fetch(result, selection::setValue, this::setError);
}
private void setError(String message) {
error.setValue(new Event<>(message));
}
}

@ -1,17 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingTop="16dp" android:paddingTop="16dp"
android:paddingBottom="16dp" android:paddingBottom="16dp"
android:background="?attr/selectableItemBackground" android:background="?attr/selectableItemBackground"
android:clickable="true" android:clickable="true"
android:focusable="true" android:focusable="true">
android:orientation="vertical">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView <ImageView
android:id="@+id/place_icon" android:id="@+id/place_icon"
@ -23,7 +18,7 @@
android:paddingEnd="@dimen/keyline_second" android:paddingEnd="@dimen/keyline_second"
android:paddingLeft="@dimen/keyline_first" android:paddingLeft="@dimen/keyline_first"
android:paddingRight="@dimen/keyline_second" android:paddingRight="@dimen/keyline_second"
android:src="@drawable/ic_outline_place_24px" android:src="@drawable/ic_outline_history_24px"
android:tint="@color/icon_tint"/> android:tint="@color/icon_tint"/>
<ImageView <ImageView
@ -36,6 +31,7 @@
android:paddingEnd="@dimen/keyline_first" android:paddingEnd="@dimen/keyline_first"
android:paddingLeft="@dimen/keyline_first" android:paddingLeft="@dimen/keyline_first"
android:paddingRight="@dimen/keyline_first" android:paddingRight="@dimen/keyline_first"
android:visibility="gone"
android:src="@drawable/ic_outline_delete_24px" android:src="@drawable/ic_outline_delete_24px"
android:tint="@color/icon_tint"/> android:tint="@color/icon_tint"/>
@ -44,8 +40,8 @@
android:layout_width="fill_parent" android:layout_width="fill_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_toEndOf="@id/place_icon" android:layout_toEndOf="@id/place_icon"
android:layout_toRightOf="@id/place_icon"
android:layout_toLeftOf="@id/delete" android:layout_toLeftOf="@id/delete"
android:layout_toRightOf="@id/place_icon"
android:layout_toStartOf="@id/delete" android:layout_toStartOf="@id/delete"
android:paddingStart="0dp" android:paddingStart="0dp"
android:paddingEnd="@dimen/keyline_first" android:paddingEnd="@dimen/keyline_first"
@ -58,19 +54,23 @@
android:textColor="?android:attr/textColorPrimary" android:textColor="?android:attr/textColorPrimary"
android:textSize="16sp"/> android:textSize="16sp"/>
</RelativeLayout>
<TextView <TextView
android:id="@+id/address" android:id="@+id/address"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingStart="@dimen/keyline_content_inset" android:layout_below="@+id/name"
android:layout_toEndOf="@id/place_icon"
android:layout_toLeftOf="@id/delete"
android:layout_toRightOf="@id/place_icon"
android:layout_toStartOf="@id/delete"
android:paddingStart="0dp"
android:paddingEnd="@dimen/keyline_first" android:paddingEnd="@dimen/keyline_first"
android:paddingLeft="@dimen/keyline_content_inset" android:paddingLeft="0dp"
android:paddingRight="@dimen/keyline_first" android:paddingRight="@dimen/keyline_first"
android:ellipsize="end" android:ellipsize="end"
android:maxLines="2" android:maxLines="2"
android:textColor="@color/text_secondary" android:textColor="@color/text_secondary"
android:visibility="gone"/> android:visibility="gone"/>
</LinearLayout>
</RelativeLayout>

@ -0,0 +1,5 @@
package org.tasks;
public interface Callback<T> {
void call(T item);
}

@ -0,0 +1,19 @@
package org.tasks;
public class Event<T> {
private final T value;
private boolean handled;
public Event(T value) {
this.value = value;
}
public T getIfUnhandled() {
if (handled) {
return null;
}
handled = true;
return value;
}
}

@ -11,7 +11,7 @@ import com.todoroo.astrid.gcal.CalendarReminderActivity;
import com.todoroo.astrid.gtasks.auth.GtasksLoginActivity; import com.todoroo.astrid.gtasks.auth.GtasksLoginActivity;
import com.todoroo.astrid.reminders.ReminderPreferences; import com.todoroo.astrid.reminders.ReminderPreferences;
import dagger.Subcomponent; import dagger.Subcomponent;
import org.tasks.location.LocationPicker; import org.tasks.location.LocationPickerActivity;
import org.tasks.activities.CalendarSelectionActivity; import org.tasks.activities.CalendarSelectionActivity;
import org.tasks.activities.CameraActivity; import org.tasks.activities.CameraActivity;
import org.tasks.activities.ColorPickerActivity; import org.tasks.activities.ColorPickerActivity;
@ -142,5 +142,5 @@ public interface ActivityComponent {
void inject(WidgetClickActivity widgetActivity); void inject(WidgetClickActivity widgetActivity);
void inject(LocationPicker locationPicker); void inject(LocationPickerActivity locationPickerActivity);
} }

@ -5,7 +5,12 @@ import android.content.Context;
import dagger.Module; import dagger.Module;
import dagger.Provides; import dagger.Provides;
import org.tasks.R; import org.tasks.R;
import org.tasks.billing.Inventory;
import org.tasks.fragments.TaskEditControlSetFragmentManager; import org.tasks.fragments.TaskEditControlSetFragmentManager;
import org.tasks.gtasks.PlayServices;
import org.tasks.location.GooglePlacesSearchProvider;
import org.tasks.location.MapboxSearchProvider;
import org.tasks.location.PlaceSearchProvider;
import org.tasks.preferences.Preferences; import org.tasks.preferences.Preferences;
import org.tasks.sync.SyncAdapters; import org.tasks.sync.SyncAdapters;
import org.tasks.themes.ThemeAccent; import org.tasks.themes.ThemeAccent;
@ -57,4 +62,15 @@ public class ActivityModule {
Preferences preferences, SyncAdapters syncAdapters) { Preferences preferences, SyncAdapters syncAdapters) {
return new TaskEditControlSetFragmentManager(activity, preferences, syncAdapters); return new TaskEditControlSetFragmentManager(activity, preferences, syncAdapters);
} }
@Provides
@ActivityScope
public PlaceSearchProvider getPlaceSearchProvider(
Preferences preferences, Inventory inventory, PlayServices playServices) {
return preferences.useGooglePlaces()
&& playServices.isPlayServicesAvailable()
&& inventory.hasPro()
? new GooglePlacesSearchProvider(activity)
: new MapboxSearchProvider(activity);
}
} }

@ -7,5 +7,6 @@
android:id="@+id/menu_search" android:id="@+id/menu_search"
android:icon="@drawable/ic_outline_search_24px" android:icon="@drawable/ic_outline_search_24px"
android:title="@string/TLA_menu_search" android:title="@string/TLA_menu_search"
app:showAsAction="always"/> app:actionViewClass="androidx.appcompat.widget.SearchView"
app:showAsAction="always|collapseActionView"/>
</menu> </menu>

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_search"
android:icon="@drawable/ic_outline_search_24px"
android:title="@string/TLA_menu_search"
app:actionViewClass="androidx.appcompat.widget.SearchView"
app:showAsAction="always|collapseActionView"/>
</menu>

@ -875,7 +875,7 @@ File %1$s contained %2$s.\n\n
<string name="building_notifications">Generating notifications</string> <string name="building_notifications">Generating notifications</string>
<string name="choose_a_location">Choose a recent location</string> <string name="choose_a_location">Choose a recent location</string>
<string name="pick_this_location">Select this location</string> <string name="pick_this_location">Select this location</string>
<string name="or_choose_a_location">Or choose a recent location</string> <string name="or_choose_a_location">Or choose a location</string>
<string name="map_provider">Map provider</string> <string name="map_provider">Map provider</string>
<string name="map_search_provider">Search provider</string> <string name="map_search_provider">Search provider</string>
<string name="requires_android_version">Requires Android %s</string> <string name="requires_android_version">Requires Android %s</string>

Loading…
Cancel
Save