diff --git a/build.gradle b/build.gradle
index 909175b67..7b8c56a04 100644
--- a/build.gradle
+++ b/build.gradle
@@ -100,6 +100,8 @@ dependencies {
exclude group: 'com.android.support', module: 'support-v4'
}
+ googleplayCompile group: 'com.google.android.gms', name: 'play-services-location', version: '7.0.0'
+
compile(group: 'com.google.apis', name: 'google-api-services-tasks', version: 'v1-rev33-1.18.0-rc') {
exclude group: 'org.apache.httpcomponents', module: 'httpclient'
}
diff --git a/src/debug/AndroidManifest.xml b/src/debug/AndroidManifest.xml
new file mode 100644
index 000000000..09dcf6b14
--- /dev/null
+++ b/src/debug/AndroidManifest.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/src/generic/java/org/tasks/dialogs/LocationPickerDialog.java b/src/generic/java/org/tasks/dialogs/LocationPickerDialog.java
new file mode 100644
index 000000000..fa3d83197
--- /dev/null
+++ b/src/generic/java/org/tasks/dialogs/LocationPickerDialog.java
@@ -0,0 +1,12 @@
+package org.tasks.dialogs;
+
+import android.support.v4.app.Fragment;
+
+import org.tasks.location.LocationApi;
+import org.tasks.location.OnLocationPickedHandler;
+
+public class LocationPickerDialog {
+ public static void pickLocation(LocationApi locationApi, Fragment fragment, OnLocationPickedHandler onLocationPickedHandler) {
+
+ }
+}
diff --git a/src/generic/java/org/tasks/location/GeofenceApi.java b/src/generic/java/org/tasks/location/GeofenceApi.java
new file mode 100644
index 000000000..7806f3101
--- /dev/null
+++ b/src/generic/java/org/tasks/location/GeofenceApi.java
@@ -0,0 +1,21 @@
+package org.tasks.location;
+
+import java.util.List;
+
+import javax.inject.Inject;
+
+public class GeofenceApi {
+
+ @Inject
+ public GeofenceApi() {
+
+ }
+
+ public void register(List activeGeofences) {
+
+ }
+
+ public void cancel(Geofence geofence) {
+
+ }
+}
diff --git a/src/generic/java/org/tasks/location/GeofenceTransitionsIntentService.java b/src/generic/java/org/tasks/location/GeofenceTransitionsIntentService.java
new file mode 100644
index 000000000..508a57af7
--- /dev/null
+++ b/src/generic/java/org/tasks/location/GeofenceTransitionsIntentService.java
@@ -0,0 +1,5 @@
+package org.tasks.location;
+
+public class GeofenceTransitionsIntentService {
+
+}
diff --git a/src/generic/java/org/tasks/location/LocationApi.java b/src/generic/java/org/tasks/location/LocationApi.java
new file mode 100644
index 000000000..50bd7783c
--- /dev/null
+++ b/src/generic/java/org/tasks/location/LocationApi.java
@@ -0,0 +1,11 @@
+package org.tasks.location;
+
+import javax.inject.Inject;
+
+public class LocationApi {
+
+ @Inject
+ public LocationApi() {
+
+ }
+}
diff --git a/src/googleplay/AndroidManifest.xml b/src/googleplay/AndroidManifest.xml
index bd4bbe0cb..7669ebd1c 100644
--- a/src/googleplay/AndroidManifest.xml
+++ b/src/googleplay/AndroidManifest.xml
@@ -1,5 +1,6 @@
@@ -16,12 +17,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/googleplay/java/org/tasks/dialogs/LocationPickerDialog.java b/src/googleplay/java/org/tasks/dialogs/LocationPickerDialog.java
new file mode 100644
index 000000000..653455563
--- /dev/null
+++ b/src/googleplay/java/org/tasks/dialogs/LocationPickerDialog.java
@@ -0,0 +1,112 @@
+package org.tasks.dialogs;
+
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+import android.support.v4.app.DialogFragment;
+import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentActivity;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.inputmethod.EditorInfo;
+import android.widget.AdapterView;
+import android.widget.EditText;
+import android.widget.ListView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.google.android.gms.common.api.ResultCallback;
+import com.google.android.gms.location.places.Place;
+import com.google.android.gms.location.places.PlaceBuffer;
+import com.google.android.gms.maps.model.LatLng;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.tasks.R;
+import org.tasks.location.Geofence;
+import org.tasks.location.LocationApi;
+import org.tasks.location.OnLocationPickedHandler;
+import org.tasks.location.PlaceAutocompleteAdapter;
+
+public class LocationPickerDialog extends DialogFragment {
+
+ private static final Logger log = LoggerFactory.getLogger(LocationPickerDialog.class);
+
+ private static final String FRAG_TAG_LOCATION_PICKER = "frag_tag_location_picker";
+ private LocationApi locationApi;
+ private FragmentActivity fragmentActivity;
+ private OnLocationPickedHandler onLocationPickedHandler;
+ private PlaceAutocompleteAdapter mAdapter;
+
+ public static void pickLocation(LocationApi locationApi, Fragment fragment, OnLocationPickedHandler onLocationPickedHandler) {
+ LocationPickerDialog locationPickerDialog = new LocationPickerDialog();
+ locationPickerDialog.initialize(locationApi, fragment.getActivity(), onLocationPickedHandler);
+ locationPickerDialog.show(fragment.getChildFragmentManager(), FRAG_TAG_LOCATION_PICKER);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ View layout = inflater.inflate(R.layout.location_picker_dialog, null);
+ EditText addressEntry = (EditText) layout.findViewById(R.id.address_entry);
+
+ addressEntry.setOnEditorActionListener(new TextView.OnEditorActionListener() {
+ @Override
+ public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
+ if (actionId == EditorInfo.IME_ACTION_SEARCH) {
+ CharSequence search = v.getText();
+ mAdapter.getAutocomplete(search);
+ return true;
+ }
+ return false;
+ }
+ });
+
+ mAdapter = new PlaceAutocompleteAdapter(locationApi, fragmentActivity, android.R.layout.simple_list_item_1);
+ ListView list = (ListView) layout.findViewById(R.id.list);
+ list.setAdapter(mAdapter);
+ list.setOnItemClickListener(mAutocompleteClickListener);
+
+ return layout;
+ }
+
+ public void initialize(LocationApi locationApi, FragmentActivity fragmentActivity, OnLocationPickedHandler onLocationPickedHandler) {
+ this.locationApi = locationApi;
+ this.fragmentActivity = fragmentActivity;
+ this.onLocationPickedHandler = onLocationPickedHandler;
+ }
+
+ private void error(String text) {
+ log.error(text);
+ Toast.makeText(fragmentActivity, text, Toast.LENGTH_LONG).show();
+ }
+
+ private AdapterView.OnItemClickListener mAutocompleteClickListener
+ = new AdapterView.OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView> parent, View view, int position, long id) {
+ final PlaceAutocompleteAdapter.PlaceAutocomplete item = mAdapter.getItem(position);
+ final String placeId = String.valueOf(item.placeId);
+ log.info("Autocomplete item selected: " + item.description);
+ locationApi.getPlaceDetails(placeId, mUpdatePlaceDetailsCallback);
+ }
+ };
+
+ private ResultCallback mUpdatePlaceDetailsCallback
+ = new ResultCallback() {
+ @Override
+ public void onResult(PlaceBuffer places) {
+ if (places.getStatus().isSuccess()) {
+ final Place place = places.get(0);
+ LatLng latLng = place.getLatLng();
+ Geofence geofence = new Geofence(place.getName().toString(), latLng.latitude, latLng.longitude, 50);
+ log.info("Picked {}", geofence);
+ onLocationPickedHandler.onLocationPicked(geofence);
+ dismiss();
+ } else {
+ error("Error looking up location details - " + places.getStatus().toString());
+ }
+ places.release();
+ }
+ };
+}
diff --git a/src/googleplay/java/org/tasks/location/GeofenceApi.java b/src/googleplay/java/org/tasks/location/GeofenceApi.java
new file mode 100644
index 000000000..c196a5c95
--- /dev/null
+++ b/src/googleplay/java/org/tasks/location/GeofenceApi.java
@@ -0,0 +1,118 @@
+package org.tasks.location;
+
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+
+import com.google.android.gms.common.ConnectionResult;
+import com.google.android.gms.common.api.GoogleApiClient;
+import com.google.android.gms.common.api.PendingResult;
+import com.google.android.gms.common.api.ResultCallback;
+import com.google.android.gms.common.api.Status;
+import com.google.android.gms.location.GeofencingRequest;
+import com.google.android.gms.location.LocationServices;
+import com.google.common.base.Function;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.tasks.injection.ForApplication;
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import javax.inject.Inject;
+
+import static com.google.android.gms.location.Geofence.NEVER_EXPIRE;
+import static com.google.common.collect.Iterables.transform;
+import static com.google.common.collect.Lists.newArrayList;
+import static java.util.Arrays.asList;
+
+public class GeofenceApi {
+
+ private static final Logger log = LoggerFactory.getLogger(GeofenceApi.class);
+
+ private Context context;
+ private GoogleApiClientProvider googleApiClientProvider;
+
+ @Inject
+ public GeofenceApi(@ForApplication Context context, GoogleApiClientProvider googleApiClientProvider) {
+ this.context = context;
+ this.googleApiClientProvider = googleApiClientProvider;
+ }
+
+ public void register(final List geofences) {
+ if (geofences.isEmpty()) {
+ return;
+ }
+
+ googleApiClientProvider.getApi(new GoogleApiClientProvider.withApi() {
+ @Override
+ public void doWork(GoogleApiClient googleApiClient) {
+ PendingResult result = LocationServices.GeofencingApi.addGeofences(
+ googleApiClient,
+ getRequests(geofences),
+ PendingIntent.getService(context, 0, new Intent(context, GeofenceTransitionsIntentService.class), PendingIntent.FLAG_UPDATE_CURRENT));
+ result.setResultCallback(new ResultCallback() {
+ @Override
+ public void onResult(Status status) {
+ if (status.isSuccess()) {
+ log.info("Registered {}", geofences);
+ } else {
+ log.error("Failed to register {}", geofences);
+ }
+ }
+ });
+ }
+
+ @Override
+ public void onConnectionFailed(ConnectionResult connectionResult) {
+ log.info("failed to register geofences");
+ }
+ });
+ }
+
+ public void cancel(final Geofence geofence) {
+ googleApiClientProvider.getApi(new GoogleApiClientProvider.withApi() {
+ @Override
+ public void doWork(GoogleApiClient googleApiClient) {
+ LocationServices.GeofencingApi.removeGeofences(
+ googleApiClient,
+ asList(Long.toString(geofence.getMetadataId())))
+ .setResultCallback(new ResultCallback() {
+ @Override
+ public void onResult(Status status) {
+ if (status.isSuccess()) {
+ log.info("Removed {}", geofence);
+ } else {
+ log.error("Failed to remove {}", geofence);
+ }
+ }
+ });
+ }
+
+ @Override
+ public void onConnectionFailed(ConnectionResult connectionResult) {
+ log.info("failed to cancel geofence");
+ }
+ });
+ }
+
+ private List getRequests(List geofences) {
+ return newArrayList(transform(geofences, new Function() {
+ @Override
+ public com.google.android.gms.location.Geofence apply(Geofence geofence) {
+ return toGoogleGeofence(geofence);
+ }
+ }));
+ }
+
+ private com.google.android.gms.location.Geofence toGoogleGeofence(Geofence geofence) {
+ return new com.google.android.gms.location.Geofence.Builder()
+ .setCircularRegion(geofence.getLatitude(), geofence.getLongitude(), geofence.getRadius())
+ .setNotificationResponsiveness((int) TimeUnit.SECONDS.toMillis(30))
+ .setRequestId(Long.toString(geofence.getMetadataId()))
+ .setTransitionTypes(GeofencingRequest.INITIAL_TRIGGER_ENTER)
+ .setExpirationDuration(NEVER_EXPIRE)
+ .build();
+ }
+}
diff --git a/src/googleplay/java/org/tasks/location/GeofenceTransitionsIntentService.java b/src/googleplay/java/org/tasks/location/GeofenceTransitionsIntentService.java
new file mode 100644
index 000000000..2afbab791
--- /dev/null
+++ b/src/googleplay/java/org/tasks/location/GeofenceTransitionsIntentService.java
@@ -0,0 +1,49 @@
+package org.tasks.location;
+
+import android.content.Intent;
+
+import com.google.android.gms.location.GeofencingEvent;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.tasks.Broadcaster;
+import org.tasks.injection.InjectingIntentService;
+
+import java.util.List;
+
+import javax.inject.Inject;
+
+public class GeofenceTransitionsIntentService extends InjectingIntentService {
+
+ private static final Logger log = LoggerFactory.getLogger(GeofenceTransitionsIntentService.class);
+
+ @Inject GeofenceService geofenceService;
+ @Inject Broadcaster broadcaster;
+
+ public GeofenceTransitionsIntentService() {
+ super(GeofenceTransitionsIntentService.class.getSimpleName());
+ }
+
+ protected void onHandleIntent(Intent intent) {
+ super.onHandleIntent(intent);
+
+ GeofencingEvent geofencingEvent = GeofencingEvent.fromIntent(intent);
+ if (geofencingEvent.hasError()) {
+ log.error("geofence error code {}", geofencingEvent.getErrorCode());
+ return;
+ }
+
+ int transitionType = geofencingEvent.getGeofenceTransition();
+
+ if (transitionType == com.google.android.gms.location.Geofence.GEOFENCE_TRANSITION_ENTER) {
+ List triggeringGeofences = geofencingEvent.getTriggeringGeofences();
+ log.info("Received geofence transition: {}, {}", transitionType, triggeringGeofences);
+ for (com.google.android.gms.location.Geofence triggerGeofence : triggeringGeofences) {
+ Geofence geofence = geofenceService.getGeofenceById(Long.parseLong(triggerGeofence.getRequestId()));
+ broadcaster.requestNotification(geofence.getMetadataId(), geofence.getTaskId());
+ }
+ } else {
+ log.warn("invalid geofence transition type: {}", transitionType);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/googleplay/java/org/tasks/location/GoogleApiClientProvider.java b/src/googleplay/java/org/tasks/location/GoogleApiClientProvider.java
new file mode 100644
index 000000000..1fd628956
--- /dev/null
+++ b/src/googleplay/java/org/tasks/location/GoogleApiClientProvider.java
@@ -0,0 +1,51 @@
+package org.tasks.location;
+
+import android.content.Context;
+import android.os.Bundle;
+
+import com.google.android.gms.common.api.GoogleApiClient;
+import com.google.android.gms.location.LocationServices;
+import com.google.android.gms.location.places.Places;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.tasks.injection.ForApplication;
+
+import javax.inject.Inject;
+
+public class GoogleApiClientProvider {
+
+ private static final Logger log = LoggerFactory.getLogger(GoogleApiClientProvider.class);
+
+ private Context context;
+
+ public interface withApi extends GoogleApiClient.OnConnectionFailedListener {
+ void doWork(GoogleApiClient googleApiClient);
+ }
+
+ @Inject
+ public GoogleApiClientProvider(@ForApplication Context context) {
+ this.context = context;
+ }
+
+ public void getApi(final withApi callback) {
+ final GoogleApiClient googleApiClient = new GoogleApiClient.Builder(context)
+ .addOnConnectionFailedListener(callback)
+ .addApi(Places.GEO_DATA_API)
+ .addApi(LocationServices.API)
+ .build();
+ googleApiClient.registerConnectionCallbacks(new GoogleApiClient.ConnectionCallbacks() {
+ @Override
+ public void onConnected(Bundle bundle) {
+ log.info("onConnected({})", bundle);
+ callback.doWork(googleApiClient);
+ }
+
+ @Override
+ public void onConnectionSuspended(int i) {
+ log.info("onConnectionSuspended({})", i);
+ }
+ });
+ googleApiClient.connect();
+ }
+}
diff --git a/src/googleplay/java/org/tasks/location/LocationApi.java b/src/googleplay/java/org/tasks/location/LocationApi.java
new file mode 100644
index 000000000..ba808ea7f
--- /dev/null
+++ b/src/googleplay/java/org/tasks/location/LocationApi.java
@@ -0,0 +1,84 @@
+package org.tasks.location;
+
+import android.location.Location;
+
+import com.google.android.gms.common.ConnectionResult;
+import com.google.android.gms.common.api.GoogleApiClient;
+import com.google.android.gms.common.api.ResultCallback;
+import com.google.android.gms.location.LocationServices;
+import com.google.android.gms.location.places.AutocompletePredictionBuffer;
+import com.google.android.gms.location.places.PlaceBuffer;
+import com.google.android.gms.location.places.Places;
+import com.google.android.gms.maps.model.LatLng;
+import com.google.android.gms.maps.model.LatLngBounds;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.concurrent.TimeUnit;
+
+import javax.inject.Inject;
+
+public class LocationApi {
+
+ private static final Logger log = LoggerFactory.getLogger(LocationApi.class);
+
+ private GoogleApiClientProvider googleApiClientProvider;
+
+ @Inject
+ public LocationApi(GoogleApiClientProvider googleApiClientProvider) {
+ this.googleApiClientProvider = googleApiClientProvider;
+ }
+
+ public void getPlaceDetails(final String placeId, final ResultCallback callback) {
+ googleApiClientProvider.getApi(new GoogleApiClientProvider.withApi() {
+ @Override
+ public void doWork(final GoogleApiClient googleApiClient) {
+ Places.GeoDataApi.getPlaceById(googleApiClient, placeId)
+ .setResultCallback(new ResultCallback() {
+ @Override
+ public void onResult(PlaceBuffer places) {
+ callback.onResult(places);
+ googleApiClient.disconnect();
+ }
+ }, 15, TimeUnit.SECONDS);
+ }
+
+ @Override
+ public void onConnectionFailed(ConnectionResult connectionResult) {
+ log.error("onConnectionFailed({})", connectionResult);
+ }
+ });
+ }
+
+ public void getAutocompletePredictions(final String constraint, final ResultCallback callback) {
+ googleApiClientProvider.getApi(new GoogleApiClientProvider.withApi() {
+ @Override
+ public void doWork(final GoogleApiClient googleApiClient) {
+ final LatLngBounds bounds = LatLngBounds.builder().include(getLastKnownLocation(googleApiClient)).build();
+ Places.GeoDataApi.getAutocompletePredictions(googleApiClient, constraint, bounds, null)
+ .setResultCallback(new ResultCallback() {
+ @Override
+ public void onResult(AutocompletePredictionBuffer autocompletePredictions) {
+ callback.onResult(autocompletePredictions);
+ googleApiClient.disconnect();
+ }
+ }, 15, TimeUnit.SECONDS);
+ }
+
+ @Override
+ public void onConnectionFailed(ConnectionResult connectionResult) {
+ log.error("onConnectionFailed({})", connectionResult);
+ }
+ });
+ }
+
+ private LatLng getLastKnownLocation(GoogleApiClient googleApiClient) {
+ try {
+ Location lastLocation = LocationServices.FusedLocationApi.getLastLocation(googleApiClient);
+ return new LatLng(lastLocation.getLatitude(), lastLocation.getLongitude());
+ } catch (Exception e) {
+ return new LatLng(0, 0);
+ }
+ }
+}
diff --git a/src/googleplay/java/org/tasks/location/PlaceAutocompleteAdapter.java b/src/googleplay/java/org/tasks/location/PlaceAutocompleteAdapter.java
new file mode 100644
index 000000000..5e72d3ea8
--- /dev/null
+++ b/src/googleplay/java/org/tasks/location/PlaceAutocompleteAdapter.java
@@ -0,0 +1,98 @@
+package org.tasks.location;
+
+import android.content.Context;
+import android.widget.ArrayAdapter;
+import android.widget.Toast;
+
+import com.google.android.gms.common.api.ResultCallback;
+import com.google.android.gms.common.api.Status;
+import com.google.android.gms.location.places.AutocompletePrediction;
+import com.google.android.gms.location.places.AutocompletePredictionBuffer;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+public class PlaceAutocompleteAdapter
+ extends ArrayAdapter {
+
+ private static final Logger log = LoggerFactory.getLogger(PlaceAutocompleteAdapter.class);
+
+ private List mResultList = new ArrayList<>();
+ private LocationApi locationApi;
+
+ public PlaceAutocompleteAdapter(LocationApi locationApi, Context context, int resource) {
+ super(context, resource);
+ this.locationApi = locationApi;
+ }
+
+ @Override
+ public int getCount() {
+ return mResultList == null ? 0 : mResultList.size();
+ }
+
+ @Override
+ public PlaceAutocomplete getItem(int position) {
+ return mResultList.get(position);
+ }
+
+ public void getAutocomplete(CharSequence constraint) {
+ locationApi.getAutocompletePredictions(constraint.toString(), onResults);
+ }
+
+ private ResultCallback onResults = new ResultCallback() {
+ @Override
+ public void onResult(AutocompletePredictionBuffer autocompletePredictions) {
+ final Status status = autocompletePredictions.getStatus();
+ if (!status.isSuccess()) {
+ Toast.makeText(getContext(), "Error contacting API: " + status.toString(),
+ Toast.LENGTH_SHORT).show();
+ log.error("Error getting autocomplete prediction API call: " + status.toString());
+ autocompletePredictions.release();
+ return;
+ }
+
+ log.info("Query completed. Received " + autocompletePredictions.getCount()
+ + " predictions.");
+
+ Iterator iterator = autocompletePredictions.iterator();
+ List resultList = new ArrayList<>(autocompletePredictions.getCount());
+ while (iterator.hasNext()) {
+ AutocompletePrediction prediction = iterator.next();
+ resultList.add(new PlaceAutocomplete(prediction.getPlaceId(),
+ prediction.getDescription()));
+ }
+
+ setResults(resultList);
+ autocompletePredictions.release();
+ }
+ };
+
+ private void setResults(List results) {
+ mResultList = results;
+ if (mResultList != null && mResultList.size() > 0) {
+ notifyDataSetChanged();
+ } else {
+ notifyDataSetInvalidated();
+ }
+ }
+
+ public class PlaceAutocomplete {
+
+ public CharSequence placeId;
+ public CharSequence description;
+
+ PlaceAutocomplete(CharSequence placeId, CharSequence description) {
+ this.placeId = placeId;
+ this.description = description;
+ }
+
+ @Override
+ public String toString() {
+ return description.toString();
+ }
+ }
+}
diff --git a/src/googleplay/res/layout/location_picker_dialog.xml b/src/googleplay/res/layout/location_picker_dialog.xml
new file mode 100644
index 000000000..70741601a
--- /dev/null
+++ b/src/googleplay/res/layout/location_picker_dialog.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/googleplay/res/values-sw600dp/template-dimens.xml b/src/googleplay/res/values-sw600dp/template-dimens.xml
new file mode 100644
index 000000000..774ba3ee1
--- /dev/null
+++ b/src/googleplay/res/values-sw600dp/template-dimens.xml
@@ -0,0 +1,6 @@
+
+
+ @dimen/margin_huge
+ @dimen/margin_medium
+
+
diff --git a/src/googleplay/res/values/bools.xml b/src/googleplay/res/values/bools.xml
index 9f359460c..b97aa432c 100644
--- a/src/googleplay/res/values/bools.xml
+++ b/src/googleplay/res/values/bools.xml
@@ -1,4 +1,5 @@
true
+ true
\ No newline at end of file
diff --git a/src/googleplay/res/values/template-dimens.xml b/src/googleplay/res/values/template-dimens.xml
new file mode 100644
index 000000000..e8b853af7
--- /dev/null
+++ b/src/googleplay/res/values/template-dimens.xml
@@ -0,0 +1,9 @@
+
+
+ 16dp
+ 64dp
+
+ @dimen/margin_medium
+ @dimen/margin_medium
+
+
diff --git a/src/main/java/com/todoroo/astrid/activity/TaskEditFragment.java b/src/main/java/com/todoroo/astrid/activity/TaskEditFragment.java
index e6195739a..6f838d42e 100755
--- a/src/main/java/com/todoroo/astrid/activity/TaskEditFragment.java
+++ b/src/main/java/com/todoroo/astrid/activity/TaskEditFragment.java
@@ -82,6 +82,8 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.tasks.R;
import org.tasks.injection.InjectingFragment;
+import org.tasks.location.GeofenceService;
+import org.tasks.location.LocationApi;
import org.tasks.notifications.NotificationManager;
import org.tasks.preferences.ActivityPreferences;
@@ -173,6 +175,8 @@ ViewPager.OnPageChangeListener, EditNoteActivity.UpdatesChangedListener {
@Inject DateChangedAlerts dateChangedAlerts;
@Inject TagDataDao tagDataDao;
@Inject ActFmCameraModule actFmCameraModule;
+ @Inject GeofenceService geofenceService;
+ @Inject LocationApi locationApi;
// --- UI components
@@ -389,10 +393,9 @@ ViewPager.OnPageChangeListener, EditNoteActivity.UpdatesChangedListener {
controlSetMap.put(getString(R.string.TEA_ctrl_notes_pref),
notesControlSet);
- ReminderControlSet reminderControl = new ReminderControlSet(alarmService, this);
- controls.add(reminderControl);
- controlSetMap.put(getString(R.string.TEA_ctrl_reminders_pref),
- reminderControl);
+ ReminderControlSet reminderControlSet = new ReminderControlSet(locationApi, alarmService, geofenceService, this);
+ controls.add(reminderControlSet);
+ controlSetMap.put(getString(R.string.TEA_ctrl_reminders_pref), reminderControlSet);
HideUntilControlSet hideUntilControls = new HideUntilControlSet(this);
controls.add(hideUntilControls);
diff --git a/src/main/java/com/todoroo/astrid/ui/ReminderControlSet.java b/src/main/java/com/todoroo/astrid/ui/ReminderControlSet.java
index b6838197b..377fc947f 100644
--- a/src/main/java/com/todoroo/astrid/ui/ReminderControlSet.java
+++ b/src/main/java/com/todoroo/astrid/ui/ReminderControlSet.java
@@ -29,11 +29,17 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.tasks.R;
import org.tasks.dialogs.DateAndTimePickerDialog;
+import org.tasks.dialogs.LocationPickerDialog;
+import org.tasks.location.Geofence;
+import org.tasks.location.GeofenceService;
+import org.tasks.location.LocationApi;
+import org.tasks.location.OnLocationPickedHandler;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Set;
import static org.tasks.date.DateTimeUtils.newDateTime;
@@ -55,15 +61,19 @@ public class ReminderControlSet extends TaskEditControlSetBase implements Adapte
private LinearLayout alertContainer;
private boolean whenDue;
private boolean whenOverdue;
+ private LocationApi locationApi;
private AlarmService alarmService;
+ private GeofenceService geofenceService;
private TaskEditFragment taskEditFragment;
private List spinnerOptions = new ArrayList<>();
private ArrayAdapter remindAdapter;
- public ReminderControlSet(AlarmService alarmService, TaskEditFragment taskEditFragment) {
+ public ReminderControlSet(LocationApi locationApi, AlarmService alarmService, GeofenceService geofenceService, TaskEditFragment taskEditFragment) {
super(taskEditFragment.getActivity(), R.layout.control_set_reminders);
+ this.locationApi = locationApi;
this.alarmService = alarmService;
+ this.geofenceService = geofenceService;
this.taskEditFragment = taskEditFragment;
}
@@ -117,6 +127,15 @@ public class ReminderControlSet extends TaskEditControlSetBase implements Adapte
});
}
+ public void addGeolocationReminder(final Geofence geofence) {
+ View alertItem = addAlarmRow(geofence.getName(), null, new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ }
+ });
+ alertItem.setTag(geofence);
+ }
+
private View addAlarmRow(String text, Long timestamp, final OnClickListener onRemove) {
final View alertItem = LayoutInflater.from(activity).inflate(R.layout.alarm_edit_row, null);
alertContainer.addView(alertItem);
@@ -155,6 +174,9 @@ public class ReminderControlSet extends TaskEditControlSetBase implements Adapte
if (randomControlSet == null) {
spinnerOptions.add(taskEditFragment.getString(R.string.randomly));
}
+ if (taskEditFragment.getResources().getBoolean(R.bool.location_enabled)) {
+ spinnerOptions.add(taskEditFragment.getString(R.string.pick_a_location));
+ }
spinnerOptions.add(taskEditFragment.getString(R.string.pick_a_date_and_time));
remindAdapter.notifyDataSetChanged();
}
@@ -257,6 +279,9 @@ public class ReminderControlSet extends TaskEditControlSetBase implements Adapte
addAlarmRow(entry.getValue(AlarmFields.TIME));
}
});
+ for (Geofence geofence : geofenceService.getGeofences(model.getId())) {
+ addGeolocationReminder(geofence);
+ }
updateSpinner();
}
@@ -310,18 +335,28 @@ public class ReminderControlSet extends TaskEditControlSetBase implements Adapte
task.setReminderPeriod(randomControlSet == null ? 0L : randomControlSet.getReminderPeriod());
- LinkedHashSet alarms = new LinkedHashSet<>();
+ Set alarms = new LinkedHashSet<>();
+ Set geofences = new LinkedHashSet<>();
+
for(int i = 0; i < alertContainer.getChildCount(); i++) {
- Long dateValue = (Long) alertContainer.getChildAt(i).getTag();
- if(dateValue == null) {
- continue;
+ Object tag = alertContainer.getChildAt(i).getTag();
+ //noinspection StatementWithEmptyBody
+ if (tag == null) {
+ } else if (tag instanceof Long) {
+ alarms.add((Long) tag);
+ } else if (tag instanceof Geofence) {
+ geofences.add((Geofence) tag);
+ } else {
+ log.error("Unexpected tag: {}", tag);
}
- alarms.add(dateValue);
}
if(alarmService.synchronizeAlarms(task.getId(), alarms)) {
task.setModificationDate(DateUtilities.now());
}
+ if (geofenceService.synchronizeGeofences(task.getId(), geofences)) {
+ task.setModificationDate(DateUtilities.now());
+ }
}
private String getDisplayString(long forDate) {
@@ -345,6 +380,13 @@ public class ReminderControlSet extends TaskEditControlSetBase implements Adapte
addRandomReminder();
} else if (selected.equals(taskEditFragment.getString(R.string.pick_a_date_and_time))) {
addNewAlarm();
+ } else if (selected.equals(taskEditFragment.getString(R.string.pick_a_location))) {
+ LocationPickerDialog.pickLocation(locationApi, taskEditFragment, new OnLocationPickedHandler() {
+ @Override
+ public void onLocationPicked(Geofence geofence) {
+ addGeolocationReminder(geofence);
+ }
+ });
}
if (position != 0) {
updateSpinner();
diff --git a/src/main/java/org/tasks/Broadcaster.java b/src/main/java/org/tasks/Broadcaster.java
index a3b4fc528..39deecdf0 100644
--- a/src/main/java/org/tasks/Broadcaster.java
+++ b/src/main/java/org/tasks/Broadcaster.java
@@ -6,6 +6,7 @@ import android.content.Intent;
import com.todoroo.astrid.api.AstridApiConstants;
import com.todoroo.astrid.reminders.NotificationFragment;
import com.todoroo.astrid.reminders.Notifications;
+import com.todoroo.astrid.reminders.ReminderService;
import com.todoroo.astrid.utility.Constants;
import org.tasks.injection.ForApplication;
@@ -55,6 +56,14 @@ public class Broadcaster {
}}, AstridApiConstants.PERMISSION_READ);
}
+ public void requestNotification(final long alarmId, final long taskId) {
+ sendOrderedBroadcast(new Intent(context, Notifications.class) {{
+ setAction("ALARM" + alarmId); //$NON-NLS-1$
+ putExtra(Notifications.ID_KEY, taskId);
+ putExtra(Notifications.EXTRAS_TYPE, ReminderService.TYPE_ALARM);
+ }});
+ }
+
public void taskCompleted(final long id) {
sendOrderedBroadcast(new Intent(AstridApiConstants.BROADCAST_EVENT_TASK_COMPLETED) {{
putExtra(AstridApiConstants.EXTRAS_TASK_ID, id);
diff --git a/src/main/java/org/tasks/injection/FragmentModule.java b/src/main/java/org/tasks/injection/FragmentModule.java
index 972a01e5a..c470973bd 100644
--- a/src/main/java/org/tasks/injection/FragmentModule.java
+++ b/src/main/java/org/tasks/injection/FragmentModule.java
@@ -3,6 +3,7 @@ package org.tasks.injection;
import android.app.Activity;
import android.content.Context;
import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentActivity;
import com.todoroo.astrid.actfm.TagViewFragment;
import com.todoroo.astrid.activity.TaskEditFragment;
diff --git a/src/main/java/org/tasks/injection/IntentServiceModule.java b/src/main/java/org/tasks/injection/IntentServiceModule.java
index baa4f91be..8c4ca06ec 100644
--- a/src/main/java/org/tasks/injection/IntentServiceModule.java
+++ b/src/main/java/org/tasks/injection/IntentServiceModule.java
@@ -1,5 +1,6 @@
package org.tasks.injection;
+import org.tasks.location.GeofenceTransitionsIntentService;
import org.tasks.scheduling.*;
import dagger.Module;
@@ -11,7 +12,8 @@ import dagger.Module;
GtasksBackgroundService.class,
MidnightRefreshService.class,
RefreshSchedulerIntentService.class,
- ReminderSchedulerIntentService.class
+ ReminderSchedulerIntentService.class,
+ GeofenceTransitionsIntentService.class
})
public class IntentServiceModule {
}
diff --git a/src/main/java/org/tasks/location/Geofence.java b/src/main/java/org/tasks/location/Geofence.java
new file mode 100644
index 000000000..889a05db8
--- /dev/null
+++ b/src/main/java/org/tasks/location/Geofence.java
@@ -0,0 +1,64 @@
+package org.tasks.location;
+
+import com.todoroo.astrid.data.Metadata;
+
+public class Geofence {
+ private final String name;
+ private final double latitude;
+ private final double longitude;
+ private final int radius;
+ private long taskId;
+ private long metadataId;
+
+ public Geofence(Metadata metadata) {
+ this(metadata.getValue(GeofenceFields.PLACE),
+ metadata.getValue(GeofenceFields.LATITUDE),
+ metadata.getValue(GeofenceFields.LONGITUDE),
+ metadata.getValue(GeofenceFields.RADIUS));
+ metadataId = metadata.getId();
+ taskId = metadata.getTask();
+ }
+
+ public Geofence(String name, double latitude, double longitude, int radius) {
+ this.name = name;
+ this.latitude = latitude;
+ this.longitude = longitude;
+ this.radius = radius;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public double getLatitude() {
+ return latitude;
+ }
+
+ public double getLongitude() {
+ return longitude;
+ }
+
+ public int getRadius() {
+ return radius;
+ }
+
+ public long getMetadataId() {
+ return metadataId;
+ }
+
+ public long getTaskId() {
+ return taskId;
+ }
+
+ @Override
+ public String toString() {
+ return "Geofence{" +
+ "name='" + name + '\'' +
+ ", latitude=" + latitude +
+ ", longitude=" + longitude +
+ ", radius=" + radius +
+ ", taskId=" + taskId +
+ ", metadataId=" + metadataId +
+ '}';
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/tasks/location/GeofenceFields.java b/src/main/java/org/tasks/location/GeofenceFields.java
new file mode 100644
index 000000000..19e147f54
--- /dev/null
+++ b/src/main/java/org/tasks/location/GeofenceFields.java
@@ -0,0 +1,24 @@
+package org.tasks.location;
+
+import com.todoroo.andlib.data.Property.IntegerProperty;
+import com.todoroo.astrid.data.Metadata;
+
+import static com.todoroo.andlib.data.Property.DoubleProperty;
+import static com.todoroo.andlib.data.Property.StringProperty;
+
+public class GeofenceFields {
+
+ public static final String METADATA_KEY = "geofence";
+
+ public static final StringProperty PLACE = new StringProperty(Metadata.TABLE,
+ Metadata.VALUE1.name);
+
+ public static final DoubleProperty LATITUDE = new DoubleProperty(Metadata.TABLE,
+ Metadata.VALUE2.name);
+
+ public static final DoubleProperty LONGITUDE = new DoubleProperty(Metadata.TABLE,
+ Metadata.VALUE3.name);
+
+ public static final IntegerProperty RADIUS = new IntegerProperty(Metadata.TABLE,
+ Metadata.VALUE4.name);
+}
diff --git a/src/main/java/org/tasks/location/GeofenceService.java b/src/main/java/org/tasks/location/GeofenceService.java
new file mode 100644
index 000000000..5326a472f
--- /dev/null
+++ b/src/main/java/org/tasks/location/GeofenceService.java
@@ -0,0 +1,162 @@
+package org.tasks.location;
+
+import android.content.ContentValues;
+
+import com.google.common.base.Function;
+import com.todoroo.andlib.data.Callback;
+import com.todoroo.andlib.sql.Criterion;
+import com.todoroo.andlib.sql.Join;
+import com.todoroo.andlib.sql.Order;
+import com.todoroo.andlib.sql.Query;
+import com.todoroo.andlib.utility.DateUtilities;
+import com.todoroo.astrid.dao.MetadataDao;
+import com.todoroo.astrid.dao.MetadataDao.MetadataCriteria;
+import com.todoroo.astrid.dao.TaskDao.TaskCriteria;
+import com.todoroo.astrid.data.Metadata;
+import com.todoroo.astrid.data.Task;
+import com.todoroo.astrid.service.SynchronizeMetadataCallback;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+
+import static com.google.common.collect.Iterables.transform;
+import static com.google.common.collect.Lists.newArrayList;
+
+@Singleton
+public class GeofenceService {
+
+ private final MetadataDao metadataDao;
+ private final GeofenceApi geofenceApi;
+
+ @Inject
+ public GeofenceService(MetadataDao metadataDao, GeofenceApi geofenceApi) {
+ this.metadataDao = metadataDao;
+ this.geofenceApi = geofenceApi;
+ }
+
+ public Geofence getGeofenceById(long metadataId) {
+ return new Geofence(metadataDao.fetch(metadataId, Metadata.TASK, GeofenceFields.PLACE, GeofenceFields.LATITUDE, GeofenceFields.LONGITUDE, GeofenceFields.RADIUS));
+ }
+
+ public List getGeofences(long taskId) {
+ return toGeofences(metadataDao.toList(Query.select(
+ Metadata.PROPERTIES).where(MetadataCriteria.byTaskAndwithKey(
+ taskId, GeofenceFields.METADATA_KEY)).orderBy(Order.asc(GeofenceFields.PLACE))));
+ }
+
+ public void setupGeofences() {
+ geofenceApi.register(getActiveGeofences());
+ }
+
+ public boolean synchronizeGeofences(final long taskId, Set geofences) {
+ List metadata = newArrayList(transform(geofences, new Function() {
+ @Override
+ public Metadata apply(final Geofence geofence) {
+ return new Metadata() {{
+ setKey(GeofenceFields.METADATA_KEY);
+ setValue(GeofenceFields.PLACE, geofence.getName());
+ setValue(GeofenceFields.LATITUDE, geofence.getLatitude());
+ setValue(GeofenceFields.LONGITUDE, geofence.getLongitude());
+ setValue(GeofenceFields.RADIUS, geofence.getRadius());
+ }};
+ }
+ }));
+
+ boolean changed = synchronizeMetadata(taskId, metadata, new SynchronizeMetadataCallback() {
+ @Override
+ public void beforeDeleteMetadata(Metadata m) {
+ geofenceApi.cancel(new Geofence(m));
+ }
+ });
+
+ if(changed) {
+ setupGeofences(taskId);
+ }
+ return changed;
+ }
+
+ private List toGeofences(List geofences) {
+ return newArrayList(transform(geofences, new Function() {
+ @Override
+ public Geofence apply(Metadata metadata) {
+ return new Geofence(metadata);
+ }
+ }));
+ }
+
+ private void setupGeofences(long taskId) {
+ geofenceApi.register(getGeofencesForTask(taskId));
+ }
+
+ private List getActiveGeofences() {
+ return toGeofences(metadataDao.toList(Query.select(Metadata.ID, Metadata.TASK, GeofenceFields.PLACE, GeofenceFields.LATITUDE, GeofenceFields.LONGITUDE, GeofenceFields.RADIUS).
+ join(Join.inner(Task.TABLE, Metadata.TASK.eq(Task.ID))).
+ where(Criterion.and(TaskCriteria.isActive(), MetadataCriteria.withKey(GeofenceFields.METADATA_KEY)))));
+ }
+
+ private List getGeofencesForTask(long taskId) {
+ return toGeofences(metadataDao.toList(Query.select(Metadata.ID, Metadata.TASK, GeofenceFields.PLACE, GeofenceFields.LATITUDE, GeofenceFields.LONGITUDE, GeofenceFields.RADIUS).
+ join(Join.inner(Task.TABLE, Metadata.TASK.eq(Task.ID))).
+ where(Criterion.and(TaskCriteria.isActive(),
+ MetadataCriteria.byTaskAndwithKey(taskId, GeofenceFields.METADATA_KEY)))));
+ }
+
+ private boolean synchronizeMetadata(long taskId, List metadata, final SynchronizeMetadataCallback callback) {
+ final boolean[] dirty = new boolean[1];
+ final Set newMetadataValues = new HashSet<>();
+ for(Metadata metadatum : metadata) {
+ metadatum.setTask(taskId);
+ metadatum.clearValue(Metadata.CREATION_DATE);
+ metadatum.clearValue(Metadata.ID);
+
+ ContentValues values = metadatum.getMergedValues();
+ for(Map.Entry entry : values.valueSet()) {
+ if(entry.getKey().startsWith("value")) //$NON-NLS-1$
+ {
+ values.put(entry.getKey(), entry.getValue().toString());
+ }
+ }
+ newMetadataValues.add(values);
+ }
+
+ metadataDao.byTaskAndKey(taskId, GeofenceFields.METADATA_KEY, new Callback() {
+ @Override
+ public void apply(Metadata item) {
+ long id = item.getId();
+
+ // clear item id when matching with incoming values
+ item.clearValue(Metadata.ID);
+ item.clearValue(Metadata.CREATION_DATE);
+ ContentValues itemMergedValues = item.getMergedValues();
+
+ if(newMetadataValues.contains(itemMergedValues)) {
+ newMetadataValues.remove(itemMergedValues);
+ } else {
+ // not matched. cut it
+ item.setId(id);
+ if (callback != null) {
+ callback.beforeDeleteMetadata(item);
+ }
+ metadataDao.delete(id);
+ dirty[0] = true;
+ }
+ }
+ });
+
+ // everything that remains shall be written
+ for(ContentValues values : newMetadataValues) {
+ Metadata item = new Metadata();
+ item.setCreationDate(DateUtilities.now());
+ item.mergeWith(values);
+ metadataDao.persist(item);
+ dirty[0] = true;
+ }
+
+ return dirty[0];
+ }
+}
diff --git a/src/main/java/org/tasks/location/OnLocationPickedHandler.java b/src/main/java/org/tasks/location/OnLocationPickedHandler.java
new file mode 100644
index 000000000..2a98eba76
--- /dev/null
+++ b/src/main/java/org/tasks/location/OnLocationPickedHandler.java
@@ -0,0 +1,5 @@
+package org.tasks.location;
+
+public interface OnLocationPickedHandler {
+ void onLocationPicked(Geofence geofence);
+}
diff --git a/src/main/java/org/tasks/scheduling/AlarmSchedulingIntentService.java b/src/main/java/org/tasks/scheduling/AlarmSchedulingIntentService.java
index 73ced8ee6..f278faa51 100644
--- a/src/main/java/org/tasks/scheduling/AlarmSchedulingIntentService.java
+++ b/src/main/java/org/tasks/scheduling/AlarmSchedulingIntentService.java
@@ -7,6 +7,7 @@ import com.todoroo.astrid.alarms.AlarmService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.tasks.injection.InjectingIntentService;
+import org.tasks.location.GeofenceService;
import javax.inject.Inject;
@@ -15,6 +16,7 @@ public class AlarmSchedulingIntentService extends InjectingIntentService {
private static final Logger log = LoggerFactory.getLogger(AlarmSchedulingIntentService.class);
@Inject AlarmService alarmService;
+ @Inject GeofenceService geofenceService;
public AlarmSchedulingIntentService() {
super(AlarmSchedulingIntentService.class.getSimpleName());
@@ -27,5 +29,6 @@ public class AlarmSchedulingIntentService extends InjectingIntentService {
log.debug("onHandleIntent({})", intent);
alarmService.scheduleAllAlarms();
+ geofenceService.setupGeofences();
}
}
diff --git a/src/main/res/values-bg-rBG/strings.xml b/src/main/res/values-bg-rBG/strings.xml
index bad795eb8..2241eabb4 100644
--- a/src/main/res/values-bg-rBG/strings.xml
+++ b/src/main/res/values-bg-rBG/strings.xml
@@ -494,6 +494,8 @@
Произволно веднъж
Произволно
Избери дата и време
+ Избери местоположение
+ Въведи местоположение
След крайния срок
При краен срок
diff --git a/src/main/res/values-ja/strings.xml b/src/main/res/values-ja/strings.xml
index 2e9288d36..6ee4e1888 100644
--- a/src/main/res/values-ja/strings.xml
+++ b/src/main/res/values-ja/strings.xml
@@ -492,6 +492,8 @@
1回ランダムに
ランダムに
日付と時間を選択
+ 場所を選択
+ 場所を入力
期限を過ぎたとき
期限に
diff --git a/src/main/res/values/bools.xml b/src/main/res/values/bools.xml
index 5b33fae3e..3af822b39 100644
--- a/src/main/res/values/bools.xml
+++ b/src/main/res/values/bools.xml
@@ -4,4 +4,5 @@
false
false
false
+ false
\ No newline at end of file
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index e24f0655c..29110fd11 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -98,6 +98,8 @@
Randomly once
Randomly
Pick a date and time
+ Pick a location
+ Type a location
When overdue
When due