diff --git a/app/build.gradle b/app/build.gradle index 3bedfb7..65bdcd6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -14,8 +14,8 @@ android { applicationId "gq.kirmanak.mealient" minSdk 23 targetSdk 31 - versionCode 10 - versionName "0.2.1" + versionCode 11 + versionName "0.2.2" javaCompileOptions { annotationProcessorOptions { @@ -148,8 +148,15 @@ dependencies { // https://github.com/Kotlin/kotlinx-datetime/releases implementation "org.jetbrains.kotlinx:kotlinx-datetime:0.3.1" - // https://github.com/square/picasso/releases - implementation "com.squareup.picasso:picasso:2.8" + // https://github.com/bumptech/glide/releases + 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 implementation "com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.6" diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 079843f..564b7b5 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -48,3 +48,12 @@ -dontwarn org.bouncycastle.** -dontwarn org.openjsse.** ### 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 ### \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3c9d105..66a71cc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ package="gq.kirmanak.mealient"> + +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt index ad4dd94..fc015b5 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt @@ -1,11 +1,14 @@ package gq.kirmanak.mealient.di import androidx.paging.InvalidatingPagingSourceFactory +import com.bumptech.glide.load.model.ModelLoaderFactory +import com.bumptech.glide.request.RequestOptions import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import gq.kirmanak.mealient.R import gq.kirmanak.mealient.data.baseurl.BaseURLStorage import gq.kirmanak.mealient.data.network.RetrofitBuilder 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.db.RecipeStorage 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.network.RecipeDataSource import gq.kirmanak.mealient.data.recipes.network.RecipeDataSourceImpl 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 okhttp3.OkHttpClient +import java.io.InputStream import javax.inject.Named import javax.inject.Singleton @@ -42,7 +48,11 @@ interface RecipeModule { @Binds @Singleton - fun provideRecipeImageLoader(recipeImageLoaderImpl: RecipeImageLoaderImpl): RecipeImageLoader + fun bindImageUrlProvider(recipeImageUrlProviderImpl: RecipeImageUrlProviderImpl): RecipeImageUrlProvider + + @Binds + @Singleton + fun bindModelLoaderFactory(recipeModelLoaderFactory: RecipeModelLoaderFactory): ModelLoaderFactory companion object { @@ -61,5 +71,10 @@ interface RecipeModule { fun provideRecipePagingSourceFactory( recipeStorage: RecipeStorage ) = InvalidatingPagingSourceFactory { recipeStorage.queryRecipes() } + + @Provides + @Singleton + fun provideGlideRequestOptions(): RequestOptions = RequestOptions.centerCropTransform() + .placeholder(R.drawable.placeholder_recipe) } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/di/UiModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/UiModule.kt index 1d31cf5..54f20dd 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/UiModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/UiModule.kt @@ -1,27 +1,25 @@ package gq.kirmanak.mealient.di -import com.squareup.picasso.Picasso import dagger.Binds import dagger.Module -import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import gq.kirmanak.mealient.ui.images.ImageLoader -import gq.kirmanak.mealient.ui.images.ImageLoaderPicasso -import gq.kirmanak.mealient.ui.images.PicassoBuilder -import javax.inject.Singleton +import dagger.hilt.android.components.FragmentComponent +import dagger.hilt.android.scopes.FragmentScoped +import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader +import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoaderImpl +import gq.kirmanak.mealient.ui.recipes.images.RecipePreloaderFactory +import gq.kirmanak.mealient.ui.recipes.images.RecipePreloaderFactoryImpl @Module -@InstallIn(SingletonComponent::class) +@InstallIn(FragmentComponent::class) interface UiModule { @Binds - @Singleton - fun bindImageLoader(imageLoaderGlide: ImageLoaderPicasso): ImageLoader + @FragmentScoped + fun provideRecipeImageLoader(recipeImageLoaderImpl: RecipeImageLoaderImpl): RecipeImageLoader + + @Binds + @FragmentScoped + fun bindRecipePreloaderFactory(recipePreloaderFactoryImpl: RecipePreloaderFactoryImpl): RecipePreloaderFactory - companion object { - @Provides - @Singleton - fun providePicasso(picassoBuilder: PicassoBuilder): Picasso = picassoBuilder.buildPicasso() - } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt index b597b95..c285394 100644 --- a/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt +++ b/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt @@ -2,11 +2,15 @@ package gq.kirmanak.mealient.extensions import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.launch -inline fun Fragment.collectWithViewLifecycle( +inline fun Fragment.collectWhenViewResumed( flow: Flow, crossinline collector: suspend (T) -> Unit, -) = viewLifecycleOwner.lifecycleScope.launch { flow.collect(collector) } \ No newline at end of file +) = launchWhenViewResumed { flow.collect(collector) } + +fun Fragment.launchWhenViewResumed( + block: suspend CoroutineScope.() -> Unit, +) = viewLifecycleOwner.lifecycleScope.launchWhenResumed(block) \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/MealieGlideModule.kt b/app/src/main/java/gq/kirmanak/mealient/ui/MealieGlideModule.kt new file mode 100644 index 0000000..93a040f --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/MealieGlideModule.kt @@ -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) + } +} diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationState.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationState.kt deleted file mode 100644 index 72429c7..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationState.kt +++ /dev/null @@ -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 - } - } -} diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/images/ImageLoader.kt b/app/src/main/java/gq/kirmanak/mealient/ui/images/ImageLoader.kt deleted file mode 100644 index cc33ed3..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/images/ImageLoader.kt +++ /dev/null @@ -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) -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/images/ImageLoaderPicasso.kt b/app/src/main/java/gq/kirmanak/mealient/ui/images/ImageLoaderPicasso.kt deleted file mode 100644 index 5dc35ec..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/images/ImageLoaderPicasso.kt +++ /dev/null @@ -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) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/images/PicassoBuilder.kt b/app/src/main/java/gq/kirmanak/mealient/ui/images/PicassoBuilder.kt deleted file mode 100644 index 9933996..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/images/PicassoBuilder.kt +++ /dev/null @@ -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() - } -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeImageLoader.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeImageLoader.kt deleted file mode 100644 index 2afa981..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeImageLoader.kt +++ /dev/null @@ -1,7 +0,0 @@ -package gq.kirmanak.mealient.ui.recipes - -import android.widget.ImageView - -interface RecipeImageLoader { - suspend fun loadRecipeImage(view: ImageView, slug: String?) -} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt index 64fd1f3..f55c9fd 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt @@ -4,11 +4,12 @@ import androidx.recyclerview.widget.RecyclerView import gq.kirmanak.mealient.R import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding +import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader import timber.log.Timber class RecipeViewHolder( private val binding: ViewHolderRecipeBinding, - private val recipeViewModel: RecipeViewModel, + private val recipeImageLoader: RecipeImageLoader, private val clickListener: (RecipeSummaryEntity) -> Unit, ) : RecyclerView.ViewHolder(binding.root) { private val loadingPlaceholder by lazy { @@ -18,7 +19,7 @@ class RecipeViewHolder( fun bind(item: RecipeSummaryEntity?) { Timber.v("bind() called with: item = $item") binding.name.text = item?.name ?: loadingPlaceholder - recipeViewModel.loadRecipeImage(binding.image, item) + recipeImageLoader.loadRecipeImage(binding.image, item) item?.let { entity -> binding.root.setOnClickListener { Timber.d("bind: item clicked $entity") diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModel.kt index 6b71714..b621488 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModel.kt @@ -1,28 +1,15 @@ package gq.kirmanak.mealient.ui.recipes -import android.widget.ImageView import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.cachedIn import dagger.hilt.android.lifecycle.HiltViewModel 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 @HiltViewModel -class RecipeViewModel @Inject constructor( - recipeRepo: RecipeRepo, - private val recipeImageLoader: RecipeImageLoader -) : ViewModel() { +class RecipeViewModel @Inject constructor(recipeRepo: RecipeRepo) : ViewModel() { 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) - } - } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt index a961ea4..f4a6d44 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt @@ -11,10 +11,13 @@ import dagger.hilt.android.AndroidEntryPoint import gq.kirmanak.mealient.R import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity 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.ui.activity.MainActivityViewModel +import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader +import gq.kirmanak.mealient.ui.recipes.images.RecipePreloaderFactory import timber.log.Timber +import javax.inject.Inject @AndroidEntryPoint class RecipesFragment : Fragment(R.layout.fragment_recipes) { @@ -22,6 +25,12 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) { private val viewModel by viewModels() private val activityViewModel by activityViewModels() + @Inject + lateinit var recipeImageLoader: RecipeImageLoader + + @Inject + lateinit var recipePreloaderFactory: RecipePreloaderFactory + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState") @@ -41,19 +50,22 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) { private fun setupRecipeAdapter() { Timber.v("setupRecipeAdapter() called") - val adapter = RecipesPagingAdapter(viewModel, ::navigateToRecipeInfo) - binding.recipes.adapter = adapter - collectWithViewLifecycle(viewModel.pagingData) { - Timber.v("setupRecipeAdapter: received data update") - adapter.submitData(lifecycle, it) + val recipesAdapter = RecipesPagingAdapter(recipeImageLoader, ::navigateToRecipeInfo) + with(binding.recipes) { + adapter = recipesAdapter + addOnScrollListener(recipePreloaderFactory.create(recipesAdapter)) } - collectWithViewLifecycle(adapter.onPagesUpdatedFlow) { + collectWhenViewResumed(viewModel.pagingData) { + Timber.v("setupRecipeAdapter: received data update") + recipesAdapter.submitData(lifecycle, it) + } + collectWhenViewResumed(recipesAdapter.onPagesUpdatedFlow) { Timber.v("setupRecipeAdapter: pages updated") binding.refresher.isRefreshing = false } - collectWithViewLifecycle(binding.refresher.refreshRequestFlow()) { + collectWhenViewResumed(binding.refresher.refreshRequestFlow()) { Timber.v("setupRecipeAdapter: received refresh request") - adapter.refresh() + recipesAdapter.refresh() } } diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt index 66d5c14..a1d5ec0 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt @@ -6,12 +6,14 @@ import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding +import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader import timber.log.Timber class RecipesPagingAdapter( - private val viewModel: RecipeViewModel, + private val recipeImageLoader: RecipeImageLoader, private val clickListener: (RecipeSummaryEntity) -> Unit ) : PagingDataAdapter(RecipeDiffCallback) { + override fun onBindViewHolder(holder: RecipeViewHolder, position: Int) { val item = getItem(position) holder.bind(item) @@ -21,7 +23,7 @@ class RecipesPagingAdapter( Timber.v("onCreateViewHolder() called with: parent = $parent, viewType = $viewType") val inflater = LayoutInflater.from(parent.context) val binding = ViewHolderRecipeBinding.inflate(inflater, parent, false) - return RecipeViewHolder(binding, viewModel, clickListener) + return RecipeViewHolder(binding, recipeImageLoader, clickListener) } private object RecipeDiffCallback : DiffUtil.ItemCallback() { diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeImageLoader.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeImageLoader.kt new file mode 100644 index 0000000..ea06a04 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeImageLoader.kt @@ -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?) +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeImageLoaderImpl.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeImageLoaderImpl.kt new file mode 100644 index 0000000..1cedd13 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeImageLoaderImpl.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeModelLoader.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeModelLoader.kt new file mode 100644 index 0000000..267d4f1 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeModelLoader.kt @@ -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, + cache: ModelCache, +) : BaseGlideUrlLoader(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) } + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeModelLoaderFactory.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeModelLoaderFactory.kt new file mode 100644 index 0000000..59ee91f --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipeModelLoaderFactory.kt @@ -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 { + + private val cache = ModelCache() + + override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader { + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipePreloadModelProvider.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipePreloadModelProvider.kt new file mode 100644 index 0000000..577e988 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipePreloadModelProvider.kt @@ -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, + private val fragment: Fragment, + private val requestOptions: RequestOptions, +) : ListPreloader.PreloadModelProvider { + + override fun getPreloadItems(position: Int): List { + 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, + ) = RecipePreloadModelProvider(adapter, fragment, requestOptions) + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipePreloaderFactory.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipePreloaderFactory.kt new file mode 100644 index 0000000..0455fe5 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipePreloaderFactory.kt @@ -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): RecyclerViewPreloader +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipePreloaderFactoryImpl.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipePreloaderFactoryImpl.kt new file mode 100644 index 0000000..c9c707c --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/images/RecipePreloaderFactoryImpl.kt @@ -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, + ): RecyclerViewPreloader { + val preloadSizeProvider = ViewPreloadSizeProvider() + val preloadModelProvider = recipePreloadModelProvider.create(adapter) + return RecyclerViewPreloader( + fragment, + preloadModelProvider, + preloadSizeProvider, + MAX_PRELOAD + ) + } + + companion object { + const val MAX_PRELOAD = 10 + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoFragment.kt index fd74d8a..e52ff11 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoFragment.kt @@ -14,7 +14,9 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment import dagger.hilt.android.AndroidEntryPoint import gq.kirmanak.mealient.R import gq.kirmanak.mealient.databinding.FragmentRecipeInfoBinding +import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader import timber.log.Timber +import javax.inject.Inject @AndroidEntryPoint class RecipeInfoFragment : BottomSheetDialogFragment() { @@ -25,6 +27,9 @@ class RecipeInfoFragment : BottomSheetDialogFragment() { private val ingredientsAdapter = RecipeIngredientsAdapter() private val instructionsAdapter = RecipeInstructionsAdapter() + @Inject + lateinit var recipeImageLoader: RecipeImageLoader + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -44,7 +49,6 @@ class RecipeInfoFragment : BottomSheetDialogFragment() { } with(viewModel) { - loadRecipeImage(binding.image, arguments.recipeSlug) loadRecipeInfo(arguments.recipeId, arguments.recipeSlug) uiState.observe(viewLifecycleOwner, ::onUiStateChange) } @@ -55,6 +59,7 @@ class RecipeInfoFragment : BottomSheetDialogFragment() { ingredientsHolder.isVisible = uiState.areIngredientsVisible instructionsGroup.isVisible = uiState.areInstructionsVisible uiState.recipeInfo?.let { + recipeImageLoader.loadRecipeImage(image, it.recipeSummaryEntity) title.text = it.recipeSummaryEntity.name description.text = it.recipeSummaryEntity.description ingredientsAdapter.submitList(it.recipeIngredients) diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt index 5bc7242..08f3502 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt @@ -1,6 +1,5 @@ package gq.kirmanak.mealient.ui.recipes.info -import android.widget.ImageView import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -8,7 +7,6 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.extensions.runCatchingExceptCancel -import gq.kirmanak.mealient.ui.recipes.RecipeImageLoader import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -16,17 +14,11 @@ import javax.inject.Inject @HiltViewModel class RecipeInfoViewModel @Inject constructor( private val recipeRepo: RecipeRepo, - private val recipeImageLoader: RecipeImageLoader, ) : ViewModel() { private val _uiState = MutableLiveData(RecipeInfoUiState()) val uiState: LiveData 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) { Timber.v("loadRecipeInfo() called with: recipeId = $recipeId, recipeSlug = $recipeSlug") _uiState.value = RecipeInfoUiState() diff --git a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageLoaderImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageUrlProviderImplTest.kt similarity index 90% rename from app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageLoaderImplTest.kt rename to app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageUrlProviderImplTest.kt index 6cedcc9..03661e4 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageLoaderImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageUrlProviderImplTest.kt @@ -2,7 +2,6 @@ package gq.kirmanak.mealient.data.recipes.impl import com.google.common.truth.Truth.assertThat import gq.kirmanak.mealient.data.baseurl.BaseURLStorage -import gq.kirmanak.mealient.ui.images.ImageLoader import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.impl.annotations.MockK @@ -12,19 +11,17 @@ import org.junit.Before import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) -class RecipeImageLoaderImplTest { - lateinit var subject: RecipeImageLoaderImpl +class RecipeImageUrlProviderImplTest { + + lateinit var subject: RecipeImageUrlProvider @MockK lateinit var baseURLStorage: BaseURLStorage - @MockK - lateinit var imageLoader: ImageLoader - @Before fun setUp() { MockKAnnotations.init(this) - subject = RecipeImageLoaderImpl(imageLoader, baseURLStorage) + subject = RecipeImageUrlProviderImpl(baseURLStorage) prepareBaseURL("https://google.com/") } diff --git a/build.gradle b/build.gradle index 5eab59b..f6d1e61 100644 --- a/build.gradle +++ b/build.gradle @@ -1,13 +1,13 @@ buildscript { ext { // https://developer.android.com/jetpack/androidx/releases/navigation - nav_version = "2.4.1" + nav_version = "2.4.2" // https://dagger.dev/hilt/gradle-setup hilt_version = "2.41" // https://kotlinlang.org/docs/gradle.html - kotlin_version = "1.6.10" + kotlin_version = "1.6.20" } repositories { @@ -17,7 +17,7 @@ buildscript { dependencies { // 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-serialization:$kotlin_version" classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"