Fixed a bug with favourites

This commit is contained in:
2025-08-01 13:57:52 -06:00
parent 49c9a6dce1
commit 571db144c4
20 changed files with 566 additions and 429 deletions

View File

@@ -13,8 +13,8 @@ plugins {
android {
defaultConfig {
applicationId = "com.atridad.mealient"
versionCode = 37
versionName = "0.5.0"
versionCode = 38
versionName = "0.5.1"
testInstrumentationRunner = "com.atridad.mealient.MealientTestRunner"
testInstrumentationRunnerArguments += mapOf("clearPackageData" to "true")
resourceConfigurations += listOf("en", "es", "ru", "fr", "nl", "pt", "de")

Binary file not shown.

Binary file not shown.

View File

@@ -11,8 +11,8 @@
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 37,
"versionName": "0.5.0",
"versionCode": 38,
"versionName": "0.5.1",
"outputFile": "app-release.apk"
}
],

View File

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

View File

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

View File

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

View File

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