@@ -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"
|
||||
|
||||
9
app/proguard-rules.pro
vendored
9
app/proguard-rules.pro
vendored
@@ -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 ###
|
||||
@@ -4,6 +4,7 @@
|
||||
package="gq.kirmanak.mealient">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:name="gq.kirmanak.mealient.App"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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<RecipeSummaryEntity, InputStream>
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 <T> Fragment.collectWithViewLifecycle(
|
||||
inline fun <T> Fragment.collectWhenViewResumed(
|
||||
flow: Flow<T>,
|
||||
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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package gq.kirmanak.mealient.ui.recipes
|
||||
|
||||
import android.widget.ImageView
|
||||
|
||||
interface RecipeImageLoader {
|
||||
suspend fun loadRecipeImage(view: ImageView, slug: String?)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<RecipeViewModel>()
|
||||
private val activityViewModel by activityViewModels<MainActivityViewModel>()
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<RecipeSummaryEntity, RecipeViewHolder>(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<RecipeSummaryEntity>() {
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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<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) {
|
||||
Timber.v("loadRecipeInfo() called with: recipeId = $recipeId, recipeSlug = $recipeSlug")
|
||||
_uiState.value = RecipeInfoUiState()
|
||||
|
||||
@@ -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/")
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user