diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09b993d --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8dada3e --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca07a0f --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +Tenor Android Search Demo +========================= + +## Introduction +In this demo, we will look at how to create a simple GIF search app. +We will have our GIFs be searchable in three different ways: + +1. **Tags**: A display of terms with a GIF preview background. When clicked, a search for the tag's term will occur. +2. **Search Box**: Searches performed by entering a search term on the EditText. +3. **Related Suggestions**: Similar to Tags, these are displayed search terms that when clicked on will open a new search. In the demo they appear as related terms to the search, and display above the GIFs in a horizontal scroller. + +## Tenor Android Core +In order to use our API, you must have the `tenor-android-core` as part of your app. Download the Tenor Android Core [here](https://github.com/Tenor-Inc/tenor-android-core). + +### Model View Presenter (Optional) +This demo uses the MVP framework. To read more about the MVP architectural pattern, click [here](https://en.wikipedia.org/wiki/Model-view-presenter). + +_Note_, that MVP is not required for using our API. It can work in any view structure, this demo just shows one example of how it can be used. + +## Using Reaction Tags +When you first open the demo, you will be in the [MainActivity][mainactivity]. In order to get our list of tags, we will use a presenter class, [MainPresenter][mainpresenter], to make the necessary API call. + +Once the API call has been constructed, we need to have its results returned, and then displayed. In our example, [IMainView][i_mainview] provides us with callbacks necessary to return the API response to our activity. + +To see a detailed look of the Tag response JSON object, click [here](https://tenor.com/gifapi#tags). + +Once you have your response, we will need to load the tags into our view. We recommend having a TextView layered on top of an ImageView, as seen in the [TagItemVH][tagitemvh]. The GIF preview background will be loaded via the `GlideTaskParams`. + +The demo displays the [TagItemVH][tagitemvh] through the [TagsAdapter][tagsadapter]. When the [TagItemVH][tagitemvh] is clicked, a tag will open a [SearchActivity][searchactivity], passing the tag name through `SearchActivity.KEY_QUERY`. + + +## Typed Search +Using the EditText inside our [MainActivity][mainactivity], we open the [SearchActivity][searchactivity] with `EditorInfo.IME_ACTION_SEARCH`. Like tags, the query is passed to [SearchActivity][searchactivity] via `SearchActivity.KEY_QUERY`. + +Once the activity has loaded, search calls are performed through the search presenter class, [GifSearchPresenter][searchpresenter]. Callbacks are made through [IGifSearchView][i_searchview]. + +To see a detailed look of the Gif response JSON object, click [here](https://tenor.com/gifapi#responseobjects). + +Like Tags, we will load the results returned through the API response into ImageViews, as seen in the [GifSearchItemVH][gifitemvh]. +They will also be loaded using the `GlideTaskParams`. +The demo displays the [GifSearchItemVH][gifitemvh] through the [GifSearchAdapter][searchadapter]. For the demo, no full click functionality has been added. How you wish to handle click events with GIFs is up to you. + + +## Suggestions (Optional) +An additional feature the demo uses in [SearchActivity][searchactivity] is to take the search query from `SearchActivity.KEY_QUERY` and call the `getSearchSuggestionsEndpoint()` in the [SearchSuggestionPresenter][suggestionpresenter]. It returns a search suggestion response (see [here](https://tenor.com/gifapi#suggestions)) and is displayed in the [SearchSuggestionAdapter][searchsuggestionadapter]. + +The adapter, and its view holder [SearchSuggestionVH][suggestionitemvh] are displayed as the top level element of the [GifSearchAdapter][searchadapter]. Clicking them will open a new instance of [SearchActivity][searchactivity]. + +Suggestions are a useful tool in the Tenor API to refine searches. We recommend using them for a more full GIF search experience. + +[mainactivity]: app/src/main/java/com/tenor/android/demo/search/activity/MainActivity.java +[mainpresenter]: app/src/main/java/com/tenor/android/demo/search/presenter/impl/MainPresenter.java +[i_mainview]: app/src/main/java/com/tenor/android/demo/search/adapter/view/IMainView.java +[tagitemvh]: app/src/main/java/com/tenor/android/demo/search/adapter/holder/TagItemVH.java +[tagsadapter]: app/src/main/java/com/tenor/android/demo/search/adapter/TagsAdapter.java + +[searchactivity]: app/src/main/java/com/tenor/android/demo/search/activity/SearchActivity.java +[searchpresenter]: app/src/main/java/com/tenor/android/demo/search/presenter/impl/GifSearchPresenter.java +[i_searchview]: app/src/main/java/com/tenor/android/demo/search/adapter/view/IGifSearchView.java +[gifitemvh]: app/src/main/java/com/tenor/android/demo/search/adapter/holder/GifSearchItemVH.java +[searchadapter]: app/src/main/java/com/tenor/android/demo/search/adapter/GifSearchAdapter.java + +[suggestionpresenter]: app/src/main/java/com/tenor/android/sdk/presenter/impl/SearchSuggestionPresenter.java +[suggestionitemvh]: app/src/main/java/com/tenor/android/sdk/adapter/holder/SearchSearchItemVH.java +[searchsuggestionadapter]: app/src/main/java/com/tenor/android/sdk/adapter/SearchSuggestionAdapter.java + + + + + diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..5979088 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,49 @@ +apply plugin: 'com.android.application' + +ext { + mSptLibVer = "26.0.1" +} + +android { + def vMajor = 0 // range between [0,+INF) + def vMinor = 1 // range between [0,9] + def vPatch = 0 // range between [0,99] + compileSdkVersion 26 + buildToolsVersion "26.0.1" + defaultConfig { + applicationId "com.tenor.android.demo.search" + minSdkVersion 15 + targetSdkVersion 26 + versionCode vMajor * 1000 + vMinor * 100 + vPatch + versionName "${vMajor}.${vMinor}.${vPatch}" + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + + compile project(':tenor-android-core') + + compile "com.android.support:recyclerview-v7:${mSptLibVer}" + compile 'com.squareup.retrofit2:converter-gson:2.3.0' + compile 'com.github.bumptech.glide:glide:3.8.0' + + compile "com.android.support:support-v4:${mSptLibVer}" + + compile("com.android.support:design:${mSptLibVer}") { + exclude group: 'com.android.support', module: 'support-v4' + exclude group: 'com.android.support', module: 'support-compat' + exclude group: 'com.android.support', module: 'support-vector-drawable' + } + + compile "com.android.support:cardview-v7:${mSptLibVer}" + compile "com.android.support:support-annotations:${mSptLibVer}" + compile "com.android.support:percent:${mSptLibVer}" + compile "com.android.support:support-vector-drawable:${mSptLibVer}" +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..5613366 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,25 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in ~/Library/Android/sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1fba1de --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/tenor/android/demo/search/activity/MainActivity.java b/app/src/main/java/com/tenor/android/demo/search/activity/MainActivity.java new file mode 100644 index 0000000..c6dc234 --- /dev/null +++ b/app/src/main/java/com/tenor/android/demo/search/activity/MainActivity.java @@ -0,0 +1,123 @@ +package com.tenor.android.demo.search.activity; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.StaggeredGridLayoutManager; +import android.text.TextUtils; +import android.view.KeyEvent; +import android.view.inputmethod.EditorInfo; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; + +import com.tenor.android.core.constant.StringConstant; +import com.tenor.android.core.model.impl.Tag; +import com.tenor.android.core.response.BaseError; +import com.tenor.android.core.util.AbstractUIUtils; +import com.tenor.android.core.widget.adapter.AbstractRVItem; +import com.tenor.android.demo.search.R; +import com.tenor.android.demo.search.adapter.TagsAdapter; +import com.tenor.android.demo.search.adapter.decorations.MainTagsItemDecoration; +import com.tenor.android.demo.search.adapter.rvitem.TagRVItem; +import com.tenor.android.demo.search.adapter.view.IMainView; +import com.tenor.android.demo.search.presenter.IMainPresenter; +import com.tenor.android.demo.search.presenter.impl.MainPresenter; +import com.tenor.android.demo.search.widget.TenorStaggeredGridLayoutManager; + +import java.util.ArrayList; +import java.util.List; + +/** + * For the MainActivity, we will display a search bar followed by a stream of Tags pulled from the Tenor API. + * Either by clicking on a tag or entering a search, SearchActivity will open. + */ +public class MainActivity extends AppCompatActivity implements IMainView{ + // Number of columns for the RecyclerView + private static final int STAGGERED_GRID_LAYOUT_COLUMN_NUMBER = 2; + // Minimum length a search term can be + private static final int TEXT_QUERY_MIN_LENGTH = 2; + + // A search box for entering a search term + public EditText mEditText; + // RecyclerView to display the stream of Tags + public RecyclerView mRecyclerView; + + // Api calls for MainActivity performed here + private IMainPresenter mPresenter; + // Adapter containing the tag items/view holders + private TagsAdapter mTagsAdapter; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + mEditText = (EditText) findViewById(R.id.am_et_search); + mEditText.setOnEditorActionListener(new TextView.OnEditorActionListener() { + @Override + public boolean onEditorAction(TextView textView, int actionId, KeyEvent keyEvent) { + + final String query = textView.getText().toString().trim(); + + if (query.length() < TEXT_QUERY_MIN_LENGTH) { + Toast.makeText(MainActivity.this, getString(R.string.search_error), Toast.LENGTH_LONG).show(); + return true; + } + + // The keyboard enter will perform the search + if (actionId == EditorInfo.IME_ACTION_SEARCH) { + startSearch(query); + return true; + } + return false; + } + }); + + mRecyclerView = (RecyclerView) findViewById(R.id.am_rv_tags); + mRecyclerView.addItemDecoration(new MainTagsItemDecoration(getContext(), AbstractUIUtils.dpToPx(this, 2))); + + // Two column, vertical display + final TenorStaggeredGridLayoutManager layoutManager = new TenorStaggeredGridLayoutManager(STAGGERED_GRID_LAYOUT_COLUMN_NUMBER, + StaggeredGridLayoutManager.VERTICAL); + mRecyclerView.setLayoutManager(layoutManager); + + mTagsAdapter = new TagsAdapter<>(this); + mRecyclerView.setAdapter(mTagsAdapter); + + mPresenter = new MainPresenter(this); + mPresenter.getTags(getContext(), null); + + } + + private void startSearch(@Nullable final CharSequence text) { + final String query = !TextUtils.isEmpty(text) ? text.toString().trim() : StringConstant.EMPTY; + Intent intent = new Intent(this, SearchActivity.class); + intent.putExtra(SearchActivity.KEY_QUERY, query); + startActivity(intent); + } + + @Override + public Context getContext() { + return getBaseContext(); + } + + @Override + public void onReceiveReactionsSucceeded(List tags) { + + // Map the tags into a list of TagRVItem for the mTagsAdapter + List list = new ArrayList<>(); + for (Tag tag : tags) { + list.add(new TagRVItem(TagsAdapter.TYPE_REACTION_ITEM, tag)); + } + mTagsAdapter.insert(list, false); + } + + @Override + public void onReceiveReactionsFailed(BaseError error) { + // For now, we will just display nothing if the tags fail to return + } +} diff --git a/app/src/main/java/com/tenor/android/demo/search/activity/SearchActivity.java b/app/src/main/java/com/tenor/android/demo/search/activity/SearchActivity.java new file mode 100644 index 0000000..76d172b --- /dev/null +++ b/app/src/main/java/com/tenor/android/demo/search/activity/SearchActivity.java @@ -0,0 +1,194 @@ +package com.tenor.android.demo.search.activity; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.StaggeredGridLayoutManager; +import android.text.TextUtils; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import com.tenor.android.core.constant.StringConstant; +import com.tenor.android.core.model.impl.Result; +import com.tenor.android.core.response.BaseError; +import com.tenor.android.core.response.impl.GifsResponse; +import com.tenor.android.core.util.AbstractLayoutManagerUtils; +import com.tenor.android.core.util.AbstractListUtils; +import com.tenor.android.core.util.AbstractUIUtils; +import com.tenor.android.core.weakref.WeakRefOnScrollListener; +import com.tenor.android.core.widget.adapter.AbstractRVItem; +import com.tenor.android.demo.search.R; +import com.tenor.android.demo.search.adapter.GifSearchAdapter; +import com.tenor.android.demo.search.adapter.decorations.GifSearchItemDecoration; +import com.tenor.android.demo.search.adapter.rvitem.GifRVItem; +import com.tenor.android.demo.search.adapter.view.IGifSearchView; +import com.tenor.android.demo.search.presenter.IGifSearchPresenter; +import com.tenor.android.demo.search.presenter.impl.GifSearchPresenter; +import com.tenor.android.demo.search.widget.TenorStaggeredGridLayoutManager; +import com.tenor.android.sdk.holder.SearchSuggestionVH; + +import java.util.ArrayList; +import java.util.List; + +/** + * SearchActivity will display GIFs in a "waterfall" vertical layout, + * where the aspect ratio of the GIFs will be maintained. + * + * Related search terms will be displayed in a horizontal top bar. + * Clicking one will open a new instance of SearchActivity. + */ +public class SearchActivity extends AppCompatActivity implements IGifSearchView { + // Number of columns for the RecyclerView + private static final int STAGGERED_GRID_LAYOUT_COLUMN_NUMBER = 2; + + // The number of GIFs pulled from each API request + private static final int SEARCH_BATCH_SIZE = 18; + + // Extra parameter for the the intent to pass in the search query + public static final String KEY_QUERY = "KEY_QUERY"; + + // Display for the search term + private TextView mTitleQuery; + // RecyclerView to display the stream of GIFs + private RecyclerView mRecyclerView; + // Back button for returning to MainActivity + private ImageView mBackButton; + + // Adapter containing the GIF items/view holders, as well as related suggestions view holder + private GifSearchAdapter mSearchAdapter; + private TenorStaggeredGridLayoutManager mStaggeredGridLayoutManager; + + // Api calls for SearchActivity performed here + private IGifSearchPresenter mSearchPresenter; + + // Search term + private String mQuery; + + // Index for the last GIF returned by the API, to pull additional GIFs after the last one + private String mNextPageId = StringConstant.EMPTY; + + // Boolean to signify a search call has been made. More calls should not perform while this is set to true + private boolean mIsLoadingMore; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_search); + + mTitleQuery = (TextView) findViewById(R.id.as_tv_query); + mRecyclerView = (RecyclerView) findViewById(R.id.as_rv_recyclerview); + mBackButton = (ImageView) findViewById(R.id.as_ib_back); + mBackButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + finish(); + } + }); + + mQuery = getIntent().getStringExtra(KEY_QUERY).trim(); + + if (!TextUtils.isEmpty(mQuery)) { + mTitleQuery.setText(mQuery); + + mSearchPresenter = new GifSearchPresenter(this); + + mSearchAdapter = new GifSearchAdapter<>(this); + + // When a search suggestion is clicked, a new instance of SearchActivity will open + mSearchAdapter.setOnSearchSuggestionClickListener(new SearchSuggestionVH.OnClickListener() { + @Override + public void onClick(int position, @NonNull String query, @NonNull String suggestion) { + Intent intent = new Intent(SearchActivity.this, SearchActivity.class); + intent.putExtra(KEY_QUERY, suggestion); + startActivity(intent); + finish(); + } + }); + + mSearchAdapter.setSearchQuery(mQuery); + mStaggeredGridLayoutManager = new TenorStaggeredGridLayoutManager( + STAGGERED_GRID_LAYOUT_COLUMN_NUMBER, StaggeredGridLayoutManager.VERTICAL); + + mRecyclerView.addItemDecoration(new GifSearchItemDecoration(AbstractUIUtils.dpToPx(this, 4))); + mRecyclerView.setAdapter(mSearchAdapter); + mRecyclerView.setLayoutManager(mStaggeredGridLayoutManager); + mRecyclerView.addOnScrollListener(new WeakRefOnScrollListener(this) { + @Override + public void onScrolled(RecyclerView recyclerView, int dx, int dy) { + super.onScrolled(recyclerView, dx, dy); + + if (dy > 0) { + final int totalItemCount = recyclerView.getLayoutManager().getItemCount(); + final int lastVisibleItem = AbstractLayoutManagerUtils.findLastVisibleItemPosition(mStaggeredGridLayoutManager); + final int spanCount = AbstractLayoutManagerUtils.getSpanCount(recyclerView.getLayoutManager()); + + /* + * + * `3 * STAGGERED_GRID_LAYOUT_COLUMN_NUMBER` is a joint effort to avoid swapping, + * it kick-starts the load more action when it reaches about 3 rows away from + * the bottom of the existing list + */ + if (!mIsLoadingMore && totalItemCount <= (lastVisibleItem + 3 * spanCount)) { + mIsLoadingMore = true; + performSearch(mQuery, true); + } + } + } + }); + + performSearch(mQuery, false); + } + } + + @Override + public Context getContext() { + return getBaseContext(); + } + + private void performSearch(String query, boolean isAppend) { + if (!isAppend) { + mNextPageId = StringConstant.EMPTY; + mSearchAdapter.clearList(); + mSearchAdapter.addPivotRV(); + } + + if (!TextUtils.isEmpty(query)) { + mSearchPresenter.search(query, SEARCH_BATCH_SIZE, mNextPageId, isAppend); + } + } + + @Override + public void onReceiveSearchResultsSucceed(@NonNull final GifsResponse response, boolean isAppend) { + mNextPageId = response.getNext(); + mSearchAdapter.insert(castToRVItems(response.getResults()), isAppend); + mIsLoadingMore = false; + } + + @Override + public void onReceiveSearchResultsFailed(BaseError error, boolean isAppend) { + if (!isAppend) { + mSearchAdapter.notifyListEmpty(); + } + } + + /** + * Places the Result object into a GifRVItem that can then be placed inside the mSearchAdapter + * @param results - List of the Result objects from a successful search response + */ + private static List castToRVItems(@Nullable final List results) { + List list = new ArrayList<>(); + if (AbstractListUtils.isEmpty(results)) { + return list; + } + + for (int i = 0; i < results.size(); i++) { + list.add(new GifRVItem<>(GifSearchAdapter.TYPE_GIF, results.get(i)).setRelativePosition(i)); + } + return list; + } +} diff --git a/app/src/main/java/com/tenor/android/demo/search/adapter/GifSearchAdapter.java b/app/src/main/java/com/tenor/android/demo/search/adapter/GifSearchAdapter.java new file mode 100644 index 0000000..6525eac --- /dev/null +++ b/app/src/main/java/com/tenor/android/demo/search/adapter/GifSearchAdapter.java @@ -0,0 +1,210 @@ +package com.tenor.android.demo.search.adapter; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.util.ArrayMap; +import android.support.v7.widget.OrientationHelper; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import com.tenor.android.core.model.impl.Result; +import com.tenor.android.core.util.AbstractListUtils; +import com.tenor.android.core.widget.adapter.AbstractRVItem; +import com.tenor.android.core.widget.adapter.ListRVAdapter; +import com.tenor.android.core.widget.viewholder.StaggeredGridLayoutItemViewHolder; +import com.tenor.android.demo.search.R; +import com.tenor.android.demo.search.adapter.holder.GifNoResultsVH; +import com.tenor.android.demo.search.adapter.holder.GifSearchItemVH; +import com.tenor.android.demo.search.adapter.holder.GifSearchPivotsRVVH; +import com.tenor.android.demo.search.adapter.rvitem.GifRVItem; +import com.tenor.android.demo.search.adapter.view.IGifSearchView; +import com.tenor.android.demo.search.widget.IFetchGifDimension; +import com.tenor.android.sdk.holder.SearchSuggestionVH; + +import java.util.List; +import java.util.Map; +import java.util.Stack; + +/** + * Adapter to display GIFs as a list, with the option either multiple columns or multiple rows + * depending on orientation given by the LayoutManager, + * + * Terms with related search terms will display terms as the first list item. + * + * If no Gifs are found for the search term, a "No GIFs were found" TextView will display as a single list item. + */ +public class GifSearchAdapter + extends ListRVAdapter> implements IFetchGifDimension { + + private String mQuery; + + // Used to set aspect ratio of GIFs, so as to not cause position shifting in the waterfall layout + private final Map mHeights; + + public final static int TYPE_NO_RESULTS = 0; + public final static int TYPE_SEARCH_PIVOT = 1; + public final static int TYPE_GIF = 2; + + private static final String ID_ITEM_NO_RESULT = "ID_ITEM_NO_RESULT"; + private static final String ID_ITEM_SEARCH_PIVOT = "ID_ITEM_SEARCH_PIVOT"; + + // Item to display a "No GIFs were found" TextView + private static final AbstractRVItem NO_RESULT_ITEM = new AbstractRVItem(TYPE_NO_RESULTS, + ID_ITEM_NO_RESULT) { + }; + + // Item to show related search terms in a horizontal RecyclerView + private static final AbstractRVItem SEARCH_PIVOT_RV_ITEM = new AbstractRVItem(TYPE_SEARCH_PIVOT, + ID_ITEM_SEARCH_PIVOT) { + }; + + // Listener for when a related suggestion is clicked + private SearchSuggestionVH.OnClickListener mOnSearchSuggestionClickListener; + + public GifSearchAdapter(@NonNull CTX context) { + super(context); + mHeights = new ArrayMap<>(); + } + + @Override + public StaggeredGridLayoutItemViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + final LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + switch (viewType) { + // View holder for related search terms RecyclerView + case TYPE_SEARCH_PIVOT: + GifSearchPivotsRVVH holder = new GifSearchPivotsRVVH<>( + inflater.inflate(R.layout.gif_search_pivots_view, parent, false), + getRef()); + holder.setFullSpan(true); + holder.setOnSearchSuggestionClickListener(mOnSearchSuggestionClickListener); + holder.setQuery(mQuery); + return holder; + // View holder for "no results" TextView + case TYPE_NO_RESULTS: + return new GifNoResultsVH<>( + inflater.inflate(R.layout.gif_search_no_results, parent, false), + getRef()); + // View holder for GIF results + case TYPE_GIF: + default: + return new GifSearchItemVH<>(inflater.inflate(R.layout.gif_base, parent, false), getRef()); + } + } + + @Override + public void onBindViewHolder(StaggeredGridLayoutItemViewHolder viewHolder, int position) { + if (viewHolder instanceof GifSearchPivotsRVVH) { + final GifSearchPivotsRVVH holder = (GifSearchPivotsRVVH) viewHolder; + holder.setFullWidthWithHeight(); + return; + } + + if (viewHolder instanceof GifNoResultsVH) { + final GifNoResultsVH holder = (GifNoResultsVH) viewHolder; + holder.setFullWidthWithHeight(); + return; + } + + if (viewHolder instanceof GifSearchItemVH) { + final GifSearchItemVH holder = (GifSearchItemVH) viewHolder; + + if (!(getList().get(position) instanceof GifRVItem)) { + return; + } + final GifRVItem item = (GifRVItem) getList().get(position); + + if (!(item.get() instanceof Result)) { + return; + } + + if (mHeights.containsKey(item.getId())) { + holder.setHeightInPixel(mHeights.get(item.getId())); + } else { + holder.setFetchGifHeightListener(this); + holder.setupViewHolder((Result) item.get(), OrientationHelper.VERTICAL); + } + holder.renderGif((Result) item.get()); + return; + } + } + + @Override + public int getItemViewType(int position) { + return getList().get(position).getType(); + } + + @Override + public int getItemCount() { + return getList().size(); + } + + public void setSearchQuery(@Nullable final String query) { + // allow empty string + if (query != null) { + mQuery = query; + } + } + + // Add related search RecyclerView, if terms exist + public void addPivotRV() { + if (AbstractListUtils.isEmpty(getList()) || + getList().get(0).getType() != TYPE_SEARCH_PIVOT) { + getList().add(0, SEARCH_PIVOT_RV_ITEM); + notifyItemInserted(0); + } + } + + public void setOnSearchSuggestionClickListener(@Nullable final SearchSuggestionVH.OnClickListener listener) { + mOnSearchSuggestionClickListener = listener; + } + + @Override + public void insert(@Nullable final List list, boolean isAppend) { + if (AbstractListUtils.isEmpty(list) && !isAppend) { + notifyListEmpty(); + return; + } + + if (!isAppend) { + // If GIFs are refreshed, we want to keep the same related suggestions, so long as the term has not changed + threadSafeRemove(new IThreadSafeConditions() { + @Override + public boolean removeIf(AbstractRVItem item) { + return item.getType() != TYPE_SEARCH_PIVOT; + } + + @Override + public void onItemsRemoved(Stack positions) { + // do nothing + } + }); + mHeights.clear(); + } + + if (!AbstractListUtils.isEmpty(list) && list.get(0) instanceof GifRVItem) { + getList().addAll(list); + + int start = getItemCount(); + if (!isAppend) { + notifyDataSetChanged(); + } else { + notifyItemRangeInserted(start, list.size()); + } + } + } + + // Display "No GIFs were found" TextView view holder if no results are found + public void notifyListEmpty() { + clearList(); + mHeights.clear(); + getList().add(NO_RESULT_ITEM); + notifyDataSetChanged(); + } + + @Override + public void onReceiveViewHolderDimension(@NonNull String id, int width, int height, int orientation) { + if (orientation == OrientationHelper.VERTICAL) { + mHeights.put(id, height); + } + } +} diff --git a/app/src/main/java/com/tenor/android/demo/search/adapter/TagsAdapter.java b/app/src/main/java/com/tenor/android/demo/search/adapter/TagsAdapter.java new file mode 100644 index 0000000..88e4ecf --- /dev/null +++ b/app/src/main/java/com/tenor/android/demo/search/adapter/TagsAdapter.java @@ -0,0 +1,74 @@ +package com.tenor.android.demo.search.adapter; + +import android.support.annotation.Nullable; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import com.tenor.android.core.model.impl.Tag; +import com.tenor.android.core.util.AbstractListUtils; +import com.tenor.android.core.widget.adapter.ListRVAdapter; +import com.tenor.android.demo.search.R; +import com.tenor.android.demo.search.activity.MainActivity; +import com.tenor.android.demo.search.adapter.holder.TagItemVH; +import com.tenor.android.demo.search.adapter.rvitem.TagRVItem; + +import java.util.List; + +/** + * Adapter to display Tags as a list, with the option either multiple columns or multiple rows + * depending on orientation given by the LayoutManager. + */ +public class TagsAdapter + extends ListRVAdapter> { + + public static final int TYPE_REACTION_ITEM = 0; + + public TagsAdapter(@Nullable CTX ctx) { + super(ctx); + } + + @Override + public void insert(@Nullable List list, boolean isAppend) { + if (!isAppend) { + getList().clear(); + if (AbstractListUtils.isEmpty(list)) { + notifyDataSetChanged(); + return; + } + } + + if (AbstractListUtils.isEmpty(list)) { + return; + } + + final int start = getItemCount(); + final int size = list.size(); + getList().addAll(list); + + if (!isAppend) { + notifyDataSetChanged(); + } else { + notifyItemRangeChanged(start, size); + } + } + + @Override + public TagItemVH onCreateViewHolder(ViewGroup parent, int viewType) { + // Create Tag view holder + final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); + return new TagItemVH<>( + layoutInflater.inflate(R.layout.item_tag, parent, false), + getRef()); + } + + @Override + public void onBindViewHolder(TagItemVH holder, int position) { + final Tag reactionTag = getList().get(position).getTag(); + holder.render(reactionTag); + } + + @Override + public int getItemCount() { + return getList().size(); + } +} diff --git a/app/src/main/java/com/tenor/android/demo/search/adapter/decorations/GifSearchItemDecoration.java b/app/src/main/java/com/tenor/android/demo/search/adapter/decorations/GifSearchItemDecoration.java new file mode 100644 index 0000000..52fc097 --- /dev/null +++ b/app/src/main/java/com/tenor/android/demo/search/adapter/decorations/GifSearchItemDecoration.java @@ -0,0 +1,89 @@ +package com.tenor.android.demo.search.adapter.decorations; + +import android.graphics.Rect; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.StaggeredGridLayoutManager; +import android.view.View; +import android.view.ViewGroup; + +import com.tenor.android.demo.search.adapter.GifSearchAdapter; + +public class GifSearchItemDecoration extends RecyclerView.ItemDecoration { + private int mLeft; + private int mTop; + private int mRight; + private int mBottom; + + public GifSearchItemDecoration(int space) { + this(space, space); + } + + public GifSearchItemDecoration(int horizontal, int vertical) { + this(horizontal, vertical, horizontal, vertical); + } + + public GifSearchItemDecoration(int left, int top, int right, int bottom) { + mLeft = left; + mTop = top; + mRight = right; + mBottom = bottom; + } + + @Override + public void getItemOffsets(Rect outRect, View view, + RecyclerView parent, RecyclerView.State state) { + outRect.left = 0; + outRect.top = 0; + outRect.right = 0; + outRect.bottom = 0; + + int position = ((RecyclerView.LayoutParams) view.getLayoutParams()).getViewAdapterPosition(); + GifSearchAdapter adapter = (GifSearchAdapter) parent.getAdapter(); + final int type = adapter.getItemViewType(position); + switch (type) { + case GifSearchAdapter.TYPE_GIF: + + StaggeredGridLayoutManager.LayoutParams lp = + (StaggeredGridLayoutManager.LayoutParams) view.getLayoutParams(); + final int spanIndex = lp.getSpanIndex(); + + if (spanIndex == 0) { + // item on left + outRect.left = mLeft; + outRect.right = mRight / 2; + } else { + // item on right + outRect.left = mRight - mRight / 2; + outRect.right = mRight; + } + + if (position == 0) { + // first item + outRect.top = mTop; + } else { + + } + outRect.bottom = mBottom / 2; + break; + case GifSearchAdapter.TYPE_SEARCH_PIVOT: + if (view.getLayoutParams() != null + && view.getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT) { + + outRect.left = 0; + outRect.right = 0; + outRect.top = mTop; + outRect.bottom = mBottom / 2; + } + break; + case GifSearchAdapter.TYPE_NO_RESULTS: + outRect.top = mTop * 3; + outRect.bottom = mBottom; + break; + default: + outRect.left = mLeft; + outRect.right = mRight; + outRect.bottom = mBottom; + break; + } + } +} diff --git a/app/src/main/java/com/tenor/android/demo/search/adapter/decorations/MainTagsItemDecoration.java b/app/src/main/java/com/tenor/android/demo/search/adapter/decorations/MainTagsItemDecoration.java new file mode 100644 index 0000000..3c779c1 --- /dev/null +++ b/app/src/main/java/com/tenor/android/demo/search/adapter/decorations/MainTagsItemDecoration.java @@ -0,0 +1,72 @@ +package com.tenor.android.demo.search.adapter.decorations; + +import android.content.Context; +import android.graphics.Rect; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.StaggeredGridLayoutManager; +import android.view.View; + +import com.tenor.android.demo.search.adapter.TagsAdapter; + +public class MainTagsItemDecoration extends RecyclerView.ItemDecoration { + private int mLeft; + private int mTop; + private int mRight; + private int mBottom; + + public MainTagsItemDecoration(Context context, int space) { + this(context, space, space); + } + + public MainTagsItemDecoration(Context context, int horizontal, int vertical) { + this(context, horizontal, vertical, horizontal, vertical); + } + + public MainTagsItemDecoration(Context context, int left, int top, int right, int bottom) { + mLeft = left; + mTop = top; + mRight = right; + mBottom = bottom; + } + + @Override + public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { + outRect.left = 0; + outRect.top = 0; + outRect.right = 0; + outRect.bottom = 0; + + int position = ((RecyclerView.LayoutParams) view.getLayoutParams()).getViewAdapterPosition(); + if (position < 0) { + return; + } + final int type = parent.getAdapter().getItemViewType(position); + + switch (type) { + case TagsAdapter.TYPE_REACTION_ITEM: + final int spanIndex = ((StaggeredGridLayoutManager.LayoutParams) view.getLayoutParams()) + .getSpanIndex(); + + if (spanIndex == 0) { + // item on left + outRect.left = mLeft; + outRect.right = mRight / 2; + } else { + // item on right + outRect.left = mRight - mRight / 2; + outRect.right = mRight; + } + + outRect.top = mTop / 2; + outRect.bottom = mBottom / 2; + break; + default: + outRect.left = mLeft; + outRect.top = mTop / 2; + outRect.right = mRight; + outRect.bottom = mBottom / 2; + break; + } + } + +} diff --git a/app/src/main/java/com/tenor/android/demo/search/adapter/holder/GifNoResultsVH.java b/app/src/main/java/com/tenor/android/demo/search/adapter/holder/GifNoResultsVH.java new file mode 100644 index 0000000..f1e26da --- /dev/null +++ b/app/src/main/java/com/tenor/android/demo/search/adapter/holder/GifNoResultsVH.java @@ -0,0 +1,24 @@ +package com.tenor.android.demo.search.adapter.holder; + +import android.support.annotation.NonNull; +import android.view.View; +import android.widget.TextView; + +import com.tenor.android.core.constant.StringConstant; +import com.tenor.android.core.widget.viewholder.StaggeredGridLayoutItemViewHolder; +import com.tenor.android.demo.search.R; +import com.tenor.android.demo.search.adapter.view.IGifSearchView; + +public class GifNoResultsVH extends StaggeredGridLayoutItemViewHolder { + // Text view to tell the user no results were found + private final TextView mNoResults; + + public GifNoResultsVH(View itemView, CTX context) { + super(itemView, context); + mNoResults = (TextView) itemView.findViewById(R.id.no_results); + } + + public void setNoResultsMessage(@NonNull String message) { + mNoResults.setText(StringConstant.getOrEmpty(message)); + } +} diff --git a/app/src/main/java/com/tenor/android/demo/search/adapter/holder/GifSearchItemVH.java b/app/src/main/java/com/tenor/android/demo/search/adapter/holder/GifSearchItemVH.java new file mode 100644 index 0000000..18dfe60 --- /dev/null +++ b/app/src/main/java/com/tenor/android/demo/search/adapter/holder/GifSearchItemVH.java @@ -0,0 +1,155 @@ +package com.tenor.android.demo.search.adapter.holder; + +import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v7.widget.OrientationHelper; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.Toast; + +import com.tenor.android.core.constant.MediaCollectionFormats; +import com.tenor.android.core.loader.GlideTaskParams; +import com.tenor.android.core.loader.WeakRefContentLoaderTaskListener; +import com.tenor.android.core.loader.gif.GifLoader; +import com.tenor.android.core.model.impl.Result; +import com.tenor.android.core.network.ApiClient; +import com.tenor.android.core.util.AbstractListUtils; +import com.tenor.android.core.widget.viewholder.StaggeredGridLayoutItemViewHolder; +import com.tenor.android.demo.search.R; +import com.tenor.android.demo.search.adapter.view.IGifSearchView; +import com.tenor.android.demo.search.widget.IFetchGifDimension; +import com.tenor.android.sdk.concurrency.WeakRefOnPreDrawListener; + +public class GifSearchItemVH extends StaggeredGridLayoutItemViewHolder { + + // ImageView to contain and display the GIF + private final ImageView mImageView; + // ProgressBar to display while waiting for GIF to load + private final ProgressBar mProgressBar; + // Icon to signify the GIF is an mp4 with sound + private final View mAudio; + + // Model with the necessary GIF fields, including urls + private Result mResult; + + // Waits for holder's view to be pre-drawn, and returns the height and width values based on the GIFs aspectRatio + private IFetchGifDimension mFetchGifDimensionListener; + + public GifSearchItemVH(View itemView, CTX ctx) { + super(itemView, ctx); + + mImageView = (ImageView) itemView.findViewById(R.id.gdi_iv_image); + mProgressBar = (ProgressBar) itemView.findViewById(R.id.gdi_pb_loading); + mAudio = itemView.findViewById(R.id.gdi_v_audio); + + itemView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + onClicked(); + } + }); + + } + + public void renderGif(@Nullable final Result result) { + if (result == null || !hasContext()) { + return; + } + mResult = result; + + // Only show mAudio for Results with sound + if (result.isHasAudio()) { + mAudio.setVisibility(View.VISIBLE); + } else { + mAudio.setVisibility(View.GONE); + } + + if (AbstractListUtils.isEmpty(result.getMedias())) { + return; + } + + mProgressBar.setVisibility(View.VISIBLE); + + final String url = result.getMedias().get(0).get(MediaCollectionFormats.GIF_TINY).getUrl(); + GlideTaskParams params = new GlideTaskParams<>(mImageView, url); + params.setPlaceholder(result.getPlaceholderColorHex()); + params.setListener(new WeakRefContentLoaderTaskListener(getRef()) { + @Override + public void success(@NonNull CTX ctx, @NonNull ImageView imageView, @Nullable Drawable drawable) { + mProgressBar.setVisibility(View.GONE); + } + + @Override + public void failure(@NonNull CTX ctx, @NonNull ImageView imageView, @Nullable Drawable drawable) { + mProgressBar.setVisibility(View.GONE); + } + }); + // Load GIF into mImageView + GifLoader.loadGif(getContext(), params); + } + + public void setFetchGifHeightListener(IFetchGifDimension fetchGifDimensionListener) { + mFetchGifDimensionListener = fetchGifDimensionListener; + } + + public boolean setupViewHolder(@Nullable final Result result, int orientation) { + if (result == null || !hasContext()) { + return false; + } + postChangeGifViewDimension(itemView, result, orientation); + return true; + } + + private void postChangeGifViewDimension(@NonNull View view, final @NonNull Result result, final int orientation) { + + final float aspectRatio = result.getMedias().get(0).get("GIF_TINY").getAspectRatio(); + + /* + * Re-adjust itemView size on items without exterior ad badge. + * The MATCH_PARENT on the image view is two levels too low, + * and thus got dominated by it parent views + */ + view.getViewTreeObserver().addOnPreDrawListener(new WeakRefOnPreDrawListener(view) { + @Override + public boolean onPreDraw(@NonNull View view) { + view.getViewTreeObserver().removeOnPreDrawListener(this); + + ViewGroup.LayoutParams params = view.getLayoutParams(); + + // Calculate the height/width of the GIF based on aspectRatio and orientation + if (orientation == OrientationHelper.VERTICAL) { + params.height = Math.round((view.getMeasuredWidth() / aspectRatio)); + } + + if (orientation == OrientationHelper.HORIZONTAL) { + params.width = Math.round((view.getMeasuredHeight() * aspectRatio)); + } + if (mFetchGifDimensionListener != null) { + mFetchGifDimensionListener.onReceiveViewHolderDimension(result.getId(), params.width, + params.height, orientation); + } + view.setLayoutParams(params); + return true; + } + }); + } + + private void onClicked() { + if (!hasContext()) { + return; + } + + if (mResult == null) { + return; + } + + // register share to receive more relevant results in the future + ApiClient.registerShare(getContext(), mResult.getId()); + + // todo - Add in functionality for when a GIF is clicked by the user + Toast.makeText(getContext(), "Add your click functionality here", Toast.LENGTH_LONG).show(); + } +} diff --git a/app/src/main/java/com/tenor/android/demo/search/adapter/holder/GifSearchPivotsRVVH.java b/app/src/main/java/com/tenor/android/demo/search/adapter/holder/GifSearchPivotsRVVH.java new file mode 100644 index 0000000..cf2dd60 --- /dev/null +++ b/app/src/main/java/com/tenor/android/demo/search/adapter/holder/GifSearchPivotsRVVH.java @@ -0,0 +1,52 @@ +package com.tenor.android.demo.search.adapter.holder; + +import android.graphics.Rect; +import android.support.annotation.Nullable; +import android.support.v7.widget.RecyclerView; +import android.text.TextUtils; +import android.view.View; + +import com.tenor.android.core.util.AbstractUIUtils; +import com.tenor.android.core.widget.viewholder.StaggeredGridLayoutItemViewHolder; +import com.tenor.android.demo.search.R; +import com.tenor.android.demo.search.adapter.view.IGifSearchView; +import com.tenor.android.sdk.holder.SearchSuggestionVH; +import com.tenor.android.sdk.widget.SearchSuggestionRecyclerView; + +public class GifSearchPivotsRVVH extends StaggeredGridLayoutItemViewHolder { + + // Horizontal RecyclerView to display related search suggestions + private final SearchSuggestionRecyclerView mPivotsRV; + // ProgressBar to display while waiting for GIF background to load + private final int mPivotMargin; + + public GifSearchPivotsRVVH(View itemView, CTX context) { + super(itemView, context); + + mPivotsRV = (SearchSuggestionRecyclerView) itemView.findViewById(R.id.gspv_rv_pivotlist); + + if (!hasContext()) { + mPivotMargin = 0; + return; + } + + mPivotMargin = AbstractUIUtils.dpToPx(getContext(), 1); + mPivotsRV.addItemDecoration(new RecyclerView.ItemDecoration() { + @Override + public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { + outRect.left = mPivotMargin; + outRect.right = mPivotMargin; + } + }); + } + + public void setOnSearchSuggestionClickListener(@Nullable final SearchSuggestionVH.OnClickListener listener) { + mPivotsRV.setOnSearchSuggestionClickListener(listener); + } + + public void setQuery(@Nullable final String query) { + if (!TextUtils.isEmpty(query)) { + mPivotsRV.getSearchSuggestions(query); + } + } +} diff --git a/app/src/main/java/com/tenor/android/demo/search/adapter/holder/TagItemVH.java b/app/src/main/java/com/tenor/android/demo/search/adapter/holder/TagItemVH.java new file mode 100644 index 0000000..e301add --- /dev/null +++ b/app/src/main/java/com/tenor/android/demo/search/adapter/holder/TagItemVH.java @@ -0,0 +1,104 @@ +package com.tenor.android.demo.search.adapter.holder; + +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.view.View; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; + +import com.tenor.android.core.loader.GlideTaskParams; +import com.tenor.android.core.loader.WeakRefContentLoaderTaskListener; +import com.tenor.android.core.loader.gif.GifLoader; +import com.tenor.android.core.model.impl.Tag; +import com.tenor.android.core.widget.viewholder.StaggeredGridLayoutItemViewHolder; +import com.tenor.android.demo.search.R; +import com.tenor.android.demo.search.activity.SearchActivity; +import com.tenor.android.demo.search.adapter.view.IMainView; + +public class TagItemVH extends StaggeredGridLayoutItemViewHolder { + + // Preview GIF image shown in the background + private final ImageView mImage; + // Display name of the Tag + private final TextView mName; + private final ProgressBar mLoadingProgress; + + private Tag mTag; + + public TagItemVH(@NonNull View itemView, CTX context) { + super(itemView, context); + + mImage = itemView.findViewById(R.id.it_iv_image); + mName = itemView.findViewById(R.id.it_tv_name); + mLoadingProgress = itemView.findViewById(R.id.it_pb_loading); + + itemView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + onClicked(); + } + }); + } + + public void render(@Nullable final Tag tag) { + if (tag == null) { + return; + } + mTag = tag; + this.setText(tag.getName()).setImage(tag.getImage()); + } + + private TagItemVH setText(@Nullable final CharSequence text) { + // empty string allowed + if (text == null) { + return this; + } + mName.setText(text); + return this; + } + + private TagItemVH setImage(@Nullable final String image) { + if (TextUtils.isEmpty(image)) { + return this; + } + + // normal load to display + mLoadingProgress.setVisibility(View.VISIBLE); + + // Load GIF preview background into mImage + GlideTaskParams params = new GlideTaskParams<>(mImage, image); + params.setListener(new WeakRefContentLoaderTaskListener(getRef()) { + @Override + public void success(@NonNull CTX ctx, @NonNull ImageView imageView, @Nullable Drawable drawable) { + mLoadingProgress.setVisibility(View.GONE); + } + + @Override + public void failure(@NonNull CTX ctx, @NonNull ImageView imageView, @Nullable Drawable drawable) { + mLoadingProgress.setVisibility(View.GONE); + } + }); + + GifLoader.loadGif(getContext(), params); + return this; + } + + public Tag getTag() { + return mTag; + } + + private void onClicked() { + if (!hasContext()) { + return; + } + + // Start a new search + Intent intent = new Intent(getContext(), SearchActivity.class); + intent.putExtra(SearchActivity.KEY_QUERY, getTag().getSearchTerm()); + getContext().startActivity(intent); + } +} diff --git a/app/src/main/java/com/tenor/android/demo/search/adapter/rvitem/GifRVItem.java b/app/src/main/java/com/tenor/android/demo/search/adapter/rvitem/GifRVItem.java new file mode 100644 index 0000000..aeaa955 --- /dev/null +++ b/app/src/main/java/com/tenor/android/demo/search/adapter/rvitem/GifRVItem.java @@ -0,0 +1,21 @@ +package com.tenor.android.demo.search.adapter.rvitem; + +import android.support.annotation.NonNull; + +import com.tenor.android.core.model.IGif; +import com.tenor.android.core.widget.adapter.AbstractRVItem; + +public class GifRVItem extends AbstractRVItem { + + private T mGif; + + public GifRVItem(int type, @NonNull final T result) { + super(type, result.getId()); + mGif = result; + } + + @NonNull + public T get() { + return mGif; + } +} diff --git a/app/src/main/java/com/tenor/android/demo/search/adapter/rvitem/TagRVItem.java b/app/src/main/java/com/tenor/android/demo/search/adapter/rvitem/TagRVItem.java new file mode 100644 index 0000000..62f1b71 --- /dev/null +++ b/app/src/main/java/com/tenor/android/demo/search/adapter/rvitem/TagRVItem.java @@ -0,0 +1,20 @@ +package com.tenor.android.demo.search.adapter.rvitem; + +import android.support.annotation.NonNull; + +import com.tenor.android.core.model.impl.Tag; +import com.tenor.android.core.widget.adapter.AbstractRVItem; + +public class TagRVItem extends AbstractRVItem { + private final Tag mTag; + + public TagRVItem(int type, @NonNull final Tag tag) { + super(type, tag.getId()); + mTag = tag; + } + + @NonNull + public Tag getTag() { + return mTag; + } +} diff --git a/app/src/main/java/com/tenor/android/demo/search/adapter/view/IGifSearchView.java b/app/src/main/java/com/tenor/android/demo/search/adapter/view/IGifSearchView.java new file mode 100644 index 0000000..7fb598b --- /dev/null +++ b/app/src/main/java/com/tenor/android/demo/search/adapter/view/IGifSearchView.java @@ -0,0 +1,11 @@ +package com.tenor.android.demo.search.adapter.view; + +import com.tenor.android.core.response.BaseError; +import com.tenor.android.core.response.impl.GifsResponse; +import com.tenor.android.core.view.IBaseView; + +public interface IGifSearchView extends IBaseView { + void onReceiveSearchResultsSucceed(GifsResponse response, final boolean isAppend); + + void onReceiveSearchResultsFailed(BaseError error, final boolean isAppend); +} diff --git a/app/src/main/java/com/tenor/android/demo/search/adapter/view/IMainView.java b/app/src/main/java/com/tenor/android/demo/search/adapter/view/IMainView.java new file mode 100644 index 0000000..9002603 --- /dev/null +++ b/app/src/main/java/com/tenor/android/demo/search/adapter/view/IMainView.java @@ -0,0 +1,12 @@ +package com.tenor.android.demo.search.adapter.view; + +import com.tenor.android.core.model.impl.Tag; +import com.tenor.android.core.response.BaseError; +import com.tenor.android.core.view.IBaseView; + +import java.util.List; + +public interface IMainView extends IBaseView { + void onReceiveReactionsSucceeded(List tags); + void onReceiveReactionsFailed(BaseError error); +} diff --git a/app/src/main/java/com/tenor/android/demo/search/presenter/IGifSearchPresenter.java b/app/src/main/java/com/tenor/android/demo/search/presenter/IGifSearchPresenter.java new file mode 100644 index 0000000..44b064e --- /dev/null +++ b/app/src/main/java/com/tenor/android/demo/search/presenter/IGifSearchPresenter.java @@ -0,0 +1,11 @@ +package com.tenor.android.demo.search.presenter; + +import com.tenor.android.core.presenter.IBasePresenter; +import com.tenor.android.core.response.impl.GifsResponse; +import com.tenor.android.demo.search.adapter.view.IGifSearchView; + +import retrofit2.Call; + +public interface IGifSearchPresenter extends IBasePresenter { + Call search(String query, int limit, String pos, final boolean isAppend); +} \ No newline at end of file diff --git a/app/src/main/java/com/tenor/android/demo/search/presenter/IMainPresenter.java b/app/src/main/java/com/tenor/android/demo/search/presenter/IMainPresenter.java new file mode 100644 index 0000000..0326446 --- /dev/null +++ b/app/src/main/java/com/tenor/android/demo/search/presenter/IMainPresenter.java @@ -0,0 +1,15 @@ +package com.tenor.android.demo.search.presenter; + +import android.content.Context; + +import com.tenor.android.core.presenter.IBasePresenter; +import com.tenor.android.core.response.impl.TagsResponse; +import com.tenor.android.demo.search.adapter.view.IMainView; + +import java.util.List; + +import retrofit2.Call; + +public interface IMainPresenter extends IBasePresenter { + Call getTags(Context context, List categories); +} diff --git a/app/src/main/java/com/tenor/android/demo/search/presenter/impl/GifSearchPresenter.java b/app/src/main/java/com/tenor/android/demo/search/presenter/impl/GifSearchPresenter.java new file mode 100644 index 0000000..a00993a --- /dev/null +++ b/app/src/main/java/com/tenor/android/demo/search/presenter/impl/GifSearchPresenter.java @@ -0,0 +1,56 @@ +package com.tenor.android.demo.search.presenter.impl; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; + +import com.tenor.android.core.constant.StringConstant; +import com.tenor.android.core.network.ApiClient; +import com.tenor.android.core.presenter.impl.BasePresenter; +import com.tenor.android.core.response.BaseError; +import com.tenor.android.core.response.impl.GifsResponse; +import com.tenor.android.demo.search.adapter.view.IGifSearchView; +import com.tenor.android.demo.search.presenter.IGifSearchPresenter; + +import retrofit2.Call; + +public class GifSearchPresenter extends BasePresenter implements IGifSearchPresenter { + public GifSearchPresenter(IGifSearchView view) { + super(view); + } + + /** + * Perform an API search call + * @param query - Term to be searched + * @param limit - Search batch size + * @param pos - Last index of the last pulled term. Empty String if first item + * @param isAppend - Should the returned results be appeneded to existing results, or is it a new query + */ + @Override + public Call search(final String query, int limit, String pos, final boolean isAppend) { + + final String qry = !TextUtils.isEmpty(query) ? query : StringConstant.EMPTY; + + Call call = ApiClient.getInstance(getView().getContext()) + .search(ApiClient.getServiceIds(getView().getContext()), qry, + limit, pos); + + call.enqueue(new BaseWeakRefCallback(getWeakRef()) { + @Override + public void success(@NonNull IGifSearchView view, @Nullable GifsResponse response) { + if (response == null) { + view.onReceiveSearchResultsFailed(new BaseError(), isAppend); + return; + } + + view.onReceiveSearchResultsSucceed(response, isAppend); + } + + @Override + public void failure(@NonNull IGifSearchView view, @Nullable BaseError error) { + view.onReceiveSearchResultsFailed(error, isAppend); + } + }); + return call; + } +} diff --git a/app/src/main/java/com/tenor/android/demo/search/presenter/impl/MainPresenter.java b/app/src/main/java/com/tenor/android/demo/search/presenter/impl/MainPresenter.java new file mode 100644 index 0000000..f3ab086 --- /dev/null +++ b/app/src/main/java/com/tenor/android/demo/search/presenter/impl/MainPresenter.java @@ -0,0 +1,57 @@ +package com.tenor.android.demo.search.presenter.impl; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; + +import com.tenor.android.core.constant.StringConstant; +import com.tenor.android.core.network.ApiClient; +import com.tenor.android.core.presenter.impl.BasePresenter; +import com.tenor.android.core.response.BaseError; +import com.tenor.android.core.response.impl.TagsResponse; +import com.tenor.android.core.util.AbstractListUtils; +import com.tenor.android.core.util.AbstractLocaleUtils; +import com.tenor.android.demo.search.adapter.view.IMainView; +import com.tenor.android.demo.search.presenter.IMainPresenter; + +import java.util.List; + +import retrofit2.Call; + +public class MainPresenter extends BasePresenter implements IMainPresenter { + public MainPresenter(IMainView view) { + super(view); + } + + /** + * Api call to fetch a list of Tag items + * @param context + * @param categories - optional field to pull specific types of tags. Null if pulling from top of the results + */ + @Override + public Call getTags(@NonNull Context context, @Nullable List categories) { + final String c = !AbstractListUtils.isEmpty(categories) ? TextUtils.join(StringConstant.COMMA, categories) + : StringConstant.EMPTY; + + Call call = ApiClient.getInstance(getView().getContext()) + .getTags(ApiClient.getServiceIds(getView().getContext()), c, AbstractLocaleUtils.getUtcOffset(context)); + + call.enqueue(new BaseWeakRefCallback(getWeakRef()) { + @Override + public void success(@NonNull IMainView view, @Nullable TagsResponse response) { + if (response == null || AbstractListUtils.isEmpty(response.getTags())) { + view.onReceiveReactionsFailed(new BaseError()); + return; + } + view.onReceiveReactionsSucceeded(response.getTags()); + } + + @Override + public void failure(@NonNull IMainView view, @Nullable BaseError error) { + view.onReceiveReactionsFailed(error); + } + }); + return call; + } +} diff --git a/app/src/main/java/com/tenor/android/demo/search/utils/DemoApp.java b/app/src/main/java/com/tenor/android/demo/search/utils/DemoApp.java new file mode 100644 index 0000000..c7b38db --- /dev/null +++ b/app/src/main/java/com/tenor/android/demo/search/utils/DemoApp.java @@ -0,0 +1,23 @@ +package com.tenor.android.demo.search.utils; + +import android.app.Application; + +import com.tenor.android.core.network.ApiClient; +import com.tenor.android.core.network.ApiService; +import com.tenor.android.core.network.IApiClient; + +public class DemoApp extends Application { + + private static final String DEMO_KEY = "LIVDSRZULELA"; + + @Override + public void onCreate() { + super.onCreate(); + + // Initialize the Tenor Core API + ApiService.IBuilder builder = new ApiService.Builder<>(this, IApiClient.class); + builder.apiKey(DEMO_KEY); + + ApiClient.init(this, builder); + } +} diff --git a/app/src/main/java/com/tenor/android/demo/search/widget/IFetchGifDimension.java b/app/src/main/java/com/tenor/android/demo/search/widget/IFetchGifDimension.java new file mode 100644 index 0000000..59f4fec --- /dev/null +++ b/app/src/main/java/com/tenor/android/demo/search/widget/IFetchGifDimension.java @@ -0,0 +1,8 @@ +package com.tenor.android.demo.search.widget; + +import android.support.annotation.NonNull; + +public interface IFetchGifDimension { + + void onReceiveViewHolderDimension(@NonNull String id, int width, int height, int orientation); +} diff --git a/app/src/main/java/com/tenor/android/demo/search/widget/NineBySixteenImageView.java b/app/src/main/java/com/tenor/android/demo/search/widget/NineBySixteenImageView.java new file mode 100644 index 0000000..08d490e --- /dev/null +++ b/app/src/main/java/com/tenor/android/demo/search/widget/NineBySixteenImageView.java @@ -0,0 +1,30 @@ +package com.tenor.android.demo.search.widget; + +import android.content.Context; +import android.support.v7.widget.AppCompatImageView; +import android.util.AttributeSet; + +/** + * An ImageView with a 16 by 9 frame. + * Used for displaying GIF tags + */ +public class NineBySixteenImageView extends AppCompatImageView { + public NineBySixteenImageView(Context context) { + super(context); + } + + public NineBySixteenImageView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public NineBySixteenImageView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + int width = getMeasuredWidth(); + setMeasuredDimension(width, (int) (width * (9f / 16f))); + } +} diff --git a/app/src/main/java/com/tenor/android/demo/search/widget/TenorStaggeredGridLayoutManager.java b/app/src/main/java/com/tenor/android/demo/search/widget/TenorStaggeredGridLayoutManager.java new file mode 100644 index 0000000..7d4027b --- /dev/null +++ b/app/src/main/java/com/tenor/android/demo/search/widget/TenorStaggeredGridLayoutManager.java @@ -0,0 +1,20 @@ +package com.tenor.android.demo.search.widget; + +import android.content.Context; +import android.support.v7.widget.StaggeredGridLayoutManager; +import android.util.AttributeSet; + +/** + * Modified StaggeredGridLayoutManager to disable pre-fetching + */ +public class TenorStaggeredGridLayoutManager extends StaggeredGridLayoutManager { + public TenorStaggeredGridLayoutManager(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + setItemPrefetchEnabled(false); + } + + public TenorStaggeredGridLayoutManager(int spanCount, int orientation) { + super(spanCount, orientation); + setItemPrefetchEnabled(false); + } +} diff --git a/app/src/main/java/com/tenor/android/sdk/adapter/SearchSuggestionAdapter.java b/app/src/main/java/com/tenor/android/sdk/adapter/SearchSuggestionAdapter.java new file mode 100644 index 0000000..f9cad38 --- /dev/null +++ b/app/src/main/java/com/tenor/android/sdk/adapter/SearchSuggestionAdapter.java @@ -0,0 +1,83 @@ +package com.tenor.android.sdk.adapter; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import com.tenor.android.core.util.AbstractListUtils; +import com.tenor.android.core.view.IBaseView; +import com.tenor.android.core.widget.adapter.ListRVAdapter; +import com.tenor.android.core.widget.viewholder.StaggeredGridLayoutItemViewHolder; +import com.tenor.android.demo.search.R; +import com.tenor.android.sdk.holder.SearchSuggestionVH; +import com.tenor.android.sdk.rvitem.SearchSuggestionRVItem; + +import java.util.List; + +public class SearchSuggestionAdapter extends ListRVAdapter> { + + private SearchSuggestionVH.OnClickListener mOnSearchSuggestionClickListener; + + public final static int TYPE_DID_YOU_MEAN_SUGGESTION = 2; + + public SearchSuggestionAdapter(@NonNull final CTX context) { + super(context); + } + + public void setOnSearchSuggestionClickListener(@Nullable final SearchSuggestionVH.OnClickListener onSearchSuggestionClickListener) { + // use the given correction listener + mOnSearchSuggestionClickListener = onSearchSuggestionClickListener; + } + + /** + * Should only be used for telescoping suggestions + * isAppend should always be false + * + * @param list the list + * @param isAppend should be always false for {@link SearchSuggestionAdapter} + */ + @Override + public void insert(@Nullable final List list, boolean isAppend) { + + if (AbstractListUtils.isEmpty(list)) { + notifyDataSetChanged(); + return; + } + + // intentional ignore append case in here + getList().clear(); + getList().addAll(list); + notifyDataSetChanged(); + } + + @Override + public StaggeredGridLayoutItemViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + switch (viewType) { + case TYPE_DID_YOU_MEAN_SUGGESTION: + default: + return new SearchSuggestionVH<>( + inflater.inflate(R.layout.item_search_suggestion, parent, false), + getRef()); + } + } + + @Override + public void onBindViewHolder(StaggeredGridLayoutItemViewHolder viewHolder, int position) { + if (viewHolder instanceof SearchSuggestionVH) { + final SearchSuggestionVH holder = (SearchSuggestionVH) viewHolder; + holder.render(getList().get(position), mOnSearchSuggestionClickListener); + } + } + + @Override + public int getItemCount() { + return getList().size(); + } + + @Override + public int getItemViewType(int position) { + return getList().get(position).getType(); + } +} diff --git a/app/src/main/java/com/tenor/android/sdk/concurrency/WeakRefOnPreDrawListener.java b/app/src/main/java/com/tenor/android/sdk/concurrency/WeakRefOnPreDrawListener.java new file mode 100644 index 0000000..ba10e14 --- /dev/null +++ b/app/src/main/java/com/tenor/android/sdk/concurrency/WeakRefOnPreDrawListener.java @@ -0,0 +1,38 @@ +package com.tenor.android.sdk.concurrency; + +import android.support.annotation.NonNull; +import android.view.View; +import android.view.ViewTreeObserver; + +import com.tenor.android.core.weakref.WeakRefObject; + +import java.lang.ref.WeakReference; + +public abstract class WeakRefOnPreDrawListener extends WeakRefObject + implements ViewTreeObserver.OnPreDrawListener { + + public WeakRefOnPreDrawListener(@NonNull T t) { + super(t); + } + + public WeakRefOnPreDrawListener(@NonNull WeakReference weakRef) { + super(weakRef); + } + + @Override + public boolean onPreDraw() { + if (!hasRef()) { + return false; + } + + final ViewTreeObserver observer = getWeakRef().get().getViewTreeObserver(); + if (observer == null || !observer.isAlive()) { + return false; + } + + observer.removeOnPreDrawListener(this); + return onPreDraw(getWeakRef().get()); + } + + public abstract boolean onPreDraw(@NonNull T t); +} diff --git a/app/src/main/java/com/tenor/android/sdk/holder/SearchSuggestionVH.java b/app/src/main/java/com/tenor/android/sdk/holder/SearchSuggestionVH.java new file mode 100644 index 0000000..6287092 --- /dev/null +++ b/app/src/main/java/com/tenor/android/sdk/holder/SearchSuggestionVH.java @@ -0,0 +1,110 @@ +package com.tenor.android.sdk.holder; + +import android.content.res.ColorStateList; +import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.graphics.drawable.DrawableCompat; +import android.support.v4.graphics.drawable.TintAwareDrawable; +import android.support.v4.view.TintableBackgroundView; +import android.support.v4.view.ViewCompat; +import android.view.View; +import android.widget.TextView; + +import com.tenor.android.core.view.IBaseView; +import com.tenor.android.core.widget.viewholder.StaggeredGridLayoutItemViewHolder; +import com.tenor.android.demo.search.R; +import com.tenor.android.sdk.rvitem.SearchSuggestionRVItem; +import com.tenor.android.sdk.util.AbstractColorUtils; +import com.tenor.android.sdk.util.AbstractDrawableUtils; + +public class SearchSuggestionVH extends StaggeredGridLayoutItemViewHolder + implements View.OnClickListener { + + /** + * Interface used to communicate info between adapter and view holder + */ + public interface OnClickListener { + void onClick(int position, @NonNull String query, @NonNull String suggestion); + } + + private static final int[][] STATES = new int[][]{ + new int[]{-android.R.attr.state_pressed}, // pressed = false, default + new int[]{android.R.attr.state_pressed}, // pressed = true + }; + + @NonNull + private OnClickListener mListener = new OnClickListener() { + @Override + public void onClick(int position, @NonNull String query, @NonNull String suggestion) { + // do nothing + } + }; + + private SearchSuggestionRVItem mItem; + private final TextView mSuggestionField; + + public SearchSuggestionVH(View itemView, CTX context) { + super(itemView, context); + mSuggestionField = itemView.findViewById(R.id.ips_tv_title); + mSuggestionField.setOnClickListener(this); + } + + public String getSuggestion() { + return mItem.getSuggestion(); + } + + public String getQuery() { + return mItem.getQuery(); + } + + public void render(@Nullable final SearchSuggestionRVItem item, + @Nullable final OnClickListener listener) { + if (!hasContext() || item == null) { + return; + } + + mItem = item; + if (listener != null) { + mListener = listener; + } + + mSuggestionField.setText(item.getSuggestion()); + + if (mSuggestionField instanceof TintableBackgroundView) { + // "AppCompatTextView" and "com.android.support:appcompat-v7" are used, tint all states + ViewCompat.setBackgroundTintList(mSuggestionField, + new ColorStateList(STATES, new int[]{ + AbstractColorUtils.getColor(getContext(), item.getPlaceholder()), + AbstractColorUtils.getColor(getContext(), R.color.tenor_sdk_primary_color)})); + return; + } + + // "com.android.support:appcompat-v7" is likely not being used, and thus "TextView" is used + Drawable background = mSuggestionField.getBackground(); + if (background instanceof TintAwareDrawable) { + // tint all states of the given drawable + DrawableCompat.setTintList(background, + new ColorStateList(STATES, new int[]{ + AbstractColorUtils.getColor(getContext(), item.getPlaceholder()), + AbstractColorUtils.getColor(getContext(), R.color.tenor_sdk_primary_color)})); + return; + } + + // last option, tint only the background individually + AbstractDrawableUtils.setDrawableTint(getContext(), background, item.getPlaceholder()); + } + + @Override + public final void onClick(View v) { + /* + * Android Studio requirements on final constant resource id + * + * http://tools.android.com/tips/non-constant-fields + */ + if (v.getId() == R.id.ips_tv_title) { + mListener.onClick(getAdapterPosition(), getQuery(), getSuggestion()); + } + } +} + diff --git a/app/src/main/java/com/tenor/android/sdk/presenter/ISearchSuggestionPresenter.java b/app/src/main/java/com/tenor/android/sdk/presenter/ISearchSuggestionPresenter.java new file mode 100644 index 0000000..6589c34 --- /dev/null +++ b/app/src/main/java/com/tenor/android/sdk/presenter/ISearchSuggestionPresenter.java @@ -0,0 +1,13 @@ +package com.tenor.android.sdk.presenter; + +import android.support.annotation.NonNull; + +import com.tenor.android.core.presenter.IBasePresenter; +import com.tenor.android.core.response.impl.SearchSuggestionResponse; +import com.tenor.android.sdk.view.ISearchSuggestionView; + +import retrofit2.Call; + +public interface ISearchSuggestionPresenter extends IBasePresenter { + Call getSearchSuggestions(@NonNull String query); +} diff --git a/app/src/main/java/com/tenor/android/sdk/presenter/impl/SearchSuggestionPresenter.java b/app/src/main/java/com/tenor/android/sdk/presenter/impl/SearchSuggestionPresenter.java new file mode 100644 index 0000000..8e908cf --- /dev/null +++ b/app/src/main/java/com/tenor/android/sdk/presenter/impl/SearchSuggestionPresenter.java @@ -0,0 +1,60 @@ +package com.tenor.android.sdk.presenter.impl; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.tenor.android.core.network.ApiClient; +import com.tenor.android.core.network.CallStub; +import com.tenor.android.core.presenter.impl.BasePresenter; +import com.tenor.android.core.response.BaseError; +import com.tenor.android.core.response.impl.SearchSuggestionResponse; +import com.tenor.android.sdk.presenter.ISearchSuggestionPresenter; +import com.tenor.android.sdk.view.ISearchSuggestionView; + +import retrofit2.Call; + +public class SearchSuggestionPresenter extends BasePresenter implements ISearchSuggestionPresenter { + + + public SearchSuggestionPresenter(ISearchSuggestionView view) { + super(view); + } + + @Override + public Call getSearchSuggestions(@NonNull final String query) { + if (!hasView()) { + return new CallStub<>(); + } + + final Call call = + ApiClient.getInstance(getWeakRef().get().getContext()) + .getSearchSuggestions(ApiClient.getServiceIds(getWeakRef().get().getContext()), query, 10); + + call.enqueue(new BaseWeakRefCallback(getWeakRef()) { + + @Override + public void success(@NonNull ISearchSuggestionView view, @Nullable SearchSuggestionResponse response) { + if (call.isCanceled()) { + return; + } + + if (response == null) { + view.onReceiveSearchSuggestionsFailed(query, new NullPointerException()); + return; + } + view.onReceiveSearchSuggestionsSucceeded(query, response.getResults()); + } + + @Override + public void failure(@NonNull ISearchSuggestionView view, BaseError error) { + if (call.isCanceled()) { + return; + } + + view.onReceiveSearchSuggestionsFailed(query, error); + } + }); + return call; + } +} + diff --git a/app/src/main/java/com/tenor/android/sdk/rvitem/SearchSuggestionRVItem.java b/app/src/main/java/com/tenor/android/sdk/rvitem/SearchSuggestionRVItem.java new file mode 100644 index 0000000..2f5efae --- /dev/null +++ b/app/src/main/java/com/tenor/android/sdk/rvitem/SearchSuggestionRVItem.java @@ -0,0 +1,39 @@ +package com.tenor.android.sdk.rvitem; + +import android.support.annotation.ColorRes; +import android.support.annotation.NonNull; + +import com.tenor.android.core.constant.StringConstant; +import com.tenor.android.core.widget.adapter.AbstractRVItem; + +public class SearchSuggestionRVItem extends AbstractRVItem { + + @ColorRes + private final int mPlaceholder; + + @NonNull + private final String mQuery; + + public SearchSuggestionRVItem(int type, @NonNull final String query, + @ColorRes int placeholder, @NonNull final String suggestion) { + super(type, suggestion); + mQuery = query; + mPlaceholder = placeholder; + } + + @NonNull + public String getQuery() { + return StringConstant.getOrEmpty(mQuery); + } + + @ColorRes + public int getPlaceholder() { + return mPlaceholder; + } + + @NonNull + public String getSuggestion() { + return getId(); + } +} + diff --git a/app/src/main/java/com/tenor/android/sdk/util/AbstractColorUtils.java b/app/src/main/java/com/tenor/android/sdk/util/AbstractColorUtils.java new file mode 100644 index 0000000..3e4d713 --- /dev/null +++ b/app/src/main/java/com/tenor/android/sdk/util/AbstractColorUtils.java @@ -0,0 +1,22 @@ +package com.tenor.android.sdk.util; + +import android.content.Context; +import android.support.annotation.ColorInt; +import android.support.annotation.ColorRes; +import android.support.annotation.NonNull; +import android.support.v4.content.ContextCompat; + +public abstract class AbstractColorUtils { + + /** + * Retrieve color from res id + * + * @param context the context + * @param colorResId res id of the color value + * @return A single color value in the form 0xAARRGGBB, or, the colorResId if context is null + */ + @ColorInt + public static int getColor(@NonNull Context context, @ColorRes int colorResId) { + return ContextCompat.getColor(context, colorResId); + } +} diff --git a/app/src/main/java/com/tenor/android/sdk/util/AbstractDrawableUtils.java b/app/src/main/java/com/tenor/android/sdk/util/AbstractDrawableUtils.java new file mode 100644 index 0000000..125ed54 --- /dev/null +++ b/app/src/main/java/com/tenor/android/sdk/util/AbstractDrawableUtils.java @@ -0,0 +1,67 @@ +package com.tenor.android.sdk.util; + +import android.content.Context; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.support.annotation.ColorInt; +import android.support.annotation.ColorRes; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.graphics.drawable.DrawableCompat; + +public abstract class AbstractDrawableUtils { + + /** + * Sets a tint on top of the desired drawable + * + * @param context the context + * @param drawable source drawable to apply the tint + * @param tintColorResId color res id of the desired tint + */ + public static void setDrawableTint(@Nullable final Context context, + @NonNull final Drawable drawable, + @ColorRes final int tintColorResId) { + if (context == null || drawable == null) { + throw new IllegalArgumentException("inputs cannot be null, context: " + context + + ", drawable: " + drawable); + } + setDrawableTint(drawable, AbstractColorUtils.getColor(context, tintColorResId)); + } + + /** + * Sets a tint on top of the desired drawable + * + * @param drawable source drawable to apply the tint + * @param tintColorInt color int of the desired tint + */ + public static void setDrawableTint(@NonNull final Drawable drawable, + @ColorInt final int tintColorInt) { + /* + * === FIXED === + * In the latest support library, DrawableCompat.wrap() is no longer needed + * + * There is an invalidation issue when setting drawable state on pre-lollipop devices, + * even if using the DrawableCompat.setTint() method. It appears to be google support + * library issue. The get around is to use DrawableCompat.wrap() to wrap the drawable + * before setting tint color + * + * http://stackoverflow.com/questions/30872101 + * https://code.google.com/p/android/issues/detail?id=172067#c13 + * ============= + */ + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + /* + * Tint ONLY the targeted drawable without affecting other drawables in the app. + * + * http://stackoverflow.com/questions/26788251 + */ + Drawable mutateDrawable = drawable.mutate(); + + // Work for API 17 and below as well + mutateDrawable.setColorFilter(tintColorInt, PorterDuff.Mode.SRC_IN); + } else { + DrawableCompat.setTint(drawable, tintColorInt); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tenor/android/sdk/util/ColorPalette.java b/app/src/main/java/com/tenor/android/sdk/util/ColorPalette.java new file mode 100644 index 0000000..9d4db9f --- /dev/null +++ b/app/src/main/java/com/tenor/android/sdk/util/ColorPalette.java @@ -0,0 +1,82 @@ +package com.tenor.android.sdk.util; + +import android.support.annotation.ColorRes; +import android.support.annotation.IntRange; + +import com.tenor.android.demo.search.R; + +public class ColorPalette { + + private final int mCount; + private final int[] mColorPalette; + private int[] mRandomizedPalette; + + public ColorPalette(@ColorRes int[] colorResIds) { + mCount = colorResIds.length; + if (mCount <= 0) { + throw new IllegalArgumentException("length of input color resource ids cannot be less than 1"); + } + mColorPalette = colorResIds; + shuffle(); + } + + public void shuffle() { + mRandomizedPalette = SdkListUtils.shuffle(mColorPalette); + } + + /** + * Get a {@link ColorRes} from the given color resource ids + * + * @param index the index of the randomized {@link ColorRes} + * @return a {@link ColorRes} + */ + @ColorRes + public int get(@IntRange(from = 0, to = Integer.MAX_VALUE) int index) { + if (index < 0) { + index = Math.abs(index); + } + return mColorPalette[index % mCount]; + } + + /** + * Get a random {@link ColorRes} from the given color resource ids + * + * @param index the index of the randomized {@link ColorRes} + * @return a random {@link ColorRes} + */ + @ColorRes + public int random(@IntRange(from = 0, to = Integer.MAX_VALUE) int index) { + if (index < 0) { + index = Math.abs(index); + } + return mRandomizedPalette[index % mCount]; + } + + /* + * ============== + * Static Methods + * ============== + */ + + @ColorRes + private static final int[] COLOR_RESOURCE_IDS = new int[]{ + R.color.color_palette_green, + R.color.color_palette_light_blue, + R.color.color_palette_yellow, + R.color.color_palette_deep_purple, + R.color.color_palette_red, + }; + + private static final ColorPalette COLOR_PALETTE = new ColorPalette(COLOR_RESOURCE_IDS); + + /** + * Get a random {@link ColorRes} from the randomized {@link #COLOR_RESOURCE_IDS} + * + * @param index the index of the randomized {@link ColorRes} + * @return a random {@link ColorRes} + */ + @ColorRes + public static int getRandomColorResId(@IntRange(from = 0, to = Integer.MAX_VALUE) int index) { + return COLOR_PALETTE.random(index); + } +} diff --git a/app/src/main/java/com/tenor/android/sdk/util/RandomCompat.java b/app/src/main/java/com/tenor/android/sdk/util/RandomCompat.java new file mode 100644 index 0000000..7760d65 --- /dev/null +++ b/app/src/main/java/com/tenor/android/sdk/util/RandomCompat.java @@ -0,0 +1,25 @@ +package com.tenor.android.sdk.util; + +import android.os.Build; + +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Get the better perform or backward compatible {@link Random} base on API version + */ +public class RandomCompat { + + public static Random get() { + if (Build.VERSION.SDK_INT >= 21) { + return ThreadLocalRandom.current(); + } else { + return new ThreadLocal() { + @Override + protected Random initialValue() { + return new Random(); + } + }.get(); + } + } +} diff --git a/app/src/main/java/com/tenor/android/sdk/util/SdkListUtils.java b/app/src/main/java/com/tenor/android/sdk/util/SdkListUtils.java new file mode 100644 index 0000000..7654d1f --- /dev/null +++ b/app/src/main/java/com/tenor/android/sdk/util/SdkListUtils.java @@ -0,0 +1,35 @@ +package com.tenor.android.sdk.util; + +import android.support.annotation.NonNull; + +import com.tenor.android.core.util.AbstractListUtils; + +import java.util.Random; + +public class SdkListUtils extends AbstractListUtils { + + /** + * Shuffle a given {@link int}[] + *

+ * A implementation of Fisher–Yates shuffle + * + * @param array given {@link int}[] to be shuffled + * @return a shuffled {@link int}[] + */ + @NonNull + public static int[] shuffle(@NonNull int[] array) { + if (array.length <= 0) { + return array; + } + Random random = RandomCompat.get(); + int randInt; + int temp; + for (int i = array.length - 1; i > 0; i--) { + randInt = random.nextInt(i + 1); + temp = array[randInt]; + array[randInt] = array[i]; + array[i] = temp; + } + return array; + } +} diff --git a/app/src/main/java/com/tenor/android/sdk/view/ISearchSuggestionView.java b/app/src/main/java/com/tenor/android/sdk/view/ISearchSuggestionView.java new file mode 100644 index 0000000..e2d7b7a --- /dev/null +++ b/app/src/main/java/com/tenor/android/sdk/view/ISearchSuggestionView.java @@ -0,0 +1,16 @@ +package com.tenor.android.sdk.view; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.tenor.android.core.view.IBaseView; + +import java.util.List; + +public interface ISearchSuggestionView extends IBaseView { + + void onReceiveSearchSuggestionsSucceeded(@NonNull String query, @NonNull List suggestions); + + void onReceiveSearchSuggestionsFailed(@NonNull String query, @Nullable Exception error); +} + diff --git a/app/src/main/java/com/tenor/android/sdk/widget/SearchSuggestionRecyclerView.java b/app/src/main/java/com/tenor/android/sdk/widget/SearchSuggestionRecyclerView.java new file mode 100644 index 0000000..2d427cc --- /dev/null +++ b/app/src/main/java/com/tenor/android/sdk/widget/SearchSuggestionRecyclerView.java @@ -0,0 +1,186 @@ +package com.tenor.android.sdk.widget; + +import android.content.Context; +import android.graphics.Rect; +import android.support.annotation.CallSuper; +import android.support.annotation.IntRange; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.StaggeredGridLayoutManager; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; + +import com.tenor.android.core.response.impl.SearchSuggestionResponse; +import com.tenor.android.core.util.AbstractListUtils; +import com.tenor.android.core.util.AbstractUIUtils; +import com.tenor.android.core.util.AbstractWeakReferenceUtils; +import com.tenor.android.core.weakref.IWeakRefObject; +import com.tenor.android.sdk.adapter.SearchSuggestionAdapter; +import com.tenor.android.sdk.holder.SearchSuggestionVH; +import com.tenor.android.sdk.presenter.impl.SearchSuggestionPresenter; +import com.tenor.android.sdk.rvitem.SearchSuggestionRVItem; +import com.tenor.android.sdk.util.ColorPalette; +import com.tenor.android.sdk.view.ISearchSuggestionView; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.List; + +import retrofit2.Call; + +/** + * {@link SearchSuggestionRecyclerView} is a subclass of {@link RecyclerView} for search suggestions + *

+ * "AppCompatTextView" under "com.android.support:appcompat-v7" is not required, yet highly recommended + * to ensure this widget is working properly on API 20- + */ +public class SearchSuggestionRecyclerView extends RecyclerView implements IWeakRefObject, + ISearchSuggestionView, SearchSuggestionVH.OnClickListener { + + private final WeakReference mWeakRef; + private final SearchSuggestionAdapter mAdapter; + private final SearchSuggestionPresenter mPresenter; + private final ItemDecoration mDefaultItemDecoration; + private SearchSuggestionVH.OnClickListener mOnSearchSuggestionClickListener; + private Call mSearchSuggestionCall; + + public SearchSuggestionRecyclerView(Context context) { + this(context, null); + } + + public SearchSuggestionRecyclerView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public SearchSuggestionRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + mWeakRef = new WeakReference<>(context); + mPresenter = new SearchSuggestionPresenter(this); + mAdapter = new SearchSuggestionAdapter<>(this); + mDefaultItemDecoration = new SearchSuggestionItemDecoration(mWeakRef, 4); + + setAdapter(mAdapter); + setLayoutManager(new StaggeredGridLayoutManager(1, LinearLayoutManager.HORIZONTAL)); + addItemDecoration(mDefaultItemDecoration); + } + + public void removeDefaultItemDecoration() { + removeItemDecoration(mDefaultItemDecoration); + } + + @Nullable + @Override + public Context getRef() { + return mWeakRef.get(); + } + + @NonNull + @Override + public WeakReference getWeakRef() { + return mWeakRef; + } + + @Override + public boolean hasRef() { + return AbstractWeakReferenceUtils.isAlive(mWeakRef); + } + + public void getSearchSuggestions(@Nullable String query) { + + if (mSearchSuggestionCall != null && !mSearchSuggestionCall.isCanceled()) { + mSearchSuggestionCall.cancel(); + } + + if (TextUtils.isEmpty(query)) { + return; + } + mSearchSuggestionCall = mPresenter.getSearchSuggestions(query); + } + + @Override + @CallSuper + public void onReceiveSearchSuggestionsSucceeded(@NonNull String query, @NonNull List suggestions) { + final boolean expandable = hasRef() && !AbstractListUtils.isEmpty(suggestions); + setVisibility(expandable ? VISIBLE : GONE); + if (!expandable) { + return; + } + scrollToPosition(0); + + final List list = new ArrayList<>(); + for (String suggestion : suggestions) { + list.add(new SearchSuggestionRVItem(SearchSuggestionAdapter.TYPE_DID_YOU_MEAN_SUGGESTION, + query, ColorPalette.getRandomColorResId(list.size()), suggestion)); + } + mAdapter.insert(list); + } + + @Override + @CallSuper + public void onReceiveSearchSuggestionsFailed(@NonNull String query, @Nullable Exception error) { + setVisibility(GONE); + } + + public void setOnSearchSuggestionClickListener(@Nullable final SearchSuggestionVH.OnClickListener listener) { + mOnSearchSuggestionClickListener = listener; + mAdapter.setOnSearchSuggestionClickListener(this); + } + + @Override + @CallSuper + public void onClick(int position, @NonNull String query, @NonNull String suggestion) { + if (mOnSearchSuggestionClickListener != null) { + mOnSearchSuggestionClickListener.onClick(position, query, suggestion); + } + } + + private static class SearchSuggestionItemDecoration extends ItemDecoration { + + private final WeakReference mWeakRef; + + // note that the item shadow on each side is 4dp + private final int mSpaceOnBothEnds; + private final int mSpaceBetweenItems; + + /** + * @param horizontalGap horizontal gap in dp + */ + private SearchSuggestionItemDecoration(@NonNull WeakReference weakRef, + @IntRange(from = 4, to = Integer.MAX_VALUE) int horizontalGap) { + mWeakRef = weakRef; + // e.g. expected gap is 16dp, then 16 - 4 = mSpaceOnBothEnds + mSpaceOnBothEnds = AbstractUIUtils.dpToPx(weakRef.get(), getOrZero(horizontalGap - 4)); + // e.g. expected gap is 16dp, then 16 - 2 * 4 = mSpaceBetweenItems + mSpaceBetweenItems = AbstractUIUtils.dpToPx(weakRef.get(), getOrZero(horizontalGap - 8)); + } + + private static int getOrZero(int value) { + return value > 0 ? value : 0; + } + + @Override + public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { + + if (!AbstractWeakReferenceUtils.isAlive(mWeakRef)) { + return; + } + + outRect.left = mSpaceBetweenItems / 2; + outRect.top = 0; + outRect.right = mSpaceBetweenItems / 2; + outRect.bottom = 0; + + final int position = ((RecyclerView.LayoutParams) view.getLayoutParams()).getViewAdapterPosition(); + final int total = parent.getAdapter().getItemCount(); + if (position == 0) { + outRect.left = mSpaceOnBothEnds; + } else if (position == total - 1) { + outRect.right = mSpaceOnBothEnds; + } + } + } +} + diff --git a/app/src/main/res/drawable-hdpi/bg_search_suggestion_shadow.9.png b/app/src/main/res/drawable-hdpi/bg_search_suggestion_shadow.9.png new file mode 100755 index 0000000..a69ae41 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/bg_search_suggestion_shadow.9.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_search_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_search_white_24dp.png new file mode 100644 index 0000000..bbfbc96 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_search_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_volume_up_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_volume_up_white_24dp.png new file mode 100644 index 0000000..57d7871 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_volume_up_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/bg_search_suggestion_shadow.9.png b/app/src/main/res/drawable-mdpi/bg_search_suggestion_shadow.9.png new file mode 100755 index 0000000..1883687 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/bg_search_suggestion_shadow.9.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_search_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_search_white_24dp.png new file mode 100644 index 0000000..faefc59 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_search_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_volume_up_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_volume_up_white_24dp.png new file mode 100644 index 0000000..7cfd4c7 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_volume_up_white_24dp.png differ diff --git a/app/src/main/res/drawable-v21/search_suggestion_background.xml b/app/src/main/res/drawable-v21/search_suggestion_background.xml new file mode 100644 index 0000000..24bbeab --- /dev/null +++ b/app/src/main/res/drawable-v21/search_suggestion_background.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-xhdpi/bg_search_suggestion_shadow.9.png b/app/src/main/res/drawable-xhdpi/bg_search_suggestion_shadow.9.png new file mode 100755 index 0000000..1728d05 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/bg_search_suggestion_shadow.9.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_search_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_search_white_24dp.png new file mode 100644 index 0000000..bfc3e39 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_search_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_volume_up_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_volume_up_white_24dp.png new file mode 100644 index 0000000..2ed0034 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_volume_up_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_search_suggestion_shadow.9.png b/app/src/main/res/drawable-xxhdpi/bg_search_suggestion_shadow.9.png new file mode 100755 index 0000000..fb47fda Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_search_suggestion_shadow.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_search_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_search_white_24dp.png new file mode 100644 index 0000000..abbb989 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_search_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_volume_up_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_volume_up_white_24dp.png new file mode 100644 index 0000000..2e751a4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_volume_up_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/bg_search_suggestion_shadow.9.png b/app/src/main/res/drawable-xxxhdpi/bg_search_suggestion_shadow.9.png new file mode 100755 index 0000000..2efb184 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/bg_search_suggestion_shadow.9.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_search_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_search_white_24dp.png new file mode 100644 index 0000000..dd5adfc Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_search_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_volume_up_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_volume_up_white_24dp.png new file mode 100644 index 0000000..82972b4 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_volume_up_white_24dp.png differ diff --git a/app/src/main/res/drawable/search_suggestion_background.xml b/app/src/main/res/drawable/search_suggestion_background.xml new file mode 100644 index 0000000..52297ac --- /dev/null +++ b/app/src/main/res/drawable/search_suggestion_background.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..ff07e4c --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_search.xml b/app/src/main/res/layout/activity_search.xml new file mode 100644 index 0000000..1f11d6c --- /dev/null +++ b/app/src/main/res/layout/activity_search.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/gif_base.xml b/app/src/main/res/layout/gif_base.xml new file mode 100644 index 0000000..5521a04 --- /dev/null +++ b/app/src/main/res/layout/gif_base.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/gif_search_no_results.xml b/app/src/main/res/layout/gif_search_no_results.xml new file mode 100644 index 0000000..fef1d7e --- /dev/null +++ b/app/src/main/res/layout/gif_search_no_results.xml @@ -0,0 +1,10 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/gif_search_pivots_view.xml b/app/src/main/res/layout/gif_search_pivots_view.xml new file mode 100644 index 0000000..8d0664c --- /dev/null +++ b/app/src/main/res/layout/gif_search_pivots_view.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_search_suggestion.xml b/app/src/main/res/layout/item_search_suggestion.xml new file mode 100644 index 0000000..e0b4679 --- /dev/null +++ b/app/src/main/res/layout/item_search_suggestion.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_tag.xml b/app/src/main/res/layout/item_tag.xml new file mode 100644 index 0000000..7986af3 --- /dev/null +++ b/app/src/main/res/layout/item_tag.xml @@ -0,0 +1,47 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..cde69bc Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..9a078e3 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..c133a0c Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..efc028a Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..bfa42f0 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..3af2608 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..324e72c Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..9bec2e6 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..aee44e1 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..34947cd Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values-hdpi/dimens.xml b/app/src/main/res/values-hdpi/dimens.xml new file mode 100644 index 0000000..b3752cf --- /dev/null +++ b/app/src/main/res/values-hdpi/dimens.xml @@ -0,0 +1,12 @@ + + + + + 36dp + + + 18sp + 16sp + + 16sp + diff --git a/app/src/main/res/values-mdpi/dimens.xml b/app/src/main/res/values-mdpi/dimens.xml new file mode 100644 index 0000000..cfc12ff --- /dev/null +++ b/app/src/main/res/values-mdpi/dimens.xml @@ -0,0 +1,12 @@ + + + + + 36dp + + + 16sp + 14sp + + 16sp + diff --git a/app/src/main/res/values-v21/styles.xml b/app/src/main/res/values-v21/styles.xml new file mode 100644 index 0000000..8e4681f --- /dev/null +++ b/app/src/main/res/values-v21/styles.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/app/src/main/res/values-xhdpi/dimens.xml b/app/src/main/res/values-xhdpi/dimens.xml new file mode 100644 index 0000000..e6578e9 --- /dev/null +++ b/app/src/main/res/values-xhdpi/dimens.xml @@ -0,0 +1,11 @@ + + + + 48dp + + + 20sp + 18sp + + 18sp + diff --git a/app/src/main/res/values-xxhdpi/dimens.xml b/app/src/main/res/values-xxhdpi/dimens.xml new file mode 100644 index 0000000..0408e7c --- /dev/null +++ b/app/src/main/res/values-xxhdpi/dimens.xml @@ -0,0 +1,7 @@ + + + + 48dp + + 18sp + diff --git a/app/src/main/res/values/color_palette.xml b/app/src/main/res/values/color_palette.xml new file mode 100644 index 0000000..86bdaf3 --- /dev/null +++ b/app/src/main/res/values/color_palette.xml @@ -0,0 +1,16 @@ + + + + @color/color_palette_green + @color/color_palette_light_blue + @color/color_palette_yellow + @color/color_palette_deep_purple + @color/color_palette_red + + + #8BB38B + #8BB3B3 + #B3B38B + #8B8BB3 + #B38B8B + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..1dc6688 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #40000000 + #73000000 + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..a2cb12b --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,11 @@ + + + + 56dp + + + 14sp + 56dp + + 20sp + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..e9e98bb --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,16 @@ + + Tenor Android Demo - Search + Search Tenor for GIFs + Search Tenor + No GIFs were found + + Did you mean? + + + stop searching + + Clear search + + Enter at least 2 characters to begin the search. + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..70d087e --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..775421d --- /dev/null +++ b/build.gradle @@ -0,0 +1,24 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + repositories { + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:2.3.3' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + jcenter() + maven { url 'https://maven.google.com' } + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..aac7c9b --- /dev/null +++ b/gradle.properties @@ -0,0 +1,17 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..13372ae Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..76c9020 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Mon Aug 21 10:36:02 PDT 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-3.3-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..9d82f78 --- /dev/null +++ b/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..aec9973 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..034cede --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +include ':app', ':tenor-android-core' diff --git a/tenor-android-core/build.gradle b/tenor-android-core/build.gradle new file mode 100644 index 0000000..bf3c496 --- /dev/null +++ b/tenor-android-core/build.gradle @@ -0,0 +1,2 @@ +configurations.maybeCreate("default") +artifacts.add("default", file('tenor-android-core.aar')) diff --git a/tenor-android-core/tenor-android-core.aar b/tenor-android-core/tenor-android-core.aar new file mode 100644 index 0000000..88cab3b Binary files /dev/null and b/tenor-android-core/tenor-android-core.aar differ