diff --git a/ParseLoginUI/build.gradle b/ParseLoginUI/build.gradle index 8bc41c0..cdad84f 100644 --- a/ParseLoginUI/build.gradle +++ b/ParseLoginUI/build.gradle @@ -5,6 +5,9 @@ dependencies { compile 'com.android.support:support-v4:22.0.0' compile 'com.parse:parse-android:1.10.1' + compile 'com.android.support:appcompat-v7:22.0.0' + compile 'com.android.support:recyclerview-v7:22.0.0' + provided 'com.facebook.android:facebook-android-sdk:4.0.1' provided files("$rootProject.projectDir/ParseLoginUI/libs/ParseFacebookUtilsV4-1.10.1.jar") provided files("$rootProject.projectDir/ParseLoginUI/libs/ParseTwitterUtils-1.10.1.jar") diff --git a/ParseLoginUI/src/androidTest/java/com/parse/ParseQueryRecyclerViewAdapterTest.java b/ParseLoginUI/src/androidTest/java/com/parse/ParseQueryRecyclerViewAdapterTest.java new file mode 100644 index 0000000..ac39478 --- /dev/null +++ b/ParseLoginUI/src/androidTest/java/com/parse/ParseQueryRecyclerViewAdapterTest.java @@ -0,0 +1,612 @@ +/* + * Copyright (c) 2015, Parse, LLC. All rights reserved. + * + * You are hereby granted a non-exclusive, worldwide, royalty-free license to use, + * copy, modify, and distribute this software in source code or binary form for use + * in connection with the web services and APIs provided by Parse. + * + * As with any software that integrates with the Parse platform, your use of + * this software is subject to the Parse Terms of Service + * [https://www.parse.com/about/terms]. This copyright notice shall be + * included in all copies or substantial portions of the software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + +package com.parse; + +import android.database.DataSetObserver; +import android.os.SystemClock; +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.parse.ui.TestActivity; +import com.parse.widget.ParseQueryAdapter; + +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +import bolts.Capture; +import bolts.Task; + +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ParseQueryRecyclerViewAdapterTest extends BaseActivityInstrumentationTestCase2 { + + @ParseClassName("Thing") + public static class Thing extends ParseObject { + public Thing() { + } + } + + public ParseQueryRecyclerViewAdapterTest() { + super(TestActivity.class); + } + + private int TOTAL_THINGS = 10; + private List savedThings = new ArrayList(); + + @Override + public void setUp() throws Exception { + super.setUp(); + + // Register a mock cachedQueryController, the controller maintain a cache list and return + // results based on query state's CachePolicy + ParseQueryController queryController = mock(ParseQueryController.class); + Answer>> queryAnswer = new Answer>>() { + private List cachedThings = new ArrayList<>(); + + @Override + public Task> answer(InvocationOnMock invocation) throws Throwable { + ParseQuery.State state = (ParseQuery.State) invocation.getArguments()[0]; + int start = state.skip(); + // The default value of limit in ParseQuery is -1. + int end = state.limit() > 0 ? + Math.min(state.skip() + state.limit(), TOTAL_THINGS) : TOTAL_THINGS; + List things; + if (state.cachePolicy() == ParseQuery.CachePolicy.CACHE_ONLY) { + try { + things = new ArrayList<>(cachedThings.subList(start, end)); + } catch (IndexOutOfBoundsException e) { + // Cache miss, throw exception + return Task.forError( + new ParseException(ParseException.CACHE_MISS, "results not cached")); + } + } else { + things = new ArrayList<>(savedThings.subList(start, end)); + // Update cache + for (int i = start; i < end; i++) { + if (i < cachedThings.size()) { + cachedThings.set(i, savedThings.get(i)); + } else { + cachedThings.add(i, savedThings.get(i)); + } + } + } + return Task.forResult(things); + } + }; + when(queryController.findAsync(any(ParseQuery.State.class), any(ParseUser.class), any(Task.class))) + .thenAnswer(queryAnswer); + ParseCorePlugins.getInstance().registerQueryController(queryController); + + // Register a mock currentUserController to make getSessionToken work + ParseCurrentUserController currentUserController = mock(ParseCurrentUserController.class); + when(currentUserController.getAsync()).thenReturn(Task.forResult(mock(ParseUser.class))); + when(currentUserController.getCurrentSessionTokenAsync()) + .thenReturn(Task.forResult(null)); + ParseCorePlugins.getInstance().registerCurrentUserController(currentUserController); + + ParseObject.registerSubclass(Thing.class); + // Make test data set + for (int i = 0; i < TOTAL_THINGS; i++) { + ParseObject thing = ParseObject.create("Thing"); + thing.put("aValue", i * 10); + thing.put("name", "Thing " + i); + thing.setObjectId(String.valueOf(i)); + savedThings.add(thing); + } + } + + @Override + public void tearDown() throws Exception { + savedThings = null; + ParseCorePlugins.getInstance().reset(); + ParseObject.unregisterSubclass("Thing"); + super.tearDown(); + } + + public void testLoadObjects() throws Exception { + final ParseQueryAdapter adapter = new ParseQueryAdapter<>(activity, Thing.class); + final Semaphore done = new Semaphore(0); + adapter.addOnQueryLoadListener(new ParseQueryAdapter.OnQueryLoadListener() { + @Override + public void onLoading() { + } + + @Override + public void onLoaded(List objects, Exception e) { + assertNull(e); + assertEquals(TOTAL_THINGS, objects.size()); + done.release(); + } + }); + + adapter.loadObjects(); + + // Make sure we assert in callback is executed + assertTrue(done.tryAcquire(10, TimeUnit.SECONDS)); + } + + public void testLoadObjectsWithGenericParseObjects() throws Exception { + final ParseQueryAdapter adapter = + new ParseQueryAdapter<>(activity, Thing.class); + final Semaphore done = new Semaphore(0); + adapter.addOnQueryLoadListener(new ParseQueryAdapter.OnQueryLoadListener() { + @Override + public void onLoading() { + } + + @Override + public void onLoaded(List objects, Exception e) { + assertNull(e); + assertEquals(TOTAL_THINGS, objects.size()); + done.release(); + } + }); + + adapter.loadObjects(); + + // Make sure we assert in callback is executed + assertTrue(done.tryAcquire(10, TimeUnit.SECONDS)); + } + + public void testGetItemViewWithTextKey() throws Exception { + final ParseQueryAdapter adapter = + new ParseQueryAdapter<>(activity, Thing.class); + adapter.setTextKey("name"); + + final Semaphore done = new Semaphore(0); + adapter.addOnQueryLoadListener(new ParseQueryAdapter.OnQueryLoadListener() { + @Override + public void onLoading() { + + } + + @Override + public void onLoaded(List objects, Exception e) { + ParseQueryAdapter.ViewHolder viewHolder = adapter.onCreateViewHolder(null, 0); + adapter.onBindViewHolder(viewHolder, 0); + assertEquals("Thing 0", viewHolder.getTextView().getText().toString()); + done.release(); + } + }); + + adapter.loadObjects(); + + // Make sure we assert in callback is executed + assertTrue(done.tryAcquire(10, TimeUnit.SECONDS)); + } + + public void testGetItemViewWithNoTextKey() throws Exception { + final ParseQueryAdapter adapter = + new ParseQueryAdapter<>(activity, Thing.class); + + final Semaphore done = new Semaphore(0); + adapter.addOnQueryLoadListener(new ParseQueryAdapter.OnQueryLoadListener() { + @Override + public void onLoading() { + + } + + @Override + public void onLoaded(List objects, Exception e) { + ParseQueryAdapter.ViewHolder viewHolder = adapter.onCreateViewHolder(null, 0); + adapter.onBindViewHolder(viewHolder, 0); + assertEquals(savedThings.get(0).getObjectId(), viewHolder.getTextView().getText()); + done.release(); + } + }); + + adapter.loadObjects(); + + // Make sure we assert in callback is executed + assertTrue(done.tryAcquire(10, TimeUnit.SECONDS)); + } + + public void testLoadObjectsWithLimitsObjectsPerPage() throws Exception { + final ParseQueryAdapter adapter = new ParseQueryAdapter<>(activity, Thing.class); + final int pageSize = 4; + adapter.setObjectsPerPage(pageSize); + final Capture timesThrough = new Capture<>(0); + final Semaphore done = new Semaphore(0); + final ParseQueryAdapter.OnQueryLoadListener listener = new ParseQueryAdapter.OnQueryLoadListener() { + @Override + public void onLoading() { + } + + @Override + public void onLoaded(List objects, Exception e) { + if (e != null) { + return; + } + + switch (timesThrough.get()) { + case 0: + // first time through, should have one page of results + "Load more" + assertEquals(pageSize, objects.size()); + assertEquals(pageSize + 1, adapter.getItemCount()); + adapter.loadNextPage(); + break; + case 1: + // second time through, should have two pages of results + "Load more" + assertEquals(pageSize, objects.size()); + assertEquals(2 * pageSize + 1, adapter.getItemCount()); + adapter.loadNextPage(); + break; + case 2: + // last time through, no "Load more" necessary. + assertEquals(TOTAL_THINGS - 2 * pageSize, objects.size()); + assertEquals(TOTAL_THINGS, adapter.getItemCount()); + done.release(); + } + timesThrough.set(timesThrough.get() + 1); + } + }; + adapter.addOnQueryLoadListener(listener); + + adapter.loadObjects(); + + // Make sure we assert in callback is executed + assertTrue(done.tryAcquire(10, TimeUnit.SECONDS)); + } + + public void testLoadObjectsWithLimitsObjectsPerPageAndNoRemainder() throws Exception { + final ParseQueryAdapter adapter = new ParseQueryAdapter<>(activity, Thing.class); + final int pageSize = 5; + adapter.setObjectsPerPage(pageSize); + final Capture timesThrough = new Capture<>(0); + final Semaphore done = new Semaphore(0); + final ParseQueryAdapter.OnQueryLoadListener listener = new ParseQueryAdapter.OnQueryLoadListener() { + @Override + public void onLoading() { + } + + @Override + public void onLoaded(List objects, Exception e) { + if (e != null) { + return; + } + + switch (timesThrough.get()) { + case 0: + // first time through, should have one page of results + "Load more" cell + assertEquals(pageSize, objects.size()); + assertEquals(pageSize + 1, adapter.getItemCount()); + adapter.loadNextPage(); + break; + case 1: + // second time through, should have two pages' worth of results. It should realize that an + // additional "Load more" link isn't necessary, since this second page covers all of the + // results. + assertEquals(TOTAL_THINGS - pageSize, objects.size()); + assertEquals(TOTAL_THINGS, adapter.getItemCount()); + done.release(); + } + timesThrough.set(timesThrough.get() + 1); + } + }; + adapter.addOnQueryLoadListener(listener); + + adapter.loadObjects(); + + // Make sure we assert in callback is executed + assertTrue(done.tryAcquire(10, TimeUnit.SECONDS)); + } + + public void testLoadObjectsWithNoPagination() throws Exception { + final int additional = 16; + for (int i = 0; i < additional; i++) { + ParseObject thing = ParseObject.create(Thing.class); + thing.put("name", "Additional Thing " + i); + savedThings.add(thing); + } + TOTAL_THINGS += additional; + + final ParseQueryAdapter adapter = new ParseQueryAdapter<>(activity, Thing.class); + adapter.setPaginationEnabled(false); + final Semaphore done = new Semaphore(0); + adapter.addOnQueryLoadListener(new ParseQueryAdapter.OnQueryLoadListener() { + @Override + public void onLoading() { + } + + @Override + public void onLoaded(List objects, Exception e) { + assertNull(e); + assertEquals(TOTAL_THINGS, objects.size()); + assertEquals(TOTAL_THINGS, adapter.getItemCount()); + done.release(); + } + }); + + adapter.loadObjects(); + + // Make sure we assert in callback is executed + assertTrue(done.tryAcquire(10, TimeUnit.SECONDS)); + } + + public void testClear() throws Exception { + final ParseQueryAdapter adapter = new ParseQueryAdapter<>(activity, Thing.class); + final Semaphore done = new Semaphore(0); + final Capture counter = new Capture<>(0); + adapter.addOnQueryLoadListener(new ParseQueryAdapter.OnQueryLoadListener() { + @Override + public void onLoading() { + } + + @Override + public void onLoaded(List objects, Exception e) { + if (e != null) { + return; + } + switch (counter.get()) { + case 0: + assertEquals(TOTAL_THINGS, objects.size()); + assertEquals(TOTAL_THINGS, adapter.getItemCount()); + adapter.clear(); + assertEquals(0, adapter.getItemCount()); + adapter.loadObjects(); + break; + default: + assertEquals(TOTAL_THINGS, objects.size()); + assertEquals(TOTAL_THINGS, adapter.getItemCount()); + done.release(); + } + counter.set(counter.get() + 1); + } + }); + + adapter.loadObjects(); + + // Make sure we assert in callback is executed + assertTrue(done.tryAcquire(10, TimeUnit.SECONDS)); + } + + public void testLoadObjectsWithCacheThenNetworkQueryAndPagination() throws Exception { + ParseQueryAdapter.QueryFactory factory = new ParseQueryAdapter.QueryFactory() { + @Override + public ParseQuery create() { + ParseQuery query = new ParseQuery(Thing.class); + query.setCachePolicy(ParseQuery.CachePolicy.CACHE_THEN_NETWORK); + return query; + } + }; + + final ParseQueryAdapter adapter = new ParseQueryAdapter<>(activity, factory); + final int pageSize = 5; + adapter.setObjectsPerPage(pageSize); + adapter.setPaginationEnabled(true); + final Capture timesThrough = new Capture<>(0); + final Semaphore done = new Semaphore(0); + adapter.addOnQueryLoadListener(new ParseQueryAdapter.OnQueryLoadListener() { + @Override + public void onLoading() { + } + + @Override + public void onLoaded(List objects, Exception e) { + if (e != null) { + return; + } + + switch (timesThrough.get()) { + case 0: + // Network callback for first page + assertEquals(pageSize, objects.size()); + assertEquals(pageSize + 1, adapter.getItemCount()); + adapter.loadNextPage(); + break; + case 1: + // Network callback for second page + assertEquals(TOTAL_THINGS - pageSize, objects.size()); + assertEquals(TOTAL_THINGS, adapter.getItemCount()); + adapter.loadObjects(); + break; + case 2: + // Cache callback for first page + assertEquals(pageSize, objects.size()); + assertEquals(pageSize + 1, adapter.getItemCount()); + break; + case 3: + // Network callback for first page + assertEquals(pageSize, objects.size()); + assertEquals(pageSize + 1, adapter.getItemCount()); + adapter.loadNextPage(); + break; + case 4: + // Cache callback for second page + assertEquals(TOTAL_THINGS - pageSize, objects.size()); + assertEquals(TOTAL_THINGS, adapter.getItemCount()); + break; + case 5: + // Network callback for second page + assertEquals(TOTAL_THINGS - pageSize, objects.size()); + assertEquals(TOTAL_THINGS, adapter.getItemCount()); + done.release(); + break; + } + timesThrough.set(timesThrough.get() + 1); + } + }); + + adapter.loadObjects(); + + // Make sure we assert in callback is executed + assertTrue(done.tryAcquire(10, TimeUnit.SECONDS)); + } + + public void testLoadObjectsWithOnLoadingAndOnLoadedCallback() throws Exception { + final ParseQueryAdapter adapter = new ParseQueryAdapter<>(activity, Thing.class); + adapter.setObjectsPerPage(5); + final Capture flag = new Capture<>(false); + final Semaphore done = new Semaphore(0); + + adapter.addOnQueryLoadListener(new ParseQueryAdapter.OnQueryLoadListener() { + @Override + public void onLoading() { + assertFalse(flag.get()); + flag.set(true); + assertEquals(0, adapter.getItemCount()); + } + + @Override + public void onLoaded(List objects, Exception e) { + assertTrue(flag.get()); + assertEquals(5, objects.size()); + done.release(); + } + }); + + adapter.loadObjects(); + + // Make sure we assert in callback is executed + assertTrue(done.tryAcquire(10, TimeUnit.SECONDS)); + } + + public void testLoadNextPageBeforeLoadObjects() throws Exception { + final ParseQueryAdapter adapter = new ParseQueryAdapter<>(activity, Thing.class); + final Semaphore done = new Semaphore(0); + adapter.addOnQueryLoadListener(new ParseQueryAdapter.OnQueryLoadListener() { + @Override + public void onLoading() { + } + + @Override + public void onLoaded(List objects, Exception e) { + assertNull(e); + assertEquals(TOTAL_THINGS, objects.size()); + done.release(); + } + }); + + adapter.loadNextPage(); + + // Make sure we assert in callback is executed + assertTrue(done.tryAcquire(10, TimeUnit.SECONDS)); + } + + public void testIncomingQueryResultAfterClearing() throws Exception { + final ParseQueryAdapter adapter = new ParseQueryAdapter<>(activity, Thing.class); + final int pageSize = 4; + adapter.setObjectsPerPage(pageSize); + final Semaphore done = new Semaphore(0); + final Capture timesThrough = new Capture<>(0); + adapter.addOnQueryLoadListener(new ParseQueryAdapter.OnQueryLoadListener() { + @Override + public void onLoading() {} + + @Override + public void onLoaded(List objects, Exception e) { + switch (timesThrough.get()) { + case 0: + adapter.loadNextPage(); + adapter.clear(); + case 1: + done.release(); + } + timesThrough.set(timesThrough.get()+1); + } + }); + + adapter.loadObjects(); + + // Make sure we assert in callback is executed + assertTrue(done.tryAcquire(10, TimeUnit.SECONDS)); + } + + public void testLoadObjectsWithOverrideSetPageOnQuery() throws Exception { + final int arbitraryLimit = 3; + final ParseQueryAdapter adapter = + new ParseQueryAdapter(activity, Thing.class) { + @Override + public void setPageOnQuery(int page, ParseQuery query) { + // Make sure that this method is being used + respected. + query.setLimit(arbitraryLimit); + } + }; + final Semaphore done = new Semaphore(0); + adapter.addOnQueryLoadListener(new ParseQueryAdapter.OnQueryLoadListener() { + @Override + public void onLoading() { + }; + + @Override + public void onLoaded(List objects, Exception e) { + assertEquals(arbitraryLimit, objects.size()); + done.release(); + } + }); + + adapter.loadObjects(); + + // Make sure we assert in callback is executed + assertTrue(done.tryAcquire(10, TimeUnit.SECONDS)); + } + + public void testLoadObjectsWithtAutoload() throws Exception { + final ParseQueryAdapter adapter = new ParseQueryAdapter<>(activity, Thing.class); + final Capture flag = new Capture<>(false); + // Make sure that the Adapter doesn't start trying to load objects until AFTER we set this flag + // to true (= triggered by calling setAutoload, NOT registerDataSetObserver, if autoload is + // false). + adapter.setAutoload(false); + final Semaphore done = new Semaphore(0); + adapter.addOnQueryLoadListener(new ParseQueryAdapter.OnQueryLoadListener() { + @Override + public void onLoading() { + assertEquals(0, adapter.getItemCount()); + assertTrue(flag.get()); + } + + @Override + public void onLoaded(List objects, Exception e) { + assertEquals(TOTAL_THINGS, adapter.getItemCount()); + done.release(); + } + }); + RecyclerView.AdapterDataObserver observer = new RecyclerView.AdapterDataObserver() { }; + adapter.registerAdapterDataObserver(observer); + flag.set(true); + adapter.setAutoload(true); + + // Make sure we assert in callback is executed + assertTrue(done.tryAcquire(10, TimeUnit.SECONDS)); + } + + private LinearLayout buildReusableListCell() { + LinearLayout view = new LinearLayout(activity); + TextView textView = new TextView(activity); + textView.setId(android.R.id.text1); + view.addView(textView); + ParseImageView imageView = new ParseImageView(activity); + imageView.setId(android.R.id.icon); + view.addView(imageView); + return view; + } +} diff --git a/ParseLoginUI/src/main/java/com/parse/widget/ParseQueryAdapter.java b/ParseLoginUI/src/main/java/com/parse/widget/ParseQueryAdapter.java new file mode 100644 index 0000000..4d287df --- /dev/null +++ b/ParseLoginUI/src/main/java/com/parse/widget/ParseQueryAdapter.java @@ -0,0 +1,718 @@ +/* + * Copyright (c) 2015, Parse, LLC. All rights reserved. + * + * You are hereby granted a non-exclusive, worldwide, royalty-free license to use, + * copy, modify, and distribute this software in source code or binary form for use + * in connection with the web services and APIs provided by Parse. + * + * As with any software that integrates with the Parse platform, your use of + * this software is subject to the Parse Terms of Service + * [https://www.parse.com/about/terms]. This copyright notice shall be + * included in all copies or substantial portions of the software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + +package com.parse.widget; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.support.v7.widget.RecyclerView; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.parse.FindCallback; +import com.parse.ParseClassName; +import com.parse.ParseException; +import com.parse.ParseFile; +import com.parse.ParseImageView; +import com.parse.ParseObject; +import com.parse.ParseQuery; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.ConcurrentHashMap; + +import bolts.Capture; + +/** + * A {@code ParseQueryAdapter} handles the fetching of objects by page, and displaying + * objects as views in a {@link android.support.v7.widget.RecyclerView}. + *

+ * This class is highly configurable, but also intended to be easy to get started with. See below + * for an example of using a {@code ParseQueryAdapter} inside an + * {@link android.app.Activity}'s {@code onCreate}: + *

+ * final ParseQueryAdapter adapter
+ *         = new ParseQueryAdapter(this, "TestObject");
+ * adapter.setTextKey("name");
+ *
+ * RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view);
+ * recyclerView.setAdapter(adapter);
+ * recyclerView.setLayoutManager(new LinearLayoutManager(this));
+ *
+ * adapter.loadObjects();
+ * 
+ *

+ * Below, an example showing off the level of configuration available with this class: + *

+ * // Instantiate a QueryFactory to define the ParseQuery to be used for fetching items in this
+ * // Adapter.
+ * ParseQueryAdapter.QueryFactory<ParseObject> factory =
+ *     new ParseQueryAdapter.QueryFactory<ParseObject>() {
+ *       public ParseQuery create() {
+ *         ParseQuery query = new ParseQuery("Customer");
+ *         query.whereEqualTo("activated", true);
+ *         query.orderByDescending("moneySpent");
+ *         return query;
+ *       }
+ *     };
+ *
+ * // Pass the factory into the ParseQueryAdapter's constructor.
+ * ParseQueryAdapter<ParseObject> adapter
+ *         = new ParseQueryAdapter<ParseObject>(this, factory);
+ * adapter.setTextKey("name");
+ *
+ * // Perhaps set a callback to be fired upon successful loading of a new set of ParseObjects.
+ * adapter.addOnQueryLoadListener(new OnQueryLoadListener<ParseObject>() {
+ *   public void onLoading() {
+ *     // Trigger any "loading" UI
+ *   }
+ *
+ *   public void onLoaded(List<ParseObject> objects, ParseException e) {
+ *     // Execute any post-loading logic, hide "loading" UI
+ *   }
+ * });
+ *
+ * 
+ */ +public class ParseQueryAdapter extends RecyclerView.Adapter { + + /** + * Implement to construct your own custom {@link ParseQuery} for fetching objects. + */ + public interface QueryFactory { + ParseQuery create(); + } + + /** + * Implement with logic that is called before and after objects are fetched from Parse by the + * adapter. + */ + public interface OnQueryLoadListener { + void onLoading(); + + void onLoaded(List objects, Exception e); + } + + /** + * OnClickListener. + */ + public interface OnClickListener { + void onClick(T item, int position); + } + + /** + * ViewHolder. + */ + public class ViewHolder extends RecyclerView.ViewHolder { + private TextView textView; + private ParseImageView imageView; + + public ViewHolder(View itemView) { + super(itemView); + + try { + textView = (TextView) itemView.findViewById(android.R.id.text1); + } catch (ClassCastException ex) { + throw new IllegalStateException( + "Your object views must have a TextView whose id attribute is 'android.R.id.text1'", ex); + } + try { + imageView = (ParseImageView) itemView.findViewById(android.R.id.icon); + } catch (ClassCastException ex) { + throw new IllegalStateException( + "Your object views must have a ParseImageView whose id attribute is 'android.R.id.icon'", ex); + } + } + + public void bind(final T object, final int position) { + if (textKey == null) { + textView.setText(object.getObjectId()); + } else if (object.get(textKey) != null) { + textView.setText(object.get(textKey).toString()); + } else { + textView.setText(null); + } + + if (imageKey != null) { + if (imageView == null) { + throw new IllegalStateException( + "Your object views must have a ParseImageView whose id attribute is 'android.R.id.icon' if an imageKey is specified"); + } + if (!imageViewSet.containsKey(imageView)) { + imageViewSet.put(imageView, null); + } + imageView.setPlaceholder(placeholder); + imageView.setParseFile((ParseFile) object.get(imageKey)); + imageView.loadInBackground(); + } + textView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (onClickListener != null) { + onClickListener.onClick(object, position); + } + } + }); + } + + public void setNextView() { + textView.setText("Load more..."); + if (imageView != null) { + imageView.setVisibility(View.INVISIBLE); + } + textView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + loadNextPage(); + } + }); + } + + public TextView getTextView() { + return textView; + } + + public ParseImageView getImageView() { + return imageView; + } + } + + // The key to use to display on the cell text label. + private String textKey; + + // The key to use to fetch an image for display in the cell's image view. + private String imageKey; + + // The number of objects to show per page (default: 25) + private int objectsPerPage = 25; + + // Whether the table should use the built-in pagination feature (default: + // true) + private boolean paginationEnabled = true; + + // A Drawable placeholder, to be set on ParseImageViews while images are loading. Can be null. + private Drawable placeholder; + + // A WeakHashMap, holding references to ParseImageViews that have been configured. + // Accessed and iterated over if setPlaceholder(Drawable) is called after some set of + // ParseImageViews have already been instantiated and configured. + private WeakHashMap imageViewSet = new WeakHashMap<>(); + + // A WeakHashMap, keeping track of the DataSetObservers on this class + private WeakHashMap dataSetObservers = new WeakHashMap<>(); + + // Whether the adapter should trigger loadObjects() on registerDataSetObserver(); Defaults to + // true. + private boolean autoload = true; + + private Context context; + + private List objects = new ArrayList<>(); + + private Set runningQueries = Collections.newSetFromMap(new ConcurrentHashMap()); + + // Used to keep track of the pages of objects when using CACHE_THEN_NETWORK. When using this, + // the data will be flattened and put into the objects list. + private List> objectPages = new ArrayList<>(); + + private int currentPage = 0; + + private int itemResourceId; + + private boolean hasNextPage = true; + + private QueryFactory queryFactory; + + private OnClickListener onClickListener; + + private List> onQueryLoadListeners = new ArrayList<>(); + + /** + * Constructs a {@code ParseQueryAdapter}. Given a {@link ParseObject} subclass, + * this adapter will fetch and display all {@link ParseObject}s of the specified class, + * ordered by creation time. + * + * @param context + * The activity utilizing this adapter. + * @param clazz + * The {@link ParseObject} subclass type to fetch and display. + */ + public ParseQueryAdapter(Context context, Class clazz) { + this(context, getClassName(clazz)); + } + + /** + * Constructs a {@code ParseQueryAdapter}. Given a {@link ParseObject} subclass, + * this adapter will fetch and display all {@link ParseObject}s of the specified class, ordered + * by creation time. + * + * @param context + * The activity utilizing this adapter. + * @param className + * The name of the Parse class of {@link ParseObject}s to display. + */ + public ParseQueryAdapter(Context context, final String className) { + this(context, new QueryFactory() { + @Override + public ParseQuery create() { + ParseQuery query = ParseQuery.getQuery(className); + query.orderByDescending("createdAt"); + + return query; + } + }); + + if (className == null) { + throw new RuntimeException("You need to specify a className for the ParseQueryAdapter"); + } + } + + /** + * Constructs a {@code ParseQueryAdapter}. Given a {@link ParseObject} subclass, + * this adapter will fetch and display all {@link ParseObject}s of the specified class, ordered + * by creation time. + * + * @param context + * The activity utilizing this adapter. + * @param clazz + * The {@link ParseObject} subclass type to fetch and display. + * @param itemViewResource + * A resource id that represents the layout for an item in the AdapterView. + */ + public ParseQueryAdapter( + Context context, Class clazz, int itemViewResource) { + this(context, getClassName(clazz), itemViewResource); + } + + /** + * Constructs a {@code ParseQueryAdapter}. Given a {@link ParseObject} subclass, + * this adapter will fetch and display all {@link ParseObject}s of the specified class, ordered + * by creation time. + * + * @param context + * The activity utilizing this adapter. + * @param className + * The name of the Parse class of {@link ParseObject}s to display. + * @param itemViewResource + * A resource id that represents the layout for an item in the AdapterView. + */ + public ParseQueryAdapter( + Context context, final String className, int itemViewResource) { + this(context, new QueryFactory() { + @Override + public ParseQuery create() { + ParseQuery query = ParseQuery.getQuery(className); + query.orderByDescending("createdAt"); + + return query; + } + }, itemViewResource); + + if (className == null) { + throw new RuntimeException( + "You need to specify a className for the ParseQueryAdapter"); + } + } + /** + * Constructs a {@code ParseQueryAdapter}. Allows the caller to define further + * constraints on the {@link ParseQuery} to be used when fetching items from Parse. + * + * @param context + * The activity utilizing this adapter. + * @param queryFactory + * A {@link QueryFactory} to build a {@link ParseQuery} for fetching objects. + */ + public ParseQueryAdapter(Context context, QueryFactory queryFactory) { + this(context, queryFactory, -1); + } + + /** + * Constructs a {@code ParseQueryAdapter}. Allows the caller to define further + * constraints on the {@link ParseQuery} to be used when fetching items from Parse. + * + * @param context + * The activity utilizing this adapter. + * @param queryFactory + * A {@link QueryFactory} to build a {@link ParseQuery} for fetching objects. + * @param itemViewResource + * A resource id (>0) that represents the layout for an item in the AdapterView. + */ + public ParseQueryAdapter( + Context context, QueryFactory queryFactory, int itemViewResource) { + super(); + this.context = context; + this.queryFactory = queryFactory; + this.itemResourceId = itemViewResource; + } + + /** + * Return the context provided by the {@code Activity} utilizing this {@code ParseQueryAdapter}. + * + * @return The activity utilizing this adapter. + */ + public Context getContext() { + return this.context; + } + + public void clear() { + objectPages.clear(); + cancelAllQueries(); + syncObjectsWithPages(); + notifyDataSetChanged(); + currentPage = 0; + } + + private void cancelAllQueries() { + for (ParseQuery q : runningQueries) { + q.cancel(); + } + runningQueries.clear(); + } + + /** + * Clears the table and loads the first page of objects asynchronously. This method is called + * automatically when this {@code Adapter} is attached to an {@code AdapterView}. + *

+ * {@code loadObjects()} should only need to be called if {@link #setAutoload(boolean)} is set to + * {@code false}. + */ + public void loadObjects() { + loadObjects(0, true); + } + + private void loadObjects(final int page, final boolean shouldClear) { + final ParseQuery query = queryFactory.create(); + + if (objectsPerPage > 0 && paginationEnabled) { + setPageOnQuery(page, query); + } + + notifyOnLoadingListeners(); + + // Create a new page + if (page >= objectPages.size()) { + objectPages.add(page, new ArrayList()); + } + + // In the case of CACHE_THEN_NETWORK, two callbacks will be called. Using this flag to keep track, + final Capture firstCallBack = new Capture<>(true); + + runningQueries.add(query); + + // TODO convert to Tasks and CancellationTokens + // (depends on https://github.com/ParsePlatform/Parse-SDK-Android/issues/6) + query.findInBackground(new FindCallback() { + @Override + public void done(List foundObjects, ParseException e) { + if (!runningQueries.contains(query)) { + return; + } + // In the case of CACHE_THEN_NETWORK, two callbacks will be called. We can only remove the + // query after the second callback. + if (query.getCachePolicy() != ParseQuery.CachePolicy.CACHE_THEN_NETWORK || + (query.getCachePolicy() == ParseQuery.CachePolicy.CACHE_THEN_NETWORK && !firstCallBack.get())) { + runningQueries.remove(query); + } + try { + if (query.getCachePolicy() == ParseQuery.CachePolicy.CACHE_ONLY + && e != null && e.getCode() == ParseException.CACHE_MISS) { + // no-op on cache miss + return; + } + } catch (IllegalStateException ignore) { + // LocaleDatastore disabled + } + + if ((e != null) && ((e.getCode() == ParseException.CONNECTION_FAILED) || (e.getCode() != ParseException.CACHE_MISS))) { + hasNextPage = true; + } else if (foundObjects != null) { + if (shouldClear && firstCallBack.get()) { + runningQueries.remove(query); + cancelAllQueries(); + runningQueries.add(query); // allow 2nd callback + objectPages.clear(); + objectPages.add(new ArrayList()); + currentPage = page; + firstCallBack.set(false); + } + + // Only advance the page, this prevents second call back from CACHE_THEN_NETWORK to + // reset the page. + if (page >= currentPage) { + currentPage = page; + + // since we set limit == objectsPerPage + 1 + hasNextPage = (foundObjects.size() > objectsPerPage); + } + + if (paginationEnabled && foundObjects.size() > objectsPerPage) { + // Remove the last object, fetched in order to tell us whether there was a "next page" + foundObjects.remove(objectsPerPage); + } + + List currentPage = objectPages.get(page); + currentPage.clear(); + currentPage.addAll(foundObjects); + + syncObjectsWithPages(); + + // executes on the UI thread + notifyDataSetChanged(); + } + + notifyOnLoadedListeners(foundObjects, e); + } + }); + } + + /** + * Loads the next page of objects, appends to table, and notifies the UI that the model has + * changed. + */ + public void loadNextPage() { + if (objects.size() == 0 && runningQueries.size() == 0) { + loadObjects(0, false); + } + else { + loadObjects(currentPage + 1, false); + } + } + + private View getDefaultView() { + if (this.itemResourceId != -1) { + return View.inflate(context, itemResourceId, null); + } + LinearLayout view = new LinearLayout(context); + view.setPadding(8, 4, 8, 4); + + ParseImageView imageView = new ParseImageView(context); + imageView.setId(android.R.id.icon); + imageView.setLayoutParams(new LinearLayout.LayoutParams(50, 50)); + view.addView(imageView); + + TextView textView = new TextView(context); + textView.setId(android.R.id.text1); + textView.setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + textView.setPadding(8, 0, 0, 0); + view.addView(textView); + + return view; + } + + @Override + public ViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) { + return new ViewHolder(getDefaultView()); + } + + @Override + public void onBindViewHolder(ParseQueryAdapter.ViewHolder viewHolder, final int position) { + if (position < objects.size()) { + final T object = objects.get(position); + viewHolder.bind(object, position); + } + else if (position == objects.size()) { + viewHolder.setNextView(); + } + else { + throw new RuntimeException(); + } + } + + @Override + public int getItemCount() { + int count = objects.size(); + + if (shouldShowPaginationCell()) { + count++; + } + + return count; + } + + @Override + public void registerAdapterDataObserver(RecyclerView.AdapterDataObserver observer) { + super.registerAdapterDataObserver(observer); + dataSetObservers.put(observer, null); + if (autoload) { + loadObjects(); + } + } + + @Override + public void unregisterAdapterDataObserver(RecyclerView.AdapterDataObserver observer) { + super.unregisterAdapterDataObserver(observer); + this.dataSetObservers.remove(observer); + } + + + /** + * This is a helper function to sync the objects with objectPages. This is only used with the + * CACHE_THEN_NETWORK option. + */ + private void syncObjectsWithPages() { + objects.clear(); + for (List pageOfObjects : objectPages) { + objects.addAll(pageOfObjects); + } + } + + private int getPaginationCellRow() { + return objects.size(); + } + + private boolean shouldShowPaginationCell() { + return paginationEnabled && objects.size() > 0 && hasNextPage; + } + + private void notifyOnLoadingListeners() { + for (OnQueryLoadListener listener : onQueryLoadListeners) { + listener.onLoading(); + } + } + + private void notifyOnLoadedListeners(List objects, Exception e) { + for (OnQueryLoadListener listener : onQueryLoadListeners) { + listener.onLoaded(objects, e); + } + } + + /** + * Override this method to manually paginate the provided {@code ParseQuery}. By default, this + * method will set the {@code limit} value to {@link #getObjectsPerPage()} and the {@code skip} + * value to {@link #getObjectsPerPage()} * {@code page}. + *

+ * Overriding this method will not be necessary, in most cases. + * + * @param page + * the page number of results to fetch from Parse. + * @param query + * the {@link ParseQuery} used to fetch items from Parse. This query will be mutated and + * used in its mutated form. + */ + protected void setPageOnQuery(int page, ParseQuery query) { + query.setLimit(objectsPerPage + 1); + query.setSkip(page * objectsPerPage); + } + + public void setTextKey(String textKey) { + this.textKey = textKey; + } + + public void setImageKey(String imageKey) { + this.imageKey = imageKey; + } + + public void setObjectsPerPage(int objectsPerPage) { + this.objectsPerPage = objectsPerPage; + } + + public int getObjectsPerPage() { + return objectsPerPage; + } + + /** + * Enable or disable pagination of results. Defaults to true. + * + * @param paginationEnabled + * Defaults to true. + */ + public void setPaginationEnabled(boolean paginationEnabled) { + this.paginationEnabled = paginationEnabled; + } + + /** + * Sets a placeholder image to be used when fetching data for each item in the {@code AdapterView} + * . Will not be used if {@link #setImageKey(String)} was not used to define which images to + * display. + * + * @param placeholder + * A {@code Drawable} to be displayed while the remote image data is being fetched. This + * value can be null, and {@code ImageView}s in this AdapterView will simply be blank + * while data is being fetched. + */ + public void setPlaceholder(Drawable placeholder) { + if (this.placeholder == placeholder) { + return; + } + this.placeholder = placeholder; + Iterator iter = imageViewSet.keySet().iterator(); + ParseImageView imageView; + while (iter.hasNext()) { + imageView = iter.next(); + if (imageView != null) { + imageView.setPlaceholder(this.placeholder); + } + } + } + + public void setOnClickListener(OnClickListener onClickListener) { + this.onClickListener = onClickListener; + } + + /** + * Enable or disable the automatic loading of results upon attachment to an {@code AdapterView}. + * Defaults to true. + * + * @param autoload + * Defaults to true. + */ + public void setAutoload(boolean autoload) { + if (this.autoload == autoload) { + // An extra precaution to prevent an overzealous setAutoload(true) after assignment to an + // AdapterView from triggering an unnecessary additional loadObjects(). + return; + } + this.autoload = autoload; + if (autoload && !dataSetObservers.isEmpty() && objects.isEmpty()) { + loadObjects(); + } + } + + public void addOnQueryLoadListener(OnQueryLoadListener listener) { + onQueryLoadListeners.add(listener); + } + + public void removeOnQueryLoadListener(OnQueryLoadListener listener) { + onQueryLoadListeners.remove(listener); + } + + /** + * Gets the class name based on the {@link ParseClassName} annotation associated with a class. + * + * @param clazz + * The class to inspect. + * @return The name of the Parse class, if one is provided. Otherwise, {@code null}. + */ + private static String getClassName(Class clazz) { + ParseClassName info = clazz.getAnnotation(ParseClassName.class); + if (info == null) { + return null; + } + return info.value(); + } +} diff --git a/ParseQueryAdapterExample/build.gradle b/ParseQueryAdapterExample/build.gradle new file mode 100644 index 0000000..06bf48d --- /dev/null +++ b/ParseQueryAdapterExample/build.gradle @@ -0,0 +1,18 @@ +apply plugin: 'android' + +dependencies { + // rootProject.ext.* variables are defined in project gradle file, you can also use path here. + compile project(':ParseLoginUI') + compile rootProject.ext.androidSupport + compile rootProject.ext.parse +} + +android { + compileSdkVersion rootProject.ext.compileSdkVersion + buildToolsVersion rootProject.ext.buildToolsVersion + + defaultConfig { + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + } +} diff --git a/ParseQueryAdapterExample/src/main/AndroidManifest.xml b/ParseQueryAdapterExample/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cc05984 --- /dev/null +++ b/ParseQueryAdapterExample/src/main/AndroidManifest.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ParseQueryAdapterExample/src/main/java/com/parse/queryadaptersample/SampleActivity.java b/ParseQueryAdapterExample/src/main/java/com/parse/queryadaptersample/SampleActivity.java new file mode 100644 index 0000000..90173b0 --- /dev/null +++ b/ParseQueryAdapterExample/src/main/java/com/parse/queryadaptersample/SampleActivity.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2014, Parse, LLC. All rights reserved. + * + * You are hereby granted a non-exclusive, worldwide, royalty-free license to use, + * copy, modify, and distribute this software in source code or binary form for use + * in connection with the web services and APIs provided by Parse. + * + * As with any software that integrates with the Parse platform, your use of + * this software is subject to the Parse Terms of Service + * [https://www.parse.com/about/terms]. This copyright notice shall be + * included in all copies or substantial portions of the software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + +package com.parse.queryadaptersample; + +import android.app.Activity; +import android.os.Bundle; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; + +import com.parse.ParseUser; +import com.parse.widget.ParseQueryAdapter; + +/** + * Shows a list of "testString"s in the class "TestClass" using a + * {@link android.support.v7.widget.RecyclerView} and {@code com.parse.widget.ParseQueryAdapter}. + */ +public class SampleActivity extends Activity { + + private RecyclerView recyclerView; + + private ParseUser currentUser; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.activity); + + recyclerView = (RecyclerView) findViewById(R.id.recycler_view); + + ParseQueryAdapter adapter = new ParseQueryAdapter(this, "TestClass"); + + // Configure adapter. + adapter.setObjectsPerPage(3); + adapter.setTextKey("testString"); + + adapter.loadObjects(); + + recyclerView.setAdapter(adapter); + recyclerView.setLayoutManager(new LinearLayoutManager(this)); + } +} diff --git a/ParseQueryAdapterExample/src/main/java/com/parse/queryadaptersample/SampleApplication.java b/ParseQueryAdapterExample/src/main/java/com/parse/queryadaptersample/SampleApplication.java new file mode 100644 index 0000000..7dd408f --- /dev/null +++ b/ParseQueryAdapterExample/src/main/java/com/parse/queryadaptersample/SampleApplication.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2014, Parse, LLC. All rights reserved. + * + * You are hereby granted a non-exclusive, worldwide, royalty-free license to use, + * copy, modify, and distribute this software in source code or binary form for use + * in connection with the web services and APIs provided by Parse. + * + * As with any software that integrates with the Parse platform, your use of + * this software is subject to the Parse Terms of Service + * [https://www.parse.com/about/terms]. This copyright notice shall be + * included in all copies or substantial portions of the software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + +package com.parse.queryadaptersample; + +import android.app.Application; + +import com.parse.Parse; + +public class SampleApplication extends Application { + @Override + public void onCreate() { + super.onCreate(); + // Required - Initialize the Parse SDK + Parse.initialize(this); + + Parse.setLogLevel(Parse.LOG_LEVEL_DEBUG); + } +} diff --git a/ParseQueryAdapterExample/src/main/res/drawable/ic_launcher.png b/ParseQueryAdapterExample/src/main/res/drawable/ic_launcher.png new file mode 100644 index 0000000..0eee02d Binary files /dev/null and b/ParseQueryAdapterExample/src/main/res/drawable/ic_launcher.png differ diff --git a/ParseQueryAdapterExample/src/main/res/layout/activity.xml b/ParseQueryAdapterExample/src/main/res/layout/activity.xml new file mode 100644 index 0000000..d28e207 --- /dev/null +++ b/ParseQueryAdapterExample/src/main/res/layout/activity.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/ParseQueryAdapterExample/src/main/res/values/strings.xml b/ParseQueryAdapterExample/src/main/res/values/strings.xml new file mode 100644 index 0000000..8a6e893 --- /dev/null +++ b/ParseQueryAdapterExample/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + + + + YOUR_PARSE_APP_ID + YOUR_PARSE_CLIENT_KEY + + Parse QueryAdapter Sample + diff --git a/ParseQueryAdapterExample/src/main/res/values/themes.xml b/ParseQueryAdapterExample/src/main/res/values/themes.xml new file mode 100644 index 0000000..4084ba0 --- /dev/null +++ b/ParseQueryAdapterExample/src/main/res/values/themes.xml @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/settings.gradle b/settings.gradle index 036e858..faa9065 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,3 +8,4 @@ include ':ParseLoginSampleBasic' include ':ParseLoginSampleWithDispatchActivity' include ':ParseLoginSampleCodeCustomization' include ':ParseLoginSampleLayoutOverride' +include ':ParseQueryAdapterExample'