Implement Glide image preload in RecyclerView
This commit is contained in:
@@ -152,6 +152,10 @@ dependencies {
|
|||||||
def glide_version = "4.13.1"
|
def glide_version = "4.13.1"
|
||||||
implementation "com.github.bumptech.glide:glide:$glide_version"
|
implementation "com.github.bumptech.glide:glide:$glide_version"
|
||||||
implementation "com.github.bumptech.glide:okhttp3-integration:$glide_version"
|
implementation "com.github.bumptech.glide:okhttp3-integration:$glide_version"
|
||||||
|
implementation("com.github.bumptech.glide:recyclerview-integration:$glide_version") {
|
||||||
|
// Excludes the support library because it's already included by Glide.
|
||||||
|
transitive = false
|
||||||
|
}
|
||||||
kapt "com.github.bumptech.glide:compiler:$glide_version"
|
kapt "com.github.bumptech.glide:compiler:$glide_version"
|
||||||
|
|
||||||
// https://github.com/androidbroadcast/ViewBindingPropertyDelegate/releases
|
// https://github.com/androidbroadcast/ViewBindingPropertyDelegate/releases
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
package gq.kirmanak.mealient.data.recipes.impl
|
|
||||||
|
|
||||||
import android.widget.ImageView
|
|
||||||
import androidx.annotation.VisibleForTesting
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import dagger.hilt.android.scopes.FragmentScoped
|
|
||||||
import gq.kirmanak.mealient.R
|
|
||||||
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
|
|
||||||
import gq.kirmanak.mealient.extensions.launchWhenViewResumed
|
|
||||||
import gq.kirmanak.mealient.ui.images.ImageLoader
|
|
||||||
import gq.kirmanak.mealient.ui.recipes.RecipeImageLoader
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|
||||||
import timber.log.Timber
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@FragmentScoped
|
|
||||||
class RecipeImageLoaderImpl @Inject constructor(
|
|
||||||
private val imageLoader: ImageLoader,
|
|
||||||
private val baseURLStorage: BaseURLStorage,
|
|
||||||
private val fragment: Fragment,
|
|
||||||
): RecipeImageLoader {
|
|
||||||
|
|
||||||
override fun loadRecipeImage(view: ImageView, slug: String?) {
|
|
||||||
Timber.v("loadRecipeImage() called with: view = $view, slug = $slug")
|
|
||||||
fragment.launchWhenViewResumed {
|
|
||||||
imageLoader.loadImage(generateImageUrl(slug), R.drawable.placeholder_recipe, view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@VisibleForTesting
|
|
||||||
suspend fun generateImageUrl(slug: String?): String? {
|
|
||||||
Timber.v("generateImageUrl() called with: slug = $slug")
|
|
||||||
val result = baseURLStorage.getBaseURL()
|
|
||||||
?.takeIf { it.isNotBlank() }
|
|
||||||
?.takeUnless { slug.isNullOrBlank() }
|
|
||||||
?.toHttpUrlOrNull()
|
|
||||||
?.newBuilder()
|
|
||||||
?.addPathSegments("api/media/recipes/$slug/images/original.webp")
|
|
||||||
?.build()
|
|
||||||
?.toString()
|
|
||||||
Timber.v("generateImageUrl() returned: $result")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package gq.kirmanak.mealient.data.recipes.impl
|
||||||
|
|
||||||
|
interface RecipeImageUrlProvider {
|
||||||
|
|
||||||
|
suspend fun generateImageUrl(slug: String?): String?
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package gq.kirmanak.mealient.data.recipes.impl
|
||||||
|
|
||||||
|
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class RecipeImageUrlProviderImpl @Inject constructor(
|
||||||
|
private val baseURLStorage: BaseURLStorage,
|
||||||
|
) : RecipeImageUrlProvider {
|
||||||
|
|
||||||
|
override suspend fun generateImageUrl(slug: String?): String? {
|
||||||
|
Timber.v("generateImageUrl() called with: slug = $slug")
|
||||||
|
slug?.takeUnless { it.isBlank() } ?: return null
|
||||||
|
val imagePath = IMAGE_PATH_FORMAT.format(slug)
|
||||||
|
val baseUrl = baseURLStorage.getBaseURL()?.takeUnless { it.isEmpty() }
|
||||||
|
val result = baseUrl?.toHttpUrlOrNull()
|
||||||
|
?.newBuilder()
|
||||||
|
?.addPathSegments(imagePath)
|
||||||
|
?.build()
|
||||||
|
?.toString()
|
||||||
|
Timber.v("getRecipeImageUrl() returned: $result")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val IMAGE_PATH_FORMAT = "api/media/recipes/%s/images/original.webp"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,20 @@
|
|||||||
package gq.kirmanak.mealient.ui.images
|
package gq.kirmanak.mealient.di
|
||||||
|
|
||||||
|
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||||
import dagger.hilt.EntryPoint
|
import dagger.hilt.EntryPoint
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import gq.kirmanak.mealient.di.AUTH_OK_HTTP
|
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import java.io.InputStream
|
||||||
import javax.inject.Named
|
import javax.inject.Named
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@EntryPoint
|
@EntryPoint
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
interface GlideModuleEntryPoint {
|
interface GlideModuleEntryPoint {
|
||||||
|
|
||||||
@Singleton
|
|
||||||
@Named(AUTH_OK_HTTP)
|
@Named(AUTH_OK_HTTP)
|
||||||
fun provideOkHttp(): OkHttpClient
|
fun provideOkHttp(): OkHttpClient
|
||||||
|
|
||||||
|
fun provideRecipeLoaderFactory(): ModelLoaderFactory<RecipeSummaryEntity, InputStream>
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
package gq.kirmanak.mealient.di
|
package gq.kirmanak.mealient.di
|
||||||
|
|
||||||
import androidx.paging.InvalidatingPagingSourceFactory
|
import androidx.paging.InvalidatingPagingSourceFactory
|
||||||
|
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||||
|
import com.bumptech.glide.request.RequestOptions
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import gq.kirmanak.mealient.R
|
||||||
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
|
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
|
||||||
import gq.kirmanak.mealient.data.network.RetrofitBuilder
|
import gq.kirmanak.mealient.data.network.RetrofitBuilder
|
||||||
import gq.kirmanak.mealient.data.network.ServiceFactory
|
import gq.kirmanak.mealient.data.network.ServiceFactory
|
||||||
@@ -13,12 +16,17 @@ import gq.kirmanak.mealient.data.network.createServiceFactory
|
|||||||
import gq.kirmanak.mealient.data.recipes.RecipeRepo
|
import gq.kirmanak.mealient.data.recipes.RecipeRepo
|
||||||
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
|
import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
|
||||||
import gq.kirmanak.mealient.data.recipes.db.RecipeStorageImpl
|
import gq.kirmanak.mealient.data.recipes.db.RecipeStorageImpl
|
||||||
|
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
|
||||||
|
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider
|
||||||
|
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProviderImpl
|
||||||
import gq.kirmanak.mealient.data.recipes.impl.RecipeRepoImpl
|
import gq.kirmanak.mealient.data.recipes.impl.RecipeRepoImpl
|
||||||
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
|
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
|
||||||
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSourceImpl
|
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSourceImpl
|
||||||
import gq.kirmanak.mealient.data.recipes.network.RecipeService
|
import gq.kirmanak.mealient.data.recipes.network.RecipeService
|
||||||
|
import gq.kirmanak.mealient.ui.recipes.images.RecipeModelLoaderFactory
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import java.io.InputStream
|
||||||
import javax.inject.Named
|
import javax.inject.Named
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -38,6 +46,14 @@ interface RecipeModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideRecipeRepo(recipeRepoImpl: RecipeRepoImpl): RecipeRepo
|
fun provideRecipeRepo(recipeRepoImpl: RecipeRepoImpl): RecipeRepo
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
fun bindImageUrlProvider(recipeImageUrlProviderImpl: RecipeImageUrlProviderImpl): RecipeImageUrlProvider
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
fun bindModelLoaderFactory(recipeModelLoaderFactory: RecipeModelLoaderFactory): ModelLoaderFactory<RecipeSummaryEntity, InputStream>
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@@ -55,5 +71,10 @@ interface RecipeModule {
|
|||||||
fun provideRecipePagingSourceFactory(
|
fun provideRecipePagingSourceFactory(
|
||||||
recipeStorage: RecipeStorage
|
recipeStorage: RecipeStorage
|
||||||
) = InvalidatingPagingSourceFactory { recipeStorage.queryRecipes() }
|
) = InvalidatingPagingSourceFactory { recipeStorage.queryRecipes() }
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideGlideRequestOptions(): RequestOptions = RequestOptions.centerCropTransform()
|
||||||
|
.placeholder(R.drawable.placeholder_recipe)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,10 +5,10 @@ import dagger.Module
|
|||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.components.FragmentComponent
|
import dagger.hilt.android.components.FragmentComponent
|
||||||
import dagger.hilt.android.scopes.FragmentScoped
|
import dagger.hilt.android.scopes.FragmentScoped
|
||||||
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageLoaderImpl
|
import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader
|
||||||
import gq.kirmanak.mealient.ui.images.ImageLoader
|
import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoaderImpl
|
||||||
import gq.kirmanak.mealient.ui.images.ImageLoaderGlide
|
import gq.kirmanak.mealient.ui.recipes.images.RecipePreloaderFactory
|
||||||
import gq.kirmanak.mealient.ui.recipes.RecipeImageLoader
|
import gq.kirmanak.mealient.ui.recipes.images.RecipePreloaderFactoryImpl
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(FragmentComponent::class)
|
@InstallIn(FragmentComponent::class)
|
||||||
@@ -16,10 +16,10 @@ interface UiModule {
|
|||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@FragmentScoped
|
@FragmentScoped
|
||||||
fun bindImageLoader(imageLoaderGlide: ImageLoaderGlide): ImageLoader
|
fun provideRecipeImageLoader(recipeImageLoaderImpl: RecipeImageLoaderImpl): RecipeImageLoader
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@FragmentScoped
|
@FragmentScoped
|
||||||
fun provideRecipeImageLoader(recipeImageLoaderImpl: RecipeImageLoaderImpl): RecipeImageLoader
|
fun bindRecipePreloaderFactory(recipePreloaderFactoryImpl: RecipePreloaderFactoryImpl): RecipePreloaderFactory
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package gq.kirmanak.mealient.ui.images
|
package gq.kirmanak.mealient.ui
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
@@ -8,6 +8,8 @@ import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader
|
|||||||
import com.bumptech.glide.load.model.GlideUrl
|
import com.bumptech.glide.load.model.GlideUrl
|
||||||
import com.bumptech.glide.module.AppGlideModule
|
import com.bumptech.glide.module.AppGlideModule
|
||||||
import dagger.hilt.android.EntryPointAccessors.fromApplication
|
import dagger.hilt.android.EntryPointAccessors.fromApplication
|
||||||
|
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
|
||||||
|
import gq.kirmanak.mealient.di.GlideModuleEntryPoint
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
@@ -16,17 +18,32 @@ class MealieGlideModule : AppGlideModule() {
|
|||||||
|
|
||||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||||
super.registerComponents(context, glide, registry)
|
super.registerComponents(context, glide, registry)
|
||||||
|
Timber.v("registerComponents() called with: context = $context, glide = $glide, registry = $registry")
|
||||||
replaceOkHttp(context, registry)
|
replaceOkHttp(context, registry)
|
||||||
|
appendRecipeLoader(registry, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appendRecipeLoader(registry: Registry, context: Context) {
|
||||||
|
Timber.v("appendRecipeLoader() called with: registry = $registry, context = $context")
|
||||||
|
registry.append(
|
||||||
|
RecipeSummaryEntity::class.java,
|
||||||
|
InputStream::class.java,
|
||||||
|
getEntryPoint(context).provideRecipeLoaderFactory(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun replaceOkHttp(context: Context, registry: Registry) {
|
private fun replaceOkHttp(context: Context, registry: Registry) {
|
||||||
Timber.v("replaceOkHttp() called with: context = $context, registry = $registry")
|
Timber.v("replaceOkHttp() called with: context = $context, registry = $registry")
|
||||||
val entryPoint = fromApplication(context, GlideModuleEntryPoint::class.java)
|
val okHttp = getEntryPoint(context).provideOkHttp()
|
||||||
val okHttp = entryPoint.provideOkHttp()
|
|
||||||
registry.replace(
|
registry.replace(
|
||||||
GlideUrl::class.java,
|
GlideUrl::class.java,
|
||||||
InputStream::class.java,
|
InputStream::class.java,
|
||||||
OkHttpUrlLoader.Factory(okHttp)
|
OkHttpUrlLoader.Factory(okHttp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getEntryPoint(context: Context): GlideModuleEntryPoint {
|
||||||
|
Timber.v("getEntryPoint() called with: context = $context")
|
||||||
|
return fromApplication(context, GlideModuleEntryPoint::class.java)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package gq.kirmanak.mealient.ui.images
|
|
||||||
|
|
||||||
import android.widget.ImageView
|
|
||||||
import androidx.annotation.DrawableRes
|
|
||||||
|
|
||||||
interface ImageLoader {
|
|
||||||
|
|
||||||
fun loadImage(url: String?, @DrawableRes placeholderId: Int, imageView: ImageView)
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package gq.kirmanak.mealient.ui.images
|
|
||||||
|
|
||||||
import android.widget.ImageView
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import com.bumptech.glide.Glide
|
|
||||||
import com.bumptech.glide.RequestManager
|
|
||||||
import dagger.hilt.android.scopes.FragmentScoped
|
|
||||||
import timber.log.Timber
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@FragmentScoped
|
|
||||||
class ImageLoaderGlide @Inject constructor(
|
|
||||||
private val fragment: Fragment,
|
|
||||||
) : ImageLoader {
|
|
||||||
|
|
||||||
private val requestManager: RequestManager
|
|
||||||
get() = Glide.with(fragment)
|
|
||||||
|
|
||||||
init {
|
|
||||||
Timber.v("init called with fragment = ${fragment.javaClass.simpleName}")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadImage(url: String?, placeholderId: Int, imageView: ImageView) {
|
|
||||||
Timber.v("loadImage() called with: url = $url, placeholderId = $placeholderId, imageView = $imageView")
|
|
||||||
requestManager.load(url)
|
|
||||||
.placeholder(placeholderId)
|
|
||||||
.centerCrop()
|
|
||||||
.into(imageView)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package gq.kirmanak.mealient.ui.recipes
|
|
||||||
|
|
||||||
import android.widget.ImageView
|
|
||||||
|
|
||||||
interface RecipeImageLoader {
|
|
||||||
|
|
||||||
fun loadRecipeImage(view: ImageView, slug: String?)
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import androidx.recyclerview.widget.RecyclerView
|
|||||||
import gq.kirmanak.mealient.R
|
import gq.kirmanak.mealient.R
|
||||||
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
|
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
|
||||||
import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding
|
import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding
|
||||||
|
import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
class RecipeViewHolder(
|
class RecipeViewHolder(
|
||||||
@@ -18,7 +19,7 @@ class RecipeViewHolder(
|
|||||||
fun bind(item: RecipeSummaryEntity?) {
|
fun bind(item: RecipeSummaryEntity?) {
|
||||||
Timber.v("bind() called with: item = $item")
|
Timber.v("bind() called with: item = $item")
|
||||||
binding.name.text = item?.name ?: loadingPlaceholder
|
binding.name.text = item?.name ?: loadingPlaceholder
|
||||||
recipeImageLoader.loadRecipeImage(binding.image, item?.slug)
|
recipeImageLoader.loadRecipeImage(binding.image, item)
|
||||||
item?.let { entity ->
|
item?.let { entity ->
|
||||||
binding.root.setOnClickListener {
|
binding.root.setOnClickListener {
|
||||||
Timber.d("bind: item clicked $entity")
|
Timber.d("bind: item clicked $entity")
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import gq.kirmanak.mealient.databinding.FragmentRecipesBinding
|
|||||||
import gq.kirmanak.mealient.extensions.collectWhenViewResumed
|
import gq.kirmanak.mealient.extensions.collectWhenViewResumed
|
||||||
import gq.kirmanak.mealient.extensions.refreshRequestFlow
|
import gq.kirmanak.mealient.extensions.refreshRequestFlow
|
||||||
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
|
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
|
||||||
|
import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader
|
||||||
|
import gq.kirmanak.mealient.ui.recipes.images.RecipePreloaderFactory
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -26,6 +28,9 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var recipeImageLoader: RecipeImageLoader
|
lateinit var recipeImageLoader: RecipeImageLoader
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var recipePreloaderFactory: RecipePreloaderFactory
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
|
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
|
||||||
@@ -45,19 +50,22 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
|
|||||||
|
|
||||||
private fun setupRecipeAdapter() {
|
private fun setupRecipeAdapter() {
|
||||||
Timber.v("setupRecipeAdapter() called")
|
Timber.v("setupRecipeAdapter() called")
|
||||||
val adapter = RecipesPagingAdapter(recipeImageLoader, ::navigateToRecipeInfo)
|
val recipesAdapter = RecipesPagingAdapter(recipeImageLoader, ::navigateToRecipeInfo)
|
||||||
binding.recipes.adapter = adapter
|
with(binding.recipes) {
|
||||||
|
adapter = recipesAdapter
|
||||||
|
addOnScrollListener(recipePreloaderFactory.create(recipesAdapter))
|
||||||
|
}
|
||||||
collectWhenViewResumed(viewModel.pagingData) {
|
collectWhenViewResumed(viewModel.pagingData) {
|
||||||
Timber.v("setupRecipeAdapter: received data update")
|
Timber.v("setupRecipeAdapter: received data update")
|
||||||
adapter.submitData(lifecycle, it)
|
recipesAdapter.submitData(lifecycle, it)
|
||||||
}
|
}
|
||||||
collectWhenViewResumed(adapter.onPagesUpdatedFlow) {
|
collectWhenViewResumed(recipesAdapter.onPagesUpdatedFlow) {
|
||||||
Timber.v("setupRecipeAdapter: pages updated")
|
Timber.v("setupRecipeAdapter: pages updated")
|
||||||
binding.refresher.isRefreshing = false
|
binding.refresher.isRefreshing = false
|
||||||
}
|
}
|
||||||
collectWhenViewResumed(binding.refresher.refreshRequestFlow()) {
|
collectWhenViewResumed(binding.refresher.refreshRequestFlow()) {
|
||||||
Timber.v("setupRecipeAdapter: received refresh request")
|
Timber.v("setupRecipeAdapter: received refresh request")
|
||||||
adapter.refresh()
|
recipesAdapter.refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import androidx.paging.PagingDataAdapter
|
|||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
|
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
|
||||||
import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding
|
import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding
|
||||||
|
import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
class RecipesPagingAdapter(
|
class RecipesPagingAdapter(
|
||||||
private val recipeImageLoader: RecipeImageLoader,
|
private val recipeImageLoader: RecipeImageLoader,
|
||||||
private val clickListener: (RecipeSummaryEntity) -> Unit
|
private val clickListener: (RecipeSummaryEntity) -> Unit
|
||||||
) : PagingDataAdapter<RecipeSummaryEntity, RecipeViewHolder>(RecipeDiffCallback) {
|
) : PagingDataAdapter<RecipeSummaryEntity, RecipeViewHolder>(RecipeDiffCallback) {
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: RecipeViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: RecipeViewHolder, position: Int) {
|
||||||
val item = getItem(position)
|
val item = getItem(position)
|
||||||
holder.bind(item)
|
holder.bind(item)
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package gq.kirmanak.mealient.ui.recipes.images
|
||||||
|
|
||||||
|
import android.widget.ImageView
|
||||||
|
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
|
||||||
|
|
||||||
|
interface RecipeImageLoader {
|
||||||
|
|
||||||
|
fun loadRecipeImage(view: ImageView, recipe: RecipeSummaryEntity?)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package gq.kirmanak.mealient.ui.recipes.images
|
||||||
|
|
||||||
|
import android.widget.ImageView
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.request.RequestOptions
|
||||||
|
import dagger.hilt.android.scopes.FragmentScoped
|
||||||
|
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@FragmentScoped
|
||||||
|
class RecipeImageLoaderImpl @Inject constructor(
|
||||||
|
private val fragment: Fragment,
|
||||||
|
private val requestOptions: RequestOptions,
|
||||||
|
) : RecipeImageLoader {
|
||||||
|
|
||||||
|
override fun loadRecipeImage(view: ImageView, recipe: RecipeSummaryEntity?) {
|
||||||
|
Timber.v("loadRecipeImage() called with: view = $view, recipe = $recipe")
|
||||||
|
Glide.with(fragment).load(recipe).apply(requestOptions).into(view)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package gq.kirmanak.mealient.ui.recipes.images
|
||||||
|
|
||||||
|
import com.bumptech.glide.load.Options
|
||||||
|
import com.bumptech.glide.load.model.GlideUrl
|
||||||
|
import com.bumptech.glide.load.model.ModelCache
|
||||||
|
import com.bumptech.glide.load.model.ModelLoader
|
||||||
|
import com.bumptech.glide.load.model.stream.BaseGlideUrlLoader
|
||||||
|
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
|
||||||
|
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
class RecipeModelLoader(
|
||||||
|
private val recipeImageUrlProvider: RecipeImageUrlProvider,
|
||||||
|
concreteLoader: ModelLoader<GlideUrl, InputStream>,
|
||||||
|
cache: ModelCache<RecipeSummaryEntity, GlideUrl>,
|
||||||
|
) : BaseGlideUrlLoader<RecipeSummaryEntity>(concreteLoader, cache) {
|
||||||
|
|
||||||
|
override fun handles(model: RecipeSummaryEntity): Boolean = true
|
||||||
|
|
||||||
|
override fun getUrl(
|
||||||
|
model: RecipeSummaryEntity?,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
options: Options?
|
||||||
|
): String? {
|
||||||
|
Timber.v("getUrl() called with: model = $model, width = $width, height = $height, options = $options")
|
||||||
|
return runBlocking { recipeImageUrlProvider.generateImageUrl(model?.slug) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package gq.kirmanak.mealient.ui.recipes.images
|
||||||
|
|
||||||
|
import com.bumptech.glide.load.model.*
|
||||||
|
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
|
||||||
|
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.InputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class RecipeModelLoaderFactory @Inject constructor(
|
||||||
|
private val recipeImageUrlProvider: RecipeImageUrlProvider,
|
||||||
|
) : ModelLoaderFactory<RecipeSummaryEntity, InputStream> {
|
||||||
|
|
||||||
|
private val cache = ModelCache<RecipeSummaryEntity, GlideUrl>()
|
||||||
|
|
||||||
|
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<RecipeSummaryEntity, InputStream> {
|
||||||
|
Timber.v("build() called with: multiFactory = $multiFactory")
|
||||||
|
val concreteLoader = multiFactory.build(GlideUrl::class.java, InputStream::class.java)
|
||||||
|
return RecipeModelLoader(recipeImageUrlProvider, concreteLoader, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun teardown() {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package gq.kirmanak.mealient.ui.recipes.images
|
||||||
|
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.paging.PagingDataAdapter
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.ListPreloader
|
||||||
|
import com.bumptech.glide.RequestBuilder
|
||||||
|
import com.bumptech.glide.request.RequestOptions
|
||||||
|
import dagger.hilt.android.scopes.FragmentScoped
|
||||||
|
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class RecipePreloadModelProvider(
|
||||||
|
private val adapter: PagingDataAdapter<RecipeSummaryEntity, *>,
|
||||||
|
private val fragment: Fragment,
|
||||||
|
private val requestOptions: RequestOptions,
|
||||||
|
) : ListPreloader.PreloadModelProvider<RecipeSummaryEntity> {
|
||||||
|
|
||||||
|
override fun getPreloadItems(position: Int): List<RecipeSummaryEntity> {
|
||||||
|
Timber.v("getPreloadItems() called with: position = $position")
|
||||||
|
return adapter.peek(position)?.let { listOf(it) } ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getPreloadRequestBuilder(item: RecipeSummaryEntity): RequestBuilder<*> {
|
||||||
|
Timber.v("getPreloadRequestBuilder() called with: item = $item")
|
||||||
|
return Glide.with(fragment).load(item).apply(requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
@FragmentScoped
|
||||||
|
class Factory @Inject constructor(
|
||||||
|
private val fragment: Fragment,
|
||||||
|
private val requestOptions: RequestOptions,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun create(
|
||||||
|
adapter: PagingDataAdapter<RecipeSummaryEntity, *>,
|
||||||
|
) = RecipePreloadModelProvider(adapter, fragment, requestOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package gq.kirmanak.mealient.ui.recipes.images
|
||||||
|
|
||||||
|
import androidx.paging.PagingDataAdapter
|
||||||
|
import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader
|
||||||
|
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
|
||||||
|
|
||||||
|
interface RecipePreloaderFactory {
|
||||||
|
|
||||||
|
fun create(adapter: PagingDataAdapter<RecipeSummaryEntity, *>): RecyclerViewPreloader<RecipeSummaryEntity>
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package gq.kirmanak.mealient.ui.recipes.images
|
||||||
|
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.paging.PagingDataAdapter
|
||||||
|
import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader
|
||||||
|
import com.bumptech.glide.util.ViewPreloadSizeProvider
|
||||||
|
import dagger.hilt.android.scopes.FragmentScoped
|
||||||
|
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@FragmentScoped
|
||||||
|
class RecipePreloaderFactoryImpl @Inject constructor(
|
||||||
|
private val recipePreloadModelProvider: RecipePreloadModelProvider.Factory,
|
||||||
|
private val fragment: Fragment,
|
||||||
|
) : RecipePreloaderFactory {
|
||||||
|
|
||||||
|
override fun create(
|
||||||
|
adapter: PagingDataAdapter<RecipeSummaryEntity, *>,
|
||||||
|
): RecyclerViewPreloader<RecipeSummaryEntity> {
|
||||||
|
val preloadSizeProvider = ViewPreloadSizeProvider<RecipeSummaryEntity>()
|
||||||
|
val preloadModelProvider = recipePreloadModelProvider.create(adapter)
|
||||||
|
return RecyclerViewPreloader(
|
||||||
|
fragment,
|
||||||
|
preloadModelProvider,
|
||||||
|
preloadSizeProvider,
|
||||||
|
MAX_PRELOAD
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MAX_PRELOAD = 10
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
|||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import gq.kirmanak.mealient.R
|
import gq.kirmanak.mealient.R
|
||||||
import gq.kirmanak.mealient.databinding.FragmentRecipeInfoBinding
|
import gq.kirmanak.mealient.databinding.FragmentRecipeInfoBinding
|
||||||
import gq.kirmanak.mealient.ui.recipes.RecipeImageLoader
|
import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -46,7 +46,6 @@ class RecipeInfoFragment : BottomSheetDialogFragment() {
|
|||||||
with(binding) {
|
with(binding) {
|
||||||
ingredientsList.adapter = ingredientsAdapter
|
ingredientsList.adapter = ingredientsAdapter
|
||||||
instructionsList.adapter = instructionsAdapter
|
instructionsList.adapter = instructionsAdapter
|
||||||
recipeImageLoader.loadRecipeImage(image, arguments.recipeSlug)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
with(viewModel) {
|
with(viewModel) {
|
||||||
@@ -60,6 +59,7 @@ class RecipeInfoFragment : BottomSheetDialogFragment() {
|
|||||||
ingredientsHolder.isVisible = uiState.areIngredientsVisible
|
ingredientsHolder.isVisible = uiState.areIngredientsVisible
|
||||||
instructionsGroup.isVisible = uiState.areInstructionsVisible
|
instructionsGroup.isVisible = uiState.areInstructionsVisible
|
||||||
uiState.recipeInfo?.let {
|
uiState.recipeInfo?.let {
|
||||||
|
recipeImageLoader.loadRecipeImage(image, it.recipeSummaryEntity)
|
||||||
title.text = it.recipeSummaryEntity.name
|
title.text = it.recipeSummaryEntity.name
|
||||||
description.text = it.recipeSummaryEntity.description
|
description.text = it.recipeSummaryEntity.description
|
||||||
ingredientsAdapter.submitList(it.recipeIngredients)
|
ingredientsAdapter.submitList(it.recipeIngredients)
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package gq.kirmanak.mealient.data.recipes.impl
|
package gq.kirmanak.mealient.data.recipes.impl
|
||||||
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
|
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
|
||||||
import gq.kirmanak.mealient.ui.images.ImageLoader
|
|
||||||
import io.mockk.MockKAnnotations
|
import io.mockk.MockKAnnotations
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.impl.annotations.MockK
|
import io.mockk.impl.annotations.MockK
|
||||||
@@ -13,22 +11,17 @@ import org.junit.Before
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class RecipeImageLoaderImplTest {
|
class RecipeImageUrlProviderImplTest {
|
||||||
lateinit var subject: RecipeImageLoaderImpl
|
|
||||||
|
lateinit var subject: RecipeImageUrlProvider
|
||||||
|
|
||||||
@MockK
|
@MockK
|
||||||
lateinit var baseURLStorage: BaseURLStorage
|
lateinit var baseURLStorage: BaseURLStorage
|
||||||
|
|
||||||
@MockK
|
|
||||||
lateinit var imageLoader: ImageLoader
|
|
||||||
|
|
||||||
@MockK
|
|
||||||
lateinit var fragment: Fragment
|
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
MockKAnnotations.init(this)
|
MockKAnnotations.init(this)
|
||||||
subject = RecipeImageLoaderImpl(imageLoader, baseURLStorage, fragment)
|
subject = RecipeImageUrlProviderImpl(baseURLStorage)
|
||||||
prepareBaseURL("https://google.com/")
|
prepareBaseURL("https://google.com/")
|
||||||
}
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user