From aefb974cb3462950777483629a4de585262e36e5 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Mon, 12 Dec 2022 21:26:57 +0100 Subject: [PATCH 01/16] Save isFavorite flag to DB for recipes --- .../data/configuration/AppDispatchers.kt | 11 + .../data/configuration/AppDispatchersImpl.kt | 17 ++ .../data/network/MealieDataSourceWrapper.kt | 4 + .../mealient/data/recipes/db/RecipeStorage.kt | 5 +- .../data/recipes/db/RecipeStorageImpl.kt | 10 +- .../data/recipes/impl/RecipeRepoImpl.kt | 2 +- .../recipes/impl/RecipesRemoteMediator.kt | 33 ++- .../data/recipes/network/RecipeDataSource.kt | 2 + .../mealient/di/ArchitectureModule.kt | 6 + .../mealient/extensions/ModelMappings.kt | 3 +- .../8.json | 198 ++++++++++++++++++ .../gq/kirmanak/mealient/database/AppDb.kt | 3 +- .../recipe/entity/RecipeSummaryEntity.kt | 1 + .../datasource/NetworkRequestWrapper.kt | 4 +- .../impl/NetworkRequestWrapperImpl.kt | 32 ++- .../datasource/v0/MealieDataSourceV0.kt | 6 +- .../datasource/v0/MealieDataSourceV0Impl.kt | 9 +- .../mealient/datasource/v0/MealieServiceV0.kt | 3 + .../v0/models/GetUserInfoResponseV0.kt | 9 + .../datasource/v1/MealieDataSourceV1.kt | 3 + .../datasource/v1/MealieDataSourceV1Impl.kt | 9 +- .../mealient/datasource/v1/MealieServiceV1.kt | 3 + .../v1/models/GetUserInfoResponseV1.kt | 9 + 23 files changed, 352 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/gq/kirmanak/mealient/data/configuration/AppDispatchers.kt create mode 100644 app/src/main/java/gq/kirmanak/mealient/data/configuration/AppDispatchersImpl.kt create mode 100644 database/schemas/gq.kirmanak.mealient.database.AppDb/8.json create mode 100644 datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/models/GetUserInfoResponseV0.kt create mode 100644 datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetUserInfoResponseV1.kt diff --git a/app/src/main/java/gq/kirmanak/mealient/data/configuration/AppDispatchers.kt b/app/src/main/java/gq/kirmanak/mealient/data/configuration/AppDispatchers.kt new file mode 100644 index 0000000..9b3d91a --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/configuration/AppDispatchers.kt @@ -0,0 +1,11 @@ +package gq.kirmanak.mealient.data.configuration + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.MainCoroutineDispatcher + +interface AppDispatchers { + val io: CoroutineDispatcher + val main: MainCoroutineDispatcher + val default: CoroutineDispatcher + val unconfined: CoroutineDispatcher +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/configuration/AppDispatchersImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/configuration/AppDispatchersImpl.kt new file mode 100644 index 0000000..e7e249d --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/configuration/AppDispatchersImpl.kt @@ -0,0 +1,17 @@ +package gq.kirmanak.mealient.data.configuration + +import kotlinx.coroutines.Dispatchers +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AppDispatchersImpl @Inject constructor() : AppDispatchers { + + override val io = Dispatchers.IO + + override val main = Dispatchers.Main + + override val default = Dispatchers.Default + + override val unconfined = Dispatchers.Unconfined +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapper.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapper.kt index 3731ca9..92758cc 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapper.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapper.kt @@ -64,4 +64,8 @@ class MealieDataSourceWrapper @Inject constructor( ServerVersion.V1 -> v1Source.parseRecipeFromURL(parseRecipeURLInfo.toV1Request()) } + override suspend fun getFavoriteRecipes(): List = when (getVersion()) { + ServerVersion.V0 -> v0Source.requestUserInfo().favoriteRecipes + ServerVersion.V1 -> v1Source.requestUserInfo().favoriteRecipes + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorage.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorage.kt index 324282d..737c417 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorage.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorage.kt @@ -2,16 +2,15 @@ package gq.kirmanak.mealient.data.recipes.db import androidx.paging.PagingSource import gq.kirmanak.mealient.data.recipes.network.FullRecipeInfo -import gq.kirmanak.mealient.data.recipes.network.RecipeSummaryInfo import gq.kirmanak.mealient.database.recipe.entity.FullRecipeEntity import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity interface RecipeStorage { - suspend fun saveRecipes(recipes: List) + suspend fun saveRecipes(recipes: List) fun queryRecipes(query: String?): PagingSource - suspend fun refreshAll(recipes: List) + suspend fun refreshAll(recipes: List) suspend fun clearAllLocalData() diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImpl.kt index 2b25783..9219e34 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImpl.kt @@ -3,7 +3,6 @@ package gq.kirmanak.mealient.data.recipes.db import androidx.paging.PagingSource import androidx.room.withTransaction import gq.kirmanak.mealient.data.recipes.network.FullRecipeInfo -import gq.kirmanak.mealient.data.recipes.network.RecipeSummaryInfo import gq.kirmanak.mealient.database.AppDb import gq.kirmanak.mealient.database.recipe.RecipeDao import gq.kirmanak.mealient.database.recipe.entity.FullRecipeEntity @@ -11,7 +10,6 @@ import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.extensions.toRecipeEntity import gq.kirmanak.mealient.extensions.toRecipeIngredientEntity import gq.kirmanak.mealient.extensions.toRecipeInstructionEntity -import gq.kirmanak.mealient.extensions.toRecipeSummaryEntity import gq.kirmanak.mealient.logging.Logger import javax.inject.Inject import javax.inject.Singleton @@ -23,11 +21,9 @@ class RecipeStorageImpl @Inject constructor( ) : RecipeStorage { private val recipeDao: RecipeDao by lazy { db.recipeDao() } - override suspend fun saveRecipes(recipes: List) { + override suspend fun saveRecipes(recipes: List) { logger.v { "saveRecipes() called with $recipes" } - val entities = recipes.map { it.toRecipeSummaryEntity() } - logger.v { "saveRecipes: entities = $entities" } - db.withTransaction { recipeDao.insertRecipes(entities) } + db.withTransaction { recipeDao.insertRecipes(recipes) } } override fun queryRecipes(query: String?): PagingSource { @@ -36,7 +32,7 @@ class RecipeStorageImpl @Inject constructor( else recipeDao.queryRecipesByPages(query) } - override suspend fun refreshAll(recipes: List) { + override suspend fun refreshAll(recipes: List) { logger.v { "refreshAll() called with: recipes = $recipes" } db.withTransaction { recipeDao.removeAllRecipes() diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt index 542cd18..80840bc 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt @@ -66,7 +66,7 @@ class RecipeRepoImpl @Inject constructor( override suspend fun refreshRecipes() { logger.v { "refreshRecipes() called" } runCatchingExceptCancel { - storage.refreshAll(dataSource.requestRecipes(0, INITIAL_LOAD_PAGE_SIZE)) + mediator.updateRecipes(0, INITIAL_LOAD_PAGE_SIZE) }.onFailure { logger.e(it) { "Can't refresh recipes" } } diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt index 54915d5..7c8d577 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt @@ -4,11 +4,16 @@ import androidx.annotation.VisibleForTesting import androidx.paging.* import androidx.paging.LoadType.PREPEND import androidx.paging.LoadType.REFRESH +import gq.kirmanak.mealient.data.configuration.AppDispatchers import gq.kirmanak.mealient.data.recipes.db.RecipeStorage import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.datasource.runCatchingExceptCancel +import gq.kirmanak.mealient.extensions.toRecipeSummaryEntity import gq.kirmanak.mealient.logging.Logger +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton @@ -19,14 +24,14 @@ class RecipesRemoteMediator @Inject constructor( private val network: RecipeDataSource, private val pagingSourceFactory: RecipePagingSourceFactory, private val logger: Logger, + private val dispatchers: AppDispatchers, ) : RemoteMediator() { @VisibleForTesting var lastRequestEnd: Int = 0 override suspend fun load( - loadType: LoadType, - state: PagingState + loadType: LoadType, state: PagingState ): MediatorResult { logger.v { "load() called with: lastRequestEnd = $lastRequestEnd, loadType = $loadType, state = $state" } @@ -39,10 +44,7 @@ class RecipesRemoteMediator @Inject constructor( val limit = if (loadType == REFRESH) state.config.initialLoadSize else state.config.pageSize val count: Int = runCatchingExceptCancel { - val recipes = network.requestRecipes(start, limit) - if (loadType == REFRESH) storage.refreshAll(recipes) - else storage.saveRecipes(recipes) - recipes.size + updateRecipes(start, limit, loadType) }.getOrElse { logger.e(it) { "load: can't load recipes" } return MediatorResult.Error(it) @@ -58,4 +60,23 @@ class RecipesRemoteMediator @Inject constructor( lastRequestEnd = start + count return MediatorResult.Success(endOfPaginationReached = count < limit) } + + suspend fun updateRecipes( + start: Int, + limit: Int, + loadType: LoadType = REFRESH, + ): Int = coroutineScope { + val deferredRecipes = async { network.requestRecipes(start, limit) } + val favorites = network.getFavoriteRecipes().toHashSet() + val recipes = deferredRecipes.await() + val entities = withContext(dispatchers.default) { + recipes.map { recipe -> + val isFavorite = favorites.contains(recipe.slug) + recipe.toRecipeSummaryEntity(isFavorite) + } + } + if (loadType == REFRESH) storage.refreshAll(entities) + else storage.saveRecipes(entities) + recipes.size + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSource.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSource.kt index 4305eb5..db6f42c 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSource.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSource.kt @@ -4,4 +4,6 @@ interface RecipeDataSource { suspend fun requestRecipes(start: Int, limit: Int): List suspend fun requestRecipeInfo(slug: String): FullRecipeInfo + + suspend fun getFavoriteRecipes(): List } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/di/ArchitectureModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/ArchitectureModule.kt index c322301..11e2378 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/ArchitectureModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/ArchitectureModule.kt @@ -5,6 +5,8 @@ import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import gq.kirmanak.mealient.architecture.configuration.BuildConfiguration +import gq.kirmanak.mealient.data.configuration.AppDispatchers +import gq.kirmanak.mealient.data.configuration.AppDispatchersImpl import gq.kirmanak.mealient.data.configuration.BuildConfigurationImpl import javax.inject.Singleton @@ -15,4 +17,8 @@ interface ArchitectureModule { @Binds @Singleton fun bindBuildConfiguration(buildConfigurationImpl: BuildConfigurationImpl): BuildConfiguration + + @Binds + @Singleton + fun bindAppDispatchers(appDispatchersImpl: AppDispatchersImpl): AppDispatchers } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/extensions/ModelMappings.kt b/app/src/main/java/gq/kirmanak/mealient/extensions/ModelMappings.kt index 2f8263c..13eed03 100644 --- a/app/src/main/java/gq/kirmanak/mealient/extensions/ModelMappings.kt +++ b/app/src/main/java/gq/kirmanak/mealient/extensions/ModelMappings.kt @@ -80,7 +80,7 @@ fun GetRecipeSummaryResponseV1.toRecipeSummaryInfo() = RecipeSummaryInfo( imageId = remoteId, ) -fun RecipeSummaryInfo.toRecipeSummaryEntity() = RecipeSummaryEntity( +fun RecipeSummaryInfo.toRecipeSummaryEntity(isFavorite: Boolean) = RecipeSummaryEntity( remoteId = remoteId, name = name, slug = slug, @@ -88,6 +88,7 @@ fun RecipeSummaryInfo.toRecipeSummaryEntity() = RecipeSummaryEntity( dateAdded = dateAdded, dateUpdated = dateUpdated, imageId = imageId, + isFavorite = isFavorite, ) fun VersionResponseV0.toVersionInfo() = VersionInfo(version) diff --git a/database/schemas/gq.kirmanak.mealient.database.AppDb/8.json b/database/schemas/gq.kirmanak.mealient.database.AppDb/8.json new file mode 100644 index 0000000..735d017 --- /dev/null +++ b/database/schemas/gq.kirmanak.mealient.database.AppDb/8.json @@ -0,0 +1,198 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "793673e401425db36544918dae6bf4c1", + "entities": [ + { + "tableName": "recipe_summaries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remote_id` TEXT NOT NULL, `name` TEXT NOT NULL, `slug` TEXT NOT NULL, `description` TEXT NOT NULL, `date_added` INTEGER NOT NULL, `date_updated` INTEGER NOT NULL, `image_id` TEXT, `is_favorite` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`remote_id`))", + "fields": [ + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "slug", + "columnName": "slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dateAdded", + "columnName": "date_added", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dateUpdated", + "columnName": "date_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "imageId", + "columnName": "image_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "columnNames": [ + "remote_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "recipe", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remote_id` TEXT NOT NULL, `recipe_yield` TEXT NOT NULL, `disable_amounts` INTEGER NOT NULL DEFAULT true, PRIMARY KEY(`remote_id`))", + "fields": [ + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipeYield", + "columnName": "recipe_yield", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disableAmounts", + "columnName": "disable_amounts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + } + ], + "primaryKey": { + "columnNames": [ + "remote_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "recipe_ingredient", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `recipe_id` TEXT NOT NULL, `note` TEXT NOT NULL, `food` TEXT, `unit` TEXT, `quantity` REAL, `title` TEXT)", + "fields": [ + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recipeId", + "columnName": "recipe_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "food", + "columnName": "food", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unit", + "columnName": "unit", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quantity", + "columnName": "quantity", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "local_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "recipe_instruction", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `recipe_id` TEXT NOT NULL, `text` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recipeId", + "columnName": "recipe_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "local_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '793673e401425db36544918dae6bf4c1')" + ] + } +} \ No newline at end of file diff --git a/database/src/main/kotlin/gq/kirmanak/mealient/database/AppDb.kt b/database/src/main/kotlin/gq/kirmanak/mealient/database/AppDb.kt index e801675..635e776 100644 --- a/database/src/main/kotlin/gq/kirmanak/mealient/database/AppDb.kt +++ b/database/src/main/kotlin/gq/kirmanak/mealient/database/AppDb.kt @@ -6,7 +6,7 @@ import gq.kirmanak.mealient.database.recipe.RecipeDao import gq.kirmanak.mealient.database.recipe.entity.* @Database( - version = 7, + version = 8, entities = [ RecipeSummaryEntity::class, RecipeEntity::class, @@ -20,6 +20,7 @@ import gq.kirmanak.mealient.database.recipe.entity.* AutoMigration(from = 4, to = 5, spec = AppDb.From4To5Migration::class), AutoMigration(from = 5, to = 6, spec = AppDb.From5To6Migration::class), AutoMigration(from = 6, to = 7), + AutoMigration(from = 7, to = 8), ] ) @TypeConverters(RoomTypeConverters::class) diff --git a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeSummaryEntity.kt b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeSummaryEntity.kt index afbc99d..cf54bfa 100644 --- a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeSummaryEntity.kt +++ b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/entity/RecipeSummaryEntity.kt @@ -15,4 +15,5 @@ data class RecipeSummaryEntity( @ColumnInfo(name = "date_added") val dateAdded: LocalDate, @ColumnInfo(name = "date_updated") val dateUpdated: LocalDateTime, @ColumnInfo(name = "image_id") val imageId: String?, + @ColumnInfo(name = "is_favorite", defaultValue = "false") val isFavorite: Boolean, ) \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/NetworkRequestWrapper.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/NetworkRequestWrapper.kt index 8a8dd01..aaf9323 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/NetworkRequestWrapper.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/NetworkRequestWrapper.kt @@ -5,13 +5,13 @@ interface NetworkRequestWrapper { suspend fun makeCall( block: suspend () -> T, logMethod: () -> String, - logParameters: () -> String, + logParameters: (() -> String)? = null, ): Result suspend fun makeCallAndHandleUnauthorized( block: suspend () -> T, logMethod: () -> String, - logParameters: () -> String, + logParameters: (() -> String)? = null, ): T } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/NetworkRequestWrapperImpl.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/NetworkRequestWrapperImpl.kt index 6c2b5b2..c19f01b 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/NetworkRequestWrapperImpl.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/impl/NetworkRequestWrapperImpl.kt @@ -16,18 +16,40 @@ class NetworkRequestWrapperImpl @Inject constructor( override suspend fun makeCall( block: suspend () -> T, logMethod: () -> String, - logParameters: () -> String, + logParameters: (() -> String)?, ): Result { - logger.v { "${logMethod()} called with: ${logParameters()}" } + logger.v { + if (logParameters == null) { + "${logMethod()} called" + } else { + "${logMethod()} called with: ${logParameters()}" + } + } return runCatchingExceptCancel { block() } - .onFailure { logger.e(it) { "${logMethod()} request failed with: ${logParameters()}" } } - .onSuccess { logger.d { "${logMethod()} request succeeded with ${logParameters()}, result = $it" } } + .onFailure { + logger.e(it) { + if (logParameters == null) { + "${logMethod()} request failed" + } else { + "${logMethod()} request failed with: ${logParameters()}" + } + } + } + .onSuccess { + logger.d { + if (logParameters == null) { + "${logMethod()} request succeeded, result = $it" + } else { + "${logMethod()} request succeeded with: ${logParameters()}, result = $it" + } + } + } } override suspend fun makeCallAndHandleUnauthorized( block: suspend () -> T, logMethod: () -> String, - logParameters: () -> String + logParameters: (() -> String)? ): T = makeCall(block, logMethod, logParameters).getOrElse { throw if (it is HttpException && it.code() in listOf(401, 403)) { NetworkError.Unauthorized(it) diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0.kt index 01aeac1..a1724d1 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0.kt @@ -4,6 +4,7 @@ import gq.kirmanak.mealient.datasource.v0.models.AddRecipeRequestV0 import gq.kirmanak.mealient.datasource.v0.models.CreateApiTokenRequestV0 import gq.kirmanak.mealient.datasource.v0.models.GetRecipeResponseV0 import gq.kirmanak.mealient.datasource.v0.models.GetRecipeSummaryResponseV0 +import gq.kirmanak.mealient.datasource.v0.models.GetUserInfoResponseV0 import gq.kirmanak.mealient.datasource.v0.models.ParseRecipeURLRequestV0 import gq.kirmanak.mealient.datasource.v0.models.VersionResponseV0 @@ -21,8 +22,7 @@ interface MealieDataSourceV0 { password: String, ): String - suspend fun getVersionInfo( - ): VersionResponseV0 + suspend fun getVersionInfo(): VersionResponseV0 suspend fun requestRecipes( start: Int, @@ -40,4 +40,6 @@ interface MealieDataSourceV0 { suspend fun createApiToken( request: CreateApiTokenRequestV0, ): String + + suspend fun requestUserInfo(): GetUserInfoResponseV0 } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0Impl.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0Impl.kt index 7d145be..4c0ea0b 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0Impl.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0Impl.kt @@ -8,6 +8,7 @@ import gq.kirmanak.mealient.datasource.v0.models.CreateApiTokenRequestV0 import gq.kirmanak.mealient.datasource.v0.models.ErrorDetailV0 import gq.kirmanak.mealient.datasource.v0.models.GetRecipeResponseV0 import gq.kirmanak.mealient.datasource.v0.models.GetRecipeSummaryResponseV0 +import gq.kirmanak.mealient.datasource.v0.models.GetUserInfoResponseV0 import gq.kirmanak.mealient.datasource.v0.models.ParseRecipeURLRequestV0 import gq.kirmanak.mealient.datasource.v0.models.VersionResponseV0 import kotlinx.serialization.SerializationException @@ -49,7 +50,6 @@ class MealieDataSourceV0Impl @Inject constructor( override suspend fun getVersionInfo(): VersionResponseV0 = networkRequestWrapper.makeCall( block = { service.getVersion() }, logMethod = { "getVersionInfo" }, - logParameters = { "" }, ).getOrElse { throw when (it) { is HttpException, is SerializationException -> NetworkError.NotMealie(it) @@ -90,4 +90,11 @@ class MealieDataSourceV0Impl @Inject constructor( logMethod = { "createApiToken" }, logParameters = { "request = $request" } ) + + override suspend fun requestUserInfo(): GetUserInfoResponseV0 { + return networkRequestWrapper.makeCallAndHandleUnauthorized( + block = { service.getUserSelfInfo() }, + logMethod = { "requestUserInfo" }, + ) + } } diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieServiceV0.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieServiceV0.kt index a5626f0..5c139d3 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieServiceV0.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieServiceV0.kt @@ -40,4 +40,7 @@ interface MealieServiceV0 { suspend fun createApiToken( @Body request: CreateApiTokenRequestV0, ): String + + @GET("/api/users/self") + suspend fun getUserSelfInfo(): GetUserInfoResponseV0 } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/models/GetUserInfoResponseV0.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/models/GetUserInfoResponseV0.kt new file mode 100644 index 0000000..6110e2e --- /dev/null +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/models/GetUserInfoResponseV0.kt @@ -0,0 +1,9 @@ +package gq.kirmanak.mealient.datasource.v0.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GetUserInfoResponseV0( + @SerialName("favoriteRecipes") val favoriteRecipes: List = emptyList(), +) diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1.kt index 332000e..d9b6a57 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1.kt @@ -5,6 +5,7 @@ import gq.kirmanak.mealient.datasource.v1.models.CreateApiTokenResponseV1 import gq.kirmanak.mealient.datasource.v1.models.CreateRecipeRequestV1 import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1 import gq.kirmanak.mealient.datasource.v1.models.GetRecipeSummaryResponseV1 +import gq.kirmanak.mealient.datasource.v1.models.GetUserInfoResponseV1 import gq.kirmanak.mealient.datasource.v1.models.ParseRecipeURLRequestV1 import gq.kirmanak.mealient.datasource.v1.models.UpdateRecipeRequestV1 import gq.kirmanak.mealient.datasource.v1.models.VersionResponseV1 @@ -47,4 +48,6 @@ interface MealieDataSourceV1 { suspend fun createApiToken( request: CreateApiTokenRequestV1, ): CreateApiTokenResponseV1 + + suspend fun requestUserInfo(): GetUserInfoResponseV1 } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1Impl.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1Impl.kt index 18105f0..be92d74 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1Impl.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1Impl.kt @@ -9,6 +9,7 @@ import gq.kirmanak.mealient.datasource.v1.models.CreateRecipeRequestV1 import gq.kirmanak.mealient.datasource.v1.models.ErrorDetailV1 import gq.kirmanak.mealient.datasource.v1.models.GetRecipeResponseV1 import gq.kirmanak.mealient.datasource.v1.models.GetRecipeSummaryResponseV1 +import gq.kirmanak.mealient.datasource.v1.models.GetUserInfoResponseV1 import gq.kirmanak.mealient.datasource.v1.models.ParseRecipeURLRequestV1 import gq.kirmanak.mealient.datasource.v1.models.UpdateRecipeRequestV1 import gq.kirmanak.mealient.datasource.v1.models.VersionResponseV1 @@ -60,7 +61,6 @@ class MealieDataSourceV1Impl @Inject constructor( override suspend fun getVersionInfo(): VersionResponseV1 = networkRequestWrapper.makeCall( block = { service.getVersion() }, logMethod = { "getVersionInfo" }, - logParameters = { "" }, ).getOrElse { throw when (it) { is HttpException, is SerializationException -> NetworkError.NotMealie(it) @@ -101,5 +101,12 @@ class MealieDataSourceV1Impl @Inject constructor( logMethod = { "createApiToken" }, logParameters = { "request = $request" } ) + + override suspend fun requestUserInfo(): GetUserInfoResponseV1 { + return networkRequestWrapper.makeCallAndHandleUnauthorized( + block = { service.getUserSelfInfo() }, + logMethod = { "requestUserInfo" }, + ) + } } diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieServiceV1.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieServiceV1.kt index a91ef39..5e9e6f5 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieServiceV1.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieServiceV1.kt @@ -46,4 +46,7 @@ interface MealieServiceV1 { suspend fun createApiToken( @Body request: CreateApiTokenRequestV1, ): CreateApiTokenResponseV1 + + @GET("/api/users/self") + suspend fun getUserSelfInfo(): GetUserInfoResponseV1 } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetUserInfoResponseV1.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetUserInfoResponseV1.kt new file mode 100644 index 0000000..4fda4f4 --- /dev/null +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetUserInfoResponseV1.kt @@ -0,0 +1,9 @@ +package gq.kirmanak.mealient.datasource.v1.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GetUserInfoResponseV1( + @SerialName("favoriteRecipes") val favoriteRecipes: List = emptyList(), +) From 3eb99206e8bea63a3f683d444f4c0423fa92221b Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Tue, 13 Dec 2022 19:31:53 +0100 Subject: [PATCH 02/16] Display favorite icon on recipe holders --- .../mealient/ui/recipes/RecipeViewHolder.kt | 20 +++++++++++++++++++ .../main/res/drawable/ic_favorite_filled.xml | 10 ++++++++++ .../res/drawable/ic_favorite_unfilled.xml | 10 ++++++++++ .../main/res/layout/view_holder_recipe.xml | 20 +++++++++++++------ app/src/main/res/values/strings.xml | 2 ++ 5 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 app/src/main/res/drawable/ic_favorite_filled.xml create mode 100644 app/src/main/res/drawable/ic_favorite_unfilled.xml diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt index 898bf5b..41f4dc4 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt @@ -1,5 +1,7 @@ package gq.kirmanak.mealient.ui.recipes +import android.view.View +import androidx.annotation.StringRes import androidx.recyclerview.widget.RecyclerView import gq.kirmanak.mealient.R import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity @@ -43,6 +45,24 @@ class RecipeViewHolder private constructor( logger.d { "bind: item clicked $entity" } clickListener(entity) } + binding.favoriteIcon.setImageResource( + if (item.isFavorite) { + R.drawable.ic_favorite_filled + } else { + R.drawable.ic_favorite_unfilled + } + ) + binding.favoriteIcon.setContentDescription( + if (item.isFavorite) { + R.string.view_holder_recipe_favorite_content_description + } else { + R.string.view_holder_recipe_non_favorite_content_description + } + ) } } +} + +private fun View.setContentDescription(@StringRes resId: Int) { + contentDescription = context.getString(resId) } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_favorite_filled.xml b/app/src/main/res/drawable/ic_favorite_filled.xml new file mode 100644 index 0000000..3dcfcb6 --- /dev/null +++ b/app/src/main/res/drawable/ic_favorite_filled.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_favorite_unfilled.xml b/app/src/main/res/drawable/ic_favorite_unfilled.xml new file mode 100644 index 0000000..3fbbab7 --- /dev/null +++ b/app/src/main/res/drawable/ic_favorite_unfilled.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/view_holder_recipe.xml b/app/src/main/res/layout/view_holder_recipe.xml index a4152b5..9749daa 100644 --- a/app/src/main/res/layout/view_holder_recipe.xml +++ b/app/src/main/res/layout/view_holder_recipe.xml @@ -17,8 +17,7 @@ android:id="@+id/name" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginTop="9dp" - android:layout_marginBottom="5dp" + android:layout_marginVertical="@dimen/margin_small" android:ellipsize="end" android:maxLines="1" android:textAppearance="?textAppearanceHeadline6" @@ -32,18 +31,27 @@ android:id="@+id/image" android:layout_width="0dp" android:layout_height="0dp" - android:layout_marginStart="15dp" - android:layout_marginTop="20dp" - android:layout_marginEnd="13dp" + android:layout_marginHorizontal="@dimen/margin_medium" android:contentDescription="@string/content_description_view_holder_recipe_image" android:scaleType="centerCrop" app:layout_constraintBottom_toTopOf="@+id/name" app:layout_constraintDimensionRatio="2:1" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" + app:layout_constraintTop_toBottomOf="@id/favorite_icon" app:layout_constraintVertical_chainStyle="packed" app:shapeAppearance="?shapeAppearanceCornerMedium" tools:srcCompat="@drawable/placeholder_recipe" /> + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c0e1fcf..0ad1898 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -57,4 +57,6 @@ Recipe saved successfully. Something went wrong. Progress indicator + Item is favorite + Item is not favorite \ No newline at end of file From 2fa43f57b7448f547b5890c764eda7e748e312ab Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Tue, 13 Dec 2022 20:14:16 +0100 Subject: [PATCH 03/16] Update favorite status on icon click --- .../data/network/MealieDataSourceWrapper.kt | 22 ++++++++++++++ .../mealient/data/recipes/RecipeRepo.kt | 4 +++ .../data/recipes/impl/RecipeRepoImpl.kt | 18 ++++++++++++ .../data/recipes/network/RecipeDataSource.kt | 4 +++ .../mealient/ui/recipes/RecipeViewHolder.kt | 29 +++++++++++++++---- .../ui/recipes/RecipesListFragment.kt | 11 ++++++- .../ui/recipes/RecipesListViewModel.kt | 13 +++++++++ .../ui/recipes/RecipesPagingAdapter.kt | 20 ++++--------- .../datasource/v0/MealieDataSourceV0.kt | 4 +++ .../datasource/v0/MealieDataSourceV0Impl.kt | 18 ++++++++++++ .../mealient/datasource/v0/MealieServiceV0.kt | 12 ++++++++ .../v0/models/GetUserInfoResponseV0.kt | 1 + .../datasource/v1/MealieDataSourceV1.kt | 4 +++ .../datasource/v1/MealieDataSourceV1Impl.kt | 18 ++++++++++++ .../mealient/datasource/v1/MealieServiceV1.kt | 12 ++++++++ .../v1/models/GetUserInfoResponseV1.kt | 1 + 16 files changed, 170 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapper.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapper.kt index 92758cc..1e4d118 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapper.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapper.kt @@ -68,4 +68,26 @@ class MealieDataSourceWrapper @Inject constructor( ServerVersion.V0 -> v0Source.requestUserInfo().favoriteRecipes ServerVersion.V1 -> v1Source.requestUserInfo().favoriteRecipes } + + override suspend fun removeFavoriteRecipe(recipeSlug: String) = when (getVersion()) { + ServerVersion.V0 -> { + val userId = v0Source.requestUserInfo().id + v0Source.removeFavoriteRecipe(userId, recipeSlug) + } + ServerVersion.V1 -> { + val userId = v1Source.requestUserInfo().id + v1Source.removeFavoriteRecipe(userId, recipeSlug) + } + } + + override suspend fun addFavoriteRecipe(recipeSlug: String) = when (getVersion()) { + ServerVersion.V0 -> { + val userId = v0Source.requestUserInfo().id + v0Source.addFavoriteRecipe(userId, recipeSlug) + } + ServerVersion.V1 -> { + val userId = v1Source.requestUserInfo().id + v1Source.addFavoriteRecipe(userId, recipeSlug) + } + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/RecipeRepo.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/RecipeRepo.kt index b7c1266..c1e3074 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/RecipeRepo.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/RecipeRepo.kt @@ -17,4 +17,8 @@ interface RecipeRepo { fun updateNameQuery(name: String?) suspend fun refreshRecipes() + + suspend fun removeFavoriteRecipe(recipeSlug: String) + + suspend fun addFavoriteRecipe(recipeSlug: String) } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt index 80840bc..62184eb 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt @@ -72,6 +72,24 @@ class RecipeRepoImpl @Inject constructor( } } + override suspend fun removeFavoriteRecipe(recipeSlug: String) { + logger.v { "removeFavoriteRecipe() called with: recipeSlug = $recipeSlug" } + runCatchingExceptCancel { + dataSource.removeFavoriteRecipe(recipeSlug) + }.onFailure { + logger.e(it) { "Can't remove a favorite recipe" } + } + } + + override suspend fun addFavoriteRecipe(recipeSlug: String) { + logger.v { "addFavoriteRecipe() called with: recipeSlug = $recipeSlug" } + runCatchingExceptCancel { + dataSource.addFavoriteRecipe(recipeSlug) + }.onFailure { + logger.e(it) { "Can't add a favorite recipe" } + } + } + companion object { private const val LOAD_PAGE_SIZE = 50 private const val INITIAL_LOAD_PAGE_SIZE = LOAD_PAGE_SIZE * 3 diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSource.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSource.kt index db6f42c..40578aa 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSource.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSource.kt @@ -6,4 +6,8 @@ interface RecipeDataSource { suspend fun requestRecipeInfo(slug: String): FullRecipeInfo suspend fun getFavoriteRecipes(): List + + suspend fun removeFavoriteRecipe(recipeSlug: String) + + suspend fun addFavoriteRecipe(recipeSlug: String) } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt index 41f4dc4..cd263b7 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt @@ -3,6 +3,7 @@ package gq.kirmanak.mealient.ui.recipes import android.view.View import androidx.annotation.StringRes import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.scopes.FragmentScoped import gq.kirmanak.mealient.R import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding @@ -10,28 +11,41 @@ import gq.kirmanak.mealient.extensions.resources import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader import javax.inject.Inject -import javax.inject.Singleton class RecipeViewHolder private constructor( private val logger: Logger, private val binding: ViewHolderRecipeBinding, private val recipeImageLoader: RecipeImageLoader, - private val clickListener: (RecipeSummaryEntity) -> Unit, + private val clickListener: (ClickEvent) -> Unit, ) : RecyclerView.ViewHolder(binding.root) { - @Singleton + @FragmentScoped class Factory @Inject constructor( + private val recipeImageLoader: RecipeImageLoader, private val logger: Logger, ) { fun build( - recipeImageLoader: RecipeImageLoader, binding: ViewHolderRecipeBinding, - clickListener: (RecipeSummaryEntity) -> Unit, + clickListener: (ClickEvent) -> Unit, ) = RecipeViewHolder(logger, binding, recipeImageLoader, clickListener) } + sealed class ClickEvent { + + abstract val recipeSummaryEntity: RecipeSummaryEntity + + data class FavoriteClick( + override val recipeSummaryEntity: RecipeSummaryEntity + ) : ClickEvent() + + data class RecipeClick( + override val recipeSummaryEntity: RecipeSummaryEntity + ) : ClickEvent() + + } + private val loadingPlaceholder by lazy { binding.resources.getString(R.string.view_holder_recipe_text_placeholder) } @@ -43,7 +57,10 @@ class RecipeViewHolder private constructor( item?.let { entity -> binding.root.setOnClickListener { logger.d { "bind: item clicked $entity" } - clickListener(entity) + clickListener(ClickEvent.RecipeClick(entity)) + } + binding.favoriteIcon.setOnClickListener { + clickListener(ClickEvent.FavoriteClick(entity)) } binding.favoriteIcon.setImageResource( if (item.isFavorite) { diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListFragment.kt index 687f258..0767e60 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListFragment.kt @@ -90,7 +90,16 @@ class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) { private fun setupRecipeAdapter() { logger.v { "setupRecipeAdapter() called" } - val recipesAdapter = recipePagingAdapterFactory.build { onRecipeClicked(it) } + val recipesAdapter = recipePagingAdapterFactory.build { + when (it) { + is RecipeViewHolder.ClickEvent.FavoriteClick -> { + viewModel.onFavoriteIconClick(it.recipeSummaryEntity) + } + is RecipeViewHolder.ClickEvent.RecipeClick -> { + onRecipeClicked(it.recipeSummaryEntity) + } + } + } with(binding.recipes) { adapter = recipesAdapter diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt index 2536f8a..9e3ec9f 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt @@ -8,10 +8,12 @@ import androidx.paging.cachedIn import dagger.hilt.android.lifecycle.HiltViewModel import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.recipes.RecipeRepo +import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.extensions.valueUpdatesOnly import gq.kirmanak.mealient.logging.Logger import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -38,4 +40,15 @@ class RecipesListViewModel @Inject constructor( emit(result) } } + + fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) { + logger.v { "onFavoriteIconClick() called with: recipeSummaryEntity = $recipeSummaryEntity" } + viewModelScope.launch { + if (recipeSummaryEntity.isFavorite) { + recipeRepo.removeFavoriteRecipe(recipeSummaryEntity.slug) + } else { + recipeRepo.addFavoriteRecipe(recipeSummaryEntity.slug) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt index cde6fb9..e36621a 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt @@ -8,28 +8,22 @@ import dagger.hilt.android.scopes.FragmentScoped import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding import gq.kirmanak.mealient.logging.Logger -import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader import javax.inject.Inject class RecipesPagingAdapter private constructor( private val logger: Logger, - private val recipeImageLoader: RecipeImageLoader, private val recipeViewHolderFactory: RecipeViewHolder.Factory, - private val clickListener: (RecipeSummaryEntity) -> Unit + private val clickListener: (RecipeViewHolder.ClickEvent) -> Unit ) : PagingDataAdapter(RecipeDiffCallback) { @FragmentScoped class Factory @Inject constructor( private val logger: Logger, private val recipeViewHolderFactory: RecipeViewHolder.Factory, - private val recipeImageLoader: RecipeImageLoader, ) { - fun build(clickListener: (RecipeSummaryEntity) -> Unit) = RecipesPagingAdapter( - logger, - recipeImageLoader, - recipeViewHolderFactory, - clickListener + fun build(clickListener: (RecipeViewHolder.ClickEvent) -> Unit) = RecipesPagingAdapter( + logger, recipeViewHolderFactory, clickListener ) } @@ -43,18 +37,16 @@ class RecipesPagingAdapter private constructor( logger.v { "onCreateViewHolder() called with: parent = $parent, viewType = $viewType" } val inflater = LayoutInflater.from(parent.context) val binding = ViewHolderRecipeBinding.inflate(inflater, parent, false) - return recipeViewHolderFactory.build(recipeImageLoader, binding, clickListener) + return recipeViewHolderFactory.build(binding, clickListener) } private object RecipeDiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame( - oldItem: RecipeSummaryEntity, - newItem: RecipeSummaryEntity + oldItem: RecipeSummaryEntity, newItem: RecipeSummaryEntity ): Boolean = oldItem.remoteId == newItem.remoteId override fun areContentsTheSame( - oldItem: RecipeSummaryEntity, - newItem: RecipeSummaryEntity + oldItem: RecipeSummaryEntity, newItem: RecipeSummaryEntity ): Boolean = oldItem.name == newItem.name && oldItem.slug == newItem.slug } } diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0.kt index a1724d1..2358c64 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0.kt @@ -42,4 +42,8 @@ interface MealieDataSourceV0 { ): String suspend fun requestUserInfo(): GetUserInfoResponseV0 + + suspend fun removeFavoriteRecipe(userId: Int, recipeSlug: String) + + suspend fun addFavoriteRecipe(userId: Int, recipeSlug: String) } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0Impl.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0Impl.kt index 4c0ea0b..146b9af 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0Impl.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieDataSourceV0Impl.kt @@ -97,4 +97,22 @@ class MealieDataSourceV0Impl @Inject constructor( logMethod = { "requestUserInfo" }, ) } + + override suspend fun removeFavoriteRecipe( + userId: Int, + recipeSlug: String + ): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized( + block = { service.removeFavoriteRecipe(userId, recipeSlug) }, + logMethod = { "removeFavoriteRecipe" }, + logParameters = { "userId = $userId, recipeSlug = $recipeSlug" } + ) + + override suspend fun addFavoriteRecipe( + userId: Int, + recipeSlug: String + ): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized( + block = { service.addFavoriteRecipe(userId, recipeSlug) }, + logMethod = { "addFavoriteRecipe" }, + logParameters = { "userId = $userId, recipeSlug = $recipeSlug" } + ) } diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieServiceV0.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieServiceV0.kt index 5c139d3..c3359a4 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieServiceV0.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/MealieServiceV0.kt @@ -43,4 +43,16 @@ interface MealieServiceV0 { @GET("/api/users/self") suspend fun getUserSelfInfo(): GetUserInfoResponseV0 + + @DELETE("/api/users/{userId}/favorites/{recipeSlug}") + suspend fun removeFavoriteRecipe( + @Path("userId") userId: Int, + @Path("recipeSlug") recipeSlug: String + ) + + @POST("/api/users/{userId}/favorites/{recipeSlug}") + suspend fun addFavoriteRecipe( + @Path("userId") userId: Int, + @Path("recipeSlug") recipeSlug: String + ) } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/models/GetUserInfoResponseV0.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/models/GetUserInfoResponseV0.kt index 6110e2e..005ccf1 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/models/GetUserInfoResponseV0.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v0/models/GetUserInfoResponseV0.kt @@ -5,5 +5,6 @@ import kotlinx.serialization.Serializable @Serializable data class GetUserInfoResponseV0( + @SerialName("id") val id: Int, @SerialName("favoriteRecipes") val favoriteRecipes: List = emptyList(), ) diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1.kt index d9b6a57..482974f 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1.kt @@ -50,4 +50,8 @@ interface MealieDataSourceV1 { ): CreateApiTokenResponseV1 suspend fun requestUserInfo(): GetUserInfoResponseV1 + + suspend fun removeFavoriteRecipe(userId: String, recipeSlug: String) + + suspend fun addFavoriteRecipe(userId: String, recipeSlug: String) } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1Impl.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1Impl.kt index be92d74..6c0b094 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1Impl.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieDataSourceV1Impl.kt @@ -108,5 +108,23 @@ class MealieDataSourceV1Impl @Inject constructor( logMethod = { "requestUserInfo" }, ) } + + override suspend fun removeFavoriteRecipe( + userId: String, + recipeSlug: String + ): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized( + block = { service.removeFavoriteRecipe(userId, recipeSlug) }, + logMethod = { "removeFavoriteRecipe" }, + logParameters = { "userId = $userId, recipeSlug = $recipeSlug" } + ) + + override suspend fun addFavoriteRecipe( + userId: String, + recipeSlug: String + ): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized( + block = { service.addFavoriteRecipe(userId, recipeSlug) }, + logMethod = { "addFavoriteRecipe" }, + logParameters = { "userId = $userId, recipeSlug = $recipeSlug" } + ) } diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieServiceV1.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieServiceV1.kt index 5e9e6f5..d1dd7cb 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieServiceV1.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/MealieServiceV1.kt @@ -49,4 +49,16 @@ interface MealieServiceV1 { @GET("/api/users/self") suspend fun getUserSelfInfo(): GetUserInfoResponseV1 + + @DELETE("/api/users/{userId}/favorites/{recipeSlug}") + suspend fun removeFavoriteRecipe( + @Path("userId") userId: String, + @Path("recipeSlug") recipeSlug: String + ) + + @POST("/api/users/{userId}/favorites/{recipeSlug}") + suspend fun addFavoriteRecipe( + @Path("userId") userId: String, + @Path("recipeSlug") recipeSlug: String + ) } \ No newline at end of file diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetUserInfoResponseV1.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetUserInfoResponseV1.kt index 4fda4f4..ee97e60 100644 --- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetUserInfoResponseV1.kt +++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/v1/models/GetUserInfoResponseV1.kt @@ -5,5 +5,6 @@ import kotlinx.serialization.Serializable @Serializable data class GetUserInfoResponseV1( + @SerialName("id") val id: String, @SerialName("favoriteRecipes") val favoriteRecipes: List = emptyList(), ) From 4334fdaa6afe2118f6ff0303a91b32f19acc6c4e Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Tue, 13 Dec 2022 20:20:34 +0100 Subject: [PATCH 04/16] Replace add/remove with update --- .../data/network/MealieDataSourceWrapper.kt | 28 +++++++++---------- .../mealient/data/recipes/RecipeRepo.kt | 4 +-- .../data/recipes/impl/RecipeRepoImpl.kt | 17 +++-------- .../data/recipes/network/RecipeDataSource.kt | 4 +-- .../ui/recipes/RecipesListViewModel.kt | 9 +++--- 5 files changed, 24 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapper.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapper.kt index 1e4d118..5147cf0 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapper.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapper.kt @@ -69,25 +69,25 @@ class MealieDataSourceWrapper @Inject constructor( ServerVersion.V1 -> v1Source.requestUserInfo().favoriteRecipes } - override suspend fun removeFavoriteRecipe(recipeSlug: String) = when (getVersion()) { + override suspend fun updateIsRecipeFavorite( + recipeSlug: String, + isFavorite: Boolean + ) = when (getVersion()) { ServerVersion.V0 -> { val userId = v0Source.requestUserInfo().id - v0Source.removeFavoriteRecipe(userId, recipeSlug) + if (isFavorite) { + v0Source.addFavoriteRecipe(userId, recipeSlug) + } else { + v0Source.removeFavoriteRecipe(userId, recipeSlug) + } } ServerVersion.V1 -> { val userId = v1Source.requestUserInfo().id - v1Source.removeFavoriteRecipe(userId, recipeSlug) - } - } - - override suspend fun addFavoriteRecipe(recipeSlug: String) = when (getVersion()) { - ServerVersion.V0 -> { - val userId = v0Source.requestUserInfo().id - v0Source.addFavoriteRecipe(userId, recipeSlug) - } - ServerVersion.V1 -> { - val userId = v1Source.requestUserInfo().id - v1Source.addFavoriteRecipe(userId, recipeSlug) + if (isFavorite) { + v1Source.addFavoriteRecipe(userId, recipeSlug) + } else { + v1Source.removeFavoriteRecipe(userId, recipeSlug) + } } } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/RecipeRepo.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/RecipeRepo.kt index c1e3074..c10b2bc 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/RecipeRepo.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/RecipeRepo.kt @@ -18,7 +18,5 @@ interface RecipeRepo { suspend fun refreshRecipes() - suspend fun removeFavoriteRecipe(recipeSlug: String) - - suspend fun addFavoriteRecipe(recipeSlug: String) + suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean) } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt index 62184eb..7f57ef5 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt @@ -72,21 +72,12 @@ class RecipeRepoImpl @Inject constructor( } } - override suspend fun removeFavoriteRecipe(recipeSlug: String) { - logger.v { "removeFavoriteRecipe() called with: recipeSlug = $recipeSlug" } + override suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean) { + logger.v { "updateIsRecipeFavorite() called with: recipeSlug = $recipeSlug, isFavorite = $isFavorite" } runCatchingExceptCancel { - dataSource.removeFavoriteRecipe(recipeSlug) + dataSource.updateIsRecipeFavorite(recipeSlug, isFavorite) }.onFailure { - logger.e(it) { "Can't remove a favorite recipe" } - } - } - - override suspend fun addFavoriteRecipe(recipeSlug: String) { - logger.v { "addFavoriteRecipe() called with: recipeSlug = $recipeSlug" } - runCatchingExceptCancel { - dataSource.addFavoriteRecipe(recipeSlug) - }.onFailure { - logger.e(it) { "Can't add a favorite recipe" } + logger.e(it) { "Can't update recipe's is favorite status" } } } diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSource.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSource.kt index 40578aa..edd9c9e 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSource.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSource.kt @@ -7,7 +7,5 @@ interface RecipeDataSource { suspend fun getFavoriteRecipes(): List - suspend fun removeFavoriteRecipe(recipeSlug: String) - - suspend fun addFavoriteRecipe(recipeSlug: String) + suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean) } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt index 9e3ec9f..55565a7 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt @@ -44,11 +44,10 @@ class RecipesListViewModel @Inject constructor( fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) { logger.v { "onFavoriteIconClick() called with: recipeSummaryEntity = $recipeSummaryEntity" } viewModelScope.launch { - if (recipeSummaryEntity.isFavorite) { - recipeRepo.removeFavoriteRecipe(recipeSummaryEntity.slug) - } else { - recipeRepo.addFavoriteRecipe(recipeSummaryEntity.slug) - } + recipeRepo.updateIsRecipeFavorite( + recipeSlug = recipeSummaryEntity.slug, + isFavorite = recipeSummaryEntity.isFavorite.not(), + ) } } } \ No newline at end of file From af390ebcafd4342c8801b735aa50e504bb50ba9f Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Tue, 13 Dec 2022 20:35:17 +0100 Subject: [PATCH 05/16] Fix recipe list diff calculation --- .../kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt index e36621a..6dd858b 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt @@ -42,11 +42,13 @@ class RecipesPagingAdapter private constructor( private object RecipeDiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame( - oldItem: RecipeSummaryEntity, newItem: RecipeSummaryEntity + oldItem: RecipeSummaryEntity, + newItem: RecipeSummaryEntity, ): Boolean = oldItem.remoteId == newItem.remoteId override fun areContentsTheSame( - oldItem: RecipeSummaryEntity, newItem: RecipeSummaryEntity - ): Boolean = oldItem.name == newItem.name && oldItem.slug == newItem.slug + oldItem: RecipeSummaryEntity, + newItem: RecipeSummaryEntity, + ): Boolean = oldItem == newItem } } From 97735847c02fcfca6584a6b4074f4659e3067725 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Tue, 13 Dec 2022 20:53:45 +0100 Subject: [PATCH 06/16] Refresh favorite recipes on change --- .../gq/kirmanak/mealient/data/recipes/db/RecipeStorage.kt | 2 ++ .../mealient/data/recipes/db/RecipeStorageImpl.kt | 8 ++++++++ .../kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt | 1 + .../mealient/data/recipes/impl/RecipesRemoteMediator.kt | 6 ++++++ .../gq/kirmanak/mealient/database/recipe/RecipeDao.kt | 6 ++++++ 5 files changed, 23 insertions(+) diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorage.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorage.kt index 737c417..171d8ab 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorage.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorage.kt @@ -17,4 +17,6 @@ interface RecipeStorage { suspend fun saveRecipeInfo(recipe: FullRecipeInfo) suspend fun queryRecipeInfo(recipeId: String): FullRecipeEntity? + + suspend fun updateFavoriteRecipes(favorites: List) } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImpl.kt index 9219e34..70fa579 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImpl.kt @@ -72,4 +72,12 @@ class RecipeStorageImpl @Inject constructor( logger.v { "queryRecipeInfo() returned: $fullRecipeInfo" } return fullRecipeInfo } + + override suspend fun updateFavoriteRecipes(favorites: List) { + logger.v { "updateFavoriteRecipes() called with: favorites = $favorites" } + db.withTransaction { + recipeDao.setFavorite(favorites) + recipeDao.setNonFavorite(favorites) + } + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt index 7f57ef5..900d320 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt @@ -76,6 +76,7 @@ class RecipeRepoImpl @Inject constructor( logger.v { "updateIsRecipeFavorite() called with: recipeSlug = $recipeSlug, isFavorite = $isFavorite" } runCatchingExceptCancel { dataSource.updateIsRecipeFavorite(recipeSlug, isFavorite) + mediator.onFavoritesChange() }.onFailure { logger.e(it) { "Can't update recipe's is favorite status" } } diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt index 7c8d577..77dcbef 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt @@ -79,4 +79,10 @@ class RecipesRemoteMediator @Inject constructor( else storage.saveRecipes(entities) recipes.size } + + suspend fun onFavoritesChange() { + val favorites = network.getFavoriteRecipes() + storage.updateFavoriteRecipes(favorites) + pagingSourceFactory.invalidate() + } } \ No newline at end of file diff --git a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/RecipeDao.kt b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/RecipeDao.kt index eaea4e9..29d74e8 100644 --- a/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/RecipeDao.kt +++ b/database/src/main/kotlin/gq/kirmanak/mealient/database/recipe/RecipeDao.kt @@ -40,4 +40,10 @@ interface RecipeDao { @Query("DELETE FROM recipe_instruction WHERE recipe_id = :recipeId") suspend fun deleteRecipeInstructions(recipeId: String) + + @Query("UPDATE recipe_summaries SET is_favorite = 1 WHERE slug IN (:favorites)") + suspend fun setFavorite(favorites: List) + + @Query("UPDATE recipe_summaries SET is_favorite = 0 WHERE slug NOT IN (:favorites)") + suspend fun setNonFavorite(favorites: List) } \ No newline at end of file From c6142c62186ef1f9f5fe93f27b3ee3737d814cdf Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Tue, 13 Dec 2022 21:02:20 +0100 Subject: [PATCH 07/16] Update favorite icon size --- app/src/main/res/drawable/ic_favorite_filled.xml | 16 ++++++++-------- .../main/res/drawable/ic_favorite_unfilled.xml | 10 +++++----- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/src/main/res/drawable/ic_favorite_filled.xml b/app/src/main/res/drawable/ic_favorite_filled.xml index 3dcfcb6..4f40d7a 100644 --- a/app/src/main/res/drawable/ic_favorite_filled.xml +++ b/app/src/main/res/drawable/ic_favorite_filled.xml @@ -1,10 +1,10 @@ - + android:width="32dp" + android:height="32dp" + android:tint="?attr/colorPrimary" + android:viewportWidth="40" + android:viewportHeight="40"> + diff --git a/app/src/main/res/drawable/ic_favorite_unfilled.xml b/app/src/main/res/drawable/ic_favorite_unfilled.xml index 3fbbab7..c258d40 100644 --- a/app/src/main/res/drawable/ic_favorite_unfilled.xml +++ b/app/src/main/res/drawable/ic_favorite_unfilled.xml @@ -1,10 +1,10 @@ + android:viewportWidth="40" + android:viewportHeight="40"> + android:pathData="M20,34.958 L18.042,33.208Q13.708,29.25 10.875,26.375Q8.042,23.5 6.354,21.229Q4.667,18.958 4,17.104Q3.333,15.25 3.333,13.333Q3.333,9.542 5.896,6.979Q8.458,4.417 12.208,4.417Q14.542,4.417 16.542,5.479Q18.542,6.542 20,8.5Q21.625,6.458 23.583,5.438Q25.542,4.417 27.792,4.417Q31.542,4.417 34.104,6.979Q36.667,9.542 36.667,13.333Q36.667,15.25 36,17.104Q35.333,18.958 33.646,21.229Q31.958,23.5 29.125,26.375Q26.292,29.25 21.958,33.208ZM20,31.292Q24.125,27.5 26.812,24.792Q29.5,22.083 31.062,20.062Q32.625,18.042 33.25,16.458Q33.875,14.875 33.875,13.333Q33.875,10.667 32.167,8.938Q30.458,7.208 27.792,7.208Q25.708,7.208 23.938,8.438Q22.167,9.667 21.208,11.833H18.792Q17.833,9.708 16.062,8.458Q14.292,7.208 12.208,7.208Q9.542,7.208 7.833,8.938Q6.125,10.667 6.125,13.333Q6.125,14.917 6.75,16.5Q7.375,18.083 8.938,20.125Q10.5,22.167 13.188,24.854Q15.875,27.542 20,31.292ZM20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Q20,19.25 20,19.25Z" /> From 127153cac788b8f9105a2bf36a175f967d873a31 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Tue, 13 Dec 2022 21:32:39 +0100 Subject: [PATCH 08/16] Update unit tests --- .../recipes/impl/RecipesRemoteMediator.kt | 2 +- .../mealient/di/ArchitectureModule.kt | 6 ------ .../data/recipes/db/RecipeStorageImplTest.kt | 20 +++++++++---------- .../impl/RecipePagingSourceFactoryImplTest.kt | 9 ++++----- .../recipes/impl/RecipesRemoteMediatorTest.kt | 16 ++++++++++----- .../mealient/extensions/ModelMappingsTest.kt | 2 +- .../mealient/test/RecipeImplTestData.kt | 2 ++ .../configuration/AppDispatchers.kt | 5 ++--- .../configuration/AppDispatchersImpl.kt | 2 +- .../configuration/ArchitectureModule.kt | 16 +++++++++++++++ testing/build.gradle.kts | 1 + .../gq/kirmanak/mealient/test/BaseUnitTest.kt | 11 ++++++++++ 12 files changed, 59 insertions(+), 33 deletions(-) rename {app/src/main/java/gq/kirmanak/mealient/data => architecture/src/main/kotlin/gq/kirmanak/mealient/architecture}/configuration/AppDispatchers.kt (57%) rename {app/src/main/java/gq/kirmanak/mealient/data => architecture/src/main/kotlin/gq/kirmanak/mealient/architecture}/configuration/AppDispatchersImpl.kt (86%) create mode 100644 architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/ArchitectureModule.kt diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt index 77dcbef..cdfe112 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt @@ -4,7 +4,7 @@ import androidx.annotation.VisibleForTesting import androidx.paging.* import androidx.paging.LoadType.PREPEND import androidx.paging.LoadType.REFRESH -import gq.kirmanak.mealient.data.configuration.AppDispatchers +import gq.kirmanak.mealient.architecture.configuration.AppDispatchers import gq.kirmanak.mealient.data.recipes.db.RecipeStorage import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity diff --git a/app/src/main/java/gq/kirmanak/mealient/di/ArchitectureModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/ArchitectureModule.kt index 11e2378..c322301 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/ArchitectureModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/ArchitectureModule.kt @@ -5,8 +5,6 @@ import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import gq.kirmanak.mealient.architecture.configuration.BuildConfiguration -import gq.kirmanak.mealient.data.configuration.AppDispatchers -import gq.kirmanak.mealient.data.configuration.AppDispatchersImpl import gq.kirmanak.mealient.data.configuration.BuildConfigurationImpl import javax.inject.Singleton @@ -17,8 +15,4 @@ interface ArchitectureModule { @Binds @Singleton fun bindBuildConfiguration(buildConfigurationImpl: BuildConfigurationImpl): BuildConfiguration - - @Binds - @Singleton - fun bindAppDispatchers(appDispatchersImpl: AppDispatchersImpl): AppDispatchers } \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImplTest.kt index 0d3db50..6cfb6f0 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/recipes/db/RecipeStorageImplTest.kt @@ -14,9 +14,7 @@ import gq.kirmanak.mealient.test.RecipeImplTestData.MIX_CAKE_RECIPE_INSTRUCTION_ import gq.kirmanak.mealient.test.RecipeImplTestData.MIX_INSTRUCTION import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_FULL_RECIPE_INFO import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_RECIPE_SUMMARY_ENTITY -import gq.kirmanak.mealient.test.RecipeImplTestData.RECIPE_SUMMARY_CAKE -import gq.kirmanak.mealient.test.RecipeImplTestData.RECIPE_SUMMARY_PORRIDGE_V0 -import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARIES +import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARY_ENTITIES import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test @@ -34,7 +32,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() { @Test fun `when saveRecipes then saves recipes`() = runTest { - subject.saveRecipes(TEST_RECIPE_SUMMARIES) + subject.saveRecipes(TEST_RECIPE_SUMMARY_ENTITIES) val actualTags = appDb.recipeDao().queryAllRecipes() assertThat(actualTags).containsExactly( CAKE_RECIPE_SUMMARY_ENTITY, @@ -44,15 +42,15 @@ class RecipeStorageImplTest : HiltRobolectricTest() { @Test fun `when refreshAll then old recipes aren't preserved`() = runTest { - subject.saveRecipes(TEST_RECIPE_SUMMARIES) - subject.refreshAll(listOf(RECIPE_SUMMARY_CAKE)) + subject.saveRecipes(TEST_RECIPE_SUMMARY_ENTITIES) + subject.refreshAll(listOf(CAKE_RECIPE_SUMMARY_ENTITY)) val actual = appDb.recipeDao().queryAllRecipes() assertThat(actual).containsExactly(CAKE_RECIPE_SUMMARY_ENTITY) } @Test fun `when clearAllLocalData then recipes aren't preserved`() = runTest { - subject.saveRecipes(TEST_RECIPE_SUMMARIES) + subject.saveRecipes(TEST_RECIPE_SUMMARY_ENTITIES) subject.clearAllLocalData() val actual = appDb.recipeDao().queryAllRecipes() assertThat(actual).isEmpty() @@ -60,7 +58,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() { @Test fun `when saveRecipeInfo then saves recipe info`() = runTest { - subject.saveRecipes(listOf(RECIPE_SUMMARY_CAKE)) + subject.saveRecipes(listOf(CAKE_RECIPE_SUMMARY_ENTITY)) subject.saveRecipeInfo(CAKE_FULL_RECIPE_INFO) val actual = appDb.recipeDao().queryFullRecipeInfo("1") assertThat(actual).isEqualTo(FULL_CAKE_INFO_ENTITY) @@ -68,7 +66,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() { @Test fun `when saveRecipeInfo with two then saves second`() = runTest { - subject.saveRecipes(listOf(RECIPE_SUMMARY_CAKE, RECIPE_SUMMARY_PORRIDGE_V0)) + subject.saveRecipes(TEST_RECIPE_SUMMARY_ENTITIES) subject.saveRecipeInfo(CAKE_FULL_RECIPE_INFO) subject.saveRecipeInfo(PORRIDGE_FULL_RECIPE_INFO) val actual = appDb.recipeDao().queryFullRecipeInfo("2") @@ -77,7 +75,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() { @Test fun `when saveRecipeInfo secondly then overwrites ingredients`() = runTest { - subject.saveRecipes(listOf(RECIPE_SUMMARY_CAKE)) + subject.saveRecipes(listOf(CAKE_RECIPE_SUMMARY_ENTITY)) subject.saveRecipeInfo(CAKE_FULL_RECIPE_INFO) val newRecipe = CAKE_FULL_RECIPE_INFO.copy(recipeIngredients = listOf(BREAD_INGREDIENT)) subject.saveRecipeInfo(newRecipe) @@ -88,7 +86,7 @@ class RecipeStorageImplTest : HiltRobolectricTest() { @Test fun `when saveRecipeInfo secondly then overwrites instructions`() = runTest { - subject.saveRecipes(listOf(RECIPE_SUMMARY_CAKE)) + subject.saveRecipes(listOf(CAKE_RECIPE_SUMMARY_ENTITY)) subject.saveRecipeInfo(CAKE_FULL_RECIPE_INFO) val newRecipe = CAKE_FULL_RECIPE_INFO.copy(recipeInstructions = listOf(MIX_INSTRUCTION)) subject.saveRecipeInfo(newRecipe) diff --git a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipePagingSourceFactoryImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipePagingSourceFactoryImplTest.kt index d9ec3b9..e5374aa 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipePagingSourceFactoryImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipePagingSourceFactoryImplTest.kt @@ -8,7 +8,6 @@ import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.test.HiltRobolectricTest import gq.kirmanak.mealient.test.RecipeImplTestData.CAKE_RECIPE_SUMMARY_ENTITY import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_RECIPE_SUMMARY_ENTITY -import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARIES import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARY_ENTITIES import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -27,28 +26,28 @@ class RecipePagingSourceFactoryImplTest : HiltRobolectricTest() { @Test fun `when query is ca expect cake only is returned`() = runTest { - storage.saveRecipes(TEST_RECIPE_SUMMARIES) + storage.saveRecipes(TEST_RECIPE_SUMMARY_ENTITIES) subject.setQuery("ca") assertThat(queryRecipes()).isEqualTo(listOf(CAKE_RECIPE_SUMMARY_ENTITY)) } @Test fun `when query is po expect porridge only is returned`() = runTest { - storage.saveRecipes(TEST_RECIPE_SUMMARIES) + storage.saveRecipes(TEST_RECIPE_SUMMARY_ENTITIES) subject.setQuery("po") assertThat(queryRecipes()).isEqualTo(listOf(PORRIDGE_RECIPE_SUMMARY_ENTITY)) } @Test fun `when query is e expect cake and porridge are returned`() = runTest { - storage.saveRecipes(TEST_RECIPE_SUMMARIES) + storage.saveRecipes(TEST_RECIPE_SUMMARY_ENTITIES) subject.setQuery("e") assertThat(queryRecipes()).isEqualTo(TEST_RECIPE_SUMMARY_ENTITIES) } @Test fun `when query is null expect cake and porridge are returned`() = runTest { - storage.saveRecipes(TEST_RECIPE_SUMMARIES) + storage.saveRecipes(TEST_RECIPE_SUMMARY_ENTITIES) subject.setQuery(null) assertThat(queryRecipes()).isEqualTo(TEST_RECIPE_SUMMARY_ENTITIES) } diff --git a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediatorTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediatorTest.kt index ed693f6..5b13581 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediatorTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediatorTest.kt @@ -9,6 +9,7 @@ import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.datasource.NetworkError.Unauthorized import gq.kirmanak.mealient.test.BaseUnitTest import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARIES +import gq.kirmanak.mealient.test.RecipeImplTestData.TEST_RECIPE_SUMMARY_ENTITIES import io.mockk.coEvery import io.mockk.coVerify import io.mockk.impl.annotations.MockK @@ -42,7 +43,14 @@ class RecipesRemoteMediatorTest : BaseUnitTest() { @Before override fun setUp() { super.setUp() - subject = RecipesRemoteMediator(storage, dataSource, pagingSourceFactory, logger) + subject = RecipesRemoteMediator( + storage = storage, + network = dataSource, + pagingSourceFactory = pagingSourceFactory, + logger = logger, + dispatchers = dispatchers, + ) + coEvery { dataSource.getFavoriteRecipes() } returns emptyList() } @Test @@ -70,7 +78,7 @@ class RecipesRemoteMediatorTest : BaseUnitTest() { fun `when first load with refresh successful then recipes stored`() = runTest { coEvery { dataSource.requestRecipes(eq(0), eq(6)) } returns TEST_RECIPE_SUMMARIES subject.load(REFRESH, pagingState()) - coVerify { storage.refreshAll(eq(TEST_RECIPE_SUMMARIES)) } + coVerify { storage.refreshAll(eq(TEST_RECIPE_SUMMARY_ENTITIES)) } } @Test @@ -132,9 +140,7 @@ class RecipesRemoteMediatorTest : BaseUnitTest() { subject.load(REFRESH, pagingState()) coEvery { dataSource.requestRecipes(any(), any()) } throws Unauthorized(RuntimeException()) subject.load(APPEND, pagingState()) - coVerify { - storage.refreshAll(TEST_RECIPE_SUMMARIES) - } + coVerify { storage.refreshAll(TEST_RECIPE_SUMMARY_ENTITIES) } } private fun pagingState( diff --git a/app/src/test/java/gq/kirmanak/mealient/extensions/ModelMappingsTest.kt b/app/src/test/java/gq/kirmanak/mealient/extensions/ModelMappingsTest.kt index 8b6bd81..578af8b 100644 --- a/app/src/test/java/gq/kirmanak/mealient/extensions/ModelMappingsTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/extensions/ModelMappingsTest.kt @@ -76,7 +76,7 @@ class ModelMappingsTest : BaseUnitTest() { @Test fun `when summary info to entity expect correct entity`() { - val actual = RECIPE_SUMMARY_PORRIDGE_V0.toRecipeSummaryEntity() + val actual = RECIPE_SUMMARY_PORRIDGE_V0.toRecipeSummaryEntity(isFavorite = false) assertThat(actual).isEqualTo(PORRIDGE_RECIPE_SUMMARY_ENTITY) } diff --git a/app/src/test/java/gq/kirmanak/mealient/test/RecipeImplTestData.kt b/app/src/test/java/gq/kirmanak/mealient/test/RecipeImplTestData.kt index 3409031..a80ff6a 100644 --- a/app/src/test/java/gq/kirmanak/mealient/test/RecipeImplTestData.kt +++ b/app/src/test/java/gq/kirmanak/mealient/test/RecipeImplTestData.kt @@ -80,6 +80,7 @@ object RecipeImplTestData { dateAdded = LocalDate.parse("2021-11-13"), dateUpdated = LocalDateTime.parse("2021-11-13T15:30:13"), imageId = "cake", + isFavorite = false, ) val PORRIDGE_RECIPE_SUMMARY_ENTITY = RecipeSummaryEntity( @@ -90,6 +91,7 @@ object RecipeImplTestData { dateAdded = LocalDate.parse("2021-11-12"), dateUpdated = LocalDateTime.parse("2021-10-13T17:35:23"), imageId = "porridge", + isFavorite = false, ) val TEST_RECIPE_SUMMARY_ENTITIES = diff --git a/app/src/main/java/gq/kirmanak/mealient/data/configuration/AppDispatchers.kt b/architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/AppDispatchers.kt similarity index 57% rename from app/src/main/java/gq/kirmanak/mealient/data/configuration/AppDispatchers.kt rename to architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/AppDispatchers.kt index 9b3d91a..81cb800 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/configuration/AppDispatchers.kt +++ b/architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/AppDispatchers.kt @@ -1,11 +1,10 @@ -package gq.kirmanak.mealient.data.configuration +package gq.kirmanak.mealient.architecture.configuration import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.MainCoroutineDispatcher interface AppDispatchers { val io: CoroutineDispatcher - val main: MainCoroutineDispatcher + val main: CoroutineDispatcher val default: CoroutineDispatcher val unconfined: CoroutineDispatcher } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/configuration/AppDispatchersImpl.kt b/architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/AppDispatchersImpl.kt similarity index 86% rename from app/src/main/java/gq/kirmanak/mealient/data/configuration/AppDispatchersImpl.kt rename to architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/AppDispatchersImpl.kt index e7e249d..69680da 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/configuration/AppDispatchersImpl.kt +++ b/architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/AppDispatchersImpl.kt @@ -1,4 +1,4 @@ -package gq.kirmanak.mealient.data.configuration +package gq.kirmanak.mealient.architecture.configuration import kotlinx.coroutines.Dispatchers import javax.inject.Inject diff --git a/architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/ArchitectureModule.kt b/architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/ArchitectureModule.kt new file mode 100644 index 0000000..2b445b4 --- /dev/null +++ b/architecture/src/main/kotlin/gq/kirmanak/mealient/architecture/configuration/ArchitectureModule.kt @@ -0,0 +1,16 @@ +package gq.kirmanak.mealient.architecture.configuration + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface ArchitectureModule { + + @Binds + @Singleton + fun bindAppDispatchers(appDispatchersImpl: AppDispatchersImpl): AppDispatchers +} \ No newline at end of file diff --git a/testing/build.gradle.kts b/testing/build.gradle.kts index 4129da2..83889f8 100644 --- a/testing/build.gradle.kts +++ b/testing/build.gradle.kts @@ -15,6 +15,7 @@ android { dependencies { implementation(project(":logging")) + implementation(project(":architecture")) implementation(libs.google.dagger.hiltAndroid) kapt(libs.google.dagger.hiltCompiler) diff --git a/testing/src/main/kotlin/gq/kirmanak/mealient/test/BaseUnitTest.kt b/testing/src/main/kotlin/gq/kirmanak/mealient/test/BaseUnitTest.kt index 0526aea..aabb40c 100644 --- a/testing/src/main/kotlin/gq/kirmanak/mealient/test/BaseUnitTest.kt +++ b/testing/src/main/kotlin/gq/kirmanak/mealient/test/BaseUnitTest.kt @@ -1,10 +1,13 @@ package gq.kirmanak.mealient.test import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import gq.kirmanak.mealient.architecture.configuration.AppDispatchers import gq.kirmanak.mealient.logging.Logger import io.mockk.MockKAnnotations +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain @@ -20,10 +23,18 @@ open class BaseUnitTest { protected val logger: Logger = FakeLogger() + lateinit var dispatchers: AppDispatchers + @Before open fun setUp() { MockKAnnotations.init(this) Dispatchers.setMain(UnconfinedTestDispatcher()) + dispatchers = object : AppDispatchers { + override val io: CoroutineDispatcher = StandardTestDispatcher() + override val main: CoroutineDispatcher = StandardTestDispatcher() + override val default: CoroutineDispatcher = StandardTestDispatcher() + override val unconfined: CoroutineDispatcher = StandardTestDispatcher() + } } @After From 700614427c7d44c9dd30731e1eef1843d2a8f79b Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Tue, 13 Dec 2022 21:34:27 +0100 Subject: [PATCH 09/16] Add more logging --- .../mealient/data/recipes/impl/RecipesRemoteMediator.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt index cdfe112..32cee67 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt @@ -66,6 +66,7 @@ class RecipesRemoteMediator @Inject constructor( 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 = network.getFavoriteRecipes().toHashSet() val recipes = deferredRecipes.await() @@ -81,6 +82,7 @@ class RecipesRemoteMediator @Inject constructor( } suspend fun onFavoritesChange() { + logger.v { "onFavoritesChange() called" } val favorites = network.getFavoriteRecipes() storage.updateFavoriteRecipes(favorites) pagingSourceFactory.invalidate() From 0b65c59b592131cc51d73f2a57659c9c176e1a0c Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Tue, 13 Dec 2022 21:46:51 +0100 Subject: [PATCH 10/16] Ignore favorites errors when updating recipes --- .../mealient/data/recipes/impl/RecipesRemoteMediator.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt index 32cee67..16f206f 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediator.kt @@ -68,7 +68,9 @@ class RecipesRemoteMediator @Inject constructor( ): Int = coroutineScope { logger.v { "updateRecipes() called with: start = $start, limit = $limit, loadType = $loadType" } val deferredRecipes = async { network.requestRecipes(start, limit) } - val favorites = network.getFavoriteRecipes().toHashSet() + val favorites = runCatchingExceptCancel { + network.getFavoriteRecipes() + }.getOrDefault(emptyList()).toHashSet() val recipes = deferredRecipes.await() val entities = withContext(dispatchers.default) { recipes.map { recipe -> From 30ee8ecdea0ac3abac9c68efb91f0ddb5a80bcca Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Tue, 13 Dec 2022 21:57:02 +0100 Subject: [PATCH 11/16] Add TODOs --- .../gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt index 55565a7..a5846e3 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt @@ -41,6 +41,8 @@ class RecipesListViewModel @Inject constructor( } } + // TODO hide favourite icons if not authorized + // TODO show error message when can't update favourite status fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) { logger.v { "onFavoriteIconClick() called with: recipeSummaryEntity = $recipeSummaryEntity" } viewModelScope.launch { From cd3931e147e51b1470cc6965f7fd04493423bc4f Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Wed, 14 Dec 2022 21:58:37 +0100 Subject: [PATCH 12/16] Show error message when favorite update fails --- .../kirmanak/mealient/data/recipes/RecipeRepo.kt | 2 +- .../mealient/data/recipes/impl/RecipeRepoImpl.kt | 15 ++++++++------- .../mealient/ui/recipes/RecipesListFragment.kt | 12 +++++++++++- .../mealient/ui/recipes/RecipesListViewModel.kt | 14 +++++--------- app/src/main/res/values/strings.xml | 1 + 5 files changed, 26 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/RecipeRepo.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/RecipeRepo.kt index c10b2bc..d8fdddd 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/RecipeRepo.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/RecipeRepo.kt @@ -18,5 +18,5 @@ interface RecipeRepo { suspend fun refreshRecipes() - suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean) + suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean): Result } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt index 900d320..8a50ce0 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt @@ -72,14 +72,15 @@ class RecipeRepoImpl @Inject constructor( } } - override suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean) { + override suspend fun updateIsRecipeFavorite( + recipeSlug: String, + isFavorite: Boolean, + ): Result = runCatchingExceptCancel { logger.v { "updateIsRecipeFavorite() called with: recipeSlug = $recipeSlug, isFavorite = $isFavorite" } - runCatchingExceptCancel { - dataSource.updateIsRecipeFavorite(recipeSlug, isFavorite) - mediator.onFavoritesChange() - }.onFailure { - logger.e(it) { "Can't update recipe's is favorite status" } - } + dataSource.updateIsRecipeFavorite(recipeSlug, isFavorite) + mediator.onFavoritesChange() + }.onFailure { + logger.e(it) { "Can't update recipe's is favorite status" } } companion object { diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListFragment.kt index 0767e60..f6f08f1 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListFragment.kt @@ -93,7 +93,7 @@ class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) { val recipesAdapter = recipePagingAdapterFactory.build { when (it) { is RecipeViewHolder.ClickEvent.FavoriteClick -> { - viewModel.onFavoriteIconClick(it.recipeSummaryEntity) + onFavoriteClick(it) } is RecipeViewHolder.ClickEvent.RecipeClick -> { onRecipeClicked(it.recipeSummaryEntity) @@ -137,6 +137,16 @@ class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) { } } + private fun onFavoriteClick(event: RecipeViewHolder.ClickEvent) { + logger.v { "onFavoriteClick() called with: event = $event" } + viewModel.onFavoriteIconClick(event.recipeSummaryEntity).observe(viewLifecycleOwner) { + logger.d { "onFavoriteClick: result is $it" } + if (it.isFailure) { + showLongToast(R.string.fragment_recipes_favorite_update_failed) + } + } + } + private fun onLoadFailure(error: Throwable) { logger.w(error) { "onLoadFailure() called" } val reason = error.toLoadErrorReasonText()?.let { getString(it) } diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt index a5846e3..7534550 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt @@ -13,7 +13,6 @@ import gq.kirmanak.mealient.extensions.valueUpdatesOnly import gq.kirmanak.mealient.logging.Logger import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -42,14 +41,11 @@ class RecipesListViewModel @Inject constructor( } // TODO hide favourite icons if not authorized - // TODO show error message when can't update favourite status - fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) { + fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) = liveData { logger.v { "onFavoriteIconClick() called with: recipeSummaryEntity = $recipeSummaryEntity" } - viewModelScope.launch { - recipeRepo.updateIsRecipeFavorite( - recipeSlug = recipeSummaryEntity.slug, - isFavorite = recipeSummaryEntity.isFavorite.not(), - ) - } + recipeRepo.updateIsRecipeFavorite( + recipeSlug = recipeSummaryEntity.slug, + isFavorite = recipeSummaryEntity.isFavorite.not(), + ).also { emit(it) } } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ad1898..bb69c91 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,6 +49,7 @@ unauthorized unexpected response no connection + Favorite status update failed Change URL Search recipes @string/app_name From a20694e7fda9b291947e67781afcb4ab87b56d2f Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Wed, 14 Dec 2022 22:14:28 +0100 Subject: [PATCH 13/16] Hide/show favorite icon on sign-out/sign-in --- .../mealient/ui/recipes/RecipeViewHolder.kt | 22 ++++++++++------- .../ui/recipes/RecipesListFragment.kt | 8 ++++--- .../ui/recipes/RecipesListViewModel.kt | 3 ++- .../ui/recipes/RecipesPagingAdapter.kt | 24 ++++++++++--------- .../main/res/layout/view_holder_recipe.xml | 4 +++- 5 files changed, 36 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt index cd263b7..12496c1 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt @@ -2,7 +2,11 @@ package gq.kirmanak.mealient.ui.recipes import android.view.View import androidx.annotation.StringRes +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.scopes.FragmentScoped import gq.kirmanak.mealient.R import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity @@ -10,25 +14,24 @@ import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding import gq.kirmanak.mealient.extensions.resources import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader -import javax.inject.Inject -class RecipeViewHolder private constructor( +class RecipeViewHolder @AssistedInject constructor( private val logger: Logger, - private val binding: ViewHolderRecipeBinding, + @Assisted private val binding: ViewHolderRecipeBinding, private val recipeImageLoader: RecipeImageLoader, - private val clickListener: (ClickEvent) -> Unit, + @Assisted private val showFavoriteIcon: Boolean, + @Assisted private val clickListener: (ClickEvent) -> Unit, ) : RecyclerView.ViewHolder(binding.root) { @FragmentScoped - class Factory @Inject constructor( - private val recipeImageLoader: RecipeImageLoader, - private val logger: Logger, - ) { + @AssistedFactory + interface Factory { fun build( + showFavoriteIcon: Boolean, binding: ViewHolderRecipeBinding, clickListener: (ClickEvent) -> Unit, - ) = RecipeViewHolder(logger, binding, recipeImageLoader, clickListener) + ): RecipeViewHolder } @@ -59,6 +62,7 @@ class RecipeViewHolder private constructor( logger.d { "bind: item clicked $entity" } clickListener(ClickEvent.RecipeClick(entity)) } + binding.favoriteIcon.isVisible = showFavoriteIcon binding.favoriteIcon.setOnClickListener { clickListener(ClickEvent.FavoriteClick(entity)) } diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListFragment.kt index f6f08f1..2df441c 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListFragment.kt @@ -55,7 +55,9 @@ class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) { checkedMenuItemId = R.id.recipes_list ) } - setupRecipeAdapter() + viewModel.showFavoriteIcon.observe(viewLifecycleOwner) { showFavoriteIcon -> + setupRecipeAdapter(showFavoriteIcon) + } hideKeyboardOnScroll() } @@ -87,10 +89,10 @@ class RecipesListFragment : Fragment(R.layout.fragment_recipes_list) { return findNavController().currentDestination?.id != R.id.recipesListFragment } - private fun setupRecipeAdapter() { + private fun setupRecipeAdapter(showFavoriteIcon: Boolean) { logger.v { "setupRecipeAdapter() called" } - val recipesAdapter = recipePagingAdapterFactory.build { + val recipesAdapter = recipePagingAdapterFactory.build(showFavoriteIcon) { when (it) { is RecipeViewHolder.ClickEvent.FavoriteClick -> { onFavoriteClick(it) diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt index 7534550..f120965 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesListViewModel.kt @@ -2,6 +2,7 @@ package gq.kirmanak.mealient.ui.recipes import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData import androidx.lifecycle.liveData import androidx.lifecycle.viewModelScope import androidx.paging.cachedIn @@ -23,6 +24,7 @@ class RecipesListViewModel @Inject constructor( ) : ViewModel() { val pagingData = recipeRepo.createPager().flow.cachedIn(viewModelScope) + val showFavoriteIcon = authRepo.isAuthorizedFlow.asLiveData() init { authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized -> @@ -40,7 +42,6 @@ class RecipesListViewModel @Inject constructor( } } - // TODO hide favourite icons if not authorized fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) = liveData { logger.v { "onFavoriteIconClick() called with: recipeSummaryEntity = $recipeSummaryEntity" } recipeRepo.updateIsRecipeFavorite( diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt index 6dd858b..2a15df8 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesPagingAdapter.kt @@ -4,27 +4,29 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.scopes.FragmentScoped import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding import gq.kirmanak.mealient.logging.Logger -import javax.inject.Inject -class RecipesPagingAdapter private constructor( +class RecipesPagingAdapter @AssistedInject constructor( private val logger: Logger, private val recipeViewHolderFactory: RecipeViewHolder.Factory, - private val clickListener: (RecipeViewHolder.ClickEvent) -> Unit + @Assisted private val showFavoriteIcon: Boolean, + @Assisted private val clickListener: (RecipeViewHolder.ClickEvent) -> Unit ) : PagingDataAdapter(RecipeDiffCallback) { @FragmentScoped - class Factory @Inject constructor( - private val logger: Logger, - private val recipeViewHolderFactory: RecipeViewHolder.Factory, - ) { + @AssistedFactory + interface Factory { - fun build(clickListener: (RecipeViewHolder.ClickEvent) -> Unit) = RecipesPagingAdapter( - logger, recipeViewHolderFactory, clickListener - ) + fun build( + showFavoriteIcon: Boolean, + clickListener: (RecipeViewHolder.ClickEvent) -> Unit, + ): RecipesPagingAdapter } override fun onBindViewHolder(holder: RecipeViewHolder, position: Int) { @@ -37,7 +39,7 @@ class RecipesPagingAdapter private constructor( logger.v { "onCreateViewHolder() called with: parent = $parent, viewType = $viewType" } val inflater = LayoutInflater.from(parent.context) val binding = ViewHolderRecipeBinding.inflate(inflater, parent, false) - return recipeViewHolderFactory.build(binding, clickListener) + return recipeViewHolderFactory.build(showFavoriteIcon, binding, clickListener) } private object RecipeDiffCallback : DiffUtil.ItemCallback() { diff --git a/app/src/main/res/layout/view_holder_recipe.xml b/app/src/main/res/layout/view_holder_recipe.xml index 9749daa..2121cfd 100644 --- a/app/src/main/res/layout/view_holder_recipe.xml +++ b/app/src/main/res/layout/view_holder_recipe.xml @@ -40,6 +40,7 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/favorite_icon" app:layout_constraintVertical_chainStyle="packed" + app:layout_goneMarginTop="@dimen/margin_medium" app:shapeAppearance="?shapeAppearanceCornerMedium" tools:srcCompat="@drawable/placeholder_recipe" /> @@ -52,6 +53,7 @@ app:layout_constraintBottom_toTopOf="@+id/image" app:layout_constraintEnd_toEndOf="@id/image" app:layout_constraintTop_toTopOf="parent" - tools:srcCompat="@drawable/ic_favorite_unfilled" /> + tools:srcCompat="@drawable/ic_favorite_unfilled" + tools:visibility="gone" /> \ No newline at end of file From 95fc1359237e7e0a331cdf51448a99a594dd68d0 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Wed, 14 Dec 2022 22:50:59 +0100 Subject: [PATCH 14/16] Add missing Russian translation --- app/src/main/res/values-ru/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 3d341a7..2b87f60 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -47,6 +47,7 @@ неожиданный ответ нет соединения Ошибка загрузки. + Не удалось обновить статус избранного Сменить URL Найти рецепты Открыть меню навигации @@ -54,4 +55,6 @@ Рецепт успешно сохранен. Что-то пошло не так. Индикатор прогресса + Добавлен в избранное + Не добавлен в избранное \ No newline at end of file From 16421d6a2a52c68ff6fffe1d886af4350684f40a Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Fri, 16 Dec 2022 17:41:01 +0100 Subject: [PATCH 15/16] Add tests for some affected components --- .../network/MealieDataSourceWrapperTest.kt | 79 +++++++++++++++++-- .../data/recipes/impl/RecipeRepoTest.kt | 36 +++++++++ .../recipes/impl/RecipesRemoteMediatorTest.kt | 29 +++++++ .../mealient/test/AuthImplTestData.kt | 6 ++ 4 files changed, 145 insertions(+), 5 deletions(-) diff --git a/app/src/test/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapperTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapperTest.kt index 418933f..d1de8af 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapperTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/network/MealieDataSourceWrapperTest.kt @@ -5,9 +5,12 @@ import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo import gq.kirmanak.mealient.datasource.v0.MealieDataSourceV0 import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1 +import gq.kirmanak.mealient.test.AuthImplTestData.FAVORITE_RECIPES_LIST import gq.kirmanak.mealient.test.AuthImplTestData.TEST_AUTH_HEADER import gq.kirmanak.mealient.test.AuthImplTestData.TEST_SERVER_VERSION_V0 import gq.kirmanak.mealient.test.AuthImplTestData.TEST_SERVER_VERSION_V1 +import gq.kirmanak.mealient.test.AuthImplTestData.USER_INFO_V0 +import gq.kirmanak.mealient.test.AuthImplTestData.USER_INFO_V1 import gq.kirmanak.mealient.test.BaseUnitTest import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_ADD_RECIPE_INFO import gq.kirmanak.mealient.test.RecipeImplTestData.PORRIDGE_ADD_RECIPE_REQUEST_V0 @@ -36,10 +39,10 @@ class MealieDataSourceWrapperTest : BaseUnitTest() { @MockK(relaxUnitFun = true) lateinit var authRepo: AuthRepo - @MockK + @MockK(relaxUnitFun = true) lateinit var v0Source: MealieDataSourceV0 - @MockK + @MockK(relaxUnitFun = true) lateinit var v1Source: MealieDataSourceV1 lateinit var subject: MealieDataSourceWrapper @@ -48,14 +51,14 @@ class MealieDataSourceWrapperTest : BaseUnitTest() { override fun setUp() { super.setUp() subject = MealieDataSourceWrapper(serverInfoRepo, v0Source, v1Source) + coEvery { v0Source.requestUserInfo() } returns USER_INFO_V0 + coEvery { v1Source.requestUserInfo() } returns USER_INFO_V1 } @Test fun `when server version v1 expect requestRecipeInfo to call v1`() = runTest { val slug = "porridge" - coEvery { - v1Source.requestRecipeInfo(eq(slug)) - } returns PORRIDGE_RECIPE_RESPONSE_V1 + coEvery { v1Source.requestRecipeInfo(eq(slug)) } returns PORRIDGE_RECIPE_RESPONSE_V1 coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1 coEvery { authRepo.getAuthHeader() } returns TEST_AUTH_HEADER @@ -157,4 +160,70 @@ class MealieDataSourceWrapperTest : BaseUnitTest() { assertThat(actual).isEqualTo(slug) } + + @Test + fun `when remove favorite recipe info with v0 expect correct sequence`() = runTest { + coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0 + subject.updateIsRecipeFavorite(recipeSlug = "cake", isFavorite = false) + coVerify { + v0Source.requestUserInfo() + v0Source.removeFavoriteRecipe(eq(3), eq("cake")) + } + } + + @Test + fun `when remove favorite recipe info with v1 expect correct sequence`() = runTest { + coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1 + subject.updateIsRecipeFavorite(recipeSlug = "cake", isFavorite = false) + coVerify { + v1Source.requestUserInfo() + v1Source.removeFavoriteRecipe(eq("userId"), eq("cake")) + } + } + + @Test + fun `when add favorite recipe info with v0 expect correct sequence`() = runTest { + coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0 + subject.updateIsRecipeFavorite(recipeSlug = "cake", isFavorite = true) + coVerify { + v0Source.requestUserInfo() + v0Source.addFavoriteRecipe(eq(3), eq("cake")) + } + } + + @Test + fun `when add favorite recipe info with v1 expect correct sequence`() = runTest { + coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1 + subject.updateIsRecipeFavorite(recipeSlug = "cake", isFavorite = true) + coVerify { + v1Source.requestUserInfo() + v1Source.addFavoriteRecipe(eq("userId"), eq("cake")) + } + } + + @Test + fun `when get favorite recipes with v1 expect correct call`() = runTest { + coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1 + subject.getFavoriteRecipes() + coVerify { v1Source.requestUserInfo() } + } + + @Test + fun `when get favorite recipes with v0 expect correct call`() = runTest { + coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0 + subject.getFavoriteRecipes() + coVerify { v0Source.requestUserInfo() } + } + + @Test + fun `when get favorite recipes with v1 expect correct result`() = runTest { + coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V1 + assertThat(subject.getFavoriteRecipes()).isEqualTo(FAVORITE_RECIPES_LIST) + } + + @Test + fun `when get favorite recipes with v0 expect correct result`() = runTest { + coEvery { serverInfoRepo.getVersion() } returns TEST_SERVER_VERSION_V0 + assertThat(subject.getFavoriteRecipes()).isEqualTo(FAVORITE_RECIPES_LIST) + } } \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoTest.kt index 20c4568..ee03000 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoTest.kt @@ -1,9 +1,11 @@ package gq.kirmanak.mealient.data.recipes.impl +import androidx.paging.LoadType import com.google.common.truth.Truth.assertThat import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.data.recipes.db.RecipeStorage import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource +import gq.kirmanak.mealient.datasource.NetworkError.Unauthorized import gq.kirmanak.mealient.test.BaseUnitTest import gq.kirmanak.mealient.test.RecipeImplTestData.CAKE_FULL_RECIPE_INFO import gq.kirmanak.mealient.test.RecipeImplTestData.FULL_CAKE_INFO_ENTITY @@ -15,6 +17,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test +import java.io.IOException @OptIn(ExperimentalCoroutinesApi::class) class RecipeRepoTest : BaseUnitTest() { @@ -64,4 +67,37 @@ class RecipeRepoTest : BaseUnitTest() { subject.updateNameQuery("query") verify { pagingSourceFactory.setQuery("query") } } + + @Test + fun `when remove favorite recipe expect correct sequence`() = runTest { + subject.updateIsRecipeFavorite("cake", false) + coVerify { + dataSource.updateIsRecipeFavorite(eq("cake"), eq(false)) + remoteMediator.onFavoritesChange() + } + } + + @Test + fun `when add favorite recipe expect correct sequence`() = runTest { + subject.updateIsRecipeFavorite("porridge", true) + coVerify { + dataSource.updateIsRecipeFavorite(eq("porridge"), eq(true)) + remoteMediator.onFavoritesChange() + } + } + + @Test + fun `when add favorite recipe fails expect no mediator call`() = runTest { + coEvery { + dataSource.updateIsRecipeFavorite(any(), any()) + } throws Unauthorized(IOException()) + subject.updateIsRecipeFavorite("porridge", true) + coVerify(inverse = true) { remoteMediator.onFavoritesChange() } + } + + @Test + fun `when refresh recipes expect correct parameters`() = runTest { + subject.refreshRecipes() + coVerify { remoteMediator.updateRecipes(eq(0), eq(150), eq(LoadType.REFRESH)) } + } } \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediatorTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediatorTest.kt index 5b13581..59a742c 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediatorTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipesRemoteMediatorTest.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test +import java.io.IOException @ExperimentalCoroutinesApi @OptIn(ExperimentalPagingApi::class) @@ -143,6 +144,34 @@ class RecipesRemoteMediatorTest : BaseUnitTest() { coVerify { storage.refreshAll(TEST_RECIPE_SUMMARY_ENTITIES) } } + @Test + fun `when favorites change expect network call`() = runTest { + coEvery { dataSource.getFavoriteRecipes() } returns listOf("cake", "porridge") + subject.onFavoritesChange() + coVerify { dataSource.getFavoriteRecipes() } + } + + @Test + fun `when favorites change expect storage update`() = runTest { + coEvery { dataSource.getFavoriteRecipes() } returns listOf("cake", "porridge") + subject.onFavoritesChange() + coVerify { storage.updateFavoriteRecipes(eq(listOf("cake", "porridge"))) } + } + + @Test + fun `when favorites change expect factory invalidation`() = runTest { + coEvery { dataSource.getFavoriteRecipes() } returns listOf("cake", "porridge") + subject.onFavoritesChange() + coVerify { pagingSourceFactory.invalidate() } + } + + @Test + fun `when recipe update requested but favorite fails expect non-zero updates`() = runTest { + coEvery { dataSource.getFavoriteRecipes() } throws Unauthorized(IOException()) + coEvery { dataSource.requestRecipes(eq(0), eq(6)) } returns TEST_RECIPE_SUMMARIES + assertThat(subject.updateRecipes(0, 6, APPEND)).isEqualTo(2) + } + private fun pagingState( pages: List> = emptyList(), anchorPosition: Int? = null diff --git a/app/src/test/java/gq/kirmanak/mealient/test/AuthImplTestData.kt b/app/src/test/java/gq/kirmanak/mealient/test/AuthImplTestData.kt index a613a84..cc8bb47 100644 --- a/app/src/test/java/gq/kirmanak/mealient/test/AuthImplTestData.kt +++ b/app/src/test/java/gq/kirmanak/mealient/test/AuthImplTestData.kt @@ -1,6 +1,8 @@ package gq.kirmanak.mealient.test import gq.kirmanak.mealient.data.baseurl.ServerVersion +import gq.kirmanak.mealient.datasource.v0.models.GetUserInfoResponseV0 +import gq.kirmanak.mealient.datasource.v1.models.GetUserInfoResponseV1 object AuthImplTestData { const val TEST_USERNAME = "TEST_USERNAME" @@ -13,4 +15,8 @@ object AuthImplTestData { const val TEST_VERSION = "v0.5.6" val TEST_SERVER_VERSION_V0 = ServerVersion.V0 val TEST_SERVER_VERSION_V1 = ServerVersion.V1 + + val FAVORITE_RECIPES_LIST = listOf("cake", "porridge") + val USER_INFO_V1 = GetUserInfoResponseV1("userId", FAVORITE_RECIPES_LIST) + val USER_INFO_V0 = GetUserInfoResponseV0(3, FAVORITE_RECIPES_LIST) } \ No newline at end of file From 7da36931acbc862ddfc2b18d97fb64cde7b7a489 Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Fri, 16 Dec 2022 17:48:15 +0100 Subject: [PATCH 16/16] Fix failing tests --- .../gq/kirmanak/mealient/data/recipes/impl/RecipeRepoTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoTest.kt index ee03000..fe98583 100644 --- a/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoTest.kt +++ b/app/src/test/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoTest.kt @@ -25,7 +25,7 @@ class RecipeRepoTest : BaseUnitTest() { @MockK(relaxUnitFun = true) lateinit var storage: RecipeStorage - @MockK + @MockK(relaxUnitFun = true) lateinit var dataSource: RecipeDataSource @MockK