Fixed a bug with favourites
This commit is contained in:
@@ -11,9 +11,11 @@ import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
||||
import com.atridad.mealient.model_mapper.ModelMapper
|
||||
import javax.inject.Inject
|
||||
|
||||
class MealieDataSourceWrapper @Inject constructor(
|
||||
private val dataSource: MealieDataSource,
|
||||
private val modelMapper: ModelMapper,
|
||||
class MealieDataSourceWrapper
|
||||
@Inject
|
||||
constructor(
|
||||
private val dataSource: MealieDataSource,
|
||||
private val modelMapper: ModelMapper,
|
||||
) : AddRecipeDataSource, RecipeDataSource, ParseRecipeDataSource {
|
||||
|
||||
override suspend fun addRecipe(recipe: AddRecipeInfo): String {
|
||||
@@ -23,10 +25,11 @@ class MealieDataSourceWrapper @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun requestRecipes(
|
||||
start: Int,
|
||||
limit: Int,
|
||||
start: Int,
|
||||
limit: Int,
|
||||
): List<GetRecipeSummaryResponse> {
|
||||
// Imagine start is 30 and limit is 15. It means that we already have page 1 and 2, now we need page 3
|
||||
// Imagine start is 30 and limit is 15. It means that we already have page 1 and 2, now we
|
||||
// need page 3
|
||||
val page = start / limit + 1
|
||||
return dataSource.requestRecipes(page, limit)
|
||||
}
|
||||
@@ -40,11 +43,31 @@ class MealieDataSourceWrapper @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun getFavoriteRecipes(): List<String> {
|
||||
return dataSource.requestUserInfo().favoriteRecipes
|
||||
val userInfo = dataSource.requestUserInfo()
|
||||
|
||||
// Use the correct favorites endpoint that actually works
|
||||
return try {
|
||||
val favoritesResponse = dataSource.getUserFavoritesAlternative(userInfo.id)
|
||||
val favoriteRecipeIds =
|
||||
favoritesResponse.ratings.filter { it.isFavorite }.map { it.recipeId }
|
||||
|
||||
// Get all recipes to create UUID-to-slug mapping
|
||||
val allRecipes = dataSource.requestRecipes(1, -1) // Get all recipes
|
||||
val uuidToSlugMap = allRecipes.associate { it.remoteId to it.slug }
|
||||
|
||||
// Map favorite UUIDs to slugs
|
||||
val favoriteSlugs = favoriteRecipeIds.mapNotNull { uuid -> uuidToSlugMap[uuid] }
|
||||
|
||||
favoriteSlugs
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean) {
|
||||
val userId = dataSource.requestUserInfo().id
|
||||
val userInfo = dataSource.requestUserInfo()
|
||||
val userId = userInfo.id
|
||||
|
||||
if (isFavorite) {
|
||||
dataSource.addFavoriteRecipe(userId, recipeSlug)
|
||||
} else {
|
||||
@@ -55,4 +78,4 @@ class MealieDataSourceWrapper @Inject constructor(
|
||||
override suspend fun deleteRecipe(recipeSlug: String) {
|
||||
dataSource.deleteRecipe(recipeSlug)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,30 +11,34 @@ import com.atridad.mealient.database.recipe.entity.RecipeSummaryEntity
|
||||
import com.atridad.mealient.datasource.runCatchingExceptCancel
|
||||
import com.atridad.mealient.logging.Logger
|
||||
import com.atridad.mealient.model_mapper.ModelMapper
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
@Singleton
|
||||
class RecipesRemoteMediator @Inject constructor(
|
||||
private val storage: RecipeStorage,
|
||||
private val network: RecipeDataSource,
|
||||
private val pagingSourceFactory: RecipePagingSourceFactory,
|
||||
private val logger: Logger,
|
||||
private val modelMapper: ModelMapper,
|
||||
private val dispatchers: AppDispatchers,
|
||||
class RecipesRemoteMediator
|
||||
@Inject
|
||||
constructor(
|
||||
private val storage: RecipeStorage,
|
||||
private val network: RecipeDataSource,
|
||||
private val pagingSourceFactory: RecipePagingSourceFactory,
|
||||
private val logger: Logger,
|
||||
private val modelMapper: ModelMapper,
|
||||
private val dispatchers: AppDispatchers,
|
||||
) : RemoteMediator<Int, RecipeSummaryEntity>() {
|
||||
|
||||
@VisibleForTesting
|
||||
var lastRequestEnd: Int = 0
|
||||
@VisibleForTesting var lastRequestEnd: Int = 0
|
||||
|
||||
override suspend fun load(
|
||||
loadType: LoadType, state: PagingState<Int, RecipeSummaryEntity>
|
||||
loadType: LoadType,
|
||||
state: PagingState<Int, RecipeSummaryEntity>
|
||||
): MediatorResult {
|
||||
logger.v { "load() called with: lastRequestEnd = $lastRequestEnd, loadType = $loadType, state = $state" }
|
||||
logger.v {
|
||||
"load() called with: lastRequestEnd = $lastRequestEnd, loadType = $loadType, state = $state"
|
||||
}
|
||||
|
||||
if (loadType == PREPEND) {
|
||||
logger.i { "load: early exit, PREPEND isn't supported" }
|
||||
@@ -44,17 +48,17 @@ class RecipesRemoteMediator @Inject constructor(
|
||||
val start = if (loadType == REFRESH) 0 else lastRequestEnd
|
||||
val limit = if (loadType == REFRESH) state.config.initialLoadSize else state.config.pageSize
|
||||
|
||||
val count: Int = runCatchingExceptCancel {
|
||||
updateRecipes(start, limit, loadType)
|
||||
}.getOrElse {
|
||||
logger.e(it) { "load: can't load recipes" }
|
||||
return MediatorResult.Error(it)
|
||||
}
|
||||
val count: Int =
|
||||
runCatchingExceptCancel { updateRecipes(start, limit, loadType) }.getOrElse {
|
||||
logger.e(it) { "load: can't load recipes" }
|
||||
return MediatorResult.Error(it)
|
||||
}
|
||||
|
||||
// After something is inserted into DB the paging sources have to be invalidated
|
||||
// But for some reason Room/Paging library don't do it automatically
|
||||
// Here we invalidate them manually.
|
||||
// Read that trick here https://github.com/android/architecture-components-samples/issues/889#issuecomment-880847858
|
||||
// Read that trick here
|
||||
// https://github.com/android/architecture-components-samples/issues/889#issuecomment-880847858
|
||||
pagingSourceFactory.invalidate()
|
||||
|
||||
logger.d { "load: expectedCount = $limit, received $count" }
|
||||
@@ -63,25 +67,30 @@ class RecipesRemoteMediator @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun updateRecipes(
|
||||
start: Int,
|
||||
limit: Int,
|
||||
loadType: LoadType = REFRESH,
|
||||
start: Int,
|
||||
limit: Int,
|
||||
loadType: LoadType = REFRESH,
|
||||
): Int = coroutineScope {
|
||||
logger.v { "updateRecipes() called with: start = $start, limit = $limit, loadType = $loadType" }
|
||||
val deferredRecipes = async { network.requestRecipes(start, limit) }
|
||||
val favorites = runCatchingExceptCancel {
|
||||
network.getFavoriteRecipes()
|
||||
}.getOrDefault(emptyList()).toHashSet()
|
||||
val recipes = deferredRecipes.await()
|
||||
val entities = withContext(dispatchers.default) {
|
||||
recipes.map { recipe ->
|
||||
val isFavorite = favorites.contains(recipe.slug)
|
||||
modelMapper.toRecipeSummaryEntity(recipe, isFavorite)
|
||||
}
|
||||
logger.v {
|
||||
"updateRecipes() called with: start = $start, limit = $limit, loadType = $loadType"
|
||||
}
|
||||
if (loadType == REFRESH) storage.refreshAll(entities)
|
||||
else storage.saveRecipes(entities)
|
||||
val deferredRecipes = async { network.requestRecipes(start, limit) }
|
||||
val favorites =
|
||||
runCatchingExceptCancel { network.getFavoriteRecipes() }
|
||||
.getOrDefault(emptyList())
|
||||
.toHashSet()
|
||||
|
||||
val recipes = deferredRecipes.await()
|
||||
|
||||
val entities =
|
||||
withContext(dispatchers.default) {
|
||||
recipes.map { recipe ->
|
||||
val isFavorite = favorites.contains(recipe.slug)
|
||||
modelMapper.toRecipeSummaryEntity(recipe, isFavorite)
|
||||
}
|
||||
}
|
||||
|
||||
if (loadType == REFRESH) storage.refreshAll(entities) else storage.saveRecipes(entities)
|
||||
recipes.size
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@ import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import androidx.paging.map
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import com.atridad.mealient.architecture.valueUpdatesOnly
|
||||
import com.atridad.mealient.data.auth.AuthRepo
|
||||
import com.atridad.mealient.data.recipes.RecipeRepo
|
||||
import com.atridad.mealient.data.recipes.impl.RecipeImageUrlProvider
|
||||
import com.atridad.mealient.database.recipe.entity.RecipeSummaryEntity
|
||||
import com.atridad.mealient.logging.Logger
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -23,44 +24,48 @@ import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
internal class RecipesListViewModel @Inject constructor(
|
||||
private val recipeRepo: RecipeRepo,
|
||||
private val logger: Logger,
|
||||
private val recipeImageUrlProvider: RecipeImageUrlProvider,
|
||||
authRepo: AuthRepo,
|
||||
internal class RecipesListViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val recipeRepo: RecipeRepo,
|
||||
private val logger: Logger,
|
||||
private val recipeImageUrlProvider: RecipeImageUrlProvider,
|
||||
authRepo: AuthRepo,
|
||||
) : ViewModel() {
|
||||
|
||||
private val pagingData: Flow<PagingData<RecipeSummaryEntity>> =
|
||||
recipeRepo.createPager().flow.cachedIn(viewModelScope)
|
||||
recipeRepo.createPager().flow.cachedIn(viewModelScope)
|
||||
|
||||
private val showFavoriteIcon: StateFlow<Boolean> =
|
||||
authRepo.isAuthorizedFlow.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
authRepo.isAuthorizedFlow.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
private val pagingDataRecipeState: Flow<PagingData<RecipeListItemState>> =
|
||||
pagingData.combine(showFavoriteIcon) { data, showFavorite ->
|
||||
data.map { item ->
|
||||
val imageUrl = recipeImageUrlProvider.generateImageUrl(item.imageId)
|
||||
RecipeListItemState(
|
||||
imageUrl = imageUrl,
|
||||
showFavoriteIcon = showFavorite,
|
||||
entity = item,
|
||||
)
|
||||
pagingData.combine(showFavoriteIcon) { data, showFavorite ->
|
||||
data.map { item ->
|
||||
val imageUrl = recipeImageUrlProvider.generateImageUrl(item.imageId)
|
||||
RecipeListItemState(
|
||||
imageUrl = imageUrl,
|
||||
showFavoriteIcon = showFavorite,
|
||||
entity = item,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val _screenState = MutableStateFlow(
|
||||
RecipeListState(pagingDataRecipeState = pagingDataRecipeState)
|
||||
)
|
||||
val screenState: StateFlow<RecipeListState> get() = _screenState.asStateFlow()
|
||||
private val _screenState =
|
||||
MutableStateFlow(RecipeListState(pagingDataRecipeState = pagingDataRecipeState))
|
||||
val screenState: StateFlow<RecipeListState>
|
||||
get() = _screenState.asStateFlow()
|
||||
|
||||
init {
|
||||
authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized ->
|
||||
logger.v { "Authorization state changed to $hasAuthorized" }
|
||||
if (hasAuthorized) recipeRepo.refreshRecipes()
|
||||
}.launchIn(viewModelScope)
|
||||
authRepo.isAuthorizedFlow
|
||||
.valueUpdatesOnly()
|
||||
.onEach { hasAuthorized ->
|
||||
logger.v { "Authorization state changed to $hasAuthorized" }
|
||||
if (hasAuthorized) recipeRepo.refreshRecipes()
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun onRecipeClicked(entity: RecipeSummaryEntity) {
|
||||
@@ -75,23 +80,23 @@ internal class RecipesListViewModel @Inject constructor(
|
||||
private fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) {
|
||||
logger.v { "onFavoriteIconClick() called with: recipeSummaryEntity = $recipeSummaryEntity" }
|
||||
viewModelScope.launch {
|
||||
val result = recipeRepo.updateIsRecipeFavorite(
|
||||
recipeSlug = recipeSummaryEntity.slug,
|
||||
isFavorite = recipeSummaryEntity.isFavorite.not(),
|
||||
)
|
||||
val snackbar = result.fold(
|
||||
onSuccess = { isFavorite ->
|
||||
val name = recipeSummaryEntity.name
|
||||
if (isFavorite) {
|
||||
RecipeListSnackbar.FavoriteAdded(name)
|
||||
} else {
|
||||
RecipeListSnackbar.FavoriteRemoved(name)
|
||||
}
|
||||
},
|
||||
onFailure = {
|
||||
RecipeListSnackbar.FavoriteUpdateFailed
|
||||
}
|
||||
)
|
||||
val result =
|
||||
recipeRepo.updateIsRecipeFavorite(
|
||||
recipeSlug = recipeSummaryEntity.slug,
|
||||
isFavorite = recipeSummaryEntity.isFavorite.not(),
|
||||
)
|
||||
val snackbar =
|
||||
result.fold(
|
||||
onSuccess = { _ ->
|
||||
val name = recipeSummaryEntity.name
|
||||
if (recipeSummaryEntity.isFavorite) {
|
||||
RecipeListSnackbar.FavoriteRemoved(name)
|
||||
} else {
|
||||
RecipeListSnackbar.FavoriteAdded(name)
|
||||
}
|
||||
},
|
||||
onFailure = { RecipeListSnackbar.FavoriteUpdateFailed }
|
||||
)
|
||||
_screenState.update { it.copy(snackbarState = snackbar) }
|
||||
}
|
||||
}
|
||||
@@ -101,10 +106,11 @@ internal class RecipesListViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
val result = recipeRepo.deleteRecipe(recipeSummaryEntity)
|
||||
logger.d { "onDeleteConfirm: delete result is $result" }
|
||||
val snackbar = result.fold(
|
||||
onSuccess = { null },
|
||||
onFailure = { RecipeListSnackbar.DeleteFailed },
|
||||
)
|
||||
val snackbar =
|
||||
result.fold(
|
||||
onSuccess = { null },
|
||||
onFailure = { RecipeListSnackbar.DeleteFailed },
|
||||
)
|
||||
_screenState.update { it.copy(snackbarState = snackbar) }
|
||||
}
|
||||
}
|
||||
@@ -138,10 +144,10 @@ internal class RecipesListViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
internal data class RecipeListState(
|
||||
val pagingDataRecipeState: Flow<PagingData<RecipeListItemState>>,
|
||||
val snackbarState: RecipeListSnackbar? = null,
|
||||
val recipeIdToOpen: String? = null,
|
||||
val searchQuery: String = "",
|
||||
val pagingDataRecipeState: Flow<PagingData<RecipeListItemState>>,
|
||||
val snackbarState: RecipeListSnackbar? = null,
|
||||
val recipeIdToOpen: String? = null,
|
||||
val searchQuery: String = "",
|
||||
)
|
||||
|
||||
internal sealed interface RecipeListEvent {
|
||||
@@ -157,4 +163,4 @@ internal sealed interface RecipeListEvent {
|
||||
data object SnackbarShown : RecipeListEvent
|
||||
|
||||
data class SearchQueryChanged(val query: String) : RecipeListEvent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,11 @@ internal fun SearchTextField(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
errorIndicatorColor = Color.Transparent
|
||||
errorIndicatorColor = Color.Transparent,
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
errorContainerColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -69,4 +73,4 @@ private fun SearchTextFieldPreview() {
|
||||
placeholder = R.string.search_recipes_hint,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user