Implement proper loading of recipe summaries
This commit is contained in:
@@ -65,6 +65,9 @@ dependencies {
|
|||||||
// https://developer.android.com/jetpack/androidx/releases/constraintlayout
|
// https://developer.android.com/jetpack/androidx/releases/constraintlayout
|
||||||
implementation "androidx.constraintlayout:constraintlayout:2.1.1"
|
implementation "androidx.constraintlayout:constraintlayout:2.1.1"
|
||||||
|
|
||||||
|
// https://developer.android.com/jetpack/androidx/releases/swiperefreshlayout
|
||||||
|
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
|
||||||
|
|
||||||
// https://developer.android.com/jetpack/androidx/releases/lifecycle
|
// https://developer.android.com/jetpack/androidx/releases/lifecycle
|
||||||
def lifecycle_version = "2.4.0"
|
def lifecycle_version = "2.4.0"
|
||||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
|
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
|
||||||
|
|||||||
@@ -5,4 +5,6 @@ import gq.kirmanak.mealie.data.recipes.db.RecipeEntity
|
|||||||
|
|
||||||
interface RecipeRepo {
|
interface RecipeRepo {
|
||||||
fun createPager(): Pager<Int, RecipeEntity>
|
fun createPager(): Pager<Int, RecipeEntity>
|
||||||
|
|
||||||
|
suspend fun clearLocalData()
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@ interface RecipeDao {
|
|||||||
@Query("SELECT * FROM categories")
|
@Query("SELECT * FROM categories")
|
||||||
suspend fun queryAllCategories(): List<CategoryEntity>
|
suspend fun queryAllCategories(): List<CategoryEntity>
|
||||||
|
|
||||||
@Query("SELECT * FROM recipes")
|
@Query("SELECT * FROM recipes ORDER BY date_added DESC")
|
||||||
fun queryRecipesByPages(): PagingSource<Int, RecipeEntity>
|
fun queryRecipesByPages(): PagingSource<Int, RecipeEntity>
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
@@ -41,7 +41,13 @@ interface RecipeDao {
|
|||||||
@Query("DELETE FROM recipes")
|
@Query("DELETE FROM recipes")
|
||||||
suspend fun removeAllRecipes()
|
suspend fun removeAllRecipes()
|
||||||
|
|
||||||
@Query("SELECT * FROM recipes")
|
@Query("DELETE FROM tags")
|
||||||
|
suspend fun removeAllTags()
|
||||||
|
|
||||||
|
@Query("DELETE FROM categories")
|
||||||
|
suspend fun removeAllCategories()
|
||||||
|
|
||||||
|
@Query("SELECT * FROM recipes ORDER BY date_updated DESC")
|
||||||
suspend fun queryAllRecipes(): List<RecipeEntity>
|
suspend fun queryAllRecipes(): List<RecipeEntity>
|
||||||
|
|
||||||
@Query("SELECT * FROM category_recipe")
|
@Query("SELECT * FROM category_recipe")
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ interface RecipeStorage {
|
|||||||
fun queryRecipes(): PagingSource<Int, RecipeEntity>
|
fun queryRecipes(): PagingSource<Int, RecipeEntity>
|
||||||
|
|
||||||
suspend fun refreshAll(recipes: List<GetRecipeSummaryResponse>)
|
suspend fun refreshAll(recipes: List<GetRecipeSummaryResponse>)
|
||||||
|
|
||||||
|
suspend fun clearAllLocalData()
|
||||||
}
|
}
|
||||||
@@ -99,4 +99,13 @@ class RecipeStorageImpl @Inject constructor(
|
|||||||
saveRecipes(recipes)
|
saveRecipes(recipes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun clearAllLocalData() {
|
||||||
|
Timber.v("clearAllLocalData() called")
|
||||||
|
db.withTransaction {
|
||||||
|
recipeDao.removeAllRecipes()
|
||||||
|
recipeDao.removeAllCategories()
|
||||||
|
recipeDao.removeAllTags()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package gq.kirmanak.mealie.data.recipes.impl
|
||||||
|
|
||||||
|
import androidx.paging.PagingSource
|
||||||
|
import gq.kirmanak.mealie.data.recipes.db.RecipeEntity
|
||||||
|
import gq.kirmanak.mealie.data.recipes.db.RecipeStorage
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class RecipePagingSourceFactory @Inject constructor(
|
||||||
|
private val recipeStorage: RecipeStorage
|
||||||
|
) : () -> PagingSource<Int, RecipeEntity> {
|
||||||
|
private val sources: MutableList<PagingSource<Int, RecipeEntity>> = mutableListOf()
|
||||||
|
|
||||||
|
override fun invoke(): PagingSource<Int, RecipeEntity> {
|
||||||
|
Timber.v("invoke() called")
|
||||||
|
val newSource = recipeStorage.queryRecipes()
|
||||||
|
sources.add(newSource)
|
||||||
|
return newSource
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invalidate() {
|
||||||
|
Timber.v("invalidate() called")
|
||||||
|
for (source in sources) {
|
||||||
|
if (!source.invalid) {
|
||||||
|
source.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sources.removeAll { it.invalid }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,17 +6,27 @@ import androidx.paging.PagingConfig
|
|||||||
import gq.kirmanak.mealie.data.recipes.RecipeRepo
|
import gq.kirmanak.mealie.data.recipes.RecipeRepo
|
||||||
import gq.kirmanak.mealie.data.recipes.db.RecipeEntity
|
import gq.kirmanak.mealie.data.recipes.db.RecipeEntity
|
||||||
import gq.kirmanak.mealie.data.recipes.db.RecipeStorage
|
import gq.kirmanak.mealie.data.recipes.db.RecipeStorage
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@ExperimentalPagingApi
|
@ExperimentalPagingApi
|
||||||
class RecipeRepoImpl @Inject constructor(
|
class RecipeRepoImpl @Inject constructor(
|
||||||
private val mediator: RecipesRemoteMediator,
|
private val mediator: RecipesRemoteMediator,
|
||||||
private val storage: RecipeStorage
|
private val storage: RecipeStorage,
|
||||||
|
private val pagingSourceFactory: RecipePagingSourceFactory
|
||||||
) : RecipeRepo {
|
) : RecipeRepo {
|
||||||
override fun createPager(): Pager<Int, RecipeEntity> {
|
override fun createPager(): Pager<Int, RecipeEntity> {
|
||||||
val pagingConfig = PagingConfig(pageSize = 30, enablePlaceholders = false, prefetchDistance = 10)
|
Timber.v("createPager() called")
|
||||||
return Pager(pagingConfig, 0, mediator) {
|
val pagingConfig = PagingConfig(pageSize = 30, enablePlaceholders = true)
|
||||||
storage.queryRecipes()
|
return Pager(
|
||||||
}
|
config = pagingConfig,
|
||||||
|
remoteMediator = mediator,
|
||||||
|
pagingSourceFactory = pagingSourceFactory
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearLocalData() {
|
||||||
|
Timber.v("clearLocalData() called")
|
||||||
|
storage.clearAllLocalData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,56 +1,63 @@
|
|||||||
package gq.kirmanak.mealie.data.recipes.impl
|
package gq.kirmanak.mealie.data.recipes.impl
|
||||||
|
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
import androidx.paging.ExperimentalPagingApi
|
import androidx.paging.ExperimentalPagingApi
|
||||||
import androidx.paging.LoadType
|
import androidx.paging.LoadType
|
||||||
import androidx.paging.LoadType.*
|
import androidx.paging.LoadType.PREPEND
|
||||||
|
import androidx.paging.LoadType.REFRESH
|
||||||
import androidx.paging.PagingState
|
import androidx.paging.PagingState
|
||||||
import androidx.paging.RemoteMediator
|
import androidx.paging.RemoteMediator
|
||||||
import gq.kirmanak.mealie.data.recipes.db.RecipeEntity
|
import gq.kirmanak.mealie.data.recipes.db.RecipeEntity
|
||||||
import gq.kirmanak.mealie.data.recipes.db.RecipeStorage
|
import gq.kirmanak.mealie.data.recipes.db.RecipeStorage
|
||||||
import gq.kirmanak.mealie.data.recipes.network.RecipeDataSource
|
import gq.kirmanak.mealie.data.recipes.network.RecipeDataSource
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@ExperimentalPagingApi
|
@ExperimentalPagingApi
|
||||||
class RecipesRemoteMediator @Inject constructor(
|
class RecipesRemoteMediator @Inject constructor(
|
||||||
private val storage: RecipeStorage,
|
private val storage: RecipeStorage,
|
||||||
private val network: RecipeDataSource
|
private val network: RecipeDataSource,
|
||||||
|
private val pagingSourceFactory: RecipePagingSourceFactory,
|
||||||
) : RemoteMediator<Int, RecipeEntity>() {
|
) : RemoteMediator<Int, RecipeEntity>() {
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
var lastRequestEnd: Int = 0
|
||||||
|
|
||||||
override suspend fun load(
|
override suspend fun load(
|
||||||
loadType: LoadType,
|
loadType: LoadType,
|
||||||
state: PagingState<Int, RecipeEntity>
|
state: PagingState<Int, RecipeEntity>
|
||||||
): MediatorResult {
|
): MediatorResult {
|
||||||
val pageSize = state.config.pageSize
|
Timber.v("load() called with: lastRequestEnd = $lastRequestEnd, loadType = $loadType, state = $state")
|
||||||
val closestPage = state.anchorPosition?.let { state.closestPageToPosition(it) }
|
|
||||||
val start = when (loadType) {
|
if (loadType == PREPEND) {
|
||||||
REFRESH -> 0
|
Timber.i("load: early exit, PREPEND isn't supported")
|
||||||
PREPEND -> closestPage?.prevKey ?: 0
|
return MediatorResult.Success(endOfPaginationReached = true)
|
||||||
APPEND -> closestPage?.nextKey ?: 0
|
|
||||||
}
|
|
||||||
val end = when (loadType) {
|
|
||||||
REFRESH -> pageSize
|
|
||||||
PREPEND, APPEND -> start + pageSize
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val recipes = try {
|
val start = if (loadType == REFRESH) 0 else lastRequestEnd
|
||||||
network.requestRecipes(start = start, end = end)
|
val limit = if (loadType == REFRESH) state.config.initialLoadSize else state.config.pageSize
|
||||||
} catch (e: Exception) {
|
|
||||||
|
val count: Int = try {
|
||||||
|
val recipes = network.requestRecipes(start, limit)
|
||||||
|
if (loadType == REFRESH) storage.refreshAll(recipes)
|
||||||
|
else storage.saveRecipes(recipes)
|
||||||
|
recipes.size
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Throwable) {
|
||||||
Timber.e(e, "Can't load recipes")
|
Timber.e(e, "Can't load recipes")
|
||||||
return MediatorResult.Error(e)
|
return MediatorResult.Error(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// After something is inserted into DB the paging sources have to be invalidated
|
||||||
when (loadType) {
|
// But for some reason Room/Paging library don't do it automatically
|
||||||
REFRESH -> storage.refreshAll(recipes)
|
// Here we invalidate them manually.
|
||||||
PREPEND, APPEND -> storage.saveRecipes(recipes)
|
// Read that trick here https://github.com/android/architecture-components-samples/issues/889#issuecomment-880847858
|
||||||
}
|
pagingSourceFactory.invalidate()
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "Can't save recipes")
|
Timber.d("load: expectedCount = $limit, received $count")
|
||||||
return MediatorResult.Error(e)
|
lastRequestEnd = start + count
|
||||||
}
|
return MediatorResult.Success(endOfPaginationReached = count < limit)
|
||||||
val expectedCount = end - start
|
|
||||||
val isEndReached = recipes.size < expectedCount
|
|
||||||
return MediatorResult.Success(isEndReached)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
package gq.kirmanak.mealie.data.recipes.network
|
package gq.kirmanak.mealie.data.recipes.network
|
||||||
|
|
||||||
interface RecipeDataSource {
|
interface RecipeDataSource {
|
||||||
suspend fun requestRecipes(start: Int = 0, end: Int = 9999): List<GetRecipeSummaryResponse>
|
suspend fun requestRecipes(start: Int = 0, limit: Int = 9999): List<GetRecipeSummaryResponse>
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package gq.kirmanak.mealie.data.recipes.network
|
package gq.kirmanak.mealie.data.recipes.network
|
||||||
|
|
||||||
import gq.kirmanak.mealie.data.impl.RetrofitBuilder
|
|
||||||
import gq.kirmanak.mealie.data.auth.AuthRepo
|
import gq.kirmanak.mealie.data.auth.AuthRepo
|
||||||
|
import gq.kirmanak.mealie.data.impl.RetrofitBuilder
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -13,10 +13,12 @@ class RecipeDataSourceImpl @Inject constructor(
|
|||||||
) : RecipeDataSource {
|
) : RecipeDataSource {
|
||||||
private var _recipeService: RecipeService? = null
|
private var _recipeService: RecipeService? = null
|
||||||
|
|
||||||
override suspend fun requestRecipes(start: Int, end: Int): List<GetRecipeSummaryResponse> {
|
override suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse> {
|
||||||
Timber.v("requestRecipes() called")
|
Timber.v("requestRecipes() called with: start = $start, limit = $limit")
|
||||||
val service: RecipeService = getRecipeService()
|
val service: RecipeService = getRecipeService()
|
||||||
return service.getRecipeSummary(start, end)
|
val recipeSummary = service.getRecipeSummary(start, limit)
|
||||||
|
Timber.v("requestRecipes() returned: $recipeSummary")
|
||||||
|
return recipeSummary
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getRecipeService(): RecipeService {
|
private suspend fun getRecipeService(): RecipeService {
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ interface RecipeService {
|
|||||||
@GET("/api/recipes/summary")
|
@GET("/api/recipes/summary")
|
||||||
suspend fun getRecipeSummary(
|
suspend fun getRecipeSummary(
|
||||||
@Query("start") start: Int,
|
@Query("start") start: Int,
|
||||||
@Query("end") end: Int
|
@Query("limit") limit: Int
|
||||||
): List<GetRecipeSummaryResponse>
|
): List<GetRecipeSummaryResponse>
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package gq.kirmanak.mealie.ui
|
||||||
|
|
||||||
|
import androidx.paging.PagingDataAdapter
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.channels.onFailure
|
||||||
|
import kotlinx.coroutines.channels.trySendBlocking
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
|
object SwipeRefreshLayoutHelper {
|
||||||
|
private fun SwipeRefreshLayout.refreshesFlow(): Flow<Unit> {
|
||||||
|
Timber.v("refreshesFlow() called")
|
||||||
|
return callbackFlow {
|
||||||
|
val listener = SwipeRefreshLayout.OnRefreshListener {
|
||||||
|
Timber.v("Refresh requested")
|
||||||
|
trySendBlocking(Unit).onFailure { Timber.e(it, "Can't send refresh callback") }
|
||||||
|
}
|
||||||
|
Timber.v("Adding refresh request listener")
|
||||||
|
setOnRefreshListener(listener)
|
||||||
|
awaitClose {
|
||||||
|
Timber.v("Removing refresh request listener")
|
||||||
|
setOnRefreshListener(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun <T : Any, VH : RecyclerView.ViewHolder> PagingDataAdapter<T, VH>.listenToRefreshRequests(
|
||||||
|
refreshLayout: SwipeRefreshLayout
|
||||||
|
) {
|
||||||
|
Timber.v("listenToRefreshRequests() called")
|
||||||
|
refreshLayout.refreshesFlow().collectLatest {
|
||||||
|
Timber.d("Received refresh request")
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,7 +75,7 @@ class AuthenticationFragment : Fragment() {
|
|||||||
"URL is empty"
|
"URL is empty"
|
||||||
} ?: return
|
} ?: return
|
||||||
}
|
}
|
||||||
lifecycleScope.launchWhenResumed {
|
viewLifecycleOwner.lifecycleScope.launchWhenResumed {
|
||||||
runCatching {
|
runCatching {
|
||||||
viewModel.authenticate(email, pass, url)
|
viewModel.authenticate(email, pass, url)
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
package gq.kirmanak.mealie.ui.auth
|
package gq.kirmanak.mealie.ui.auth
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import gq.kirmanak.mealie.data.auth.AuthRepo
|
import gq.kirmanak.mealie.data.auth.AuthRepo
|
||||||
|
import gq.kirmanak.mealie.data.recipes.RecipeRepo
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AuthenticationViewModel @Inject constructor(
|
class AuthenticationViewModel @Inject constructor(
|
||||||
private val authRepo: AuthRepo
|
private val authRepo: AuthRepo,
|
||||||
|
private val recipeRepo: RecipeRepo
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
init {
|
init {
|
||||||
Timber.v("constructor called")
|
Timber.v("constructor called")
|
||||||
@@ -28,5 +32,6 @@ class AuthenticationViewModel @Inject constructor(
|
|||||||
fun logout() {
|
fun logout() {
|
||||||
Timber.v("logout() called")
|
Timber.v("logout() called")
|
||||||
authRepo.logout()
|
authRepo.logout()
|
||||||
|
viewModelScope.launch { recipeRepo.clearLocalData() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,24 +3,19 @@ package gq.kirmanak.mealie.ui.recipes
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.paging.Pager
|
|
||||||
import androidx.paging.PagingData
|
|
||||||
import androidx.paging.cachedIn
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import gq.kirmanak.mealie.data.recipes.RecipeImageLoader
|
import gq.kirmanak.mealie.data.recipes.RecipeImageLoader
|
||||||
import gq.kirmanak.mealie.data.recipes.RecipeRepo
|
import gq.kirmanak.mealie.data.recipes.RecipeRepo
|
||||||
import gq.kirmanak.mealie.data.recipes.db.RecipeEntity
|
import gq.kirmanak.mealie.data.recipes.db.RecipeEntity
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class RecipeViewModel @Inject constructor(
|
class RecipeViewModel @Inject constructor(
|
||||||
private val recipeRepo: RecipeRepo,
|
recipeRepo: RecipeRepo,
|
||||||
private val recipeImageLoader: RecipeImageLoader
|
private val recipeImageLoader: RecipeImageLoader
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val pager: Pager<Int, RecipeEntity> by lazy { recipeRepo.createPager() }
|
val recipeFlow = recipeRepo.createPager().flow
|
||||||
val recipeFlow: Flow<PagingData<RecipeEntity>> by lazy { pager.flow.cachedIn(viewModelScope) }
|
|
||||||
|
|
||||||
fun loadRecipeImage(view: ImageView, recipe: RecipeEntity?) {
|
fun loadRecipeImage(view: ImageView, recipe: RecipeEntity?) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -11,10 +11,14 @@ import androidx.navigation.fragment.findNavController
|
|||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import gq.kirmanak.mealie.databinding.FragmentRecipesBinding
|
import gq.kirmanak.mealie.databinding.FragmentRecipesBinding
|
||||||
|
import gq.kirmanak.mealie.ui.SwipeRefreshLayoutHelper.listenToRefreshRequests
|
||||||
import gq.kirmanak.mealie.ui.auth.AuthenticationViewModel
|
import gq.kirmanak.mealie.ui.auth.AuthenticationViewModel
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
|
@ExperimentalCoroutinesApi
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class RecipesFragment : Fragment() {
|
class RecipesFragment : Fragment() {
|
||||||
private var _binding: FragmentRecipesBinding? = null
|
private var _binding: FragmentRecipesBinding? = null
|
||||||
@@ -58,12 +62,26 @@ class RecipesFragment : Fragment() {
|
|||||||
private fun setupRecipeAdapter() {
|
private fun setupRecipeAdapter() {
|
||||||
Timber.v("setupRecipeAdapter() called")
|
Timber.v("setupRecipeAdapter() called")
|
||||||
binding.recipes.layoutManager = LinearLayoutManager(requireContext())
|
binding.recipes.layoutManager = LinearLayoutManager(requireContext())
|
||||||
val recipesPagingAdapter = RecipesPagingAdapter(viewModel)
|
val adapter = RecipesPagingAdapter(viewModel)
|
||||||
binding.recipes.adapter = recipesPagingAdapter
|
binding.recipes.adapter = adapter
|
||||||
lifecycleScope.launchWhenResumed {
|
viewLifecycleOwner.lifecycleScope.launchWhenResumed {
|
||||||
viewModel.recipeFlow.collectLatest {
|
viewModel.recipeFlow.collect {
|
||||||
Timber.d("setupRecipeAdapter: received update")
|
Timber.d("Received update")
|
||||||
recipesPagingAdapter.submitData(it)
|
adapter.submitData(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewLifecycleOwner.lifecycleScope.launchWhenResumed {
|
||||||
|
adapter.listenToRefreshRequests(binding.refresher)
|
||||||
|
}
|
||||||
|
viewLifecycleOwner.lifecycleScope.launchWhenResumed {
|
||||||
|
adapter.onPagesUpdatedFlow.collect {
|
||||||
|
Timber.d("Pages have been updated")
|
||||||
|
binding.refresher.isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewLifecycleOwner.lifecycleScope.launchWhenResumed {
|
||||||
|
adapter.loadStateFlow.collect {
|
||||||
|
Timber.d("New load state: $it")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,20 @@
|
|||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
tools:context=".ui.recipes.RecipesFragment">
|
tools:context=".ui.recipes.RecipesFragment">
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
android:id="@+id/recipes"
|
android:id="@+id/refresher"
|
||||||
android:layout_width="0dp"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="wrap_content"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
app:layout_constraintTop_toTopOf="parent">
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/recipes"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
tools:listitem="@layout/view_holder_recipe" />
|
||||||
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package gq.kirmanak.mealie.data.recipes
|
||||||
|
|
||||||
|
import gq.kirmanak.mealie.data.recipes.db.RecipeEntity
|
||||||
|
import gq.kirmanak.mealie.data.recipes.network.GetRecipeSummaryResponse
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
|
||||||
|
object RecipeImplTestData {
|
||||||
|
val RECIPE_SUMMARY_CAKE = GetRecipeSummaryResponse(
|
||||||
|
remoteId = 1,
|
||||||
|
name = "Cake",
|
||||||
|
slug = "cake",
|
||||||
|
image = "86",
|
||||||
|
description = "A tasty cake",
|
||||||
|
recipeCategories = listOf("dessert", "tasty"),
|
||||||
|
tags = listOf("gluten", "allergic"),
|
||||||
|
rating = 4,
|
||||||
|
dateAdded = LocalDate.parse("2021-11-13"),
|
||||||
|
dateUpdated = LocalDateTime.parse("2021-11-13T15:30:13"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val RECIPE_SUMMARY_PORRIDGE = GetRecipeSummaryResponse(
|
||||||
|
remoteId = 2,
|
||||||
|
name = "Porridge",
|
||||||
|
slug = "porridge",
|
||||||
|
image = "89",
|
||||||
|
description = "A tasty porridge",
|
||||||
|
recipeCategories = listOf("porridge", "tasty"),
|
||||||
|
tags = listOf("gluten", "milk"),
|
||||||
|
rating = 5,
|
||||||
|
dateAdded = LocalDate.parse("2021-11-12"),
|
||||||
|
dateUpdated = LocalDateTime.parse("2021-10-13T17:35:23"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val TEST_RECIPE_SUMMARIES = listOf(RECIPE_SUMMARY_CAKE, RECIPE_SUMMARY_PORRIDGE)
|
||||||
|
|
||||||
|
const val RECIPE_SUMMARY_SUCCESSFUL = """[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Cake",
|
||||||
|
"slug": "cake",
|
||||||
|
"image": "86",
|
||||||
|
"description": "A tasty cake",
|
||||||
|
"recipeCategory": ["dessert", "tasty"],
|
||||||
|
"tags": ["gluten", "allergic"],
|
||||||
|
"rating": 4,
|
||||||
|
"dateAdded": "2021-11-13",
|
||||||
|
"dateUpdated": "2021-11-13T15:30:13"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "Porridge",
|
||||||
|
"slug": "porridge",
|
||||||
|
"image": "89",
|
||||||
|
"description": "A tasty porridge",
|
||||||
|
"recipeCategory": ["porridge", "tasty"],
|
||||||
|
"tags": ["gluten", "milk"],
|
||||||
|
"rating": 5,
|
||||||
|
"dateAdded": "2021-11-12",
|
||||||
|
"dateUpdated": "2021-10-13T17:35:23"
|
||||||
|
}
|
||||||
|
]"""
|
||||||
|
|
||||||
|
const val RECIPE_SUMMARY_UNSUCCESSFUL = """
|
||||||
|
{"detail":"Unauthorized"}
|
||||||
|
"""
|
||||||
|
|
||||||
|
val CAKE_RECIPE_ENTITY = RecipeEntity(
|
||||||
|
localId = 1,
|
||||||
|
remoteId = 1,
|
||||||
|
name = "Cake",
|
||||||
|
slug = "cake",
|
||||||
|
image = "86",
|
||||||
|
description = "A tasty cake",
|
||||||
|
rating = 4,
|
||||||
|
dateAdded = LocalDate.parse("2021-11-13"),
|
||||||
|
dateUpdated = LocalDateTime.parse("2021-11-13T15:30:13")
|
||||||
|
)
|
||||||
|
|
||||||
|
val PORRIDGE_RECIPE_ENTITY = RecipeEntity(
|
||||||
|
localId = 2,
|
||||||
|
remoteId = 2,
|
||||||
|
name = "Porridge",
|
||||||
|
slug = "porridge",
|
||||||
|
image = "89",
|
||||||
|
description = "A tasty porridge",
|
||||||
|
rating = 5,
|
||||||
|
dateAdded = LocalDate.parse("2021-11-12"),
|
||||||
|
dateUpdated = LocalDateTime.parse("2021-10-13T17:35:23"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val TEST_RECIPE_ENTITIES = listOf(CAKE_RECIPE_ENTITY, PORRIDGE_RECIPE_ENTITY)
|
||||||
|
|
||||||
|
fun MockWebServer.enqueueSuccessfulRecipeSummaryResponse() {
|
||||||
|
val response = MockResponse()
|
||||||
|
.setBody(RECIPE_SUMMARY_SUCCESSFUL)
|
||||||
|
.setHeader("Content-Type", "application/json")
|
||||||
|
.setResponseCode(200)
|
||||||
|
enqueue(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MockWebServer.enqueueUnsuccessfulRecipeSummaryResponse() {
|
||||||
|
val response = MockResponse()
|
||||||
|
.setBody(RECIPE_SUMMARY_UNSUCCESSFUL)
|
||||||
|
.setHeader("Content-Type", "application/json")
|
||||||
|
.setResponseCode(401)
|
||||||
|
enqueue(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,11 @@ import com.google.common.truth.Truth.assertThat
|
|||||||
import dagger.hilt.android.testing.HiltAndroidTest
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
import gq.kirmanak.mealie.data.MealieDb
|
import gq.kirmanak.mealie.data.MealieDb
|
||||||
import gq.kirmanak.mealie.data.auth.impl.HiltRobolectricTest
|
import gq.kirmanak.mealie.data.auth.impl.HiltRobolectricTest
|
||||||
import gq.kirmanak.mealie.data.recipes.network.GetRecipeSummaryResponse
|
import gq.kirmanak.mealie.data.recipes.RecipeImplTestData.CAKE_RECIPE_ENTITY
|
||||||
|
import gq.kirmanak.mealie.data.recipes.RecipeImplTestData.PORRIDGE_RECIPE_ENTITY
|
||||||
|
import gq.kirmanak.mealie.data.recipes.RecipeImplTestData.RECIPE_SUMMARY_CAKE
|
||||||
|
import gq.kirmanak.mealie.data.recipes.RecipeImplTestData.TEST_RECIPE_SUMMARIES
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.LocalDateTime
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -46,30 +47,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
|
|||||||
fun `when saveRecipes then saves recipes`(): Unit = runBlocking {
|
fun `when saveRecipes then saves recipes`(): Unit = runBlocking {
|
||||||
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
|
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
|
||||||
val actualTags = mealieDb.recipeDao().queryAllRecipes()
|
val actualTags = mealieDb.recipeDao().queryAllRecipes()
|
||||||
assertThat(actualTags).containsExactly(
|
assertThat(actualTags).containsExactly(CAKE_RECIPE_ENTITY, PORRIDGE_RECIPE_ENTITY)
|
||||||
RecipeEntity(
|
|
||||||
localId = 1,
|
|
||||||
remoteId = 1,
|
|
||||||
name = "Cake",
|
|
||||||
slug = "cake",
|
|
||||||
image = "86",
|
|
||||||
description = "A tasty cake",
|
|
||||||
rating = 4,
|
|
||||||
dateAdded = LocalDate.parse("2021-11-13"),
|
|
||||||
dateUpdated = LocalDateTime.parse("2021-11-13T15:30:13")
|
|
||||||
),
|
|
||||||
RecipeEntity(
|
|
||||||
localId = 2,
|
|
||||||
remoteId = 2,
|
|
||||||
name = "Porridge",
|
|
||||||
slug = "porridge",
|
|
||||||
image = "89",
|
|
||||||
description = "A tasty porridge",
|
|
||||||
rating = 5,
|
|
||||||
dateAdded = LocalDate.parse("2021-11-12"),
|
|
||||||
dateUpdated = LocalDateTime.parse("2021-10-13T17:35:23"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -96,33 +74,59 @@ class RecipeStorageImplTest : HiltRobolectricTest() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
@Test
|
||||||
private val RECIPE_SUMMARY_CAKE = GetRecipeSummaryResponse(
|
fun `when refreshAll then old recipes aren't preserved`(): Unit = runBlocking {
|
||||||
remoteId = 1,
|
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
|
||||||
name = "Cake",
|
subject.refreshAll(listOf(RECIPE_SUMMARY_CAKE))
|
||||||
slug = "cake",
|
val actual = mealieDb.recipeDao().queryAllRecipes()
|
||||||
image = "86",
|
assertThat(actual).containsExactly(
|
||||||
description = "A tasty cake",
|
CAKE_RECIPE_ENTITY.copy(localId = 3),
|
||||||
recipeCategories = listOf("dessert", "tasty"),
|
|
||||||
tags = listOf("gluten", "allergic"),
|
|
||||||
rating = 4,
|
|
||||||
dateAdded = LocalDate.parse("2021-11-13"),
|
|
||||||
dateUpdated = LocalDateTime.parse("2021-11-13T15:30:13"),
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private val RECIPE_SUMMARY_PORRIDGE = GetRecipeSummaryResponse(
|
@Test
|
||||||
remoteId = 2,
|
fun `when refreshAll then old category recipes aren't preserved`(): Unit = runBlocking {
|
||||||
name = "Porridge",
|
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
|
||||||
slug = "porridge",
|
subject.refreshAll(listOf(RECIPE_SUMMARY_CAKE))
|
||||||
image = "89",
|
val actual = mealieDb.recipeDao().queryAllCategoryRecipes()
|
||||||
description = "A tasty porridge",
|
assertThat(actual).containsExactly(
|
||||||
recipeCategories = listOf("porridge", "tasty"),
|
CategoryRecipeEntity(categoryId = 1, recipeId = 3),
|
||||||
tags = listOf("gluten", "milk"),
|
CategoryRecipeEntity(categoryId = 2, recipeId = 3),
|
||||||
rating = 5,
|
|
||||||
dateAdded = LocalDate.parse("2021-11-12"),
|
|
||||||
dateUpdated = LocalDateTime.parse("2021-10-13T17:35:23"),
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private val TEST_RECIPE_SUMMARIES = listOf(RECIPE_SUMMARY_CAKE, RECIPE_SUMMARY_PORRIDGE)
|
@Test
|
||||||
|
fun `when refreshAll then old tag recipes aren't preserved`(): Unit = runBlocking {
|
||||||
|
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
|
||||||
|
subject.refreshAll(listOf(RECIPE_SUMMARY_CAKE))
|
||||||
|
val actual = mealieDb.recipeDao().queryAllTagRecipes()
|
||||||
|
assertThat(actual).containsExactly(
|
||||||
|
TagRecipeEntity(tagId = 1, recipeId = 3),
|
||||||
|
TagRecipeEntity(tagId = 2, recipeId = 3),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when clearAllLocalData then recipes aren't preserved`(): Unit = runBlocking {
|
||||||
|
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
|
||||||
|
subject.clearAllLocalData()
|
||||||
|
val actual = mealieDb.recipeDao().queryAllRecipes()
|
||||||
|
assertThat(actual).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when clearAllLocalData then categories aren't preserved`(): Unit = runBlocking {
|
||||||
|
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
|
||||||
|
subject.clearAllLocalData()
|
||||||
|
val actual = mealieDb.recipeDao().queryAllCategories()
|
||||||
|
assertThat(actual).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when clearAllLocalData then tags aren't preserved`(): Unit = runBlocking {
|
||||||
|
subject.saveRecipes(TEST_RECIPE_SUMMARIES)
|
||||||
|
subject.clearAllLocalData()
|
||||||
|
val actual = mealieDb.recipeDao().queryAllTags()
|
||||||
|
assertThat(actual).isEmpty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package gq.kirmanak.mealie.data.recipes.impl
|
||||||
|
|
||||||
|
import androidx.paging.*
|
||||||
|
import androidx.paging.LoadType.*
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
|
import gq.kirmanak.mealie.data.MealieDb
|
||||||
|
import gq.kirmanak.mealie.data.auth.AuthRepo
|
||||||
|
import gq.kirmanak.mealie.data.auth.impl.AuthImplTestData.TEST_PASSWORD
|
||||||
|
import gq.kirmanak.mealie.data.auth.impl.AuthImplTestData.TEST_USERNAME
|
||||||
|
import gq.kirmanak.mealie.data.auth.impl.AuthImplTestData.enqueueSuccessfulAuthResponse
|
||||||
|
import gq.kirmanak.mealie.data.auth.impl.MockServerTest
|
||||||
|
import gq.kirmanak.mealie.data.recipes.RecipeImplTestData.CAKE_RECIPE_ENTITY
|
||||||
|
import gq.kirmanak.mealie.data.recipes.RecipeImplTestData.PORRIDGE_RECIPE_ENTITY
|
||||||
|
import gq.kirmanak.mealie.data.recipes.RecipeImplTestData.TEST_RECIPE_ENTITIES
|
||||||
|
import gq.kirmanak.mealie.data.recipes.RecipeImplTestData.enqueueSuccessfulRecipeSummaryResponse
|
||||||
|
import gq.kirmanak.mealie.data.recipes.RecipeImplTestData.enqueueUnsuccessfulRecipeSummaryResponse
|
||||||
|
import gq.kirmanak.mealie.data.recipes.db.RecipeEntity
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ExperimentalPagingApi
|
||||||
|
@HiltAndroidTest
|
||||||
|
class RecipesRemoteMediatorTest : MockServerTest() {
|
||||||
|
private val pagingConfig = PagingConfig(
|
||||||
|
pageSize = 2,
|
||||||
|
prefetchDistance = 5,
|
||||||
|
enablePlaceholders = false
|
||||||
|
)
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var subject: RecipesRemoteMediator
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var authRepo: AuthRepo
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var mealieDb: MealieDb
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun authenticate(): Unit = runBlocking {
|
||||||
|
mockServer.enqueueSuccessfulAuthResponse()
|
||||||
|
authRepo.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl)
|
||||||
|
mockServer.takeRequest()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when first load with refresh successful then result success`(): Unit = runBlocking {
|
||||||
|
mockServer.enqueueSuccessfulRecipeSummaryResponse()
|
||||||
|
val result = subject.load(REFRESH, pagingState())
|
||||||
|
assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Success::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when first load with refresh successful then recipes stored`(): Unit = runBlocking {
|
||||||
|
mockServer.enqueueSuccessfulRecipeSummaryResponse()
|
||||||
|
subject.load(REFRESH, pagingState())
|
||||||
|
val actual = mealieDb.recipeDao().queryAllRecipes()
|
||||||
|
assertThat(actual).containsExactly(CAKE_RECIPE_ENTITY, PORRIDGE_RECIPE_ENTITY)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when load state prepend then success`(): Unit = runBlocking {
|
||||||
|
val result = subject.load(PREPEND, pagingState())
|
||||||
|
assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Success::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when load state prepend then end is reached`(): Unit = runBlocking {
|
||||||
|
val result = subject.load(PREPEND, pagingState())
|
||||||
|
assertThat((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when load successful then lastRequestEnd updated`(): Unit = runBlocking {
|
||||||
|
mockServer.enqueueSuccessfulRecipeSummaryResponse()
|
||||||
|
subject.load(REFRESH, pagingState())
|
||||||
|
val actual = subject.lastRequestEnd
|
||||||
|
assertThat(actual).isEqualTo(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when load fails then lastRequestEnd still 0`(): Unit = runBlocking {
|
||||||
|
mockServer.enqueueUnsuccessfulRecipeSummaryResponse()
|
||||||
|
subject.load(REFRESH, pagingState())
|
||||||
|
val actual = subject.lastRequestEnd
|
||||||
|
assertThat(actual).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when load fails then result is error`(): Unit = runBlocking {
|
||||||
|
mockServer.enqueueUnsuccessfulRecipeSummaryResponse()
|
||||||
|
val actual = subject.load(REFRESH, pagingState())
|
||||||
|
assertThat(actual).isInstanceOf(RemoteMediator.MediatorResult.Error::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when refresh then request params correct`(): Unit = runBlocking {
|
||||||
|
mockServer.enqueueUnsuccessfulRecipeSummaryResponse()
|
||||||
|
subject.load(REFRESH, pagingState())
|
||||||
|
val actual = mockServer.takeRequest().path
|
||||||
|
assertThat(actual).isEqualTo("/api/recipes/summary?start=0&limit=6")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when append then request params correct`(): Unit = runBlocking {
|
||||||
|
mockServer.enqueueSuccessfulRecipeSummaryResponse()
|
||||||
|
subject.load(REFRESH, pagingState())
|
||||||
|
mockServer.takeRequest()
|
||||||
|
mockServer.enqueueSuccessfulRecipeSummaryResponse()
|
||||||
|
subject.load(APPEND, pagingState())
|
||||||
|
val actual = mockServer.takeRequest().path
|
||||||
|
assertThat(actual).isEqualTo("/api/recipes/summary?start=2&limit=2")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when append fails then recipes aren't removed`(): Unit = runBlocking {
|
||||||
|
mockServer.enqueueSuccessfulRecipeSummaryResponse()
|
||||||
|
subject.load(REFRESH, pagingState())
|
||||||
|
mockServer.takeRequest()
|
||||||
|
mockServer.enqueueUnsuccessfulRecipeSummaryResponse()
|
||||||
|
subject.load(APPEND, pagingState())
|
||||||
|
val actual = mealieDb.recipeDao().queryAllRecipes()
|
||||||
|
assertThat(actual).isEqualTo(TEST_RECIPE_ENTITIES)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pagingState(
|
||||||
|
pages: List<PagingSource.LoadResult.Page<Int, RecipeEntity>> = emptyList(),
|
||||||
|
anchorPosition: Int? = null
|
||||||
|
): PagingState<Int, RecipeEntity> = PagingState(
|
||||||
|
pages = pages,
|
||||||
|
anchorPosition = anchorPosition,
|
||||||
|
config = pagingConfig,
|
||||||
|
leadingPlaceholderCount = 0
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user