From 8572b9cccec523efc34a76e32e0d03ca90ab6ef9 Mon Sep 17 00:00:00 2001 From: Ben Weiss Date: Fri, 17 May 2019 11:18:48 +0100 Subject: [PATCH 1/3] Add playCore dependency --- app/build.gradle | 3 ++- build.gradle | 2 ++ repositories.gradle | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index d666e4509..577d45995 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -78,9 +78,10 @@ dependencies { implementation project(':core') implementation "androidx.appcompat:appcompat:${versions.appcompat}" implementation "com.crashlytics.sdk.android:crashlytics:${versions.crashlytics}" - implementation "com.google.firebase:firebase-core:${versions.firebase}" implementation "com.github.bumptech.glide:glide:${versions.glide}" implementation "com.github.bumptech.glide:recyclerview-integration:${versions.glide}" + implementation "com.google.android.play:core:${versions.playCore}" + implementation "com.google.firebase:firebase-core:${versions.firebase}" kapt "com.google.dagger:dagger-compiler:${versions.dagger}" } diff --git a/build.gradle b/build.gradle index d7c3fa17a..955aa96f4 100644 --- a/build.gradle +++ b/build.gradle @@ -48,7 +48,9 @@ buildscript { scriptHandler -> 'mockito' : '2.23.0', 'mockito_kotlin' : '2.0.0-RC3', 'okhttp' : '3.10.0', + 'playCore' : '1.6.1', 'retrofit' : '2.6.0', + 'retrofitCoroutines' : '0.9.2', 'room' : '2.1.0-alpha05', 'supportLibrary' : '28.0.0', 'test_rules' : '1.1.0-beta02', diff --git a/repositories.gradle b/repositories.gradle index d9a30215b..2f014ef3d 100644 --- a/repositories.gradle +++ b/repositories.gradle @@ -9,6 +9,7 @@ repositories { includeGroup "com.google.firebase" includeGroup "com.google.android.gms" includeGroup "com.google.android.material" + includeGroup "com.google.android.play" } } From 85afe86b3c3054c7fb85600160e16de3ef7bccac Mon Sep 17 00:00:00 2001 From: Ben Weiss Date: Mon, 8 Jul 2019 16:54:42 +0100 Subject: [PATCH 2/3] Initial implementation of In-App Updates --- .../main/java/io/plaidapp/ui/HomeActivity.kt | 109 +++++++++ .../util/AppUpdateManagerExtensions.kt | 209 ++++++++++++++++++ .../plaidapp/util/InstallStateExtensions.kt | 83 +++++++ ...itInstallStateUpdatedListenerExtensions.kt | 110 +++++++++ app/src/main/res/layout/activity_home.xml | 1 + app/src/main/res/values/strings.xml | 2 + 6 files changed, 514 insertions(+) create mode 100644 app/src/main/java/io/plaidapp/util/AppUpdateManagerExtensions.kt create mode 100644 app/src/main/java/io/plaidapp/util/InstallStateExtensions.kt create mode 100644 app/src/main/java/io/plaidapp/util/SplitInstallStateUpdatedListenerExtensions.kt diff --git a/app/src/main/java/io/plaidapp/ui/HomeActivity.kt b/app/src/main/java/io/plaidapp/ui/HomeActivity.kt index b4da3dfa5..08fbb7b15 100644 --- a/app/src/main/java/io/plaidapp/ui/HomeActivity.kt +++ b/app/src/main/java/io/plaidapp/ui/HomeActivity.kt @@ -23,6 +23,7 @@ import android.app.ActivityOptions import android.content.Context import android.content.Intent import android.graphics.drawable.AnimatedVectorDrawable +import android.os.Build import android.os.Bundle import android.text.Annotation import android.text.Spannable @@ -32,6 +33,7 @@ import android.text.SpannedString import android.text.style.ForegroundColorSpan import android.text.style.ImageSpan import android.transition.TransitionManager +import android.util.Log import android.view.Menu import android.view.MenuItem import android.view.View @@ -57,6 +59,12 @@ import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader import com.bumptech.glide.util.ViewPreloadSizeProvider +import com.google.android.material.snackbar.Snackbar +import com.google.android.play.core.appupdate.AppUpdateInfo +import com.google.android.play.core.appupdate.AppUpdateManagerFactory +import com.google.android.play.core.install.InstallState +import com.google.android.play.core.install.InstallStateUpdatedListener +import com.google.android.play.core.install.model.AppUpdateType import io.plaidapp.R import io.plaidapp.core.dagger.qualifier.IsPocketInstalled import io.plaidapp.core.data.prefs.SourcesRepository @@ -81,6 +89,11 @@ import io.plaidapp.core.util.intentTo import io.plaidapp.dagger.inject import io.plaidapp.ui.recyclerview.FilterTouchHelperCallback import io.plaidapp.ui.recyclerview.GridItemDividerDecoration +import io.plaidapp.util.checkForUpdate +import io.plaidapp.util.onActivityResult +import io.plaidapp.util.onInstalled +import io.plaidapp.util.updateFlexibly +import io.plaidapp.util.updateImmediately import javax.inject.Inject /** @@ -184,6 +197,10 @@ class HomeActivity : AppCompatActivity() { } } + private val appUpdateManager by lazy { + AppUpdateManagerFactory.create(this) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_home) @@ -236,6 +253,11 @@ class HomeActivity : AppCompatActivity() { it.attachToRecyclerView(filtersList) } checkEmptyState() + + appUpdateManager.checkForUpdate( + immediateUpdate = ::performImmediateUpdate, + flexibleUpdate = ::performFlexibleUpdate + ) } private fun initViewModelObservers() { @@ -515,6 +537,91 @@ class HomeActivity : AppCompatActivity() { } } } + RC_APP_UPDATE_CHECK -> { + // TODO define handling and handle these cases properly. + appUpdateManager.onActivityResult( + resultCode, { Log.i(TAG, "User accepted update") }, + { Log.e(TAG, "User cancelled update.") }, + { Log.e(TAG, "In App Update failed with result code $resultCode.") } + ) + } + } + } + + /** + * Perform an In App Update depending on type. + * + * @param appUpdateInfo The [AppUpdateInfo] received. + * @param type either of the [AppUpdateType] values. + * @param updateReady Called once the update is installed. + */ + private fun performInAppUpdate( + appUpdateInfo: AppUpdateInfo, + @AppUpdateType type: Int, + updateReady: () -> Unit + ) { + val listener = object : InstallStateUpdatedListener { + override fun onStateUpdate(state: InstallState) { + state.onInstalled { + appUpdateManager.unregisterListener(this) + updateReady() + } + } + } + + with(appUpdateManager) { + registerListener(listener) + + val homeActivity = this@HomeActivity + when (type) { + AppUpdateType.IMMEDIATE -> updateImmediately(homeActivity, RC_APP_UPDATE_CHECK) + AppUpdateType.FLEXIBLE -> updateFlexibly(homeActivity, RC_APP_UPDATE_CHECK) + } + } + } + + private fun performImmediateUpdate(appUpdateInfo: AppUpdateInfo) { + /* + This is a basic check, which will be replaced with a more sophisticated one in the + future. + + Instead of relying simply on the version code difference to check whether an update + is required immediately, other apps might want to defer to a server that provides the + signal required to decide on which update path should be followed. + */ + if (appUpdateInfo.availableVersionCode() - getVersionCode() > 100) { + performInAppUpdate(appUpdateInfo, AppUpdateType.IMMEDIATE) { + appUpdateManager.completeUpdate() + } + } + } + + private fun performFlexibleUpdate(appUpdateInfo: AppUpdateInfo) { + performInAppUpdate(appUpdateInfo, AppUpdateType.FLEXIBLE) { + flexibleUpdateReady() + } + } + + private fun flexibleUpdateReady() { + // TODO("Add flexible in-app update tile as shown in #703") + Snackbar.make( + findViewById(R.id.home_frame), getString(R.string.update_downloaded), + Snackbar.LENGTH_INDEFINITE + ) + .setAction(getString(R.string.snackbar_restart)) { appUpdateManager.completeUpdate() } + .show() + } + + /** + * Get the versionCode for this installed application. + */ + private fun getVersionCode(): Long { + val packageInfo = packageManager.getPackageInfo(packageName, 0) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode + } else { + @Suppress("DEPRECATION") + packageInfo.versionCode.toLong() } } @@ -644,6 +751,8 @@ class HomeActivity : AppCompatActivity() { private const val RC_SEARCH = 0 private const val RC_NEW_DESIGNER_NEWS_LOGIN = 5 + private const val RC_APP_UPDATE_CHECK = 6 + private const val TAG = "HomeActivity" } } diff --git a/app/src/main/java/io/plaidapp/util/AppUpdateManagerExtensions.kt b/app/src/main/java/io/plaidapp/util/AppUpdateManagerExtensions.kt new file mode 100644 index 000000000..45595d7d7 --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/AppUpdateManagerExtensions.kt @@ -0,0 +1,209 @@ +/* + * Copyright 2019 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.app.Activity +import com.google.android.play.core.appupdate.AppUpdateInfo +import com.google.android.play.core.appupdate.AppUpdateManager +import com.google.android.play.core.install.InstallStateUpdatedListener +import com.google.android.play.core.install.model.ActivityResult +import com.google.android.play.core.install.model.AppUpdateType +import com.google.android.play.core.install.model.UpdateAvailability +import com.google.android.play.core.tasks.OnSuccessListener + +// Update checks + +/** + * Checks for an update and performs an action based on any of the update availability states. + */ +inline fun AppUpdateManager.checkForUpdate( + crossinline noUpdateAvailable: (info: AppUpdateInfo) -> Unit = {}, + crossinline updateInProgress: (info: AppUpdateInfo) -> Unit = {}, + crossinline flexibleUpdate: (info: AppUpdateInfo) -> Unit = {}, + crossinline immediateUpdate: (info: AppUpdateInfo) -> Unit = {} +) { + val listener = OnSuccessListener { info -> + with(info) { + when (updateAvailability()) { + UpdateAvailability.UPDATE_AVAILABLE -> { + if (isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)) { + flexibleUpdate(this) + } else if (isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)) { + immediateUpdate(this) + } + } + UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS -> updateInProgress(this) + else -> noUpdateAvailable(this) + } + } + } + appUpdateInfo.addOnSuccessListener(listener) +} + +inline fun AppUpdateManager.doOnImmediateUpdate(crossinline action: (info: AppUpdateInfo) -> Unit) = + checkForUpdate(immediateUpdate = action) + +inline fun AppUpdateManager.doOnFlexibleUpdate(crossinline action: (info: AppUpdateInfo) -> Unit) = + checkForUpdate(flexibleUpdate = action) + +inline fun AppUpdateManager.doOnNoUpdate(crossinline action: (info: AppUpdateInfo) -> Unit) = + checkForUpdate(noUpdateAvailable = action) + +inline fun AppUpdateManager.doOnUpdateInProgress( + crossinline action: (info: AppUpdateInfo) -> Unit +) = checkForUpdate(updateInProgress = action) + +inline fun AppUpdateManager.doOnAppUpdateInfoRetrieved( + crossinline action: (info: AppUpdateInfo) -> Unit +) = checkForUpdate(action, action, action) + +// Update the app + +/** + * Update the app for an update of type [AppUpdateType.FLEXIBLE]. + */ +fun AppUpdateManager.updateFlexibly(activity: Activity, resultCode: Int) { + doOnFlexibleUpdate { + startUpdateFlowForResult( + it, + AppUpdateType.IMMEDIATE, + activity, + resultCode + ) + } +} + +/** + * Update the app for an update of type [AppUpdateType.IMMEDIATE]. + */ +fun AppUpdateManager.updateImmediately(activity: Activity, resultCode: Int) { + doOnImmediateUpdate { + startUpdateFlowForResult( + it, + AppUpdateType.IMMEDIATE, + activity, + resultCode + ) + } +} + +/** + * Update the app for a given update type. + * + * @param activity The activity that performs the update. + * @param resultCode The result code to use within your activity's onActivityResult. + * @param type The type of update to perform. + */ +fun AppUpdateManager.update( + activity: Activity, + resultCode: Int, + @AppUpdateType type: Int +) { + doOnAppUpdateInfoRetrieved { + if (it.isUpdateTypeAllowed(type)) { + if (it.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)) { + updateFlexibly(activity, resultCode) + } else if (it.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)) { + updateImmediately(activity, resultCode) + } + } + // TODO handle update type not allowed flow. + } +} + +// Install state handling + +/** + * Perform an action on any provided install state. + */ +inline fun AppUpdateManager.doOnInstallState( + crossinline onUnknown: (errorCode: Int) -> Unit = {}, + crossinline onCanceled: (errorCode: Int) -> Unit = {}, + crossinline onFailed: (errorCode: Int) -> Unit = {}, + crossinline onRequiresUiIntent: () -> Unit = {}, + crossinline onPending: () -> Unit = {}, + crossinline onDownloading: () -> Unit = {}, + crossinline onDownloaded: () -> Unit = {}, + crossinline onInstalling: () -> Unit = {}, + crossinline onInstalled: () -> Unit = {} +): InstallStateUpdatedListener { + return InstallStateUpdatedListener { + it.onStatus( + onUnknown = onUnknown, + onCanceled = onCanceled, + onFailed = onFailed, + onRequiresUiIntent = onRequiresUiIntent, + onPending = onPending, + onDownloading = onDownloading, + onDownloaded = onDownloaded, + onInstalling = onInstalling, + onInstalled = onInstalled + ) + } +} + +inline fun AppUpdateManager.onInstallStateUnknown(crossinline onUnknown: (errorCode: Int) -> Unit) = + doOnInstallState(onUnknown = onUnknown) + +inline fun AppUpdateManager.onInstallStateCanceled( + crossinline onCanceled: (errorCode: Int) -> Unit +) = + doOnInstallState(onCanceled = onCanceled) + +inline fun AppUpdateManager.onInstallStateFailed(crossinline onFailed: (errorCode: Int) -> Unit) = + doOnInstallState(onFailed = onFailed) + +inline fun AppUpdateManager.onInstallStateRequiresUiIntent( + crossinline onRequiresUiIntent: () -> Unit +) = + doOnInstallState(onRequiresUiIntent = onRequiresUiIntent) + +inline fun AppUpdateManager.onInstallStatePending(crossinline onPending: () -> Unit) = + doOnInstallState(onPending = onPending) + +inline fun AppUpdateManager.onInstallStateDownloading( + crossinline onDownloading: () -> Unit +) = + doOnInstallState(onDownloading = onDownloading) + +inline fun AppUpdateManager.onInstallStateDownloaded( + crossinline onDownloaded: () -> Unit +) = + doOnInstallState(onDownloaded = onDownloaded) + +inline fun AppUpdateManager.onInstallStateRequiresInstalling( + crossinline onInstalling: () -> Unit +) = + doOnInstallState(onInstalling = onInstalling) + +inline fun AppUpdateManager.onInstallStateInstalled( + crossinline onInstalled: () -> Unit +) = + doOnInstallState(onInstalled = onInstalled) + +inline fun AppUpdateManager.onActivityResult( + resultCode: Int, + accepted: () -> Unit, + canceled: () -> Unit, + failed: () -> Unit +) { + when (resultCode) { + Activity.RESULT_OK -> accepted() + Activity.RESULT_CANCELED -> canceled() + ActivityResult.RESULT_IN_APP_UPDATE_FAILED -> failed() + } +} diff --git a/app/src/main/java/io/plaidapp/util/InstallStateExtensions.kt b/app/src/main/java/io/plaidapp/util/InstallStateExtensions.kt new file mode 100644 index 000000000..d3ea3fcb6 --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/InstallStateExtensions.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2019 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 com.google.android.play.core.install.InstallState +import com.google.android.play.core.install.model.InstallStatus + +/** + * Performs an action on a given [InstallStatus]. + */ +inline fun InstallState.onStatus( + crossinline onUnknown: (errorCode: Int) -> Unit = {}, + crossinline onCanceled: (errorCode: Int) -> Unit = {}, + crossinline onFailed: (errorCode: Int) -> Unit = {}, + crossinline onRequiresUiIntent: () -> Unit = {}, + crossinline onPending: () -> Unit = {}, + crossinline onDownloading: () -> Unit = {}, + crossinline onDownloaded: () -> Unit = {}, + crossinline onInstalling: () -> Unit = {}, + crossinline onInstalled: () -> Unit = {} +) { + when (installStatus()) { + InstallStatus.UNKNOWN -> onUnknown(installErrorCode()) + InstallStatus.CANCELED -> onCanceled(installErrorCode()) + InstallStatus.FAILED -> onFailed(installErrorCode()) + InstallStatus.REQUIRES_UI_INTENT -> onRequiresUiIntent() + InstallStatus.PENDING -> onPending() + InstallStatus.DOWNLOADING -> onDownloading() + InstallStatus.DOWNLOADED -> onDownloaded() + InstallStatus.INSTALLING -> onInstalling() + InstallStatus.INSTALLED -> onInstalled() + } +} + +inline fun InstallState.onUnknownError(crossinline action: (errorCode: Int) -> Unit) { + onStatus(onUnknown = action) +} + +inline fun InstallState.onCanceled(crossinline action: (errorCode: Int) -> Unit) { + onStatus(onCanceled = action) +} + +inline fun InstallState.onFailed(crossinline action: (errorCode: Int) -> Unit) { + onStatus(onFailed = action) +} + +inline fun InstallState.onRequiresUiIntent(crossinline action: () -> Unit) { + onStatus(onRequiresUiIntent = action) +} + +inline fun InstallState.onPending(crossinline action: () -> Unit) { + onStatus(onPending = action) +} + +inline fun InstallState.onDownloading(crossinline action: () -> Unit) { + onStatus(onDownloading = action) +} + +inline fun InstallState.onDownloaded(crossinline action: () -> Unit) { + onStatus(onDownloaded = action) +} + +inline fun InstallState.onInstalling(crossinline action: () -> Unit) { + onStatus(onInstalling = action) +} + +inline fun InstallState.onInstalled(crossinline action: () -> Unit) { + onStatus(onInstalled = action) +} diff --git a/app/src/main/java/io/plaidapp/util/SplitInstallStateUpdatedListenerExtensions.kt b/app/src/main/java/io/plaidapp/util/SplitInstallStateUpdatedListenerExtensions.kt new file mode 100644 index 000000000..10933fbf7 --- /dev/null +++ b/app/src/main/java/io/plaidapp/util/SplitInstallStateUpdatedListenerExtensions.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2019 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 com.google.android.play.core.splitinstall.SplitInstallSessionState +import com.google.android.play.core.splitinstall.model.SplitInstallSessionStatus + +/** + * Performs an action on a [SplitInstallSessionStatus] update. + */ +inline fun SplitInstallSessionState.onStatus( + crossinline onUnknownError: (errorCode: Int) -> Unit = {}, + crossinline onCanceling: () -> Unit = {}, + crossinline onCanceled: () -> Unit = {}, + crossinline onFailed: () -> Unit = {}, + crossinline onRequiresConfirmation: () -> Unit = {}, + crossinline onPending: () -> Unit = {}, + crossinline onDownloading: (bytesDownloaded: Long, totalBytesToDownload: Long) -> Unit = + { _, + _ -> + }, + crossinline onDownloaded: () -> Unit = {}, + crossinline onInstalling: () -> Unit = {}, + crossinline onInstalled: () -> Unit = {} +) { + + when (status()) { + SplitInstallSessionStatus.UNKNOWN -> onUnknownError(errorCode()) + SplitInstallSessionStatus.CANCELING -> onCanceling() + SplitInstallSessionStatus.CANCELED -> onCanceled() + SplitInstallSessionStatus.FAILED -> onFailed() + SplitInstallSessionStatus.REQUIRES_USER_CONFIRMATION -> onRequiresConfirmation() + SplitInstallSessionStatus.PENDING -> onPending() + SplitInstallSessionStatus.DOWNLOADING -> onDownloading( + bytesDownloaded(), + totalBytesToDownload() + ) + SplitInstallSessionStatus.DOWNLOADED -> onDownloaded() + SplitInstallSessionStatus.INSTALLING -> onInstalling() + SplitInstallSessionStatus.INSTALLED -> onInstalled() + } +} + +inline fun SplitInstallSessionState.onUnknownError(crossinline action: (errorCode: Int) -> Unit) = + onStatus(onUnknownError = action) + +inline fun SplitInstallSessionState.onCanceling(crossinline action: () -> Unit) = + onStatus(onCanceling = action) + +inline fun SplitInstallSessionState.onCanceled(crossinline action: () -> Unit) = + onStatus(onCanceled = action) + +inline fun SplitInstallSessionState.onFailed(crossinline action: () -> Unit) = + onStatus(onFailed = action) + +inline fun SplitInstallSessionState.onRequiresConfirmation(crossinline action: () -> Unit) = + onStatus(onRequiresConfirmation = action) + +inline fun SplitInstallSessionState.onPending(crossinline action: () -> Unit) = + onStatus(onPending = action) + +inline fun SplitInstallSessionState.onDownloading( + crossinline action: ( + bytesDownloaded: Long, + totalBytesToDownload: Long + ) -> Unit +) = + onStatus(onDownloading = action) + +inline fun SplitInstallSessionState.onDownloaded(crossinline action: () -> Unit) = + onStatus(onDownloaded = action) + +inline fun SplitInstallSessionState.onInstalling(crossinline action: () -> Unit) = + onStatus(onInstalling = action) + +inline fun SplitInstallSessionState.onInstalled(crossinline action: () -> Unit) = + onStatus(onInstalled = action) + +inline fun SplitInstallSessionState.onHappyPath( + crossinline onPending: () -> Unit = {}, + crossinline onDownloading: (bytesDownloaded: Long, totalBytesToDownload: Long) -> Unit = + { _, + _ -> + }, + crossinline onDownloaded: () -> Unit = {}, + crossinline onInstalling: () -> Unit = {}, + crossinline onInstalled: () -> Unit = {} +) { + onStatus( + onPending = onPending, + onDownloading = onDownloading, + onDownloaded = onDownloaded, + onInstalling = onInstalling, + onInstalled = onInstalled + ) +} diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml index 64401dc83..3220afd60 100644 --- a/app/src/main/res/layout/activity_home.xml +++ b/app/src/main/res/layout/activity_home.xml @@ -25,6 +25,7 @@ tools:context=".ui.HomeActivity"> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6e3810d70..b9cc087df 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -38,5 +38,7 @@ Search Dribbble & Designer News + An update has just been downloaded. + RESTART From 8fad18203e63ced869c849d0613d662e26ba56ae Mon Sep 17 00:00:00 2001 From: Ben Weiss Date: Wed, 17 Jul 2019 14:18:36 +0100 Subject: [PATCH 3/3] Introduce FeedViewHolder Use for all types of ViewHolders that can be displayed within the FeedAdapter. Also moving out DribbbleShotHolder into its own class simplifies FeedAdapter. --- build.gradle | 7 +- .../ui/stories/StoryViewHolder.kt | 126 ++++++++++------ .../java/io/plaidapp/core/feed/FeedAdapter.kt | 97 +----------- .../producthunt/ui/ProductHuntPostHolder.kt | 7 +- .../io/plaidapp/core/ui/DribbbleShotHolder.kt | 139 +++++++++++++++++- .../core/ui/recyclerview/FeedViewHolder.kt | 29 ++++ repositories.gradle | 1 + 7 files changed, 255 insertions(+), 151 deletions(-) create mode 100644 core/src/main/java/io/plaidapp/core/ui/recyclerview/FeedViewHolder.kt diff --git a/build.gradle b/build.gradle index 955aa96f4..0a7e4c80e 100644 --- a/build.gradle +++ b/build.gradle @@ -17,6 +17,9 @@ buildscript { scriptHandler -> apply from: 'repositories.gradle', to: scriptHandler + repositories { + google() + } ext.versions = [ 'compileSdk' : 28, 'minSdk' : 23, @@ -33,7 +36,7 @@ buildscript { scriptHandler -> 'dagger' : '2.23.2', 'espresso' : '3.1.0-beta02', 'extJunit' : '1.1.0', - 'fabric' : '1.25.4', + 'fabric' : '1.28.0', 'firebase' : '16.0.6', 'glide' : '4.9.0', 'googleServices' : '4.0.1', @@ -63,7 +66,7 @@ buildscript { scriptHandler -> ] dependencies { - classpath 'com.android.tools.build:gradle:3.4.1' + classpath 'com.android.tools.build:gradle:3.5.0-beta05' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}" classpath "com.google.gms:google-services:${versions.googleServices}" classpath "io.fabric.tools:gradle:${versions.fabric}" diff --git a/core/src/main/java/io/plaidapp/core/designernews/ui/stories/StoryViewHolder.kt b/core/src/main/java/io/plaidapp/core/designernews/ui/stories/StoryViewHolder.kt index b17a4ba40..255d652b4 100644 --- a/core/src/main/java/io/plaidapp/core/designernews/ui/stories/StoryViewHolder.kt +++ b/core/src/main/java/io/plaidapp/core/designernews/ui/stories/StoryViewHolder.kt @@ -20,7 +20,6 @@ import android.animation.Animator import android.animation.AnimatorSet import android.animation.ObjectAnimator import android.animation.PropertyValuesHolder -import androidx.recyclerview.widget.RecyclerView import android.util.Pair import android.view.View import android.view.ViewGroup @@ -32,11 +31,11 @@ import androidx.core.animation.doOnEnd import io.plaidapp.core.R import io.plaidapp.core.designernews.data.stories.model.Story import io.plaidapp.core.ui.recyclerview.Divided +import io.plaidapp.core.ui.recyclerview.FeedViewHolder import io.plaidapp.core.ui.transitions.GravityArcMotion import io.plaidapp.core.ui.widget.BaselineGridTextView import io.plaidapp.core.util.AnimUtils import io.plaidapp.core.util.ViewUtils -import java.util.Arrays class StoryViewHolder( itemView: View, @@ -44,7 +43,7 @@ class StoryViewHolder( private val onPocketClicked: (story: Story, adapterPosition: Int) -> Unit, private val onCommentsClicked: (data: TransitionData) -> Unit, private val onItemClicked: (data: TransitionData) -> Unit -) : RecyclerView.ViewHolder(itemView), Divided { +) : FeedViewHolder(itemView), Divided { private var story: Story? = null private val title: BaselineGridTextView = itemView.findViewById(R.id.story_title) private val comments: TextView = itemView.findViewById(R.id.story_comments) @@ -55,7 +54,14 @@ class StoryViewHolder( visibility = if (pocketIsInstalled) View.VISIBLE else View.GONE if (pocketIsInstalled) { imageAlpha = 178 // grumble... no xml setter, grumble... - setOnClickListener { story?.let { story -> onPocketClicked(story, adapterPosition) } } + setOnClickListener { + story?.let { story -> + onPocketClicked( + story, + adapterPosition + ) + } + } } } comments.setOnClickListener { @@ -86,19 +92,21 @@ class StoryViewHolder( } } - fun bind(story: Story) { - this.story = story - title.text = story.title + override fun bind(item: Story) { + story = item + title.text = item.title title.alpha = 1f // interrupted add to pocket anim can mangle - comments.text = story.commentCount.toString() - itemView.transitionName = story.url + comments.text = item.commentCount.toString() + itemView.transitionName = item.url } private fun getSharedElementsForTransition(): Array> { val resources = itemView.context.resources - return arrayOf(Pair(title as View, resources.getString(R.string.transition_story_title)), - Pair(itemView, resources.getString(R.string.transition_story_title_background)), - Pair(itemView, resources.getString(R.string.transition_story_background))) + return arrayOf( + Pair(title as View, resources.getString(R.string.transition_story_title)), + Pair(itemView, resources.getString(R.string.transition_story_title_background)), + Pair(itemView, resources.getString(R.string.transition_story_background)) + ) } fun createAddToPocketAnimator(): Animator { @@ -112,18 +120,30 @@ class StoryViewHolder( // animate the title & pocket icon up, scale the pocket icon up val titleMoveFadeOut = ObjectAnimator.ofPropertyValuesHolder( - title, - PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -(itemView.height / 5).toFloat()), - PropertyValuesHolder.ofFloat(View.ALPHA, 0.54f)) - - val pocketMoveUp = ObjectAnimator.ofFloat(pocket, - View.TRANSLATION_X, View.TRANSLATION_Y, - arc.getPath(initialLeft.toFloat(), initialTop.toFloat(), translatedLeft.toFloat(), translatedTop.toFloat())) - val pocketScaleUp = ObjectAnimator.ofPropertyValuesHolder(pocket, - PropertyValuesHolder.ofFloat(View.SCALE_X, 3f), - PropertyValuesHolder.ofFloat(View.SCALE_Y, 3f)) - val pocketFadeUp = ObjectAnimator.ofInt(pocket, - ViewUtils.IMAGE_ALPHA, 255) + title, + PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -(itemView.height / 5).toFloat()), + PropertyValuesHolder.ofFloat(View.ALPHA, 0.54f) + ) + + val pocketMoveUp = ObjectAnimator.ofFloat( + pocket, + View.TRANSLATION_X, View.TRANSLATION_Y, + arc.getPath( + initialLeft.toFloat(), + initialTop.toFloat(), + translatedLeft.toFloat(), + translatedTop.toFloat() + ) + ) + val pocketScaleUp = ObjectAnimator.ofPropertyValuesHolder( + pocket, + PropertyValuesHolder.ofFloat(View.SCALE_X, 3f), + PropertyValuesHolder.ofFloat(View.SCALE_Y, 3f) + ) + val pocketFadeUp = ObjectAnimator.ofInt( + pocket, + ViewUtils.IMAGE_ALPHA, 255 + ) val up = AnimatorSet().apply { playTogether(titleMoveFadeOut, pocketMoveUp, pocketScaleUp, pocketFadeUp) @@ -132,17 +152,25 @@ class StoryViewHolder( } // animate everything back into place - val titleMoveFadeIn = ObjectAnimator.ofPropertyValuesHolder(title, - PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0f), - PropertyValuesHolder.ofFloat(View.ALPHA, 1f)) - val pocketMoveDown = ObjectAnimator.ofFloat(pocket, - View.TRANSLATION_X, View.TRANSLATION_Y, - arc.getPath(translatedLeft.toFloat(), translatedTop.toFloat(), 0f, 0f)) - val pvhPocketScaleDown = ObjectAnimator.ofPropertyValuesHolder(pocket, - PropertyValuesHolder.ofFloat(View.SCALE_X, 1f), - PropertyValuesHolder.ofFloat(View.SCALE_Y, 1f)) - val pocketFadeDown = ObjectAnimator.ofInt(pocket, - ViewUtils.IMAGE_ALPHA, 178) + val titleMoveFadeIn = ObjectAnimator.ofPropertyValuesHolder( + title, + PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0f), + PropertyValuesHolder.ofFloat(View.ALPHA, 1f) + ) + val pocketMoveDown = ObjectAnimator.ofFloat( + pocket, + View.TRANSLATION_X, View.TRANSLATION_Y, + arc.getPath(translatedLeft.toFloat(), translatedTop.toFloat(), 0f, 0f) + ) + val pvhPocketScaleDown = ObjectAnimator.ofPropertyValuesHolder( + pocket, + PropertyValuesHolder.ofFloat(View.SCALE_X, 1f), + PropertyValuesHolder.ofFloat(View.SCALE_Y, 1f) + ) + val pocketFadeDown = ObjectAnimator.ofInt( + pocket, + ViewUtils.IMAGE_ALPHA, 178 + ) val down = AnimatorSet().apply { playTogether(titleMoveFadeIn, pocketMoveDown, pvhPocketScaleDown, pocketFadeDown) @@ -173,17 +201,18 @@ class StoryViewHolder( } fun createStoryCommentReturnAnimator(): Animator { - val animator = AnimatorSet() - animator.playTogether( + return AnimatorSet().apply { + playTogether( ObjectAnimator.ofFloat(pocket, View.ALPHA, 0f, 1f), - ObjectAnimator.ofFloat(comments, View.ALPHA, 0f, 1f)) - animator.duration = 120L - animator.interpolator = AnimUtils.getLinearOutSlowInInterpolator(itemView.context) - animator.doOnCancel { - pocket.alpha = 1f - comments.alpha = 1f + ObjectAnimator.ofFloat(comments, View.ALPHA, 0f, 1f) + ) + duration = 120L + interpolator = AnimUtils.getLinearOutSlowInInterpolator(itemView.context) + doOnCancel { + pocket.alpha = 1f + comments.alpha = 1f + } } - return animator } /** @@ -196,25 +225,26 @@ class StoryViewHolder( val sharedElements: Array>, val itemView: View ) { - override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as TransitionData + if (story != other.story) return false if (position != other.position) return false if (title != other.title) return false - if (!Arrays.equals(sharedElements, other.sharedElements)) return false + if (!sharedElements.contentEquals(other.sharedElements)) return false if (itemView != other.itemView) return false return true } override fun hashCode(): Int { - var result = position + var result = story.hashCode() + result = 31 * result + position result = 31 * result + title.hashCode() - result = 31 * result + Arrays.hashCode(sharedElements) + result = 31 * result + sharedElements.contentHashCode() result = 31 * result + itemView.hashCode() return result } diff --git a/core/src/main/java/io/plaidapp/core/feed/FeedAdapter.kt b/core/src/main/java/io/plaidapp/core/feed/FeedAdapter.kt index 18091115a..743bf5985 100644 --- a/core/src/main/java/io/plaidapp/core/feed/FeedAdapter.kt +++ b/core/src/main/java/io/plaidapp/core/feed/FeedAdapter.kt @@ -20,8 +20,6 @@ import android.app.Activity import android.app.ActivityOptions import android.app.SharedElementCallback import android.content.Context -import android.graphics.Color -import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.net.Uri import android.util.Pair @@ -29,19 +27,11 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ProgressBar -import androidx.annotation.ColorInt import androidx.browser.customtabs.CustomTabsIntent import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.ListPreloader import com.bumptech.glide.RequestBuilder -import com.bumptech.glide.load.DataSource -import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.bumptech.glide.load.engine.GlideException -import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions -import com.bumptech.glide.request.RequestListener -import com.bumptech.glide.request.target.Target -import com.bumptech.glide.util.ViewPreloadSizeProvider import io.plaidapp.core.R import io.plaidapp.core.data.PlaidItem import io.plaidapp.core.data.pocket.PocketUtils @@ -55,7 +45,6 @@ import io.plaidapp.core.ui.HomeGridItemAnimator import io.plaidapp.core.ui.transitions.ReflowText import io.plaidapp.core.util.Activities import io.plaidapp.core.util.customtabs.CustomTabActivityHelper -import io.plaidapp.core.util.glide.DribbbleTarget import io.plaidapp.core.util.glide.GlideApp import io.plaidapp.core.util.intentTo @@ -70,11 +59,7 @@ class FeedAdapter( private val isDarkTheme: Boolean ) : RecyclerView.Adapter(), ListPreloader.PreloadModelProvider { private val layoutInflater: LayoutInflater = LayoutInflater.from(host) - private val shotLoadingPlaceholders: Array - private val shotPreloadSizeProvider = ViewPreloadSizeProvider() - - @ColorInt - private val initialGifBadgeColor: Int + private val shotStyle = DribbbleShotHolder.getShotLoadingPlaceholders(host) private var showLoadingMore = false private val loadingMoreItemPosition: Int get() = if (showLoadingMore) itemCount - 1 else RecyclerView.NO_POSITION @@ -90,27 +75,6 @@ class FeedAdapter( init { setHasStableIds(true) - - // get the dribbble shot placeholder colors & badge color from the theme - val a = host.obtainStyledAttributes(R.styleable.DribbbleFeed) - val loadingColorArrayId = - a.getResourceId(R.styleable.DribbbleFeed_shotLoadingPlaceholderColors, 0) - if (loadingColorArrayId != 0) { - val placeholderColors = host.resources.getIntArray(loadingColorArrayId) - shotLoadingPlaceholders = arrayOfNulls(placeholderColors.size) - placeholderColors.indices.forEach { - shotLoadingPlaceholders[it] = ColorDrawable(placeholderColors[it]) - } - } else { - shotLoadingPlaceholders = arrayOf(ColorDrawable(Color.DKGRAY)) - } - val initialGifBadgeColorId = a.getResourceId(R.styleable.DribbbleFeed_initialBadgeColor, 0) - initialGifBadgeColor = if (initialGifBadgeColorId != 0) { - ContextCompat.getColor(host, initialGifBadgeColorId) - } else { - 0x40ffffff - } - a.recycle() } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { @@ -128,11 +92,10 @@ class FeedAdapter( override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when (getItemViewType(position)) { TYPE_DESIGNER_NEWS_STORY -> (holder as StoryViewHolder).bind((getItem(position) as Story)) - TYPE_DRIBBBLE_SHOT -> bindDribbbleShotHolder( - (getItem(position) as Shot), holder as DribbbleShotHolder, position - ) + TYPE_DRIBBBLE_SHOT -> (holder as DribbbleShotHolder).bind(getItem(position) as Shot) TYPE_PRODUCT_HUNT_POST -> (holder as ProductHuntPostHolder).bind((getItem(position) as Post)) - TYPE_LOADING_MORE -> bindLoadingViewHolder(holder as LoadingMoreHolder, position) + TYPE_FLEXIBLE_UPDATE_AVAILABLE -> (holder as FlexibleUpdateAvailableViewHolder) + TYPE_LOADING_MORE -> bindLoadingViewHolder(holder as LoadingMoreHolder, position) else -> throw IllegalStateException("Unsupported View type") } } @@ -206,8 +169,8 @@ class FeedAdapter( private fun createDribbbleShotHolder(parent: ViewGroup): DribbbleShotHolder { return DribbbleShotHolder( layoutInflater.inflate(R.layout.dribbble_shot_item, parent, false), - initialGifBadgeColor, - isDarkTheme + isDarkTheme, + shotStyle ) { view, position -> val intent = intentTo(Activities.Dribbble.Shot) intent.putExtra( @@ -224,54 +187,6 @@ class FeedAdapter( } } - private fun bindDribbbleShotHolder( - shot: Shot, - holder: DribbbleShotHolder, - position: Int - ) { - val imageSize = shot.images.bestSize() - GlideApp.with(host) - .load(shot.images.best()) - .listener(object : RequestListener { - override fun onResourceReady( - resource: Drawable, - model: Any, - target: Target, - dataSource: DataSource, - isFirstResource: Boolean - ): Boolean { - if (!shot.hasFadedIn) { - holder.fade() - shot.hasFadedIn = true - } - return false - } - - override fun onLoadFailed( - e: GlideException?, - model: Any, - target: Target, - isFirstResource: Boolean - ) = false - }) - .placeholder(shotLoadingPlaceholders[position % shotLoadingPlaceholders.size]) - .diskCacheStrategy(DiskCacheStrategy.DATA) - .fitCenter() - .transition(DrawableTransitionOptions.withCrossFade()) - .override(imageSize.width, imageSize.height) - .into(DribbbleTarget(holder.image, false)) - // need both placeholder & background to prevent seeing through shot as it fades in - shotLoadingPlaceholders[position % shotLoadingPlaceholders.size]?.apply { - holder.prepareForFade( - this, - shot.animated, - // need a unique transition name per shot, let's use its url - shot.htmlUrl - ) - } - shotPreloadSizeProvider.setView(holder.image) - } - private fun createProductHuntStoryHolder(parent: ViewGroup): ProductHuntPostHolder { return ProductHuntPostHolder( layoutInflater.inflate(R.layout.product_hunt_item, parent, false), diff --git a/core/src/main/java/io/plaidapp/core/producthunt/ui/ProductHuntPostHolder.kt b/core/src/main/java/io/plaidapp/core/producthunt/ui/ProductHuntPostHolder.kt index 08be3ed71..4a17c181b 100644 --- a/core/src/main/java/io/plaidapp/core/producthunt/ui/ProductHuntPostHolder.kt +++ b/core/src/main/java/io/plaidapp/core/producthunt/ui/ProductHuntPostHolder.kt @@ -16,13 +16,12 @@ package io.plaidapp.core.producthunt.ui -import androidx.recyclerview.widget.RecyclerView import android.view.View import android.widget.TextView - import io.plaidapp.core.R import io.plaidapp.core.producthunt.data.api.model.Post import io.plaidapp.core.ui.recyclerview.Divided +import io.plaidapp.core.ui.recyclerview.FeedViewHolder /** * ViewHolder for a Product Hunt Post @@ -31,7 +30,7 @@ class ProductHuntPostHolder( itemView: View, private val commentsClicked: (post: Post) -> Unit, private val viewClicked: (post: Post) -> Unit -) : RecyclerView.ViewHolder(itemView), Divided { +) : FeedViewHolder(itemView), Divided { private var post: Post? = null private var title: TextView = itemView.findViewById(R.id.hunt_title) @@ -43,7 +42,7 @@ class ProductHuntPostHolder( itemView.setOnClickListener { post?.let { post -> viewClicked(post) } } } - fun bind(item: Post) { + override fun bind(item: Post) { post = item title.text = item.title tagline.text = item.tagline diff --git a/core/src/main/java/io/plaidapp/core/ui/DribbbleShotHolder.kt b/core/src/main/java/io/plaidapp/core/ui/DribbbleShotHolder.kt index c044e6144..ac9249fc8 100644 --- a/core/src/main/java/io/plaidapp/core/ui/DribbbleShotHolder.kt +++ b/core/src/main/java/io/plaidapp/core/ui/DribbbleShotHolder.kt @@ -19,33 +19,53 @@ package io.plaidapp.core.ui import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.ObjectAnimator +import android.app.Activity +import android.graphics.Color import android.graphics.ColorMatrix import android.graphics.ColorMatrixColorFilter +import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.view.MotionEvent import android.view.View +import androidx.annotation.ColorInt import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import com.bumptech.glide.util.ViewPreloadSizeProvider import io.plaidapp.core.R +import io.plaidapp.core.dribbble.data.api.model.Shot +import io.plaidapp.core.ui.DribbbleShotHolder.Companion.getShotLoadingPlaceholders +import io.plaidapp.core.ui.recyclerview.FeedViewHolder import io.plaidapp.core.ui.widget.BadgedFourThreeImageView import io.plaidapp.core.util.AnimUtils import io.plaidapp.core.util.ObservableColorMatrix import io.plaidapp.core.util.asGif +import io.plaidapp.core.util.glide.DribbbleTarget +import io.plaidapp.core.util.glide.GlideApp private const val NIGHT_MODE_RGB_SCALE = 0.85f private const val ALPHA_SCALE = 1.0f +/** + * [FeedViewHolder] for Dribbble [Shot] items. + * Make sure to call [getShotLoadingPlaceholders] within the adapter to provide [ShotStyle]. + */ class DribbbleShotHolder constructor( itemView: View, - private val initialGifBadgeColor: Int, private val isNightMode: Boolean, + private val shotStyle: ShotStyle, private val onItemClicked: (image: View, position: Int) -> Unit -) : RecyclerView.ViewHolder(itemView) { - +) : FeedViewHolder(itemView) { val image: BadgedFourThreeImageView = itemView as BadgedFourThreeImageView + private val shotPreloadSizeProvider = ViewPreloadSizeProvider() + init { - image.setBadgeColor(initialGifBadgeColor) + image.setBadgeColor(shotStyle.initialGifBadgeColor) image.setOnClickListener { onItemClicked(image, adapterPosition) } @@ -73,8 +93,57 @@ class DribbbleShotHolder constructor( darkenImage() } + override fun bind(item: Shot) { + val imageSize = item.images.bestSize() + val requestListener = object : RequestListener { + override fun onResourceReady( + resource: Drawable, + model: Any, + target: Target, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { + if (!item.hasFadedIn) { + fade() + item.hasFadedIn = true + } + return false + } + + override fun onLoadFailed( + e: GlideException?, + model: Any, + target: Target, + isFirstResource: Boolean + ) = false + } + GlideApp.with(image) + .load(item.images.best()) + .listener(requestListener) + .placeholder( + shotStyle.shotLoadingPlaceholders[adapterPosition % + shotStyle.shotLoadingPlaceholders.size] + ) + .diskCacheStrategy(DiskCacheStrategy.DATA) + .fitCenter() + .transition(DrawableTransitionOptions.withCrossFade()) + .override(imageSize.width, imageSize.height) + .into(DribbbleTarget(image, false)) + // need both placeholder & background to prevent seeing through shot as it fades in + shotStyle.shotLoadingPlaceholders[adapterPosition % shotStyle.shotLoadingPlaceholders.size] + ?.apply { + prepareForFade( + this, + item.animated, + // need a unique transition name per shot, let's use its url + item.htmlUrl + ) + } + shotPreloadSizeProvider.setView(image) + } + fun reset() { - image.setBadgeColor(initialGifBadgeColor) + image.setBadgeColor(shotStyle.initialGifBadgeColor) image.drawBadge = false image.foreground = ContextCompat.getDrawable(image.context, R.drawable.mid_grey_ripple) } @@ -120,4 +189,62 @@ class DribbbleShotHolder constructor( } image.colorFilter = ColorMatrixColorFilter(colorMatrix) } + + companion object { + + fun getShotLoadingPlaceholders(host: Activity): ShotStyle { + val shotLoadingPlaceholders: Array + + // get the dribbble shot placeholder colors & badge color from the theme + val a = host.obtainStyledAttributes(R.styleable.DribbbleFeed) + val loadingColorArrayId = + a.getResourceId(R.styleable.DribbbleFeed_shotLoadingPlaceholderColors, 0) + if (loadingColorArrayId != 0) { + val placeholderColors = host.resources.getIntArray(loadingColorArrayId) + shotLoadingPlaceholders = arrayOfNulls(placeholderColors.size) + placeholderColors.indices.forEach { + shotLoadingPlaceholders[it] = ColorDrawable(placeholderColors[it]) + } + } else { + shotLoadingPlaceholders = arrayOf(ColorDrawable(Color.DKGRAY)) + } + val initialGifBadgeColorId = + a.getResourceId(R.styleable.DribbbleFeed_initialBadgeColor, 0) + + val style = ShotStyle( + shotLoadingPlaceholders, + if (initialGifBadgeColorId != 0) { + ContextCompat.getColor(host, initialGifBadgeColorId) + } else { + 0x40ffffff + } + ) + a.recycle() + + return style + } + } +} + +data class ShotStyle( + val shotLoadingPlaceholders: Array, + @ColorInt val initialGifBadgeColor: Int +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ShotStyle + + if (!shotLoadingPlaceholders.contentEquals(other.shotLoadingPlaceholders)) return false + if (initialGifBadgeColor != other.initialGifBadgeColor) return false + + return true + } + + override fun hashCode(): Int { + var result = shotLoadingPlaceholders.contentHashCode() + result = 31 * result + initialGifBadgeColor + return result + } } diff --git a/core/src/main/java/io/plaidapp/core/ui/recyclerview/FeedViewHolder.kt b/core/src/main/java/io/plaidapp/core/ui/recyclerview/FeedViewHolder.kt new file mode 100644 index 000000000..a7235af8d --- /dev/null +++ b/core/src/main/java/io/plaidapp/core/ui/recyclerview/FeedViewHolder.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2019 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.core.ui.recyclerview + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import io.plaidapp.core.data.PlaidItem + +/** + * Base class for view holders that display information in [io.plaidapp.core.feed.FeedAdapter]. + */ +abstract class FeedViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + + abstract fun bind(item: T) +} diff --git a/repositories.gradle b/repositories.gradle index 2f014ef3d..6429f9e37 100644 --- a/repositories.gradle +++ b/repositories.gradle @@ -10,6 +10,7 @@ repositories { includeGroup "com.google.android.gms" includeGroup "com.google.android.material" includeGroup "com.google.android.play" + includeGroup "zipflinger" } }