diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..713fe3c36 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +build +*.iml +out +.idea +prebuilts +.DS_Store +local.properties +.gradle diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..2bdc5c235 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# How to become a contributor and submit your own code + +## Contributor License Agreements + +We'd love to accept your sample apps and patches! Before we can take them, we +have to jump a couple of legal hurdles. + +Please fill out either the individual or corporate Contributor License Agreement (CLA). + + * If you are an individual writing original source code and you're sure you + own the intellectual property, then you'll need to sign an [individual CLA] + (https://developers.google.com/open-source/cla/individual). + * If you work for a company that wants to allow you to contribute your work, + then you'll need to sign a [corporate CLA] + (https://developers.google.com/open-source/cla/corporate). + +Follow either of the two links above to access the appropriate CLA and +instructions for how to sign and return it. Once we receive it, we'll be able to +accept your pull requests. + +## Contributing A Patch + +1. Submit an issue describing your proposed change to the repo in question. +1. The repo owner will respond to your issue promptly. +1. If your proposed change is accepted, and you haven't already done so, sign a + Contributor License Agreement (see details above). +1. Fork the desired repo, develop and test your code changes. +1. Ensure that your code adheres to the existing style in the sample to which + you are contributing. Refer to the + [Android Code Style Guide] + (https://source.android.com/source/code-style.html) for the + recommended coding standards for this organization. +1. Ensure that your code has an appropriate set of unit tests which all pass. +1. Submit a pull request. diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..4f63d53e4 --- /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 2015 Google Inc. + + 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. \ No newline at end of file diff --git a/NOTICE b/NOTICE new file mode 100644 index 000000000..4ed34e6be --- /dev/null +++ b/NOTICE @@ -0,0 +1,15 @@ +This sample uses the following software: + +Copyright 2015 Google Inc. + +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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 000000000..7ee6d45f2 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# Plaid + + + +*Design news and inspiration.* + +Plaid is a showcase of [material design](https://www.google.com/design/spec/) that we hope you will +keep installed. It pulls in news & inspiration from [Designer News](https://www.designernews.co/), +[Dribbble](https://dribbble.com/) & [Product Hunt](https://www.producthunt.com/). It demonstrates +the use of +[material principles](https://www.google.com/design/spec/material-design/introduction.html#introduction-principles) +to create tactile, bold, understandable UIs. + +**[Install on Google Play](https://play.google.com/store/apps/details?id=io.plaidapp)** + + +### Screenshots + + + + + + + + +### License + + +``` +Copyright 2015 Google, Inc. + +Licensed to the Apache Software Foundation (ASF) under one or more contributor +license agreements. See the NOTICE file distributed with this work for +additional information regarding copyright ownership. The ASF licenses this +file to you 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. +``` \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 000000000..2c57bb75f --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,79 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +apply plugin: 'com.android.application' + +// query git for the the SHA, Tag and commit count. Use these to automate versioning. +def gitSha = 'git rev-parse --short HEAD'.execute([], project.rootDir).text.trim() +def gitTag = 'git describe --tags'.execute([], project.rootDir).text.trim() +def gitCommitCount = + Integer.parseInt('git rev-list --count HEAD'.execute([], project.rootDir).text.trim()) + +android { + compileSdkVersion 23 + buildToolsVersion '22.0.1' + + defaultConfig { + applicationId "io.plaidapp" + minSdkVersion 21 + targetSdkVersion 23 + versionCode gitCommitCount + versionName gitTag + buildConfigField "String", "GIT_SHA", "\"${gitSha}\"" + buildConfigField "String", "DRIBBBLE_CLIENT_ID", "\"${dribbble_client_id}\"" + buildConfigField "String", "DRIBBBLE_CLIENT_SECRET", "\"${dribbble_client_secret}\"" + buildConfigField "String", + "DRIBBBLE_CLIENT_ACCESS_TOKEN", "\"${dribbble_client_access_token}\"" + buildConfigField "String", "DESIGNER_NEWS_CLIENT_ID", "\"${designer_news_client_id}\"" + buildConfigField "String", + "DESIGNER_NEWS_CLIENT_SECRET", "\"${designer_news_client_secret}\"" + buildConfigField "String", + "PROCUCT_HUNT_DEVELOPER_TOKEN", "\"${product_hunt_developer_token}\"" + } + buildTypes { + release { + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_7 + targetCompatibility JavaVersion.VERSION_1_7 + } +} + +repositories { + jcenter() +} + +ext { + archivesBaseName = "plaid-${android.defaultConfig.versionName}" + supportLibVersion = '23.1.0' +} + +dependencies { + compile "com.android.support:support-v4:${supportLibVersion}" + compile "com.android.support:palette-v7:${supportLibVersion}" + compile "com.android.support:recyclerview-v7:${supportLibVersion}" + compile "com.android.support:cardview-v7:${supportLibVersion}" + compile "com.android.support:design:${supportLibVersion}" + compile "com.android.support:customtabs:${supportLibVersion}" + compile 'com.squareup.retrofit:retrofit:1.9.0' + compile 'com.squareup.okhttp:okhttp:2.4.0' + compile 'com.github.bumptech.glide:glide:3.6.1' + compile 'com.github.bumptech.glide:okhttp-integration:1.3.0' + compile 'com.jakewharton:butterknife:7.0.1' + compile 'org.jsoup:jsoup:1.8.3' + compile project(':bypass') +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..9f9cbbdc7 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/fonts/roboto-mono-regular.ttf b/app/src/main/assets/fonts/roboto-mono-regular.ttf new file mode 100755 index 000000000..b158a334e Binary files /dev/null and b/app/src/main/assets/fonts/roboto-mono-regular.ttf differ diff --git a/app/src/main/java/io/plaidapp/data/BaseDataManager.java b/app/src/main/java/io/plaidapp/data/BaseDataManager.java new file mode 100644 index 000000000..7d0d95ca3 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/BaseDataManager.java @@ -0,0 +1,141 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data; + +import android.content.Context; + +import com.google.gson.GsonBuilder; + +import java.util.List; + +import io.plaidapp.BuildConfig; +import io.plaidapp.data.api.AuthInterceptor; +import io.plaidapp.data.api.ClientAuthInterceptor; +import io.plaidapp.data.api.designernews.DesignerNewsService; +import io.plaidapp.data.api.dribbble.DribbbleService; +import io.plaidapp.data.api.producthunt.ProductHuntService; +import io.plaidapp.data.prefs.DesignerNewsPrefs; +import io.plaidapp.data.prefs.DribbblePrefs; +import retrofit.RestAdapter; +import retrofit.converter.GsonConverter; + +/** + * Base class for loading data. + */ +public abstract class BaseDataManager implements + DribbblePrefs.DribbbleLoginStatusListener, + DesignerNewsPrefs.DesignerNewsLoginStatusListener{ + + private DesignerNewsPrefs designerNewsPrefs; + private DesignerNewsService designerNewsApi; + private DribbblePrefs dribbblePrefs; + private DribbbleService dribbbleApi; + private ProductHuntService productHuntApi; + + public BaseDataManager(Context context) { + // setup the API access objects + designerNewsPrefs = DesignerNewsPrefs.get(context); + createDesignerNewsApi(); + dribbblePrefs = DribbblePrefs.get(context); + createDribbbleApi(); + createProductHuntApi(); + } + + public abstract void onDataLoaded(List data); + + protected static void setPage(List items, int page) { + for (PlaidItem item : items) { + item.page = page; + } + } + + protected static void setDataSource(List items, String dataSource) { + for (PlaidItem item : items) { + item.dataSource = dataSource; + } + } + + private void createDesignerNewsApi() { + designerNewsApi = new RestAdapter.Builder() + .setEndpoint(DesignerNewsService.ENDPOINT) + .setRequestInterceptor(new ClientAuthInterceptor(designerNewsPrefs.getAccessToken(), + BuildConfig.DESIGNER_NEWS_CLIENT_ID)) + .build() + .create(DesignerNewsService.class); + } + + public DesignerNewsService getDesignerNewsApi() { + return designerNewsApi; + } + + public DesignerNewsPrefs getDesignerNewsPrefs() { + return designerNewsPrefs; + } + + private void createDribbbleApi() { + dribbbleApi = new RestAdapter.Builder() + .setEndpoint(DribbbleService.ENDPOINT) + .setConverter(new GsonConverter(new GsonBuilder() + .setDateFormat(DribbbleService.DATE_FORMAT) + .create())) + .setRequestInterceptor(new AuthInterceptor(dribbblePrefs.getAccessToken())) + .build() + .create((DribbbleService.class)); + } + + public DribbbleService getDribbbleApi() { + return dribbbleApi; + } + + public DribbblePrefs getDribbblePrefs() { + return dribbblePrefs; + } + + private void createProductHuntApi() { + productHuntApi = new RestAdapter.Builder() + .setEndpoint(ProductHuntService.ENDPOINT) + .setRequestInterceptor( + new AuthInterceptor(BuildConfig.PROCUCT_HUNT_DEVELOPER_TOKEN)) + .build() + .create(ProductHuntService.class); + } + + public ProductHuntService getProductHuntApi() { + return productHuntApi; + } + + @Override + public void onDribbbleLogin() { + createDribbbleApi(); // capture the auth token + } + + @Override + public void onDribbbleLogout() { + createDribbbleApi(); // clear the auth token + } + + @Override + public void onDesignerNewsLogin() { + createDesignerNewsApi(); // capture the auth token + } + + @Override + public void onDesignerNewsLogout() { + createDesignerNewsApi(); // clear the auth token + } + +} diff --git a/app/src/main/java/io/plaidapp/data/DataLoadingSubject.java b/app/src/main/java/io/plaidapp/data/DataLoadingSubject.java new file mode 100644 index 000000000..498cb0d8c --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/DataLoadingSubject.java @@ -0,0 +1,24 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data; + +/** + * An interface for classes offering data loading state to be observed + */ +public interface DataLoadingSubject { + boolean isDataLoading(); +} diff --git a/app/src/main/java/io/plaidapp/data/DataManager.java b/app/src/main/java/io/plaidapp/data/DataManager.java new file mode 100644 index 000000000..1fc00c037 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/DataManager.java @@ -0,0 +1,421 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data; + +import android.content.Context; +import android.os.AsyncTask; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import io.plaidapp.data.api.designernews.model.StoriesResponse; +import io.plaidapp.data.api.dribbble.DribbbleSearch; +import io.plaidapp.data.api.dribbble.DribbbleService; +import io.plaidapp.data.api.dribbble.model.Like; +import io.plaidapp.data.api.dribbble.model.Shot; +import io.plaidapp.data.api.dribbble.model.User; +import io.plaidapp.data.api.producthunt.model.PostsResponse; +import io.plaidapp.data.prefs.SourceManager; +import io.plaidapp.ui.FilterAdapter; +import retrofit.Callback; +import retrofit.RetrofitError; +import retrofit.client.Response; + +/** + * Responsible for loading data from the various sources. Instantiating classes are responsible for + * providing the {code onDataLoaded} method to do something with the data. + */ +public abstract class DataManager extends BaseDataManager + implements FilterAdapter.FiltersChangedListener, DataLoadingSubject { + + private final FilterAdapter filterAdapter; + private AtomicInteger loadingCount; + private Map pageIndexes; + + /** + * @param filterAdapter + */ + public DataManager(Context context, + FilterAdapter filterAdapter) { + super(context); + this.filterAdapter = filterAdapter; + loadingCount = new AtomicInteger(0); + setupPageIndexes(); + } + + public void loadAllDataSources() { + for (Source filter : filterAdapter.getFilters()) { + loadSource(filter); + } + } + + @Override + public boolean isDataLoading() { + return loadingCount.get() > 0; + } + + @Override + public void onFiltersChanged(Source changedFilter){ + if (changedFilter.active) { + loadSource(changedFilter); + } else { + // clear the page index for the source + pageIndexes.put(changedFilter.key, 0); + } + } + + @Override + public void onFilterRemoved(Source removed) { } // no-op + + private void loadSource(Source source) { + if (source.active) { + loadingCount.incrementAndGet(); + int page = getNextPageIndex(source.key); + switch (source.key) { + case SourceManager.SOURCE_DESIGNER_NEWS_POPULAR: + loadDesignerNewsTopStories(page); + break; + case SourceManager.SOURCE_DESIGNER_NEWS_RECENT: + loadDesignerNewsRecent(page); + break; + case SourceManager.SOURCE_DRIBBBLE_POPULAR: + loadDribbblePopular(page); + break; + case SourceManager.SOURCE_DRIBBBLE_FOLLOWING: + loadDribbbleFollowing(page); + break; + case SourceManager.SOURCE_DRIBBBLE_USER_LIKES: + loadDribbbleUserLikes(page); + break; + case SourceManager.SOURCE_DRIBBBLE_USER_SHOTS: + loadDribbbleUserShots(page); + break; + case SourceManager.SOURCE_DRIBBBLE_RECENT: + loadDribbbleRecent(page); + break; + case SourceManager.SOURCE_DRIBBBLE_DEBUTS: + loadDribbbleDebuts(page); + break; + case SourceManager.SOURCE_DRIBBBLE_ANIMATED: + loadDribbbleAnimated(page); + break; + case SourceManager.SOURCE_PRODUCT_HUNT: + loadProductHunt(page); + break; + default: + if (source instanceof Source.DribbbleSearchSource) { + loadDribbbleSearch((Source.DribbbleSearchSource) source, page); + } else if (source instanceof Source.DesignerNewsSearchSource) { + loadDesignerNewsSearch((Source.DesignerNewsSearchSource) source, page); + } + break; + } + } + } + + private void setupPageIndexes() { + List dateSources = filterAdapter.getFilters(); + pageIndexes = new HashMap<>(dateSources.size()); + for (Source source : dateSources) { + pageIndexes.put(source.key, 0); + } + } + + private int getNextPageIndex(String dataSource) { + int nextPage = 1; // default to one – i.e. for newly added sources + if (pageIndexes.containsKey(dataSource)) { + nextPage = pageIndexes.get(dataSource) + 1; + } + pageIndexes.put(dataSource, nextPage); + return nextPage; + } + + private boolean sourceIsEnabled(String key) { + return pageIndexes.get(key) != 0; + } + + private void loadDesignerNewsTopStories(final int page) { + getDesignerNewsApi().getTopStories(page, new Callback() { + @Override + public void success(StoriesResponse storiesResponse, Response response) { + if (storiesResponse != null + && sourceIsEnabled(SourceManager.SOURCE_DESIGNER_NEWS_POPULAR)) { + setPage(storiesResponse.stories, page); + setDataSource(storiesResponse.stories, + SourceManager.SOURCE_DESIGNER_NEWS_POPULAR); + onDataLoaded(storiesResponse.stories); + } + loadingCount.decrementAndGet(); + } + + @Override + public void failure(RetrofitError error) { + loadingCount.decrementAndGet(); + } + }); + } + + private void loadDesignerNewsRecent(final int page) { + getDesignerNewsApi().getRecentStories(page, new Callback() { + @Override + public void success(StoriesResponse storiesResponse, Response response) { + if (storiesResponse != null + && sourceIsEnabled(SourceManager.SOURCE_DESIGNER_NEWS_RECENT)) { + setPage(storiesResponse.stories, page); + setDataSource(storiesResponse.stories, + SourceManager.SOURCE_DESIGNER_NEWS_RECENT); + onDataLoaded(storiesResponse.stories); + } + loadingCount.decrementAndGet(); + } + + @Override + public void failure(RetrofitError error) { + loadingCount.decrementAndGet(); + } + }); + } + + private void loadDesignerNewsSearch(final Source.DesignerNewsSearchSource source, + final int page) { + getDesignerNewsApi().search(source.query, page, new Callback() { + @Override + public void success(StoriesResponse storiesResponse, Response response) { + if (storiesResponse != null) { + setPage(storiesResponse.stories, page); + setDataSource(storiesResponse.stories, source.key); + onDataLoaded(storiesResponse.stories); + } + loadingCount.decrementAndGet(); + } + + @Override + public void failure(RetrofitError error) { + loadingCount.decrementAndGet(); + } + }); + } + + private void loadDribbblePopular(final int page) { + getDribbbleApi().getPopular(page, DribbbleService.PER_PAGE_DEFAULT, new + Callback>() { + @Override + public void success(List shots, Response response) { + if (sourceIsEnabled(SourceManager.SOURCE_DRIBBBLE_POPULAR)) { + setPage(shots, page); + setDataSource(shots, SourceManager.SOURCE_DRIBBBLE_POPULAR); + onDataLoaded(shots); + } + loadingCount.decrementAndGet(); + } + + @Override + public void failure(RetrofitError error) { + loadingCount.decrementAndGet(); + } + }); + } + + private void loadDribbbleDebuts(final int page) { + getDribbbleApi().getDebuts(page, DribbbleService.PER_PAGE_DEFAULT, new + Callback>() { + @Override + public void success(List shots, Response response) { + if (sourceIsEnabled(SourceManager.SOURCE_DRIBBBLE_DEBUTS)) { + setPage(shots, page); + setDataSource(shots, SourceManager.SOURCE_DRIBBBLE_DEBUTS); + onDataLoaded(shots); + } + loadingCount.decrementAndGet(); + } + + @Override + public void failure(RetrofitError error) { + loadingCount.decrementAndGet(); + } + }); + } + + private void loadDribbbleAnimated(final int page) { + getDribbbleApi().getAnimated(page, DribbbleService.PER_PAGE_DEFAULT, new + Callback>() { + @Override + public void success(List shots, Response response) { + if (sourceIsEnabled(SourceManager.SOURCE_DRIBBBLE_ANIMATED)) { + setPage(shots, page); + setDataSource(shots, SourceManager.SOURCE_DRIBBBLE_ANIMATED); + onDataLoaded(shots); + } + loadingCount.decrementAndGet(); + } + + @Override + public void failure(RetrofitError error) { + loadingCount.decrementAndGet(); + } + }); + } + + private void loadDribbbleRecent(final int page) { + getDribbbleApi().getRecent(page, DribbbleService.PER_PAGE_DEFAULT, new + Callback>() { + @Override + public void success(List shots, Response response) { + if (sourceIsEnabled(SourceManager.SOURCE_DRIBBBLE_RECENT)) { + setPage(shots, page); + setDataSource(shots, SourceManager.SOURCE_DRIBBBLE_RECENT); + onDataLoaded(shots); + } + loadingCount.decrementAndGet(); + } + + @Override + public void failure(RetrofitError error) { + loadingCount.decrementAndGet(); + } + }); + } + + private void loadDribbbleFollowing(final int page) { + if (getDribbblePrefs().isLoggedIn()) { + getDribbbleApi().getFollowing(page, DribbbleService.PER_PAGE_DEFAULT, + new Callback>() { + @Override + public void success(List shots, Response response) { + if (sourceIsEnabled(SourceManager.SOURCE_DRIBBBLE_FOLLOWING)) { + setPage(shots, page); + setDataSource(shots, SourceManager.SOURCE_DRIBBBLE_FOLLOWING); + onDataLoaded(shots); + } + loadingCount.decrementAndGet(); + } + + @Override + public void failure(RetrofitError error) { + loadingCount.decrementAndGet(); + } + }); + } else { + loadingCount.decrementAndGet(); + } + } + + private void loadDribbbleUserLikes(final int page) { + if (getDribbblePrefs().isLoggedIn()) { + getDribbbleApi().getUserLikes(page, DribbbleService.PER_PAGE_DEFAULT, + new Callback>() { + @Override + public void success(List likes, Response response) { + if (sourceIsEnabled(SourceManager.SOURCE_DRIBBBLE_USER_LIKES)) { + // API returns Likes but we just want the Shots + List likedShots = new ArrayList<>(likes.size()); + for (Like like : likes) { + likedShots.add(like.shot); + } + // these will be sorted like any other shot (popularity per page) + // TODO figure out a more appropriate sorting strategy for likes + setPage(likedShots, page); + setDataSource(likedShots, SourceManager.SOURCE_DRIBBBLE_USER_LIKES); + onDataLoaded(likedShots); + } + loadingCount.decrementAndGet(); + } + + @Override + public void failure(RetrofitError error) { + loadingCount.decrementAndGet(); + } + }); + } else { + loadingCount.decrementAndGet(); + } + } + + private void loadDribbbleUserShots(final int page) { + if (getDribbblePrefs().isLoggedIn()) { + getDribbbleApi().getUserShots(page, DribbbleService.PER_PAGE_DEFAULT, + new Callback>() { + @Override + public void success(List shots, Response response) { + if (sourceIsEnabled(SourceManager.SOURCE_DRIBBBLE_USER_SHOTS)) { + // this api call doesn't populate the shot user field but we need it + User user = getDribbblePrefs().getUser(); + for (Shot shot : shots) { + shot.user = user; + } + + setPage(shots, page); + setDataSource(shots, SourceManager.SOURCE_DRIBBBLE_USER_SHOTS); + onDataLoaded(shots); + } + loadingCount.decrementAndGet(); + } + + @Override + public void failure(RetrofitError error) { + loadingCount.decrementAndGet(); + } + }); + } else { + loadingCount.decrementAndGet(); + } + } + + + private void loadDribbbleSearch(final Source.DribbbleSearchSource source, final int page) { + new AsyncTask>() { + @Override + protected List doInBackground(Void... params) { + return DribbbleSearch.search(source.query, DribbbleSearch.SORT_RECENT, page); + } + + @Override + protected void onPostExecute(List shots) { + if (shots != null && shots.size() > 0 && sourceIsEnabled(source.key)) { + setPage(shots, page); + setDataSource(shots, source.key); + onDataLoaded(shots); + } + loadingCount.decrementAndGet(); + } + }.execute(); + } + + private void loadProductHunt(final int page) { + // this API's paging is 0 based but this class (& sorting) is 1 based so adjust locally + getProductHuntApi().getPosts(page - 1, new Callback() { + @Override + public void success(PostsResponse postsResponse, Response response) { + if (postsResponse != null && sourceIsEnabled(SourceManager.SOURCE_PRODUCT_HUNT)) { + setPage(postsResponse.posts, page); + setDataSource(postsResponse.posts, + SourceManager.SOURCE_PRODUCT_HUNT); + onDataLoaded(postsResponse.posts); + } + loadingCount.decrementAndGet(); + } + + @Override + public void failure(RetrofitError error) { + loadingCount.decrementAndGet(); + } + }); + } +} diff --git a/app/src/main/java/io/plaidapp/data/PlaidItem.java b/app/src/main/java/io/plaidapp/data/PlaidItem.java new file mode 100644 index 000000000..8e2b0536e --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/PlaidItem.java @@ -0,0 +1,52 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data; + +/** + * Base class for all model types + */ +public abstract class PlaidItem { + + public final long id; + public final String title; + public String url; // can't be final as some APIs use different serialized names + public String dataSource; + public int page; + public float weight; + public float weightBoost; + + public PlaidItem(long id, + String title, + String url) { + this.id = id; + this.title = title; + this.url = url; + } + + @Override + public String toString() { + return title; + } + + /** + * Equals check based on the id field + */ + @Override + public boolean equals(Object o) { + return (o.getClass() == getClass() && ((PlaidItem) o).id == id); + } +} diff --git a/app/src/main/java/io/plaidapp/data/PlaidItemComparator.java b/app/src/main/java/io/plaidapp/data/PlaidItemComparator.java new file mode 100644 index 000000000..d0ad34c30 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/PlaidItemComparator.java @@ -0,0 +1,30 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data; + +import java.util.Comparator; + +/** + * A comparator that compares {@link PlaidItem}s based on their {@code weight} attribute. + */ +public class PlaidItemComparator implements Comparator { + + @Override + public int compare(PlaidItem lhs, PlaidItem rhs) { + return Float.compare(lhs.weight, rhs.weight); + } +} diff --git a/app/src/main/java/io/plaidapp/data/SearchDataManager.java b/app/src/main/java/io/plaidapp/data/SearchDataManager.java new file mode 100644 index 000000000..e7f6a78e5 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/SearchDataManager.java @@ -0,0 +1,120 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data; + +import android.content.Context; +import android.os.AsyncTask; + +import java.util.List; + +import io.plaidapp.data.api.designernews.model.StoriesResponse; +import io.plaidapp.data.api.dribbble.DribbbleSearch; +import io.plaidapp.data.api.dribbble.model.Shot; +import retrofit.Callback; +import retrofit.RetrofitError; +import retrofit.client.Response; + +/** + * Responsible for loading search results from dribbble and designer news. Instantiating classes are + * responsible for providing the {code onDataLoaded} method to do something with the data. + */ +public abstract class SearchDataManager extends BaseDataManager implements DataLoadingSubject { + + // state + private String query = ""; + private boolean loadingDribbble = false; + private boolean loadingDesignerNews = false; + private int page = 1; + + public SearchDataManager(Context context) { + super(context); + } + + @Override + public boolean isDataLoading() { + return loadingDribbble || loadingDesignerNews; + } + + public void searchFor(String query) { + if (!this.query.equals(query)) { + clear(); + this.query = query; + } else { + page++; + } + searchDribbble(query, page); + searchDesignerNews(query, page); + } + + public void loadMore() { + searchFor(query); + } + + public void clear() { + query = ""; + page = 1; + loadingDribbble = false; + loadingDesignerNews = false; + + } + + public String getQuery() { + return query; + } + + private void searchDesignerNews(final String query, final int resultsPage) { + loadingDesignerNews = true; + getDesignerNewsApi().search(query, resultsPage, new Callback() { + @Override + public void success(StoriesResponse storiesResponse, Response response) { + if (storiesResponse != null) { + setPage(storiesResponse.stories, resultsPage); + setDataSource(storiesResponse.stories, + Source.DribbbleSearchSource.DRIBBBLE_QUERY_PREFIX + query); + onDataLoaded(storiesResponse.stories); + } + loadingDesignerNews = false; + } + + @Override + public void failure(RetrofitError error) { + loadingDesignerNews = false; + } + }); + } + + private void searchDribbble(final String query, final int page) { + loadingDribbble = true; + new AsyncTask>() { + @Override + protected List doInBackground(Void... params) { + return DribbbleSearch.search(query, DribbbleSearch.SORT_POPULAR, page); + } + + @Override + protected void onPostExecute(List shots) { + if (shots != null && shots.size() > 0) { + setPage(shots, page); + setDataSource(shots, "Dribbble Search"); + onDataLoaded(shots); + } + loadingDribbble = false; + } + }.execute(); + } + +} diff --git a/app/src/main/java/io/plaidapp/data/Source.java b/app/src/main/java/io/plaidapp/data/Source.java new file mode 100644 index 000000000..f1facb243 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/Source.java @@ -0,0 +1,119 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data; + +import android.support.annotation.DrawableRes; + +import java.util.Comparator; + +import io.plaidapp.R; + +/** + * Representation of a data source + */ +public class Source { + + public final String key; + public final int sortOrder; + public final String name; + public final @DrawableRes int iconRes; + public boolean active; + + public Source(String key, + int sortOrder, + String name, + @DrawableRes int iconResId, + boolean active) { + this.key = key; + this.sortOrder = sortOrder; + this.name = name; + this.iconRes = iconResId; + this.active = active; + } + + public boolean isSwipeDismissable() { + return false; + } + + public static class DribbbleSource extends Source { + + public DribbbleSource(String key, + int sortOrder, + String name, + boolean active) { + super(key, sortOrder, name, R.drawable.ic_dribbble, active); + } + } + + public static class DribbbleSearchSource extends DribbbleSource { + + public static final String DRIBBBLE_QUERY_PREFIX = "DRIBBBLE_QUERY_"; + private static final int SEARCH_SORT_ORDER = 400; + + public final String query; + + public DribbbleSearchSource(String query, + boolean active) { + super(DRIBBBLE_QUERY_PREFIX + query, SEARCH_SORT_ORDER, "“" + query + "”", active); + this.query = query; + } + + @Override + public boolean isSwipeDismissable() { + return true; + } + } + + public static class DesignerNewsSource extends Source { + + public DesignerNewsSource(String key, + int sortOrder, + String name, + boolean active) { + super(key, sortOrder, name, R.drawable.ic_designer_news, active); + } + } + + public static class DesignerNewsSearchSource extends DesignerNewsSource { + + public static final String DESIGNER_NEWS_QUERY_PREFIX = "DESIGNER_NEWS_QUERY_"; + private static final int SEARCH_SORT_ORDER = 200; + + public final String query; + + public DesignerNewsSearchSource(String query, + boolean active) { + super(DESIGNER_NEWS_QUERY_PREFIX + query, SEARCH_SORT_ORDER, "“" + query + "”", active); + this.query = query; + } + + @Override + public boolean isSwipeDismissable() { + return true; + } + } + + public static class SourceComparator implements Comparator { + + @Override + public int compare(Source lhs, Source rhs) { + return lhs.sortOrder - rhs.sortOrder; + } + } +} + + diff --git a/app/src/main/java/io/plaidapp/data/api/AuthInterceptor.java b/app/src/main/java/io/plaidapp/data/api/AuthInterceptor.java new file mode 100644 index 000000000..8fafcf025 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/AuthInterceptor.java @@ -0,0 +1,40 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api; + +import retrofit.RequestInterceptor; + +/** + * A {@see RequestInterceptor} that adds an auth token to requests + */ +public class AuthInterceptor implements RequestInterceptor { + + private String accessToken; + + public AuthInterceptor(String accessToken) { + this.accessToken = accessToken; + } + + @Override + public void intercept(RequestFacade request) { + request.addHeader("Authorization", "Bearer " + accessToken); + } + + private void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } +} diff --git a/app/src/main/java/io/plaidapp/data/api/ClientAuthInterceptor.java b/app/src/main/java/io/plaidapp/data/api/ClientAuthInterceptor.java new file mode 100644 index 000000000..0f741f008 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/ClientAuthInterceptor.java @@ -0,0 +1,53 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; + +import retrofit.RequestInterceptor; + +/** + * A {@see RequestInterceptor} that adds an auth token to requests if one is provided, otherwise + * adds a client id. + */ +public class ClientAuthInterceptor implements RequestInterceptor { + + private String accessToken; + private String clientId; + private boolean hasAccessToken = false; + + public ClientAuthInterceptor(@Nullable String accessToken, @NonNull String clientId) { + setAccessToken(accessToken); + this.clientId = clientId; + } + + @Override + public void intercept(RequestFacade request) { + if (hasAccessToken) { + request.addHeader("Authorization", "Bearer " + accessToken); + } else { + request.addQueryParam("client_id", clientId); + } + } + + private void setAccessToken(String accessToken) { + this.accessToken = accessToken; + hasAccessToken = !TextUtils.isEmpty(accessToken); + } +} diff --git a/app/src/main/java/io/plaidapp/data/api/designernews/DesignerNewsService.java b/app/src/main/java/io/plaidapp/data/api/designernews/DesignerNewsService.java new file mode 100644 index 000000000..6a38226b6 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/designernews/DesignerNewsService.java @@ -0,0 +1,80 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.designernews; + +import java.util.Map; + +import io.plaidapp.data.api.designernews.model.AccessToken; +import io.plaidapp.data.api.designernews.model.NewStoryRequest; +import io.plaidapp.data.api.designernews.model.StoriesResponse; +import io.plaidapp.data.api.designernews.model.Story; +import io.plaidapp.data.api.designernews.model.StoryResponse; +import io.plaidapp.data.api.designernews.model.UserResponse; +import retrofit.Callback; +import retrofit.http.Body; +import retrofit.http.FieldMap; +import retrofit.http.FormUrlEncoded; +import retrofit.http.GET; +import retrofit.http.Headers; +import retrofit.http.POST; +import retrofit.http.Path; +import retrofit.http.Query; + +/** + * Models the Designer News API. + * + * v1 docs: https://github.com/layervault/dn_api + * v2 docs: https://github.com/DesignerNews/dn_api_v2 + */ +public interface DesignerNewsService { + + String ENDPOINT = "https://www.designernews.co/"; + + @GET("/api/v1/stories") + void getTopStories(@Query("page") Integer page, + Callback callback); + + @GET("/api/v1/stories/recent") + void getRecentStories(@Query("page") Integer page, + Callback callback); + + @GET("/api/v1/stories/search") + void search(@Query("query") String query, + @Query("page") Integer page, + Callback callback); + + @FormUrlEncoded + @POST("/oauth/token") + void login(@FieldMap() Map loginParams, + Callback callback); + + @GET("/api/v1/me") + void getAuthedUser(Callback callback); + + @POST("/api/v1/stories/{id}/upvote") + void upvoteStory(@Path("id") long storyId, + @Body String ignored, // can remove when retrofit releases this fix: + // https://github + // .com/square/retrofit/commit/19ac1e2c4551448184ad66c4a0ec172e2741c2ee + Callback callback); + + @Headers("Content-Type: application/vnd.api+json") + @POST("/api/v2/stories") + void postStory(@Body NewStoryRequest story, + Callback callback); + +} diff --git a/app/src/main/java/io/plaidapp/data/api/designernews/UpvoteStoryService.java b/app/src/main/java/io/plaidapp/data/api/designernews/UpvoteStoryService.java new file mode 100644 index 000000000..5156f8aae --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/designernews/UpvoteStoryService.java @@ -0,0 +1,86 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.designernews; + +import android.app.IntentService; +import android.content.Context; +import android.content.Intent; + +import io.plaidapp.BuildConfig; +import io.plaidapp.data.api.ClientAuthInterceptor; +import io.plaidapp.data.api.designernews.model.StoryResponse; +import io.plaidapp.data.prefs.DesignerNewsPrefs; +import retrofit.Callback; +import retrofit.RestAdapter; +import retrofit.RetrofitError; +import retrofit.client.Response; + +public class UpvoteStoryService extends IntentService { + + public static final String ACTION_UPVOTE = "ACTION_UPVOTE"; + public static final String EXTRA_STORY_ID = "EXTRA_STORY_ID"; + + public UpvoteStoryService() { + super("UpvoteStoryService"); + } + + public static void startActionUpvote(Context context, long storyId) { + Intent intent = new Intent(context, UpvoteStoryService.class); + intent.setAction(ACTION_UPVOTE); + intent.putExtra(EXTRA_STORY_ID, storyId); + context.startService(intent); + } + + @Override + protected void onHandleIntent(Intent intent) { + if (intent != null) { + final String action = intent.getAction(); + if (ACTION_UPVOTE.equals(action)) { + handleActionUpvote(intent.getLongExtra(EXTRA_STORY_ID, 0l)); + } + } + } + + private void handleActionUpvote(long storyId) { + if (storyId == 0l) return; + DesignerNewsPrefs designerNewsPrefs = DesignerNewsPrefs.get(this); + if (!designerNewsPrefs.isLoggedIn()) { + // TODO prompt for login + return; + } + DesignerNewsService designerNewsService = new RestAdapter.Builder() + .setEndpoint(DesignerNewsService.ENDPOINT) + .setRequestInterceptor( + new ClientAuthInterceptor(designerNewsPrefs.getAccessToken(), + BuildConfig.DESIGNER_NEWS_CLIENT_ID)) + .build() + .create(DesignerNewsService.class); + designerNewsService.upvoteStory(storyId, "", new Callback() { + @Override + public void success(StoryResponse storyResponse, Response response) { + int newVotesCount = storyResponse.story.vote_count; + // TODO report success + + } + + @Override + public void failure(RetrofitError error) { + // TODO report failure + } + }); + } +} diff --git a/app/src/main/java/io/plaidapp/data/api/designernews/model/AccessToken.java b/app/src/main/java/io/plaidapp/data/api/designernews/model/AccessToken.java new file mode 100644 index 000000000..737e29f55 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/designernews/model/AccessToken.java @@ -0,0 +1,35 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.designernews.model; + +/** + * Models a Designer News API access token + */ +public class AccessToken { + + public final String access_token; + public final String token_type; + public final String scope; + + public AccessToken(String access_token, + String token_type, + String scope) { + this.access_token = access_token; + this.token_type = token_type; + this.scope = scope; + } +} diff --git a/app/src/main/java/io/plaidapp/data/api/designernews/model/Comment.java b/app/src/main/java/io/plaidapp/data/api/designernews/model/Comment.java new file mode 100644 index 000000000..594c4683a --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/designernews/model/Comment.java @@ -0,0 +1,123 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.designernews.model; + +import android.os.Parcel; +import android.os.Parcelable; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * Models a comment on a designer news story. + */ +public class Comment implements Parcelable { + + @SuppressWarnings("unused") + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public Comment createFromParcel(Parcel in) { + return new Comment(in); + } + + @Override + public Comment[] newArray(int size) { + return new Comment[size]; + } + }; + public final long id; + public final String body; + public final String body_html; + public final Date created_at; + public final int depth; + public final int vote_count; + public final long user_id; + public final String user_display_name; + public final String user_portrait_url; + public final String user_job; + public final List comments; + + public Comment(long id, + String body, + String body_html, + Date created_at, + int depth, + int vote_count, + long user_id, + String user_display_name, + String user_portrait_url, + String user_job, + List comments) { + this.id = id; + this.body = body; + this.body_html = body_html; + this.created_at = created_at; + this.depth = depth; + this.vote_count = vote_count; + this.user_id = user_id; + this.user_display_name = user_display_name; + this.user_portrait_url = user_portrait_url; + this.user_job = user_job; + this.comments = comments; + } + + protected Comment(Parcel in) { + id = in.readLong(); + body = in.readString(); + body_html = in.readString(); + long tmpCreated_at = in.readLong(); + created_at = tmpCreated_at != -1 ? new Date(tmpCreated_at) : null; + depth = in.readInt(); + vote_count = in.readInt(); + user_id = in.readLong(); + user_display_name = in.readString(); + user_portrait_url = in.readString(); + user_job = in.readString(); + if (in.readByte() == 0x01) { + comments = new ArrayList(); + in.readList(comments, Comment.class.getClassLoader()); + } else { + comments = null; + } + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(id); + dest.writeString(body); + dest.writeString(body_html); + dest.writeLong(created_at != null ? created_at.getTime() : -1L); + dest.writeInt(depth); + dest.writeInt(vote_count); + dest.writeLong(user_id); + dest.writeString(user_display_name); + dest.writeString(user_portrait_url); + dest.writeString(user_job); + if (comments == null) { + dest.writeByte((byte) (0x00)); + } else { + dest.writeByte((byte) (0x01)); + dest.writeList(comments); + } + } +} diff --git a/app/src/main/java/io/plaidapp/data/api/designernews/model/NewStoryRequest.java b/app/src/main/java/io/plaidapp/data/api/designernews/model/NewStoryRequest.java new file mode 100644 index 000000000..b8ba59ab9 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/designernews/model/NewStoryRequest.java @@ -0,0 +1,52 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.designernews.model; + +/** + * Models the editable attributes usable when posting a new story. New stories can have either a + * comment or a URL, not both. + */ +public class NewStoryRequest { + + public static NewStoryRequest createWithUrl(String title, String url) { + return new NewStoryRequest(new NewStory(title, url, null)); + } + + public static NewStoryRequest createWithComment(String title, String comment) { + return new NewStoryRequest(new NewStory(title, null, comment)); + } + + public final NewStory stories; + + private static class NewStory { + + public final String title; + public final String url; + public final String comment; + + private NewStory(String title, String url, String comment) { + this.title = title; + this.url = url; + this.comment = comment; + } + } + + private NewStoryRequest(NewStory story) { + this.stories = story; + } + +} diff --git a/app/src/main/java/io/plaidapp/data/api/designernews/model/StoriesResponse.java b/app/src/main/java/io/plaidapp/data/api/designernews/model/StoriesResponse.java new file mode 100644 index 000000000..f4d2b037b --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/designernews/model/StoriesResponse.java @@ -0,0 +1,31 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.designernews.model; + +import java.util.List; + +/** + * Models a response from the Designer News API that returns a collection of stories + */ +public class StoriesResponse { + + public final List stories; + + public StoriesResponse(List stories) { + this.stories = stories; + } +} diff --git a/app/src/main/java/io/plaidapp/data/api/designernews/model/Story.java b/app/src/main/java/io/plaidapp/data/api/designernews/model/Story.java new file mode 100644 index 000000000..be025c2b1 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/designernews/model/Story.java @@ -0,0 +1,144 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.designernews.model; + +import android.os.Parcel; +import android.os.Parcelable; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import io.plaidapp.data.PlaidItem; + +/** + * Models a Designer News story + */ +public class Story extends PlaidItem implements Parcelable { + + @SuppressWarnings("unused") + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public Story createFromParcel(Parcel in) { + return new Story(in); + } + + @Override + public Story[] newArray(int size) { + return new Story[size]; + } + }; + public final String comment; + public final String comment_html; + public final int comment_count; + public final int vote_count; + public final Date created_at; + public final long user_id; + public final String user_display_name; + public final String user_portrait_url; + public final String hostname; + public final String badge; + public final String user_job; + public final List comments; + + public Story(long id, + String title, + String url, + String comment, + String comment_html, + int comment_count, + int vote_count, + Date created_at, + long user_id, + String user_display_name, + String user_portrait_url, + String hostname, + String badge, + String user_job, + List comments) { + super(id, title, url); + this.comment = comment; + this.comment_html = comment_html; + this.comment_count = comment_count; + this.vote_count = vote_count; + this.created_at = created_at; + this.user_id = user_id; + this.user_display_name = user_display_name; + this.user_portrait_url = user_portrait_url; + this.hostname = hostname; + this.badge = badge; + this.user_job = user_job; + this.comments = comments; + } + + protected Story(Parcel in) { + super(in.readLong(), in.readString(), in.readString()); + comment = in.readString(); + comment_html = in.readString(); + comment_count = in.readInt(); + vote_count = in.readInt(); + long tmpCreated_at = in.readLong(); + created_at = tmpCreated_at != -1 ? new Date(tmpCreated_at) : null; + user_id = in.readLong(); + user_display_name = in.readString(); + user_portrait_url = in.readString(); + hostname = in.readString(); + badge = in.readString(); + user_job = in.readString(); + if (in.readByte() == 0x01) { + comments = new ArrayList(); + in.readList(comments, Comment.class.getClassLoader()); + } else { + comments = null; + } + } + + public void weigh(float maxDesignNewsComments, float maxDesignNewsVotes) { + weight = 1f - ((((float) comment_count) / maxDesignNewsComments) + + ((float) vote_count / maxDesignNewsVotes)) / 2f; + weight = Math.min(weight + weightBoost, 1f); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(id); + dest.writeString(title); + dest.writeString(url); + dest.writeString(comment); + dest.writeString(comment_html); + dest.writeInt(comment_count); + dest.writeInt(vote_count); + dest.writeLong(created_at != null ? created_at.getTime() : -1L); + dest.writeLong(user_id); + dest.writeString(user_display_name); + dest.writeString(user_portrait_url); + dest.writeString(hostname); + dest.writeString(badge); + dest.writeString(user_job); + if (comments == null) { + dest.writeByte((byte) (0x00)); + } else { + dest.writeByte((byte) (0x01)); + dest.writeList(comments); + } + } +} diff --git a/app/src/main/java/io/plaidapp/data/api/designernews/model/StoryResponse.java b/app/src/main/java/io/plaidapp/data/api/designernews/model/StoryResponse.java new file mode 100644 index 000000000..dc715d2de --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/designernews/model/StoryResponse.java @@ -0,0 +1,29 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.designernews.model; + +/** + * Models a response from the Designer News API that returns a single story + */ +public class StoryResponse { + + public final Story story; + + public StoryResponse(Story story) { + this.story = story; + } +} diff --git a/app/src/main/java/io/plaidapp/data/api/designernews/model/User.java b/app/src/main/java/io/plaidapp/data/api/designernews/model/User.java new file mode 100644 index 000000000..0cd93c653 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/designernews/model/User.java @@ -0,0 +1,47 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.designernews.model; + +/** + * Models a Desinger News User + */ +public class User { + + public final long id; + public final String first_name; + public final String last_name; + public final String display_name; + public final String job; + public final String portrait_url; + public final String cover_photo_url; + + public User(long id, + String first_name, + String last_name, + String display_name, + String job, + String portrait_url, + String cover_photo_url) { + this.id = id; + this.first_name = first_name; + this.last_name = last_name; + this.display_name = display_name; + this.job = job; + this.portrait_url = portrait_url; + this.cover_photo_url = cover_photo_url; + } +} diff --git a/app/src/main/java/io/plaidapp/data/api/designernews/model/UserResponse.java b/app/src/main/java/io/plaidapp/data/api/designernews/model/UserResponse.java new file mode 100644 index 000000000..82d5dd427 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/designernews/model/UserResponse.java @@ -0,0 +1,29 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.designernews.model; + +/** + * Models a response from the Designer News API that returns a single user + */ +public class UserResponse { + + public final User user; + + public UserResponse(User user) { + this.user = user; + } +} diff --git a/app/src/main/java/io/plaidapp/data/api/dribbble/DribbbleAuthService.java b/app/src/main/java/io/plaidapp/data/api/dribbble/DribbbleAuthService.java new file mode 100644 index 000000000..aaef4dfaf --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/dribbble/DribbbleAuthService.java @@ -0,0 +1,46 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.dribbble; + +import io.plaidapp.data.api.dribbble.model.AccessToken; +import retrofit.Callback; +import retrofit.http.Body; +import retrofit.http.POST; +import retrofit.http.Query; + +/** + * Dribbble Auth API (a different endpoint) + */ +public interface DribbbleAuthService { + + public static final String ENDPOINT = "https://dribbble.com/"; + + @POST("/oauth/token") + public AccessToken getAccessToken(@Query("client_id") String client_id, + @Query("client_secret") String client_secret, + @Query("code") String code); + + @POST("/oauth/token") + public void getAccessToken(@Query("client_id") String client_id, + @Query("client_secret") String client_secret, + @Query("code") String code, + @Body String unused, // can remove when retrofit releases this + // fix: https://github + // .com/square/retrofit/commit/19ac1e2c4551448184ad66c4a0ec172e2741c2ee + Callback callback); + +} diff --git a/app/src/main/java/io/plaidapp/data/api/dribbble/DribbbleSearch.java b/app/src/main/java/io/plaidapp/data/api/dribbble/DribbbleSearch.java new file mode 100644 index 000000000..7b5a3e5b1 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/dribbble/DribbbleSearch.java @@ -0,0 +1,149 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.dribbble; + +import android.support.annotation.StringDef; +import android.support.annotation.WorkerThread; +import android.text.TextUtils; + +import com.squareup.okhttp.OkHttpClient; +import com.squareup.okhttp.Request; +import com.squareup.okhttp.Response; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; + +import java.io.IOException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.net.URLEncoder; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.plaidapp.data.api.dribbble.model.Images; +import io.plaidapp.data.api.dribbble.model.Shot; +import io.plaidapp.data.api.dribbble.model.User; + +/** + * Dribbble API does not have a search endpoint so we have to do gross things :( + */ +public class DribbbleSearch { + + public static final String SORT_POPULAR = "s="; + public static final String SORT_RECENT = "s=latest"; + private static final String SEARCH_ENDPOINT = "/search?q="; + private static final String HOST = "https://dribbble.com"; + private static final Pattern PATTERN_PLAYER_ID = Pattern.compile("users/(\\d+?)/", Pattern + .DOTALL); + + @WorkerThread + public static List search(String query, @SortOrder String sort, int page) { + + // e.g https://dribbble.com/search?q=material+design&page=7&per_page=12 + String html = downloadPage(HOST + SEARCH_ENDPOINT + URLEncoder.encode(query) + "&" + sort + + "&page=" + page + "&per_page=12"); + if (html == null) return null; + Elements shotElements = Jsoup.parse(html, HOST).select("li[id^=screenshot]"); + SimpleDateFormat dateFormat = new SimpleDateFormat("MMMM d, yyyy"); + List shots = new ArrayList<>(shotElements.size()); + for (Element element : shotElements) { + Shot shot = parseShot(element, dateFormat); + if (shot != null) { + shots.add(shot); + } + } + return shots; + } + + private static Shot parseShot(Element element, SimpleDateFormat dateFormat) { + Element descriptionBlock = element.select("a.dribbble-over").first(); + // API responses wrap description in a

tag. Do the same for consistent display. + String description = descriptionBlock.select("span.comment").text().trim(); + if (!TextUtils.isEmpty(description)) { + description = "

" + description + "

"; + } + String imgUrl = element.select("img").first().attr("src"); + if (imgUrl.contains("_teaser.")) { + imgUrl = imgUrl.replace("_teaser.", "."); + } + Date createdAt = null; + try { + createdAt + = dateFormat.parse(descriptionBlock.select("em.timestamp").first().text()); + } catch (ParseException e) { } + + return new Shot.Builder() + .setId(Long.parseLong(element.id().replace("screenshot-", ""))) + .setHtmlUrl(HOST + element.select("a.dribbble-link").first().attr("href")) + .setTitle(descriptionBlock.select("strong").first().text()) + .setDescription(description) + .setImages(new Images(null, imgUrl, null)) + .setCreatedAt(createdAt) + .setLikesCount(Long.parseLong(element.select("li.fav").first().child(0).text().replaceAll(",", ""))) + .setCommentsCount(Long.parseLong(element.select("li.cmnt").first().child(0).text().replaceAll(",", ""))) + .setViewsCount(Long.parseLong(element.select("li.views").first().child(0) + .text().replaceAll(",", ""))) + .setUser(parsePlayer(element.select("h2").first())) + .build(); + } + + private static User parsePlayer(Element element) { + Element userBlock = element.select("a.url").first(); + String avatarUrl = userBlock.select("img.photo").first().attr("src"); + if (avatarUrl.contains("/mini/")) { + avatarUrl = avatarUrl.replace("/mini/", "/normal/"); + } + Matcher matchId = PATTERN_PLAYER_ID.matcher(avatarUrl); + Long id = -1l; + if (matchId.find()) { + id = Long.parseLong(matchId.group(1)); + } + return new User.Builder() + .setId(id) + .setName(userBlock.text()) + .setUsername(userBlock.attr("href").substring(1)) + .setHtmlUrl(HOST + userBlock.attr("href")) + .setAvatarUrl(avatarUrl) + .setPro(element.select("span.badge-pro").size() > 0) + .build(); + } + + private static String downloadPage(String url) { + OkHttpClient client = new OkHttpClient(); + Request request = new Request.Builder().url(url).build(); + try { + Response response = client.newCall(request).execute(); + return response.body().string(); + } catch (IOException ioe) { + return null; + } + } + + @Retention(RetentionPolicy.SOURCE) + @StringDef({ + SORT_POPULAR, + SORT_RECENT + }) + public @interface SortOrder {} + +} diff --git a/app/src/main/java/io/plaidapp/data/api/dribbble/DribbbleService.java b/app/src/main/java/io/plaidapp/data/api/dribbble/DribbbleService.java new file mode 100644 index 000000000..fbae33e10 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/dribbble/DribbbleService.java @@ -0,0 +1,232 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.dribbble; + +import android.support.annotation.StringDef; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.List; + +import io.plaidapp.data.api.dribbble.model.Comment; +import io.plaidapp.data.api.dribbble.model.Like; +import io.plaidapp.data.api.dribbble.model.Shot; +import io.plaidapp.data.api.dribbble.model.User; +import retrofit.Callback; +import retrofit.http.Body; +import retrofit.http.DELETE; +import retrofit.http.GET; +import retrofit.http.POST; +import retrofit.http.Path; +import retrofit.http.Query; + +/** + * Dribbble API - http://developer.dribbble.com/v1/ + */ +public interface DribbbleService { + + String ENDPOINT = "https://api.dribbble.com/v1/"; + String DATE_FORMAT = "yyyy/MM/dd HH:mm:ss Z"; + int PER_PAGE_MAX = 100; + int PER_PAGE_DEFAULT = 30; + + + /* Shots */ + + @GET("/shots") + void getPopular(@Query("page") Integer page, + @Query("per_page") Integer pageSize, + Callback> callback); + + @GET("/shots?sort=recent") + void getRecent(@Query("page") Integer page, + @Query("per_page") Integer pageSize, + Callback> callback); + + @GET("/shots?list=debuts") + void getDebuts(@Query("page") Integer page, + @Query("per_page") Integer pageSize, + Callback> callback); + + @GET("/shots?list=animated") + void getAnimated(@Query("page") Integer page, + @Query("per_page") Integer pageSize, + Callback> callback); + + @GET("/shots") + void getShots(@Query("list") @ShotType String shotType, + @Query("timeframe") @ShotTimeframe String timeframe, + @Query("sort") @ShotSort String shotSort, + Callback> callback); + + @GET("/shots/{id}") + Shot getShot(@Path("id") long shotId); + + @GET("/shots/{id}") + void getShot(@Path("id") long shotId, + Callback callback); + + @GET("/user/following/shots") + void getFollowing(@Query("page") Integer page, + @Query("per_page") Integer pageSize, + Callback> callback); + + /* List the authenticated user’s shot likes */ + @GET("/user/likes") + void getUserLikes(@Query("page") Integer page, + @Query("per_page") Integer pageSize, + Callback> callback); + + /* List the authenticated user’s shots */ + @GET("/user/shots") + void getUserShots(@Query("page") Integer page, + @Query("per_page") Integer pageSize, + Callback> callback); + + /* Shot likes */ + + @GET("/shots/{id}/likes") + void getUserLikes(@Path("id") long shotId, + Callback> callback); + + @GET("/shots/{id}/like") + void liked(@Path("id") long shotId, + Callback callback); + + @POST("/shots/{id}/like") + void like(@Path("id") long shotId, + @Body String ignored, // can remove when retrofit releases this fix: + // https://github.com/square/retrofit/commit/19ac1e2c4551448184ad66c4a0ec172e2741c2ee + Callback callback); + + @DELETE("/shots/{id}/like") + void unlike(@Path("id") long shotId, + Callback callback); + + + /* Comments */ + + @GET("/shots/{id}/comments") + void getComments(@Path("id") long shotId, + @Query("page") Integer page, + @Query("per_page") Integer pageSize, + Callback> callback); + + @GET("/shots/{shot}/comments/{id}/likes") + void getCommentLikes(@Path("shot") long shotId, + @Path("id") long commentId, + Callback> callback); + + @POST("/shots/{shot}/comments") + void postComment(@Path("shot") long shotId, + @Query("body") String body, + Callback callback); + + + @DELETE("/shots/{shot}/comments/{id}") + void deleteComment(@Path("shot") long shotId, + @Path("id") long commentId, + Callback callback); + + @GET("/shots/{shot}/comments/{id}/like") + void likedComment(@Path("shot") long shotId, + @Path("id") long commentId, + Callback callback); + + @POST("/shots/{shot}/comments/{id}/like") + void likeComment(@Path("shot") long shotId, + @Path("id") long commentId, + @Body String ignored, // can remove when retrofit releases this fix: + // https://github + // .com/square/retrofit/commit/19ac1e2c4551448184ad66c4a0ec172e2741c2ee + Callback callback); + + @DELETE("/shots/{shot}/comments/{id}/like") + void unlikeComment(@Path("shot") long shotId, + @Path("id") long commentId, + Callback callback); + + + /* Users */ + + @GET("/users/{user}") + User getUser(@Path("user") long userId); + + @GET("/users/{user}") + void getUser(@Path("user") long userId, Callback callback); + + @GET("/users/{user}") + User getUser(@Path("user") String username); + + @GET("/users/{user}") + void getUser(@Path("user") String username, Callback callback); + + @GET("/user") + User getAuthenticatedUser(); + + @GET("/user") + void getAuthenticatedUser(Callback callback); + + + /* Magic Constants */ + + String SHOT_TYPE_ANIMATED = "animated"; + String SHOT_TYPE_ATTACHMENTS = "attachments"; + String SHOT_TYPE_DEBUTS = "debuts"; + String SHOT_TYPE_PLAYOFFS = "playoffs"; + String SHOT_TYPE_REBOUNDS = "rebounds"; + String SHOT_TYPE_TEAMS = "teams"; + String SHOT_TIMEFRAME_WEEK = "week"; + String SHOT_TIMEFRAME_MONTH = "month"; + String SHOT_TIMEFRAME_YEAR = "year"; + String SHOT_TIMEFRAME_EVER = "ever"; + String SHOT_SORT_COMMENTS = "comments"; + String SHOT_SORT_RECENT = "recent"; + String SHOT_SORT_VIEWS = "views"; + + // Shot type + @Retention(RetentionPolicy.SOURCE) + @StringDef({ + SHOT_TYPE_ANIMATED, + SHOT_TYPE_ATTACHMENTS, + SHOT_TYPE_DEBUTS, + SHOT_TYPE_PLAYOFFS, + SHOT_TYPE_REBOUNDS, + SHOT_TYPE_TEAMS + }) + @interface ShotType {} + + // Shot timeframe + @Retention(RetentionPolicy.SOURCE) + @StringDef({ + SHOT_TIMEFRAME_WEEK, + SHOT_TIMEFRAME_MONTH, + SHOT_TIMEFRAME_YEAR, + SHOT_TIMEFRAME_EVER + }) + @interface ShotTimeframe {} + + // Short sort order + @Retention(RetentionPolicy.SOURCE) + @StringDef({ + SHOT_SORT_COMMENTS, + SHOT_SORT_RECENT, + SHOT_SORT_VIEWS + }) + @interface ShotSort {} + +} diff --git a/app/src/main/java/io/plaidapp/data/api/dribbble/model/AccessToken.java b/app/src/main/java/io/plaidapp/data/api/dribbble/model/AccessToken.java new file mode 100644 index 000000000..c7e783c8f --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/dribbble/model/AccessToken.java @@ -0,0 +1,30 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.dribbble.model; + +public class AccessToken { + + public final String access_token; + public final String token_type; + public final String scope; + + public AccessToken(String access_token, String token_type, String scope) { + this.access_token = access_token; + this.token_type = token_type; + this.scope = scope; + } +} diff --git a/app/src/main/java/io/plaidapp/data/api/dribbble/model/Comment.java b/app/src/main/java/io/plaidapp/data/api/dribbble/model/Comment.java new file mode 100644 index 000000000..7a75981d5 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/dribbble/model/Comment.java @@ -0,0 +1,71 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.dribbble.model; + +import android.text.Spanned; +import android.text.TextUtils; +import android.widget.TextView; + +import java.util.Date; + +import io.plaidapp.util.HtmlUtils; + +/** + * Models a commend on a Dribbble shot. + */ +public class Comment { + + public final long id; + public final String body; + public final String likes_url; + public final Date created_at; + public final Date updated_at; + public final User user; + public long likes_count; + // todo move this into a decorator + public Boolean liked; + public Spanned parsedBody; + + public Comment(long id, + String body, + long likes_count, + String likes_url, + Date created_at, + Date updated_at, + User user) { + this.id = id; + this.body = body; + this.likes_count = likes_count; + this.likes_url = likes_url; + this.created_at = created_at; + this.updated_at = updated_at; + this.user = user; + } + + public Spanned getParsedBody(TextView textView) { + if (parsedBody == null && !TextUtils.isEmpty(body)) { + parsedBody = HtmlUtils.parseHtml(body, textView.getLinkTextColors(), textView + .getHighlightColor()); + } + return parsedBody; + } + + @Override + public String toString() { + return body; + } +} diff --git a/app/src/main/java/io/plaidapp/data/api/dribbble/model/Images.java b/app/src/main/java/io/plaidapp/data/api/dribbble/model/Images.java new file mode 100644 index 000000000..c3a3ac522 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/dribbble/model/Images.java @@ -0,0 +1,71 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.dribbble.model; + +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +/** + * Models links to the various quality of images of a shot. + */ +public class Images implements Parcelable { + + @SuppressWarnings("unused") + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public Images createFromParcel(Parcel in) { + return new Images(in); + } + + @Override + public Images[] newArray(int size) { + return new Images[size]; + } + }; + public final String hidpi; + public final String normal; + public final String teaser; + + public Images(String hidpi, String normal, String teaser) { + this.hidpi = hidpi; + this.normal = normal; + this.teaser = teaser; + } + + protected Images(Parcel in) { + hidpi = in.readString(); + normal = in.readString(); + teaser = in.readString(); + } + + public String best() { + return !TextUtils.isEmpty(hidpi) ? hidpi : normal; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(hidpi); + dest.writeString(normal); + dest.writeString(teaser); + } +} diff --git a/app/src/main/java/io/plaidapp/data/api/dribbble/model/Like.java b/app/src/main/java/io/plaidapp/data/api/dribbble/model/Like.java new file mode 100644 index 000000000..2e9f756d6 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/dribbble/model/Like.java @@ -0,0 +1,39 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.dribbble.model; + +import android.support.annotation.Nullable; + +import java.util.Date; + +/** + * Models a like of a Dribbble shot. + */ +public class Like { + + public final long id; + public final Date created_at; + public final @Nullable User user; // some calls do not populate the user field + public final @Nullable Shot shot; // some calls do not populate the shot field + + public Like(long id, Date created_at, User user, Shot shot) { + this.id = id; + this.created_at = created_at; + this.user = user; + this.shot = shot; + } +} diff --git a/app/src/main/java/io/plaidapp/data/api/dribbble/model/Shot.java b/app/src/main/java/io/plaidapp/data/api/dribbble/model/Shot.java new file mode 100644 index 000000000..16c5ee7f2 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/dribbble/model/Shot.java @@ -0,0 +1,360 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.dribbble.model; + +import android.os.Parcel; +import android.os.Parcelable; +import android.text.Spanned; +import android.text.TextUtils; +import android.widget.TextView; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import io.plaidapp.data.PlaidItem; +import io.plaidapp.util.HtmlUtils; + +/** + * Models a dibbble shot + */ +public class Shot extends PlaidItem implements Parcelable { + + public final String description; + public final long width; + public final long height; + public final Images images; + public final long views_count; + public final long likes_count; + public final long comments_count; + public final long attachments_count; + public final long rebounds_count; + public final long buckets_count; + public final Date created_at; + public final Date updated_at; + public final String html_url; + public final String attachments_url; + public final String buckets_url; + public final String comments_url; + public final String likes_url; + public final String projects_url; + public final String rebounds_url; + public final List tags; + public User user; + public final Team team; + // todo move this into a decorator + public boolean hasFadedIn = false; + public Spanned parsedDescription; + + public Shot(long id, + String title, + String description, + long width, + long height, + Images images, + long views_count, + long likes_count, + long comments_count, + long attachments_count, + long rebounds_count, + long buckets_count, + Date created_at, + Date updated_at, + String html_url, + String attachments_url, + String buckets_url, + String comments_url, + String likes_url, + String projects_url, + String rebounds_url, + List tags, + User user, + Team team) { + super(id, title, html_url); + this.description = description; + this.width = width; + this.height = height; + this.images = images; + this.views_count = views_count; + this.likes_count = likes_count; + this.comments_count = comments_count; + this.attachments_count = attachments_count; + this.rebounds_count = rebounds_count; + this.buckets_count = buckets_count; + this.created_at = created_at; + this.updated_at = updated_at; + this.html_url = html_url; + this.attachments_url = attachments_url; + this.buckets_url = buckets_url; + this.comments_url = comments_url; + this.likes_url = likes_url; + this.projects_url = projects_url; + this.rebounds_url = rebounds_url; + this.tags = tags; + this.user = user; + this.team = team; + } + + protected Shot(Parcel in) { + super(in.readLong(), in.readString(), in.readString()); + description = in.readString(); + width = in.readLong(); + height = in.readLong(); + images = (Images) in.readValue(Images.class.getClassLoader()); + views_count = in.readLong(); + likes_count = in.readLong(); + comments_count = in.readLong(); + attachments_count = in.readLong(); + rebounds_count = in.readLong(); + buckets_count = in.readLong(); + long tmpCreated_at = in.readLong(); + created_at = tmpCreated_at != -1 ? new Date(tmpCreated_at) : null; + long tmpUpdated_at = in.readLong(); + updated_at = tmpUpdated_at != -1 ? new Date(tmpUpdated_at) : null; + html_url = in.readString(); + url = html_url; + attachments_url = in.readString(); + buckets_url = in.readString(); + comments_url = in.readString(); + likes_url = in.readString(); + projects_url = in.readString(); + rebounds_url = in.readString(); + tags = new ArrayList(); + in.readStringList(tags); + user = (User) in.readValue(User.class.getClassLoader()); + team = (Team) in.readValue(Team.class.getClassLoader()); + hasFadedIn = in.readByte() != 0x00; + } + + public Spanned getParsedDescription(TextView textView) { + if (parsedDescription == null && !TextUtils.isEmpty(description)) { + parsedDescription = HtmlUtils.parseHtml(description, textView.getLinkTextColors(), + textView.getHighlightColor()); + } + return parsedDescription; + } + + public void weigh(long maxLikes) { + weight = 1f - (float) likes_count / maxLikes * 0.8f; + weight = Math.min(weight + weightBoost, 1f); + } + + public static class Builder { + private long id; + private String title; + private String description; + private long width; + private long height; + private Images images; + private long views_count; + private long likes_count; + private long comments_count; + private long attachments_count; + private long rebounds_count; + private long buckets_count; + private Date created_at; + private Date updated_at; + private String html_url; + private String attachments_url; + private String buckets_url; + private String comments_url; + private String likes_url; + private String projects_url; + private String rebounds_url; + private List tags; + private User user; + private Team team; + + public Builder setId(long id) { + this.id = id; + return this; + } + + public Builder setTitle(String title) { + this.title = title; + return this; + } + + public Builder setDescription(String description) { + this.description = description; + return this; + } + + public Builder setWidth(long width) { + this.width = width; + return this; + } + + public Builder setHeight(long height) { + this.height = height; + return this; + } + + public Builder setImages(Images images) { + this.images = images; + return this; + } + + public Builder setViewsCount(long views_count) { + this.views_count = views_count; + return this; + } + + public Builder setLikesCount(long likes_count) { + this.likes_count = likes_count; + return this; + } + + public Builder setCommentsCount(long comments_count) { + this.comments_count = comments_count; + return this; + } + + public Builder setAttachmentsCount(long attachments_count) { + this.attachments_count = attachments_count; + return this; + } + + public Builder setReboundsCount(long rebounds_count) { + this.rebounds_count = rebounds_count; + return this; + } + + public Builder setBucketsCount(long buckets_count) { + this.buckets_count = buckets_count; + return this; + } + + public Builder setCreatedAt(Date created_at) { + this.created_at = created_at; + return this; + } + + public Builder setUpdatedAt(Date updated_at) { + this.updated_at = updated_at; + return this; + } + + public Builder setHtmlUrl(String html_url) { + this.html_url = html_url; + return this; + } + + public Builder setAttachmentsUrl(String attachments_url) { + this.attachments_url = attachments_url; + return this; + } + + public Builder setBucketsUrl(String buckets_url) { + this.buckets_url = buckets_url; + return this; + } + + public Builder setCommentsUrl(String comments_url) { + this.comments_url = comments_url; + return this; + } + + public Builder setLikesUrl(String likes_url) { + this.likes_url = likes_url; + return this; + } + + public Builder setProjectsUrl(String projects_url) { + this.projects_url = projects_url; + return this; + } + + public Builder setReboundsUrl(String rebounds_url) { + this.rebounds_url = rebounds_url; + return this; + } + + public Builder setTags(List tags) { + this.tags = tags; + return this; + } + + public Builder setUser(User user) { + this.user = user; + return this; + } + + public Builder setTeam(Team team) { + this.team = team; + return this; + } + + public Shot build() { + return new Shot(id, title, description, width, height, images, views_count, + likes_count, comments_count, attachments_count, rebounds_count, + buckets_count, created_at, updated_at, html_url, attachments_url, + buckets_url, comments_url, likes_url, projects_url, rebounds_url, tags, user, + team); + } + } + + /* Parcelable stuff */ + + @SuppressWarnings("unused") + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public Shot createFromParcel(Parcel in) { + return new Shot(in); + } + + @Override + public Shot[] newArray(int size) { + return new Shot[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(id); + dest.writeString(title); + dest.writeString(url); + dest.writeString(description); + dest.writeLong(width); + dest.writeLong(height); + dest.writeValue(images); + dest.writeLong(views_count); + dest.writeLong(likes_count); + dest.writeLong(comments_count); + dest.writeLong(attachments_count); + dest.writeLong(rebounds_count); + dest.writeLong(buckets_count); + dest.writeLong(created_at != null ? created_at.getTime() : -1L); + dest.writeLong(updated_at != null ? updated_at.getTime() : -1L); + dest.writeString(html_url); + dest.writeString(attachments_url); + dest.writeString(buckets_url); + dest.writeString(comments_url); + dest.writeString(likes_url); + dest.writeString(projects_url); + dest.writeString(rebounds_url); + dest.writeStringList(tags); + dest.writeValue(user); + dest.writeValue(team); + dest.writeByte((byte) (hasFadedIn ? 0x01 : 0x00)); + } +} diff --git a/app/src/main/java/io/plaidapp/data/api/dribbble/model/Team.java b/app/src/main/java/io/plaidapp/data/api/dribbble/model/Team.java new file mode 100644 index 000000000..78fd84cec --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/dribbble/model/Team.java @@ -0,0 +1,98 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.dribbble.model; + +import android.os.Parcel; +import android.os.Parcelable; + +import java.util.Map; + +import io.plaidapp.util.ParcelUtils; + +/** + * Models a Dribbble team. + */ +public class Team implements Parcelable { + + @SuppressWarnings("unused") + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public Team createFromParcel(Parcel in) { + return new Team(in); + } + + @Override + public Team[] newArray(int size) { + return new Team[size]; + } + }; + public final long id; + public final String name; + public final String username; + public final String html_url; + public final String avatar_url; + public final String bio; + public final String location; + public final Map links; + + + public Team(long id, + String name, + String username, + String html_url, + String avatar_url, + String bio, + String location, + Map links) { + this.id = id; + this.name = name; + this.username = username; + this.html_url = html_url; + this.avatar_url = avatar_url; + this.bio = bio; + this.location = location; + this.links = links; + } + + protected Team(Parcel in) { + id = in.readLong(); + name = in.readString(); + username = in.readString(); + html_url = in.readString(); + avatar_url = in.readString(); + bio = in.readString(); + location = in.readString(); + links = ParcelUtils.readStringMap(in); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(id); + dest.writeString(name); + dest.writeString(username); + dest.writeString(html_url); + dest.writeString(avatar_url); + dest.writeString(bio); + dest.writeString(location); + ParcelUtils.writeStringMap(links, dest); + } +} diff --git a/app/src/main/java/io/plaidapp/data/api/dribbble/model/User.java b/app/src/main/java/io/plaidapp/data/api/dribbble/model/User.java new file mode 100644 index 000000000..946db8b60 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/dribbble/model/User.java @@ -0,0 +1,386 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.dribbble.model; + +import android.os.Parcel; +import android.os.Parcelable; + +import java.util.Date; +import java.util.Map; + +import io.plaidapp.util.ParcelUtils; + +/** + * Models a dribbble user + */ +public class User implements Parcelable { + + public final long id; + public final String name; + public final String username; + public final String html_url; + public final String avatar_url; + public final String bio; + public final String location; + public final Map links; + public final int buckets_count; + public final int followers_count; + public final int followings_count; + public final int likes_count; + public final int projects_count; + public final int shots_count; + public final int teams_count; + public final String type; + public final Boolean pro; + public final String buckets_url; + public final String followers_url; + public final String following_url; + public final String likes_url; + public final String projects_url; + public final String shots_url; + public final String teams_url; + public final Date created_at; + public final Date updated_at; + + public User(long id, + String name, + String username, + String html_url, + String avatar_url, + String bio, + String location, + Map links, + int buckets_count, + int followers_count, + int followings_count, + int likes_count, + int projects_count, + int shots_count, + int teams_count, + String type, + Boolean pro, + String buckets_url, + String followers_url, + String following_url, + String likes_url, + String projects_url, + String shots_url, + String teams_url, + Date created_at, + Date updated_at) { + this.id = id; + this.name = name; + this.username = username; + this.html_url = html_url; + this.avatar_url = avatar_url; + this.bio = bio; + this.location = location; + this.links = links; + this.buckets_count = buckets_count; + this.followers_count = followers_count; + this.followings_count = followings_count; + this.likes_count = likes_count; + this.projects_count = projects_count; + this.shots_count = shots_count; + this.teams_count = teams_count; + this.type = type; + this.pro = pro; + this.buckets_url = buckets_url; + this.followers_url = followers_url; + this.following_url = following_url; + this.likes_url = likes_url; + this.projects_url = projects_url; + this.shots_url = shots_url; + this.teams_url = teams_url; + this.created_at = created_at; + this.updated_at = updated_at; + } + + protected User(Parcel in) { + id = in.readLong(); + name = in.readString(); + username = in.readString(); + html_url = in.readString(); + avatar_url = in.readString(); + bio = in.readString(); + location = in.readString(); + links = ParcelUtils.readStringMap(in); + buckets_count = in.readInt(); + followers_count = in.readInt(); + followings_count = in.readInt(); + likes_count = in.readInt(); + projects_count = in.readInt(); + shots_count = in.readInt(); + teams_count = in.readInt(); + type = in.readString(); + byte proVal = in.readByte(); + pro = proVal == 0x02 ? null : proVal != 0x00; + buckets_url = in.readString(); + followers_url = in.readString(); + following_url = in.readString(); + likes_url = in.readString(); + projects_url = in.readString(); + shots_url = in.readString(); + teams_url = in.readString(); + long tmpCreated_at = in.readLong(); + created_at = tmpCreated_at != -1 ? new Date(tmpCreated_at) : null; + long tmpUpdated_at = in.readLong(); + updated_at = tmpUpdated_at != -1 ? new Date(tmpUpdated_at) : null; + } + + public static class Builder { + private long id; + private String name; + private String username; + private String html_url = null; + private String avatar_url; + private String bio = null; + private String location = null; + private Map links = null; + private int buckets_count = 0; + private int followers_count = 0; + private int followings_count = 0; + private int likes_count = 0; + private int projects_count = 0; + private int shots_count = 0; + private int teams_count = 0; + private String type = null; + private Boolean pro = null; + private String buckets_url = null; + private String followers_url = null; + private String following_url = null; + private String likes_url = null; + private String projects_url = null; + private String shots_url = null; + private String teams_url = null; + private Date created_at = null; + private Date updated_at = null; + + public Builder setId(long id) { + this.id = id; + return this; + } + + public Builder setName(String name) { + this.name = name; + return this; + } + + public Builder setUsername(String username) { + this.username = username; + return this; + } + + public Builder setAvatarUrl(String avatar_url) { + this.avatar_url = avatar_url; + return this; + } + + public Builder setHtmlUrl(String html_url) { + this.html_url = html_url; + return this; + } + + public Builder setBio(String bio) { + this.bio = bio; + return this; + } + + public Builder setLocation(String location) { + this.location = location; + return this; + } + + public Builder setLinks(Map links) { + this.links = links; + return this; + } + + public Builder setBucketsCount(int buckets_count) { + this.buckets_count = buckets_count; + return this; + } + + public Builder setFollowersCount(int followers_count) { + this.followers_count = followers_count; + return this; + } + + public Builder setFollowingsCount(int followings_count) { + this.followings_count = followings_count; + return this; + } + + public Builder setLikesCount(int likes_count) { + this.likes_count = likes_count; + return this; + } + + public Builder setProjectsCount(int projects_count) { + this.projects_count = projects_count; + return this; + } + + public Builder setShotsCount(int shots_count) { + this.shots_count = shots_count; + return this; + } + + public Builder setTeamsCount(int teams_count) { + this.teams_count = teams_count; + return this; + } + + public Builder setType(String type) { + this.type = type; + return this; + } + + public Builder setPro(Boolean pro) { + this.pro = pro; + return this; + } + + public Builder setBucketsUrl(String buckets_url) { + this.buckets_url = buckets_url; + return this; + } + + public Builder setFollowersUrl(String followers_url) { + this.followers_url = followers_url; + return this; + } + + public Builder setFollowingUrl(String following_url) { + this.following_url = following_url; + return this; + } + + public Builder setLikesUrl(String likes_url) { + this.likes_url = likes_url; + return this; + } + + public Builder setProjectsUrl(String projects_url) { + this.projects_url = projects_url; + return this; + } + + public Builder setShotsUrl(String shots_url) { + this.shots_url = shots_url; + return this; + } + + public Builder setTeamsUrl(String teams_url) { + this.teams_url = teams_url; + return this; + } + + public Builder setCreatedAt(Date created_at) { + this.created_at = created_at; + return this; + } + + public Builder setUpdatedAt(Date updated_at) { + this.updated_at = updated_at; + return this; + } + + public User build() { + return new User(id, + name, + username, + html_url, + avatar_url, + bio, + location, + links, + buckets_count, + followers_count, + followings_count, + likes_count, + projects_count, + shots_count, + teams_count, + type, + pro, + buckets_url, + followers_url, + following_url, + likes_url, + projects_url, + shots_url, + teams_url, + created_at, + updated_at); + } + } + + /* Parcelable stuff */ + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(id); + dest.writeString(name); + dest.writeString(username); + dest.writeString(html_url); + dest.writeString(avatar_url); + dest.writeString(bio); + dest.writeString(location); + ParcelUtils.writeStringMap(links, dest); + dest.writeInt(buckets_count); + dest.writeInt(followers_count); + dest.writeInt(followings_count); + dest.writeInt(likes_count); + dest.writeInt(projects_count); + dest.writeInt(shots_count); + dest.writeInt(teams_count); + dest.writeString(type); + if (pro == null) { + dest.writeByte((byte) (0x02)); + } else { + dest.writeByte((byte) (pro ? 0x01 : 0x00)); + } + dest.writeString(buckets_url); + dest.writeString(followers_url); + dest.writeString(following_url); + dest.writeString(likes_url); + dest.writeString(projects_url); + dest.writeString(shots_url); + dest.writeString(teams_url); + dest.writeLong(created_at != null ? created_at.getTime() : -1L); + dest.writeLong(updated_at != null ? updated_at.getTime() : -1L); + } + + @SuppressWarnings("unused") + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public User createFromParcel(Parcel in) { + return new User(in); + } + + @Override + public User[] newArray(int size) { + return new User[size]; + } + }; +} diff --git a/app/src/main/java/io/plaidapp/data/api/producthunt/ProductHuntService.java b/app/src/main/java/io/plaidapp/data/api/producthunt/ProductHuntService.java new file mode 100644 index 000000000..fd8ab7dbf --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/producthunt/ProductHuntService.java @@ -0,0 +1,34 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.producthunt; + +import io.plaidapp.data.api.producthunt.model.PostsResponse; +import retrofit.Callback; +import retrofit.http.GET; +import retrofit.http.Query; + +/** + * Models the Product Hunt API. See https://api.producthunt.com/v1/docs + */ +public interface ProductHuntService { + + String ENDPOINT = "https://api.producthunt.com/v1/"; + + @GET("/posts") + void getPosts(@Query("days_ago") Integer page, + Callback callback); +} diff --git a/app/src/main/java/io/plaidapp/data/api/producthunt/model/Post.java b/app/src/main/java/io/plaidapp/data/api/producthunt/model/Post.java new file mode 100644 index 000000000..fa100024b --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/producthunt/model/Post.java @@ -0,0 +1,202 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.producthunt.model; + +import android.os.Parcel; +import android.os.Parcelable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import io.plaidapp.data.PlaidItem; +import io.plaidapp.util.ParcelUtils; + +/** + * Models a post on Product Hunt. + */ +public class Post extends PlaidItem implements Parcelable { + + @SuppressWarnings("unused") + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public Post createFromParcel(Parcel in) { + return new Post(in); + } + + @Override + public Post[] newArray(int size) { + return new Post[size]; + } + }; + public final String name; + public final String tagline; + public final String discussion_url; + public final String redirect_url; + //public final Date created_at; + public final int comments_count; + public final int votes_count; + public final User user; + public final List makers; + public final CurrentUser current_user; + public final boolean maker_inside; + public final Map screenshot_url; + + public Post(long id, + String name, + String tagline, + String discussion_url, + String redirect_url, + //Date created_at, + int comments_count, + int votes_count, + User user, + List makers, + CurrentUser current_user, + boolean maker_inside, + Map screenshot_url) { + super(id, name, discussion_url); + this.name = name; + //this.title = name; + this.tagline = tagline; + this.discussion_url = discussion_url; + this.redirect_url = redirect_url; + //this.created_at = created_at; + this.comments_count = comments_count; + this.votes_count = votes_count; + this.user = user; + this.makers = makers; + this.current_user = current_user; + this.maker_inside = maker_inside; + this.screenshot_url = screenshot_url; + } + + protected Post(Parcel in) { + super(in.readLong(), in.readString(), in.readString()); + name = in.readString(); + tagline = in.readString(); + discussion_url = in.readString(); + redirect_url = in.readString(); + long tmpCreated_at = in.readLong(); + //created_at = tmpCreated_at != -1 ? new Date(tmpCreated_at) : null; + comments_count = in.readInt(); + votes_count = in.readInt(); + user = (User) in.readValue(User.class.getClassLoader()); + if (in.readByte() == 0x01) { + makers = new ArrayList(); + in.readList(makers, User.class.getClassLoader()); + } else { + makers = null; + } + current_user = (CurrentUser) in.readValue(CurrentUser.class.getClassLoader()); + maker_inside = in.readByte() != 0x00; + screenshot_url = ParcelUtils.readStringMap(in); + } + + public String getScreenshotUrl(int width) { + String url = null; + for (String widthStr : screenshot_url.keySet()) { + url = screenshot_url.get(widthStr); + try { + int screenshotWidth = Integer.parseInt(widthStr.substring(0, widthStr.length() - + 2)); + if (screenshotWidth > width) { + break; + } + } catch (NumberFormatException nfe) { + } + } + + return url; + } + + public void weigh(float maxProductHuntComments, float maxProductHuntVotes) { + weight = 1f - ((((float) comments_count) / maxProductHuntComments) + + ((float) votes_count / maxProductHuntVotes)) / 2f; + weight = Math.min(weight + weightBoost, 1f); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(id); + dest.writeString(title); + dest.writeString(url); + dest.writeString(name); + dest.writeString(tagline); + dest.writeString(discussion_url); + dest.writeString(redirect_url); + //dest.writeLong(created_at != null ? created_at.getTime() : -1L); + dest.writeInt(comments_count); + dest.writeInt(votes_count); + dest.writeValue(user); + if (makers == null) { + dest.writeByte((byte) (0x00)); + } else { + dest.writeByte((byte) (0x01)); + dest.writeList(makers); + } + dest.writeValue(current_user); + dest.writeByte((byte) (maker_inside ? 0x01 : 0x00)); + ParcelUtils.writeStringMap(screenshot_url, dest); + } + + public static class CurrentUser implements Parcelable { + + @SuppressWarnings("unused") + public static final Parcelable.Creator CREATOR = new Parcelable + .Creator() { + @Override + public CurrentUser createFromParcel(Parcel in) { + return new CurrentUser(in); + } + + @Override + public CurrentUser[] newArray(int size) { + return new CurrentUser[size]; + } + }; + public final boolean voted_for_post; + public final boolean commented_on_post; + + public CurrentUser(boolean voted_for_post, + boolean commented_on_post) { + this.voted_for_post = voted_for_post; + this.commented_on_post = commented_on_post; + } + + protected CurrentUser(Parcel in) { + voted_for_post = in.readByte() != 0x00; + commented_on_post = in.readByte() != 0x00; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeByte((byte) (voted_for_post ? 0x01 : 0x00)); + dest.writeByte((byte) (commented_on_post ? 0x01 : 0x00)); + } + } +} diff --git a/app/src/main/java/io/plaidapp/data/api/producthunt/model/PostsResponse.java b/app/src/main/java/io/plaidapp/data/api/producthunt/model/PostsResponse.java new file mode 100644 index 000000000..e1256e344 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/producthunt/model/PostsResponse.java @@ -0,0 +1,31 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.producthunt.model; + +import java.util.List; + +/** + * Models a response from the Product Hunt API. + */ +public class PostsResponse { + + public final List posts; + + public PostsResponse(List posts) { + this.posts = posts; + } +} diff --git a/app/src/main/java/io/plaidapp/data/api/producthunt/model/User.java b/app/src/main/java/io/plaidapp/data/api/producthunt/model/User.java new file mode 100644 index 000000000..1d7dac492 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/api/producthunt/model/User.java @@ -0,0 +1,98 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.api.producthunt.model; + +import android.os.Parcel; +import android.os.Parcelable; + +import java.util.Map; + +import io.plaidapp.util.ParcelUtils; + +/** + * Models a user on Product Hunt. + */ +public class User implements Parcelable { + + @SuppressWarnings("unused") + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public User createFromParcel(Parcel in) { + return new User(in); + } + + @Override + public User[] newArray(int size) { + return new User[size]; + } + }; + public final long id; + public final String name; + public final String headline; + //public final Date created_at; + public final String username; + public final String website_url; + public final String profile_url; + public final Map image_url; + + public User(long id, + String name, + String headline, + //Date created_at, + String username, + String website_url, + String profile_url, + Map image_url) { + this.id = id; + this.name = name; + this.headline = headline; + //this.created_at = created_at; + this.username = username; + this.website_url = website_url; + this.profile_url = profile_url; + this.image_url = image_url; + } + + protected User(Parcel in) { + id = in.readLong(); + name = in.readString(); + headline = in.readString(); + long tmpCreated_at = in.readLong(); + //created_at = tmpCreated_at != -1 ? new Date(tmpCreated_at) : null; + username = in.readString(); + website_url = in.readString(); + profile_url = in.readString(); + image_url = ParcelUtils.readStringMap(in); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(id); + dest.writeString(name); + dest.writeString(headline); + //dest.writeLong(created_at != null ? created_at.getTime() : -1L); + dest.writeString(username); + dest.writeString(website_url); + dest.writeString(profile_url); + ParcelUtils.writeStringMap(image_url, dest); + } +} \ No newline at end of file diff --git a/app/src/main/java/io/plaidapp/data/pocket/PocketUtils.java b/app/src/main/java/io/plaidapp/data/pocket/PocketUtils.java new file mode 100644 index 000000000..24f1396cd --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/pocket/PocketUtils.java @@ -0,0 +1,66 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.pocket; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; + +/** + * Adapted form https://github.com/Pocket/Pocket-AndroidWear-SDK/blob/master/library/src/com + * /pocket/util/PocketUtil.java + */ +public class PocketUtils { + + private final static String PACKAGE = "com.ideashower.readitlater.pro"; + private final static String MIME_TYPE = "text/plain"; + private static final String EXTRA_SOURCE_PACKAGE = "source"; + private static final String EXTRA_TWEET_STATUS_ID = "tweetStatusId"; + + public static void addToPocket(Context context, + String url) { + addToPocket(context, url, null); + } + + public static void addToPocket(Context context, + String url, + String tweetStatusId) { + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setPackage(PACKAGE); + intent.setType(MIME_TYPE); + + intent.putExtra(Intent.EXTRA_TEXT, url); + if (tweetStatusId != null && tweetStatusId.length() > 0) { + intent.putExtra(EXTRA_TWEET_STATUS_ID, tweetStatusId); + } + intent.putExtra(EXTRA_SOURCE_PACKAGE, context.getPackageName()); + context.startActivity(intent); + } + + public static boolean isPocketInstalled(Context context) { + PackageManager pm = context.getPackageManager(); + PackageInfo info; + try { + info = pm.getPackageInfo(PACKAGE, 0); + } catch (PackageManager.NameNotFoundException e) { + info = null; + } + + return info != null; + } +} diff --git a/app/src/main/java/io/plaidapp/data/prefs/DesignerNewsPrefs.java b/app/src/main/java/io/plaidapp/data/prefs/DesignerNewsPrefs.java new file mode 100644 index 000000000..c4a269323 --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/prefs/DesignerNewsPrefs.java @@ -0,0 +1,161 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.prefs; + +import android.content.Context; +import android.content.SharedPreferences; +import android.support.annotation.Nullable; +import android.text.TextUtils; + +import java.util.ArrayList; +import java.util.List; + +import io.plaidapp.data.api.designernews.model.User; + +/** + * Storing Designer News user state + */ +public class DesignerNewsPrefs { + + private static final String DESIGNER_NEWS_PREF = "DESIGNER_NEWS_PREF"; + private static final String KEY_ACCESS_TOKEN = "KEY_ACCESS_TOKEN"; + private static final String KEY_USER_ID = "KEY_USER_ID"; + private static final String KEY_USER_NAME = "KEY_USER_NAME"; + private static final String KEY_USER_AVATAR = "KEY_USER_AVATAR"; + + private static volatile DesignerNewsPrefs singleton; + private final SharedPreferences prefs; + + private String accessToken; + private boolean isLoggedIn = false; + private long userId; + private String username; + private String userAvatar; + private List loginStatusListeners; + + public static DesignerNewsPrefs get(Context context) { + if (singleton == null) { + synchronized (DesignerNewsPrefs.class) { + singleton = new DesignerNewsPrefs(context); + } + } + return singleton; + } + + private DesignerNewsPrefs(Context context) { + prefs = context.getApplicationContext().getSharedPreferences(DESIGNER_NEWS_PREF, Context + .MODE_PRIVATE); + accessToken = prefs.getString(KEY_ACCESS_TOKEN, null); + isLoggedIn = !TextUtils.isEmpty(accessToken); + if (isLoggedIn) { + userId = prefs.getLong(KEY_USER_ID, 0l); + username = prefs.getString(KEY_USER_NAME, null); + userAvatar = prefs.getString(KEY_USER_AVATAR, null); + } + } + + public boolean isLoggedIn() { + return isLoggedIn; + } + + public @Nullable String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + if (!TextUtils.isEmpty(accessToken)) { + this.accessToken = accessToken; + isLoggedIn = true; + prefs.edit().putString(KEY_ACCESS_TOKEN, accessToken).apply(); + dispatchLoginEvent(); + } + } + + public void setLoggedInUser(User user) { + if (user != null) { + userId = user.id; + username = user.display_name; + userAvatar = user.portrait_url; + SharedPreferences.Editor editor = prefs.edit(); + editor.putLong(KEY_USER_ID, userId); + editor.putString(KEY_USER_NAME, username); + editor.putString(KEY_USER_AVATAR, userAvatar); + editor.apply(); + } + } + + public long getUserId() { + return userId; + } + + public String getUserName() { + return username; + } + + public String getUserAvatar() { + return userAvatar; + } + + public void logout() { + isLoggedIn = false; + accessToken = null; + username = null; + userAvatar = null; + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(KEY_ACCESS_TOKEN, null); + editor.putLong(KEY_USER_ID, 0l); + editor.putString(KEY_USER_NAME, null); + editor.putString(KEY_USER_AVATAR, null); + editor.apply(); + dispatchLogoutEvent(); + } + + public void addLoginStatusListener(DesignerNewsLoginStatusListener listener) { + if (loginStatusListeners == null) { + loginStatusListeners = new ArrayList<>(); + } + loginStatusListeners.add(listener); + } + + public void removeLoginStatusListener(DesignerNewsLoginStatusListener listener) { + if (loginStatusListeners != null) { + loginStatusListeners.remove(listener); + } + } + + private void dispatchLoginEvent() { + if (loginStatusListeners != null && loginStatusListeners.size() > 0) { + for (DesignerNewsLoginStatusListener listener : loginStatusListeners) { + listener.onDesignerNewsLogin(); + } + } + } + + private void dispatchLogoutEvent() { + if (loginStatusListeners != null && loginStatusListeners.size() > 0) { + for (DesignerNewsLoginStatusListener listener : loginStatusListeners) { + listener.onDesignerNewsLogout(); + } + } + } + + public interface DesignerNewsLoginStatusListener { + void onDesignerNewsLogin(); + void onDesignerNewsLogout(); + } + +} diff --git a/app/src/main/java/io/plaidapp/data/prefs/DribbblePrefs.java b/app/src/main/java/io/plaidapp/data/prefs/DribbblePrefs.java new file mode 100644 index 000000000..f8f38d56b --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/prefs/DribbblePrefs.java @@ -0,0 +1,193 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.prefs; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.text.TextUtils; + +import java.util.ArrayList; +import java.util.List; + +import io.plaidapp.BuildConfig; +import io.plaidapp.data.api.dribbble.model.User; + +/** + * Storing dribbble user state. + */ +public class DribbblePrefs { + + public static final String LOGIN_CALLBACK = "dribbble-auth-callback"; + public static final String LOGIN_URL = "https://dribbble.com/oauth/authorize?client_id=" + + BuildConfig.DRIBBBLE_CLIENT_ID + + "&redirect_uri=plaid%3A%2F%2F" + LOGIN_CALLBACK + + "&scope=public+write+comment+upload"; + private static final String DRIBBBLE_PREF = "DRIBBBLE_PREF"; + private static final String KEY_ACCESS_TOKEN = "KEY_ACCESS_TOKEN"; + private static final String KEY_USER_ID = "KEY_USER_ID"; + private static final String KEY_USER_NAME = "KEY_USER_NAME"; + private static final String KEY_USER_USERNAME = "KEY_USER_USERNAME"; + private static final String KEY_USER_AVATAR = "KEY_USER_AVATAR"; + + private static volatile DribbblePrefs singleton; + private final SharedPreferences prefs; + + private String accessToken; + private boolean isLoggedIn = false; + private long userId; + private String userName; + private String userUsername; + private String userAvatar; + private List loginStatusListeners; + + public static DribbblePrefs get(Context context) { + if (singleton == null) { + synchronized (DribbblePrefs.class) { + singleton = new DribbblePrefs(context); + } + } + return singleton; + } + + private DribbblePrefs(Context context) { + prefs = context.getApplicationContext().getSharedPreferences(DRIBBBLE_PREF, Context + .MODE_PRIVATE); + accessToken = prefs.getString(KEY_ACCESS_TOKEN, null); + isLoggedIn = !TextUtils.isEmpty(accessToken); + if (isLoggedIn) { + userId = prefs.getLong(KEY_USER_ID, 0l); + userName = prefs.getString(KEY_USER_NAME, null); + userUsername = prefs.getString(KEY_USER_USERNAME, null); + userAvatar = prefs.getString(KEY_USER_AVATAR, null); + } + } + + public boolean isLoggedIn() { + return isLoggedIn; + } + + public String getAccessToken() { + return !TextUtils.isEmpty(accessToken) ? accessToken + : BuildConfig.DRIBBBLE_CLIENT_ACCESS_TOKEN; + } + + public void setAccessToken(String accessToken) { + if (!TextUtils.isEmpty(accessToken)) { + this.accessToken = accessToken; + isLoggedIn = true; + prefs.edit().putString(KEY_ACCESS_TOKEN, accessToken).apply(); + dispatchLoginEvent(); + } + } + + public void setLoggedInUser(User user) { + if (user != null) { + userName = user.name; + userUsername = user.username; + userId = user.id; + userAvatar = user.avatar_url; + SharedPreferences.Editor editor = prefs.edit(); + editor.putLong(KEY_USER_ID, userId); + editor.putString(KEY_USER_NAME, userName); + editor.putString(KEY_USER_USERNAME, userUsername); + editor.putString(KEY_USER_AVATAR, userAvatar); + editor.apply(); + } + } + + public long getUserId() { + return userId; + } + + public String getUserName() { + return userName; + } + + public String getUserUsername() { + return userUsername; + } + + public String getUserAvatar() { + return userAvatar; + } + + public User getUser() { + return new User.Builder() + .setId(userId) + .setName(userName) + .setUsername(userUsername) + .setAvatarUrl(userAvatar) + .build(); + } + + public void logout() { + isLoggedIn = false; + accessToken = null; + userId = 0l; + userName = null; + userUsername = null; + userAvatar = null; + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(KEY_ACCESS_TOKEN, null); + editor.putLong(KEY_USER_ID, 0l); + editor.putString(KEY_USER_NAME, null); + editor.putString(KEY_USER_AVATAR, null); + editor.apply(); + dispatchLogoutEvent(); + } + + public void login(Context context) { + context.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(LOGIN_URL))); + } + + public void addLoginStatusListener(DribbbleLoginStatusListener listener) { + if (loginStatusListeners == null) { + loginStatusListeners = new ArrayList<>(); + } + loginStatusListeners.add(listener); + } + + public void removeLoginStatusListener(DribbbleLoginStatusListener listener) { + if (loginStatusListeners != null) { + loginStatusListeners.remove(listener); + } + } + + private void dispatchLoginEvent() { + if (loginStatusListeners != null && loginStatusListeners.size() > 0) { + for (DribbbleLoginStatusListener listener : loginStatusListeners) { + listener.onDribbbleLogin(); + } + } + } + + private void dispatchLogoutEvent() { + if (loginStatusListeners != null && loginStatusListeners.size() > 0) { + for (DribbbleLoginStatusListener listener : loginStatusListeners) { + listener.onDribbbleLogout(); + } + } + } + + public interface DribbbleLoginStatusListener { + void onDribbbleLogin(); + void onDribbbleLogout(); + } + +} diff --git a/app/src/main/java/io/plaidapp/data/prefs/SourceManager.java b/app/src/main/java/io/plaidapp/data/prefs/SourceManager.java new file mode 100644 index 000000000..536545e6c --- /dev/null +++ b/app/src/main/java/io/plaidapp/data/prefs/SourceManager.java @@ -0,0 +1,157 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.data.prefs; + +import android.content.Context; +import android.content.SharedPreferences; +import android.support.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import io.plaidapp.R; +import io.plaidapp.data.Source; + +/** + * Manage saving and retrieving data sources from disk. + */ +public class SourceManager { + + public static final String SOURCE_DESIGNER_NEWS_POPULAR = "SOURCE_DESIGNER_NEWS_POPULAR"; + public static final String SOURCE_DESIGNER_NEWS_RECENT = "SOURCE_DESIGNER_NEWS_RECENT"; + public static final String SOURCE_DRIBBBLE_POPULAR = "SOURCE_DRIBBBLE_POPULAR"; + public static final String SOURCE_DRIBBBLE_FOLLOWING = "SOURCE_DRIBBBLE_FOLLOWING"; + public static final String SOURCE_DRIBBBLE_USER_LIKES = "SOURCE_DRIBBBLE_USER_LIKES"; + public static final String SOURCE_DRIBBBLE_USER_SHOTS = "SOURCE_DRIBBBLE_USER_SHOTS"; + public static final String SOURCE_DRIBBBLE_RECENT = "SOURCE_DRIBBBLE_RECENT"; + public static final String SOURCE_DRIBBBLE_DEBUTS = "SOURCE_DRIBBBLE_DEBUTS"; + public static final String SOURCE_DRIBBBLE_ANIMATED = "SOURCE_DRIBBBLE_ANIMATED"; + public static final String SOURCE_PRODUCT_HUNT = "SOURCE_PRODUCT_HUNT"; + private static final String SOURCES_PREF = "SOURCES_PREF"; + private static final String KEY_SOURCES = "KEY_SOURCES"; + + public static List getSources(Context context) { + SharedPreferences prefs = context.getSharedPreferences(SOURCES_PREF, Context.MODE_PRIVATE); + Set sourceKeys = prefs.getStringSet(KEY_SOURCES, null); + if (sourceKeys == null) { + setupDefaultSources(context, prefs.edit()); + return getDefaultSources(context); + } + + List sources = new ArrayList<>(sourceKeys.size()); + for (String sourceKey : sourceKeys) { + if (sourceKey.startsWith(Source.DribbbleSearchSource.DRIBBBLE_QUERY_PREFIX)) { + sources.add(new Source.DribbbleSearchSource( + sourceKey.replace(Source.DribbbleSearchSource.DRIBBBLE_QUERY_PREFIX, ""), + prefs.getBoolean(sourceKey, false))); + } else if (sourceKey.startsWith(Source.DesignerNewsSearchSource + .DESIGNER_NEWS_QUERY_PREFIX)) { + sources.add(new Source.DesignerNewsSearchSource( + sourceKey.replace(Source.DesignerNewsSearchSource + .DESIGNER_NEWS_QUERY_PREFIX, ""), + prefs.getBoolean(sourceKey, false))); + } else { + // TODO improve this O(n2) search + sources.add(getSource(context, sourceKey, prefs.getBoolean(sourceKey, false))); + } + } + Collections.sort(sources, new Source.SourceComparator()); + return sources; + } + + public static void addSource(Source toAdd, Context context) { + SharedPreferences prefs = context.getSharedPreferences(SOURCES_PREF, Context.MODE_PRIVATE); + SharedPreferences.Editor editor = prefs.edit(); + Set sourceKeys = prefs.getStringSet(KEY_SOURCES, null); + sourceKeys.add(toAdd.key); + editor.putStringSet(KEY_SOURCES, sourceKeys); + editor.putBoolean(toAdd.key, toAdd.active); + editor.apply(); + } + + public static void updateSource(Source source, Context context) { + SharedPreferences.Editor editor = + context.getSharedPreferences(SOURCES_PREF, Context.MODE_PRIVATE).edit(); + editor.putBoolean(source.key, source.active); + editor.apply(); + } + + public static void removeSource(Source source, Context context) { + SharedPreferences prefs = context.getSharedPreferences(SOURCES_PREF, Context.MODE_PRIVATE); + SharedPreferences.Editor editor = prefs.edit(); + Set sourceKeys = prefs.getStringSet(KEY_SOURCES, null); + sourceKeys.remove(source.key); + editor.putStringSet(KEY_SOURCES, sourceKeys); + editor.remove(source.key); + editor.apply(); + } + + private static void setupDefaultSources(Context context, SharedPreferences.Editor editor) { + ArrayList defaultSources = getDefaultSources(context); + Set keys = new HashSet<>(defaultSources.size()); + for (Source source : defaultSources) { + keys.add(source.key); + editor.putBoolean(source.key, source.active); + } + editor.putStringSet(KEY_SOURCES, keys); + editor.commit(); + } + + private static @Nullable Source getSource(Context context, String key, boolean active) { + for (Source source : getDefaultSources(context)) { + if (source.key.equals(key)) { + source.active = active; + return source; + } + } + return null; + } + + private static ArrayList getDefaultSources(Context context) { + ArrayList defaultSources = new ArrayList<>(11); + defaultSources.add(new Source.DesignerNewsSource(SOURCE_DESIGNER_NEWS_POPULAR, 100, + context.getString(R.string.source_designer_news_popular), true)); + defaultSources.add(new Source.DesignerNewsSource(SOURCE_DESIGNER_NEWS_RECENT, 101, + context.getString(R.string.source_designer_news_recent), false)); + // 200 sort order range left for DN searches + defaultSources.add(new Source.DribbbleSource(SOURCE_DRIBBBLE_POPULAR, 300, + context.getString(R.string.source_dribbble_popular), true)); + defaultSources.add(new Source.DribbbleSource(SOURCE_DRIBBBLE_FOLLOWING, 301, + context.getString(R.string.source_dribbble_following), false)); + defaultSources.add(new Source.DribbbleSource(SOURCE_DRIBBBLE_USER_SHOTS, 302, + context.getString(R.string.source_dribbble_user_shots), false)); + defaultSources.add(new Source.DribbbleSource(SOURCE_DRIBBBLE_USER_LIKES, 303, + context.getString(R.string.source_dribbble_user_likes), false)); + defaultSources.add(new Source.DribbbleSource(SOURCE_DRIBBBLE_RECENT, 304, + context.getString(R.string.source_dribbble_recent), false)); + defaultSources.add(new Source.DribbbleSource(SOURCE_DRIBBBLE_DEBUTS, 305, + context.getString(R.string.source_dribbble_debuts), false)); + defaultSources.add(new Source.DribbbleSource(SOURCE_DRIBBBLE_ANIMATED, 306, + context.getString(R.string.source_dribbble_animated), false)); + defaultSources.add(new Source.DribbbleSearchSource(context.getString(R.string + .source_dribbble_search_material_design), true)); + // 400 sort order range left for dribbble searches + defaultSources.add(new Source(SOURCE_PRODUCT_HUNT, 500, + context.getString(R.string.source_product_hunt), + R.drawable.ic_product_hunt, false)); + return defaultSources; + } + +} diff --git a/app/src/main/java/io/plaidapp/ui/AboutActivity.java b/app/src/main/java/io/plaidapp/ui/AboutActivity.java new file mode 100644 index 000000000..ef4ecdfe9 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/AboutActivity.java @@ -0,0 +1,32 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui; + +import android.app.Activity; +import android.os.Bundle; + +import io.plaidapp.R; + +public class AboutActivity extends Activity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_about); + } + +} diff --git a/app/src/main/java/io/plaidapp/ui/DesignerNewsLogin.java b/app/src/main/java/io/plaidapp/ui/DesignerNewsLogin.java new file mode 100644 index 000000000..083022d2a --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/DesignerNewsLogin.java @@ -0,0 +1,335 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui; + +import android.Manifest; +import android.accounts.Account; +import android.accounts.AccountManager; +import android.app.Activity; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.design.widget.TextInputLayout; +import android.support.v4.content.ContextCompat; +import android.text.Editable; +import android.text.TextWatcher; +import android.transition.TransitionManager; +import android.util.Log; +import android.util.Patterns; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.widget.ArrayAdapter; +import android.widget.AutoCompleteTextView; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; + +import com.bumptech.glide.Glide; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import butterknife.Bind; +import butterknife.ButterKnife; +import io.plaidapp.BuildConfig; +import io.plaidapp.R; +import io.plaidapp.data.api.ClientAuthInterceptor; +import io.plaidapp.data.api.designernews.DesignerNewsService; +import io.plaidapp.data.api.designernews.model.AccessToken; +import io.plaidapp.data.api.designernews.model.User; +import io.plaidapp.data.api.designernews.model.UserResponse; +import io.plaidapp.data.prefs.DesignerNewsPrefs; +import io.plaidapp.ui.transitions.FabDialogMorphSetup; +import io.plaidapp.util.ScrimUtil; +import io.plaidapp.util.glide.CircleTransform; +import retrofit.Callback; +import retrofit.RestAdapter; +import retrofit.RetrofitError; +import retrofit.client.Response; + +public class DesignerNewsLogin extends Activity { + + private static final int PERMISSIONS_REQUEST_GET_ACCOUNTS = 0; + + boolean isDismissing = false; + @Bind(R.id.container) ViewGroup container; + @Bind(R.id.dialog_title) TextView title; + @Bind(R.id.username_float_label) TextInputLayout usernameLabel; + @Bind(R.id.username) AutoCompleteTextView username; + @Bind(R.id.permission_primer) CheckBox permissionPrimer; + @Bind(R.id.password_float_label) TextInputLayout passwordLabel; + @Bind(R.id.password) EditText password; + @Bind(R.id.actions_container) FrameLayout actionsContainer; + @Bind(R.id.signup) Button signup; + @Bind(R.id.login) Button login; + @Bind(R.id.loading) ProgressBar loading; + private DesignerNewsPrefs designerNewsPrefs; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_designer_news_login); + ButterKnife.bind(this); + FabDialogMorphSetup.setupSharedEelementTransitions(this, container, + getResources().getDimensionPixelSize(R.dimen.dialog_corners)); + + loading.setVisibility(View.GONE); + setupAccountAutocomplete(); + username.setOnFocusChangeListener(new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View v, boolean hasFocus) { + if (hasFocus && username.isAttachedToWindow()) { + username.showDropDown(); + } + } + }); + username.addTextChangedListener(loginFieldWatcher); + // the primer checkbox messes with focus order so force it + username.setOnEditorActionListener(new TextView.OnEditorActionListener() { + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_NEXT) { + password.requestFocus(); + return true; + } + return false; + } + }); + password.addTextChangedListener(loginFieldWatcher); + password.setOnEditorActionListener(new TextView.OnEditorActionListener() { + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_DONE && isLoginValid()) { + login.performClick(); + return true; + } + return false; + } + }); + designerNewsPrefs = DesignerNewsPrefs.get(this); + } + + @Override + public void onBackPressed() { + dismiss(null); + } + + @Override + public void onRequestPermissionsResult(int requestCode, + String[] permissions, + int[] grantResults) { + if (requestCode == PERMISSIONS_REQUEST_GET_ACCOUNTS) { + TransitionManager.beginDelayedTransition(container); + if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { + setupAccountAutocomplete(); + username.requestFocus(); + username.showDropDown(); + } else { + // if permission was denied check if we should ask again in the future (i.e. they + // did not check 'never ask again') + if (shouldShowRequestPermissionRationale(Manifest.permission.GET_ACCOUNTS)) { + setupPermissionPrimer(); + } else { + // denied & shouldn't ask again. deal with it (•_•) ( •_•)>⌐■-■ (⌐■_■) + TransitionManager.beginDelayedTransition(container); + permissionPrimer.setVisibility(View.GONE); + } + } + } + } + + public void doLogin(View view) { + showLoading(); + getAccessToken(); + } + + public void signup(View view) { + startActivity(new Intent(Intent.ACTION_VIEW, + Uri.parse("https://www.designernews.co/users/new"))); + } + + public void dismiss(View view) { + isDismissing = true; + setResult(Activity.RESULT_CANCELED); + finishAfterTransition(); + } + + private TextWatcher loginFieldWatcher = new TextWatcher() { + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { } + + @Override public void onTextChanged(CharSequence s, int start, int before, int count) { } + + @Override + public void afterTextChanged(Editable s) { + login.setEnabled(isLoginValid()); + } + }; + + private boolean isLoginValid() { + return username.length() > 0 && password.length() > 0; + } + + private void showLoading() { + TransitionManager.beginDelayedTransition(container); + title.setVisibility(View.GONE); + usernameLabel.setVisibility(View.GONE); + passwordLabel.setVisibility(View.GONE); + actionsContainer.setVisibility(View.GONE); + loading.setVisibility(View.VISIBLE); + } + + private void showLogin() { + TransitionManager.beginDelayedTransition(container); + title.setVisibility(View.VISIBLE); + usernameLabel.setVisibility(View.VISIBLE); + passwordLabel.setVisibility(View.VISIBLE); + actionsContainer.setVisibility(View.VISIBLE); + loading.setVisibility(View.GONE); + } + + private void getAccessToken() { + DesignerNewsService designerNewsService = new RestAdapter.Builder() + .setEndpoint(DesignerNewsService.ENDPOINT) + .setRequestInterceptor(new ClientAuthInterceptor(designerNewsPrefs.getAccessToken(), + BuildConfig.DESIGNER_NEWS_CLIENT_ID)) + .build() + .create(DesignerNewsService.class); + designerNewsService.login( + buildLoginParams(username.getText().toString(), password.getText().toString()), + new Callback() { + @Override + public void success(AccessToken accessToken, Response response) { + designerNewsPrefs.setAccessToken(accessToken.access_token); + showLoggedInUser(); + setResult(Activity.RESULT_OK); + finish(); + } + + @Override + public void failure(RetrofitError error) { + Log.e(getClass().getCanonicalName(), error.getMessage(), error); + // TODO snackbar? + Toast.makeText(getApplicationContext(), "Log in failed", + Toast.LENGTH_LONG).show(); + showLogin(); + password.requestFocus(); + } + }); + } + + private Map buildLoginParams(@NonNull String username, @NonNull String password) { + Map loginParams = new HashMap(5); + loginParams.put("client_id", BuildConfig.DESIGNER_NEWS_CLIENT_ID); + loginParams.put("client_secret", BuildConfig.DESIGNER_NEWS_CLIENT_SECRET); + loginParams.put("grant_type", "password"); + loginParams.put("username", username); + loginParams.put("password", password); + return loginParams; + } + + private void showLoggedInUser() { + DesignerNewsService designerNewsService = new RestAdapter.Builder() + .setEndpoint(DesignerNewsService.ENDPOINT) + .setRequestInterceptor(new ClientAuthInterceptor(designerNewsPrefs.getAccessToken(), + BuildConfig.DESIGNER_NEWS_CLIENT_ID)) + .build() + .create(DesignerNewsService.class); + designerNewsService.getAuthedUser(new Callback() { + @Override + public void success(UserResponse userResponse, Response response) { + final User user = userResponse.user; + designerNewsPrefs.setLoggedInUser(user); + Toast confirmLogin = new Toast(getApplicationContext()); + View v = LayoutInflater.from(DesignerNewsLogin.this).inflate(R.layout + .toast_logged_in_confirmation, null, false); + ((TextView) v.findViewById(R.id.name)).setText(user.display_name); + // need to use app context here as the activity will be destroyed shortly + Glide.with(getApplicationContext()) + .load(user.portrait_url) + .placeholder(R.drawable.avatar_placeholder) + .transform(new CircleTransform(getApplicationContext())) + .into((ImageView) v.findViewById(R.id.avatar)); + v.findViewById(R.id.scrim).setBackground(ScrimUtil + .makeCubicGradientScrimDrawable( + ContextCompat.getColor(DesignerNewsLogin.this, R.color.scrim), + 5, Gravity.BOTTOM)); + confirmLogin.setView(v); + confirmLogin.setGravity(Gravity.BOTTOM | Gravity.FILL_HORIZONTAL, 0, 0); + confirmLogin.setDuration(Toast.LENGTH_LONG); + confirmLogin.show(); + } + + @Override + public void failure(RetrofitError error) { + Log.e(getClass().getCanonicalName(), error.getMessage(), error); + } + }); + } + + private void setupAccountAutocomplete() { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.GET_ACCOUNTS) == + PackageManager.PERMISSION_GRANTED) { + permissionPrimer.setVisibility(View.GONE); + final Account[] accounts = AccountManager.get(this).getAccounts(); + final Set emailSet = new HashSet<>(); + for (Account account : accounts) { + if (Patterns.EMAIL_ADDRESS.matcher(account.name).matches()) { + emailSet.add(account.name); + } + } + username.setAdapter(new ArrayAdapter<>(this, + R.layout.account_dropdown_item, new ArrayList<>(emailSet))); + } else { + if (shouldShowRequestPermissionRationale(Manifest.permission.GET_ACCOUNTS)) { + setupPermissionPrimer(); + } else { + permissionPrimer.setVisibility(View.GONE); + requestPermissions(new String[]{ Manifest.permission.GET_ACCOUNTS }, + PERMISSIONS_REQUEST_GET_ACCOUNTS); + } + } + } + + private void setupPermissionPrimer() { + permissionPrimer.setChecked(false); + permissionPrimer.setVisibility(View.VISIBLE); + permissionPrimer.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + if (isChecked) { + requestPermissions(new String[]{ Manifest.permission.GET_ACCOUNTS }, + PERMISSIONS_REQUEST_GET_ACCOUNTS); + } + } + }); + } +} diff --git a/app/src/main/java/io/plaidapp/ui/DesignerNewsStory.java b/app/src/main/java/io/plaidapp/ui/DesignerNewsStory.java new file mode 100644 index 000000000..bd85ccdf4 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/DesignerNewsStory.java @@ -0,0 +1,520 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui; + +import android.animation.Animator; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.PendingIntent; +import android.app.SharedElementCallback; +import android.app.assist.AssistContent; +import android.content.Context; +import android.content.Intent; +import android.graphics.Path; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.customtabs.CustomTabsIntent; +import android.support.customtabs.CustomTabsSession; +import android.support.v4.content.ContextCompat; +import android.support.v7.widget.RecyclerView; +import android.text.TextUtils; +import android.text.format.DateUtils; +import android.transition.ArcMotion; +import android.transition.Transition; +import android.util.TypedValue; +import android.view.View; +import android.view.ViewAnimationUtils; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toolbar; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import butterknife.Bind; +import butterknife.BindDimen; +import butterknife.BindInt; +import butterknife.ButterKnife; +import in.uncod.android.bypass.Bypass; +import in.uncod.android.bypass.style.ImageLoadingSpan; +import io.plaidapp.R; +import io.plaidapp.data.api.designernews.UpvoteStoryService; +import io.plaidapp.data.api.designernews.model.Comment; +import io.plaidapp.data.api.designernews.model.Story; +import io.plaidapp.ui.drawable.ThreadedCommentDrawable; +import io.plaidapp.ui.widget.AuthorTextView; +import io.plaidapp.ui.widget.CollapsingTitleLayout; +import io.plaidapp.ui.widget.ElasticDragDismissFrameLayout; +import io.plaidapp.ui.widget.FontTextView; +import io.plaidapp.ui.widget.PinnedOffsetView; +import io.plaidapp.util.AnimUtils; +import io.plaidapp.util.ColorUtils; +import io.plaidapp.util.HtmlUtils; +import io.plaidapp.util.ImageUtils; +import io.plaidapp.util.ViewUtils; +import io.plaidapp.util.customtabs.CustomTabActivityHelper; +import io.plaidapp.util.glide.ImageSpanTarget; + +public class DesignerNewsStory extends Activity { + + protected static final String EXTRA_STORY = "story"; + + @Bind(R.id.comments_list) RecyclerView commentsList; + @Bind(R.id.fab) ImageButton fab; + @Bind(R.id.fab_expand) View fabExpand; + @Bind(R.id.comments_container) ElasticDragDismissFrameLayout draggableFrame; + private ElasticDragDismissFrameLayout.SystemChromeFader chromeFader; + @BindInt(R.integer.fab_expand_duration) int fabExpandDuration; + @BindDimen(R.dimen.comment_thread_width) int threadWidth; + @BindDimen(R.dimen.comment_thread_gap) int threadGap; + + private Story story; + private CollapsingTitleLayout collapsingToolbar; + private PinnedOffsetView toolbarBackground; + private Bypass markdown; + private CustomTabActivityHelper customTab; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_designer_news_story); + ButterKnife.bind(this); + getWindow().getSharedElementReturnTransition().addListener(returnHomeListener); + + story = getIntent().getParcelableExtra(EXTRA_STORY); + fab.setOnClickListener(fabClick); + + chromeFader = new ElasticDragDismissFrameLayout.SystemChromeFader(getWindow()) { + @Override + public void onDragDismissed() { + finishAfterTransition(); + } + }; + + markdown = new Bypass(this, new Bypass.Options() + .setBlockQuoteLineColor( + ContextCompat.getColor(this, R.color.designer_news_quote_line)) + .setBlockQuoteLineWidth(2) // dps + .setBlockQuoteLineIndent(8) // dps + .setPreImageLinebreakHeight(4) //dps + .setBlockQuoteIndentSize(TypedValue.COMPLEX_UNIT_DIP, 2f) + .setBlockQuoteTextColor(ContextCompat.getColor(this, R.color.designer_news_quote))); + + View storyDescription = getLayoutInflater().inflate(R.layout + .designer_news_story_description, commentsList, false); + bindDescription(storyDescription); + + // setup toolbar + Toolbar toolbar = (Toolbar) findViewById(R.id.story_toolbar); + if (toolbar != null) { // portrait: collapsing toolbar + collapsingToolbar = (CollapsingTitleLayout) findViewById(R.id.backdrop_toolbar); + collapsingToolbar.setTitle(story.title); + toolbarBackground = (PinnedOffsetView) findViewById(R.id.story_title_background); + commentsList.addOnScrollListener(headerScrollListener); + collapsingToolbar.addOnLayoutChangeListener(titlebarLayout); + } else { // landscape: scroll toolbar with content + toolbar = (Toolbar) storyDescription.findViewById(R.id.story_toolbar); + FontTextView title = (FontTextView) toolbar.findViewById(R.id.story_title); + title.setText(story.title); + } + + toolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + finishAfterTransition(); + } + }); + + if (story.comment_count > 0) { + // flatten the comments from a nested structure {@see Comment#comments} to an + // array for our adapter (saving the depth). + List wrapped = new ArrayList<>(story.comment_count); + addComments(story.comments, 0, wrapped); + commentsList.setAdapter(new DesignerNewsCommentsAdapter(storyDescription, wrapped)); + + } else { + commentsList.setAdapter( + new DesignerNewsCommentsAdapter(storyDescription, Collections.EMPTY_LIST)); + } + customTab = new CustomTabActivityHelper(); + customTab.setConnectionCallback(customTabConnect); + setEnterSharedElementCallback(sharedEnterCallback); + } + + @Override + protected void onStart() { + super.onStart(); + customTab.bindCustomTabsService(this); + } + + @Override + protected void onResume() { + super.onResume(); + // clean up after any fab expansion + fab.setAlpha(1f); + fabExpand.setVisibility(View.INVISIBLE); + draggableFrame.addListener(chromeFader); + } + + @Override + protected void onPause() { + draggableFrame.removeListener(chromeFader); + super.onPause(); + } + + @Override + protected void onStop() { + customTab.unbindCustomTabsService(this); + super.onStop(); + } + + @Override + protected void onDestroy() { + customTab.setConnectionCallback(null); + super.onDestroy(); + } + + @Override @TargetApi(Build.VERSION_CODES.M) + public void onProvideAssistContent(AssistContent outContent) { + outContent.setWebUri(Uri.parse(story.url)); + } + + public static CustomTabsIntent.Builder getCustomTabIntent(@NonNull Context context, + @NonNull Story story, + @Nullable CustomTabsSession session) { + Intent upvoteStory = new Intent(context, UpvoteStoryService.class); + upvoteStory.setAction(UpvoteStoryService.ACTION_UPVOTE); + upvoteStory.putExtra(UpvoteStoryService.EXTRA_STORY_ID, story.id); + PendingIntent pendingIntent = PendingIntent.getService(context, 0, upvoteStory, 0); + return new CustomTabsIntent.Builder(session) + .setToolbarColor(ContextCompat.getColor(context, R.color.designer_news)) + .setActionButton(ImageUtils.vectorToBitmap(context, R.drawable.ic_thumb_up), + context.getString(R.string.upvote_story), + pendingIntent, + false) + .setShowTitle(true) + .enableUrlBarHiding(); + } + + private final CustomTabActivityHelper.ConnectionCallback customTabConnect + = new CustomTabActivityHelper.ConnectionCallback() { + + @Override + public void onCustomTabsConnected() { + customTab.mayLaunchUrl(Uri.parse(story.url), null, null); + } + + @Override public void onCustomTabsDisconnected() { } + }; + + private int gridScrollY = 0; + private RecyclerView.OnScrollListener headerScrollListener + = new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(RecyclerView recyclerView, int dx, int dy) { + gridScrollY += dy; + collapsingToolbar.setScrollPixelOffset(gridScrollY); + toolbarBackground.setOffset(-gridScrollY); + } + }; + + // title can expand up to a max number of lines. If it does then adjust the list padding + // & reset scroll trackers + private View.OnLayoutChangeListener titlebarLayout = new View.OnLayoutChangeListener() { + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, int + oldLeft, int oldTop, int oldRight, int oldBottom) { + commentsList.setPaddingRelative(commentsList.getPaddingStart(), + collapsingToolbar.getHeight(), + commentsList.getPaddingEnd(), + commentsList.getPaddingBottom()); + commentsList.scrollToPosition(0); + gridScrollY = 0; + collapsingToolbar.setScrollPixelOffset(0); + toolbarBackground.setOffset(0); + } + }; + + private View.OnClickListener fabClick = new View.OnClickListener() { + @Override + public void onClick(View v) { + doFabExpand(); + CustomTabActivityHelper.openCustomTab( + DesignerNewsStory.this, + getCustomTabIntent(DesignerNewsStory.this, story, + customTab.getSession()) + .setStartAnimations(getApplicationContext(), + R.anim.chrome_custom_tab_enter, + R.anim.fade_out_rapidly) + .build(), + Uri.parse(story.url)); + } + }; + + private SharedElementCallback sharedEnterCallback = new SharedElementCallback() { + @Override + public void onSharedElementEnd(List sharedElementNames, + List sharedElements, + List sharedElementSnapshots) { + // force a remeasure to account for shared element shenanigans + if (collapsingToolbar != null) { + collapsingToolbar.measure( + View.MeasureSpec.makeMeasureSpec(draggableFrame.getWidth(), + View.MeasureSpec.AT_MOST), + View.MeasureSpec.makeMeasureSpec(draggableFrame.getWidth(), + View.MeasureSpec.AT_MOST)); + collapsingToolbar.requestLayout(); + } + if (toolbarBackground != null) { + toolbarBackground.measure( + View.MeasureSpec.makeMeasureSpec(draggableFrame.getWidth(), + View.MeasureSpec.AT_MOST), + View.MeasureSpec.makeMeasureSpec(draggableFrame.getWidth(), + View.MeasureSpec.AT_MOST)); + toolbarBackground.requestLayout(); + } + } + }; + + private Transition.TransitionListener returnHomeListener = new AnimUtils + .TransitionListenerAdapter() { + @Override + public void onTransitionStart(Transition transition) { + super.onTransitionStart(transition); + // hide the fab as for some reason it jumps position?? TODO work out why + fab.setVisibility(View.INVISIBLE); + } + }; + + private void doFabExpand() { + // translate the chrome placeholder ui so that it is centered on the FAB + int fabCenterX = (fab.getLeft() + fab.getRight()) / 2; + int fabCenterY = ((fab.getTop() + fab.getBottom()) / 2) - fabExpand.getTop(); + int translateX = fabCenterX - (fabExpand.getWidth() / 2); + int translateY = fabCenterY - (fabExpand.getHeight() / 2); + fabExpand.setTranslationX(translateX); + fabExpand.setTranslationY(translateY); + + // then reveal the placeholder ui, starting from the center & same dimens as fab + fabExpand.setVisibility(View.VISIBLE); + Animator reveal = ViewAnimationUtils.createCircularReveal( + fabExpand, + fabExpand.getWidth() / 2, + fabExpand.getHeight() / 2, + fab.getWidth() / 2, + (int) Math.hypot(fabExpand.getWidth() / 2, fabExpand.getHeight() / 2)) + .setDuration(fabExpandDuration); + + // translate the placeholder ui back into position along an arc + ArcMotion arcMotion = new ArcMotion(); + arcMotion.setMinimumVerticalAngle(70f); + Path motionPath = arcMotion.getPath(translateX, translateY, 0, 0); + Animator position = ObjectAnimator.ofFloat(fabExpand, View.TRANSLATION_X, View + .TRANSLATION_Y, motionPath) + .setDuration(fabExpandDuration); + + // animate from the FAB colour to the placeholder background color + Animator background = ObjectAnimator.ofArgb(fabExpand, + ViewUtils.BACKGROUND_COLOR, + ContextCompat.getColor(this, R.color.designer_news), + ContextCompat.getColor(this, R.color.background_light)) + .setDuration(fabExpandDuration); + + // fade out the fab (rapidly) + Animator fadeOutFab = ObjectAnimator.ofFloat(fab, View.ALPHA, 0f) + .setDuration(60); + + // play 'em all together with the material interpolator + AnimatorSet show = new AnimatorSet(); + show.setInterpolator(AnimUtils.getMaterialInterpolator(DesignerNewsStory.this)); + show.playTogether(reveal, background, position, fadeOutFab); + show.start(); + } + + private void bindDescription(View storyDescription) { + TextView storyPoster = (TextView) storyDescription.findViewById(R.id.story_poster); + storyPoster.setText(DateUtils.getRelativeTimeSpanString(story.created_at.getTime(), + System.currentTimeMillis(), + DateUtils.SECOND_IN_MILLIS) + + " by " + story.user_display_name + + ", " + story.user_job); + + final TextView storyComment = (TextView) storyDescription.findViewById(R.id.story_comment); + if (!TextUtils.isEmpty(story.comment)) { + HtmlUtils.setTextWithNiceLinks(storyComment, markdown.markdownToSpannable(story + .comment, storyComment, new Bypass.LoadImageCallback() { + @Override + public void loadImage(String src, ImageLoadingSpan loadingSpan) { + Glide.with(DesignerNewsStory.this) + .load(src) + .asBitmap() + .diskCacheStrategy(DiskCacheStrategy.ALL) + .into(new ImageSpanTarget(storyComment, loadingSpan)); + } + })); + } + storyComment.setVisibility(TextUtils.isEmpty(story.comment) ? View.GONE : View.VISIBLE); + } + + private void addComments(List comments, int depth, List wrapped) { + for (Comment comment : comments) { + wrapped.add(new ThreadedComment(depth, comment)); + // todo move this to after downloading so only done once + if (comment.comments != null && comment.comments.size() > 0) { + addComments(comment.comments, depth + 1, wrapped); + } + } + } + + private boolean isOP(Long userId) { + return userId.equals(story.user_id); + } + + // convenience class used to convert nested comment structure returned from the API to a flat + // structure with a depth attribute, suitable for showing in a list. + protected class ThreadedComment { + final int depth; + final Comment comment; + + ThreadedComment(int depth, + Comment comment) { + this.depth = depth; + this.comment = comment; + } + } + + /* package */ class DesignerNewsCommentsAdapter + extends RecyclerView.Adapter { + + private static final int TYPE_HEADER = 0; + private static final int TYPE_NO_COMMENTS = 1; + private static final int TYPE_COMMENT = 2; + + private View header; + private List comments; + + DesignerNewsCommentsAdapter(@NonNull View header, @NonNull List comments) { + this.header = header; + this.comments = comments; + } + + private boolean hasComments() { + return !comments.isEmpty(); + } + + @Override + public int getItemViewType(int position) { + if (position == 0) { + return TYPE_HEADER; + } else { + return hasComments() ? TYPE_COMMENT : TYPE_NO_COMMENTS; + } + } + + @Override + public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + switch (viewType) { + case TYPE_HEADER: + return new HeaderHolder(header); + case TYPE_COMMENT: + return new CommentHolder( + getLayoutInflater().inflate(R.layout.designer_news_comment, parent, false)); + case TYPE_NO_COMMENTS: + return new NoCommentsHolder( + getLayoutInflater().inflate( + R.layout.designer_news_no_comments, parent, false)); + } + return null; + } + + @Override + public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { + if (getItemViewType(position) == TYPE_COMMENT) { + bindComment((CommentHolder) holder, comments.get(position - 1)); // minus header + } // nothing to bind for header / no comment views + } + + private void bindComment(final CommentHolder holder, final ThreadedComment comment) { + HtmlUtils.setTextWithNiceLinks(holder.comment, markdown.markdownToSpannable(comment + .comment.body, holder.comment, new Bypass.LoadImageCallback() { + @Override + public void loadImage(String src, ImageLoadingSpan loadingSpan) { + Glide.with(DesignerNewsStory.this) + .load(src) + .asBitmap() + .diskCacheStrategy(DiskCacheStrategy.ALL) + .into(new ImageSpanTarget(holder.comment, loadingSpan)); + } + })); + holder.author.setText(comment.comment.user_display_name); + holder.author.setOriginalPoster(isOP(comment + .comment.user_id)); + holder.timeAgo.setText( + DateUtils.getRelativeTimeSpanString(comment.comment.created_at.getTime(), + System.currentTimeMillis(), + DateUtils.SECOND_IN_MILLIS)); + ThreadedCommentDrawable depthDrawable = new ThreadedCommentDrawable(threadWidth, + threadGap); + depthDrawable.setDepth(comment.depth); + holder.threadDepth.setImageDrawable(depthDrawable); + } + + @Override + public int getItemCount() { + return hasComments() ? comments.size() + 1 // add one for header + : 2; // header + no comments view + } + } + + /* package */ static class CommentHolder extends RecyclerView.ViewHolder { + + @Bind(R.id.depth) ImageView threadDepth; + @Bind(R.id.comment_author) AuthorTextView author; + @Bind(R.id.comment_time_ago) TextView timeAgo; + @Bind(R.id.comment_text) TextView comment; + + public CommentHolder(View itemView) { + super(itemView); + ButterKnife.bind(this, itemView); + } + } + + /* package */ static class HeaderHolder extends RecyclerView.ViewHolder { + + public HeaderHolder(View itemView) { + super(itemView); + } + } + + /* package */ static class NoCommentsHolder extends RecyclerView.ViewHolder { + + public NoCommentsHolder(View itemView) { + super(itemView); + } + } +} diff --git a/app/src/main/java/io/plaidapp/ui/DribbbleLogin.java b/app/src/main/java/io/plaidapp/ui/DribbbleLogin.java new file mode 100644 index 000000000..412dbeb94 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/DribbbleLogin.java @@ -0,0 +1,276 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui; + +import android.animation.ObjectAnimator; +import android.app.Activity; +import android.app.SharedElementCallback; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.drawable.BitmapDrawable; +import android.os.Bundle; +import android.os.Parcelable; +import android.support.v4.content.ContextCompat; +import android.text.TextUtils; +import android.transition.ArcMotion; +import android.transition.TransitionManager; +import android.util.Log; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AnimationUtils; +import android.view.animation.Interpolator; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; + +import com.bumptech.glide.Glide; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.util.List; + +import io.plaidapp.BuildConfig; +import io.plaidapp.R; +import io.plaidapp.data.api.AuthInterceptor; +import io.plaidapp.data.api.dribbble.DribbbleAuthService; +import io.plaidapp.data.api.dribbble.DribbbleService; +import io.plaidapp.data.api.dribbble.model.AccessToken; +import io.plaidapp.data.api.dribbble.model.User; +import io.plaidapp.data.prefs.DribbblePrefs; +import io.plaidapp.ui.transitions.FabDialogMorphSetup; +import io.plaidapp.ui.transitions.MorphDialogToFab; +import io.plaidapp.ui.transitions.MorphFabToDialog; +import io.plaidapp.util.ScrimUtil; +import io.plaidapp.util.glide.CircleTransform; +import retrofit.Callback; +import retrofit.RestAdapter; +import retrofit.RetrofitError; +import retrofit.client.Response; +import retrofit.converter.GsonConverter; + +public class DribbbleLogin extends Activity { + + boolean isDismissing = false; + private ViewGroup container; + private TextView message; + private Button login; + private ProgressBar loading; + private DribbblePrefs dribbblePrefs; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_dribbble_login); + FabDialogMorphSetup.setupSharedEelementTransitions(this, container, + getResources().getDimensionPixelSize(R.dimen.dialog_corners)); + + container = (ViewGroup) findViewById(R.id.container); + message = (TextView) findViewById(R.id.login_message); + login = (Button) findViewById(R.id.login); + loading = (ProgressBar) findViewById(R.id.loading); + loading.setVisibility(View.GONE); + dribbblePrefs = DribbblePrefs.get(this); + + checkAuthCallback(getIntent()); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + checkAuthCallback(intent); + } + + public void doLogin(View view) { + showLoading(); + dribbblePrefs.login(DribbbleLogin.this); + } + + public void dismiss(View view) { + isDismissing = true; + setResult(Activity.RESULT_CANCELED); + finishAfterTransition(); + } + + @Override + public void onBackPressed() { + dismiss(null); + } + + private void showLoading() { + TransitionManager.beginDelayedTransition(container); + message.setVisibility(View.GONE); + login.setVisibility(View.GONE); + loading.setVisibility(View.VISIBLE); + } + + private void showLogin() { + TransitionManager.beginDelayedTransition(container); + message.setVisibility(View.VISIBLE); + login.setVisibility(View.VISIBLE); + loading.setVisibility(View.GONE); + } + + private void checkAuthCallback(Intent intent) { + if (intent != null + && intent.getData() != null + && !TextUtils.isEmpty(intent.getData().getAuthority()) + && DribbblePrefs.LOGIN_CALLBACK.equals(intent.getData().getAuthority())) { + showLoading(); + getAccessToken(intent.getData().getQueryParameter("code")); + } + } + + private void getAccessToken(String code) { + RestAdapter restAdapter = new RestAdapter.Builder() + .setEndpoint(DribbbleAuthService.ENDPOINT) + .build(); + + DribbbleAuthService dribbbleAuthApi = restAdapter.create((DribbbleAuthService.class)); + + dribbbleAuthApi.getAccessToken(BuildConfig.DRIBBBLE_CLIENT_ID, + BuildConfig.DRIBBBLE_CLIENT_SECRET, + code, "", new Callback() { + @Override + public void success(AccessToken accessToken, Response response) { + dribbblePrefs.setAccessToken(accessToken.access_token); + showLoggedInUser(); + setResult(Activity.RESULT_OK); + finishAfterTransition(); + } + + @Override + public void failure(RetrofitError error) { + Log.e(getClass().getCanonicalName(), error.getMessage(), error); + // TODO snackbar? + Toast.makeText(getApplicationContext(), "Log in failed: " + error + .getResponse() + .getStatus(), Toast.LENGTH_LONG).show(); + showLogin(); + } + }); + } + + private void showLoggedInUser() { + Gson gson = new GsonBuilder() + .setDateFormat(DribbbleService.DATE_FORMAT) + .create(); + + RestAdapter restAdapter = new RestAdapter.Builder() + .setEndpoint(DribbbleService.ENDPOINT) + .setConverter(new GsonConverter(gson)) + .setRequestInterceptor(new AuthInterceptor(dribbblePrefs.getAccessToken())) + .build(); + + DribbbleService dribbbleApi = restAdapter.create((DribbbleService.class)); + dribbbleApi.getAuthenticatedUser(new Callback() { + @Override + public void success(User user, Response response) { + dribbblePrefs.setLoggedInUser(user); + Toast confirmLogin = new Toast(getApplicationContext()); + View v = LayoutInflater.from(DribbbleLogin.this).inflate(R.layout + .toast_logged_in_confirmation, null, false); + ((TextView) v.findViewById(R.id.name)).setText(user.name); + // need to use app context here as the activity will be destroyed shortly + Glide.with(getApplicationContext()) + .load(user.avatar_url) + .placeholder(R.drawable.ic_player) + .transform(new CircleTransform(getApplicationContext())) + .into((ImageView) v.findViewById(R.id.avatar)); + v.findViewById(R.id.scrim).setBackground(ScrimUtil.makeCubicGradientScrimDrawable + (ContextCompat.getColor(DribbbleLogin.this, R.color.scrim), + 5, Gravity.BOTTOM)); + confirmLogin.setView(v); + confirmLogin.setGravity(Gravity.BOTTOM | Gravity.FILL_HORIZONTAL, 0, 0); + confirmLogin.setDuration(Toast.LENGTH_LONG); + confirmLogin.show(); + } + + @Override + public void failure(RetrofitError error) { + } + }); + } + + private void forceSharedElementLayout() { + int widthSpec = View.MeasureSpec.makeMeasureSpec(container.getWidth(), + View.MeasureSpec.EXACTLY); + int heightSpec = View.MeasureSpec.makeMeasureSpec(container.getHeight(), + View.MeasureSpec.EXACTLY); + container.measure(widthSpec, heightSpec); + container.layout(container.getLeft(), container.getTop(), container.getRight(), container + .getBottom()); + } + + private SharedElementCallback sharedElementEnterCallback = new SharedElementCallback() { + @Override + public View onCreateSnapshotView(Context context, Parcelable snapshot) { + // grab the saved fab snapshot and pass it to the below via a View + View view = new View(context); + final Bitmap snapshotBitmap = getSnapshot(snapshot); + if (snapshotBitmap != null) { + view.setBackground(new BitmapDrawable(context.getResources(), snapshotBitmap)); + } + return view; + } + + @Override + public void onSharedElementStart(List sharedElementNames, + List sharedElements, + List sharedElementSnapshots) { + // grab the fab snapshot and fade it out/in (depending on if we are entering or exiting) + for (int i = 0; i < sharedElements.size(); i++) { + if (sharedElements.get(i) == container) { + View snapshot = sharedElementSnapshots.get(i); + BitmapDrawable fabSnapshot = (BitmapDrawable) snapshot.getBackground(); + fabSnapshot.setBounds(0, 0, snapshot.getWidth(), snapshot.getHeight()); + container.getOverlay().clear(); + container.getOverlay().add(fabSnapshot); + if (!isDismissing) { + // fab -> login: fade out the fab snapshot + ObjectAnimator.ofInt(fabSnapshot, "alpha", 0).setDuration(100).start(); + } else { + // login -> fab: fade in the fab snapshot toward the end of the transition + fabSnapshot.setAlpha(0); + ObjectAnimator fadeIn = ObjectAnimator.ofInt(fabSnapshot, "alpha", 255) + .setDuration(150); + fadeIn.setStartDelay(150); + fadeIn.start(); + } + forceSharedElementLayout(); + break; + } + } + } + + private Bitmap getSnapshot(Parcelable parcel) { + if (parcel instanceof Bitmap) { + return (Bitmap) parcel; + } else if (parcel instanceof Bundle) { + Bundle bundle = (Bundle) parcel; + // see SharedElementCallback#onCaptureSharedElementSnapshot + return (Bitmap) bundle.getParcelable("sharedElement:snapshot:bitmap"); + } + return null; + } + }; +} diff --git a/app/src/main/java/io/plaidapp/ui/DribbbleShot.java b/app/src/main/java/io/plaidapp/ui/DribbbleShot.java new file mode 100644 index 000000000..280b19a1e --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/DribbbleShot.java @@ -0,0 +1,957 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.ActivityOptions; +import android.app.SharedElementCallback; +import android.app.assist.AssistContent; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.RectF; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Parcelable; +import android.support.customtabs.CustomTabsIntent; +import android.support.v4.content.ContextCompat; +import android.support.v7.graphics.Palette; +import android.text.TextUtils; +import android.text.format.DateUtils; +import android.transition.Transition; +import android.transition.TransitionManager; +import android.util.Log; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.animation.AnimationUtils; +import android.view.animation.Interpolator; +import android.widget.AbsListView; +import android.widget.ArrayAdapter; +import android.widget.BaseAdapter; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ListAdapter; +import android.widget.ListView; +import android.widget.TextView; +import android.widget.Toast; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.Priority; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.resource.drawable.GlideDrawable; +import com.bumptech.glide.request.RequestListener; +import com.bumptech.glide.request.target.Target; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.text.NumberFormat; +import java.util.List; + +import butterknife.Bind; +import butterknife.ButterKnife; +import io.plaidapp.R; +import io.plaidapp.data.api.AuthInterceptor; +import io.plaidapp.data.api.dribbble.DribbbleService; +import io.plaidapp.data.api.dribbble.model.Comment; +import io.plaidapp.data.api.dribbble.model.Like; +import io.plaidapp.data.api.dribbble.model.Shot; +import io.plaidapp.data.prefs.DribbblePrefs; +import io.plaidapp.ui.transitions.FabDialogMorphSetup; +import io.plaidapp.ui.widget.AuthorTextView; +import io.plaidapp.ui.widget.CheckableImageButton; +import io.plaidapp.ui.widget.ElasticDragDismissFrameLayout; +import io.plaidapp.ui.widget.FABToggle; +import io.plaidapp.ui.widget.FabOverlapTextView; +import io.plaidapp.ui.widget.ForegroundImageView; +import io.plaidapp.ui.widget.ParallaxScrimageView; +import io.plaidapp.util.AnimUtils; +import io.plaidapp.util.ColorUtils; +import io.plaidapp.util.HtmlUtils; +import io.plaidapp.util.ViewUtils; +import io.plaidapp.util.customtabs.CustomTabActivityHelper; +import io.plaidapp.util.glide.CircleTransform; +import io.plaidapp.util.glide.GlideUtils; +import retrofit.RestAdapter; +import retrofit.RetrofitError; +import retrofit.client.Response; +import retrofit.converter.GsonConverter; + +public class DribbbleShot extends Activity { + + protected final static String EXTRA_SHOT = "shot"; + private static final int RC_LOGIN_LIKE = 0; + private static final int RC_LOGIN_COMMENT = 1; + private static final float SCRIM_ADJUSTMENT = 0.075f; + + @Bind(R.id.draggable_frame) ElasticDragDismissFrameLayout draggableFrame; + @Bind(R.id.back) ImageButton back; + @Bind(R.id.shot) ParallaxScrimageView imageView; + @Bind(R.id.fab_heart) FABToggle fab; + private View shotSpacer; + private View title; + private TextView description; + private LinearLayout shotActions; + private Button likeCount; + private Button viewCount; + private Button share; + private TextView playerName; + private ImageView playerAvatar; + private TextView shotTimeAgo; + private ListView commentsList; + private DribbbleCommentsAdapter commentsAdapter; + private ImageView userAvatar; + private EditText enterComment; + private ImageButton postComment; + + private Shot shot; + private int fabOffset; + private DribbblePrefs dribbblePrefs; + private DribbbleService dribbbleApi; + private boolean performingLike; + private CircleTransform circleTransform; + private ElasticDragDismissFrameLayout.SystemChromeFader chromeFader; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_dribbble_shot); + shot = getIntent().getParcelableExtra(EXTRA_SHOT); + setupDribbble(); + setExitSharedElementCallback(fabLoginSharedElementCallback); + getWindow().getSharedElementReturnTransition().addListener(shotReturnHomeListener); + Resources res = getResources(); + + ButterKnife.bind(this); + View shotDescription = getLayoutInflater().inflate(R.layout.dribbble_shot_description, + commentsList, false); + shotSpacer = shotDescription.findViewById(R.id.shot_spacer); + title = shotDescription.findViewById(R.id.shot_title); + description = (TextView) shotDescription.findViewById(R.id.shot_description); + shotActions = (LinearLayout) shotDescription.findViewById(R.id.shot_actions); + likeCount = (Button) shotDescription.findViewById(R.id.shot_like_count); + viewCount = (Button) shotDescription.findViewById(R.id.shot_view_count); + share = (Button) shotDescription.findViewById(R.id.shot_share_action); + playerName = (TextView) shotDescription.findViewById(R.id.player_name); + playerAvatar = (ImageView) shotDescription.findViewById(R.id.player_avatar); + shotTimeAgo = (TextView) shotDescription.findViewById(R.id.shot_time_ago); + commentsList = (ListView) findViewById(R.id.dribbble_comments); + commentsList.addHeaderView(shotDescription); + View enterCommentView = getLayoutInflater().inflate(R.layout.dribbble_enter_comment, + commentsList, false); + userAvatar = (ForegroundImageView) enterCommentView.findViewById(R.id.avatar); + enterComment = (EditText) enterCommentView.findViewById(R.id.comment); + postComment = (ImageButton) enterCommentView.findViewById(R.id.post_comment); + enterComment.setOnFocusChangeListener(enterCommentFocus); + commentsList.addFooterView(enterCommentView); + commentsList.setOnScrollListener(scrollListener); + back.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + expandImageAndFinish(); + } + }); + fab.setOnClickListener(fabClick); + chromeFader = new ElasticDragDismissFrameLayout.SystemChromeFader(getWindow()) { + @Override + public void onDragDismissed() { + expandImageAndFinish(); + } + }; + circleTransform = new CircleTransform(this); + + // load the main image + Glide.with(this) + .load(shot.images.best()) + .listener(shotLoadListener) + .diskCacheStrategy(DiskCacheStrategy.SOURCE) + .priority(Priority.IMMEDIATE) + .into(imageView); + imageView.setOnClickListener(shotClick); + shotSpacer.setOnClickListener(shotClick); + + postponeEnterTransition(); + imageView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver + .OnPreDrawListener() { + @Override + public boolean onPreDraw() { + imageView.getViewTreeObserver().removeOnPreDrawListener(this); + calculateFabPosition(); + enterAnimation(); + startPostponedEnterTransition(); + return true; + } + }); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + ((FabOverlapTextView) title).setText(shot.title); + } else { + ((TextView) title).setText(shot.title); + } + if (!TextUtils.isEmpty(shot.description)) { + HtmlUtils.setTextWithNiceLinks(description, shot.getParsedDescription(description)); + } else { + description.setVisibility(View.GONE); + } + NumberFormat nf = NumberFormat.getInstance(); + likeCount.setText( + res.getQuantityString(R.plurals.likes, + (int) shot.likes_count, + nf.format(shot.likes_count))); + // TODO onClick show likes + viewCount.setText( + res.getQuantityString(R.plurals.views, + (int) shot.views_count, + nf.format(shot.views_count))); + share.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + new ShareDribbbleImageTask(DribbbleShot.this, shot).execute(); + } + }); + if (shot.user != null) { + playerName.setText("–" + shot.user.name); + Glide.with(this) + .load(shot.user.avatar_url) + .transform(circleTransform) + .placeholder(R.drawable.avatar_placeholder) + .into(playerAvatar); + playerAvatar.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + DribbbleShot.this.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(shot + .user.html_url))); + } + }); + if (shot.created_at != null) { + shotTimeAgo.setText(DateUtils.getRelativeTimeSpanString(shot.created_at.getTime(), + System.currentTimeMillis(), + DateUtils.SECOND_IN_MILLIS)); + } + } else { + playerName.setVisibility(View.GONE); + playerAvatar.setVisibility(View.GONE); + shotTimeAgo.setVisibility(View.GONE); + } + + if (shot.comments_count > 0) { + loadComments(); + } else { + commentsList.setAdapter(getNoCommentsAdapter()); + } + + if (dribbblePrefs.isLoggedIn() && !TextUtils.isEmpty(dribbblePrefs.getUserAvatar())) { + Glide.with(this) + .load(dribbblePrefs.getUserAvatar()) + .transform(circleTransform) + .placeholder(R.drawable.ic_player) + .into(userAvatar); + } + } + + @Override + protected void onResume() { + super.onResume(); + if (!performingLike) { + checkLiked(); + } + draggableFrame.addListener(chromeFader); + } + + @Override + protected void onPause() { + draggableFrame.removeListener(chromeFader); + super.onPause(); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + switch (requestCode) { + case RC_LOGIN_LIKE: + if (resultCode == RESULT_OK) { + setupDribbble(); // recreate to capture the new access token + // TODO when we add more authenticated actions will need to keep track of what + // the user was trying to do when forced to login + fab.setChecked(true); + doLike(); + } + break; + } + } + + @Override + public void onBackPressed() { + expandImageAndFinish(); + } + + @Override + public boolean onNavigateUp() { + expandImageAndFinish(); + return true; + } + + @Override @TargetApi(Build.VERSION_CODES.M) + public void onProvideAssistContent(AssistContent outContent) { + outContent.setWebUri(Uri.parse(shot.url)); + } + + private View.OnClickListener shotClick = new View.OnClickListener() { + @Override + public void onClick(View view) { + openLink(shot.url); + } + }; + + private void openLink(String url) { + CustomTabActivityHelper.openCustomTab( + DribbbleShot.this, + new CustomTabsIntent.Builder() + .setToolbarColor(ContextCompat.getColor(DribbbleShot.this, R.color.dribbble)) + .build(), + Uri.parse(url)); + } + + private RequestListener shotLoadListener = new RequestListener() { + @Override + public boolean onResourceReady(GlideDrawable resource, String model, + Target target, boolean isFromMemoryCache, + boolean isFirstResource) { + final Bitmap bitmap = GlideUtils.getBitmap(resource); + float imageScale = (float) imageView.getHeight() / (float) bitmap.getHeight(); + float twentyFourDip = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 24, + DribbbleShot.this.getResources().getDisplayMetrics()); + Palette.from(bitmap) + .maximumColorCount(3) + .clearFilters() + .setRegion(0, 0, bitmap.getWidth(), (int) (twentyFourDip / imageScale)) + .generate(new Palette.PaletteAsyncListener() { + @Override + public void onGenerated(Palette palette) { + boolean isDark; + @ColorUtils.Lightness int lightness = ColorUtils.isDark(palette); + if (lightness == ColorUtils.LIGHTNESS_UNKNOWN) { + isDark = ColorUtils.isDark(bitmap, bitmap.getWidth() / 2, 0); + } else { + isDark = lightness == ColorUtils.IS_DARK; + } + + if (!isDark) { // make back icon dark on light images + back.setColorFilter(ContextCompat.getColor( + DribbbleShot.this, R.color.dark_icon)); + } + + // color the status bar. Set a complementary dark color on L, + // light or dark color on M (with matching status bar icons) + int statusBarColor = getWindow().getStatusBarColor(); + Palette.Swatch topColor = ColorUtils.getMostPopulousSwatch(palette); + if (topColor != null && + (isDark || Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)) { + statusBarColor = ColorUtils.scrimify(topColor.getRgb(), + isDark, SCRIM_ADJUSTMENT); + // set a light status bar on M+ + if (!isDark && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + ViewUtils.setLightStatusBar(imageView); + } + } + + if (statusBarColor != getWindow().getStatusBarColor()) { + imageView.setScrimColor(statusBarColor); + ValueAnimator statusBarColorAnim = ValueAnimator.ofArgb(getWindow + ().getStatusBarColor(), statusBarColor); + statusBarColorAnim.addUpdateListener(new ValueAnimator + .AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + getWindow().setStatusBarColor((int) animation + .getAnimatedValue()); + } + }); + statusBarColorAnim.setDuration(1000); + statusBarColorAnim.setInterpolator(AnimationUtils + .loadInterpolator(DribbbleShot.this, android.R + .interpolator.fast_out_slow_in)); + statusBarColorAnim.start(); + } + } + }); + + Palette.from(bitmap) + .clearFilters() // by default palette ignore certain hues (e.g. pure + // black/white) but we don't want this. + .generate(new Palette.PaletteAsyncListener() { + @Override + public void onGenerated(Palette palette) { + Palette.Swatch vibrant = palette.getVibrantSwatch(); + if (vibrant != null) { + // color the ripple on the image spacer (default is grey) + shotSpacer.setBackground(ViewUtils.createMaskedRipple(vibrant + .getRgb(), 0.25f)); + // slightly more opaque ripple on the pinned image to compensate + // for the scrim + imageView.setForeground(ViewUtils.createRipple(vibrant.getRgb(), + 0.3f)); + } + } + }); + + // TODO should keep the background if the image contains transparency?! + imageView.setBackground(null); + return false; + } + + @Override + public boolean onException(Exception e, String model, Target target, + boolean isFirstResource) { + return false; + } + }; + + private View.OnFocusChangeListener enterCommentFocus = new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View view, boolean hasFocus) { + // kick off an anim (via animated state list) on the post button. see + // @drawable/ic_add_comment_state + postComment.setActivated(hasFocus); + } + }; + + private AbsListView.OnScrollListener scrollListener = new AbsListView.OnScrollListener() { + @Override + public void onScroll(AbsListView view, int firstVisibleItemPosition, int + visibleItemCount, int totalItemCount) { + if (commentsList.getMaxScrollAmount() > 0 + && firstVisibleItemPosition == 0 + && commentsList.getChildAt(0) != null) { + int listScroll = commentsList.getChildAt(0).getTop(); + imageView.setOffset(listScroll); + fab.setOffset(fabOffset + listScroll); + } + } + + public void onScrollStateChanged(AbsListView view, int scrollState) { + // as we animate the main image's elevation change when it 'pins' at it's min height + // a fling can cause the title to go over the image before the animation has a chance to + // run. In this case we short circuit the animation and just jump to state. + imageView.setImmediatePin(scrollState == AbsListView.OnScrollListener + .SCROLL_STATE_FLING); + } + }; + + private View.OnClickListener fabClick = new View.OnClickListener() { + @Override + public void onClick(View view) { + if (dribbblePrefs.isLoggedIn()) { + fab.toggle(); + doLike(); + } else { + Intent login = new Intent(DribbbleShot.this, DribbbleLogin.class); + login.putExtra(FabDialogMorphSetup.EXTRA_SHARED_ELEMENT_START_COLOR, + ContextCompat.getColor(DribbbleShot.this, R.color.dribbble)); + ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation + (DribbbleShot.this, fab, getString(R.string.transition_dribbble_login)); + startActivityForResult(login, RC_LOGIN_LIKE, options.toBundle()); + } + } + }; + + private SharedElementCallback fabLoginSharedElementCallback = new SharedElementCallback() { + @Override + public Parcelable onCaptureSharedElementSnapshot(View sharedElement, + Matrix viewToGlobalMatrix, + RectF screenBounds) { + // store a snapshot of the fab to fade out when morphing to the login dialog + int bitmapWidth = Math.round(screenBounds.width()); + int bitmapHeight = Math.round(screenBounds.height()); + Bitmap bitmap = null; + if (bitmapWidth > 0 && bitmapHeight > 0) { + bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888); + sharedElement.draw(new Canvas(bitmap)); + } + return bitmap; + } + }; + + private Transition.TransitionListener shotReturnHomeListener = new AnimUtils + .TransitionListenerAdapter() { + @Override + public void onTransitionStart(Transition transition) { + super.onTransitionStart(transition); + // hide the fab as for some reason it jumps position?? TODO work out why + fab.setVisibility(View.INVISIBLE); + // fade out the "toolbar" & list as we don't want them to be visible during return + // animation + back.animate() + .alpha(0f) + .setDuration(100) + .setInterpolator(AnimationUtils.loadInterpolator(DribbbleShot.this, android.R + .interpolator.linear_out_slow_in)); + imageView.setElevation(1f); + back.setElevation(0f); + commentsList.animate() + .alpha(0f) + .setDuration(50) + .setInterpolator(AnimationUtils.loadInterpolator(DribbbleShot.this, android.R + .interpolator.linear_out_slow_in)); + } + }; + + private void loadComments() { + commentsList.setAdapter(getLoadingCommentsAdapter()); + + // then load comments + dribbbleApi.getComments(shot.id, null, DribbbleService.PER_PAGE_MAX, new retrofit + .Callback>() { + @Override + public void success(List comments, Response response) { + if (comments != null && !comments.isEmpty()) { + commentsAdapter = new DribbbleCommentsAdapter(DribbbleShot.this, R.layout + .dribbble_comment, comments); + commentsList.setAdapter(commentsAdapter); + commentsList.setDivider(getDrawable(R.drawable.list_divider)); + commentsList.setDividerHeight(getResources().getDimensionPixelSize(R.dimen + .divider_height)); + } + } + + @Override + public void failure(RetrofitError error) { + } + }); + } + + private void expandImageAndFinish() { + if (imageView.getOffset() != 0f) { + Animator expandImage = ObjectAnimator.ofFloat(imageView, ParallaxScrimageView.OFFSET, + 0f); + expandImage.setDuration(80); + expandImage.setInterpolator(AnimationUtils.loadInterpolator(this, android.R + .interpolator.fast_out_slow_in)); + expandImage.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + finishAfterTransition(); + } + }); + expandImage.start(); + } else { + finishAfterTransition(); + } + } + + private void setupDribbble() { + // setup the api object which captures the current access token + dribbblePrefs = DribbblePrefs.get(this); + Gson gson = new GsonBuilder() + .setDateFormat(DribbbleService.DATE_FORMAT) + .create(); + RestAdapter restAdapter = new RestAdapter.Builder() + .setEndpoint(DribbbleService.ENDPOINT) + .setConverter(new GsonConverter(gson)) + .setRequestInterceptor(new AuthInterceptor(dribbblePrefs.getAccessToken())) + .build(); + dribbbleApi = restAdapter.create(DribbbleService.class); + } + + private void calculateFabPosition() { + // calculate 'natural' position i.e. with full height image. Store it for use when scrolling + fabOffset = imageView.getHeight() + title.getHeight() - (fab.getHeight() / 2); + fab.setOffset(fabOffset); + + // calculate min position i.e. pinned to the collapsed image when scrolled + fab.setMinOffset(imageView.getMinimumHeight() - (fab.getHeight() / 2)); + } + + /** + * Animate in the title, description and author – can't do this in a content transition as they + * are within the ListView so do it manually. Also handle the FAB tanslation here so that it + * plays nicely with #calculateFabPosition + */ + private void enterAnimation() { + Interpolator interp = AnimationUtils.loadInterpolator(this, android.R.interpolator + .fast_out_slow_in); + int offset = title.getHeight(); + viewEnterAnimation(title, offset, interp); + if (description.getVisibility() == View.VISIBLE) { + offset *= 1.5f; + viewEnterAnimation(description, offset, interp); + } + // animate the fab without touching the alpha as this is handled in the content transition + offset *= 1.5f; + float fabTransY = fab.getTranslationY(); + fab.setTranslationY(fabTransY + offset); + fab.animate() + .translationY(fabTransY) + .setDuration(600) + .setInterpolator(interp) + .start(); + offset *= 1.5f; + viewEnterAnimation(shotActions, offset, interp); + offset *= 1.5f; + viewEnterAnimation(playerName, offset, interp); + viewEnterAnimation(playerAvatar, offset, interp); + viewEnterAnimation(shotTimeAgo, offset, interp); + back.animate() + .alpha(1f) + .setDuration(600) + .setInterpolator(interp) + .start(); + } + + private void viewEnterAnimation(View view, float offset, Interpolator interp) { + view.setTranslationY(offset); + view.setAlpha(0.8f); + view.animate() + .translationY(0f) + .alpha(1f) + .setDuration(600) + .setInterpolator(interp) + .setListener(null) + .start(); + } + + private void doLike() { + performingLike = true; + if (fab.isChecked()) { + dribbbleApi.like(shot.id, "", new retrofit.Callback() { + @Override + public void success(Like like, Response response) { + performingLike = false; + } + + @Override + public void failure(RetrofitError error) { + performingLike = false; + } + }); + } else { + dribbbleApi.unlike(shot.id, new retrofit.Callback() { + @Override + public void success(Void aVoid, Response response) { + performingLike = false; + } + + @Override + public void failure(RetrofitError error) { + performingLike = false; + } + }); + } + } + + private void checkLiked() { + if (dribbblePrefs.isLoggedIn()) { + dribbbleApi.liked(shot.id, new retrofit.Callback() { + @Override + public void success(Like like, Response response) { + // note that like.user will be null here + fab.setChecked(like != null); + fab.jumpDrawablesToCurrentState(); + } + + @Override + public void failure(RetrofitError error) { + // 404 is expected if shot is not liked + fab.setChecked(false); + fab.jumpDrawablesToCurrentState(); + } + }); + } + } + + public void postComment(View view) { + if (dribbblePrefs.isLoggedIn()) { + if (TextUtils.isEmpty(enterComment.getText())) return; + enterComment.setEnabled(false); + dribbbleApi.postComment(shot.id, enterComment.getText().toString().trim(), new retrofit + .Callback() { + @Override + public void success(Comment comment, Response response) { + loadComments(); + enterComment.getText().clear(); + enterComment.setEnabled(true); + } + + @Override + public void failure(RetrofitError error) { + enterComment.setEnabled(true); + } + }); + } else { + Intent login = new Intent(DribbbleShot.this, DribbbleLogin.class); + login.putExtra(FabDialogMorphSetup.EXTRA_SHARED_ELEMENT_START_COLOR, ContextCompat.getColor + (this, R.color.background_light)); + ActivityOptions options = + ActivityOptions.makeSceneTransitionAnimation(DribbbleShot.this, postComment, + getString(R.string.transition_dribbble_login)); + startActivityForResult(login, RC_LOGIN_COMMENT, options.toBundle()); + } + } + + private boolean isOP(long playerId) { + return shot.user != null && shot.user.id == playerId; + } + + private ListAdapter getNoCommentsAdapter() { + String[] noComments = {getString(R.string.no_comments)}; + return new ArrayAdapter<>(this, R.layout.dribbble_no_comments, noComments); + } + + private ListAdapter getLoadingCommentsAdapter() { + return new BaseAdapter() { + @Override + public int getCount() { + return 1; + } + + @Override + public Object getItem(int position) { + return null; + } + + @Override + public long getItemId(int position) { + return 0; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + return DribbbleShot.this.getLayoutInflater().inflate(R.layout.loading, parent, + false); + } + }; + } + + protected class DribbbleCommentsAdapter extends ArrayAdapter { + + private final LayoutInflater inflater; + private int expandedCommentPosition = ListView.INVALID_POSITION; + + public DribbbleCommentsAdapter(Context context, int resource, List comments) { + super(context, resource, comments); + inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + } + + @Override + public View getView(int position, View view, ViewGroup container) { + if (view == null) { + view = newNewCommentView(position, container); + } + bindComment(getItem(position), position, view); + return view; + } + + private View newNewCommentView(int position, ViewGroup parent) { + View view = inflater.inflate(R.layout.dribbble_comment, parent, false); + view.setTag(R.id.player_avatar, view.findViewById(R.id.player_avatar)); + view.setTag(R.id.comment_author, view.findViewById(R.id.comment_author)); + view.setTag(R.id.comment_time_ago, view.findViewById(R.id.comment_time_ago)); + view.setTag(R.id.comment_text, view.findViewById(R.id.comment_text)); + view.setTag(R.id.comment_reply, view.findViewById(R.id.comment_reply)); + view.setTag(R.id.comment_like, view.findViewById(R.id.comment_like)); + view.setTag(R.id.comment_likes_count, view.findViewById(R.id.comment_likes_count)); + return view; + } + + private void bindComment(final Comment comment, final int position, final View view) { + final ImageView avatar = (ImageView) view.getTag(R.id.player_avatar); + final AuthorTextView author = (AuthorTextView) view.getTag(R.id.comment_author); + final TextView timeAgo = (TextView) view.getTag(R.id.comment_time_ago); + final TextView commentBody = (TextView) view.getTag(R.id.comment_text); + final ImageButton reply = (ImageButton) view.getTag(R.id.comment_reply); + final CheckableImageButton likeHeart = (CheckableImageButton) view.getTag(R.id + .comment_like); + final TextView likesCount = (TextView) view.getTag(R.id.comment_likes_count); + + Glide.with(DribbbleShot.this) + .load(comment.user.avatar_url) + .transform(circleTransform) + .placeholder(R.drawable.avatar_placeholder) + .into(avatar); + avatar.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + DribbbleShot.this.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse + (comment.user.html_url))); + } + }); + author.setText(comment.user.name); + author.setOriginalPoster(isOP(comment.user.id)); + timeAgo.setText(comment.created_at == null ? "" : + DateUtils.getRelativeTimeSpanString(comment.created_at.getTime(), + System.currentTimeMillis(), + DateUtils.SECOND_IN_MILLIS)); + HtmlUtils.setTextWithNiceLinks(commentBody, comment.getParsedBody(commentBody)); + + view.setActivated(position == expandedCommentPosition); + view.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + boolean isExpanded = reply.getVisibility() == View.VISIBLE; + TransitionManager.beginDelayedTransition((ViewGroup) view); + view.setActivated(!isExpanded); + if (!isExpanded) { // do expand + expandedCommentPosition = position; + reply.setVisibility(View.VISIBLE); + likeHeart.setVisibility(View.VISIBLE); + likesCount.setVisibility(View.VISIBLE); + if (comment.liked == null) { + dribbbleApi.likedComment(shot.id, comment.id, new retrofit + .Callback() { + @Override + public void success(Like like, Response response) { + comment.liked = true; + likeHeart.setChecked(true); + likeHeart.jumpDrawablesToCurrentState(); + } + + @Override + public void failure(RetrofitError error) { + comment.liked = false; + likeHeart.setChecked(false); + likeHeart.jumpDrawablesToCurrentState(); + } + }); + } + } else { // do collapse + expandedCommentPosition = ListView.INVALID_POSITION; + reply.setVisibility(View.GONE); + likeHeart.setVisibility(View.GONE); + likesCount.setVisibility(View.GONE); + } + notifyDataSetChanged(); + } + }); + + reply.setVisibility(position == expandedCommentPosition ? View.VISIBLE : View.GONE); + reply.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + enterComment.setText("@" + comment.user.username + " "); + enterComment.setSelection(enterComment.getText().length()); + + // collapse the comment and scroll the reply box (in the footer) into view + expandedCommentPosition = ListView.INVALID_POSITION; + notifyDataSetChanged(); + enterComment.requestFocus(); + commentsList.smoothScrollToPositionFromTop(commentsList.getCount(), 0, 300); + } + }); + + likeHeart.setChecked(comment.liked != null && comment.liked.booleanValue()); + likeHeart.setVisibility(position == expandedCommentPosition ? View.VISIBLE : View.GONE); + if (comment.user.id != dribbblePrefs.getUserId()) { + likeHeart.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (dribbblePrefs.isLoggedIn()) { + if (comment.liked == null || !comment.liked) { + comment.liked = true; + comment.likes_count++; + likesCount.setText(String.valueOf(comment.likes_count)); + notifyDataSetChanged(); + dribbbleApi.likeComment(shot.id, comment.id, "", new retrofit + .Callback() { + @Override + public void success(Like like, Response response) { + } + + @Override + public void failure(RetrofitError error) { + } + }); + } else { + comment.liked = false; + comment.likes_count--; + likesCount.setText(String.valueOf(comment.likes_count)); + notifyDataSetChanged(); + dribbbleApi.unlikeComment(shot.id, comment.id, new retrofit + .Callback() { + @Override + public void success(Void voyd, Response response) { + } + + @Override + public void failure(RetrofitError error) { + } + }); + } + } else { + likeHeart.setChecked(false); + startActivityForResult(new Intent(DribbbleShot.this, DribbbleLogin + .class), RC_LOGIN_LIKE); + } + } + }); + } + likesCount.setVisibility(position == expandedCommentPosition ? View.VISIBLE : View + .GONE); + likesCount.setText(String.valueOf(comment.likes_count)); + likesCount.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + dribbbleApi.getCommentLikes(shot.id, comment.id, new retrofit + .Callback>() { + @Override + public void success(List likes, Response response) { + // TODO something better than this. + StringBuilder sb = new StringBuilder("Liked by:\n\n"); + for (Like like : likes) { + if (like.user != null) { + sb.append("@"); + sb.append(like.user.username); + sb.append("\n"); + } + } + Toast.makeText(getApplicationContext(), sb.toString(), Toast + .LENGTH_SHORT).show(); + } + + @Override + public void failure(RetrofitError error) { + Log.e("GET COMMENT LIKES", error.getMessage(), error); + } + }); + } + }); + } + + @Override + public boolean hasStableIds() { + return true; + } + + @Override + public long getItemId(int position) { + return getItem(position).id; + } + } + +} diff --git a/app/src/main/java/io/plaidapp/ui/FeedAdapter.java b/app/src/main/java/io/plaidapp/ui/FeedAdapter.java new file mode 100644 index 000000000..0aa571347 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/FeedAdapter.java @@ -0,0 +1,542 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.animation.PropertyValuesHolder; +import android.animation.ValueAnimator; +import android.app.Activity; +import android.app.ActivityOptions; +import android.content.Intent; +import android.graphics.ColorMatrixColorFilter; +import android.net.Uri; +import android.support.annotation.Nullable; +import android.support.customtabs.CustomTabsIntent; +import android.support.v4.content.ContextCompat; +import android.support.v7.widget.RecyclerView; +import android.transition.ArcMotion; +import android.util.Pair; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AnimationUtils; +import android.widget.ImageButton; +import android.widget.ProgressBar; +import android.widget.TextView; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.resource.drawable.GlideDrawable; +import com.bumptech.glide.request.RequestListener; +import com.bumptech.glide.request.target.Target; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import butterknife.Bind; +import butterknife.ButterKnife; +import io.plaidapp.R; +import io.plaidapp.data.DataLoadingSubject; +import io.plaidapp.data.PlaidItem; +import io.plaidapp.data.PlaidItemComparator; +import io.plaidapp.data.api.designernews.model.Story; +import io.plaidapp.data.api.dribbble.model.Shot; +import io.plaidapp.data.api.producthunt.model.Post; +import io.plaidapp.data.pocket.PocketUtils; +import io.plaidapp.ui.widget.BadgedFourThreeImageView; +import io.plaidapp.util.ObservableColorMatrix; +import io.plaidapp.util.ViewUtils; +import io.plaidapp.util.customtabs.CustomTabActivityHelper; +import io.plaidapp.util.glide.DribbbleTarget; + +/** + * Adapter for the main screen grid of items + */ +public class FeedAdapter extends RecyclerView.Adapter { + + private static final int TYPE_DESIGNER_NEWS_STORY = 0; + private static final int TYPE_DRIBBBLE_SHOT = 1; + private static final int TYPE_PRODUCT_HUNT_POST = 2; + private static final int TYPE_LOADING_MORE = -1; + public static final float DUPE_WEIGHT_BOOST = 0.4f; + + // we need to hold on to an activity ref for the shared element transitions :/ + private final Activity host; + private final LayoutInflater layoutInflater; + private final PlaidItemComparator comparator; + private final boolean pocketIsInstalled; + private @Nullable DataLoadingSubject dataLoading; + + private List items; + + public FeedAdapter(Activity hostActivity, + DataLoadingSubject dataLoading, + boolean pocketInstalled) { + this.host = hostActivity; + this.dataLoading = dataLoading; + this.pocketIsInstalled = pocketInstalled; + layoutInflater = LayoutInflater.from(host); + comparator = new PlaidItemComparator(); + items = new ArrayList<>(); + setHasStableIds(true); + } + + @Override + public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + switch (viewType) { + case TYPE_DESIGNER_NEWS_STORY: + return new DesignerNewsStoryHolder( + layoutInflater.inflate(R.layout.designer_news_story_item, parent, false), + pocketIsInstalled); + case TYPE_DRIBBBLE_SHOT: + return new DribbbleShotHolder( + layoutInflater.inflate(R.layout.dribbble_shot_item, parent, false)); + case TYPE_PRODUCT_HUNT_POST: + return new ProductHuntStoryHolder( + layoutInflater.inflate(R.layout.product_hunt_item, parent, false)); + case TYPE_LOADING_MORE: + return new LoadingMoreHolder( + layoutInflater.inflate(R.layout.infinite_loading, parent, false)); + } + return null; + } + + @Override + public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { + if (position < getDataItemCount() + && getDataItemCount() > 0) { + PlaidItem item = getItem(position); + if (item instanceof Story) { + bindDesignerNewsStory((Story) getItem(position), (DesignerNewsStoryHolder) holder); + } else if (item instanceof Shot) { + bindDribbbleShotView((Shot) item, (DribbbleShotHolder) holder); + } else if (item instanceof Post) { + bindProductHuntPostView((Post) item, (ProductHuntStoryHolder) holder); + } + } else { + bindLoadingViewHolder((LoadingMoreHolder) holder, position); + } + } + + private void bindDesignerNewsStory(final Story story, final DesignerNewsStoryHolder holder) { + holder.title.setText(story.title); + holder.itemView.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View v) { + CustomTabActivityHelper.openCustomTab(host, + DesignerNewsStory.getCustomTabIntent(host, story, null).build(), + Uri.parse(story.url)); + } + } + ); + holder.comments.setText(String.valueOf(story.comment_count)); + holder.comments.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View commentsView) { + final Intent intent = new Intent(); + intent.setClass(host, DesignerNewsStory.class); + intent.putExtra(DesignerNewsStory.EXTRA_STORY, story); + final ActivityOptions options = + ActivityOptions.makeSceneTransitionAnimation(host, + Pair.create(holder.itemView, + host.getString(R.string.transition_story_title_background)), + Pair.create(holder.itemView, + host.getString(R.string.transition_story_background))); + host.startActivity(intent, options.toBundle()); + } + }); + if (pocketIsInstalled) { + holder.pocket.setImageAlpha(178); // grumble... no xml setter, grumble... + holder.pocket.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View view) { + final ImageButton pocketButton = (ImageButton) view; + // actually add to pocket + PocketUtils.addToPocket(host, story.url); + + // setup for anim + holder.itemView.setHasTransientState(true); + ((ViewGroup) pocketButton.getParent().getParent()).setClipChildren(false); + final int initialLeft = pocketButton.getLeft(); + final int initialTop = pocketButton.getTop(); + final int translatedLeft = + (holder.itemView.getWidth() - pocketButton.getWidth()) / 2; + final int translatedTop = + initialTop - ((holder.itemView.getHeight() - pocketButton.getHeight()) / 2); + final ArcMotion arc = new ArcMotion(); + + // animate the title & pocket icon up, scale the pocket icon up + PropertyValuesHolder pvhTitleUp = PropertyValuesHolder.ofFloat(View + .TRANSLATION_Y, -(holder.itemView.getHeight() / 5)); + PropertyValuesHolder pvhTitleFade = PropertyValuesHolder.ofFloat(View.ALPHA, + 0.54f); + Animator titleMoveFadeOut = ObjectAnimator.ofPropertyValuesHolder(holder.title, + pvhTitleUp, pvhTitleFade); + + Animator pocketMoveUp = ObjectAnimator.ofFloat(pocketButton, View + .TRANSLATION_X, View.TRANSLATION_Y, + arc.getPath(initialLeft, initialTop, translatedLeft, translatedTop)); + PropertyValuesHolder pvhPocketScaleUpX = PropertyValuesHolder.ofFloat(View + .SCALE_X, 3f); + PropertyValuesHolder pvhPocketScaleUpY = PropertyValuesHolder.ofFloat(View + .SCALE_Y, 3f); + Animator pocketScaleUp = ObjectAnimator.ofPropertyValuesHolder(pocketButton, + pvhPocketScaleUpX, pvhPocketScaleUpY); + ObjectAnimator pocketFadeUp = ObjectAnimator.ofInt(pocketButton, + ViewUtils.IMAGE_ALPHA, 255); + + AnimatorSet up = new AnimatorSet(); + up.playTogether(titleMoveFadeOut, pocketMoveUp, pocketScaleUp, pocketFadeUp); + up.setDuration(300); + up.setInterpolator(AnimationUtils.loadInterpolator(host, android.R + .interpolator.fast_out_slow_in)); + + // animate everything back into place + PropertyValuesHolder pvhTitleMoveUp = PropertyValuesHolder.ofFloat(View + .TRANSLATION_Y, 0f); + PropertyValuesHolder pvhTitleFadeUp = PropertyValuesHolder.ofFloat(View + .ALPHA, 1f); + Animator titleMoveFadeIn = ObjectAnimator.ofPropertyValuesHolder(holder.title, + pvhTitleMoveUp, pvhTitleFadeUp); + Animator pocketMoveDown = ObjectAnimator.ofFloat(pocketButton, View + .TRANSLATION_X, View.TRANSLATION_Y, + arc.getPath(translatedLeft, translatedTop, 0, 0)); + PropertyValuesHolder pvhPocketScaleDownX = PropertyValuesHolder.ofFloat(View + .SCALE_X, 1f); + PropertyValuesHolder pvhPocketScaleDownY = PropertyValuesHolder.ofFloat(View + .SCALE_Y, 1f); + Animator pvhPocketScaleDown = ObjectAnimator.ofPropertyValuesHolder + (pocketButton, pvhPocketScaleDownX, pvhPocketScaleDownY); + ObjectAnimator pocketFadeDown = ObjectAnimator.ofInt(pocketButton, + ViewUtils.IMAGE_ALPHA, 138); + + AnimatorSet down = new AnimatorSet(); + down.playTogether(titleMoveFadeIn, pocketMoveDown, pvhPocketScaleDown, + pocketFadeDown); + down.setDuration(300); + down.setInterpolator(AnimationUtils.loadInterpolator(host, android.R + .interpolator.fast_out_slow_in)); + down.setStartDelay(500); + + // play it + AnimatorSet upDown = new AnimatorSet(); + upDown.playSequentially(up, down); + + // clean up + upDown.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + ((ViewGroup) pocketButton.getParent().getParent()).setClipChildren + (true); + holder.itemView.setHasTransientState(false); + } + }); + upDown.start(); + } + }); + } + } + + private void bindDribbbleShotView(final Shot shot, final DribbbleShotHolder holder) { + final BadgedFourThreeImageView iv = (BadgedFourThreeImageView) holder.itemView; + Glide.with(host) + .load(shot.images.best()) + .listener(new RequestListener() { + + @Override + public boolean onResourceReady(GlideDrawable resource, String model, + Target target, boolean + isFromMemoryCache, boolean + isFirstResource) { + if (!shot.hasFadedIn) { + iv.setHasTransientState(true); + final ObservableColorMatrix cm = new ObservableColorMatrix(); + ObjectAnimator saturation = ObjectAnimator.ofFloat(cm, + ObservableColorMatrix.SATURATION, 0f, 1f); + saturation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener + () { + @Override + public void onAnimationUpdate(ValueAnimator valueAnimator) { + // just animating the color matrix does not invalidate the + // drawable so need this update listener. Also have to create a + // new CMCF as the matrix is immutable :( + if (iv.getDrawable() != null) { + iv.getDrawable().setColorFilter(new + ColorMatrixColorFilter(cm)); + } + } + }); + saturation.setDuration(2000); + saturation.setInterpolator(AnimationUtils.loadInterpolator(host, + android.R.interpolator.fast_out_slow_in)); + saturation.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + iv.setHasTransientState(false); + } + }); + saturation.start(); + shot.hasFadedIn = true; + } + return false; + } + + @Override + public boolean onException(Exception e, String model, Target + target, boolean isFirstResource) { + return false; + } + }) + // needed to prevent seeing through view as it fades in + .placeholder(R.color.background_dark) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .into(new DribbbleTarget(iv, false)); + + iv.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + iv.setTransitionName(iv.getResources().getString(R.string.transition_shot)); + iv.setBackgroundColor( + ContextCompat.getColor(host, R.color.background_light)); + Intent intent = new Intent(); + intent.setClass(host, DribbbleShot.class); + intent.putExtra(DribbbleShot.EXTRA_SHOT, shot); + ActivityOptions options = + ActivityOptions.makeSceneTransitionAnimation(host, + Pair.create(view, host.getString(R.string.transition_shot)), + Pair.create(view, host.getString(R.string + .transition_shot_background))); + host.startActivity(intent, options.toBundle()); + } + }); + } + + private void bindProductHuntPostView(final Post item, ProductHuntStoryHolder holder) { + holder.title.setText(item.name); + holder.tagline.setText(item.tagline); + holder.comments.setText(String.valueOf(item.comments_count)); + holder.comments.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + CustomTabActivityHelper.openCustomTab( + host, + new CustomTabsIntent.Builder() + .setToolbarColor(ContextCompat.getColor(host, R.color.product_hunt)) + .build(), + Uri.parse(item.discussion_url)); + } + }); + holder.itemView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + CustomTabActivityHelper.openCustomTab( + host, + new CustomTabsIntent.Builder() + .setToolbarColor(ContextCompat.getColor(host, R.color.product_hunt)) + .build(), + Uri.parse(item.redirect_url)); + } + }); + } + + private void bindLoadingViewHolder(LoadingMoreHolder holder, int position) { + // only show the infinite load progress spinner if there are already items in the + // grid i.e. it's not the first item & data is being loaded + holder.progress.setVisibility(position > 0 && dataLoading.isDataLoading() ? + View.VISIBLE : View.INVISIBLE); + } + + @Override + public int getItemViewType(int position) { + if (position < getDataItemCount() + && getDataItemCount() > 0) { + PlaidItem item = getItem(position); + if (item instanceof Story) { + return TYPE_DESIGNER_NEWS_STORY; + } else if (item instanceof Shot) { + return TYPE_DRIBBBLE_SHOT; + } else if (item instanceof Post) { + return TYPE_PRODUCT_HUNT_POST; + } + } + return TYPE_LOADING_MORE; + } + + private PlaidItem getItem(int position) { + return items.get(position); + } + + private void add(PlaidItem item) { + items.add(item); + } + + public void clear() { + items.clear(); + notifyDataSetChanged(); + } + + public void addAndResort(Collection newItems) { + // de-dupe results as the same item can be returned by multiple feeds + boolean add = true; + for (PlaidItem newItem : newItems) { + int count = getDataItemCount(); + for (int i = 0; i < count; i++) { + PlaidItem existingItem = getItem(i); + if (existingItem.equals(newItem)) { + // if we find a dupe mark the weight boost field on the first-in, but don't add + // the dupe. We use the fact that an item comes from multiple sources to indicate it + // is more important and sort it higher + existingItem.weightBoost = DUPE_WEIGHT_BOOST; + add = false; + break; + } + } + if (add) { + add(newItem); + add = true; + } + } + sort(); + } + + protected void sort() { + // calculate the 'weight' for each data type and then sort by that. Each data type has a + // different metric for weighing it e.g. Dribbble uses likes etc. Weights are 'scoped' to + // the page they belong to and lower weights are sorted higher in the grid. + int count = getDataItemCount(); + int maxDesignNewsVotes = 0; + int maxDesignNewsComments = 0; + long maxDribbleLikes = 0; + int maxProductHuntVotes = 0; + int maxProductHuntComments = 0; + + // work out some maximum values to weigh individual items against + for (int i = 0; i < count; i++) { + PlaidItem item = getItem(i); + if (item instanceof Story) { + maxDesignNewsComments = Math.max(((Story) item).comment_count, + maxDesignNewsComments); + maxDesignNewsVotes = Math.max(((Story) item).vote_count, maxDesignNewsVotes); + } else if (item instanceof Shot) { + maxDribbleLikes = Math.max(((Shot) item).likes_count, maxDribbleLikes); + } else if (item instanceof Post) { + maxProductHuntComments = Math.max(((Post) item).comments_count, + maxProductHuntComments); + maxProductHuntVotes = Math.max(((Post) item).votes_count, maxProductHuntVotes); + } + } + + // now go through and set the weight of each item + for (int i = 0; i < count; i++) { + PlaidItem item = getItem(i); + if (item instanceof Story) { + ((Story) item).weigh(maxDesignNewsComments, maxDesignNewsVotes); + } else if (item instanceof Shot) { + ((Shot) item).weigh(maxDribbleLikes); + } else if (item instanceof Post) { + ((Post) item).weigh(maxProductHuntComments, maxProductHuntVotes); + } + // scope it to the page it came from + item.weight += item.page; + } + + // sort by weight + Collections.sort(items, comparator); + notifyDataSetChanged(); // TODO call the more specific RV variants + } + + public void removeDataSource(String dataSource) { + int i = items.size() - 1; + while (i >= 0) { + PlaidItem item = items.get(i); + if (dataSource.equals(item.dataSource)) { + items.remove(i); + } + i--; + } + notifyDataSetChanged(); + } + + @Override + public long getItemId(int position) { + if (getItemViewType(position) == TYPE_LOADING_MORE) { + return -1L; + } + return getItem(position).id; + } + + @Override + public int getItemCount() { + // include loading footer + return getDataItemCount() + 1; + } + + public int getDataItemCount() { + return items.size(); + } + + /* protected */ class DribbbleShotHolder extends RecyclerView.ViewHolder { + + public DribbbleShotHolder(View itemView) { + super(itemView); + } + + } + + /* protected */ class DesignerNewsStoryHolder extends RecyclerView.ViewHolder { + + @Bind(R.id.story_title) TextView title; + @Bind(R.id.story_comments) TextView comments; + @Bind(R.id.pocket) ImageButton pocket; + + public DesignerNewsStoryHolder(View itemView, boolean pocketIsInstalled) { + super(itemView); + ButterKnife.bind(this, itemView); + pocket.setVisibility(pocketIsInstalled ? View.VISIBLE : View.GONE); + } + } + + /* protected */ class ProductHuntStoryHolder extends RecyclerView.ViewHolder { + + @Bind(R.id.hunt_title) TextView title; + @Bind(R.id.tagline) TextView tagline; + @Bind(R.id.story_comments) TextView comments; + + public ProductHuntStoryHolder(View itemView) { + super(itemView); + ButterKnife.bind(this, itemView); + } + } + + /* protected */ class LoadingMoreHolder extends RecyclerView.ViewHolder { + + ProgressBar progress; + + public LoadingMoreHolder(View itemView) { + super(itemView); + progress = (ProgressBar) itemView; + } + + } + +} diff --git a/app/src/main/java/io/plaidapp/ui/FilterAdapter.java b/app/src/main/java/io/plaidapp/ui/FilterAdapter.java new file mode 100644 index 000000000..a81c08778 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/FilterAdapter.java @@ -0,0 +1,310 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.content.ContextCompat; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AnimationUtils; +import android.widget.ImageView; +import android.widget.TextView; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import io.plaidapp.R; +import io.plaidapp.data.Source; +import io.plaidapp.data.prefs.DribbblePrefs; +import io.plaidapp.data.prefs.SourceManager; +import io.plaidapp.ui.recyclerview.ItemTouchHelperAdapter; +import io.plaidapp.util.ColorUtils; +import io.plaidapp.util.ViewUtils; + +/** + * Adapter for showing the list of data sources used as filters for the home grid. + */ +public class FilterAdapter extends RecyclerView.Adapter + implements ItemTouchHelperAdapter, DribbblePrefs.DribbbleLoginStatusListener { + + public interface FilterAuthoriser { + void requestDribbbleAuthorisation(View sharedElement, Source forSource); + } + + private static final int FILTER_ICON_ENABLED_ALPHA = 179; // 70% + private static final int FILTER_ICON_DISABLED_ALPHA = 51; // 20% + + private final List filters; + private final FilterAuthoriser authoriser; + private final Context context; + private @Nullable List listeners; + + public FilterAdapter(@NonNull Context context, + @NonNull List filters, + @NonNull FilterAuthoriser authoriser) { + this.context = context.getApplicationContext(); + this.filters = filters; + this.authoriser = authoriser; + setHasStableIds(true); + } + + public List getFilters() { + return filters; + } + + /** + * Adds a new data source to the list of filters. If the source already exists then it is simply + * activated. + * + * @param toAdd the source to add + * @return whether the filter was added (i.e. if it did not already exist) + */ + public boolean addFilter(Source toAdd) { + // first check if it already exists + final int count = filters.size(); + for (int i = 0; i < count; i++) { + Source existing = filters.get(i); + if (existing.getClass() == toAdd.getClass() + && existing.key.equalsIgnoreCase(toAdd.key)) { + // already exists, just ensure it's active + if (!existing.active) { + existing.active = true; + dispatchFiltersChanged(existing); + notifyItemChanged(i); + SourceManager.updateSource(existing, context); + } + return false; + } + } + // didn't already exist, so add it + filters.add(toAdd); + Collections.sort(filters, new Source.SourceComparator()); + dispatchFiltersChanged(toAdd); + notifyDataSetChanged(); + SourceManager.addSource(toAdd, context); + return true; + } + + public void removeFilter(Source removing) { + int position = filters.indexOf(removing); + filters.remove(position); + notifyItemRemoved(position); + dispatchFilterRemoved(removing); + SourceManager.removeSource(removing, context); + } + + public int getFilterPosition(Source filter) { + return filters.indexOf(filter); + } + + public void enableFilterByKey(@NonNull String key, @NonNull Context context) { + final int count = filters.size(); + for (int i = 0; i < count; i++) { + Source filter = filters.get(i); + if (filter.key.equals(key)) { + if (!filter.active) { + filter.active = true; + notifyItemChanged(i); + dispatchFiltersChanged(filter); + SourceManager.updateSource(filter, context); + return; + } + } + } + } + + @Override + public FilterViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) { + return new FilterViewHolder(LayoutInflater.from(viewGroup.getContext()) + .inflate(R.layout.filter_item, viewGroup, false)); + } + + @Override + public void onBindViewHolder(final FilterViewHolder vh, final int position) { + final Source filter = filters.get(position); + vh.isSwipeable = filter.isSwipeDismissable(); + vh.filterName.setText(filter.name); + vh.filterName.setEnabled(filter.active); + if (filter.iconRes > 0) { + vh.filterIcon.setImageDrawable(vh.itemView.getContext().getDrawable(filter.iconRes)); + } + vh.filterIcon.setImageAlpha(filter.active ? FILTER_ICON_ENABLED_ALPHA : + FILTER_ICON_DISABLED_ALPHA); + vh.itemView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (isAuthorisedDribbbleSource(filter) && + !DribbblePrefs.get(vh.itemView.getContext()).isLoggedIn()) { + authoriser.requestDribbbleAuthorisation(vh.filterIcon, filter); + } else { + vh.itemView.setHasTransientState(true); + ObjectAnimator fade = ObjectAnimator.ofInt(vh.filterIcon, ViewUtils.IMAGE_ALPHA, + filter.active ? FILTER_ICON_DISABLED_ALPHA : FILTER_ICON_ENABLED_ALPHA); + fade.setDuration(300); + fade.setInterpolator(AnimationUtils.loadInterpolator(vh.itemView.getContext() + , android.R.interpolator.fast_out_slow_in)); + fade.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + vh.itemView.setHasTransientState(false); + } + }); + fade.start(); + filter.active = !filter.active; + vh.filterName.setEnabled(filter.active); + notifyItemChanged(position); + SourceManager.updateSource(filter, vh.itemView.getContext()); + dispatchFiltersChanged(filter); + } + } + }); + } + + @Override + public int getItemCount() { + return filters.size(); + } + + @Override + public long getItemId(int position) { + return filters.get(position).key.hashCode(); + } + + private boolean isAuthorisedDribbbleSource(Source source) { + return source.key.equals(SourceManager.SOURCE_DRIBBBLE_FOLLOWING) + || source.key.equals(SourceManager.SOURCE_DRIBBBLE_USER_LIKES) + || source.key.equals(SourceManager.SOURCE_DRIBBBLE_USER_SHOTS); + } + + @Override + public void onItemDismiss(int position) { + Source removing = filters.get(position); + if (removing.isSwipeDismissable()) { + removeFilter(removing); + } + } + + public int getEnabledSourcesCount() { + int count = 0; + for (Source source : filters) { + if (source.active) { + count++; + } + } + return count; + } + + public void addFilterChangedListener(FiltersChangedListener listener) { + if (listeners == null) { + listeners = new ArrayList<>(); + } + listeners.add(listener); + } + + public void removeFilterChangedListener(FiltersChangedListener listener) { + if (listeners != null) { + listeners.remove(listener); + } + } + + private void dispatchFiltersChanged(Source filter) { + if (listeners != null) { + for (FiltersChangedListener listener : listeners) { + listener.onFiltersChanged(filter); + } + } + } + + private void dispatchFilterRemoved(Source filter) { + if (listeners != null) { + for (FiltersChangedListener listener : listeners) { + listener.onFilterRemoved(filter); + } + } + } + + public interface FiltersChangedListener { + void onFiltersChanged(Source changedFilter); + void onFilterRemoved(Source removed); + } + + public static class FilterViewHolder extends RecyclerView.ViewHolder { + + public TextView filterName; + public ImageView filterIcon; + public boolean isSwipeable; + + public FilterViewHolder(View itemView) { + super(itemView); + filterName = (TextView) itemView.findViewById(R.id.filter_name); + filterIcon = (ImageView) itemView.findViewById(R.id.filter_icon); + } + + public void highlightFilter() { + itemView.setHasTransientState(true); + int highlightColor = ContextCompat.getColor(itemView.getContext(), R.color.accent); + int fadeFromTo = ColorUtils.modifyAlpha(highlightColor, 0); + ObjectAnimator background = ObjectAnimator.ofArgb( + itemView, + ViewUtils.BACKGROUND_COLOR, + fadeFromTo, + highlightColor, + fadeFromTo); + background.setDuration(1000L); + background.setInterpolator(AnimationUtils.loadInterpolator(itemView.getContext(), + android.R.interpolator.linear)); + background.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + itemView.setBackground(null); + itemView.setHasTransientState(false); + } + }); + background.start(); + } + } + + @Override + public void onDribbbleLogin() { + // no-op + } + + @Override + public void onDribbbleLogout() { + boolean changed = false; + for (Source filter : filters) { + if (filter.active && isAuthorisedDribbbleSource(filter)) { + filter.active = false; + SourceManager.updateSource(filter, context); + dispatchFiltersChanged(filter); + changed = true; + } + } + if (changed) { + notifyDataSetChanged(); + } + } + +} diff --git a/app/src/main/java/io/plaidapp/ui/HomeActivity.java b/app/src/main/java/io/plaidapp/ui/HomeActivity.java new file mode 100644 index 000000000..a72b654d3 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/HomeActivity.java @@ -0,0 +1,702 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui; + +import android.app.Activity; +import android.app.ActivityManager; +import android.app.ActivityOptions; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Typeface; +import android.graphics.drawable.AnimatedVectorDrawable; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.os.Bundle; +import android.support.annotation.ColorInt; +import android.support.v4.content.ContextCompat; +import android.support.v4.view.GravityCompat; +import android.support.v4.widget.DrawerLayout; +import android.support.v7.widget.GridLayoutManager; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.helper.ItemTouchHelper; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.ForegroundColorSpan; +import android.text.style.ImageSpan; +import android.text.style.StyleSpan; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewStub; +import android.view.WindowInsets; +import android.view.animation.AnimationUtils; +import android.widget.ActionMenuView; +import android.widget.FrameLayout; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; +import android.widget.Toolbar; + +import java.security.InvalidParameterException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import butterknife.Bind; +import butterknife.BindInt; +import butterknife.ButterKnife; +import butterknife.OnClick; +import io.plaidapp.BuildConfig; +import io.plaidapp.R; +import io.plaidapp.data.DataManager; +import io.plaidapp.data.PlaidItem; +import io.plaidapp.data.Source; +import io.plaidapp.data.api.ClientAuthInterceptor; +import io.plaidapp.data.api.designernews.DesignerNewsService; +import io.plaidapp.data.api.designernews.model.NewStoryRequest; +import io.plaidapp.data.api.designernews.model.StoriesResponse; +import io.plaidapp.data.api.designernews.model.Story; +import io.plaidapp.data.pocket.PocketUtils; +import io.plaidapp.data.prefs.DesignerNewsPrefs; +import io.plaidapp.data.prefs.DribbblePrefs; +import io.plaidapp.data.prefs.SourceManager; +import io.plaidapp.ui.recyclerview.FilterTouchHelperCallback; +import io.plaidapp.ui.recyclerview.InfiniteScrollListener; +import io.plaidapp.ui.transitions.FabDialogMorphSetup; +import io.plaidapp.util.AnimUtils; +import io.plaidapp.util.ColorUtils; +import io.plaidapp.util.ViewUtils; +import retrofit.Callback; +import retrofit.RestAdapter; +import retrofit.RetrofitError; +import retrofit.client.Response; + + +public class HomeActivity extends Activity { + + private static final int RC_SEARCH = 0; + private static final int RC_AUTH_DRIBBBLE_FOLLOWING = 1; + private static final int RC_AUTH_DRIBBBLE_USER_LIKES = 2; + private static final int RC_AUTH_DRIBBBLE_USER_SHOTS = 3; + private static final int RC_NEW_DESIGNER_NEWS_STORY = 4; + private static final int RC_NEW_DESIGNER_NEWS_LOGIN = 5; + + @Bind(R.id.drawer) DrawerLayout drawer; + @Bind(R.id.toolbar) Toolbar toolbar; + @Bind(R.id.stories_grid) RecyclerView grid; + @Bind(R.id.fab) ImageButton fab; + @Bind(R.id.filters) RecyclerView filtersList; + @Bind(android.R.id.empty) ProgressBar loading; + private TextView noFiltersEmptyText; + private GridLayoutManager layoutManager; + @BindInt(R.integer.num_columns) int columns; + + // data + private DataManager dataManager; + private FeedAdapter adapter; + private FilterAdapter filtersAdapter; + private DesignerNewsPrefs designerNewsPrefs; + private DribbblePrefs dribbblePrefs; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_home); + ButterKnife.bind(this); + + drawer.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION); + + //toolbar.inflateMenu(R.menu.main); + setActionBar(toolbar); + if (savedInstanceState == null) { + animateToolbar(); + } + + dribbblePrefs = DribbblePrefs.get(this); + designerNewsPrefs = DesignerNewsPrefs.get(this); + filtersAdapter = new FilterAdapter(this, SourceManager.getSources(this), + new FilterAdapter.FilterAuthoriser() { + @Override + public void requestDribbbleAuthorisation(View sharedElemeent, Source forSource) { + Intent login = new Intent(HomeActivity.this, DribbbleLogin.class); + login.putExtra(FabDialogMorphSetup.EXTRA_SHARED_ELEMENT_START_COLOR, + ContextCompat.getColor(HomeActivity.this, R.color.background_dark)); + ActivityOptions options = + ActivityOptions.makeSceneTransitionAnimation(HomeActivity.this, + sharedElemeent, getString(R.string.transition_dribbble_login)); + startActivityForResult(login, + getAuthSourceRequestCode(forSource), options.toBundle()); + } + }); + dataManager = new DataManager(this, filtersAdapter) { + @Override + public void onDataLoaded(List data) { + adapter.addAndResort(data); + checkEmptyState(); + } + }; + adapter = new FeedAdapter(this, dataManager, PocketUtils.isPocketInstalled(this)); + grid.setAdapter(adapter); + layoutManager = new GridLayoutManager(this, columns); + layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() { + @Override + public int getSpanSize(int position) { + return position == adapter.getDataItemCount() ? columns : 1; + } + }); + grid.setLayoutManager(layoutManager); + grid.addOnScrollListener(gridScroll); + grid.addOnScrollListener(new InfiniteScrollListener(layoutManager, dataManager) { + @Override + public void onLoadMore() { + dataManager.loadAllDataSources(); + } + }); + grid.setHasFixedSize(true); + + // drawer layout treats fitsSystemWindows specially so we have to handle insets ourselves + drawer.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() { + @Override + public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { + // inset the toolbar down by the status bar height + ViewGroup.MarginLayoutParams lpToolbar = (ViewGroup.MarginLayoutParams) toolbar + .getLayoutParams(); + lpToolbar.topMargin += insets.getSystemWindowInsetTop(); + lpToolbar.rightMargin += insets.getSystemWindowInsetRight(); + toolbar.setLayoutParams(lpToolbar); + + // inset the grid top by statusbar+toolbar & the bottom by the navbar (don't clip) + grid.setPadding(grid.getPaddingLeft(), + insets.getSystemWindowInsetTop() + ViewUtils.getActionBarSize + (HomeActivity.this), + grid.getPaddingRight() + insets.getSystemWindowInsetRight(), // landscape + grid.getPaddingBottom()); + + // inset the fab for the navbar + ViewGroup.MarginLayoutParams lpFab = (ViewGroup.MarginLayoutParams) fab + .getLayoutParams(); + lpFab.bottomMargin += insets.getSystemWindowInsetBottom(); // portrait + lpFab.rightMargin += insets.getSystemWindowInsetRight(); // landscape + fab.setLayoutParams(lpFab); + + // we place a background behind the status bar to combine with it's semi-transparent + // color to get the desired appearance. Set it's height to the status bar height + View statusBarBackground = findViewById(R.id.status_bar_background); + FrameLayout.LayoutParams lpStatus = (FrameLayout.LayoutParams) + statusBarBackground.getLayoutParams(); + lpStatus.height = insets.getSystemWindowInsetTop(); + statusBarBackground.setLayoutParams(lpStatus); + + // inset the filters list for the status bar / navbar + // need to set the padding end for landscape case + final boolean ltr = filtersList.getLayoutDirection() == View.LAYOUT_DIRECTION_LTR; + filtersList.setPaddingRelative(filtersList.getPaddingStart(), + filtersList.getPaddingTop() + insets.getSystemWindowInsetTop(), + filtersList.getPaddingEnd() + (ltr ? insets.getSystemWindowInsetRight() : 0), + filtersList.getPaddingBottom() + insets.getSystemWindowInsetBottom()); + + // clear this listener so insets aren't re-applied + drawer.setOnApplyWindowInsetsListener(null); + + return insets.consumeSystemWindowInsets(); + } + }); + setupTaskDescription(); + + filtersList.setAdapter(filtersAdapter); + filtersAdapter.addFilterChangedListener(filtersChangedListener); + filtersAdapter.addFilterChangedListener(dataManager); + dataManager.loadAllDataSources(); + ItemTouchHelper.Callback callback = new FilterTouchHelperCallback(filtersAdapter); + ItemTouchHelper itemTouchHelper = new ItemTouchHelper(callback); + itemTouchHelper.attachToRecyclerView(filtersList); + checkEmptyState(); + checkConnectivity(); + } + + // listener for notifying adapter when data sources are deactivated + private FilterAdapter.FiltersChangedListener filtersChangedListener = + new FilterAdapter.FiltersChangedListener() { + @Override + public void onFiltersChanged(Source changedFilter) { + if (!changedFilter.active) { + adapter.removeDataSource(changedFilter.key); + } + checkEmptyState(); + } + + @Override + public void onFilterRemoved(Source removed) { + adapter.removeDataSource(removed.key); + checkEmptyState(); + } + }; + + private int gridScrollY = 0; + private RecyclerView.OnScrollListener gridScroll = new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(RecyclerView recyclerView, int dx, int dy) { + gridScrollY += dy; + if (gridScrollY > 0 && toolbar.getTranslationZ() != -1f) { + toolbar.setTranslationZ(-1f); + } else if (gridScrollY == 0 && toolbar.getTranslationZ() != 0) { + toolbar.setTranslationZ(0f); + } + } + }; + + @OnClick(R.id.fab) + protected void fabClick() { + if (designerNewsPrefs.isLoggedIn()) { + Intent intent = new Intent(this, PostNewDesignerNewsStory.class); + intent.putExtra(FabDialogMorphSetup.EXTRA_SHARED_ELEMENT_START_COLOR, + ContextCompat.getColor(this, R.color.accent)); + ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation(this, fab, + getString(R.string.transition_new_designer_news_post)); + startActivityForResult(intent, RC_NEW_DESIGNER_NEWS_STORY, options.toBundle()); + } else { + Intent intent = new Intent(this, DesignerNewsLogin.class); + intent.putExtra(FabDialogMorphSetup.EXTRA_SHARED_ELEMENT_START_COLOR, + ContextCompat.getColor(this, R.color.accent)); + ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation(this, fab, + getString(R.string.transition_designer_news_login)); + startActivityForResult(intent, RC_NEW_DESIGNER_NEWS_LOGIN, options.toBundle()); + } + } + + private void checkEmptyState() { + if (adapter.getDataItemCount() == 0) { + // if grid is empty check whether we're loading or if no filters are selected + if (filtersAdapter.getEnabledSourcesCount() > 0) { + loading.setVisibility(View.VISIBLE); + setNoFiltersEmptyTextVisibility(View.GONE); + } else { + loading.setVisibility(View.GONE); + setNoFiltersEmptyTextVisibility(View.VISIBLE); + } + // ensure grid scroll tracking/toolbar z-order is reset + gridScrollY = 0; + toolbar.setTranslationZ(0f); + } else { + loading.setVisibility(View.GONE); + setNoFiltersEmptyTextVisibility(View.GONE); + } + } + + private void setNoFiltersEmptyTextVisibility(int visibility) { + if (visibility == View.VISIBLE) { + if (noFiltersEmptyText == null) { + // create the no filters empty text + ViewStub stub = (ViewStub) findViewById(R.id.stub_no_filters); + noFiltersEmptyText = (TextView) stub.inflate(); + String emptyText = getString(R.string.no_filters_selected); + int filterPlaceholderStart = emptyText.indexOf('\u08B4'); + int altMethodStart = filterPlaceholderStart + 3; + SpannableStringBuilder ssb = new SpannableStringBuilder(emptyText); + // show an image of the filter icon + ssb.setSpan(new ImageSpan(this, R.drawable.ic_filter_small, + ImageSpan.ALIGN_BASELINE), + filterPlaceholderStart, + filterPlaceholderStart + 1, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + // make the alt method (swipe from right) less prominent and italic + ssb.setSpan(new ForegroundColorSpan( + ContextCompat.getColor(this, R.color.text_secondary_light)), + altMethodStart, + emptyText.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + ssb.setSpan(new StyleSpan(Typeface.ITALIC), + altMethodStart, + emptyText.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + noFiltersEmptyText.setText(ssb); + noFiltersEmptyText.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + drawer.openDrawer(GravityCompat.END); + } + }); + } + noFiltersEmptyText.setVisibility(visibility); + } else if (noFiltersEmptyText != null) { + noFiltersEmptyText.setVisibility(visibility); + } + + } + + private void setupTaskDescription() { + // set a silhouette icon in overview as the launcher icon is a bit busy + // and looks bad on top of colorPrimary + //Bitmap overviewIcon = ImageUtils.vectorToBitmap(this, R.drawable.ic_launcher_silhouette); + // TODO replace launcher icon with a monochrome version from RN. + Bitmap overviewIcon = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher); + setTaskDescription(new ActivityManager.TaskDescription(getString(R.string.app_name), + overviewIcon, + ContextCompat.getColor(this, R.color.primary))); + overviewIcon.recycle(); + } + + @Override + protected void onResume() { + super.onResume(); + dribbblePrefs.addLoginStatusListener(dataManager); + dribbblePrefs.addLoginStatusListener(filtersAdapter); + } + + @Override + protected void onPause() { + dribbblePrefs.removeLoginStatusListener(dataManager); + dribbblePrefs.removeLoginStatusListener(filtersAdapter); + super.onPause(); + } + + private void animateToolbar() { + // this is gross but toolbar doesn't expose it's children to animate them :( + View t = toolbar.getChildAt(0); + if (t != null && t instanceof TextView) { + TextView title = (TextView) t; + + // fade in and space out the title. Animating the letterSpacing performs horribly so + // fake it by setting the desired letterSpacing then animating the scaleX ¯\_(ツ)_/¯ + title.setAlpha(0f); + title.setScaleX(0.8f); + + title.animate() + .alpha(1f) + .scaleX(1f) + .setStartDelay(300) + .setDuration(900) + .setInterpolator(AnimUtils.getMaterialInterpolator(this)); + } + View amv = toolbar.getChildAt(1); + if (amv != null & amv instanceof ActionMenuView) { + ActionMenuView actions = (ActionMenuView) amv; + popAnim(actions.getChildAt(0), 500, 200); // filter + popAnim(actions.getChildAt(1), 700, 200); // overflow + } + } + + private void popAnim(View v, int startDelay, int duration) { + if (v != null) { + v.setAlpha(0f); + v.setScaleX(0f); + v.setScaleY(0f); + + v.animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .setStartDelay(startDelay) + .setDuration(duration) + .setInterpolator(AnimationUtils.loadInterpolator(this, android.R.interpolator + .overshoot)); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.main, menu); + return true; + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + MenuItem dribbbleLogin = menu.findItem(R.id.menu_dribbble_login); + if (dribbbleLogin != null) { + dribbbleLogin.setTitle(dribbblePrefs.isLoggedIn() ? R.string.dribbble_log_out : R + .string.dribbble_login); + } + MenuItem designerNewsLogin = menu.findItem(R.id.menu_designer_news_login); + if (designerNewsLogin != null) { + designerNewsLogin.setTitle(designerNewsPrefs.isLoggedIn() ? R.string + .designer_news_log_out : R.string.designer_news_login); + } + + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_filter: + drawer.openDrawer(GravityCompat.END); + return true; + case R.id.menu_search: + // get the icon's location on screen to pass through to the search screen + View searchMenuView = toolbar.findViewById(R.id.menu_search); + int[] loc = new int[2]; + searchMenuView.getLocationOnScreen(loc); + startActivityForResult(SearchActivity.createStartIntent(this, loc[0], loc[0] + + (searchMenuView.getWidth() / 2)), RC_SEARCH, ActivityOptions + .makeSceneTransitionAnimation(this).toBundle()); + searchMenuView.setAlpha(0f); + return true; + case R.id.menu_dribbble_login: + if (!dribbblePrefs.isLoggedIn()) { + dribbblePrefs.login(HomeActivity.this); + } else { + dribbblePrefs.logout(); + // TODO something better than a toast!! + Toast.makeText(getApplicationContext(), R.string.dribbble_logged_out, Toast + .LENGTH_SHORT).show(); + } + return true; + case R.id.menu_designer_news_login: + if (!designerNewsPrefs.isLoggedIn()) { + startActivity(new Intent(this, DesignerNewsLogin.class)); + } else { + designerNewsPrefs.logout(); + // TODO something better than a toast!! + Toast.makeText(getApplicationContext(), R.string.designer_news_logged_out, + Toast.LENGTH_SHORT).show(); + } + return true; + case R.id.menu_about: + startActivity(new Intent(HomeActivity.this, AboutActivity.class)); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + switch (requestCode) { + case RC_SEARCH: + // reset the search icon which we hid + View searchMenuView = toolbar.findViewById(R.id.menu_search); + if (searchMenuView != null) { + searchMenuView.setAlpha(1f); + } + if (resultCode == SearchActivity.RESULT_CODE_SAVE) { + String query = data.getStringExtra(SearchActivity.EXTRA_QUERY); + if (TextUtils.isEmpty(query)) return; + Source dribbbleSearch = null; + Source designerNewsSearch = null; + boolean newSource = false; + if (data.getBooleanExtra(SearchActivity.EXTRA_SAVE_DRIBBBLE, false)) { + dribbbleSearch = new Source.DribbbleSearchSource(query, true); + newSource |= filtersAdapter.addFilter(dribbbleSearch); + } + if (data.getBooleanExtra(SearchActivity.EXTRA_SAVE_DESIGNER_NEWS, false)) { + designerNewsSearch = new Source.DesignerNewsSearchSource(query, true); + newSource |= filtersAdapter.addFilter(designerNewsSearch); + } + if (newSource && (dribbbleSearch != null || designerNewsSearch != null)) { + highlightNewSources(dribbbleSearch, designerNewsSearch); + } + } + break; + case RC_NEW_DESIGNER_NEWS_STORY: + if (resultCode == PostNewDesignerNewsStory.RESULT_DRAG_DISMISSED) { + // need to reshow the FAB as there's no shared element transition + showFab(); + } else if (resultCode == PostNewDesignerNewsStory.RESULT_POST) { + String title = data.getStringExtra(PostNewDesignerNewsStory.EXTRA_STORY_TITLE); + String url = data.getStringExtra(PostNewDesignerNewsStory.EXTRA_STORY_URL); + String comment = data.getStringExtra( + PostNewDesignerNewsStory.EXTRA_STORY_COMMENT); + if (!TextUtils.isEmpty(title)) { + NewStoryRequest storyToPost = null; + if (!TextUtils.isEmpty(url)) { + storyToPost = NewStoryRequest.createWithUrl(title, url); + } else if (!TextUtils.isEmpty(comment)) { + storyToPost = NewStoryRequest.createWithComment(title, comment); + } + if (storyToPost != null) { + // TODO: move this to a service in follow up CL? + DesignerNewsService designerNewsApi = new RestAdapter.Builder() + .setEndpoint(DesignerNewsService.ENDPOINT) + .setRequestInterceptor(new ClientAuthInterceptor + (designerNewsPrefs.getAccessToken(), + BuildConfig.DESIGNER_NEWS_CLIENT_ID)) + .build() + .create(DesignerNewsService.class); + designerNewsApi.postStory(storyToPost, new Callback() { + @Override + public void success(StoriesResponse story, Response response) { + if (story != null + && story.stories != null + && story.stories.size() > 0) { + long id = story.stories.get(0).id; + } + } + + @Override + public void failure(RetrofitError error) { + Log.e("HomeActivity", "Failed posting story", error); + } + }); + } + } + } + break; + case RC_NEW_DESIGNER_NEWS_LOGIN: + if (resultCode == RESULT_OK) { + showFab(); + } + break; + case RC_AUTH_DRIBBBLE_FOLLOWING: + if (resultCode == RESULT_OK) { + filtersAdapter.enableFilterByKey(SourceManager.SOURCE_DRIBBBLE_FOLLOWING, this); + } + break; + case RC_AUTH_DRIBBBLE_USER_LIKES: + if (resultCode == RESULT_OK) { + filtersAdapter.enableFilterByKey( + SourceManager.SOURCE_DRIBBBLE_USER_LIKES, this); + } + break; + case RC_AUTH_DRIBBBLE_USER_SHOTS: + if (resultCode == RESULT_OK) { + filtersAdapter.enableFilterByKey( + SourceManager.SOURCE_DRIBBBLE_USER_SHOTS, this); + } + break; + } + } + + private void showFab() { + fab.setAlpha(0f); + fab.setScaleX(0f); + fab.setScaleY(0f); + fab.setTranslationY(fab.getHeight() / 2); + fab.animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .translationY(0f) + .setDuration(300L) + .setInterpolator(AnimationUtils.loadInterpolator(this, android.R.interpolator + .linear_out_slow_in)) + .start(); + } + + /** + * Highlight the new item by: + * 1. opening the drawer + * 2. scrolling it into view + * 3. flashing it's background + * 4. closing the drawer + */ + private void highlightNewSources(final Source... sources) { + final Runnable closeDrawerRunnable = new Runnable() { + @Override + public void run() { + drawer.closeDrawer(GravityCompat.END); + } + }; + drawer.setDrawerListener(new DrawerLayout.SimpleDrawerListener() { + + // if the user interacts with the filters while it's open then don't auto-close + private final View.OnTouchListener filtersTouch = new View.OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent event) { + drawer.removeCallbacks(closeDrawerRunnable); + return false; + } + }; + + @Override + public void onDrawerOpened(View drawerView) { + // scroll to the new item(s) and highlight them + List filterPositions = new ArrayList<>(sources.length); + for (Source source : sources) { + if (source != null) { + filterPositions.add(filtersAdapter.getFilterPosition(source)); + } + } + int scrollTo = Collections.max(filterPositions); + filtersList.smoothScrollToPosition(scrollTo); + for (int position : filterPositions) { + FilterAdapter.FilterViewHolder holder = (FilterAdapter.FilterViewHolder) + filtersList.findViewHolderForAdapterPosition(position); + if (holder != null) { + // this is failing for the first saved search, then working for subsequent calls + // TODO work out why! + holder.highlightFilter(); + } + } + filtersList.setOnTouchListener(filtersTouch); + } + + @Override + public void onDrawerClosed(View drawerView) { + // reset + filtersList.setOnTouchListener(null); + } + + @Override + public void onDrawerStateChanged(int newState) { + // if the user interacts with the drawer manually then don't auto-close + if (newState == DrawerLayout.STATE_DRAGGING) { + drawer.removeCallbacks(closeDrawerRunnable); + } + } + }); + drawer.openDrawer(GravityCompat.END); + drawer.postDelayed(closeDrawerRunnable, 2000); + } + + @Override + public void onBackPressed() { + if (drawer.isDrawerOpen(GravityCompat.END)) { + drawer.closeDrawer(GravityCompat.END); + } else { + super.onBackPressed(); + } + } + + private void checkConnectivity() { + ConnectivityManager connectivityManager + = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo(); + boolean connected = activeNetworkInfo != null && activeNetworkInfo.isConnected(); + if (!connected) { + loading.setVisibility(View.GONE); + ViewStub stub = (ViewStub) findViewById(R.id.stub_no_connection); + ImageView iv = (ImageView) stub.inflate(); + final AnimatedVectorDrawable avd = + (AnimatedVectorDrawable) getDrawable(R.drawable.avd_no_connection); + iv.setImageDrawable(avd); + avd.start(); + } + } + + private int getAuthSourceRequestCode(Source filter) { + switch (filter.key) { + case SourceManager.SOURCE_DRIBBBLE_FOLLOWING: + return RC_AUTH_DRIBBBLE_FOLLOWING; + case SourceManager.SOURCE_DRIBBBLE_USER_LIKES: + return RC_AUTH_DRIBBBLE_USER_LIKES; + case SourceManager.SOURCE_DRIBBBLE_USER_SHOTS: + return RC_AUTH_DRIBBBLE_USER_SHOTS; + } + throw new InvalidParameterException(); + } +} diff --git a/app/src/main/java/io/plaidapp/ui/PostNewDesignerNewsStory.java b/app/src/main/java/io/plaidapp/ui/PostNewDesignerNewsStory.java new file mode 100644 index 000000000..e8a5cf187 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/PostNewDesignerNewsStory.java @@ -0,0 +1,176 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.support.design.widget.TextInputLayout; +import android.text.TextUtils; +import android.view.KeyEvent; +import android.view.ViewGroup; +import android.view.animation.AnimationUtils; +import android.view.inputmethod.EditorInfo; +import android.widget.Button; +import android.widget.EditText; +import android.widget.TextView; + +import butterknife.Bind; +import butterknife.BindDimen; +import butterknife.ButterKnife; +import butterknife.OnClick; +import butterknife.OnEditorAction; +import butterknife.OnTextChanged; +import io.plaidapp.R; +import io.plaidapp.ui.transitions.FabDialogMorphSetup; +import io.plaidapp.ui.widget.BottomSheet; +import io.plaidapp.ui.widget.ObservableScrollView; +import io.plaidapp.util.ImeUtils; + +public class PostNewDesignerNewsStory extends Activity { + + public static final String EXTRA_STORY_TITLE = + "EXTRA_STORY_TITLE"; + public static final String EXTRA_STORY_URL = + "EXTRA_STORY_URL"; + public static final String EXTRA_STORY_COMMENT = + "EXTRA_STORY_COMMENT"; + public static final int RESULT_POST = 2; + public static final int RESULT_DRAG_DISMISSED = 3; + + @Bind(R.id.bottom_sheet) BottomSheet bottomSheet; + @Bind(R.id.bottom_sheet_content) ViewGroup bottomSheetContent; + @Bind(R.id.title) TextView sheetTitle; + @Bind(R.id.scroll_container) ObservableScrollView scrollContainer; + @Bind(R.id.new_story_title) EditText title; + @Bind(R.id.new_story_url_label) TextInputLayout urlLabel; + @Bind(R.id.new_story_url) EditText url; + @Bind(R.id.new_story_comment_label) TextInputLayout commentLabel; + @Bind(R.id.new_story_comment) EditText comment; + @Bind(R.id.new_story_post) Button post; + @BindDimen(R.dimen.z_app_bar) float appBarElevation; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_post_new_designer_news_story); + ButterKnife.bind(this); + FabDialogMorphSetup.setupSharedEelementTransitions(this, bottomSheetContent, 0); + + bottomSheet.addListener(new BottomSheet.Listener() { + @Override + public void onDragDismissed() { + // After a drag dismiss, finish without the shared element return transition as + // it no longer makes sense. Let the launching window know it's a drag dismiss so + // that it can restore any UI used as an entering shared element + setResult(RESULT_DRAG_DISMISSED); + finish(); + } + + @Override + public void onDrag(int top) { /* no-op */ } + }); + + scrollContainer.setListener(new ObservableScrollView.OnScrollListener() { + @Override + public void onScrolled(int scrollY) { + if (scrollY != 0 + && sheetTitle.getTranslationZ() != appBarElevation) { + sheetTitle.animate() + .translationZ(appBarElevation) + .setStartDelay(0L) + .setDuration(80L) + .setInterpolator(AnimationUtils.loadInterpolator + (PostNewDesignerNewsStory.this, android.R.interpolator + .fast_out_slow_in)) + .start(); + } else if (scrollY == 0 && sheetTitle.getTranslationZ() == appBarElevation) { + sheetTitle.animate() + .translationZ(0f) + .setStartDelay(0L) + .setDuration(80L) + .setInterpolator(AnimationUtils.loadInterpolator + (PostNewDesignerNewsStory.this, android.R.interpolator + .fast_out_slow_in)) + .start(); + } + } + }); + } + + @Override + protected void onPause() { + // customize window animations + overridePendingTransition(0, R.anim.fade_out_rapidly); + super.onPause(); + } + + @OnClick(R.id.bottom_sheet) + protected void dismiss() { + finishAfterTransition(); + } + + @OnTextChanged(R.id.new_story_title) + protected void titleTextChanged(CharSequence text) { + setPostButtonState(); + } + + @OnTextChanged(R.id.new_story_url) + protected void urlTextChanged(CharSequence text) { + final boolean emptyUrl = TextUtils.isEmpty(text); + comment.setEnabled(emptyUrl); + commentLabel.setEnabled(emptyUrl); + comment.setFocusableInTouchMode(emptyUrl); + setPostButtonState(); + } + + @OnTextChanged(R.id.new_story_comment) + protected void commentTextChanged(CharSequence text) { + final boolean emptyComment = TextUtils.isEmpty(text); + url.setEnabled(emptyComment); + urlLabel.setEnabled(emptyComment); + url.setFocusableInTouchMode(emptyComment); + setPostButtonState(); + } + + @OnClick(R.id.new_story_post) + protected void postNewStory() { + ImeUtils.hideIme(title); + Intent data = new Intent(); + data.putExtra(EXTRA_STORY_TITLE, title.getText().toString()); + data.putExtra(EXTRA_STORY_URL, url.getText().toString()); + data.putExtra(EXTRA_STORY_COMMENT, comment.getText().toString()); + setResult(RESULT_POST, data); + finishAfterTransition(); + } + + private void setPostButtonState() { + post.setEnabled(!TextUtils.isEmpty(title.getText()) + && (!TextUtils.isEmpty(url.getText()) + || !TextUtils.isEmpty(comment.getText()))); + } + + @OnEditorAction({ R.id.new_story_url, R.id.new_story_comment }) + protected boolean onEditorAction(TextView textView, int actionId, KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_SEND) { + postNewStory(); + return true; + } + return false; + } + +} diff --git a/app/src/main/java/io/plaidapp/ui/SearchActivity.java b/app/src/main/java/io/plaidapp/ui/SearchActivity.java new file mode 100644 index 000000000..207771f54 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/SearchActivity.java @@ -0,0 +1,554 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.app.Activity; +import android.app.SearchManager; +import android.content.Context; +import android.content.Intent; +import android.graphics.Color; +import android.graphics.Typeface; +import android.graphics.drawable.AnimatedVectorDrawable; +import android.os.Bundle; +import android.support.v4.content.ContextCompat; +import android.support.v7.widget.GridLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.text.InputType; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.StyleSpan; +import android.transition.Transition; +import android.transition.TransitionInflater; +import android.transition.TransitionManager; +import android.util.TypedValue; +import android.view.View; +import android.view.ViewAnimationUtils; +import android.view.ViewGroup; +import android.view.ViewStub; +import android.view.ViewTreeObserver; +import android.view.animation.AnimationUtils; +import android.view.inputmethod.EditorInfo; +import android.widget.CheckBox; +import android.widget.ImageButton; +import android.widget.ProgressBar; +import android.widget.SearchView; + +import java.util.List; + +import butterknife.Bind; +import butterknife.BindDimen; +import butterknife.BindInt; +import butterknife.ButterKnife; +import butterknife.OnClick; +import io.plaidapp.R; +import io.plaidapp.data.PlaidItem; +import io.plaidapp.data.SearchDataManager; +import io.plaidapp.data.pocket.PocketUtils; +import io.plaidapp.ui.recyclerview.InfiniteScrollListener; +import io.plaidapp.ui.widget.BaselineGridTextView; +import io.plaidapp.util.ImeUtils; +import io.plaidapp.util.ViewUtils; + +public class SearchActivity extends Activity { + + public static final String EXTRA_MENU_LEFT = "EXTRA_MENU_LEFT"; + public static final String EXTRA_MENU_CENTER_X = "EXTRA_MENU_CENTER_X"; + public static final String EXTRA_QUERY = "EXTRA_QUERY"; + public static final String EXTRA_SAVE_DRIBBBLE = "EXTRA_SAVE_DRIBBBLE"; + public static final String EXTRA_SAVE_DESIGNER_NEWS = "EXTRA_SAVE_DESIGNER_NEWS"; + public static final int RESULT_CODE_SAVE = 7; + + @Bind(R.id.searchback) ImageButton searchBack; + @Bind(R.id.searchback_container) ViewGroup searchBackContainer; + @Bind(R.id.search_view) SearchView searchView; + @Bind(R.id.search_background) View searchBackground; + @Bind(android.R.id.empty) ProgressBar progress; + @Bind(R.id.search_results) RecyclerView results; + @Bind(R.id.container) ViewGroup container; + @Bind(R.id.search_toolbar) ViewGroup searchToolbar; + @Bind(R.id.results_container) ViewGroup resultsContainer; + @Bind(R.id.fab) ImageButton fab; + @Bind(R.id.confirm_save_container) ViewGroup confirmSaveContainer; + @Bind(R.id.save_dribbble) CheckBox saveDribbble; + @Bind(R.id.save_designer_news) CheckBox saveDesignerNews; + @Bind(R.id.scrim) View scrim; + @Bind(R.id.results_scrim) View resultsScrim; + private BaselineGridTextView noResults; + @BindInt(R.integer.num_columns) int columns; + @BindDimen(R.dimen.z_app_bar) float appBarElevation; + private Transition auto; + + private int searchBackDistanceX; + private int searchIconCenterX; + private SearchDataManager dataManager; + private FeedAdapter adapter; + + public static Intent createStartIntent(Context context, int menuIconLeft, int menuIconCenterX) { + Intent starter = new Intent(context, SearchActivity.class); + starter.putExtra(EXTRA_MENU_LEFT, menuIconLeft); + starter.putExtra(EXTRA_MENU_CENTER_X, menuIconCenterX); + return starter; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_search); + ButterKnife.bind(this); + setupSearchView(); + auto = TransitionInflater.from(this).inflateTransition(R.transition.auto); + + dataManager = new SearchDataManager(this) { + @Override + public void onDataLoaded(List data) { + if (data != null && data.size() > 0) { + if (results.getVisibility() != View.VISIBLE) { + TransitionManager.beginDelayedTransition(container, auto); + progress.setVisibility(View.GONE); + results.setVisibility(View.VISIBLE); + fab.setVisibility(View.VISIBLE); + fab.setAlpha(0.6f); + fab.setScaleX(0f); + fab.setScaleY(0f); + fab.animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .setStartDelay(800L) + .setDuration(300L) + .setInterpolator(AnimationUtils.loadInterpolator(SearchActivity + .this, android.R.interpolator.linear_out_slow_in)); + } + adapter.addAndResort(data); + } else { + TransitionManager.beginDelayedTransition(container, auto); + progress.setVisibility(View.GONE); + setNoResultsVisibility(View.VISIBLE); + } + } + }; + adapter = new FeedAdapter(this, dataManager, PocketUtils.isPocketInstalled(this)); + results.setAdapter(adapter); + GridLayoutManager layoutManager = new GridLayoutManager(this, columns); + layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() { + @Override + public int getSpanSize(int position) { + return position == adapter.getDataItemCount() ? columns : 1; + } + }); + results.setLayoutManager(layoutManager); + results.addOnScrollListener(new InfiniteScrollListener(layoutManager, dataManager) { + @Override + public void onLoadMore() { + dataManager.loadMore(); + } + }); + results.setHasFixedSize(true); + results.addOnScrollListener(gridScroll); + + // extract the search icon's location passed from the launching activity, minus 4dp to + // compensate for different paddings in the views + searchBackDistanceX = getIntent().getIntExtra(EXTRA_MENU_LEFT, 0) - (int) TypedValue + .applyDimension(TypedValue.COMPLEX_UNIT_DIP, 4, getResources().getDisplayMetrics()); + searchIconCenterX = getIntent().getIntExtra(EXTRA_MENU_CENTER_X, 0); + + // translate icon to match the launching screen then animate back into position + searchBackContainer.setTranslationX(searchBackDistanceX); + searchBackContainer.animate() + .translationX(0f) + .setDuration(650L) + .setInterpolator(AnimationUtils.loadInterpolator(this, + android.R.interpolator.fast_out_slow_in)); + // transform from search icon to back icon + AnimatedVectorDrawable searchToBack = (AnimatedVectorDrawable) ContextCompat + .getDrawable(this, R.drawable.avd_search_to_back); + searchBack.setImageDrawable(searchToBack); + searchToBack.start(); + // for some reason the animation doesn't always finish (leaving a part arrow!?) so after + // the animation set a static drawable. Also animation callbacks weren't added until API23 + // so using post delayed :( + // TODO fix properly!! + searchBack.postDelayed(new Runnable() { + @Override + public void run() { + searchBack.setImageDrawable(ContextCompat.getDrawable(SearchActivity.this, + R.drawable.ic_arrow_back_padded)); + } + }, 600); + + // fade in the other search chrome + searchBackground.animate() + .alpha(1f) + .setDuration(300L) + .setInterpolator(AnimationUtils.loadInterpolator(this, + android.R.interpolator.linear_out_slow_in)); + searchView.animate() + .alpha(1f) + .setStartDelay(400L) + .setDuration(400L) + .setInterpolator(AnimationUtils.loadInterpolator(this, + android.R.interpolator.linear_out_slow_in)) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + searchView.requestFocus(); + ImeUtils.showIme(searchView); + } + }); + + // animate in a scrim over the content behind + scrim.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + scrim.getViewTreeObserver().removeOnPreDrawListener(this); + AnimatorSet showScrim = new AnimatorSet(); + showScrim.playTogether( + ViewAnimationUtils.createCircularReveal( + scrim, + searchIconCenterX, + searchBackground.getBottom(), + 0, + (float) Math.hypot(searchBackDistanceX, scrim.getHeight() + - searchBackground.getBottom())), + ObjectAnimator.ofArgb( + scrim, + ViewUtils.BACKGROUND_COLOR, + Color.TRANSPARENT, + ContextCompat.getColor(SearchActivity.this, R.color.scrim))); + showScrim.setDuration(400L); + showScrim.setInterpolator(AnimationUtils.loadInterpolator(SearchActivity.this, + android.R.interpolator.linear_out_slow_in)); + showScrim.start(); + return false; + } + }); + onNewIntent(getIntent()); + } + + @Override + protected void onNewIntent(Intent intent) { + if (intent.hasExtra(SearchManager.QUERY)) { + String query = intent.getStringExtra(SearchManager.QUERY); + if (!TextUtils.isEmpty(query)) { + searchView.setQuery(query, false); + searchFor(query); + } + } + } + + @Override + public void onBackPressed() { + if (confirmSaveContainer.getVisibility() == View.VISIBLE) { + hideSaveConfimation(); + } else { + dismiss(); + } + } + + @Override + protected void onPause() { + // needed to suppress the default window animation when closing the activity + overridePendingTransition(0, 0); + super.onPause(); + } + + @OnClick({ R.id.scrim, R.id.searchback }) + protected void dismiss() { + // translate the icon to match position in the launching activity + searchBackContainer.animate() + .translationX(searchBackDistanceX) + .setDuration(600L) + .setInterpolator(AnimationUtils.loadInterpolator(this, + android.R.interpolator.fast_out_slow_in)) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + finishAfterTransition(); + } + }) + .start(); + // transform from back icon to search icon + AnimatedVectorDrawable backToSearch = (AnimatedVectorDrawable) ContextCompat + .getDrawable(this, R.drawable.avd_back_to_search); + searchBack.setImageDrawable(backToSearch); + // clear the background else the touch ripple moves with the translation which looks bad + searchBack.setBackground(null); + backToSearch.start(); + // fade out the other search chrome + searchView.animate() + .alpha(0f) + .setStartDelay(0L) + .setDuration(120L) + .setInterpolator(AnimationUtils.loadInterpolator(this, + android.R.interpolator.fast_out_linear_in)) + .setListener(null) + .start(); + searchBackground.animate() + .alpha(0f) + .setStartDelay(300L) + .setDuration(160L) + .setInterpolator(AnimationUtils.loadInterpolator(this, + android.R.interpolator.fast_out_linear_in)) + .setListener(null) + .start(); + + // if we're showing search results, circular hide them + if (resultsContainer.getHeight() > 0) { + Animator closeResults = ViewAnimationUtils.createCircularReveal( + resultsContainer, + searchIconCenterX, + 0, + (float) Math.hypot(searchIconCenterX, resultsContainer.getHeight()), + 0f); + closeResults.setDuration(500L); + closeResults.setInterpolator(AnimationUtils.loadInterpolator(SearchActivity.this, + android.R.interpolator.fast_out_slow_in)); + closeResults.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + resultsContainer.setVisibility(View.INVISIBLE); + } + }); + closeResults.start(); + } + + // fade out the scrim + scrim.animate() + .alpha(0f) + .setDuration(400L) + .setInterpolator(AnimationUtils.loadInterpolator(this, + android.R.interpolator.fast_out_linear_in)) + .setListener(null) + .start(); + } + + @OnClick(R.id.fab) + protected void save() { + // show the save confirmation bubble + fab.setVisibility(View.INVISIBLE); + confirmSaveContainer.setVisibility(View.VISIBLE); + resultsScrim.setVisibility(View.VISIBLE); + + // expand it once it's been measured and show a scrim over the search results + confirmSaveContainer.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver + .OnPreDrawListener() { + @Override + public boolean onPreDraw() { + // expand the confirmation + confirmSaveContainer.getViewTreeObserver().removeOnPreDrawListener(this); + Animator reveal = ViewAnimationUtils.createCircularReveal(confirmSaveContainer, + confirmSaveContainer.getWidth() / 2, + confirmSaveContainer.getHeight() / 2, + fab.getWidth() / 2, + confirmSaveContainer.getWidth() / 2); + reveal.setDuration(250L); + reveal.setInterpolator(AnimationUtils.loadInterpolator(SearchActivity.this, + android.R.interpolator.fast_out_slow_in)); + reveal.start(); + + // show the scrim + int centerX = (fab.getLeft() + fab.getRight()) / 2; + int centerY = (fab.getTop() + fab.getBottom()) / 2; + Animator revealScrim = ViewAnimationUtils.createCircularReveal( + resultsScrim, + centerX, + centerY, + 0, + (float) Math.hypot(centerX, centerY)); + revealScrim.setDuration(400L); + revealScrim.setInterpolator(AnimationUtils.loadInterpolator(SearchActivity + .this, android.R.interpolator.linear_out_slow_in)); + revealScrim.start(); + ObjectAnimator fadeInScrim = ObjectAnimator.ofArgb(resultsScrim, + ViewUtils.BACKGROUND_COLOR, + Color.TRANSPARENT, + ContextCompat.getColor(SearchActivity.this, R.color.scrim)); + fadeInScrim.setDuration(800L); + fadeInScrim.setInterpolator(AnimationUtils.loadInterpolator(SearchActivity + .this, android.R.interpolator.linear_out_slow_in)); + fadeInScrim.start(); + + // ease in the checkboxes + saveDribbble.setAlpha(0.6f); + saveDribbble.setTranslationY(saveDribbble.getHeight() * 0.4f); + saveDribbble.animate() + .alpha(1f) + .translationY(0f) + .setDuration(200L) + .setInterpolator(AnimationUtils.loadInterpolator(SearchActivity.this, + android.R.interpolator.linear_out_slow_in)); + saveDesignerNews.setAlpha(0.6f); + saveDesignerNews.setTranslationY(saveDesignerNews.getHeight() * 0.5f); + saveDesignerNews.animate() + .alpha(1f) + .translationY(0f) + .setDuration(200L) + .setInterpolator(AnimationUtils.loadInterpolator(SearchActivity.this, + android.R.interpolator.linear_out_slow_in)); + return false; + } + }); + } + + @OnClick(R.id.save_confirmed) + protected void doSave() { + Intent saveData = new Intent(); + saveData.putExtra(EXTRA_QUERY, dataManager.getQuery()); + saveData.putExtra(EXTRA_SAVE_DRIBBBLE, saveDribbble.isChecked()); + saveData.putExtra(EXTRA_SAVE_DESIGNER_NEWS, saveDesignerNews.isChecked()); + setResult(RESULT_CODE_SAVE, saveData); + dismiss(); + } + + @OnClick(R.id.results_scrim) + protected void hideSaveConfimation() { + if (confirmSaveContainer.getVisibility() == View.VISIBLE) { + // contract the bubble & hide the scrim + AnimatorSet hideConfirmation = new AnimatorSet(); + hideConfirmation.playTogether( + ViewAnimationUtils.createCircularReveal(confirmSaveContainer, + confirmSaveContainer.getWidth() / 2, + confirmSaveContainer.getHeight() / 2, + confirmSaveContainer.getWidth() / 2, + fab.getWidth() / 2), + ObjectAnimator.ofArgb(resultsScrim, + ViewUtils.BACKGROUND_COLOR, + Color.TRANSPARENT)); + hideConfirmation.setDuration(150L); + hideConfirmation.setInterpolator(AnimationUtils.loadInterpolator(SearchActivity.this, + android.R.interpolator.fast_out_slow_in)); + hideConfirmation.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + confirmSaveContainer.setVisibility(View.GONE); + resultsScrim.setVisibility(View.GONE); + fab.setVisibility(results.getVisibility()); + } + }); + hideConfirmation.start(); + } + } + + private void setupSearchView() { + SearchManager searchManager = (SearchManager) getSystemService(SEARCH_SERVICE); + searchView.setSearchableInfo(searchManager.getSearchableInfo(getComponentName())); + // hint, inputType & ime options seem to be ignored from XML! Set in code + searchView.setQueryHint(getString(R.string.search_hint)); + searchView.setInputType(InputType.TYPE_TEXT_FLAG_CAP_WORDS); + searchView.setImeOptions(searchView.getImeOptions() | EditorInfo.IME_ACTION_SEARCH | + EditorInfo.IME_FLAG_NO_EXTRACT_UI | EditorInfo.IME_FLAG_NO_FULLSCREEN); + searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String query) { + searchFor(query); + return true; + } + + @Override + public boolean onQueryTextChange(String query) { + if (TextUtils.isEmpty(query)) { + clearResults(); + } + return true; + } + }); + searchView.setOnQueryTextFocusChangeListener(new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View v, boolean hasFocus) { + if (hasFocus && confirmSaveContainer.getVisibility() == View.VISIBLE) { + hideSaveConfimation(); + } + } + }); + } + + private void clearResults() { + adapter.clear(); + dataManager.clear(); + TransitionManager.beginDelayedTransition(container, auto); + results.setVisibility(View.GONE); + progress.setVisibility(View.GONE); + fab.setVisibility(View.GONE); + confirmSaveContainer.setVisibility(View.GONE); + resultsScrim.setVisibility(View.GONE); + setNoResultsVisibility(View.GONE); + } + + private void setNoResultsVisibility(int visibility) { + if (visibility == View.VISIBLE) { + if (noResults == null) { + noResults = (BaselineGridTextView) ((ViewStub) + findViewById(R.id.stub_no_search_results)).inflate(); + noResults.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + searchView.setQuery("", false); + searchView.requestFocus(); + ImeUtils.showIme(searchView); + } + }); + } + String message = String.format(getString(R + .string.no_search_results), searchView.getQuery().toString()); + SpannableStringBuilder ssb = new SpannableStringBuilder(message); + ssb.setSpan(new StyleSpan(Typeface.ITALIC), + message.indexOf('“') + 1, + message.length() - 1, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + noResults.setText(ssb); + } + if (noResults != null) { + noResults.setVisibility(visibility); + } + } + + private void searchFor(String query) { + clearResults(); + progress.setVisibility(View.VISIBLE); + ImeUtils.hideIme(searchView); + searchView.clearFocus(); + dataManager.searchFor(query); + } + + private int gridScrollY = 0; + private RecyclerView.OnScrollListener gridScroll = new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(RecyclerView recyclerView, int dx, int dy) { + gridScrollY += dy; + if (gridScrollY > 0 && searchToolbar.getTranslationZ() != appBarElevation) { + searchToolbar.animate() + .translationZ(appBarElevation) + .setDuration(300L) + .setInterpolator(AnimationUtils.loadInterpolator(SearchActivity.this, + android.R.interpolator.fast_out_slow_in)) + .start(); + } else if (gridScrollY == 0 && searchToolbar.getTranslationZ() != 0) { + searchToolbar.animate() + .translationZ(0f) + .setDuration(300L) + .setInterpolator(AnimationUtils.loadInterpolator(SearchActivity.this, + android.R.interpolator.fast_out_slow_in)) + .start(); + } + } + }; +} diff --git a/app/src/main/java/io/plaidapp/ui/ShareDribbbleImageTask.java b/app/src/main/java/io/plaidapp/ui/ShareDribbbleImageTask.java new file mode 100644 index 000000000..af7f19582 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/ShareDribbbleImageTask.java @@ -0,0 +1,99 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui; + +import android.app.Activity; +import android.net.Uri; +import android.os.AsyncTask; +import android.support.annotation.NonNull; +import android.support.v4.app.ShareCompat; +import android.support.v4.content.FileProvider; +import android.util.Log; + +import com.bumptech.glide.Glide; + +import java.io.File; + +import io.plaidapp.R; +import io.plaidapp.data.api.dribbble.model.Shot; + +/** + * An AsyncTask which retrieves a File from the Glide cache then shares it. + */ +class ShareDribbbleImageTask extends AsyncTask { + + private final Activity activity; + private final Shot shot; + + ShareDribbbleImageTask(Activity activity, Shot shot) { + this.activity = activity; + this.shot = shot; + } + + @Override + protected File doInBackground(Void... params) { + final String url = shot.images.best(); + try { + return Glide + .with(activity) + .load(url) + .downloadOnly((int) shot.width, (int) shot.height) + .get(); + } catch (Exception ex) { + Log.w("SHARE", "Sharing " + url + " failed", ex); + return null; + } + } + + @Override + protected void onPostExecute(File result) { + if (result == null) { return; } + // glide cache uses an unfriendly & extension-less name, + // massage it based on the original + String fileName = shot.images.best(); + fileName = fileName.substring(fileName.lastIndexOf('/') + 1); + File renamed = new File(result.getParent(), fileName); + result.renameTo(renamed); + Uri uri = FileProvider.getUriForFile(activity, + activity.getString(R.string.share_authority), renamed); + ShareCompat.IntentBuilder.from(activity) + .setText(getShareText()) + .setType(getImageMimeType(fileName)) + .setStream(uri) + .startChooser(); + } + + private String getShareText() { + return new StringBuilder() + .append("“") + .append(shot.title) + .append("” by ") + .append(shot.user.name) + .append("\n") + .append(shot.url) + .toString(); + } + + private String getImageMimeType(@NonNull String fileName) { + if (fileName.endsWith(".png")) { + return "image/png"; + } else if (fileName.endsWith(".gif")) { + return "image/gif"; + } + return "image/jpeg"; + } +} diff --git a/app/src/main/java/io/plaidapp/ui/drawable/MorphDrawable.java b/app/src/main/java/io/plaidapp/ui/drawable/MorphDrawable.java new file mode 100644 index 000000000..12f6e53a0 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/drawable/MorphDrawable.java @@ -0,0 +1,116 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.drawable; + +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Outline; +import android.graphics.Paint; +import android.graphics.drawable.Drawable; +import android.support.annotation.ColorInt; +import android.util.Property; + +import io.plaidapp.util.AnimUtils; + +/** + * A drawable that can morph size, shape (via it's corner radius) and color. Specifically this is + * useful for animating between a FAB and a dialog. + */ +public class MorphDrawable extends Drawable { + + private float cornerRadius; + public static final Property CORNER_RADIUS = new AnimUtils + .FloatProperty("cornerRadius") { + + @Override + public void setValue(MorphDrawable morphDrawable, float value) { + morphDrawable.setCornerRadius(value); + } + + @Override + public Float get(MorphDrawable morphDrawable) { + return morphDrawable.getCornerRadius(); + } + }; + private Paint paint; + public static final Property COLOR = new AnimUtils + .IntProperty("color") { + + @Override + public void setValue(MorphDrawable morphDrawable, int value) { + morphDrawable.setColor(value); + } + + @Override + public Integer get(MorphDrawable morphDrawable) { + return morphDrawable.getColor(); + } + }; + + public MorphDrawable(@ColorInt int color, float cornerRadius) { + this.cornerRadius = cornerRadius; + paint = new Paint(Paint.ANTI_ALIAS_FLAG); + paint.setColor(color); + } + + public float getCornerRadius() { + return cornerRadius; + } + + public void setCornerRadius(float cornerRadius) { + this.cornerRadius = cornerRadius; + invalidateSelf(); + } + + public int getColor() { + return paint.getColor(); + } + + public void setColor(int color) { + paint.setColor(color); + invalidateSelf(); + } + + @Override + public void draw(Canvas canvas) { + canvas.drawRoundRect(getBounds().left, getBounds().top, getBounds().right, getBounds() + .bottom, cornerRadius, cornerRadius, paint); + } + + @Override + public void getOutline(Outline outline) { + outline.setRoundRect(getBounds(), cornerRadius); + } + + @Override + public void setAlpha(int alpha) { + paint.setAlpha(alpha); + invalidateSelf(); + } + + @Override + public void setColorFilter(ColorFilter cf) { + paint.setColorFilter(cf); + invalidateSelf(); + } + + @Override + public int getOpacity() { + return paint.getAlpha(); + } + +} diff --git a/app/src/main/java/io/plaidapp/ui/drawable/ThreadedCommentDrawable.java b/app/src/main/java/io/plaidapp/ui/drawable/ThreadedCommentDrawable.java new file mode 100644 index 000000000..abd0c0af6 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/drawable/ThreadedCommentDrawable.java @@ -0,0 +1,98 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.drawable; + +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.drawable.Drawable; +import android.support.annotation.ColorInt; + +/** + * A drawable showing the depth of a threaded conversation + */ +public class ThreadedCommentDrawable extends Drawable { + + private static final @ColorInt int THREAD_COLOR = 0xffeceef1; + + private final int threadWidth; + private final int gap; + private final int halfThreadWidth; + private final Paint paint; + private int threads; + + /** + * + * @param threadWidth in pixels + * @param gap in pixels + */ + public ThreadedCommentDrawable(int threadWidth, int gap) { + this.threadWidth = threadWidth; + this.gap = gap; + halfThreadWidth = threadWidth / 2; + paint = new Paint(); + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(threadWidth); + paint.setColor(THREAD_COLOR); + } + + public void setDepth(int depth) { + this.threads = depth + 1; + invalidateSelf(); + } + + @Override + public void draw(Canvas canvas) { + for (int thread = 0; thread < threads; thread++) { + int left = halfThreadWidth + (thread * (threadWidth + gap)); + canvas.drawLine(left, 0, left, getBounds().bottom, paint); + } + } + + @Override + public int getIntrinsicWidth() { + return (threads * threadWidth) + ((threads - 1) * gap); + } + + @Override + public void setAlpha(int i) { + paint.setAlpha(i); + } + + @Override + public void setColorFilter(ColorFilter colorFilter) { + paint.setColorFilter(colorFilter); + } + + @Override + public int getOpacity() { + return paint.getAlpha(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ThreadedCommentDrawable that = (ThreadedCommentDrawable) o; + return threads == that.threads; + } + + @Override + public int hashCode() { + return threads; + } +} diff --git a/app/src/main/java/io/plaidapp/ui/recyclerview/FilterTouchHelperCallback.java b/app/src/main/java/io/plaidapp/ui/recyclerview/FilterTouchHelperCallback.java new file mode 100644 index 000000000..d2c7d4f7a --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/recyclerview/FilterTouchHelperCallback.java @@ -0,0 +1,55 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.recyclerview; + +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.helper.ItemTouchHelper; + +import io.plaidapp.ui.FilterAdapter; + +/** + * Callback for item swipe-dismissing + */ +public class FilterTouchHelperCallback extends ItemTouchHelper.SimpleCallback { + + private final ItemTouchHelperAdapter adapter; + + public FilterTouchHelperCallback(ItemTouchHelperAdapter adapter) { + super(0, ItemTouchHelper.START); + this.adapter = adapter; + } + + + @Override + public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder source, RecyclerView + .ViewHolder target) { + // nothing to do here + return false; + } + + @Override + public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) { + adapter.onItemDismiss(viewHolder.getAdapterPosition()); + } + + @Override + public int getSwipeDirs(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) { + // can only swipe-dismiss certain sources + return makeMovementFlags(0, ((FilterAdapter.FilterViewHolder) viewHolder).isSwipeable ? + ItemTouchHelper.START : 0); + } +} diff --git a/app/src/main/java/io/plaidapp/ui/recyclerview/InfiniteScrollListener.java b/app/src/main/java/io/plaidapp/ui/recyclerview/InfiniteScrollListener.java new file mode 100644 index 000000000..38b56fae5 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/recyclerview/InfiniteScrollListener.java @@ -0,0 +1,56 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.recyclerview; + +import android.support.v7.widget.GridLayoutManager; +import android.support.v7.widget.RecyclerView; + +import io.plaidapp.data.DataLoadingSubject; + +/** + * A scroll listener for RecyclerView to load more items as you approach the end. + * + * Adapted from https://gist.github.com/ssinss/e06f12ef66c51252563e + */ +public abstract class InfiniteScrollListener extends RecyclerView.OnScrollListener { + + // The minimum number of items remaining before we should loading more. + private static final int VISIBLE_THRESHOLD = 5; + + private final GridLayoutManager layoutManager; + private final DataLoadingSubject dataLoading; + + public InfiniteScrollListener(GridLayoutManager layoutManager, DataLoadingSubject dataLoading) { + this.layoutManager = layoutManager; + this.dataLoading = dataLoading; + } + + @Override + public void onScrolled(RecyclerView recyclerView, int dx, int dy) { + final int visibleItemCount = recyclerView.getChildCount(); + final int totalItemCount = layoutManager.getItemCount(); + final int firstVisibleItem = layoutManager.findFirstVisibleItemPosition(); + + if (!dataLoading.isDataLoading() && + (totalItemCount - visibleItemCount) <= (firstVisibleItem + VISIBLE_THRESHOLD)) { + onLoadMore(); + } + } + + public abstract void onLoadMore(); + +} diff --git a/app/src/main/java/io/plaidapp/ui/recyclerview/ItemTouchHelperAdapter.java b/app/src/main/java/io/plaidapp/ui/recyclerview/ItemTouchHelperAdapter.java new file mode 100644 index 000000000..e28eb2be2 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/recyclerview/ItemTouchHelperAdapter.java @@ -0,0 +1,24 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.recyclerview; + +/** + * Interface for events related to swipe dismissing filters + */ +public interface ItemTouchHelperAdapter { + void onItemDismiss(int position); +} \ No newline at end of file diff --git a/app/src/main/java/io/plaidapp/ui/transitions/FabDialogMorphSetup.java b/app/src/main/java/io/plaidapp/ui/transitions/FabDialogMorphSetup.java new file mode 100644 index 000000000..7dbf9b91e --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/transitions/FabDialogMorphSetup.java @@ -0,0 +1,70 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.transitions; + +import android.app.Activity; +import android.graphics.Color; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.transition.ArcMotion; +import android.view.View; +import android.view.animation.AnimationUtils; +import android.view.animation.Interpolator; + +/** + * Helper class for setting up Fab <-> Dialog shared element transitions. + */ +public class FabDialogMorphSetup { + + public static final String EXTRA_SHARED_ELEMENT_START_COLOR = + "EXTRA_SHARED_ELEMENT_START_COLOR"; + + private FabDialogMorphSetup() { } + + /** + * Configure the shared element transitions for morphin from a fab <-> dialog. We need to do + * this in code rather than declaratively as we need to supply the color to transition from/to + * and the dialog corner radius which is dynamically supplied depending upon where this screen + * is launched from. + */ + public static void setupSharedEelementTransitions(@NonNull Activity activity, + @Nullable View target, + int dialogCornerRadius) { + if (!activity.getIntent().hasExtra(EXTRA_SHARED_ELEMENT_START_COLOR)) return; + + ArcMotion arcMotion = new ArcMotion(); + arcMotion.setMinimumHorizontalAngle(50f); + arcMotion.setMinimumVerticalAngle(50f); + int color = activity.getIntent(). + getIntExtra(EXTRA_SHARED_ELEMENT_START_COLOR, Color.TRANSPARENT); + Interpolator easeInOut = + AnimationUtils.loadInterpolator(activity, android.R.interpolator.fast_out_slow_in); + MorphFabToDialog sharedEnter = new MorphFabToDialog(color, dialogCornerRadius); + sharedEnter.setPathMotion(arcMotion); + sharedEnter.setInterpolator(easeInOut); + MorphDialogToFab sharedReturn = new MorphDialogToFab(color); + sharedReturn.setPathMotion(arcMotion); + sharedReturn.setInterpolator(easeInOut); + if (target != null) { + sharedEnter.addTarget(target); + sharedReturn.addTarget(target); + } + activity.getWindow().setSharedElementEnterTransition(sharedEnter); + activity.getWindow().setSharedElementReturnTransition(sharedReturn); + } + +} diff --git a/app/src/main/java/io/plaidapp/ui/transitions/MorphDialogToFab.java b/app/src/main/java/io/plaidapp/ui/transitions/MorphDialogToFab.java new file mode 100644 index 000000000..fe658130f --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/transitions/MorphDialogToFab.java @@ -0,0 +1,140 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.transitions; + +import android.animation.Animator; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.graphics.Color; +import android.support.annotation.ColorInt; +import android.support.v4.content.ContextCompat; +import android.transition.ChangeBounds; +import android.transition.TransitionValues; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AnimationUtils; + +import io.plaidapp.R; +import io.plaidapp.ui.drawable.MorphDrawable; +import io.plaidapp.util.AnimUtils; + +/** + * A transition that morphs a rectangle into a circle, changing it's background color. + */ +public class MorphDialogToFab extends ChangeBounds { + + private static final String PROPERTY_COLOR = "plaid:rectMorph:color"; + private static final String PROPERTY_CORNER_RADIUS = "plaid:rectMorph:cornerRadius"; + private static final String[] TRANSITION_PROPERTIES = { + PROPERTY_COLOR, + PROPERTY_CORNER_RADIUS + }; + private @ColorInt int endColor = Color.TRANSPARENT; + + public MorphDialogToFab(@ColorInt int endColor) { + super(); + setEndColor(endColor); + } + + public MorphDialogToFab(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public void setEndColor(@ColorInt int endColor) { + this.endColor = endColor; + } + + @Override + public String[] getTransitionProperties() { + return TRANSITION_PROPERTIES; + } + + @Override + public void captureStartValues(TransitionValues transitionValues) { + super.captureStartValues(transitionValues); + final View view = transitionValues.view; + if (view.getWidth() <= 0 || view.getHeight() <= 0) { + return; + } + transitionValues.values.put(PROPERTY_COLOR, + ContextCompat.getColor(view.getContext(), R.color.background_light)); + transitionValues.values.put(PROPERTY_CORNER_RADIUS, view.getResources() + .getDimensionPixelSize(R.dimen.dialog_corners)); + } + + @Override + public void captureEndValues(TransitionValues transitionValues) { + super.captureEndValues(transitionValues); + final View view = transitionValues.view; + if (view.getWidth() <= 0 || view.getHeight() <= 0) { + return; + } + transitionValues.values.put(PROPERTY_COLOR, endColor); + transitionValues.values.put(PROPERTY_CORNER_RADIUS, view.getHeight() / 2); + } + + @Override + public Animator createAnimator(final ViewGroup sceneRoot, + TransitionValues startValues, + TransitionValues endValues) { + Animator changeBounds = super.createAnimator(sceneRoot, startValues, endValues); + if (startValues == null || endValues == null || changeBounds == null) { + return null; + } + + Integer startColor = (Integer) startValues.values.get(PROPERTY_COLOR); + Integer startCornerRadius = (Integer) startValues.values.get(PROPERTY_CORNER_RADIUS); + Integer endColor = (Integer) endValues.values.get(PROPERTY_COLOR); + Integer endCornerRadius = (Integer) endValues.values.get(PROPERTY_CORNER_RADIUS); + + if (startColor == null || startCornerRadius == null || endColor == null || + endCornerRadius == null) { + return null; + } + + MorphDrawable background = new MorphDrawable(startColor, startCornerRadius); + endValues.view.setBackground(background); + + Animator color = ObjectAnimator.ofArgb(background, background.COLOR, endColor); + Animator corners = ObjectAnimator.ofFloat(background, background.CORNER_RADIUS, + endCornerRadius); + + // hide child views (offset down & fade out) + if (endValues.view instanceof ViewGroup) { + ViewGroup vg = (ViewGroup) endValues.view; + for (int i = 0; i < vg.getChildCount(); i++) { + View v = vg.getChildAt(i); + v.animate() + .alpha(0f) + .translationY(v.getHeight() / 3) + .setStartDelay(0L) + .setDuration(50L) + .setInterpolator(AnimationUtils.loadInterpolator(vg.getContext(), + android.R.interpolator.fast_out_linear_in)) + .start(); + } + } + + AnimatorSet transition = new AnimatorSet(); + transition.playTogether(changeBounds, corners, color); + transition.setDuration(300); + transition.setInterpolator(AnimUtils.getMaterialInterpolator(sceneRoot.getContext())); + return transition; + } +} diff --git a/app/src/main/java/io/plaidapp/ui/transitions/MorphFabToDialog.java b/app/src/main/java/io/plaidapp/ui/transitions/MorphFabToDialog.java new file mode 100644 index 000000000..b82f614e4 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/transitions/MorphFabToDialog.java @@ -0,0 +1,149 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.transitions; + +import android.animation.Animator; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.graphics.Color; +import android.support.annotation.ColorInt; +import android.support.v4.content.ContextCompat; +import android.transition.ChangeBounds; +import android.transition.TransitionValues; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AnimationUtils; + +import io.plaidapp.R; +import io.plaidapp.ui.drawable.MorphDrawable; + +/** + * A transition that morphs a circle into a rectangle, changing it's background color. + */ +public class MorphFabToDialog extends ChangeBounds { + + private static final String PROPERTY_COLOR = "plaid:circleMorph:color"; + private static final String PROPERTY_CORNER_RADIUS = "plaid:circleMorph:cornerRadius"; + private static final String[] TRANSITION_PROPERTIES = { + PROPERTY_COLOR, + PROPERTY_CORNER_RADIUS + }; + private @ColorInt int startColor = Color.TRANSPARENT; + private int endCornerRadius; + + public MorphFabToDialog(@ColorInt int startColor, int endCornerRadius) { + super(); + setStartColor(startColor); + setEndCornerRadius(endCornerRadius); + } + + public MorphFabToDialog(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public void setStartColor(@ColorInt int startColor) { + this.startColor = startColor; + } + + public void setEndCornerRadius(int endCornerRadius) { + this.endCornerRadius = endCornerRadius; + } + + @Override + public String[] getTransitionProperties() { + return TRANSITION_PROPERTIES; + } + + @Override + public void captureStartValues(TransitionValues transitionValues) { + super.captureStartValues(transitionValues); + final View view = transitionValues.view; + if (view.getWidth() <= 0 || view.getHeight() <= 0) { + return; + } + transitionValues.values.put(PROPERTY_COLOR, startColor); + transitionValues.values.put(PROPERTY_CORNER_RADIUS, view.getHeight() / 2); + } + + @Override + public void captureEndValues(TransitionValues transitionValues) { + super.captureEndValues(transitionValues); + final View view = transitionValues.view; + if (view.getWidth() <= 0 || view.getHeight() <= 0) { + return; + } + transitionValues.values.put(PROPERTY_COLOR, + ContextCompat.getColor(view.getContext(), R.color.background_light)); + transitionValues.values.put(PROPERTY_CORNER_RADIUS, endCornerRadius); + } + + @Override + public Animator createAnimator(final ViewGroup sceneRoot, + TransitionValues startValues, + final TransitionValues endValues) { + Animator changeBounds = super.createAnimator(sceneRoot, startValues, endValues); + if (startValues == null || endValues == null || changeBounds == null) { + return null; + } + + Integer startColor = (Integer) startValues.values.get(PROPERTY_COLOR); + Integer startCornerRadius = (Integer) startValues.values.get(PROPERTY_CORNER_RADIUS); + Integer endColor = (Integer) endValues.values.get(PROPERTY_COLOR); + Integer endCornerRadius = (Integer) endValues.values.get(PROPERTY_CORNER_RADIUS); + + if (startColor == null || startCornerRadius == null || endColor == null || + endCornerRadius == null) { + return null; + } + + MorphDrawable background = new MorphDrawable(startColor, startCornerRadius); + endValues.view.setBackground(background); + + Animator color = ObjectAnimator.ofArgb(background, background.COLOR, endColor); + Animator corners = ObjectAnimator.ofFloat(background, background.CORNER_RADIUS, + endCornerRadius); + + // ease in the dialog's child views (slide up & fade in) + if (endValues.view instanceof ViewGroup) { + ViewGroup vg = (ViewGroup) endValues.view; + float offset = vg.getHeight() / 3; + for (int i = 0; i < vg.getChildCount(); i++) { + View v = vg.getChildAt(i); + v.setTranslationY(offset); + v.setAlpha(0f); + v.animate() + .alpha(1f) + .translationY(0f) + .setDuration(150) + .setStartDelay(150) + .setInterpolator(AnimationUtils.loadInterpolator(vg.getContext(), + android.R.interpolator.fast_out_slow_in)); + offset *= 1.8f; + } + } + + AnimatorSet transition = new AnimatorSet(); + transition.playTogether(changeBounds, corners, color); + transition.setDuration(300); + transition.setInterpolator(AnimationUtils.loadInterpolator(sceneRoot.getContext(), + android.R.interpolator.fast_out_slow_in)); + return transition; + } + +} diff --git a/app/src/main/java/io/plaidapp/ui/transitions/Pop.java b/app/src/main/java/io/plaidapp/ui/transitions/Pop.java new file mode 100644 index 000000000..87eea2551 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/transitions/Pop.java @@ -0,0 +1,78 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.transitions; + +import android.animation.Animator; +import android.animation.ObjectAnimator; +import android.animation.PropertyValuesHolder; +import android.content.Context; +import android.transition.Transition; +import android.transition.TransitionValues; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; + +import io.plaidapp.util.AnimUtils; + +/** + * A transition that animates the alpha & scale X & Y of a view simultaneously. + */ +public class Pop extends Transition { + + private static final String PROPNAME_ALPHA = "plaid:pop:alpha"; + private static final String PROPNAME_SCALE_X = "plaid:pop:scaleX"; + private static final String PROPNAME_SCALE_Y = "plaid:pop:scaleY"; + + private static final String[] transitionProperties = { + PROPNAME_ALPHA, + PROPNAME_SCALE_X, + PROPNAME_SCALE_Y + }; + + public Pop(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public String[] getTransitionProperties() { + return transitionProperties; + } + + @Override + public void captureStartValues(TransitionValues transitionValues) { + transitionValues.values.put(PROPNAME_ALPHA, 0f); + transitionValues.values.put(PROPNAME_SCALE_X, 0f); + transitionValues.values.put(PROPNAME_SCALE_Y, 0f); + } + + @Override + public void captureEndValues(TransitionValues transitionValues) { + transitionValues.values.put(PROPNAME_ALPHA, 1f); + transitionValues.values.put(PROPNAME_SCALE_X, 1f); + transitionValues.values.put(PROPNAME_SCALE_Y, 1f); + } + + @Override + public Animator createAnimator(ViewGroup sceneRoot, TransitionValues startValues, + TransitionValues endValues) { + PropertyValuesHolder alpha = PropertyValuesHolder.ofFloat(View.ALPHA, 0f, 1f); + PropertyValuesHolder scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 0f, 1f); + PropertyValuesHolder scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0f, 1f); + return new AnimUtils.NoPauseAnimator(ObjectAnimator.ofPropertyValuesHolder(endValues + .view, alpha, scaleX, scaleY)); + } +} diff --git a/app/src/main/java/io/plaidapp/ui/transitions/ShotSharedEnter.java b/app/src/main/java/io/plaidapp/ui/transitions/ShotSharedEnter.java new file mode 100644 index 000000000..44eae8f7d --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/transitions/ShotSharedEnter.java @@ -0,0 +1,49 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.transitions; + +import android.content.Context; +import android.graphics.Rect; +import android.transition.ChangeBounds; +import android.transition.TransitionValues; +import android.util.AttributeSet; +import android.view.View; + +/** + * Shared element transitions do not seem to like transitioning from a single view to two separate + * views so we need to alter the ChangeBounds transition to compensate + */ +public class ShotSharedEnter extends ChangeBounds { + + private static final String PROPNAME_BOUNDS = "android:changeBounds:bounds"; + private static final String PROPNAME_PARENT = "android:changeBounds:parent"; + + public ShotSharedEnter(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void captureEndValues(TransitionValues transitionValues) { + super.captureEndValues(transitionValues); + int width = ((View) transitionValues.values.get(PROPNAME_PARENT)).getWidth(); + Rect bounds = (Rect) transitionValues.values.get(PROPNAME_BOUNDS); + bounds.right = width; + bounds.bottom = width * 3 / 4; + transitionValues.values.put(PROPNAME_BOUNDS, bounds); + } + +} diff --git a/app/src/main/java/io/plaidapp/ui/transitions/StoryTitleSharedEnter.java b/app/src/main/java/io/plaidapp/ui/transitions/StoryTitleSharedEnter.java new file mode 100644 index 000000000..f5453e036 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/transitions/StoryTitleSharedEnter.java @@ -0,0 +1,47 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.transitions; + +import android.content.Context; +import android.graphics.Rect; +import android.transition.ChangeBounds; +import android.transition.TransitionValues; +import android.util.AttributeSet; +import android.view.View; + +/** + * Shared element transitions do not seem to like transitioning from a single view to two separate + * views so we need to alter the ChangeBounds transition to compensate + */ +public class StoryTitleSharedEnter extends ChangeBounds { + + private static final String PROPNAME_BOUNDS = "android:changeBounds:bounds"; + private static final String PROPNAME_PARENT = "android:changeBounds:parent"; + + public StoryTitleSharedEnter(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void captureEndValues(TransitionValues transitionValues) { + super.captureEndValues(transitionValues); + Rect bounds = (Rect) transitionValues.values.get(PROPNAME_BOUNDS); + bounds.right = ((View) transitionValues.values.get(PROPNAME_PARENT)).getWidth(); + bounds.bottom = ((View) transitionValues.values.get(PROPNAME_PARENT)).getHeight(); + transitionValues.values.put(PROPNAME_BOUNDS, bounds); + } +} diff --git a/app/src/main/java/io/plaidapp/ui/widget/AuthorTextView.java b/app/src/main/java/io/plaidapp/ui/widget/AuthorTextView.java new file mode 100644 index 000000000..6acaab2ec --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/widget/AuthorTextView.java @@ -0,0 +1,58 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.TextView; + +import io.plaidapp.R; + +/** + * An extension to TextView which supports a custom state of {@link #STATE_ORIGINAL_POSTER} for + * denoting that a comment author was the original poster. + */ +public class AuthorTextView extends TextView { + + private static final int[] STATE_ORIGINAL_POSTER = {R.attr.state_original_poster}; + + private boolean isOP = false; + + public AuthorTextView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + if (isOP) { + mergeDrawableStates(drawableState, STATE_ORIGINAL_POSTER); + } + return drawableState; + } + + public boolean isOriginalPoster() { + return isOP; + } + + public void setOriginalPoster(boolean isOP) { + if (this.isOP != isOP) { + this.isOP = isOP; + refreshDrawableState(); + } + } +} diff --git a/app/src/main/java/io/plaidapp/ui/widget/BadgedFourThreeImageView.java b/app/src/main/java/io/plaidapp/ui/widget/BadgedFourThreeImageView.java new file mode 100644 index 000000000..1dd4795a1 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/widget/BadgedFourThreeImageView.java @@ -0,0 +1,165 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.support.annotation.ColorInt; +import android.text.TextPaint; +import android.util.AttributeSet; +import android.view.Gravity; + +import io.plaidapp.R; + +/** + * A view group that draws a badge drawable on top of it's contents. + */ +public class BadgedFourThreeImageView extends FourThreeImageView { + + private Drawable badge; + private boolean drawBadge; + private boolean badgeBoundsSet = false; + private int badgeGravity; + private int badgePadding; + + public BadgedFourThreeImageView(Context context, AttributeSet attrs) { + super(context, attrs); + badge = new GifBadge(context); + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.BadgedImageView, 0, 0); + badgeGravity = a.getInt(R.styleable.BadgedImageView_badgeGravity, Gravity.END | Gravity + .BOTTOM); + badgePadding = a.getDimensionPixelSize(R.styleable.BadgedImageView_badgePadding, 0); + a.recycle(); + + } + + public void showBadge(boolean show) { + drawBadge = show; + } + + public void setBadgeColor(@ColorInt int color) { + badge.setColorFilter(color, PorterDuff.Mode.SRC_IN); + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + if (drawBadge) { + if (!badgeBoundsSet) { + layoutBadge(); + } + badge.draw(canvas); + } + } + + private void layoutBadge() { + Rect badgeBounds = badge.getBounds(); + Gravity.apply(badgeGravity, + badge.getIntrinsicWidth(), + badge.getIntrinsicHeight(), + new Rect(0, 0, getWidth(), getHeight()), + badgePadding, + badgePadding, + badgeBounds); + badge.setBounds(badgeBounds); + badgeBoundsSet = true; + } + + /** + * A drawable for indicating that an image is animated + */ + private static class GifBadge extends Drawable { + + private static final String GIF = "GIF"; + private static final int TEXT_SIZE = 12; // dp + private static final int PADDING = 4; // dp + private static final int CORNER_RADIUS = 2; // dp + private static final int BACKGROUND_COLOR = 0xffffffff; + private static final String TYPEFACE = "sans-serif-black"; + private static final int TYPEFACE_STYLE = Typeface.NORMAL; + private static Bitmap bitmap; + private static int width; + private static int height; + private final Paint paint; + + GifBadge(Context context) { + if (bitmap == null) { + final float density = context.getResources().getDisplayMetrics().scaledDensity; + final TextPaint textPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint + .SUBPIXEL_TEXT_FLAG); + textPaint.setTypeface(Typeface.create(TYPEFACE, TYPEFACE_STYLE)); + textPaint.setTextSize(TEXT_SIZE * density); + + final float padding = PADDING * density; + final Rect textBounds = new Rect(); + textPaint.getTextBounds(GIF, 0, GIF.length(), textBounds); + height = (int) (padding + textBounds.height() + padding); + width = (int) (padding + textBounds.width() + padding); + bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + bitmap.setHasAlpha(true); + final Canvas canvas = new Canvas(bitmap); + final Paint backgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + backgroundPaint.setColor(BACKGROUND_COLOR); + canvas.drawRoundRect(0, 0, width, height, CORNER_RADIUS * density, CORNER_RADIUS + * density, backgroundPaint); + // this is the magic, this mode punches out the word as transparency + textPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); + canvas.drawText(GIF, padding, height - padding, textPaint); + } + paint = new Paint(); + } + + @Override + public int getIntrinsicWidth() { + return width; + } + + @Override + public int getIntrinsicHeight() { + return height; + } + + @Override + public void draw(Canvas canvas) { + canvas.drawBitmap(bitmap, getBounds().left, getBounds().top, paint); + } + + @Override + public void setAlpha(int alpha) { + // ignored + } + + @Override + public void setColorFilter(ColorFilter cf) { + paint.setColorFilter(cf); + } + + @Override + public int getOpacity() { + return 0; + } + } +} diff --git a/app/src/main/java/io/plaidapp/ui/widget/BaselineGridTextView.java b/app/src/main/java/io/plaidapp/ui/widget/BaselineGridTextView.java new file mode 100644 index 000000000..a53d19c1c --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/widget/BaselineGridTextView.java @@ -0,0 +1,110 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Paint; +import android.util.AttributeSet; +import android.util.TypedValue; + +import io.plaidapp.R; + +public class BaselineGridTextView extends FontTextView { + + private float lineHeightMultiplierHint = 1f; + private float lineHeightHint = 0f; + private int topPaddingHint = 0; + + public BaselineGridTextView(Context context) { + this(context, null); + } + + public BaselineGridTextView(Context context, AttributeSet attrs) { + this(context, attrs, android.R.attr.textViewStyle); + } + + public BaselineGridTextView(Context context, AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public BaselineGridTextView(Context context, AttributeSet attrs, + int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + + final TypedArray a = context.obtainStyledAttributes( + attrs, R.styleable.BaselineGridTextView, defStyleAttr, defStyleRes); + + lineHeightMultiplierHint = + a.getFloat(R.styleable.BaselineGridTextView_lineHeightMultiplierHint, 1f); + lineHeightHint = + a.getDimensionPixelSize(R.styleable.BaselineGridTextView_lineHeightHint, 0); + topPaddingHint = + a.getDimensionPixelSize(R.styleable.BaselineGridTextView_topPaddingHint, 0); + + a.recycle(); + + setIncludeFontPadding(false); + setElegantTextHeight(false); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + recomputeLineHeight(); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + private void recomputeLineHeight() { + float fourDip = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 4, + getResources().getDisplayMetrics()); + + // Ensure that the first line's baselines sits on 4dp grid by setting the top padding + Paint.FontMetricsInt fm = getPaint().getFontMetricsInt(); + int gridAlignedTopPadding = (int) (fourDip * (float) + Math.ceil((topPaddingHint + Math.abs(fm.ascent)) / fourDip) + - Math.ceil(Math.abs(fm.ascent))); + setPadding(getPaddingLeft(), gridAlignedTopPadding, getPaddingRight(), getPaddingBottom()); + + // Ensures line height is a multiple of 4dp + int fontHeight = Math.abs(fm.ascent - fm.descent) + fm.leading; + float desiredLineHeight = (lineHeightHint > 0) + ? lineHeightHint + : lineHeightMultiplierHint * fontHeight; + + int baselineAlignedLineHeight = + (int) (fourDip * (float) Math.ceil(desiredLineHeight / fourDip)); + setLineSpacing(baselineAlignedLineHeight - fontHeight, 1f); + } + + public float getLineHeightMultiplierHint() { + return lineHeightMultiplierHint; + } + + public void setLineHeightMultiplierHint(float lineHeightMultiplierHint) { + this.lineHeightMultiplierHint = lineHeightMultiplierHint; + recomputeLineHeight(); + } + + public float getLineHeightHint() { + return lineHeightHint; + } + + public void setLineHeightHint(float lineHeightHint) { + this.lineHeightHint = lineHeightHint; + recomputeLineHeight(); + } +} diff --git a/app/src/main/java/io/plaidapp/ui/widget/BottomSheet.java b/app/src/main/java/io/plaidapp/ui/widget/BottomSheet.java new file mode 100644 index 000000000..27ef7af62 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/widget/BottomSheet.java @@ -0,0 +1,301 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.widget; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.content.res.TypedArray; +import android.support.v4.view.MotionEventCompat; +import android.support.v4.view.ViewCompat; +import android.support.v4.widget.ViewDragHelper; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.animation.AnimationUtils; +import android.widget.FrameLayout; + +import java.util.ArrayList; +import java.util.List; + +import io.plaidapp.R; +import io.plaidapp.util.ViewOffsetHelper; + +/** + * A {@link FrameLayout} which can be dragged downward to be dismissed (either directly or via a + * specified nested scrolling child). It expects to contain a single child view and exposes a + * listener interface to react to it's dismissal. + * + * View dragging has the benefit of reporting it's velocity allowing us to respond to flings etc + * but does not allow children to scroll. Nested scrolling allows child views to scroll (duh) + * but does not report velocity. We combine both to get the best experience we can with the APIs. + */ +public class BottomSheet extends FrameLayout { + + // configurable attributes + private int dragDismissDistance = Integer.MAX_VALUE; + private boolean hasScrollingChild = false; + private int scrollingChildId = -1; + + // child views & helpers + private View dragView; + private View scrollingChild; + private ViewDragHelper viewDragHelper; + private ViewOffsetHelper dragViewOffsetHelper; + + // state + private final int FLING_VELOCITY; + private List listeners; + private boolean isDismissing; + private int dragViewLeft; + private int dragViewTop; + private int dragViewBottom; + private boolean lastNestedScrollWasDownward; + + public BottomSheet(Context context) { + this(context, null, 0); + } + + public BottomSheet(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public BottomSheet(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + FLING_VELOCITY = ViewConfiguration.get(context).getScaledMinimumFlingVelocity(); + final TypedArray a = + getContext().obtainStyledAttributes(attrs, R.styleable.BottomSheet, 0, 0); + + if (a.hasValue(R.styleable.BottomSheet_scrollingChild)) { + hasScrollingChild = true; + scrollingChildId = a.getResourceId(R.styleable.BottomSheet_scrollingChild, + scrollingChildId); + } + if (a.hasValue(R.styleable.BottomSheet_dragDismissDistance)) { + dragDismissDistance = a.getDimensionPixelSize( + R.styleable.BottomSheet_dragDismissDistance, dragDismissDistance); + } + a.recycle(); + } + + public void addListener(Listener listener) { + if (listeners == null) { + listeners = new ArrayList<>(); + } + listeners.add(listener); + } + + public interface Listener { + void onDragDismissed(); + void onDrag(int top); + } + + public void doDismiss() { + viewDragHelper.settleCapturedViewAt(dragViewLeft, dragViewBottom); + } + + @Override + public void addView(View child, int index, ViewGroup.LayoutParams params) { + if (dragView != null) { + throw new UnsupportedOperationException("BottomSheet must only have 1 child view"); + } + dragView = child; + dragViewOffsetHelper = new ViewOffsetHelper(dragView); + if (hasScrollingChild) { + scrollingChild = dragView.findViewById(scrollingChildId); + } + super.addView(child, index, params); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + viewDragHelper = ViewDragHelper.create(this, dragHelperCallbacks); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + if (dragView != null && dragView.isLaidOut()) { + dragViewLeft = dragView.getLeft(); + dragViewTop = dragView.getTop(); + dragViewBottom = dragView.getBottom(); + dragViewOffsetHelper.onViewLayout(); + } + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + final int action = MotionEventCompat.getActionMasked(ev); + if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) { + viewDragHelper.cancel(); + return false; + } + return isDraggableViewUnder((int) ev.getX(), (int) ev.getY()) + && (viewDragHelper.shouldInterceptTouchEvent(ev) || super.onInterceptTouchEvent(ev)); + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + viewDragHelper.processTouchEvent(ev); + if (viewDragHelper.getCapturedView() == null) { + return super.onTouchEvent(ev); + } + return true; + } + + @Override + public void computeScroll() { + if (viewDragHelper.continueSettling(true)) { + ViewCompat.postInvalidateOnAnimation(this); + } + } + + @Override + public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) { + return (nestedScrollAxes & View.SCROLL_AXIS_VERTICAL) != 0; + } + + @Override + public void onNestedScroll(View target, int dxConsumed, int dyConsumed, + int dxUnconsumed, int dyUnconsumed) { + // if scrolling downward, use any unconsumed (i.e. not used by the scrolling child) + // to drag the sheet downward + lastNestedScrollWasDownward = dyUnconsumed < 0; + if (lastNestedScrollWasDownward) { + dragView.offsetTopAndBottom(-dyUnconsumed); + } + } + + @Override + public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) { + // if scrolling upward & the sheet has been dragged downward + // then drag back into place before allowing scrolls + if (dy > 0) { + final int dragDisplacement = dragView.getTop() - dragViewTop; + if (dragDisplacement > 0) { + final int consume = Math.min(dragDisplacement, dy); + dragView.offsetTopAndBottom(-consume); + consumed[1] = consume; + lastNestedScrollWasDownward = false; + } + } + } + + @Override + public void onStopNestedScroll(View child) { + final int dragDisplacement = dragView.getTop() - dragViewTop; + if (dragDisplacement == 0) return; + + // check if we should perform a dismiss or settle back into place + final boolean dismiss = + lastNestedScrollWasDownward && dragDisplacement >= dragDismissDistance; + // animate either back into place or to bottom + ObjectAnimator settleAnim = ObjectAnimator.ofInt(dragViewOffsetHelper, + ViewOffsetHelper.OFFSET_Y, + dragView.getTop(), + dismiss ? dragViewBottom : dragViewTop); + settleAnim.setDuration(200L); + settleAnim.setInterpolator(AnimationUtils.loadInterpolator(getContext(), + android.R.interpolator.fast_out_slow_in)); + if (dismiss) { + settleAnim.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + dispatchDismissCallback(); + } + }); + } + settleAnim.start(); + } + + protected void dispatchDismissCallback() { + if (listeners != null && listeners.size() > 0) { + for (Listener listener : listeners) { + listener.onDragDismissed(); + } + } + } + + protected void dispatchDragCallback() { + if (listeners != null && listeners.size() > 0) { + for (Listener listener : listeners) { + listener.onDrag(getTop()); + } + } + } + + private boolean isDraggableViewUnder(int x, int y) { + return getVisibility() == VISIBLE && viewDragHelper.isViewUnder(this, x, y); + } + + private ViewDragHelper.Callback dragHelperCallbacks = new ViewDragHelper.Callback() { + + @Override + public boolean tryCaptureView(View child, int pointerId) { + // if we have a scrolling child and it can scroll then don't drag, it'll be handled + // by nested scrolling + boolean childCanScroll = scrollingChild != null + && (scrollingChild.canScrollVertically(1) + || scrollingChild.canScrollVertically(-1)); + return !childCanScroll; + } + + @Override + public int clampViewPositionVertical(View child, int top, int dy) { + return Math.min(Math.max(top, dragViewTop), dragViewBottom); + } + + @Override + public int clampViewPositionHorizontal(View child, int left, int dx) { + return dragViewLeft; + } + + @Override + public int getViewVerticalDragRange(View child) { + return dragViewBottom - dragViewTop; + } + + @Override + public void onViewPositionChanged(View child, int left, int top, int dx, int dy) { + dispatchDragCallback(); + } + + @Override + public void onViewReleased(View releasedChild, float velocityX, float velocityY) { + if (velocityY >= FLING_VELOCITY) { + isDismissing = true; + doDismiss(); + } else { + // settle back into position + viewDragHelper.settleCapturedViewAt(dragViewLeft, dragViewTop); + } + ViewCompat.postInvalidateOnAnimation(BottomSheet.this); + } + + @Override + public void onViewDragStateChanged(int state) { + if (isDismissing && state == ViewDragHelper.STATE_IDLE) { + isDismissing = false; + dispatchDismissCallback(); + } + } + }; +} diff --git a/app/src/main/java/io/plaidapp/ui/widget/CheckableImageButton.java b/app/src/main/java/io/plaidapp/ui/widget/CheckableImageButton.java new file mode 100644 index 000000000..0accb1966 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/widget/CheckableImageButton.java @@ -0,0 +1,73 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.SoundEffectConstants; +import android.widget.Checkable; +import android.widget.ImageButton; + +/** + * An extension to {@link ImageButton} which implements the {@link Checkable} interface. + */ +public class CheckableImageButton extends ImageButton implements Checkable { + + private static final int[] CHECKED_STATE_SET = {android.R.attr.state_checked}; + + private boolean isChecked = false; + + public CheckableImageButton(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public boolean isChecked() { + return isChecked; + } + + public void setChecked(boolean isChecked) { + if (this.isChecked != isChecked) { + this.isChecked = isChecked; + refreshDrawableState(); + } + } + + public void toggle() { + setChecked(!isChecked); + } + + @Override // borrowed from CompoundButton#performClick() + public boolean performClick() { + toggle(); + final boolean handled = super.performClick(); + if (!handled) { + // View only makes a sound effect if the onClickListener was + // called, so we'll need to make one here instead. + playSoundEffect(SoundEffectConstants.CLICK); + } + return handled; + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + if (isChecked()) { + mergeDrawableStates(drawableState, CHECKED_STATE_SET); + } + return drawableState; + } +} diff --git a/app/src/main/java/io/plaidapp/ui/widget/CollapsingTitleLayout.java b/app/src/main/java/io/plaidapp/ui/widget/CollapsingTitleLayout.java new file mode 100644 index 000000000..4849cbc5f --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/widget/CollapsingTitleLayout.java @@ -0,0 +1,390 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.widget; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.os.Build; +import android.support.v4.view.GravityCompat; +import android.support.v4.view.ViewCompat; +import android.text.Layout; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.StaticLayout; +import android.text.TextPaint; +import android.text.TextUtils; +import android.text.style.ForegroundColorSpan; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.Gravity; +import android.widget.FrameLayout; + +import io.plaidapp.R; +import io.plaidapp.util.CollapsingTextHelper; +import io.plaidapp.util.ColorUtils; +import io.plaidapp.util.FontUtil; +import io.plaidapp.util.ViewUtils; + +/** + * A layout that draws a displayText and can collapse down to a condensed size. If the displayText shows over + * multiple lines then it will fade out line by line as it collapses. It displayText is a single line then + * text is displayed as large as possible initially and scaled down to fit the collapsed state. + */ +public class CollapsingTitleLayout extends FrameLayout { + + private static final float density = 420f / 160f; + + // configurable attributes + private int titleInsetStart; + private float titleInsetTop; + private int titleInsetEnd; + private int titleInsetBottom; + private float collapsedTextSize; + private float maxExpandedTextSize; + private float lineHeightHint; + private int maxLines; + + // state + private CharSequence title; + private SpannableStringBuilder displayText; + private TextPaint paint; + private float textTop; + private float scrollOffset; + private int scrollRange; + private float collapsedHeight; + private CollapsingTextHelper collapsingText; + private StaticLayout layout; + private Line[] lines; + private int lineCount; + + public CollapsingTitleLayout(Context context) { + this(context, null, 0, 0); + } + + public CollapsingTitleLayout(Context context, AttributeSet attrs) { + this(context, attrs, 0, 0); + } + + public CollapsingTitleLayout(Context context, AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public CollapsingTitleLayout(Context context, AttributeSet attrs, int defStyleAttr, + int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + setWillNotDraw(false); + paint = new TextPaint(Paint.ANTI_ALIAS_FLAG); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CollapsingTitleLayout); + final boolean isRtl = ViewCompat.getLayoutDirection(this) + == ViewCompat.LAYOUT_DIRECTION_RTL; + + // first check if all insets set the same + titleInsetStart = titleInsetEnd = titleInsetBottom = + a.getDimensionPixelSize(R.styleable.CollapsingTitleLayout_titleInset, 0); + titleInsetTop = titleInsetStart; + + if (a.hasValue(R.styleable.CollapsingTitleLayout_titleInsetStart)) { + final int insetStart = a.getDimensionPixelSize( + R.styleable.CollapsingTitleLayout_titleInsetStart, 0); + if (isRtl) { + titleInsetEnd = insetStart; + } else { + titleInsetStart = insetStart; + } + } + if (a.hasValue(R.styleable.CollapsingTitleLayout_titleInsetTop)) { + titleInsetTop = a.getDimensionPixelSize( + R.styleable.CollapsingTitleLayout_titleInsetTop, 0); + } + if (a.hasValue(R.styleable.CollapsingTitleLayout_titleInsetEnd)) { + final int insetEnd = a.getDimensionPixelSize( + R.styleable.CollapsingTitleLayout_titleInsetEnd, 0); + if (isRtl) { + titleInsetStart = insetEnd; + } else { + titleInsetEnd = insetEnd; + } + } + if (a.hasValue(R.styleable.CollapsingTitleLayout_titleInsetBottom)) { + titleInsetBottom = a.getDimensionPixelSize( + R.styleable.CollapsingTitleLayout_titleInsetBottom, 0); + } + + final int textAppearance = a.getResourceId(R.styleable.CollapsingTitleLayout_android_textAppearance, + android.R.style.TextAppearance); + TypedArray atp = getContext().obtainStyledAttributes(textAppearance, + R.styleable.CollapsingTextAppearance); + paint.setColor(atp.getColor(R.styleable.CollapsingTextAppearance_android_textColor, + Color.WHITE)); + collapsedTextSize = atp.getDimensionPixelSize( + R.styleable.CollapsingTextAppearance_android_textSize, 0); + if (atp.hasValue(R.styleable.CollapsingTextAppearance_font)) { + paint.setTypeface(FontUtil.get(getContext(), + atp.getString(R.styleable.CollapsingTextAppearance_font))); + } + atp.recycle(); + + if (a.hasValue(R.styleable.CollapsingTitleLayout_collapsedTextSize)) { + collapsedTextSize = a.getDimensionPixelSize( + R.styleable.CollapsingTitleLayout_collapsedTextSize, 0); + paint.setTextSize(collapsedTextSize); + } + + maxExpandedTextSize = a.getDimensionPixelSize( + R.styleable.CollapsingTitleLayout_maxExpandedTextSize, Integer.MAX_VALUE); + lineHeightHint = + a.getDimensionPixelSize(R.styleable.CollapsingTitleLayout_lineHeightHint, 0); + maxLines = a.getInteger(R.styleable.CollapsingTitleLayout_android_maxLines, 5); + a.recycle(); + } + + public void setTitle(CharSequence title) { + this.title = title; + this.displayText = new SpannableStringBuilder(title); + } + + public void setScrollPixelOffset(int offset) { + if (scrollOffset != offset) { + scrollOffset = offset; + + if (lineCount == 1) { + setScrollOffsetSingleLine(); + } else { + setScrollOffsetMultiLine(); + } + ViewCompat.postInvalidateOnAnimation(this); + } + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + if (lineCount == 1) { + collapsingText.draw(canvas); + } else { + float x = titleInsetStart; + float y = Math.max(textTop - scrollOffset, titleInsetTop); + canvas.translate(x, y); + canvas.clipRect(0, 0, + getWidth() - titleInsetStart - titleInsetEnd, + Math.max(getHeight() - scrollOffset, collapsedHeight) - y); + layout.draw(canvas); + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + final int width = MeasureSpec.getSize(widthMeasureSpec); + + recalculate(width); + + final int desiredHeight = getDesiredHeight(); + int height; + switch (MeasureSpec.getMode(heightMeasureSpec)) { + case MeasureSpec.EXACTLY: + height = MeasureSpec.getSize(heightMeasureSpec); + break; + case MeasureSpec.AT_MOST: + height = Math.min(desiredHeight, MeasureSpec.getSize(heightMeasureSpec)); + break; + default: // MeasureSpec.UNSPECIFIED + height = desiredHeight; + break; + } + setMeasuredDimension(width, height); + measureChildren(widthMeasureSpec, MeasureSpec.makeMeasureSpec(height, MeasureSpec.AT_MOST)); + } + + private int getDesiredHeight() { + if (layout == null) return getMinimumHeight(); + return Math.max( + (int) (titleInsetTop + layout.getHeight() + titleInsetBottom), getMinimumHeight()); + } + + private void recalculate(int width) { + + // reset stateful objects that might change over measure passes + paint.setTextSize(collapsedTextSize); + displayText = new SpannableStringBuilder(title); + + // Calculate line height; ensure it' a multiple of 4dp to sit on the grid + final float fourDip = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 4, + getResources().getDisplayMetrics()); + Paint.FontMetricsInt fm = paint.getFontMetricsInt(); + int fontHeight = Math.abs(fm.ascent - fm.descent) + fm.leading; + final int baselineAlignedLineHeight = + (int) (fourDip * (float) Math.ceil(lineHeightHint / fourDip)); + final int lineSpacingAdd = baselineAlignedLineHeight - fontHeight; + + // now create the layout with our desired insets & line height + createLayout(width, lineSpacingAdd); + + // adjust the displayText top inset to vertically center text with the toolbar + collapsedHeight = (int) Math.max(ViewUtils.getActionBarSize(getContext()), + (fourDip + baselineAlignedLineHeight + fourDip)); + titleInsetTop = (collapsedHeight - baselineAlignedLineHeight) / 2f; + + if (lineCount == 1) { // single line mode + layout = null; + collapsingText = new CollapsingTextHelper(this); + collapsingText.setText(title); + + collapsingText.setCollapsedBounds(titleInsetStart, + 0, + width - titleInsetEnd, + (int) collapsedHeight); + + collapsingText.setExpandedBounds(titleInsetStart, + (int) titleInsetTop, + width - titleInsetEnd, + getMinimumHeight() - titleInsetBottom); + collapsingText.setCollapsedTextColor(paint.getColor()); + collapsingText.setExpandedTextColor(paint.getColor()); + collapsingText.setCollapsedTextSize(collapsedTextSize); + + int expandedTitleTextSize = (int) Math.max(collapsedTextSize, + ViewUtils.getSingleLineTextSize(displayText.toString(), paint, + width - titleInsetStart - titleInsetEnd, + collapsedTextSize, + maxExpandedTextSize, 0.5f, getResources().getDisplayMetrics())); + collapsingText.setExpandedTextSize(expandedTitleTextSize); + + collapsingText.setExpandedTextGravity(GravityCompat.START | Gravity.BOTTOM); + collapsingText.setCollapsedTextGravity(GravityCompat.START | Gravity.CENTER_VERTICAL); + collapsingText.setTypeface(paint.getTypeface()); + + fm = paint.getFontMetricsInt(); + fontHeight = Math.abs(fm.ascent - fm.descent) + fm.leading; + textTop = getHeight() - titleInsetBottom - fontHeight; + scrollRange = getMinimumHeight() - (int) collapsedHeight; + } else { // multi-line mode + // bottom align the text + textTop = getDesiredHeight() - titleInsetBottom - layout.getHeight(); + + // pre-calculate at what scroll offsets lines should disappear + scrollRange = (int) (textTop - titleInsetTop); + final int fadeDistance = lineSpacingAdd + fm.descent; // line bottom to baseline + lines = new Line[lineCount]; + for (int i = 1; i < lineCount; i++) { + int lineBottomScrollOffset = + scrollRange + ((lineCount - i - 1) * baselineAlignedLineHeight); + lines[i] = new Line( + layout.getLineStart(i), + layout.getLineEnd(i), + new ForegroundColorSpan(paint.getColor()), + lineBottomScrollOffset, + lineBottomScrollOffset + fadeDistance); + } + } + } + + private void createLayout(int width, int lineSpacingAdd) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + createLayoutM(width, lineSpacingAdd); + } else { + createLayoutPreM(width, lineSpacingAdd); + } + } + + @TargetApi(Build.VERSION_CODES.M) + private void createLayoutM(int width, int lineSpacingAdd) { + layout = StaticLayout.Builder.obtain(displayText, 0, displayText.length(), paint, + width - titleInsetStart - titleInsetEnd) + .setLineSpacing(lineSpacingAdd, 1f) + .setMaxLines(maxLines) + .setEllipsize(TextUtils.TruncateAt.END) + .build(); + lineCount = layout.getLineCount(); + } + + private void createLayoutPreM(int width, int lineSpacingAdd) { + layout = new StaticLayout(displayText, + paint, + width - titleInsetStart - titleInsetEnd, + Layout.Alignment.ALIGN_NORMAL, + 1f, + lineSpacingAdd, + true); + lineCount = layout.getLineCount(); + + if (lineCount > maxLines) { + // if it exceeds our max number of lines then truncate the displayText & recreate the layout + int endIndex = layout.getLineEnd(maxLines - 1) - 2; // minus 2 chars for the ellipse + displayText = new SpannableStringBuilder(title.subSequence(0, endIndex) + "…"); + layout = new StaticLayout(displayText, + paint, + width - titleInsetStart - titleInsetEnd, + Layout.Alignment.ALIGN_NORMAL, + 1f, + lineSpacingAdd, + true); + lineCount = maxLines; + } + } + + private void setScrollOffsetSingleLine() { + // see how far we have scrolled as a fraction of the scroll range + collapsingText.setExpansionFraction(Math.min(scrollOffset, scrollRange) / scrollRange); + } + + private void setScrollOffsetMultiLine() { + // loop over each line and check/set an appropriate alpha for the current scroll offset + for (int i = 1; i < lineCount; i++) { + Line line = lines[i]; + float lineAlpha = 1f; + if (scrollOffset >= line.zeroAlphaScrollOffset) { + lineAlpha = 0f; + } else if (scrollOffset <= line.fullAlphaScrollOffset) { + lineAlpha = 1f; + } else if (scrollOffset > line.fullAlphaScrollOffset && scrollOffset < line.zeroAlphaScrollOffset) { + lineAlpha = 1f - (scrollOffset - line.zeroAlphaScrollOffset) + / (line.zeroAlphaScrollOffset - line.fullAlphaScrollOffset); + } + if (line.currentAlpha != lineAlpha) { + displayText.removeSpan(line.span); + line.span = new ForegroundColorSpan(ColorUtils.modifyAlpha(paint.getColor(), lineAlpha)); + displayText.setSpan(line.span, line.startIndex, line.endIndex, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + line.currentAlpha = lineAlpha; + } + } + } + + private class Line { + public int startIndex; + public int endIndex; + public ForegroundColorSpan span; + public int fullAlphaScrollOffset; + public int zeroAlphaScrollOffset; + public float currentAlpha = 1f; + + public Line(int startIndex, int endIndex, ForegroundColorSpan span, + int fullAlphaScrollOffset, int zeroAlphaScrollOffset) { + this.startIndex = startIndex; + this.endIndex = endIndex; + this.span = span; + this.zeroAlphaScrollOffset = zeroAlphaScrollOffset; + this.fullAlphaScrollOffset = fullAlphaScrollOffset; + } + } + +} diff --git a/app/src/main/java/io/plaidapp/ui/widget/CutoutTextView.java b/app/src/main/java/io/plaidapp/ui/widget/CutoutTextView.java new file mode 100644 index 000000000..2d56545e4 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/widget/CutoutTextView.java @@ -0,0 +1,118 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.Rect; +import android.text.TextPaint; +import android.util.AttributeSet; +import android.view.View; + +import io.plaidapp.R; +import io.plaidapp.util.FontUtil; + +/** + * A view which punches out some text from an opaque color block, allowing you to see through it. + */ +public class CutoutTextView extends View { + + private final TextPaint textPaint; + private Bitmap cutout; + private int foregroundColor = 0xfffafafa; + private String text; + private float textSize; + private float textY; + private float textX; + + public CutoutTextView(Context context, AttributeSet attrs) { + super(context, attrs); + + textPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.SUBPIXEL_TEXT_FLAG); + + final TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable + .CutoutTextView, 0, 0); + if (a.hasValue(R.styleable.CutoutTextView_font)) { + textPaint.setTypeface(FontUtil.get(context, a.getString(R.styleable + .CutoutTextView_font))); + } + if (a.hasValue(R.styleable.CutoutTextView_android_foreground)) { + foregroundColor = a.getColor(R.styleable.CutoutTextView_android_foreground, + foregroundColor); + } + if (a.hasValue(R.styleable.CutoutTextView_android_text)) { + text = a.getString(R.styleable.CutoutTextView_android_text); + } + a.recycle(); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + calculateTextPosition(); + createBitmap(); + } + + private void calculateTextPosition() { + float targetWidth = getWidth() / 1.6182f; + textSize = getTextSize(targetWidth); + textPaint.setTextSize(textSize); + + // measuring text is fun :] see: https://chris.banes.me/2014/03/27/measuring-text/ + textX = (getWidth() - textPaint.measureText(text)) / 2; + Rect textBounds = new Rect(); + textPaint.getTextBounds(text, 0, text.length(), textBounds); + float textHeight = textBounds.height(); + textY = (getHeight() + textHeight) / 2; // note that when drawing text the 'Y' co-ord is + // the bottom!?! + } + + private float getTextSize(float targetWidth) { + + // todo calculate best text size for the given width + return 392f; + } + + private void createBitmap() { + if (cutout != null && !cutout.isRecycled()) { + cutout.recycle(); + } + cutout = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888); + cutout.eraseColor(foregroundColor); + cutout.setHasAlpha(true); + Canvas cutoutCanvas = new Canvas(cutout); + + // this is the magic – Clear mode punches out the bitmap + textPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); + cutoutCanvas.drawText(text, textX, textY, textPaint); + } + + @Override + protected void onDraw(Canvas canvas) { + canvas.drawBitmap(cutout, 0, 0, null); + } + + @Override + public boolean hasOverlappingRendering() { + return true; + } +} diff --git a/app/src/main/java/io/plaidapp/ui/widget/DynamicTextView.java b/app/src/main/java/io/plaidapp/ui/widget/DynamicTextView.java new file mode 100644 index 000000000..be7adc3fd --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/widget/DynamicTextView.java @@ -0,0 +1,268 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.Typeface; +import android.text.Layout; +import android.text.StaticLayout; +import android.text.TextPaint; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.widget.TextView; + +import io.plaidapp.R; + +/** + * TODO: document your custom view class. + */ +public class DynamicTextView extends TextView { + + private static MaterialTypeStyle[] mStyles = { + new MaterialTypeStyle(112, "sans-serif-light", 0x8a), /* Display 4 */ + new MaterialTypeStyle(56, "sans-serif", 0x8a), /* Display 3 */ + new MaterialTypeStyle(45, "sans-serif", 0x8a), /* Display 2 */ + new MaterialTypeStyle(34, "sans-serif", 0x8a), /* Display 1 */ + new MaterialTypeStyle(24, "sans-serif", 0xde), /* Headline */ + new MaterialTypeStyle(20, "sans-serif-medium", 0xde) /* Title */ + }; + private boolean mSnapToMaterialScale; + private int mMinTextSize; + private int mMaxTextSize; + private float scaledDensity; + private boolean mCalculated = false; + + public DynamicTextView(Context context) { + super(context); + init(null, 0); + } + + public DynamicTextView(Context context, AttributeSet attrs) { + super(context, attrs); + init(attrs, 0); + } + + public DynamicTextView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(attrs, defStyle); + } + + private void init(AttributeSet attrs, int defStyle) { + scaledDensity = getContext().getResources().getDisplayMetrics().scaledDensity; + // Load attributes + final TypedArray a = getContext().obtainStyledAttributes( + attrs, R.styleable.DynamicTextView, defStyle, 0); + + mSnapToMaterialScale = a.getBoolean(R.styleable.DynamicTextView_snapToMaterialScale, true); + mMinTextSize = a.getDimensionPixelSize( + R.styleable.DynamicTextView_minTextSize, + (int) (20 * scaledDensity)); + + mMaxTextSize = a.getDimensionPixelSize( + R.styleable.DynamicTextView_maxTextSize, + (int) (112 * scaledDensity)); + + a.recycle(); + } + + private void fitText() { + + // different methods for achieving this depending on whether we are snapping to the material + // scale, and if multiple lines are allowed. 4 method for the permutations of this. + + if (mSnapToMaterialScale && getMaxLines() == 1) { + // technically we could use the multi line algorithm here but this is more efficient + fitSnappedSingleLine(); + } else if (mSnapToMaterialScale) { + fitSnappedMultiLine(); + } else if (!mSnapToMaterialScale && getMaxLines() == 1) { + fitSingleLine(); + } else if (!mSnapToMaterialScale) { + fitMultiline(); + } + } + + private void fitSnappedMultiLine() { + int targetWidth = getWidth() - getPaddingLeft() - getPaddingRight(); + int targetHeight = getHeight() - getPaddingTop() - getPaddingBottom(); + if (targetWidth > 0 && targetHeight > 0) { + int style = 0; + MaterialTypeStyle currentStyle = mStyles[style]; + TextPaint paint = getPaint(); + StaticLayout staticLayout = null; + int currentHeight = Integer.MAX_VALUE; + int lines = 0; + boolean maxLinesSet = getMaxLines() != Integer.MAX_VALUE; + + while ((currentHeight > targetHeight || (maxLinesSet && lines > getMaxLines())) + && style <= mStyles.length - 1 + && currentStyle.size * scaledDensity >= mMinTextSize + && currentStyle.size * scaledDensity <= mMaxTextSize) { + currentStyle = mStyles[style]; + paint.setTextSize(currentStyle.size * scaledDensity); + paint.setTypeface(Typeface.create(currentStyle.fontFamily, Typeface.NORMAL)); + staticLayout = new StaticLayout(getText(), paint, targetWidth, Layout.Alignment + .ALIGN_NORMAL, 1.0f, 0.0f, true); + currentHeight = staticLayout.getHeight(); + lines = staticLayout.getLineCount(); + style++; + } + super.setTextSize(TypedValue.COMPLEX_UNIT_SP, currentStyle.size); + setTypeface(Typeface.create(currentStyle.fontFamily, Typeface.NORMAL)); + + int currentColour = getCurrentTextColor(); + setTextColor(Color.argb(currentStyle.opacity, + Color.red(currentColour), + Color.green(currentColour), + Color.blue(currentColour))); + + if (style == mStyles.length) { + setEllipsize(TextUtils.TruncateAt.END); + } + if (currentStyle.size * scaledDensity < mMinTextSize) { + // wanted to make text smaller but hit min text size. Need to set max lines. + setMaxLines((int) Math.floor((((float) targetHeight / (float) currentHeight) * + lines))); + setEllipsize(TextUtils.TruncateAt.END); + } + setTextAlignment(TEXT_ALIGNMENT_TEXT_START); + mCalculated = true; + } + } + + private void fitSnappedSingleLine() { + int targetWidth = getWidth() - getPaddingLeft() - getPaddingRight(); + if (targetWidth > 0) { + int style = 0; + TextPaint paint = getPaint(); + final String text = getText().toString(); + MaterialTypeStyle currentStyle = null; + float currentWidth = Float.MAX_VALUE; + + while (currentWidth > targetWidth && style < mStyles.length) { + currentStyle = mStyles[style]; + paint.setTextSize(currentStyle.size * scaledDensity); + paint.setTypeface(Typeface.create(currentStyle.fontFamily, Typeface.NORMAL)); + currentWidth = paint.measureText(text); + style++; + } + setTextSize(TypedValue.COMPLEX_UNIT_SP, currentStyle.size); + setTypeface(Typeface.create(currentStyle.fontFamily, Typeface.NORMAL)); + + int currentColour = getCurrentTextColor(); + setTextColor(Color.argb(currentStyle.opacity, + Color.red(currentColour), + Color.green(currentColour), + Color.blue(currentColour))); + + if (style == mStyles.length) { + setEllipsize(TextUtils.TruncateAt.END); + } + mCalculated = true; + } + } + + private void fitMultiline() { + int targetWidth = getWidth() - getPaddingLeft() - getPaddingRight(); + int targetHeight = getHeight() - getPaddingTop() - getPaddingBottom(); + if (targetWidth > 0 && targetHeight > 0) { + int textSize = mMaxTextSize; + TextPaint paint = getPaint(); + paint.setTextSize(textSize); + StaticLayout staticLayout = new StaticLayout(getText(), paint, targetWidth, Layout + .Alignment.ALIGN_NORMAL, 1.0f, 0.0f, true); + int currentHeight = staticLayout.getHeight(); + + while (currentHeight > targetHeight && textSize > mMinTextSize) { + textSize--; + paint.setTextSize(textSize); + staticLayout = new StaticLayout(getText(), paint, targetWidth, Layout.Alignment + .ALIGN_NORMAL, 1.0f, 0.0f, true); + currentHeight = staticLayout.getHeight(); + } + setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize); + setTextAlignment(TEXT_ALIGNMENT_TEXT_START); + mCalculated = true; + } + } + + private void fitSingleLine() { + int targetWidth = getWidth() - getPaddingLeft() - getPaddingRight(); + if (targetWidth > 0) { + int textSize = mMaxTextSize; + TextPaint paint = getPaint(); + paint.setTextSize(textSize); + final String text = getText().toString(); + float currentWidth = paint.measureText(text); + + while (currentWidth > targetWidth && textSize > mMinTextSize) { + textSize--; + paint.setTextSize(textSize); + currentWidth = paint.measureText(text); + } + setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize); + mCalculated = true; + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + if (!mCalculated) { + fitText(); + } + } + + public boolean isSnapToMaterialScale() { + return mSnapToMaterialScale; + } + + public void setSnapToMaterialScale(boolean snapToMaterialScale) { + this.mSnapToMaterialScale = snapToMaterialScale; + } + + public int getMinTextSize() { + return mMinTextSize; + } + + public void setMinTextSize(int minTextSize) { + this.mMinTextSize = minTextSize; + } + + public int getMaxTextSize() { + return mMaxTextSize; + } + + public void setMaxTextSize(int maxTextSize) { + this.mMaxTextSize = maxTextSize; + } + + private static class MaterialTypeStyle { + int size; + String fontFamily; + int opacity; + + MaterialTypeStyle(int size, String fontFamily, int opacity) { + this.size = size; + this.fontFamily = fontFamily; + this.opacity = opacity; + } + } +} diff --git a/app/src/main/java/io/plaidapp/ui/widget/ElasticDragDismissFrameLayout.java b/app/src/main/java/io/plaidapp/ui/widget/ElasticDragDismissFrameLayout.java new file mode 100644 index 000000000..cf0f30a15 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/widget/ElasticDragDismissFrameLayout.java @@ -0,0 +1,280 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.view.View; +import android.view.Window; +import android.view.animation.AnimationUtils; +import android.widget.FrameLayout; + +import java.util.ArrayList; +import java.util.List; + +import io.plaidapp.R; +import io.plaidapp.util.ColorUtils; + +/** + * A {@link FrameLayout} which responds to nested scrolls to create drag-dismissable layouts. + * Applies an elasticity factor to reduce movement as you approach the given dismiss distance. + * Optionally also scales down content during drag. + */ +public class ElasticDragDismissFrameLayout extends FrameLayout { + + // configurable attribs + private float dragDismissDistance = Float.MAX_VALUE; + private float dragDismissFraction = -1f; + private float dragDismissScale = 1f; + private boolean shouldScale = false; + private float dragElacticity = 0.8f; + + // state + private float totalDrag; + private boolean draggingDown = false; + private boolean draggingUp = false; + + private List listeners; + + public ElasticDragDismissFrameLayout(Context context) { + this(context, null, 0, 0); + } + + public ElasticDragDismissFrameLayout(Context context, AttributeSet attrs) { + this(context, attrs, 0, 0); + } + + public ElasticDragDismissFrameLayout(Context context, AttributeSet attrs, + int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public ElasticDragDismissFrameLayout(Context context, AttributeSet attrs, + int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + + final TypedArray a = getContext().obtainStyledAttributes( + attrs, R.styleable.ElasticDragDismissFrameLayout, 0, 0); + + if (a.hasValue(R.styleable.ElasticDragDismissFrameLayout_dragDismissDistance)) { + dragDismissDistance = a.getDimensionPixelSize(R.styleable + .ElasticDragDismissFrameLayout_dragDismissDistance, 0); + } else if (a.hasValue(R.styleable.ElasticDragDismissFrameLayout_dragDismissFraction)) { + dragDismissFraction = a.getFloat(R.styleable + .ElasticDragDismissFrameLayout_dragDismissFraction, dragDismissFraction); + } + if (a.hasValue(R.styleable.ElasticDragDismissFrameLayout_dragDismissScale)) { + dragDismissScale = a.getFloat(R.styleable + .ElasticDragDismissFrameLayout_dragDismissScale, dragDismissScale); + shouldScale = dragDismissScale != 1f; + } + if (a.hasValue(R.styleable.ElasticDragDismissFrameLayout_dragElasticity)) { + dragElacticity = a.getFloat(R.styleable.ElasticDragDismissFrameLayout_dragElasticity, + dragElacticity); + } + a.recycle(); + } + + public interface ElasticDragDismissListener { + + /** + * Called for each drag event. + * + * @param elasticOffset Indicating the drag offset with elasticity applied i.e. may + * exceed 1. + * @param elasticOffsetPixels The elastically scaled drag distance in pixels. + * @param rawOffset Value from [0, 1] indicating the raw drag offset i.e. + * without elasticity applied. A value of 1 indicates that the + * dismiss distance has been reached. + * @param rawOffsetPixels The raw distance the user has dragged + */ + void onDrag(float elasticOffset, float elasticOffsetPixels, + float rawOffset, float rawOffsetPixels); + + /** + * Called when dragging is released and has exceeded the threshold dismiss distance. + */ + void onDragDismissed(); + + } + + @Override + public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) { + return (nestedScrollAxes & View.SCROLL_AXIS_VERTICAL) != 0; + } + + @Override + public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) { + // if we're in a drag gesture and the user reverses up the we should take those events + if (draggingDown && dy > 0 || draggingUp && dy < 0) { + dragScale(dy); + consumed[1] = dy; + } + } + + @Override + public void onNestedScroll(View target, int dxConsumed, int dyConsumed, + int dxUnconsumed, int dyUnconsumed) { + dragScale(dyUnconsumed); + } + + @Override + public void onStopNestedScroll(View child) { + if (Math.abs(totalDrag) >= dragDismissDistance) { + dispatchDismissCallback(); + } else { // settle back to natural position + animate() + .translationY(0f) + .scaleX(1f) + .scaleY(1f) + .setDuration(200L) + .setInterpolator(AnimationUtils.loadInterpolator(getContext(), android.R + .interpolator.fast_out_slow_in)) + .setListener(null) + .start(); + totalDrag = 0; + draggingDown = draggingUp = false; + dispatchDragCallback(0f, 0f, 0f, 0f); + } + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + if (dragDismissFraction > 0f) { + dragDismissDistance = h * dragDismissFraction; + } + } + + public void addListener(ElasticDragDismissListener listener) { + if (listeners == null) { + listeners = new ArrayList<>(); + } + listeners.add(listener); + } + + public void removeListener(ElasticDragDismissListener listener) { + if (listeners != null && listeners.size() > 0) { + listeners.remove(listener); + } + } + + private void dragScale(int scroll) { + if (scroll == 0) return; + + totalDrag += scroll; + + // track the direction & set the pivot point for scaling + // don't double track i.e. if start dragging down and then reverse, keep tracking as + // dragging down until they reach the 'natural' position + if (scroll < 0 && !draggingUp && !draggingDown) { + draggingDown = true; + if (shouldScale) setPivotY(getHeight()); + } else if (scroll > 0 && !draggingDown && !draggingUp) { + draggingUp = true; + if (shouldScale) setPivotY(0f); + } + // how far have we dragged relative to the distance to perform a dismiss + // (0–1 where 1 = dismiss distance). Decreasing logarithmically as we approach the limit + float dragFraction = (float) Math.log10(1 + (Math.abs(totalDrag) / dragDismissDistance)); + + // calculate the desired translation given the drag fraction + float dragTo = dragFraction * dragDismissDistance * dragElacticity; + + if (draggingUp) { + // as we use the absolute magnitude when calculating the drag fraction, need to + // re-apply the drag direction + dragTo *= -1; + } + setTranslationY(dragTo); + + if (shouldScale) { + final float scale = 1 - ((1 - dragDismissScale) * dragFraction); + setScaleX(scale); + setScaleY(scale); + } + + // if we've reversed direction and gone past the settle point then clear the flags to + // allow the list to get the scroll events & reset any transforms + if ((draggingDown && totalDrag >= 0) + || (draggingUp && totalDrag <= 0)) { + totalDrag = dragTo = dragFraction = 0; + draggingDown = draggingUp = false; + setTranslationY(0f); + setScaleX(1f); + setScaleY(1f); + } + dispatchDragCallback(dragFraction, dragTo, + Math.min(1f, Math.abs(totalDrag) / dragDismissDistance), totalDrag); + } + + private void dispatchDragCallback(float elasticOffset, float elasticOffsetPixels, + float rawOffset, float rawOffsetPixels) { + if (listeners != null && listeners.size() > 0) { + for (ElasticDragDismissListener listener : listeners) { + listener.onDrag(elasticOffset, elasticOffsetPixels, + rawOffset, rawOffsetPixels); + } + } + } + + private void dispatchDismissCallback() { + if (listeners != null && listeners.size() > 0) { + for (ElasticDragDismissListener listener : listeners) { + listener.onDragDismissed(); + } + } + } + + /** + * An {@link ElasticDragDismissListener} which fades system chrome (i.e. status bar and + * navigation bar) when elastic drags are performed. Consuming classes must provide the + * implementation for {@link ElasticDragDismissListener#onDragDismissed()}. + */ + public static abstract class SystemChromeFader implements ElasticDragDismissListener { + + private Window window; + + public SystemChromeFader(Window window) { + this.window = window; + } + + @Override + public void onDrag(float elasticOffset, float elasticOffsetPixels, + float rawOffset, float rawOffsetPixels) { + if (elasticOffsetPixels < 0) { + // dragging upward, fade the navigation bar in proportion + // TODO don't fade nav bar on landscape phones? + window.setNavigationBarColor(ColorUtils.modifyAlpha(window.getNavigationBarColor(), + 1f - rawOffset)); + } else if (elasticOffsetPixels == 0) { + // reset + window.setStatusBarColor(ColorUtils.modifyAlpha(window.getStatusBarColor(), 1f)); + window.setNavigationBarColor( + ColorUtils.modifyAlpha(window.getNavigationBarColor(), 1f)); + } else { + // dragging downward, fade the status bar in proportion + window.setStatusBarColor(ColorUtils.modifyAlpha(window + .getStatusBarColor(), 1f - rawOffset)); + } + } + + public abstract void onDragDismissed(); + } + +} diff --git a/app/src/main/java/io/plaidapp/ui/widget/FABToggle.java b/app/src/main/java/io/plaidapp/ui/widget/FABToggle.java new file mode 100644 index 000000000..a95758d99 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/widget/FABToggle.java @@ -0,0 +1,74 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.widget; + +import android.content.Context; +import android.support.v4.view.ViewCompat; +import android.util.AttributeSet; +import android.widget.Checkable; +import android.widget.ImageButton; + +/** + * A {@link Checkable} {@link ImageButton} which has a minimum offset i.e. translation Y. + */ +public class FABToggle extends ImageButton implements Checkable { + + private static final int[] CHECKED_STATE_SET = {android.R.attr.state_checked}; + + private boolean isChecked = false; + private int minOffset; + + public FABToggle(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public void setOffset(int offset) { + offset = Math.max(minOffset, offset); + if (getTranslationY() != offset) { + setTranslationY(offset); + ViewCompat.postInvalidateOnAnimation(this); + } + } + + public void setMinOffset(int minOffset) { + this.minOffset = minOffset; + } + + public boolean isChecked() { + return isChecked; + } + + public void setChecked(boolean isChecked) { + if (this.isChecked != isChecked) { + this.isChecked = isChecked; + refreshDrawableState(); + } + } + + public void toggle() { + setChecked(!isChecked); + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + if (isChecked()) { + mergeDrawableStates(drawableState, CHECKED_STATE_SET); + } + return drawableState; + } +} diff --git a/app/src/main/java/io/plaidapp/ui/widget/FabOverlapTextView.java b/app/src/main/java/io/plaidapp/ui/widget/FabOverlapTextView.java new file mode 100644 index 000000000..b24fcacaa --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/widget/FabOverlapTextView.java @@ -0,0 +1,300 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Typeface; +import android.support.annotation.ColorInt; +import android.support.v4.view.GravityCompat; +import android.text.StaticLayout; +import android.text.TextPaint; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.View; + +import io.plaidapp.R; +import io.plaidapp.util.FontUtil; + +/** + * A view for displaying text that is will be overlapped by a Floating Action Button (FAB). + * This view will indent itself at the given overlap point (as specified by + * {@link #setFabOverlapGravity(int)}) to flow around it. + *

+ * Not actually a TextView but conforms to many of it's idioms. + */ +public class FabOverlapTextView extends View { + + private static final int DEFAULT_TEXT_SIZE_SP = 14; + + private int fabOverlapHeight; + private int fabOverlapWidth; + private int fabGravity; + private int lineHeightHint; + private int topPaddingHint; + private StaticLayout layout; + private CharSequence text; + private TextPaint paint; + private int fabId; + private View fabView; + + public FabOverlapTextView(Context context) { + super(context); + init(context, null, 0, 0); + } + + public FabOverlapTextView(Context context, AttributeSet attrs) { + super(context, attrs); + init(context, attrs, 0, 0); + } + + public FabOverlapTextView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context, attrs, defStyleAttr, 0); + } + + public FabOverlapTextView(Context context, AttributeSet attrs, int defStyleAttr, int + defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(context, attrs, defStyleAttr, defStyleRes); + } + + private void init(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + // Attribute initialization. + paint = new TextPaint(Paint.ANTI_ALIAS_FLAG); + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.FabOverlapTextView); + + fabId = a.getResourceId(R.styleable.FabOverlapTextView_fabId, 0); + setFabOverlapGravity(a.getInt(R.styleable.FabOverlapTextView_fabGravity, Gravity.BOTTOM | + Gravity.RIGHT)); + setFabOverlapHeight(a.getDimensionPixelSize(R.styleable + .FabOverlapTextView_fabOverlayHeight, 0)); + setFabOverlapWidth(a.getDimensionPixelSize(R.styleable + .FabOverlapTextView_fabOverlayWidth, 0)); + + // TODO handle TextAppearance + /*if (a.hasValue(R.styleable.FabOverlapTextView_android_textAppearance)) { + final int textAppearanceId = a.getResourceId(R.styleable + .FabOverlapTextView_android_textAppearance, + android.R.style.TextAppearance); + setTextAppearance(textAppearanceId); + }*/ + + if (a.hasValue(R.styleable.FabOverlapTextView_font)) { + setFont(a.getString(R.styleable.FabOverlapTextView_font)); + } + + if (a.hasValue(R.styleable.FabOverlapTextView_android_textColor)) { + setTextColor(a.getColor(R.styleable.FabOverlapTextView_android_textColor, 0)); + } + + float defaultTextSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, + DEFAULT_TEXT_SIZE_SP, getResources().getDisplayMetrics()); + setTextSize(a.getDimensionPixelSize(R.styleable.FabOverlapTextView_android_textSize, + (int) defaultTextSize)); + + lineHeightHint = a.getDimensionPixelSize(R.styleable.FabOverlapTextView_lineHeightHint, 0); + topPaddingHint = a.getDimensionPixelSize(R.styleable.FabOverlapTextView_topPaddingHint, 0); + + a.recycle(); + } + + public void setFabOverlapGravity(int fabGravity) { + // we only really support [top|bottom][left|right|start|end] + // TODO validate input + this.fabGravity = GravityCompat.getAbsoluteGravity(fabGravity, getLayoutDirection()); + } + + public void setFabOverlapHeight(int fabOverlapHeight) { + this.fabOverlapHeight = fabOverlapHeight; + } + + public void setFabOverlapWidth(int fabOverlapWidth) { + this.fabOverlapWidth = fabOverlapWidth; + } + + public void setText(CharSequence text) { + this.text = text; + layout = null; + recompute(getWidth()); + requestLayout(); + } + + public void setTextSize(int textSize) { + paint.setTextSize(textSize); + } + + public void setTextColor(@ColorInt int color) { + paint.setColor(color); + } + + public void setTypeface(Typeface typeface) { + paint.setTypeface(typeface); + } + + public void setFont(String font) { + setTypeface(FontUtil.get(getContext(), font)); + } + + public void setLetterSpacing(float letterSpacing) { + paint.setLetterSpacing(letterSpacing); + } + + public void setFontFeatureSettings(String fontFeatureSettings) { + paint.setFontFeatureSettings(fontFeatureSettings); + } + +// @Override +// protected void onAttachedToWindow() { +// super.onAttachedToWindow(); +// if (fabView == null) { +// fabView = getRootView().findViewById(fabId); +// } +// } +// +// @Override +// protected void onDetachedFromWindow() { +// fabView = null; +// super.onDetachedFromWindow(); +// } + + private void recompute(int width) { + if (text != null) { + // work out the top padding and line height to align text to a 4dp grid + float fourDip = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 4, + getResources().getDisplayMetrics()); + + // Ensure that the first line's baselines sits on 4dp grid by setting the top padding + Paint.FontMetricsInt fm = paint.getFontMetricsInt(); + int gridAlignedTopPadding = (int) (fourDip * (float) + Math.ceil((topPaddingHint + Math.abs(fm.ascent)) / fourDip) + - Math.ceil(Math.abs(fm.ascent))); + setPadding(getPaddingLeft(), gridAlignedTopPadding, getPaddingRight(), + getPaddingBottom()); + + // Ensures line height is a multiple of 4dp + int fontHeight = Math.abs(fm.ascent - fm.descent) + fm.leading; + int baselineAlignedLineHeight = (int) (fourDip * (float) Math.ceil(lineHeightHint / + fourDip)); + + // before we can workout indents we need to know how many lines of text there are; so we + // need to create a temporary layout :( + layout = StaticLayout.Builder.obtain(text, 0, text.length(), paint, width) + .setLineSpacing(baselineAlignedLineHeight - fontHeight, 1f) + .build(); + int preIndentedLineCount = layout.getLineCount(); + + + /*int[] fabLocation = new int[2]; + int[] ourLocation = new int[2]; + fabView.getLocationOnScreen(fabLocation); + getLocationOnScreen(ourLocation); + ViewGroup.MarginLayoutParams fabLp = (ViewGroup.MarginLayoutParams) fabView + .getLayoutParams(); + int fabLeft = fabLocation[0] - fabLp.getMarginStart(); + int fabRight = fabLocation[0] + fabView.getWidth() + fabLp.getMarginEnd(); + int fabWidth = fabRight - fabLeft; + int distanceFromLeft = fabLeft - ourLocation[0]; + int distanceFromRight = ourLocation[0] + getWidth() - fabRight; + boolean leftAlignedFab = distanceFromLeft < distanceFromRight; + + + int[] leftIndents = new int[preIndentedLineCount]; + int[] rightIndents = new int[preIndentedLineCount]; + Rect fabRect = new Rect(fabLeft, fabLocation[1] - fabLp.topMargin, fabRight, + fabLocation[1] + fabView.getHeight() + fabLp.bottomMargin); + Rect lineRect = new Rect(ourLocation[0], ourLocation[1], ourLocation[0] + getWidth(), + ourLocation[1] + baselineAlignedLineHeight); + + for (int line = 0; line < preIndentedLineCount; line++) { + if (lineRect.intersect(fabRect)) { + leftIndents[line] = leftAlignedFab ? fabWidth : 0; + rightIndents[line] = leftAlignedFab ? 0 : fabWidth; + } else { + leftIndents[line] = 0; + rightIndents[line] = 0; + } + lineRect.offset(0, baselineAlignedLineHeight); + }*/ + + // now we can calculate the indents required for the given fab gravity + boolean gravityTop = (fabGravity & Gravity.VERTICAL_GRAVITY_MASK) == Gravity.TOP; + boolean gravityLeft = (fabGravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.LEFT; + // we want to iterate forward/backward over the lines depending on whether the fab + // overlap vertical gravity is top/bottom + int currentLine = gravityTop ? 0 : preIndentedLineCount - 1; + int remainingHeightOverlap = fabOverlapHeight - + (gravityTop ? getPaddingTop() : getPaddingBottom()); + int[] leftIndents = new int[preIndentedLineCount]; + int[] rightIndents = new int[preIndentedLineCount]; + do { + if (remainingHeightOverlap > 0) { + // still have overlap height to consume, set the appropriate indent + leftIndents[currentLine] = gravityLeft ? fabOverlapWidth : 0; + rightIndents[currentLine] = gravityLeft ? 0 : fabOverlapWidth; + remainingHeightOverlap -= baselineAlignedLineHeight; + } else { + // have consumed the overlap height: no indent + leftIndents[currentLine] = 0; + rightIndents[currentLine] = 0; + } + if (gravityTop) { // iterate forward over the lines + currentLine++; + } else { // iterate backward over the lines + currentLine--; + } + } while (gravityTop ? currentLine < preIndentedLineCount : currentLine >= 0); + + // now that we know the indents, create the actual layout + layout = StaticLayout.Builder.obtain(text, 0, text.length(), paint, width) + .setLineSpacing(baselineAlignedLineHeight - fontHeight, 1f) + .setIndents(leftIndents, rightIndents) + .build(); + + if (layout.getLineCount() > preIndentedLineCount) { + // adding indents has flown text onto a new line + // TODO: ? + } + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED) { + throw new IllegalArgumentException("FabOverlapTextView requires a constrained width"); + } + int layoutWidth = MeasureSpec.getSize(widthMeasureSpec) - getPaddingLeft() - + getPaddingRight(); + if (layout == null || layoutWidth != layout.getWidth()) { + recompute(layoutWidth); + } + setMeasuredDimension(getPaddingLeft() + layout.getWidth() + getPaddingRight(), + getPaddingTop() + layout.getHeight() + getPaddingBottom()); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + if (layout != null) { + canvas.translate(getPaddingLeft(), getPaddingTop()); + layout.draw(canvas); + } + } +} diff --git a/app/src/main/java/io/plaidapp/ui/widget/FontTextView.java b/app/src/main/java/io/plaidapp/ui/widget/FontTextView.java new file mode 100644 index 000000000..6b980a54f --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/widget/FontTextView.java @@ -0,0 +1,79 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Paint; +import android.util.AttributeSet; +import android.widget.TextView; + +import io.plaidapp.R; +import io.plaidapp.util.FontUtil; + +/** + * Extension to TextView that adds support for custom fonts. + */ +public class FontTextView extends TextView { + + + public FontTextView(Context context) { + super(context); + init(context, null); + } + + public FontTextView(Context context, AttributeSet attrs) { + super(context, attrs); + init(context, attrs); + } + + public FontTextView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context, attrs); + } + + public FontTextView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(context, attrs); + } + + private void init(Context context, AttributeSet attrs) { + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.FontTextView); + + if (a.hasValue(R.styleable.FontTextView_android_textAppearance)) { + final int textAppearanceId = a.getResourceId(R.styleable + .FontTextView_android_textAppearance, + android.R.style.TextAppearance); + TypedArray atp = getContext().obtainStyledAttributes(textAppearanceId, + R.styleable.FontTextAppearance); + if (atp.hasValue(R.styleable.FontTextAppearance_font)) { + setFont(atp.getString(R.styleable.FontTextAppearance_font)); + } + atp.recycle(); + } + + if (a.hasValue(R.styleable.FontTextView_font)) { + setFont(a.getString(R.styleable.FontTextView_font)); + } + a.recycle(); + } + + public void setFont(String font) { + setPaintFlags(getPaintFlags() | Paint.SUBPIXEL_TEXT_FLAG); + setTypeface(FontUtil.get(getContext(), font)); + } +} diff --git a/app/src/main/java/io/plaidapp/ui/widget/ForegroundImageView.java b/app/src/main/java/io/plaidapp/ui/widget/ForegroundImageView.java new file mode 100644 index 000000000..15e20dbb1 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/widget/ForegroundImageView.java @@ -0,0 +1,134 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.ViewOutlineProvider; +import android.widget.ImageView; + +import io.plaidapp.R; + +/** + * An extension to {@link ImageView} which has a foreground drawable. + */ +public class ForegroundImageView extends ImageView { + + private Drawable foreground; + + public ForegroundImageView(Context context, AttributeSet attrs) { + super(context, attrs); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ForegroundView); + + final Drawable d = a.getDrawable(R.styleable.ForegroundView_android_foreground); + if (d != null) { + setForeground(d); + } + a.recycle(); + setOutlineProvider(ViewOutlineProvider.BOUNDS); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + if (foreground != null) { + foreground.setBounds(0, 0, w, h); + } + } + + @Override + public boolean hasOverlappingRendering() { + return false; + } + + @Override + protected boolean verifyDrawable(Drawable who) { + return super.verifyDrawable(who) || (who == foreground); + } + + @Override + public void jumpDrawablesToCurrentState() { + super.jumpDrawablesToCurrentState(); + if (foreground != null) foreground.jumpToCurrentState(); + } + + @Override + protected void drawableStateChanged() { + super.drawableStateChanged(); + if (foreground != null && foreground.isStateful()) { + foreground.setState(getDrawableState()); + } + } + + /** + * Returns the drawable used as the foreground of this view. The + * foreground drawable, if non-null, is always drawn on top of the children. + * + * @return A Drawable or null if no foreground was set. + */ + public Drawable getForeground() { + return foreground; + } + + /** + * Supply a Drawable that is to be rendered on top of the contents of this ImageView + * + * @param drawable The Drawable to be drawn on top of the ImageView + */ + public void setForeground(Drawable drawable) { + if (foreground != drawable) { + if (foreground != null) { + foreground.setCallback(null); + unscheduleDrawable(foreground); + } + + foreground = drawable; + + if (foreground != null) { + foreground.setBounds(0, 0, getWidth(), getHeight()); + setWillNotDraw(false); + foreground.setCallback(this); + if (foreground.isStateful()) { + foreground.setState(getDrawableState()); + } + } else { + setWillNotDraw(true); + } + invalidate(); + } + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + if (foreground != null) { + foreground.draw(canvas); + } + } + + @Override + public void drawableHotspotChanged(float x, float y) { + super.drawableHotspotChanged(x, y); + if (foreground != null) { + foreground.setHotspot(x, y); + } + } +} diff --git a/app/src/main/java/io/plaidapp/ui/widget/ForegroundLinearLayout.java b/app/src/main/java/io/plaidapp/ui/widget/ForegroundLinearLayout.java new file mode 100644 index 000000000..b150f76a8 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/widget/ForegroundLinearLayout.java @@ -0,0 +1,224 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.Gravity; +import android.widget.LinearLayout; + +import io.plaidapp.R; + +/** + * See https://gist.github.com/chrisbanes/9091754 + */ +public class ForegroundLinearLayout extends LinearLayout { + + private final Rect mSelfBounds = new Rect(); + private final Rect mOverlayBounds = new Rect(); + protected boolean mForegroundInPadding = true; + boolean mForegroundBoundsChanged = false; + private Drawable mForeground; + private int mForegroundGravity = Gravity.FILL; + + public ForegroundLinearLayout(Context context) { + super(context); + } + + public ForegroundLinearLayout(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ForegroundLinearLayout(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ForegroundView, + defStyle, 0); + + mForegroundGravity = a.getInt( + R.styleable.ForegroundView_android_foregroundGravity, mForegroundGravity); + + final Drawable d = a.getDrawable(R.styleable.ForegroundView_android_foreground); + if (d != null) { + setForeground(d); + } + + mForegroundInPadding = a.getBoolean( + R.styleable.ForegroundView_android_foregroundInsidePadding, true); + + a.recycle(); + } + + /** + * Describes how the foreground is positioned. + * + * @return foreground gravity. + * @see #setForegroundGravity(int) + */ + public int getForegroundGravity() { + return mForegroundGravity; + } + + /** + * Describes how the foreground is positioned. Defaults to START and TOP. + * + * @param foregroundGravity See {@link android.view.Gravity} + * @see #getForegroundGravity() + */ + public void setForegroundGravity(int foregroundGravity) { + if (mForegroundGravity != foregroundGravity) { + if ((foregroundGravity & Gravity.RELATIVE_HORIZONTAL_GRAVITY_MASK) == 0) { + foregroundGravity |= Gravity.START; + } + + if ((foregroundGravity & Gravity.VERTICAL_GRAVITY_MASK) == 0) { + foregroundGravity |= Gravity.TOP; + } + + mForegroundGravity = foregroundGravity; + + + if (mForegroundGravity == Gravity.FILL && mForeground != null) { + Rect padding = new Rect(); + mForeground.getPadding(padding); + } + + requestLayout(); + } + } + + @Override + protected boolean verifyDrawable(Drawable who) { + return super.verifyDrawable(who) || (who == mForeground); + } + + @Override + public void jumpDrawablesToCurrentState() { + super.jumpDrawablesToCurrentState(); + if (mForeground != null) mForeground.jumpToCurrentState(); + } + + @Override + protected void drawableStateChanged() { + super.drawableStateChanged(); + if (mForeground != null && mForeground.isStateful()) { + mForeground.setState(getDrawableState()); + } + } + + /** + * Returns the drawable used as the foreground of this layout. The + * foreground drawable, if non-null, is always drawn on top of the children. + * + * @return A Drawable or null if no foreground was set. + */ + public Drawable getForeground() { + return mForeground; + } + + /** + * Supply a Drawable that is to be rendered on top of all of the child + * views in this layout. Any padding in the Drawable will be taken + * into account by ensuring that the children are inset to be placed + * inside of the padding area. + * + * @param drawable The Drawable to be drawn on top of the children. + */ + public void setForeground(Drawable drawable) { + if (mForeground != drawable) { + if (mForeground != null) { + mForeground.setCallback(null); + unscheduleDrawable(mForeground); + } + + mForeground = drawable; + + if (drawable != null) { + setWillNotDraw(false); + drawable.setCallback(this); + if (drawable.isStateful()) { + drawable.setState(getDrawableState()); + } + if (mForegroundGravity == Gravity.FILL) { + Rect padding = new Rect(); + drawable.getPadding(padding); + } + } else { + setWillNotDraw(true); + } + requestLayout(); + invalidate(); + } + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + if (changed) { + mForegroundBoundsChanged = true; + } + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + mForegroundBoundsChanged = true; + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + + if (mForeground != null) { + final Drawable foreground = mForeground; + + if (mForegroundBoundsChanged) { + mForegroundBoundsChanged = false; + final Rect selfBounds = mSelfBounds; + final Rect overlayBounds = mOverlayBounds; + + final int w = getRight() - getLeft(); + final int h = getBottom() - getTop(); + + if (mForegroundInPadding) { + selfBounds.set(0, 0, w, h); + } else { + selfBounds.set(getPaddingLeft(), getPaddingTop(), + w - getPaddingRight(), h - getPaddingBottom()); + } + + Gravity.apply(mForegroundGravity, foreground.getIntrinsicWidth(), + foreground.getIntrinsicHeight(), selfBounds, overlayBounds); + foreground.setBounds(overlayBounds); + } + + foreground.draw(canvas); + } + } + + @Override + public void drawableHotspotChanged(float x, float y) { + super.drawableHotspotChanged(x, y); + if (mForeground != null) { + mForeground.setHotspot(x, y); + } + } +} diff --git a/app/src/main/java/io/plaidapp/ui/widget/ForegroundRelativeLayout.java b/app/src/main/java/io/plaidapp/ui/widget/ForegroundRelativeLayout.java new file mode 100644 index 000000000..7ddcf65f6 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/widget/ForegroundRelativeLayout.java @@ -0,0 +1,137 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.ViewOutlineProvider; +import android.widget.RelativeLayout; + +import io.plaidapp.R; + +/** + * An extension to {@link RelativeLayout} which has a foreground drawable. + */ +public class ForegroundRelativeLayout extends RelativeLayout { + + private Drawable foreground; + + public ForegroundRelativeLayout(Context context, AttributeSet attrs) { + super(context, attrs); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ForegroundView); + + final Drawable d = a.getDrawable(R.styleable.ForegroundView_android_foreground); + if (d != null) { + setForeground(d); + } + a.recycle(); + setOutlineProvider(ViewOutlineProvider.BOUNDS); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + if (foreground != null) { + foreground.setBounds(0, 0, w, h); + } + } + + @Override + public boolean hasOverlappingRendering() { + return false; + } + + @Override + protected boolean verifyDrawable(Drawable who) { + return super.verifyDrawable(who) || (who == foreground); + } + + @Override + public void jumpDrawablesToCurrentState() { + super.jumpDrawablesToCurrentState(); + if (foreground != null) foreground.jumpToCurrentState(); + } + + @Override + protected void drawableStateChanged() { + super.drawableStateChanged(); + if (foreground != null && foreground.isStateful()) { + foreground.setState(getDrawableState()); + } + } + + /** + * Returns the drawable used as the foreground of this view. The + * foreground drawable, if non-null, is always drawn on top of the children. + * + * @return A Drawable or null if no foreground was set. + */ + public Drawable getForeground() { + return foreground; + } + + /** + * Supply a Drawable that is to be rendered on top of all of the child + * views within this layout. Any padding in the Drawable will be taken + * into account by ensuring that the children are inset to be placed + * inside of the padding area. + * + * @param drawable The Drawable to be drawn on top of the children. + */ + public void setForeground(Drawable drawable) { + if (foreground != drawable) { + if (foreground != null) { + foreground.setCallback(null); + unscheduleDrawable(foreground); + } + + foreground = drawable; + + if (foreground != null) { + foreground.setBounds(getLeft(), getTop(), getRight(), getBottom()); + setWillNotDraw(false); + foreground.setCallback(this); + if (foreground.isStateful()) { + foreground.setState(getDrawableState()); + } + } else { + setWillNotDraw(true); + } + invalidate(); + } + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + if (foreground != null) { + foreground.draw(canvas); + } + } + + @Override + public void drawableHotspotChanged(float x, float y) { + super.drawableHotspotChanged(x, y); + if (foreground != null) { + foreground.setHotspot(x, y); + } + } +} diff --git a/app/src/main/java/io/plaidapp/ui/widget/FourThreeImageView.java b/app/src/main/java/io/plaidapp/ui/widget/FourThreeImageView.java new file mode 100644 index 000000000..9ab0e58ab --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/widget/FourThreeImageView.java @@ -0,0 +1,37 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.widget; + +import android.content.Context; +import android.util.AttributeSet; + +/** + * A extension of ForegroundImageView that is always 4:3 aspect ratio. + */ +public class FourThreeImageView extends ForegroundImageView { + + public FourThreeImageView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onMeasure(int widthSpec, int heightSpec) { + int fourThreeHeight = MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(widthSpec) * 3 / 4, + MeasureSpec.EXACTLY); + super.onMeasure(widthSpec, fourThreeHeight); + } +} diff --git a/app/src/main/java/io/plaidapp/ui/widget/FourThreeLinearLayout.java b/app/src/main/java/io/plaidapp/ui/widget/FourThreeLinearLayout.java new file mode 100644 index 000000000..98265a82c --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/widget/FourThreeLinearLayout.java @@ -0,0 +1,42 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.widget; + +import android.content.Context; +import android.util.AttributeSet; + +/** + * A extension of ForegroundLinearLayout that is always 4:3 aspect ratio. + */ +public class FourThreeLinearLayout extends ForegroundLinearLayout { + + public FourThreeLinearLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onMeasure(int widthSpec, int heightSpec) { + int fourThreeHeight = MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(widthSpec) * 3 / 4, + MeasureSpec.EXACTLY); + super.onMeasure(widthSpec, fourThreeHeight); + } + + @Override + public boolean hasOverlappingRendering() { + return false; + } +} diff --git a/app/src/main/java/io/plaidapp/ui/widget/FourThreeView.java b/app/src/main/java/io/plaidapp/ui/widget/FourThreeView.java new file mode 100644 index 000000000..b43becc12 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/widget/FourThreeView.java @@ -0,0 +1,44 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; + +/** + * A View that always has a 4:3 aspect ratio. + */ +public class FourThreeView extends View { + + + public FourThreeView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onMeasure(int widthSpec, int heightSpec) { + int fourThreeHeight = MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(widthSpec) * 3 / 4, + MeasureSpec.EXACTLY); + super.onMeasure(widthSpec, fourThreeHeight); + } + + @Override + public boolean hasOverlappingRendering() { + return false; + } +} diff --git a/app/src/main/java/io/plaidapp/ui/widget/ObservableScrollView.java b/app/src/main/java/io/plaidapp/ui/widget/ObservableScrollView.java new file mode 100644 index 000000000..ef4feff2b --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/widget/ObservableScrollView.java @@ -0,0 +1,52 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.ScrollView; + +/** + * An extension to {@link ScrollView} which exposes a scroll listener. + */ +public class ObservableScrollView extends ScrollView { + + private OnScrollListener listener; + + public ObservableScrollView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public void setListener(OnScrollListener listener) { + this.listener = listener; + } + + @Override + protected void onScrollChanged(int currentScrollX, + int currentScrollY, + int oldScrollX, + int oldScrollY) { + super.onScrollChanged(currentScrollX, currentScrollY, oldScrollX, oldScrollY); + if (listener != null) { + listener.onScrolled(currentScrollY); + } + } + + public interface OnScrollListener { + void onScrolled(int scrollY); + } +} diff --git a/app/src/main/java/io/plaidapp/ui/widget/ParallaxScrimageView.java b/app/src/main/java/io/plaidapp/ui/widget/ParallaxScrimageView.java new file mode 100644 index 000000000..079bdf07d --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/widget/ParallaxScrimageView.java @@ -0,0 +1,176 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.widget; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.support.annotation.ColorInt; +import android.support.annotation.FloatRange; +import android.support.v4.view.ViewCompat; +import android.util.AttributeSet; +import android.util.Property; + +import io.plaidapp.R; +import io.plaidapp.util.AnimUtils; +import io.plaidapp.util.ColorUtils; + +/** + * An image view which supports parallax scrolling and applying a scrim onto it's content. Get it. + * + * It also has a custom pinned state, for use via state lists. + */ +public class ParallaxScrimageView extends FourThreeImageView { + + private static final int[] STATE_PINNED = {R.attr.state_pinned}; + private final Paint scrimPaint; + private int imageOffset; + private int minOffset; + private float scrimAlpha = 0f; + private float maxScrimAlpha = 1f; + private int scrimColor = 0x00000000; + private float parallaxFactor = -0.5f; + private boolean isPinned = false; + private boolean immediatePin = false; + public static final Property OFFSET = new AnimUtils + .FloatProperty("offset") { + + @Override + public void setValue(ParallaxScrimageView parallaxScrimageView, float value) { + parallaxScrimageView.setOffset(value); + } + + @Override + public Float get(ParallaxScrimageView parallaxScrimageView) { + return parallaxScrimageView.getOffset(); + } + }; + + public ParallaxScrimageView(Context context, AttributeSet attrs) { + super(context, attrs); + + final TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable + .ParallaxScrimageView); + if (a.hasValue(R.styleable.ParallaxScrimageView_scrimColor)) { + scrimColor = a.getColor(R.styleable.ParallaxScrimageView_scrimColor, scrimColor); + } + if (a.hasValue(R.styleable.ParallaxScrimageView_scrimAlpha)) { + scrimAlpha = a.getFloat(R.styleable.ParallaxScrimageView_scrimAlpha, scrimAlpha); + } + if (a.hasValue(R.styleable.ParallaxScrimageView_maxScrimAlpha)) { + maxScrimAlpha = a.getFloat(R.styleable.ParallaxScrimageView_maxScrimAlpha, + maxScrimAlpha); + } + if (a.hasValue(R.styleable.ParallaxScrimageView_parallaxFactor)) { + parallaxFactor = a.getFloat(R.styleable.ParallaxScrimageView_parallaxFactor, + parallaxFactor); + } + a.recycle(); + + scrimPaint = new Paint(); + scrimPaint.setColor(ColorUtils.modifyAlpha(scrimColor, scrimAlpha)); + } + + public float getOffset() { + return getTranslationY(); + } + + public void setOffset(float offset) { + offset = Math.max(minOffset, offset); + if (offset != getTranslationY()) { + setTranslationY(offset); + imageOffset = (int) (offset * parallaxFactor); + setScrimAlpha(Math.min((-offset / getMinimumHeight()) * maxScrimAlpha, maxScrimAlpha)); + ViewCompat.postInvalidateOnAnimation(this); + } + setPinned(offset == minOffset); + } + + public void setScrimColor(@ColorInt int scrimColor) { + if (this.scrimColor != scrimColor) { + this.scrimColor = scrimColor; + ViewCompat.postInvalidateOnAnimation(this); + } + } + + public void setScrimAlpha(@FloatRange(from = 0f, to = 1f) float alpha) { + if (scrimAlpha != alpha) { + scrimAlpha = alpha; + scrimPaint.setColor(ColorUtils.modifyAlpha(scrimColor, scrimAlpha)); + ViewCompat.postInvalidateOnAnimation(this); + } + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + if (h > getMinimumHeight()) { + minOffset = getMinimumHeight() - h; + } + } + + @Override + protected void onDraw(Canvas canvas) { + if (imageOffset != 0) { + canvas.save(); + canvas.translate(0f, imageOffset); + canvas.clipRect(0f, 0f, canvas.getWidth(), canvas.getHeight() + imageOffset); + super.onDraw(canvas); + canvas.drawRect(0, 0, canvas.getWidth(), canvas.getHeight(), scrimPaint); + canvas.restore(); + } else { + super.onDraw(canvas); + canvas.drawRect(0, 0, canvas.getWidth(), canvas.getHeight(), scrimPaint); + } + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + if (isPinned) { + mergeDrawableStates(drawableState, STATE_PINNED); + } + return drawableState; + } + + public boolean isPinned() { + return isPinned; + } + + public void setPinned(boolean isPinned) { + if (this.isPinned != isPinned) { + this.isPinned = isPinned; + refreshDrawableState(); + if (isPinned && immediatePin) { + jumpDrawablesToCurrentState(); + } + } + } + + public boolean isImmediatePin() { + return immediatePin; + } + + /** + * As the pinned state is designed to work with a {@see StateListAnimator}, we may want to short + * circuit this animation in certain situations e.g. when flinging a list. + */ + public void setImmediatePin(boolean immediatePin) { + this.immediatePin = immediatePin; + } +} diff --git a/app/src/main/java/io/plaidapp/ui/widget/PinnedOffsetView.java b/app/src/main/java/io/plaidapp/ui/widget/PinnedOffsetView.java new file mode 100644 index 000000000..7144e46a6 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/widget/PinnedOffsetView.java @@ -0,0 +1,84 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.widget; + +import android.content.Context; +import android.support.v4.view.ViewCompat; +import android.util.AttributeSet; +import android.view.View; + +import io.plaidapp.R; + +/** + * A view which supports a minimum vertical offset (i.e. translation Y) and has a custom pinned + * state. + */ +public class PinnedOffsetView extends View { + + private static final int[] STATE_PINNED = {R.attr.state_pinned}; + + private int minOffset; + private boolean isPinned = false; + + public PinnedOffsetView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public PinnedOffsetView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public PinnedOffsetView(Context context, AttributeSet attrs, int defStyleAttr, int + defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public void setOffset(int offset) { + offset = Math.max(minOffset, offset); + if (getTranslationY() != offset) { + setTranslationY(offset); + setPinned(offset == minOffset); + ViewCompat.postInvalidateOnAnimation(this); + } + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + minOffset = -(h - getMinimumHeight()); + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + if (isPinned) { + mergeDrawableStates(drawableState, STATE_PINNED); + } + return drawableState; + } + + public boolean isPinned() { + return isPinned; + } + + public void setPinned(boolean isPinned) { + if (this.isPinned != isPinned) { + this.isPinned = isPinned; + refreshDrawableState(); + } + } +} diff --git a/app/src/main/java/io/plaidapp/ui/widget/SquareLinearLayout.java b/app/src/main/java/io/plaidapp/ui/widget/SquareLinearLayout.java new file mode 100644 index 000000000..a418f7a18 --- /dev/null +++ b/app/src/main/java/io/plaidapp/ui/widget/SquareLinearLayout.java @@ -0,0 +1,41 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.ui.widget; + +import android.content.Context; +import android.util.AttributeSet; + +/** + * An extension to ForegroundLinearLayout that is always square. + */ +public class SquareLinearLayout extends ForegroundLinearLayout { + + public SquareLinearLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + // pass width spec for *both* width & height to get a square tile + super.onMeasure(widthMeasureSpec, widthMeasureSpec); + } + + @Override + public boolean hasOverlappingRendering() { + return false; + } +} diff --git a/app/src/main/java/io/plaidapp/util/AnimUtils.java b/app/src/main/java/io/plaidapp/util/AnimUtils.java new file mode 100644 index 000000000..c0d030b16 --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/AnimUtils.java @@ -0,0 +1,304 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.util; + +import android.animation.Animator; +import android.animation.TimeInterpolator; +import android.content.Context; +import android.transition.Transition; +import android.util.ArrayMap; +import android.util.Property; +import android.view.animation.Interpolator; + +import java.util.ArrayList; + +/** + * Utility methods for working with animations. + */ +public class AnimUtils { + + private AnimUtils() { } + + private static Interpolator gusterpolator; + + public static Interpolator getMaterialInterpolator(Context ctx) { + if (gusterpolator == null) { + synchronized (AnimUtils.class) { + if (gusterpolator == null) { + gusterpolator = android.view.animation.AnimationUtils.loadInterpolator(ctx, + android.R.interpolator.fast_out_slow_in); + } + } + } + return gusterpolator; + } + + /** + * Linear interpolate between a and b with parameter t. + */ + public static float lerp(float a, float b, float t) { + return a + (b - a) * t; + } + + + /** + * An implementation of {@link android.util.Property} to be used specifically with fields of + * type + * float. This type-specific subclass enables performance benefit by allowing + * calls to a {@link #set(Object, Float) set()} function that takes the primitive + * float type and avoids autoboxing and other overhead associated with the + * Float class. + * + * @param The class on which the Property is declared. + **/ + public static abstract class FloatProperty extends Property { + public FloatProperty(String name) { + super(Float.class, name); + } + + /** + * A type-specific override of the {@link #set(Object, Float)} that is faster when dealing + * with fields of type float. + */ + public abstract void setValue(T object, float value); + + @Override + final public void set(T object, Float value) { + setValue(object, value); + } + } + + /** + * An implementation of {@link android.util.Property} to be used specifically with fields of + * type + * int. This type-specific subclass enables performance benefit by allowing + * calls to a {@link #set(Object, Integer) set()} function that takes the primitive + * int type and avoids autoboxing and other overhead associated with the + * Integer class. + * + * @param The class on which the Property is declared. + */ + public static abstract class IntProperty extends Property { + + public IntProperty(String name) { + super(Integer.class, name); + } + + /** + * A type-specific override of the {@link #set(Object, Integer)} that is faster when dealing + * with fields of type int. + */ + public abstract void setValue(T object, int value); + + @Override + final public void set(T object, Integer value) { + setValue(object, value.intValue()); + } + + } + + /** + * https://halfthought.wordpress.com/2014/11/07/reveal-transition/ + *

+ * Interrupting Activity transitions can yield an OperationNotSupportedException when the + * transition tries to pause the animator. Yikes! We can fix this by wrapping the Animator: + */ + public static class NoPauseAnimator extends Animator { + private final Animator mAnimator; + private final ArrayMap mListeners = + new ArrayMap(); + + public NoPauseAnimator(Animator animator) { + mAnimator = animator; + } + + @Override + public void addListener(AnimatorListener listener) { + AnimatorListener wrapper = new AnimatorListenerWrapper(this, listener); + if (!mListeners.containsKey(listener)) { + mListeners.put(listener, wrapper); + mAnimator.addListener(wrapper); + } + } + + @Override + public void cancel() { + mAnimator.cancel(); + } + + @Override + public void end() { + mAnimator.end(); + } + + @Override + public long getDuration() { + return mAnimator.getDuration(); + } + + @Override + public TimeInterpolator getInterpolator() { + return mAnimator.getInterpolator(); + } + + @Override + public void setInterpolator(TimeInterpolator timeInterpolator) { + mAnimator.setInterpolator(timeInterpolator); + } + + @Override + public ArrayList getListeners() { + return new ArrayList(mListeners.keySet()); + } + + @Override + public long getStartDelay() { + return mAnimator.getStartDelay(); + } + + @Override + public void setStartDelay(long delayMS) { + mAnimator.setStartDelay(delayMS); + } + + @Override + public boolean isPaused() { + return mAnimator.isPaused(); + } + + @Override + public boolean isRunning() { + return mAnimator.isRunning(); + } + + @Override + public boolean isStarted() { + return mAnimator.isStarted(); + } + + /* We don't want to override pause or resume methods because we don't want them + * to affect mAnimator. + public void pause(); + + public void resume(); + + public void addPauseListener(AnimatorPauseListener listener); + + public void removePauseListener(AnimatorPauseListener listener); + */ + + @Override + public void removeAllListeners() { + mListeners.clear(); + mAnimator.removeAllListeners(); + } + + @Override + public void removeListener(AnimatorListener listener) { + AnimatorListener wrapper = mListeners.get(listener); + if (wrapper != null) { + mListeners.remove(listener); + mAnimator.removeListener(wrapper); + } + } + + @Override + public Animator setDuration(long durationMS) { + mAnimator.setDuration(durationMS); + return this; + } + + @Override + public void setTarget(Object target) { + mAnimator.setTarget(target); + } + + @Override + public void setupEndValues() { + mAnimator.setupEndValues(); + } + + @Override + public void setupStartValues() { + mAnimator.setupStartValues(); + } + + @Override + public void start() { + mAnimator.start(); + } + } + + static class AnimatorListenerWrapper implements Animator.AnimatorListener { + private final Animator mAnimator; + private final Animator.AnimatorListener mListener; + + public AnimatorListenerWrapper(Animator animator, Animator.AnimatorListener listener) { + mAnimator = animator; + mListener = listener; + } + + @Override + public void onAnimationStart(Animator animator) { + mListener.onAnimationStart(mAnimator); + } + + @Override + public void onAnimationEnd(Animator animator) { + mListener.onAnimationEnd(mAnimator); + } + + @Override + public void onAnimationCancel(Animator animator) { + mListener.onAnimationCancel(mAnimator); + } + + @Override + public void onAnimationRepeat(Animator animator) { + mListener.onAnimationRepeat(mAnimator); + } + } + + public static class TransitionListenerAdapter implements Transition.TransitionListener { + + @Override + public void onTransitionStart(Transition transition) { + + } + + @Override + public void onTransitionEnd(Transition transition) { + + } + + @Override + public void onTransitionCancel(Transition transition) { + + } + + @Override + public void onTransitionPause(Transition transition) { + + } + + @Override + public void onTransitionResume(Transition transition) { + + } + } + +} diff --git a/app/src/main/java/io/plaidapp/util/CollapsingTextHelper.java b/app/src/main/java/io/plaidapp/util/CollapsingTextHelper.java new file mode 100644 index 000000000..336c36ee0 --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/CollapsingTextHelper.java @@ -0,0 +1,536 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * 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. + */ + +package io.plaidapp.util; + +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Typeface; +import android.os.Build; +import android.support.annotation.FloatRange; +import android.support.design.R; +import android.support.v4.text.TextDirectionHeuristicsCompat; +import android.support.v4.view.GravityCompat; +import android.support.v4.view.ViewCompat; +import android.text.TextPaint; +import android.text.TextUtils; +import android.view.Gravity; +import android.view.View; +import android.view.animation.Interpolator; + +/** + * Adapted from design support lib. + */ +public final class CollapsingTextHelper { + // Pre-JB-MR2 doesn't support HW accelerated canvas scaled text so we will workaround it + // by using our own texture + private static final boolean USE_SCALING_TEXTURE = Build.VERSION.SDK_INT < 18; + private static final boolean DEBUG_DRAW = false; + private static final Paint DEBUG_DRAW_PAINT; + + static { + DEBUG_DRAW_PAINT = DEBUG_DRAW ? new Paint() : null; + if (DEBUG_DRAW_PAINT != null) { + DEBUG_DRAW_PAINT.setAntiAlias(true); + DEBUG_DRAW_PAINT.setColor(Color.MAGENTA); + } + } + + private final View mView; + private final Rect mExpandedBounds; + private final Rect mCollapsedBounds; + private final RectF mCurrentBounds; + private final TextPaint mTextPaint; + private boolean mDrawTitle; + private float mExpandedFraction; + private int mExpandedTextGravity = Gravity.CENTER_VERTICAL; + private int mCollapsedTextGravity = Gravity.CENTER_VERTICAL; + private float mExpandedTextSize = 15; + private float mCollapsedTextSize = 15; + private int mExpandedTextColor; + private int mCollapsedTextColor; + private float mExpandedDrawY; + private float mCollapsedDrawY; + private float mExpandedDrawX; + private float mCollapsedDrawX; + private float mCurrentDrawX; + private float mCurrentDrawY; + private CharSequence mText; + private CharSequence mTextToDraw; + private boolean mIsRtl; + private boolean mUseTexture; + private Bitmap mExpandedTitleTexture; + private Paint mTexturePaint; + private float mTextureAscent; + private float mTextureDescent; + private float mScale; + private float mCurrentTextSize; + private boolean mBoundsChanged; + private Interpolator mPositionInterpolator; + private Interpolator mTextSizeInterpolator; + + public CollapsingTextHelper(View view) { + mView = view; + mTextPaint = new TextPaint(); + mTextPaint.setAntiAlias(true); + mCollapsedBounds = new Rect(); + mExpandedBounds = new Rect(); + mCurrentBounds = new RectF(); + } + + /** + * Set the value indicating the current scroll value. This decides how much of the + * background will be displayed, as well as the title metrics/positioning. + *

+ * A value of {@code 0.0} indicates that the layout is fully expanded. + * A value of {@code 1.0} indicates that the layout is fully collapsed. + */ + public void setExpansionFraction(@FloatRange(from = 0f, to = 1f) float fraction) { + fraction = MathUtils.constrain(fraction, 0f, 1f); + if (fraction != mExpandedFraction) { + mExpandedFraction = fraction; + calculateCurrentOffsets(); + } + } + + public float getExpansionFraction() { + return mExpandedFraction; + } + + public void draw(Canvas canvas) { + final int saveCount = canvas.save(); + if (mTextToDraw != null && mDrawTitle) { + float x = mCurrentDrawX; + float y = mCurrentDrawY; + final boolean drawTexture = mUseTexture && mExpandedTitleTexture != null; + final float ascent; + final float descent; + // Update the TextPaint to the current text size + mTextPaint.setTextSize(mCurrentTextSize); + if (drawTexture) { + ascent = mTextureAscent * mScale; + descent = mTextureDescent * mScale; + } else { + ascent = mTextPaint.ascent() * mScale; + descent = mTextPaint.descent() * mScale; + } + if (DEBUG_DRAW) { + // Just a debug tool, which drawn a Magneta rect in the text bounds + canvas.drawRect(mCurrentBounds.left, y + ascent, mCurrentBounds.right, y + descent, + DEBUG_DRAW_PAINT); + } + if (drawTexture) { + y += ascent; + } + if (mScale != 1f) { + canvas.scale(mScale, mScale, x, y); + } + if (drawTexture) { + // If we should use a texture, draw it instead of text + canvas.drawBitmap(mExpandedTitleTexture, x, y, mTexturePaint); + } else { + canvas.drawText(mTextToDraw, 0, mTextToDraw.length(), x, y, mTextPaint); + } + } + canvas.restoreToCount(saveCount); + } + + /** + * Set the title to display + * + * @param text + */ + public void setText(CharSequence text) { + if (text == null || !text.equals(mText)) { + mText = text; + mTextToDraw = null; + clearTexture(); + recalculate(); + } + } + + public CharSequence getText() { + return mText; + } + + public void setTextSizeInterpolator(Interpolator interpolator) { + mTextSizeInterpolator = interpolator; + recalculate(); + } + + public void setPositionInterpolator(Interpolator interpolator) { + mPositionInterpolator = interpolator; + recalculate(); + } + + public void setExpandedBounds(int left, int top, int right, int bottom) { + if (!rectEquals(mExpandedBounds, left, top, right, bottom)) { + mExpandedBounds.set(left, top, right, bottom); + mBoundsChanged = true; + onBoundsChanged(); + } + } + + public void setCollapsedBounds(int left, int top, int right, int bottom) { + if (!rectEquals(mCollapsedBounds, left, top, right, bottom)) { + mCollapsedBounds.set(left, top, right, bottom); + mBoundsChanged = true; + onBoundsChanged(); + } + } + + public int getExpandedTextGravity() { + return mExpandedTextGravity; + } + + public void setExpandedTextGravity(int gravity) { + if (mExpandedTextGravity != gravity) { + mExpandedTextGravity = gravity; + recalculate(); + } + } + + public int getCollapsedTextGravity() { + return mCollapsedTextGravity; + } + + public void setCollapsedTextGravity(int gravity) { + if (mCollapsedTextGravity != gravity) { + mCollapsedTextGravity = gravity; + recalculate(); + } + } + + public void setCollapsedTextAppearance(int resId) { + TypedArray a = mView.getContext().obtainStyledAttributes(resId, R.styleable.TextAppearance); + if (a.hasValue(R.styleable.TextAppearance_android_textColor)) { + mCollapsedTextColor = a.getColor( + R.styleable.TextAppearance_android_textColor, mCollapsedTextColor); + } + if (a.hasValue(R.styleable.TextAppearance_android_textSize)) { + mCollapsedTextSize = a.getDimensionPixelSize( + R.styleable.TextAppearance_android_textSize, (int) mCollapsedTextSize); + } + a.recycle(); + recalculate(); + } + + public void setExpandedTextAppearance(int resId) { + TypedArray a = mView.getContext().obtainStyledAttributes(resId, R.styleable.TextAppearance); + if (a.hasValue(R.styleable.TextAppearance_android_textColor)) { + mExpandedTextColor = a.getColor( + R.styleable.TextAppearance_android_textColor, mExpandedTextColor); + } + if (a.hasValue(R.styleable.TextAppearance_android_textSize)) { + mExpandedTextSize = a.getDimensionPixelSize( + R.styleable.TextAppearance_android_textSize, (int) mExpandedTextSize); + } + a.recycle(); + recalculate(); + } + + public Typeface getTypeface() { + return mTextPaint.getTypeface(); + } + + public void setTypeface(Typeface typeface) { + if (typeface == null) { + typeface = Typeface.DEFAULT; + } + if (mTextPaint.getTypeface() != typeface) { + mTextPaint.setTypeface(typeface); + recalculate(); + } + } + + public float getCollapsedTextSize() { + return mCollapsedTextSize; + } + + public void setCollapsedTextSize(float textSize) { + if (mCollapsedTextSize != textSize) { + mCollapsedTextSize = textSize; + recalculate(); + } + } + + public float getExpandedTextSize() { + return mExpandedTextSize; + } + + public void setExpandedTextSize(float textSize) { + if (mExpandedTextSize != textSize) { + mExpandedTextSize = textSize; + recalculate(); + } + } + + public int getExpandedTextColor() { + return mExpandedTextColor; + } + + public void setExpandedTextColor(int textColor) { + if (mExpandedTextColor != textColor) { + mExpandedTextColor = textColor; + recalculate(); + } + } + + public int getCollapsedTextColor() { + return mCollapsedTextColor; + } + + public void setCollapsedTextColor(int textColor) { + if (mCollapsedTextColor != textColor) { + mCollapsedTextColor = textColor; + recalculate(); + } + } + + private static float lerp(float startValue, float endValue, float fraction, + Interpolator interpolator) { + if (interpolator != null) { + fraction = interpolator.getInterpolation(fraction); + } + return AnimUtils.lerp(startValue, endValue, fraction); + } + + private static boolean rectEquals(Rect r, int left, int top, int right, int bottom) { + return !(r.left != left || r.top != top || r.right != right || r.bottom != bottom); + } + + private void onBoundsChanged() { + mDrawTitle = mCollapsedBounds.width() > 0 && mCollapsedBounds.height() > 0 + && mExpandedBounds.width() > 0 && mExpandedBounds.height() > 0; + } + + private void calculateCurrentOffsets() { + final float fraction = mExpandedFraction; + interpolateBounds(fraction); + mCurrentDrawX = lerp(mExpandedDrawX, mCollapsedDrawX, fraction, + mPositionInterpolator); + mCurrentDrawY = lerp(mExpandedDrawY, mCollapsedDrawY, fraction, + mPositionInterpolator); + setInterpolatedTextSize(lerp(mExpandedTextSize, mCollapsedTextSize, + fraction, mTextSizeInterpolator)); + if (mCollapsedTextColor != mExpandedTextColor) { + // If the collapsed and expanded text colors are different, blend them based on the + // fraction + mTextPaint.setColor(blendColors(mExpandedTextColor, mCollapsedTextColor, fraction)); + } else { + mTextPaint.setColor(mCollapsedTextColor); + } + ViewCompat.postInvalidateOnAnimation(mView); + } + + private void calculateBaseOffsets() { + // We then calculate the collapsed text size, using the same logic + mTextPaint.setTextSize(mCollapsedTextSize); + float width = mTextToDraw != null ? + mTextPaint.measureText(mTextToDraw, 0, mTextToDraw.length()) : 0; + final int collapsedAbsGravity = GravityCompat.getAbsoluteGravity(mCollapsedTextGravity, + mIsRtl ? ViewCompat.LAYOUT_DIRECTION_RTL : ViewCompat.LAYOUT_DIRECTION_LTR); + switch (collapsedAbsGravity & Gravity.VERTICAL_GRAVITY_MASK) { + case Gravity.BOTTOM: + mCollapsedDrawY = mCollapsedBounds.bottom; + break; + case Gravity.TOP: + mCollapsedDrawY = mCollapsedBounds.top - mTextPaint.ascent(); + break; + case Gravity.CENTER_VERTICAL: + default: + float textHeight = mTextPaint.descent() - mTextPaint.ascent(); + float textOffset = (textHeight / 2) - mTextPaint.descent(); + mCollapsedDrawY = mCollapsedBounds.centerY() + textOffset; + break; + } + switch (collapsedAbsGravity & Gravity.HORIZONTAL_GRAVITY_MASK) { + case Gravity.CENTER_HORIZONTAL: + mCollapsedDrawX = mCollapsedBounds.centerX() - (width / 2); + break; + case Gravity.RIGHT: + mCollapsedDrawX = mCollapsedBounds.right - width; + break; + case Gravity.LEFT: + default: + mCollapsedDrawX = mCollapsedBounds.left; + break; + } + mTextPaint.setTextSize(mExpandedTextSize); + width = mTextToDraw != null + ? mTextPaint.measureText(mTextToDraw, 0, mTextToDraw.length()) : 0; + final int expandedAbsGravity = GravityCompat.getAbsoluteGravity(mExpandedTextGravity, + mIsRtl ? ViewCompat.LAYOUT_DIRECTION_RTL : ViewCompat.LAYOUT_DIRECTION_LTR); + switch (expandedAbsGravity & Gravity.VERTICAL_GRAVITY_MASK) { + case Gravity.BOTTOM: + mExpandedDrawY = mExpandedBounds.bottom; + break; + case Gravity.TOP: + mExpandedDrawY = mExpandedBounds.top - mTextPaint.ascent(); + break; + case Gravity.CENTER_VERTICAL: + default: + float textHeight = mTextPaint.descent() - mTextPaint.ascent(); + float textOffset = (textHeight / 2) - mTextPaint.descent(); + mExpandedDrawY = mExpandedBounds.centerY() + textOffset; + break; + } + switch (expandedAbsGravity & Gravity.HORIZONTAL_GRAVITY_MASK) { + case Gravity.CENTER_HORIZONTAL: + mExpandedDrawX = mExpandedBounds.centerX() - (width / 2); + break; + case Gravity.RIGHT: + mExpandedDrawX = mExpandedBounds.right - width; + break; + case Gravity.LEFT: + default: + mExpandedDrawX = mExpandedBounds.left; + break; + } + // The bounds have changed so we need to clear the texture + clearTexture(); + } + + private void interpolateBounds(float fraction) { + mCurrentBounds.left = lerp(mExpandedBounds.left, mCollapsedBounds.left, + fraction, mPositionInterpolator); + mCurrentBounds.top = lerp(mExpandedDrawY, mCollapsedDrawY, + fraction, mPositionInterpolator); + mCurrentBounds.right = lerp(mExpandedBounds.right, mCollapsedBounds.right, + fraction, mPositionInterpolator); + mCurrentBounds.bottom = lerp(mExpandedBounds.bottom, mCollapsedBounds.bottom, + fraction, mPositionInterpolator); + } + + private boolean calculateIsRtl(CharSequence text) { + final boolean defaultIsRtl = ViewCompat.getLayoutDirection(mView) + == ViewCompat.LAYOUT_DIRECTION_RTL; + return (defaultIsRtl + ? TextDirectionHeuristicsCompat.FIRSTSTRONG_RTL + : TextDirectionHeuristicsCompat.FIRSTSTRONG_LTR).isRtl(text, 0, text.length()); + } + + private void setInterpolatedTextSize(final float textSize) { + if (mText == null) return; + final float availableWidth; + final float newTextSize; + boolean updateDrawText = false; + if (isClose(textSize, mCollapsedTextSize)) { + availableWidth = mCollapsedBounds.width(); + newTextSize = mCollapsedTextSize; + mScale = 1f; + } else { + availableWidth = mExpandedBounds.width(); + newTextSize = mExpandedTextSize; + if (isClose(textSize, mExpandedTextSize)) { + // If we're close to the expanded text size, snap to it and use a scale of 1 + mScale = 1f; + } else { + // Else, we'll scale down from the expanded text size + mScale = textSize / mExpandedTextSize; + } + } + if (availableWidth > 0) { + updateDrawText = (mCurrentTextSize != newTextSize) || mBoundsChanged; + mCurrentTextSize = newTextSize; + mBoundsChanged = false; + } + if (mTextToDraw == null || updateDrawText) { + mTextPaint.setTextSize(mCurrentTextSize); + // If we don't currently have text to draw, or the text size has changed, ellipsize... + final CharSequence title = TextUtils.ellipsize(mText, mTextPaint, + availableWidth, TextUtils.TruncateAt.END); + if (mTextToDraw == null || !mTextToDraw.equals(title)) { + mTextToDraw = title; + } + mIsRtl = calculateIsRtl(mTextToDraw); + } + // Use our texture if the scale isn't 1.0 + mUseTexture = USE_SCALING_TEXTURE && mScale != 1f; + if (mUseTexture) { + // Make sure we have an expanded texture if needed + ensureExpandedTexture(); + } + ViewCompat.postInvalidateOnAnimation(mView); + } + + private void ensureExpandedTexture() { + if (mExpandedTitleTexture != null || mExpandedBounds.isEmpty() + || TextUtils.isEmpty(mTextToDraw)) { + return; + } + mTextPaint.setTextSize(mExpandedTextSize); + mTextPaint.setColor(mExpandedTextColor); + mTextureAscent = mTextPaint.ascent(); + mTextureDescent = mTextPaint.descent(); + final int w = Math.round(mTextPaint.measureText(mTextToDraw, 0, mTextToDraw.length())); + final int h = Math.round(mTextureDescent - mTextureAscent); + if (w <= 0 && h <= 0) { + return; // If the width or height are 0, return + } + mExpandedTitleTexture = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); + Canvas c = new Canvas(mExpandedTitleTexture); + c.drawText(mTextToDraw, 0, mTextToDraw.length(), 0, h - mTextPaint.descent(), mTextPaint); + if (mTexturePaint == null) { + // Make sure we have a paint + mTexturePaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG); + } + } + + private void recalculate() { + if (mView.getHeight() > 0 && mView.getWidth() > 0) { + // If we've already been laid out, calculate everything now otherwise we'll wait + // until a layout + calculateBaseOffsets(); + calculateCurrentOffsets(); + } + } + + private void clearTexture() { + if (mExpandedTitleTexture != null) { + mExpandedTitleTexture.recycle(); + mExpandedTitleTexture = null; + } + } + + /** + * Returns true if {@code value} is 'close' to it's closest decimal value. Close is currently + * defined as it's difference being < 0.001. + */ + private static boolean isClose(float value, float targetValue) { + return Math.abs(value - targetValue) < 0.001f; + } + + /** + * Blend {@code color1} and {@code color2} using the given ratio. + * + * @param ratio of which to blend. 0.0 will return {@code color1}, 0.5 will give an even blend, + * 1.0 will return {@code color2}. + */ + private static int blendColors(int color1, int color2, float ratio) { + final float inverseRatio = 1f - ratio; + float a = (Color.alpha(color1) * inverseRatio) + (Color.alpha(color2) * ratio); + float r = (Color.red(color1) * inverseRatio) + (Color.red(color2) * ratio); + float g = (Color.green(color1) * inverseRatio) + (Color.green(color2) * ratio); + float b = (Color.blue(color1) * inverseRatio) + (Color.blue(color2) * ratio); + return Color.argb((int) a, (int) r, (int) g, (int) b); + } +} diff --git a/app/src/main/java/io/plaidapp/util/ColorUtils.java b/app/src/main/java/io/plaidapp/util/ColorUtils.java new file mode 100644 index 000000000..2abe5604f --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/ColorUtils.java @@ -0,0 +1,176 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.util; + +import android.graphics.Bitmap; +import android.graphics.Color; +import android.support.annotation.ColorInt; +import android.support.annotation.FloatRange; +import android.support.annotation.IntDef; +import android.support.annotation.IntRange; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v7.graphics.Palette; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Utility methods for working with colors. + */ +public class ColorUtils { + + private ColorUtils() { } + + public static final int IS_LIGHT = 0; + public static final int IS_DARK = 1; + public static final int LIGHTNESS_UNKNOWN = 2; + + /** + * Set the alpha component of {@code color} to be {@code alpha}. + */ + public static int modifyAlpha(@ColorInt int color, @IntRange(from = 0, to = 255) int alpha) { + return (color & 0x00ffffff) | (alpha << 24); + } + + /** + * Set the alpha component of {@code color} to be {@code alpha}. + */ + public static int modifyAlpha(@ColorInt int color, + @FloatRange(from = 0f, to = 1f) float alpha) { + return modifyAlpha(color, (int) (255f * alpha)); + } + + /** + * Blend {@code color1} and {@code color2} using the given ratio. + * + * @param ratio of which to blend. 0.0 will return {@code color1}, 0.5 will give an even blend, + * 1.0 will return {@code color2}. + */ + public static @ColorInt int blendColors(@ColorInt int color1, + @ColorInt int color2, + @FloatRange(from = 0f, to = 1f) float ratio) { + final float inverseRatio = 1f - ratio; + float a = (Color.alpha(color1) * inverseRatio) + (Color.alpha(color2) * ratio); + float r = (Color.red(color1) * inverseRatio) + (Color.red(color2) * ratio); + float g = (Color.green(color1) * inverseRatio) + (Color.green(color2) * ratio); + float b = (Color.blue(color1) * inverseRatio) + (Color.blue(color2) * ratio); + return Color.argb((int) a, (int) r, (int) g, (int) b); + } + + /** + * Checks if the most populous color in the given palette is dark + *

+ * Annoyingly we have to return this Lightness 'enum' rather than a boolean as palette isn't + * guaranteed to find the most populous color. + */ + public static @Lightness int isDark(Palette palette) { + Palette.Swatch mostPopulous = getMostPopulousSwatch(palette); + if (mostPopulous == null) return LIGHTNESS_UNKNOWN; + return isDark(mostPopulous.getHsl()) ? IS_DARK : IS_LIGHT; + } + + public static @Nullable Palette.Swatch getMostPopulousSwatch(Palette palette) { + Palette.Swatch mostPopulous = null; + if (palette != null) { + for (Palette.Swatch swatch : palette.getSwatches()) { + if (mostPopulous == null || swatch.getPopulation() > mostPopulous.getPopulation()) { + mostPopulous = swatch; + } + } + } + return mostPopulous; + } + + /** + * Determines if a given bitmap is dark. This extracts a palette inline so should not be called + * with a large image!! + *

+ * Note: If palette fails then check the color of the central pixel + */ + public static boolean isDark(@NonNull Bitmap bitmap) { + return isDark(bitmap, bitmap.getWidth() / 2, bitmap.getHeight() / 2); + } + + /** + * Determines if a given bitmap is dark. This extracts a palette inline so should not be called + * with a large image!! If palette fails then check the color of the specified pixel + */ + public static boolean isDark(@NonNull Bitmap bitmap, int backupPixelX, int backupPixelY) { + // first try palette with a small color quant size + Palette palette = Palette.from(bitmap).maximumColorCount(3).generate(); + if (palette != null && palette.getSwatches().size() > 0) { + return isDark(palette) == IS_DARK; + } else { + // if palette failed, then check the color of the specified pixel + return isDark(bitmap.getPixel(backupPixelX, backupPixelY)); + } + } + + /** + * Check that the lightness value (0–1) + */ + public static boolean isDark(float[] hsl) { // @Size(3) + return hsl[2] < 0.5f; + } + + /** + * Convert to HSL & check that the lightness value + */ + public static boolean isDark(@ColorInt int color) { + float[] hsl = new float[3]; + android.support.v4.graphics.ColorUtils.colorToHSL(color, hsl); + return isDark(hsl); + } + + /** + * Calculate a variant of the color to make it more suitable for overlaying information. Light + * colors will be lightened and dark colors will be darkened + * + * @param color the color to adjust + * @param isDark whether {@code color} is light or dark + * @param lightnessMultiplier the amount to modify the color e.g. 0.1f will alter it by 10% + * @return the adjusted color + */ + public static @ColorInt int scrimify(@ColorInt int color, + boolean isDark, + @FloatRange(from = 0f, to = 1f) float lightnessMultiplier) { + float[] hsl = new float[3]; + android.support.v4.graphics.ColorUtils.colorToHSL(color, hsl); + + if (!isDark) { + lightnessMultiplier += 1f; + } else { + lightnessMultiplier = 1f - lightnessMultiplier; + } + + + hsl[2] = MathUtils.constrain(0f, 1f, hsl[2] * lightnessMultiplier); + return android.support.v4.graphics.ColorUtils.HSLToColor(hsl); + } + + public static @ColorInt int scrimify(@ColorInt int color, + @FloatRange(from = 0f, to = 1f) float lightnessMultiplier) { + return scrimify(color, isDark(color), lightnessMultiplier); + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({IS_LIGHT, IS_DARK, LIGHTNESS_UNKNOWN}) + public @interface Lightness { + } + +} diff --git a/app/src/main/java/io/plaidapp/util/FontUtil.java b/app/src/main/java/io/plaidapp/util/FontUtil.java new file mode 100644 index 000000000..75db953a1 --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/FontUtil.java @@ -0,0 +1,46 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.util; + +import android.content.Context; +import android.graphics.Typeface; + +import java.util.HashMap; +import java.util.Map; + +/** + * Adapted from github.com/romannurik/muzei/ + *

+ * Also see https://code.google.com/p/android/issues/detail?id=9904 + */ +public class FontUtil { + + private FontUtil() { } + + private static final Map sTypefaceCache = new HashMap(); + + public static Typeface get(Context context, String font) { + synchronized (sTypefaceCache) { + if (!sTypefaceCache.containsKey(font)) { + Typeface tf = Typeface.createFromAsset( + context.getApplicationContext().getAssets(), "fonts/" + font + ".ttf"); + sTypefaceCache.put(font, tf); + } + return sTypefaceCache.get(font); + } + } +} diff --git a/app/src/main/java/io/plaidapp/util/HtmlUtils.java b/app/src/main/java/io/plaidapp/util/HtmlUtils.java new file mode 100644 index 000000000..b2364286e --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/HtmlUtils.java @@ -0,0 +1,87 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.util; + +import android.content.res.ColorStateList; +import android.text.Html; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.URLSpan; +import android.widget.TextView; + +import in.uncod.android.bypass.style.TouchableUrlSpan; + +/** + * Utility methods for working with HTML. + */ +public class HtmlUtils { + + private HtmlUtils() { } + + /** + * Work around some 'features' of TextView and URLSpans. i.e. vanilla URLSpans do not react to + * touch so we replace them with our own {@link io.plaidapp.ui.span + * .TouchableUrlSpan} + * & {@link io.plaidapp.util.LinkTouchMovementMethod} to fix this. + *

+ * Setting a custom MovementMethod on a TextView also alters touch handling (see + * TextView#fixFocusableAndClickableSettings) so we need to correct this. + * + * @param textView + * @param input + */ + public static void setTextWithNiceLinks(TextView textView, CharSequence input) { + textView.setText(input); + textView.setMovementMethod(LinkTouchMovementMethod.getInstance()); + textView.setFocusable(false); + textView.setClickable(false); + textView.setLongClickable(false); + } + + /** + * Parse the given input using {@link TouchableUrlSpan}s + * rather than vanilla {@link android.text.style.URLSpan}s so that they respond to touch. + * + * @param input + * @param linkTextColor + * @param linkHighlightColor + * @return + */ + public static Spanned parseHtml(String input, + ColorStateList linkTextColor, + int linkHighlightColor) { + SpannableStringBuilder spanned = (SpannableStringBuilder) Html.fromHtml(input); + + // strip any trailing newlines + while (spanned.charAt(spanned.length() - 1) == '\n') { + spanned = spanned.delete(spanned.length() - 1, spanned.length()); + } + + URLSpan[] urlSpans = spanned.getSpans(0, spanned.length(), URLSpan.class); + for (URLSpan urlSpan : urlSpans) { + int start = spanned.getSpanStart(urlSpan); + int end = spanned.getSpanEnd(urlSpan); + spanned.removeSpan(urlSpan); + // spanned.subSequence(start, start + 1) == "@" TODO send to our own user activity... + // when i've written it + spanned.setSpan(new TouchableUrlSpan(urlSpan.getURL(), linkTextColor, + linkHighlightColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + return spanned; + } + +} diff --git a/app/src/main/java/io/plaidapp/util/ImageUtils.java b/app/src/main/java/io/plaidapp/util/ImageUtils.java new file mode 100644 index 000000000..42a282dcd --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/ImageUtils.java @@ -0,0 +1,45 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.util; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.support.annotation.DrawableRes; + +/** + * Utility methods for working with images. + */ +public class ImageUtils { + + private ImageUtils() { } + + public static Bitmap vectorToBitmap(Context context, Drawable vector) { + final Bitmap bitmap = Bitmap.createBitmap(vector.getIntrinsicWidth(), + vector.getIntrinsicHeight(), + Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(bitmap); + vector.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + vector.draw(canvas); + return bitmap; + } + + public static Bitmap vectorToBitmap(Context context, @DrawableRes int vectorDrawableId) { + return vectorToBitmap(context, context.getDrawable(vectorDrawableId)); + } +} diff --git a/app/src/main/java/io/plaidapp/util/ImeUtils.java b/app/src/main/java/io/plaidapp/util/ImeUtils.java new file mode 100644 index 000000000..68beb8ca0 --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/ImeUtils.java @@ -0,0 +1,54 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.util; + +import android.content.Context; +import android.os.ResultReceiver; +import android.support.annotation.NonNull; +import android.view.View; +import android.view.inputmethod.InputMethodManager; + +import java.lang.reflect.Method; + +/** + * Utility methods for working with the keyboard + */ +public class ImeUtils { + + private ImeUtils() { } + + public static void showIme(@NonNull View view) { + InputMethodManager imm = (InputMethodManager) view.getContext().getSystemService + (Context.INPUT_METHOD_SERVICE); + // the public methods don't seem to work for me, so… reflection. + try { + Method showSoftInputUnchecked = InputMethodManager.class.getMethod( + "showSoftInputUnchecked", int.class, ResultReceiver.class); + showSoftInputUnchecked.setAccessible(true); + showSoftInputUnchecked.invoke(imm, 0, null); + } catch (Exception e) { + // ho hum + } + } + + public static void hideIme(@NonNull View view) { + InputMethodManager imm = (InputMethodManager) view.getContext().getSystemService(Context + .INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(view.getWindowToken(), 0); + } + +} diff --git a/app/src/main/java/io/plaidapp/util/LinkTouchMovementMethod.java b/app/src/main/java/io/plaidapp/util/LinkTouchMovementMethod.java new file mode 100644 index 000000000..40a8e75fa --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/LinkTouchMovementMethod.java @@ -0,0 +1,102 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.util; + +import android.text.Layout; +import android.text.Selection; +import android.text.Spannable; +import android.text.method.LinkMovementMethod; +import android.text.method.MovementMethod; +import android.view.MotionEvent; +import android.widget.TextView; + +import in.uncod.android.bypass.style.TouchableUrlSpan; + +/** + * A movement method that only highlights any touched + * {@link TouchableUrlSpan}s + * + * Adapted from http://stackoverflow.com/a/20905824 + */ +public class LinkTouchMovementMethod extends LinkMovementMethod { + + + private static LinkTouchMovementMethod instance; + private TouchableUrlSpan pressedSpan; + + public static MovementMethod getInstance() { + if (instance == null) + instance = new LinkTouchMovementMethod(); + + return instance; + } + + @Override + public boolean onTouchEvent(TextView textView, Spannable spannable, MotionEvent event) { + boolean handled = false; + if (event.getAction() == MotionEvent.ACTION_DOWN) { + pressedSpan = getPressedSpan(textView, spannable, event); + if (pressedSpan != null) { + pressedSpan.setPressed(true); + Selection.setSelection(spannable, spannable.getSpanStart(pressedSpan), + spannable.getSpanEnd(pressedSpan)); + handled = true; + } + } else if (event.getAction() == MotionEvent.ACTION_MOVE) { + TouchableUrlSpan touchedSpan = getPressedSpan(textView, spannable, event); + if (pressedSpan != null && touchedSpan != pressedSpan) { + pressedSpan.setPressed(false); + pressedSpan = null; + Selection.removeSelection(spannable); + } + } else { + if (pressedSpan != null) { + pressedSpan.setPressed(false); + super.onTouchEvent(textView, spannable, event); + handled = true; + } + pressedSpan = null; + Selection.removeSelection(spannable); + } + return handled; + } + + private TouchableUrlSpan getPressedSpan(TextView textView, Spannable spannable, MotionEvent + event) { + + int x = (int) event.getX(); + int y = (int) event.getY(); + + x -= textView.getTotalPaddingLeft(); + y -= textView.getTotalPaddingTop(); + + x += textView.getScrollX(); + y += textView.getScrollY(); + + Layout layout = textView.getLayout(); + int line = layout.getLineForVertical(y); + int off = layout.getOffsetForHorizontal(line, x); + + TouchableUrlSpan[] link = spannable.getSpans(off, off, TouchableUrlSpan.class); + TouchableUrlSpan touchedSpan = null; + if (link.length > 0) { + touchedSpan = link[0]; + } + return touchedSpan; + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/plaidapp/util/MathUtils.java b/app/src/main/java/io/plaidapp/util/MathUtils.java new file mode 100644 index 000000000..26486a2a7 --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/MathUtils.java @@ -0,0 +1,29 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.util; + +/** + * Borrowed from github.com/romannurik/muzei + */ +public class MathUtils { + + private MathUtils() { } + + public static float constrain(float min, float max, float v) { + return Math.max(min, Math.min(max, v)); + } +} diff --git a/app/src/main/java/io/plaidapp/util/ObservableColorMatrix.java b/app/src/main/java/io/plaidapp/util/ObservableColorMatrix.java new file mode 100644 index 000000000..e063bb06a --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/ObservableColorMatrix.java @@ -0,0 +1,59 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.util; + +import android.graphics.ColorMatrix; +import android.util.Property; + +/** + * An extension to {@link ColorMatrix} which caches the saturation value for animation purposes. + * + * TODO: Look into this: https://github + * .com/square/picasso/commit/a181e0fbead6889347b39ac49363dbedde308120 + * https://blog.neteril.org/blog/2014/11/23/android-material-image-loading/ + */ +public class ObservableColorMatrix extends ColorMatrix { + + private float saturation = 1f; + public static final Property SATURATION = new AnimUtils + .FloatProperty("saturation") { + + @Override + public void setValue(ObservableColorMatrix cm, float value) { + cm.setSaturation(value); + } + + @Override + public Float get(ObservableColorMatrix cm) { + return cm.getSaturation(); + } + }; + + public ObservableColorMatrix() { + super(); + } + + public float getSaturation() { + return saturation; + } + + @Override + public void setSaturation(float saturation) { + this.saturation = saturation; + super.setSaturation(saturation); + } +} diff --git a/app/src/main/java/io/plaidapp/util/ParcelUtils.java b/app/src/main/java/io/plaidapp/util/ParcelUtils.java new file mode 100644 index 000000000..966d7980e --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/ParcelUtils.java @@ -0,0 +1,56 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.util; + +import android.os.Parcel; + +import java.util.HashMap; +import java.util.Map; + +/** + * Utility methods for working with Parcels. + */ +public class ParcelUtils { + + private ParcelUtils() { } + + public static void writeStringMap(Map map, Parcel parcel) { + if (map != null && map.size() > 0) { + parcel.writeInt(map.size()); + for (Map.Entry entry : map.entrySet()) { + parcel.writeString(entry.getKey()); + parcel.writeString(entry.getValue()); + } + } else { + parcel.writeInt(0); + } + } + + public static Map readStringMap(Parcel parcel) { + Map map = null; + int size = parcel.readInt(); + if (size > 0) { + map = new HashMap(size); + for (int i = 0; i < size; i++) { + String key = parcel.readString(); + String value = parcel.readString(); + map.put(key, value); + } + } + return map; + } +} diff --git a/app/src/main/java/io/plaidapp/util/ScrimUtil.java b/app/src/main/java/io/plaidapp/util/ScrimUtil.java new file mode 100644 index 000000000..359a19a89 --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/ScrimUtil.java @@ -0,0 +1,105 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.util; + +import android.graphics.Color; +import android.graphics.LinearGradient; +import android.graphics.Shader; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.PaintDrawable; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.RectShape; +import android.support.annotation.ColorInt; +import android.view.Gravity; + +/** + * * Borrowed from github.com/romannurik/muzei + */ +public class ScrimUtil { + + private ScrimUtil() { } + + /** + * Creates an approximated cubic gradient using a multi-stop linear gradient. See + * this post for more + * details. + */ + public static Drawable makeCubicGradientScrimDrawable(@ColorInt int baseColor, + int numStops, + int gravity) { + numStops = Math.max(numStops, 2); + + PaintDrawable paintDrawable = new PaintDrawable(); + paintDrawable.setShape(new RectShape()); + + final int[] stopColors = new int[numStops]; + + int alpha = Color.alpha(baseColor); + + for (int i = 0; i < numStops; i++) { + float x = i * 1f / (numStops - 1); + float opacity = MathUtils.constrain(0, 1, (float) Math.pow(x, 3)); + stopColors[i] = ColorUtils.modifyAlpha(baseColor, (int) (alpha * opacity)); + } + + final float x0, x1, y0, y1; + switch (gravity & Gravity.HORIZONTAL_GRAVITY_MASK) { + case Gravity.LEFT: + x0 = 1; + x1 = 0; + break; + case Gravity.RIGHT: + x0 = 0; + x1 = 1; + break; + default: + x0 = 0; + x1 = 0; + break; + } + switch (gravity & Gravity.VERTICAL_GRAVITY_MASK) { + case Gravity.TOP: + y0 = 1; + y1 = 0; + break; + case Gravity.BOTTOM: + y0 = 0; + y1 = 1; + break; + default: + y0 = 0; + y1 = 0; + break; + } + + paintDrawable.setShaderFactory(new ShapeDrawable.ShaderFactory() { + @Override + public Shader resize(int width, int height) { + LinearGradient linearGradient = new LinearGradient( + width * x0, + height * y0, + width * x1, + height * y1, + stopColors, null, + Shader.TileMode.CLAMP); + return linearGradient; + } + }); + + return paintDrawable; + } +} diff --git a/app/src/main/java/io/plaidapp/util/ViewOffsetHelper.java b/app/src/main/java/io/plaidapp/util/ViewOffsetHelper.java new file mode 100644 index 000000000..c35fcfbe6 --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/ViewOffsetHelper.java @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * 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. + */ + +package io.plaidapp.util; + +import android.os.Build; +import android.support.v4.view.ViewCompat; +import android.util.Property; +import android.view.View; +import android.view.ViewParent; + +/** + * Borrowed from the design lib. + * + * Utility helper for moving a {@link android.view.View} around using + * {@link android.view.View#offsetLeftAndRight(int)} and + * {@link android.view.View#offsetTopAndBottom(int)}. + *

+ * Also the setting of absolute offsets (similar to translationX/Y), rather than additive + * offsets. + */ +public class ViewOffsetHelper { + + private final View mView; + + private int mLayoutTop; + private int mLayoutLeft; + private int mOffsetTop; + private int mOffsetLeft; + + public ViewOffsetHelper(View view) { + mView = view; + } + + public void onViewLayout() { + // Now grab the intended top + mLayoutTop = mView.getTop(); + mLayoutLeft = mView.getLeft(); + + // And offset it as needed + updateOffsets(); + } + + private void updateOffsets() { + ViewCompat.offsetTopAndBottom(mView, mOffsetTop - (mView.getTop() - mLayoutTop)); + ViewCompat.offsetLeftAndRight(mView, mOffsetLeft - (mView.getLeft() - mLayoutLeft)); + + // Manually invalidate the view and parent to make sure we get drawn pre-M + if (Build.VERSION.SDK_INT < 23) { + tickleInvalidationFlag(mView); + final ViewParent vp = mView.getParent(); + if (vp instanceof View) { + tickleInvalidationFlag((View) vp); + } + } + } + + private static void tickleInvalidationFlag(View view) { + final float x = ViewCompat.getTranslationX(view); + ViewCompat.setTranslationY(view, x + 1); + ViewCompat.setTranslationY(view, x); + } + + /** + * Set the top and bottom offset for this {@link ViewOffsetHelper}'s view. + * + * @param offset the offset in px. + * @return true if the offset has changed + */ + public boolean setTopAndBottomOffset(int offset) { + if (mOffsetTop != offset) { + mOffsetTop = offset; + updateOffsets(); + return true; + } + return false; + } + + /** + * Set the left and right offset for this {@link ViewOffsetHelper}'s view. + * + * @param offset the offset in px. + * @return true if the offset has changed + */ + public boolean setLeftAndRightOffset(int offset) { + if (mOffsetLeft != offset) { + mOffsetLeft = offset; + updateOffsets(); + return true; + } + return false; + } + + public int getTopAndBottomOffset() { + return mOffsetTop; + } + + public int getLeftAndRightOffset() { + return mOffsetLeft; + } + + public static final Property OFFSET_Y = new AnimUtils + .IntProperty("topAndBottomOffset") { + + @Override + public void setValue(ViewOffsetHelper viewOffsetHelper, int offset) { + viewOffsetHelper.setTopAndBottomOffset(offset); + } + + @Override + public Integer get(ViewOffsetHelper viewOffsetHelper) { + return viewOffsetHelper.getTopAndBottomOffset(); + } + }; +} diff --git a/app/src/main/java/io/plaidapp/util/ViewUtils.java b/app/src/main/java/io/plaidapp/util/ViewUtils.java new file mode 100644 index 000000000..f34129d7a --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/ViewUtils.java @@ -0,0 +1,143 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.util; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.RippleDrawable; +import android.os.Build; +import android.support.annotation.ColorInt; +import android.support.annotation.FloatRange; +import android.support.annotation.NonNull; +import android.text.TextPaint; +import android.util.DisplayMetrics; +import android.util.Property; +import android.util.TypedValue; +import android.view.View; +import android.widget.ImageView; + +/** + * Utility methods for working with Views. + */ +public class ViewUtils { + + private ViewUtils() { } + + private static int actionBarSize = -1; + + public static int getActionBarSize(Context context) { + if (actionBarSize < 0) { + TypedValue value = new TypedValue(); + context.getTheme().resolveAttribute(android.R.attr.actionBarSize, value, true); + actionBarSize = TypedValue.complexToDimensionPixelSize(value.data, context + .getResources().getDisplayMetrics()); + } + return actionBarSize; + } + + public static RippleDrawable createRipple(@ColorInt int color, + @FloatRange(from = 0f, to = 1f) float alpha) { + color = ColorUtils.modifyAlpha(color, alpha); + return new RippleDrawable(ColorStateList.valueOf(color), null, null); + } + + public static RippleDrawable createMaskedRipple(@ColorInt int color, + @FloatRange(from = 0f, to = 1f) float alpha) { + color = ColorUtils.modifyAlpha(color, alpha); + return new RippleDrawable(ColorStateList.valueOf(color), null, new ColorDrawable + (0xffffffff)); + } + + public static void setLightStatusBar(@NonNull View view) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + int flags = view.getSystemUiVisibility(); + flags |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + view.setSystemUiVisibility(flags); + } + } + + public static void clearLightStatusBar(@NonNull View view) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + int flags = view.getSystemUiVisibility(); + flags &= ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + view.setSystemUiVisibility(flags); + } + } + + /** + * Recursive binary search to find the best size for the text. + * + * Adapted from https://github.com/grantland/android-autofittextview + */ + public static float getSingleLineTextSize(String text, + TextPaint paint, + float targetWidth, + float low, + float high, + float precision, + DisplayMetrics metrics) { + final float mid = (low + high) / 2.0f; + + paint.setTextSize(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_PX, mid, metrics)); + final float maxLineWidth = paint.measureText(text); + + if ((high - low) < precision) { + return low; + } else if (maxLineWidth > targetWidth) { + return getSingleLineTextSize(text, paint, targetWidth, low, mid, precision, metrics); + } else if (maxLineWidth < targetWidth) { + return getSingleLineTextSize(text, paint, targetWidth, mid, high, precision, metrics); + } else { + return mid; + } + } + + public static final Property BACKGROUND_COLOR + = new AnimUtils.IntProperty("backgroundColor") { + + @Override + public void setValue(View view, int value) { + view.setBackgroundColor(value); + } + + @Override + public Integer get(View view) { + Drawable d = view.getBackground(); + if (d instanceof ColorDrawable) { + return ((ColorDrawable) d).getColor(); + } + return Color.TRANSPARENT; + } + }; + + public static final Property IMAGE_ALPHA + = new AnimUtils.IntProperty("imageAlpha") { + + @Override + public void setValue(ImageView imageView, int value) { + imageView.setImageAlpha(value); + } + + @Override + public Integer get(ImageView imageView) { + return imageView.getImageAlpha(); + } + }; +} diff --git a/app/src/main/java/io/plaidapp/util/customtabs/CustomTabActivityHelper.java b/app/src/main/java/io/plaidapp/util/customtabs/CustomTabActivityHelper.java new file mode 100644 index 000000000..828ef99df --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/customtabs/CustomTabActivityHelper.java @@ -0,0 +1,155 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.util.customtabs; + +import android.app.Activity; +import android.content.ComponentName; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.support.customtabs.CustomTabsClient; +import android.support.customtabs.CustomTabsIntent; +import android.support.customtabs.CustomTabsServiceConnection; +import android.support.customtabs.CustomTabsSession; + +import java.util.List; + +/** + * This is a helper class to manage the connection to the Custom Tabs Service and + * + * Adapted from github.com/GoogleChrome/custom-tabs-client + */ +public class CustomTabActivityHelper { + private CustomTabsSession mCustomTabsSession; + private CustomTabsClient mClient; + private CustomTabsServiceConnection mConnection; + private ConnectionCallback mConnectionCallback; + + /** + * Opens the URL on a Custom Tab if possible; otherwise falls back to opening it via + * {@code Intent.ACTION_VIEW} + * + * @param activity The host activity + * @param customTabsIntent a CustomTabsIntent to be used if Custom Tabs is available + * @param uri the Uri to be opened + */ + public static void openCustomTab(Activity activity, + CustomTabsIntent customTabsIntent, + Uri uri) { + String packageName = CustomTabsHelper.getPackageNameToUse(activity); + + // if we cant find a package name, it means there's no browser that supports + // Custom Tabs installed. So, we fallback to a view intent + if (packageName != null) { + customTabsIntent.intent.setPackage(packageName); + customTabsIntent.launchUrl(activity, uri); + } else { + activity.startActivity(new Intent(Intent.ACTION_VIEW, uri)); + } + } + + /** + * Binds the Activity to the Custom Tabs Service + * @param activity the activity to be bound to the service + */ + public void bindCustomTabsService(Activity activity) { + if (mClient != null) return; + + String packageName = CustomTabsHelper.getPackageNameToUse(activity); + if (packageName == null) return; + mConnection = new CustomTabsServiceConnection() { + @Override + public void onCustomTabsServiceConnected(ComponentName name, CustomTabsClient client) { + mClient = client; + mClient.warmup(0L); + if (mConnectionCallback != null) mConnectionCallback.onCustomTabsConnected(); + //Initialize a session as soon as possible. + getSession(); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + mClient = null; + if (mConnectionCallback != null) mConnectionCallback.onCustomTabsDisconnected(); + } + }; + CustomTabsClient.bindCustomTabsService(activity, packageName, mConnection); + } + + /** + * Unbinds the Activity from the Custom Tabs Service + * @param activity the activity that is bound to the service + */ + public void unbindCustomTabsService(Activity activity) { + if (mConnection == null) return; + activity.unbindService(mConnection); + mClient = null; + mCustomTabsSession = null; + } + + /** + * Creates or retrieves an exiting CustomTabsSession + * + * @return a CustomTabsSession + */ + public CustomTabsSession getSession() { + if (mClient == null) { + mCustomTabsSession = null; + } else if (mCustomTabsSession == null) { + mCustomTabsSession = mClient.newSession(null); + } + return mCustomTabsSession; + } + + /** + * Register a Callback to be called when connected or disconnected from the Custom Tabs Service + * @param connectionCallback + */ + public void setConnectionCallback(ConnectionCallback connectionCallback) { + this.mConnectionCallback = connectionCallback; + } + + /** + * @see {@link CustomTabsSession#mayLaunchUrl(Uri, Bundle, List)} + * @return true if call to mayLaunchUrl was accepted + */ + public boolean mayLaunchUrl(Uri uri, Bundle extras, List otherLikelyBundles) { + if (mClient == null) return false; + + CustomTabsSession session = getSession(); + if (session == null) return false; + + return session.mayLaunchUrl(uri, extras, otherLikelyBundles); + } + + /** + * A Callback for when the service is connected or disconnected. Use those callbacks to + * handle UI changes when the service is connected or disconnected + */ + public interface ConnectionCallback { + /** + * Called when the service is connected + */ + void onCustomTabsConnected(); + + /** + * Called when the service is disconnected + */ + void onCustomTabsDisconnected(); + } + +} diff --git a/app/src/main/java/io/plaidapp/util/customtabs/CustomTabsHelper.java b/app/src/main/java/io/plaidapp/util/customtabs/CustomTabsHelper.java new file mode 100644 index 000000000..a0159d9ea --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/customtabs/CustomTabsHelper.java @@ -0,0 +1,145 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.util.customtabs; + +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.support.customtabs.CustomTabsService; +import android.text.TextUtils; +import android.util.Log; + +import java.util.ArrayList; +import java.util.List; + +/** + * Helper class for Custom Tabs. + * + * Adapted from github.com/GoogleChrome/custom-tabs-client + */ +public class CustomTabsHelper { + private static final String TAG = "CustomTabsHelper"; + static final String STABLE_PACKAGE = "com.android.chrome"; + static final String BETA_PACKAGE = "com.chrome.beta"; + static final String DEV_PACKAGE = "com.chrome.dev"; + static final String LOCAL_PACKAGE = "com.google.android.apps.chrome"; + private static final String EXTRA_CUSTOM_TABS_KEEP_ALIVE = + "android.support.customtabs.extra.KEEP_ALIVE"; + + private static String sPackageNameToUse; + + private CustomTabsHelper() {} + + public static void addKeepAliveExtra(Context context, Intent intent) { + Intent keepAliveIntent = new Intent().setClassName( + context.getPackageName(), KeepAliveService.class.getCanonicalName()); + intent.putExtra(EXTRA_CUSTOM_TABS_KEEP_ALIVE, keepAliveIntent); + } + + /** + * Goes through all apps that handle VIEW intents and have a warmup service. Picks + * the one chosen by the user if there is one, otherwise makes a best effort to return a + * valid package name. + * + * This is not threadsafe. + * + * @param context {@link Context} to use for accessing {@link PackageManager}. + * @return The package name recommended to use for connecting to custom tabs related components. + */ + public static String getPackageNameToUse(Context context) { + if (sPackageNameToUse != null) return sPackageNameToUse; + + PackageManager pm = context.getPackageManager(); + // Get default VIEW intent handler. + Intent activityIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("http://www.example.com")); + ResolveInfo defaultViewHandlerInfo = pm.resolveActivity(activityIntent, 0); + String defaultViewHandlerPackageName = null; + if (defaultViewHandlerInfo != null) { + defaultViewHandlerPackageName = defaultViewHandlerInfo.activityInfo.packageName; + } + + // Get all apps that can handle VIEW intents. + List resolvedActivityList = pm.queryIntentActivities(activityIntent, 0); + List packagesSupportingCustomTabs = new ArrayList<>(); + for (ResolveInfo info : resolvedActivityList) { + Intent serviceIntent = new Intent(); + serviceIntent.setAction(CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION); + serviceIntent.setPackage(info.activityInfo.packageName); + if (pm.resolveService(serviceIntent, 0) != null) { + packagesSupportingCustomTabs.add(info.activityInfo.packageName); + } + } + + // Now packagesSupportingCustomTabs contains all apps that can handle both VIEW intents + // and service calls. + if (packagesSupportingCustomTabs.isEmpty()) { + sPackageNameToUse = null; + } else if (packagesSupportingCustomTabs.size() == 1) { + sPackageNameToUse = packagesSupportingCustomTabs.get(0); + } else if (!TextUtils.isEmpty(defaultViewHandlerPackageName) + && !hasSpecializedHandlerIntents(context, activityIntent) + && packagesSupportingCustomTabs.contains(defaultViewHandlerPackageName)) { + sPackageNameToUse = defaultViewHandlerPackageName; + } else if (packagesSupportingCustomTabs.contains(STABLE_PACKAGE)) { + sPackageNameToUse = STABLE_PACKAGE; + } else if (packagesSupportingCustomTabs.contains(BETA_PACKAGE)) { + sPackageNameToUse = BETA_PACKAGE; + } else if (packagesSupportingCustomTabs.contains(DEV_PACKAGE)) { + sPackageNameToUse = DEV_PACKAGE; + } else if (packagesSupportingCustomTabs.contains(LOCAL_PACKAGE)) { + sPackageNameToUse = LOCAL_PACKAGE; + } + return sPackageNameToUse; + } + + /** + * Used to check whether there is a specialized handler for a given intent. + * @param intent The intent to check with. + * @return Whether there is a specialized handler for the given intent. + */ + private static boolean hasSpecializedHandlerIntents(Context context, Intent intent) { + try { + PackageManager pm = context.getPackageManager(); + List handlers = pm.queryIntentActivities( + intent, + PackageManager.GET_RESOLVED_FILTER); + if (handlers == null || handlers.size() == 0) { + return false; + } + for (ResolveInfo resolveInfo : handlers) { + IntentFilter filter = resolveInfo.filter; + if (filter == null) continue; + if (filter.countDataAuthorities() == 0 || filter.countDataPaths() == 0) continue; + if (resolveInfo.activityInfo == null) continue; + return true; + } + } catch (RuntimeException e) { + Log.e(TAG, "Runtime exception while getting specialized handlers"); + } + return false; + } + + /** + * @return All possible chrome package names that provide custom tabs feature. + */ + public static String[] getPackages() { + return new String[]{"", STABLE_PACKAGE, BETA_PACKAGE, DEV_PACKAGE, LOCAL_PACKAGE}; + } +} diff --git a/app/src/main/java/io/plaidapp/util/customtabs/KeepAliveService.java b/app/src/main/java/io/plaidapp/util/customtabs/KeepAliveService.java new file mode 100644 index 000000000..25401e2b1 --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/customtabs/KeepAliveService.java @@ -0,0 +1,36 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.util.customtabs; + +import android.app.Service; +import android.content.Intent; +import android.os.Binder; +import android.os.IBinder; + +/** + * Empty service used by the custom tab to bind to, raising the application's importance. + * + * Adapted from github.com/GoogleChrome/custom-tabs-client + */ +public class KeepAliveService extends Service { + private static final Binder sBinder = new Binder(); + + @Override + public IBinder onBind(Intent intent) { + return sBinder; + } +} diff --git a/app/src/main/java/io/plaidapp/util/glide/CircleTransform.java b/app/src/main/java/io/plaidapp/util/glide/CircleTransform.java new file mode 100644 index 000000000..0a35d5dda --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/glide/CircleTransform.java @@ -0,0 +1,71 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.util.glide; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapShader; +import android.graphics.Canvas; +import android.graphics.Paint; + +import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool; +import com.bumptech.glide.load.resource.bitmap.BitmapTransformation; + +/** + * Created by http://stackoverflow.com/a/25806229/409481 + */ +public class CircleTransform extends BitmapTransformation { + + public CircleTransform(Context context) { + super(context); + } + + private static Bitmap circleCrop(BitmapPool pool, Bitmap source) { + if (source == null) return null; + + int size = Math.min(source.getWidth(), source.getHeight()); + int x = (source.getWidth() - size) / 2; + int y = (source.getHeight() - size) / 2; + + // TODO this could be acquired from the pool too + Bitmap squared = Bitmap.createBitmap(source, x, y, size, size); + + Bitmap result = pool.get(size, size, Bitmap.Config.ARGB_8888); + if (result == null) { + result = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); + } + + Canvas canvas = new Canvas(result); + Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.DITHER_FLAG | Paint + .ANTI_ALIAS_FLAG); + paint.setShader(new BitmapShader(squared, BitmapShader.TileMode.CLAMP, BitmapShader + .TileMode.CLAMP)); + float r = size / 2f; + canvas.drawCircle(r, r, r, paint); + return result; + } + + @Override + protected Bitmap transform(BitmapPool pool, Bitmap toTransform, int outWidth, int outHeight) { + return circleCrop(pool, toTransform); + } + + @Override + public String getId() { + return getClass().getName(); + } +} diff --git a/app/src/main/java/io/plaidapp/util/glide/DribbbleTarget.java b/app/src/main/java/io/plaidapp/util/glide/DribbbleTarget.java new file mode 100644 index 000000000..8deeb71bc --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/glide/DribbbleTarget.java @@ -0,0 +1,117 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.util.glide; + +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.support.v4.content.ContextCompat; +import android.support.v7.graphics.Palette; + +import com.bumptech.glide.load.resource.bitmap.GlideBitmapDrawable; +import com.bumptech.glide.load.resource.drawable.GlideDrawable; +import com.bumptech.glide.load.resource.gif.GifDrawable; +import com.bumptech.glide.request.animation.GlideAnimation; +import com.bumptech.glide.request.target.GlideDrawableImageViewTarget; + +import io.plaidapp.R; +import io.plaidapp.ui.widget.BadgedFourThreeImageView; +import io.plaidapp.util.ColorUtils; +import io.plaidapp.util.ViewUtils; + +/** + * + */ +public class DribbbleTarget extends GlideDrawableImageViewTarget implements Palette + .PaletteAsyncListener { + + private boolean playGifs; + + public DribbbleTarget(BadgedFourThreeImageView view, boolean playGifs) { + super(view); + this.playGifs = playGifs; + } + + @Override + public void onResourceReady(GlideDrawable resource, GlideAnimation + animation) { + super.onResourceReady(resource, animation); + if (!playGifs) { + resource.stop(); + } + + BadgedFourThreeImageView badgedImageView = (BadgedFourThreeImageView) getView(); + if (resource instanceof GlideBitmapDrawable) { + Palette.from(((GlideBitmapDrawable) resource).getBitmap()) + .clearFilters() + .generate(this); + badgedImageView.showBadge(false); + } else if (resource instanceof GifDrawable) { + Bitmap image = ((GifDrawable) resource).getFirstFrame(); + Palette.from(image).clearFilters().generate(this); + badgedImageView.showBadge(true); + + // look at the corner to determine the gif badge color + int cornerSize = (int) (56 * getView().getContext().getResources().getDisplayMetrics + ().scaledDensity); + Bitmap corner = Bitmap.createBitmap(image, + image.getWidth() - cornerSize, + image.getHeight() - cornerSize, + cornerSize, cornerSize); + boolean isDark = ColorUtils.isDark(corner); + corner.recycle(); + badgedImageView.setBadgeColor(ContextCompat.getColor(getView().getContext(), + isDark ? R.color.gif_badge_dark_image : R.color.gif_badge_light_image)); + } + } + + @Override + public void onStart() { + if (playGifs) { + super.onStart(); + } + } + + @Override + public void onStop() { + if (playGifs) { + super.onStop(); + } + } + + @Override + public void onGenerated(Palette palette) { + Drawable ripple = null; + // try the named swatches in preference order + if (palette.getVibrantSwatch() != null) { + ripple = ViewUtils.createRipple(palette.getVibrantSwatch().getRgb(), 0.25f); + } else if (palette.getLightVibrantSwatch() != null) { + ripple = ViewUtils.createRipple(palette.getLightVibrantSwatch().getRgb(), 0.5f); + } else if (palette.getDarkVibrantSwatch() != null) { + ripple = ViewUtils.createRipple(palette.getDarkVibrantSwatch().getRgb(), 0.25f); + } else if (palette.getMutedSwatch() != null) { + ripple = ViewUtils.createRipple(palette.getMutedSwatch().getRgb(), 0.25f); + } else if (palette.getLightMutedSwatch() != null) { + ripple = ViewUtils.createRipple(palette.getLightMutedSwatch().getRgb(), 0.5f); + } else if (palette.getDarkMutedSwatch() != null) { + ripple = ViewUtils.createRipple(palette.getDarkMutedSwatch().getRgb(), 0.25f); + } else { + // no swatches found, fall back to grey :( + ripple = getView().getContext().getDrawable(R.drawable.mid_grey_ripple); + } + ((BadgedFourThreeImageView) getView()).setForeground(ripple); + } +} diff --git a/app/src/main/java/io/plaidapp/util/glide/GlideConfiguration.java b/app/src/main/java/io/plaidapp/util/glide/GlideConfiguration.java new file mode 100644 index 000000000..dcc94b497 --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/glide/GlideConfiguration.java @@ -0,0 +1,45 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.util.glide; + +import android.app.ActivityManager; +import android.content.Context; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.GlideBuilder; +import com.bumptech.glide.load.DecodeFormat; +import com.bumptech.glide.module.GlideModule; + +/** + * Configure Glide to set desired image quality. + */ +public class GlideConfiguration implements GlideModule { + + @Override + public void applyOptions(Context context, GlideBuilder builder) { + // Prefer higher quality images unless we're on a low RAM device + ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + builder.setDecodeFormat(activityManager.isLowRamDevice() ? + DecodeFormat.PREFER_RGB_565 : DecodeFormat.PREFER_ARGB_8888); + } + + @Override + public void registerComponents(Context context, Glide glide) { + + } +} diff --git a/app/src/main/java/io/plaidapp/util/glide/GlideUtils.java b/app/src/main/java/io/plaidapp/util/glide/GlideUtils.java new file mode 100644 index 000000000..af8b08107 --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/glide/GlideUtils.java @@ -0,0 +1,41 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.util.glide; + +import android.graphics.Bitmap; + +import com.bumptech.glide.load.resource.bitmap.GlideBitmapDrawable; +import com.bumptech.glide.load.resource.drawable.GlideDrawable; +import com.bumptech.glide.load.resource.gif.GifDrawable; + +/** + * Utility methods for working with Glide. + */ +public class GlideUtils { + + private GlideUtils() { + } + + public static Bitmap getBitmap(GlideDrawable glideDrawable) { + if (glideDrawable instanceof GlideBitmapDrawable) { + return ((GlideBitmapDrawable) glideDrawable).getBitmap(); + } else if (glideDrawable instanceof GifDrawable) { + return ((GifDrawable) glideDrawable).getFirstFrame(); + } + return null; + } +} diff --git a/app/src/main/java/io/plaidapp/util/glide/ImageSpanTarget.java b/app/src/main/java/io/plaidapp/util/glide/ImageSpanTarget.java new file mode 100644 index 000000000..64c0117b1 --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/glide/ImageSpanTarget.java @@ -0,0 +1,76 @@ +/* + * Copyright 2015 Google Inc. + * + * 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. + */ + +package io.plaidapp.util.glide; + +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.ImageSpan; +import android.transition.TransitionManager; +import android.view.ViewGroup; +import android.widget.TextView; + +import com.bumptech.glide.request.animation.GlideAnimation; +import com.bumptech.glide.request.target.SimpleTarget; + +import java.lang.ref.WeakReference; + +import in.uncod.android.bypass.style.ImageLoadingSpan; + +/** + * A target that puts a downloaded image into an ImageSpan in the provided TextView. It uses a + * {@link ImageLoadingSpan} to mark the area to be replaced by the image. + */ +public class ImageSpanTarget extends SimpleTarget { + + private WeakReference textView; + private ImageLoadingSpan loadingSpan; + + public ImageSpanTarget(TextView textView, ImageLoadingSpan loadingSpan) { + this.textView = new WeakReference<>(textView); + this.loadingSpan = loadingSpan; + } + + @Override + public void onResourceReady(Bitmap bitmap, GlideAnimation glideAnimation) { + TextView tv = textView.get(); + if (tv != null) { + BitmapDrawable bitmapDrawable = new BitmapDrawable(tv.getResources(), bitmap); + // image span doesn't handle scaling so we manually set bounds + if (bitmap.getWidth() > tv.getWidth()) { + float aspectRatio = (float) bitmap.getHeight() / (float) bitmap.getWidth(); + bitmapDrawable.setBounds(0, 0, tv.getWidth(), (int) (aspectRatio * tv.getWidth())); + } else { + bitmapDrawable.setBounds(0, 0, bitmap.getWidth(), bitmap.getHeight()); + } + ImageSpan span = new ImageSpan(bitmapDrawable); + // add the image span and remove our marker + SpannableStringBuilder ssb = new SpannableStringBuilder(tv.getText()); + int start = ssb.getSpanStart(loadingSpan); + int end = ssb.getSpanEnd(loadingSpan); + if (start >= 0 && end >= 0) { + ssb.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + ssb.removeSpan(loadingSpan); + // animate the change + TransitionManager.beginDelayedTransition((ViewGroup) tv.getParent()); + tv.setText(ssb); + } + } + +} diff --git a/app/src/main/res/anim/chrome_custom_tab_enter.xml b/app/src/main/res/anim/chrome_custom_tab_enter.xml new file mode 100644 index 000000000..2ebc85eda --- /dev/null +++ b/app/src/main/res/anim/chrome_custom_tab_enter.xml @@ -0,0 +1,26 @@ + + + + diff --git a/app/src/main/res/anim/fade_out_rapidly.xml b/app/src/main/res/anim/fade_out_rapidly.xml new file mode 100644 index 000000000..882d59ff9 --- /dev/null +++ b/app/src/main/res/anim/fade_out_rapidly.xml @@ -0,0 +1,26 @@ + + + + diff --git a/app/src/main/res/anim/grid_enter.xml b/app/src/main/res/anim/grid_enter.xml new file mode 100644 index 000000000..b77ae34e5 --- /dev/null +++ b/app/src/main/res/anim/grid_enter.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/layout_grid_enter.xml b/app/src/main/res/anim/layout_grid_enter.xml new file mode 100644 index 000000000..f30aea2e7 --- /dev/null +++ b/app/src/main/res/anim/layout_grid_enter.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app/src/main/res/animator/app_bar_pin.xml b/app/src/main/res/animator/app_bar_pin.xml new file mode 100644 index 000000000..2d3dab9d2 --- /dev/null +++ b/app/src/main/res/animator/app_bar_pin.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/animator/button_frown.xml b/app/src/main/res/animator/button_frown.xml new file mode 100644 index 000000000..bf9e440c4 --- /dev/null +++ b/app/src/main/res/animator/button_frown.xml @@ -0,0 +1,26 @@ + + + + diff --git a/app/src/main/res/animator/comment_add_lines_1.xml b/app/src/main/res/animator/comment_add_lines_1.xml new file mode 100644 index 000000000..e9db70b76 --- /dev/null +++ b/app/src/main/res/animator/comment_add_lines_1.xml @@ -0,0 +1,25 @@ + + + + diff --git a/app/src/main/res/animator/comment_add_lines_2.xml b/app/src/main/res/animator/comment_add_lines_2.xml new file mode 100644 index 000000000..e6dcab21a --- /dev/null +++ b/app/src/main/res/animator/comment_add_lines_2.xml @@ -0,0 +1,25 @@ + + + + diff --git a/app/src/main/res/animator/comment_add_lines_3.xml b/app/src/main/res/animator/comment_add_lines_3.xml new file mode 100644 index 000000000..a89f4f1e7 --- /dev/null +++ b/app/src/main/res/animator/comment_add_lines_3.xml @@ -0,0 +1,25 @@ + + + + diff --git a/app/src/main/res/animator/comment_expand_full_heart.xml b/app/src/main/res/animator/comment_expand_full_heart.xml new file mode 100644 index 000000000..500c20321 --- /dev/null +++ b/app/src/main/res/animator/comment_expand_full_heart.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/app/src/main/res/animator/comment_fade_empty_heart.xml b/app/src/main/res/animator/comment_fade_empty_heart.xml new file mode 100644 index 000000000..acfaa4042 --- /dev/null +++ b/app/src/main/res/animator/comment_fade_empty_heart.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app/src/main/res/animator/comment_lines_add_1.xml b/app/src/main/res/animator/comment_lines_add_1.xml new file mode 100644 index 000000000..0e25aeb96 --- /dev/null +++ b/app/src/main/res/animator/comment_lines_add_1.xml @@ -0,0 +1,25 @@ + + + + diff --git a/app/src/main/res/animator/comment_lines_add_2.xml b/app/src/main/res/animator/comment_lines_add_2.xml new file mode 100644 index 000000000..f22634597 --- /dev/null +++ b/app/src/main/res/animator/comment_lines_add_2.xml @@ -0,0 +1,25 @@ + + + + diff --git a/app/src/main/res/animator/comment_lines_add_3.xml b/app/src/main/res/animator/comment_lines_add_3.xml new file mode 100644 index 000000000..f9b64d52b --- /dev/null +++ b/app/src/main/res/animator/comment_lines_add_3.xml @@ -0,0 +1,25 @@ + + + + diff --git a/app/src/main/res/animator/comment_lines_add_rotate.xml b/app/src/main/res/animator/comment_lines_add_rotate.xml new file mode 100644 index 000000000..d495858af --- /dev/null +++ b/app/src/main/res/animator/comment_lines_add_rotate.xml @@ -0,0 +1,24 @@ + + + + diff --git a/app/src/main/res/animator/comment_selection.xml b/app/src/main/res/animator/comment_selection.xml new file mode 100644 index 000000000..bc5c090a9 --- /dev/null +++ b/app/src/main/res/animator/comment_selection.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/disable_text_entry.xml b/app/src/main/res/animator/disable_text_entry.xml new file mode 100644 index 000000000..bd7ba91a1 --- /dev/null +++ b/app/src/main/res/animator/disable_text_entry.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/animator/fab_empty_progress_left.xml b/app/src/main/res/animator/fab_empty_progress_left.xml new file mode 100644 index 000000000..335c7263a --- /dev/null +++ b/app/src/main/res/animator/fab_empty_progress_left.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/fab_empty_progress_right.xml b/app/src/main/res/animator/fab_empty_progress_right.xml new file mode 100644 index 000000000..38e92619a --- /dev/null +++ b/app/src/main/res/animator/fab_empty_progress_right.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/fab_hide_activity_circle.xml b/app/src/main/res/animator/fab_hide_activity_circle.xml new file mode 100644 index 000000000..3738c54cf --- /dev/null +++ b/app/src/main/res/animator/fab_hide_activity_circle.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/fab_load_progress.xml b/app/src/main/res/animator/fab_load_progress.xml new file mode 100644 index 000000000..a2ea622d1 --- /dev/null +++ b/app/src/main/res/animator/fab_load_progress.xml @@ -0,0 +1,25 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/fab_load_progress_rotate.xml b/app/src/main/res/animator/fab_load_progress_rotate.xml new file mode 100644 index 000000000..b9ce3087c --- /dev/null +++ b/app/src/main/res/animator/fab_load_progress_rotate.xml @@ -0,0 +1,25 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/fab_show_activity_circle.xml b/app/src/main/res/animator/fab_show_activity_circle.xml new file mode 100644 index 000000000..b7d6d8c5b --- /dev/null +++ b/app/src/main/res/animator/fab_show_activity_circle.xml @@ -0,0 +1,25 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/fab_show_progress_bar_emptying.xml b/app/src/main/res/animator/fab_show_progress_bar_emptying.xml new file mode 100644 index 000000000..4c8403d5b --- /dev/null +++ b/app/src/main/res/animator/fab_show_progress_bar_emptying.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/heart_empty.xml b/app/src/main/res/animator/heart_empty.xml new file mode 100644 index 000000000..a42be5f57 --- /dev/null +++ b/app/src/main/res/animator/heart_empty.xml @@ -0,0 +1,25 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/heart_fill.xml b/app/src/main/res/animator/heart_fill.xml new file mode 100644 index 000000000..7f456028a --- /dev/null +++ b/app/src/main/res/animator/heart_fill.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/raise.xml b/app/src/main/res/animator/raise.xml new file mode 100644 index 000000000..5400c3898 --- /dev/null +++ b/app/src/main/res/animator/raise.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/animator/reply.xml b/app/src/main/res/animator/reply.xml new file mode 100644 index 000000000..86e72c25e --- /dev/null +++ b/app/src/main/res/animator/reply.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/searchback_hide_arrow_head.xml b/app/src/main/res/animator/searchback_hide_arrow_head.xml new file mode 100644 index 000000000..9b4431543 --- /dev/null +++ b/app/src/main/res/animator/searchback_hide_arrow_head.xml @@ -0,0 +1,24 @@ + + + + diff --git a/app/src/main/res/animator/searchback_hide_circle.xml b/app/src/main/res/animator/searchback_hide_circle.xml new file mode 100644 index 000000000..cd3709005 --- /dev/null +++ b/app/src/main/res/animator/searchback_hide_circle.xml @@ -0,0 +1,24 @@ + + + + diff --git a/app/src/main/res/animator/searchback_show_arrow_head.xml b/app/src/main/res/animator/searchback_show_arrow_head.xml new file mode 100644 index 000000000..c700f2a3d --- /dev/null +++ b/app/src/main/res/animator/searchback_show_arrow_head.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/animator/searchback_show_circle.xml b/app/src/main/res/animator/searchback_show_circle.xml new file mode 100644 index 000000000..bfaf0af2b --- /dev/null +++ b/app/src/main/res/animator/searchback_show_circle.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/animator/searchback_stem_back_to_search.xml b/app/src/main/res/animator/searchback_stem_back_to_search.xml new file mode 100644 index 000000000..e2340fbd5 --- /dev/null +++ b/app/src/main/res/animator/searchback_stem_back_to_search.xml @@ -0,0 +1,36 @@ + + + + + + + + + + diff --git a/app/src/main/res/animator/searchback_stem_search_to_back.xml b/app/src/main/res/animator/searchback_stem_search_to_back.xml new file mode 100644 index 000000000..b45ea3155 --- /dev/null +++ b/app/src/main/res/animator/searchback_stem_search_to_back.xml @@ -0,0 +1,36 @@ + + + + + + + + + + diff --git a/app/src/main/res/animator/show_connection_cross.xml b/app/src/main/res/animator/show_connection_cross.xml new file mode 100644 index 000000000..a558c42a2 --- /dev/null +++ b/app/src/main/res/animator/show_connection_cross.xml @@ -0,0 +1,37 @@ + + + + + + + + + + diff --git a/app/src/main/res/animator/show_connection_line.xml b/app/src/main/res/animator/show_connection_line.xml new file mode 100644 index 000000000..73d18db79 --- /dev/null +++ b/app/src/main/res/animator/show_connection_line.xml @@ -0,0 +1,26 @@ + + + + diff --git a/app/src/main/res/color/designer_news_author.xml b/app/src/main/res/color/designer_news_author.xml new file mode 100644 index 000000000..a4c7715f5 --- /dev/null +++ b/app/src/main/res/color/designer_news_author.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/app/src/main/res/color/designer_news_button.xml b/app/src/main/res/color/designer_news_button.xml new file mode 100644 index 000000000..51674f210 --- /dev/null +++ b/app/src/main/res/color/designer_news_button.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/designer_news_links.xml b/app/src/main/res/color/designer_news_links.xml new file mode 100644 index 000000000..916476d7b --- /dev/null +++ b/app/src/main/res/color/designer_news_links.xml @@ -0,0 +1,24 @@ + + + + + + + + + + diff --git a/app/src/main/res/color/dribbble_links.xml b/app/src/main/res/color/dribbble_links.xml new file mode 100644 index 000000000..78a9c5d76 --- /dev/null +++ b/app/src/main/res/color/dribbble_links.xml @@ -0,0 +1,24 @@ + + + + + + + + + + diff --git a/app/src/main/res/color/dribble_comment_author.xml b/app/src/main/res/color/dribble_comment_author.xml new file mode 100644 index 000000000..90280e2c2 --- /dev/null +++ b/app/src/main/res/color/dribble_comment_author.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/app/src/main/res/color/filter_text.xml b/app/src/main/res/color/filter_text.xml new file mode 100644 index 000000000..907802266 --- /dev/null +++ b/app/src/main/res/color/filter_text.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable-mdpi/grid_item_background.9.png b/app/src/main/res/drawable-mdpi/grid_item_background.9.png new file mode 100644 index 000000000..37054150b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/grid_item_background.9.png differ diff --git a/app/src/main/res/drawable-v23/designer_news_item_background.xml b/app/src/main/res/drawable-v23/designer_news_item_background.xml new file mode 100644 index 000000000..04ef98835 --- /dev/null +++ b/app/src/main/res/drawable-v23/designer_news_item_background.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-v23/product_hunt_item_background.xml b/app/src/main/res/drawable-v23/product_hunt_item_background.xml new file mode 100644 index 000000000..8c41785c5 --- /dev/null +++ b/app/src/main/res/drawable-v23/product_hunt_item_background.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/avatar_placeholder.xml b/app/src/main/res/drawable/avatar_placeholder.xml new file mode 100644 index 000000000..d199d499f --- /dev/null +++ b/app/src/main/res/drawable/avatar_placeholder.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/avd_back_to_search.xml b/app/src/main/res/drawable/avd_back_to_search.xml new file mode 100644 index 000000000..3da6124b9 --- /dev/null +++ b/app/src/main/res/drawable/avd_back_to_search.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/avd_no_connection.xml b/app/src/main/res/drawable/avd_no_connection.xml new file mode 100644 index 000000000..3ef320be9 --- /dev/null +++ b/app/src/main/res/drawable/avd_no_connection.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/avd_search_to_back.xml b/app/src/main/res/drawable/avd_search_to_back.xml new file mode 100644 index 000000000..a3657878d --- /dev/null +++ b/app/src/main/res/drawable/avd_search_to_back.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/comment_background.xml b/app/src/main/res/drawable/comment_background.xml new file mode 100644 index 000000000..0cf5898a9 --- /dev/null +++ b/app/src/main/res/drawable/comment_background.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/comment_heart.xml b/app/src/main/res/drawable/comment_heart.xml new file mode 100644 index 000000000..68992fbe7 --- /dev/null +++ b/app/src/main/res/drawable/comment_heart.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/designer_news_app_bar_background.xml b/app/src/main/res/drawable/designer_news_app_bar_background.xml new file mode 100644 index 000000000..bdb2c29c8 --- /dev/null +++ b/app/src/main/res/drawable/designer_news_app_bar_background.xml @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/designer_news_custom_tab_placeholder.xml b/app/src/main/res/drawable/designer_news_custom_tab_placeholder.xml new file mode 100644 index 000000000..1f5beb242 --- /dev/null +++ b/app/src/main/res/drawable/designer_news_custom_tab_placeholder.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/designer_news_item_background.xml b/app/src/main/res/drawable/designer_news_item_background.xml new file mode 100644 index 000000000..6dccc4b91 --- /dev/null +++ b/app/src/main/res/drawable/designer_news_item_background.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/app/src/main/res/drawable/dialog_background.xml b/app/src/main/res/drawable/dialog_background.xml new file mode 100644 index 000000000..18553f217 --- /dev/null +++ b/app/src/main/res/drawable/dialog_background.xml @@ -0,0 +1,23 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dribbble_logo.xml b/app/src/main/res/drawable/dribbble_logo.xml new file mode 100644 index 000000000..bd2afe4cb --- /dev/null +++ b/app/src/main/res/drawable/dribbble_logo.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/app/src/main/res/drawable/fab.xml b/app/src/main/res/drawable/fab.xml new file mode 100644 index 000000000..0d4adaeba --- /dev/null +++ b/app/src/main/res/drawable/fab.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/fab_dribbble_fav.xml b/app/src/main/res/drawable/fab_dribbble_fav.xml new file mode 100644 index 000000000..444f16084 --- /dev/null +++ b/app/src/main/res/drawable/fab_dribbble_fav.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/fab_heart.xml b/app/src/main/res/drawable/fab_heart.xml new file mode 100644 index 000000000..48d42dd27 --- /dev/null +++ b/app/src/main/res/drawable/fab_heart.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/filter_placeholder.xml b/app/src/main/res/drawable/filter_placeholder.xml new file mode 100644 index 000000000..5c2a2a7f3 --- /dev/null +++ b/app/src/main/res/drawable/filter_placeholder.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/heart_anim.xml b/app/src/main/res/drawable/heart_anim.xml new file mode 100644 index 000000000..566b59ddd --- /dev/null +++ b/app/src/main/res/drawable/heart_anim.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/heart_comment_anim.xml b/app/src/main/res/drawable/heart_comment_anim.xml new file mode 100644 index 000000000..5735dd83d --- /dev/null +++ b/app/src/main/res/drawable/heart_comment_anim.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_action_share_24dp.xml b/app/src/main/res/drawable/ic_action_share_24dp.xml new file mode 100644 index 000000000..f2b45fe0f --- /dev/null +++ b/app/src/main/res/drawable/ic_action_share_24dp.xml @@ -0,0 +1,27 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_add_comment.xml b/app/src/main/res/drawable/ic_add_comment.xml new file mode 100644 index 000000000..5061bc2d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_comment.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_add_comment_state.xml b/app/src/main/res/drawable/ic_add_comment_state.xml new file mode 100644 index 000000000..78b0fecd6 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_comment_state.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add_dark.xml b/app/src/main/res/drawable/ic_add_dark.xml new file mode 100644 index 000000000..60dad2913 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_dark.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_add_light.xml b/app/src/main/res/drawable/ic_add_light.xml new file mode 100644 index 000000000..7d1393901 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_light.xml @@ -0,0 +1,31 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 000000000..b69c3cf98 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_arrow_back_padded.xml b/app/src/main/res/drawable/ic_arrow_back_padded.xml new file mode 100644 index 000000000..2566836cc --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back_padded.xml @@ -0,0 +1,21 @@ + + + + diff --git a/app/src/main/res/drawable/ic_comment_dark.xml b/app/src/main/res/drawable/ic_comment_dark.xml new file mode 100644 index 000000000..039f25630 --- /dev/null +++ b/app/src/main/res/drawable/ic_comment_dark.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_comment_light.xml b/app/src/main/res/drawable/ic_comment_light.xml new file mode 100644 index 000000000..567a9a124 --- /dev/null +++ b/app/src/main/res/drawable/ic_comment_light.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_comment_lines.xml b/app/src/main/res/drawable/ic_comment_lines.xml new file mode 100644 index 000000000..549e754a1 --- /dev/null +++ b/app/src/main/res/drawable/ic_comment_lines.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_designer_news.xml b/app/src/main/res/drawable/ic_designer_news.xml new file mode 100644 index 000000000..93bb05230 --- /dev/null +++ b/app/src/main/res/drawable/ic_designer_news.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_dribbble.xml b/app/src/main/res/drawable/ic_dribbble.xml new file mode 100644 index 000000000..101d5edd6 --- /dev/null +++ b/app/src/main/res/drawable/ic_dribbble.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 000000000..f5c817ee0 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_filter.xml b/app/src/main/res/drawable/ic_filter.xml new file mode 100644 index 000000000..50e6310fe --- /dev/null +++ b/app/src/main/res/drawable/ic_filter.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_filter_small.xml b/app/src/main/res/drawable/ic_filter_small.xml new file mode 100644 index 000000000..6f014886a --- /dev/null +++ b/app/src/main/res/drawable/ic_filter_small.xml @@ -0,0 +1,31 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_hacker_news.xml b/app/src/main/res/drawable/ic_hacker_news.xml new file mode 100644 index 000000000..e1e87a3ea --- /dev/null +++ b/app/src/main/res/drawable/ic_hacker_news.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_heart_empty_24dp.xml b/app/src/main/res/drawable/ic_heart_empty_24dp.xml new file mode 100644 index 000000000..4e0f8586c --- /dev/null +++ b/app/src/main/res/drawable/ic_heart_empty_24dp.xml @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_heart_empty_56dp.xml b/app/src/main/res/drawable/ic_heart_empty_56dp.xml new file mode 100644 index 000000000..0ff83a972 --- /dev/null +++ b/app/src/main/res/drawable/ic_heart_empty_56dp.xml @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_heart_full_24dp_grey.xml b/app/src/main/res/drawable/ic_heart_full_24dp_grey.xml new file mode 100644 index 000000000..55dcaa1df --- /dev/null +++ b/app/src/main/res/drawable/ic_heart_full_24dp_grey.xml @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_heart_full_24dp_pink.xml b/app/src/main/res/drawable/ic_heart_full_24dp_pink.xml new file mode 100644 index 000000000..d5a2c78ea --- /dev/null +++ b/app/src/main/res/drawable/ic_heart_full_24dp_pink.xml @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_heart_full_56dp.xml b/app/src/main/res/drawable/ic_heart_full_56dp.xml new file mode 100644 index 000000000..081abbcf8 --- /dev/null +++ b/app/src/main/res/drawable/ic_heart_full_56dp.xml @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_news.xml b/app/src/main/res/drawable/ic_news.xml new file mode 100644 index 000000000..a873ddc64 --- /dev/null +++ b/app/src/main/res/drawable/ic_news.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_no_comments.xml b/app/src/main/res/drawable/ic_no_comments.xml new file mode 100644 index 000000000..461ac8b00 --- /dev/null +++ b/app/src/main/res/drawable/ic_no_comments.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_player.xml b/app/src/main/res/drawable/ic_player.xml new file mode 100644 index 000000000..52cec2c51 --- /dev/null +++ b/app/src/main/res/drawable/ic_player.xml @@ -0,0 +1,41 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pocket.xml b/app/src/main/res/drawable/ic_pocket.xml new file mode 100644 index 000000000..5cefe9612 --- /dev/null +++ b/app/src/main/res/drawable/ic_pocket.xml @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_product_hunt.xml b/app/src/main/res/drawable/ic_product_hunt.xml new file mode 100644 index 000000000..af22f0f1f --- /dev/null +++ b/app/src/main/res/drawable/ic_product_hunt.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_reply.xml b/app/src/main/res/drawable/ic_reply.xml new file mode 100644 index 000000000..1ac8b59ba --- /dev/null +++ b/app/src/main/res/drawable/ic_reply.xml @@ -0,0 +1,30 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_save_24dp.xml b/app/src/main/res/drawable/ic_save_24dp.xml new file mode 100644 index 000000000..3df8667e0 --- /dev/null +++ b/app/src/main/res/drawable/ic_save_24dp.xml @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_search_24dp.xml b/app/src/main/res/drawable/ic_search_24dp.xml new file mode 100644 index 000000000..c3fa56e09 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_24dp.xml @@ -0,0 +1,25 @@ + + + + diff --git a/app/src/main/res/drawable/ic_thumb_up.xml b/app/src/main/res/drawable/ic_thumb_up.xml new file mode 100644 index 000000000..afca5f67e --- /dev/null +++ b/app/src/main/res/drawable/ic_thumb_up.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_views_24dp.xml b/app/src/main/res/drawable/ic_views_24dp.xml new file mode 100644 index 000000000..2a77fb5d2 --- /dev/null +++ b/app/src/main/res/drawable/ic_views_24dp.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/app/src/main/res/drawable/light_ripple.xml b/app/src/main/res/drawable/light_ripple.xml new file mode 100644 index 000000000..15938ce0f --- /dev/null +++ b/app/src/main/res/drawable/light_ripple.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/list_divider.xml b/app/src/main/res/drawable/list_divider.xml new file mode 100644 index 000000000..3b2f9e74d --- /dev/null +++ b/app/src/main/res/drawable/list_divider.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/mid_grey_bounded_ripple.xml b/app/src/main/res/drawable/mid_grey_bounded_ripple.xml new file mode 100644 index 000000000..11e8411fb --- /dev/null +++ b/app/src/main/res/drawable/mid_grey_bounded_ripple.xml @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/mid_grey_ripple.xml b/app/src/main/res/drawable/mid_grey_ripple.xml new file mode 100644 index 000000000..f80db08a0 --- /dev/null +++ b/app/src/main/res/drawable/mid_grey_ripple.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/app/src/main/res/drawable/no_connection.xml b/app/src/main/res/drawable/no_connection.xml new file mode 100644 index 000000000..6492b458c --- /dev/null +++ b/app/src/main/res/drawable/no_connection.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/post_story.xml b/app/src/main/res/drawable/post_story.xml new file mode 100644 index 000000000..a9ab53a72 --- /dev/null +++ b/app/src/main/res/drawable/post_story.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/product_hunt_item_background.xml b/app/src/main/res/drawable/product_hunt_item_background.xml new file mode 100644 index 000000000..db4de46d8 --- /dev/null +++ b/app/src/main/res/drawable/product_hunt_item_background.xml @@ -0,0 +1,22 @@ + + + + diff --git a/app/src/main/res/drawable/searchback_back.xml b/app/src/main/res/drawable/searchback_back.xml new file mode 100644 index 000000000..acf738b82 --- /dev/null +++ b/app/src/main/res/drawable/searchback_back.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/searchback_search.xml b/app/src/main/res/drawable/searchback_search.xml new file mode 100644 index 000000000..bad5ac37a --- /dev/null +++ b/app/src/main/res/drawable/searchback_search.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-land/activity_designer_news_story.xml b/app/src/main/res/layout-land/activity_designer_news_story.xml new file mode 100644 index 000000000..5f8acfb58 --- /dev/null +++ b/app/src/main/res/layout-land/activity_designer_news_story.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout-land/designer_news_story_description.xml b/app/src/main/res/layout-land/designer_news_story_description.xml new file mode 100644 index 000000000..7ceda433b --- /dev/null +++ b/app/src/main/res/layout-land/designer_news_story_description.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-v23/dribbble_shot_title.xml b/app/src/main/res/layout-v23/dribbble_shot_title.xml new file mode 100644 index 000000000..d7c84084e --- /dev/null +++ b/app/src/main/res/layout-v23/dribbble_shot_title.xml @@ -0,0 +1,39 @@ + + + + + + diff --git a/app/src/main/res/layout/account_dropdown_item.xml b/app/src/main/res/layout/account_dropdown_item.xml new file mode 100644 index 000000000..9287901da --- /dev/null +++ b/app/src/main/res/layout/account_dropdown_item.xml @@ -0,0 +1,29 @@ + + + + diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml new file mode 100644 index 000000000..a5c0044bd --- /dev/null +++ b/app/src/main/res/layout/activity_about.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_designer_news_login.xml b/app/src/main/res/layout/activity_designer_news_login.xml new file mode 100644 index 000000000..4f8b07c1b --- /dev/null +++ b/app/src/main/res/layout/activity_designer_news_login.xml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + +