Merge pull request #44 from kirmanak/glide

Improve image loading speed
This commit is contained in:
Kirill Kamakin
2022-04-18 18:23:37 +02:00
committed by GitHub
31 changed files with 379 additions and 210 deletions

View File

@@ -14,8 +14,8 @@ android {
applicationId "gq.kirmanak.mealient" applicationId "gq.kirmanak.mealient"
minSdk 23 minSdk 23
targetSdk 31 targetSdk 31
versionCode 10 versionCode 11
versionName "0.2.1" versionName "0.2.2"
javaCompileOptions { javaCompileOptions {
annotationProcessorOptions { annotationProcessorOptions {
@@ -148,8 +148,15 @@ dependencies {
// https://github.com/Kotlin/kotlinx-datetime/releases // https://github.com/Kotlin/kotlinx-datetime/releases
implementation "org.jetbrains.kotlinx:kotlinx-datetime:0.3.1" implementation "org.jetbrains.kotlinx:kotlinx-datetime:0.3.1"
// https://github.com/square/picasso/releases // https://github.com/bumptech/glide/releases
implementation "com.squareup.picasso:picasso:2.8" def glide_version = "4.13.1"
implementation "com.github.bumptech.glide:glide:$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"
// https://github.com/androidbroadcast/ViewBindingPropertyDelegate/releases // https://github.com/androidbroadcast/ViewBindingPropertyDelegate/releases
implementation "com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.6" implementation "com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.6"

View File

@@ -48,3 +48,12 @@
-dontwarn org.bouncycastle.** -dontwarn org.bouncycastle.**
-dontwarn org.openjsse.** -dontwarn org.openjsse.**
### OkHttp warnings ### ### OkHttp warnings ###
### Glide https://bumptech.github.io/glide/doc/download-setup.html#proguard ###
-keep public class * implements com.bumptech.glide.module.GlideModule
-keep public class * extends com.bumptech.glide.module.AppGlideModule
-keep public enum com.bumptech.glide.load.ImageHeaderParser$** {
**[] $VALUES;
public *;
}
### Glide https://bumptech.github.io/glide/doc/download-setup.html#proguard ###

View File

@@ -4,6 +4,7 @@
package="gq.kirmanak.mealient"> package="gq.kirmanak.mealient">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application <application
android:name="gq.kirmanak.mealient.App" android:name="gq.kirmanak.mealient.App"

View File

@@ -1,39 +0,0 @@
package gq.kirmanak.mealient.data.recipes.impl
import android.widget.ImageView
import androidx.annotation.VisibleForTesting
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
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
import javax.inject.Singleton
@Singleton
class RecipeImageLoaderImpl @Inject constructor(
private val imageLoader: ImageLoader,
private val baseURLStorage: BaseURLStorage,
): RecipeImageLoader {
override suspend fun loadRecipeImage(view: ImageView, slug: String?) {
Timber.v("loadRecipeImage() called with: view = $view, slug = $slug")
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
}
}

View File

@@ -0,0 +1,6 @@
package gq.kirmanak.mealient.data.recipes.impl
interface RecipeImageUrlProvider {
suspend fun generateImageUrl(slug: String?): String?
}

View File

@@ -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"
}
}

View File

@@ -0,0 +1,20 @@
package gq.kirmanak.mealient.di
import com.bumptech.glide.load.model.ModelLoaderFactory
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import okhttp3.OkHttpClient
import java.io.InputStream
import javax.inject.Named
@EntryPoint
@InstallIn(SingletonComponent::class)
interface GlideModuleEntryPoint {
@Named(AUTH_OK_HTTP)
fun provideOkHttp(): OkHttpClient
fun provideRecipeLoaderFactory(): ModelLoaderFactory<RecipeSummaryEntity, InputStream>
}

View File

@@ -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,14 +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.impl.RecipeImageLoaderImpl 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.RecipeImageLoader 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
@@ -42,7 +48,11 @@ interface RecipeModule {
@Binds @Binds
@Singleton @Singleton
fun provideRecipeImageLoader(recipeImageLoaderImpl: RecipeImageLoaderImpl): RecipeImageLoader fun bindImageUrlProvider(recipeImageUrlProviderImpl: RecipeImageUrlProviderImpl): RecipeImageUrlProvider
@Binds
@Singleton
fun bindModelLoaderFactory(recipeModelLoaderFactory: RecipeModelLoaderFactory): ModelLoaderFactory<RecipeSummaryEntity, InputStream>
companion object { companion object {
@@ -61,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)
} }
} }

View File

@@ -1,27 +1,25 @@
package gq.kirmanak.mealient.di package gq.kirmanak.mealient.di
import com.squareup.picasso.Picasso
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.android.components.FragmentComponent
import gq.kirmanak.mealient.ui.images.ImageLoader import dagger.hilt.android.scopes.FragmentScoped
import gq.kirmanak.mealient.ui.images.ImageLoaderPicasso import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader
import gq.kirmanak.mealient.ui.images.PicassoBuilder import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoaderImpl
import javax.inject.Singleton import gq.kirmanak.mealient.ui.recipes.images.RecipePreloaderFactory
import gq.kirmanak.mealient.ui.recipes.images.RecipePreloaderFactoryImpl
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(FragmentComponent::class)
interface UiModule { interface UiModule {
@Binds @Binds
@Singleton @FragmentScoped
fun bindImageLoader(imageLoaderGlide: ImageLoaderPicasso): ImageLoader fun provideRecipeImageLoader(recipeImageLoaderImpl: RecipeImageLoaderImpl): RecipeImageLoader
@Binds
@FragmentScoped
fun bindRecipePreloaderFactory(recipePreloaderFactoryImpl: RecipePreloaderFactoryImpl): RecipePreloaderFactory
companion object {
@Provides
@Singleton
fun providePicasso(picassoBuilder: PicassoBuilder): Picasso = picassoBuilder.buildPicasso()
}
} }

View File

@@ -2,11 +2,15 @@ package gq.kirmanak.mealient.extensions
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
inline fun <T> Fragment.collectWithViewLifecycle( inline fun <T> Fragment.collectWhenViewResumed(
flow: Flow<T>, flow: Flow<T>,
crossinline collector: suspend (T) -> Unit, crossinline collector: suspend (T) -> Unit,
) = viewLifecycleOwner.lifecycleScope.launch { flow.collect(collector) } ) = launchWhenViewResumed { flow.collect(collector) }
fun Fragment.launchWhenViewResumed(
block: suspend CoroutineScope.() -> Unit,
) = viewLifecycleOwner.lifecycleScope.launchWhenResumed(block)

View File

@@ -0,0 +1,49 @@
package gq.kirmanak.mealient.ui
import android.content.Context
import com.bumptech.glide.Glide
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.module.AppGlideModule
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 java.io.InputStream
@GlideModule
class MealieGlideModule : AppGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
super.registerComponents(context, glide, registry)
Timber.v("registerComponents() called with: context = $context, glide = $glide, registry = $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) {
Timber.v("replaceOkHttp() called with: context = $context, registry = $registry")
val okHttp = getEntryPoint(context).provideOkHttp()
registry.replace(
GlideUrl::class.java,
InputStream::class.java,
OkHttpUrlLoader.Factory(okHttp)
)
}
private fun getEntryPoint(context: Context): GlideModuleEntryPoint {
Timber.v("getEntryPoint() called with: context = $context")
return fromApplication(context, GlideModuleEntryPoint::class.java)
}
}

View File

@@ -1,26 +0,0 @@
package gq.kirmanak.mealient.ui.auth
import timber.log.Timber
enum class AuthenticationState {
AUTHORIZED,
UNAUTHORIZED,
HIDDEN;
companion object {
fun determineState(
showLoginButton: Boolean,
isAuthorized: Boolean,
): AuthenticationState {
Timber.v("determineState() called with: showLoginButton = $showLoginButton, isAuthorized = $isAuthorized")
val result = when {
!showLoginButton -> HIDDEN
isAuthorized -> AUTHORIZED
else -> UNAUTHORIZED
}
Timber.v("determineState() returned: $result")
return result
}
}
}

View File

@@ -1,8 +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)
}

View File

@@ -1,25 +0,0 @@
package gq.kirmanak.mealient.ui.images
import android.widget.ImageView
import com.squareup.picasso.Picasso
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ImageLoaderPicasso @Inject constructor(
private val picasso: Picasso
) : ImageLoader {
override fun loadImage(url: String?, placeholderId: Int, imageView: ImageView) {
Timber.v("loadImage() called with: url = $url, placeholderId = $placeholderId, imageView = $imageView")
val width = imageView.measuredWidth
val height = imageView.measuredHeight
Timber.d("loadImage: width = $width, height = $height")
picasso.load(url).apply {
placeholder(placeholderId)
if (width > 0 && height > 0) resize(width, height).centerCrop()
into(imageView)
}
}
}

View File

@@ -1,34 +0,0 @@
package gq.kirmanak.mealient.ui.images
import android.content.Context
import com.squareup.picasso.OkHttp3Downloader
import com.squareup.picasso.Picasso
import dagger.hilt.android.qualifiers.ApplicationContext
import gq.kirmanak.mealient.BuildConfig
import gq.kirmanak.mealient.di.AUTH_OK_HTTP
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
@Singleton
class PicassoBuilder @Inject constructor(
@ApplicationContext private val context: Context,
@Named(AUTH_OK_HTTP) private val okHttpClient: OkHttpClient
) {
fun buildPicasso(): Picasso {
Timber.v("buildPicasso() called")
val builder = Picasso.Builder(context)
builder.downloader(OkHttp3Downloader(okHttpClient))
if (BuildConfig.DEBUG_PICASSO) {
builder.loggingEnabled(true)
builder.indicatorsEnabled(true)
builder.listener { _, uri, exception ->
Timber.tag("Picasso").e(exception, "Can't load from $uri")
}
}
return builder.build()
}
}

View File

@@ -1,7 +0,0 @@
package gq.kirmanak.mealient.ui.recipes
import android.widget.ImageView
interface RecipeImageLoader {
suspend fun loadRecipeImage(view: ImageView, slug: String?)
}

View File

@@ -4,11 +4,12 @@ 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(
private val binding: ViewHolderRecipeBinding, private val binding: ViewHolderRecipeBinding,
private val recipeViewModel: RecipeViewModel, private val recipeImageLoader: RecipeImageLoader,
private val clickListener: (RecipeSummaryEntity) -> Unit, private val clickListener: (RecipeSummaryEntity) -> Unit,
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
private val loadingPlaceholder by lazy { private val loadingPlaceholder by lazy {
@@ -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
recipeViewModel.loadRecipeImage(binding.image, item) 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")

View File

@@ -1,28 +1,15 @@
package gq.kirmanak.mealient.ui.recipes package gq.kirmanak.mealient.ui.recipes
import android.widget.ImageView
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.cachedIn import androidx.paging.cachedIn
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class RecipeViewModel @Inject constructor( class RecipeViewModel @Inject constructor(recipeRepo: RecipeRepo) : ViewModel() {
recipeRepo: RecipeRepo,
private val recipeImageLoader: RecipeImageLoader
) : ViewModel() {
val pagingData = recipeRepo.createPager().flow.cachedIn(viewModelScope) val pagingData = recipeRepo.createPager().flow.cachedIn(viewModelScope)
fun loadRecipeImage(view: ImageView, recipeSummary: RecipeSummaryEntity?) {
Timber.v("loadRecipeImage() called with: view = $view, recipeSummary = $recipeSummary")
viewModelScope.launch {
recipeImageLoader.loadRecipeImage(view, recipeSummary?.slug)
}
}
} }

View File

@@ -11,10 +11,13 @@ import dagger.hilt.android.AndroidEntryPoint
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.FragmentRecipesBinding import gq.kirmanak.mealient.databinding.FragmentRecipesBinding
import gq.kirmanak.mealient.extensions.collectWithViewLifecycle 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
@AndroidEntryPoint @AndroidEntryPoint
class RecipesFragment : Fragment(R.layout.fragment_recipes) { class RecipesFragment : Fragment(R.layout.fragment_recipes) {
@@ -22,6 +25,12 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
private val viewModel by viewModels<RecipeViewModel>() private val viewModel by viewModels<RecipeViewModel>()
private val activityViewModel by activityViewModels<MainActivityViewModel>() private val activityViewModel by activityViewModels<MainActivityViewModel>()
@Inject
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")
@@ -41,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(viewModel, ::navigateToRecipeInfo) val recipesAdapter = RecipesPagingAdapter(recipeImageLoader, ::navigateToRecipeInfo)
binding.recipes.adapter = adapter with(binding.recipes) {
collectWithViewLifecycle(viewModel.pagingData) { adapter = recipesAdapter
Timber.v("setupRecipeAdapter: received data update") addOnScrollListener(recipePreloaderFactory.create(recipesAdapter))
adapter.submitData(lifecycle, it)
} }
collectWithViewLifecycle(adapter.onPagesUpdatedFlow) { collectWhenViewResumed(viewModel.pagingData) {
Timber.v("setupRecipeAdapter: received data update")
recipesAdapter.submitData(lifecycle, it)
}
collectWhenViewResumed(recipesAdapter.onPagesUpdatedFlow) {
Timber.v("setupRecipeAdapter: pages updated") Timber.v("setupRecipeAdapter: pages updated")
binding.refresher.isRefreshing = false binding.refresher.isRefreshing = false
} }
collectWithViewLifecycle(binding.refresher.refreshRequestFlow()) { collectWhenViewResumed(binding.refresher.refreshRequestFlow()) {
Timber.v("setupRecipeAdapter: received refresh request") Timber.v("setupRecipeAdapter: received refresh request")
adapter.refresh() recipesAdapter.refresh()
} }
} }

View File

@@ -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 viewModel: RecipeViewModel, 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)
@@ -21,7 +23,7 @@ class RecipesPagingAdapter(
Timber.v("onCreateViewHolder() called with: parent = $parent, viewType = $viewType") Timber.v("onCreateViewHolder() called with: parent = $parent, viewType = $viewType")
val inflater = LayoutInflater.from(parent.context) val inflater = LayoutInflater.from(parent.context)
val binding = ViewHolderRecipeBinding.inflate(inflater, parent, false) val binding = ViewHolderRecipeBinding.inflate(inflater, parent, false)
return RecipeViewHolder(binding, viewModel, clickListener) return RecipeViewHolder(binding, recipeImageLoader, clickListener)
} }
private object RecipeDiffCallback : DiffUtil.ItemCallback<RecipeSummaryEntity>() { private object RecipeDiffCallback : DiffUtil.ItemCallback<RecipeSummaryEntity>() {

View File

@@ -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?)
}

View File

@@ -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)
}
}

View File

@@ -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) }
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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>
}

View File

@@ -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
}
}

View File

@@ -14,7 +14,9 @@ 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.images.RecipeImageLoader
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class RecipeInfoFragment : BottomSheetDialogFragment() { class RecipeInfoFragment : BottomSheetDialogFragment() {
@@ -25,6 +27,9 @@ class RecipeInfoFragment : BottomSheetDialogFragment() {
private val ingredientsAdapter = RecipeIngredientsAdapter() private val ingredientsAdapter = RecipeIngredientsAdapter()
private val instructionsAdapter = RecipeInstructionsAdapter() private val instructionsAdapter = RecipeInstructionsAdapter()
@Inject
lateinit var recipeImageLoader: RecipeImageLoader
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@@ -44,7 +49,6 @@ class RecipeInfoFragment : BottomSheetDialogFragment() {
} }
with(viewModel) { with(viewModel) {
loadRecipeImage(binding.image, arguments.recipeSlug)
loadRecipeInfo(arguments.recipeId, arguments.recipeSlug) loadRecipeInfo(arguments.recipeId, arguments.recipeSlug)
uiState.observe(viewLifecycleOwner, ::onUiStateChange) uiState.observe(viewLifecycleOwner, ::onUiStateChange)
} }
@@ -55,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)

View File

@@ -1,6 +1,5 @@
package gq.kirmanak.mealient.ui.recipes.info package gq.kirmanak.mealient.ui.recipes.info
import android.widget.ImageView
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@@ -8,7 +7,6 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.ui.recipes.RecipeImageLoader
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@@ -16,17 +14,11 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class RecipeInfoViewModel @Inject constructor( class RecipeInfoViewModel @Inject constructor(
private val recipeRepo: RecipeRepo, private val recipeRepo: RecipeRepo,
private val recipeImageLoader: RecipeImageLoader,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableLiveData(RecipeInfoUiState()) private val _uiState = MutableLiveData(RecipeInfoUiState())
val uiState: LiveData<RecipeInfoUiState> get() = _uiState val uiState: LiveData<RecipeInfoUiState> get() = _uiState
fun loadRecipeImage(view: ImageView, recipeSlug: String) {
Timber.v("loadRecipeImage() called with: view = $view, recipeSlug = $recipeSlug")
viewModelScope.launch { recipeImageLoader.loadRecipeImage(view, recipeSlug) }
}
fun loadRecipeInfo(recipeId: Long, recipeSlug: String) { fun loadRecipeInfo(recipeId: Long, recipeSlug: String) {
Timber.v("loadRecipeInfo() called with: recipeId = $recipeId, recipeSlug = $recipeSlug") Timber.v("loadRecipeInfo() called with: recipeId = $recipeId, recipeSlug = $recipeSlug")
_uiState.value = RecipeInfoUiState() _uiState.value = RecipeInfoUiState()

View File

@@ -2,7 +2,6 @@ package gq.kirmanak.mealient.data.recipes.impl
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
@@ -12,19 +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
@Before @Before
fun setUp() { fun setUp() {
MockKAnnotations.init(this) MockKAnnotations.init(this)
subject = RecipeImageLoaderImpl(imageLoader, baseURLStorage) subject = RecipeImageUrlProviderImpl(baseURLStorage)
prepareBaseURL("https://google.com/") prepareBaseURL("https://google.com/")
} }

View File

@@ -1,13 +1,13 @@
buildscript { buildscript {
ext { ext {
// https://developer.android.com/jetpack/androidx/releases/navigation // https://developer.android.com/jetpack/androidx/releases/navigation
nav_version = "2.4.1" nav_version = "2.4.2"
// https://dagger.dev/hilt/gradle-setup // https://dagger.dev/hilt/gradle-setup
hilt_version = "2.41" hilt_version = "2.41"
// https://kotlinlang.org/docs/gradle.html // https://kotlinlang.org/docs/gradle.html
kotlin_version = "1.6.10" kotlin_version = "1.6.20"
} }
repositories { repositories {
@@ -17,7 +17,7 @@ buildscript {
dependencies { dependencies {
// https://maven.google.com/web/index.html?q=com.android.tools.build#com.android.tools.build:gradle // https://maven.google.com/web/index.html?q=com.android.tools.build#com.android.tools.build:gradle
classpath "com.android.tools.build:gradle:7.1.2" classpath "com.android.tools.build:gradle:7.1.3"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version" classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"