6 Commits

Author SHA1 Message Date
1be2cb425c Merge pull request 'BUG FIX: Favourite state was not showing' (#2) from bugfix-round into main
Reviewed-on: #2
2025-08-01 19:59:27 +00:00
571db144c4 Fixed a bug with favourites 2025-08-01 13:57:52 -06:00
49c9a6dce1 Update LICENSE 2025-08-01 06:34:19 +00:00
c4e6d6b69f Build 0.5.0 2025-07-31 18:44:36 -06:00
9ecfcc2a74 No actions 2025-07-31 18:36:31 -06:00
f5db153ac2 plssss
Some checks failed
Check / check (pull_request) Has been cancelled
Check / uiTests (30) (pull_request) Has been cancelled
Sign / sign (push) Has been cancelled
2025-07-31 18:35:40 -06:00
24 changed files with 606 additions and 583 deletions

View File

@@ -1,91 +0,0 @@
name: Check
on: [ pull_request ]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2
with:
fastlaneDir: ./fastlane/metadata/android
usePlayStoreLocales: true
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Checks
run: ./gradlew check :app:koverXmlReportRelease :app:koverVerifyRelease
- name: SonarCloud
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: ./gradlew sonar
- name: Publish test reports
uses: mikepenz/action-junit-report@v4
if: always() # always run even if the previous step fails
with:
report_paths: './**/build/test-results/**/TEST-*.xml'
uiTests:
runs-on: ubuntu-latest
strategy:
matrix:
api-level: [ 30 ]
steps:
- name: Free Disk Space (Ubuntu)
uses: jlumbroso/free-disk-space@v1.3.1
with:
android: false
large-packages: true
tool-cache: true
dotnet: true
haskell: true
docker-images: true
swap-storage: true
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- uses: actions/checkout@v4
name: Checkout the code
with:
fetch-depth: 0
- uses: actions/setup-java@v4
name: Setup JDK 17
with:
distribution: 'zulu'
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
validate-wrappers: true
gradle-home-cache-cleanup: true
- name: Run tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ matrix.api-level }}
arch: x86_64
disable-animations: true
disk-size: 6000M
heap-size: 600M
script: ./gradlew :app:connectedCheck

View File

@@ -1,61 +0,0 @@
name: Sign
on:
push:
branches:
- master
jobs:
sign:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/gradle-build-action@v3
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Restore keystore
env:
MEALIENT_KEY_STORE: ${{ secrets.MEALIENT_KEY_STORE }}
MEALIENT_KEY_STORE_PASSWORD: ${{ secrets.MEALIENT_KEY_STORE_PASSWORD }}
MEALIENT_KEY_ALIAS: ${{ secrets.MEALIENT_KEY_ALIAS }}
MEALIENT_KEY_PASSWORD: ${{ secrets.MEALIENT_KEY_PASSWORD }}
run: |
echo "$MEALIENT_KEY_STORE" | base64 -d > app/keystore.jks
echo "storeFile=keystore.jks" > keystore.properties
echo "storePassword=$MEALIENT_KEY_STORE_PASSWORD" >> keystore.properties
echo "keyAlias=$MEALIENT_KEY_ALIAS" >> keystore.properties
echo "keyPassword=$MEALIENT_KEY_PASSWORD" >> keystore.properties
- name: APK
run: |
./gradlew build
cp app/build/outputs/apk/release/*.apk mealient-release.apk
- name: Bundle
run: |
./gradlew bundle
cp app/build/outputs/bundle/release/*.aab mealient-release.aab
- name: Upload release build
uses: actions/upload-artifact@v4
with:
name: Release build
path: |
mealient-release.apk
mealient-release.aab
- name: SonarCloud
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: ./gradlew sonar

View File

@@ -1,6 +1,7 @@
MIT License
Copyright (c) 2022, Kirill Kamakin
Copyright (c) 2025, Atridad Lahiji
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -16,6 +17,6 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
LIABILITY, WHETHER IN AN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,11 +1,13 @@
# Mealient
## DISCLAIMER
## DISCLAIMERS
This project is developed independently from the core Mealie project. It is NOT associated with the
core Mealie developers. Any issues must be reported to the Mealient repository, NOT the Mealie
repository.
Also, this is a fork of the original Mealient project. All credit goes to Kirill Kamakin on GitHub for the original project.
## What is this?
An unofficial Android client for [Mealie](https://github.com/mealie-recipes/mealie/). It enables you

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.atridad.mealient",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 38,
"versionName": "0.5.1",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 26
}

View File

@@ -11,9 +11,11 @@ import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
import com.atridad.mealient.model_mapper.ModelMapper
import javax.inject.Inject
class MealieDataSourceWrapper @Inject constructor(
private val dataSource: MealieDataSource,
private val modelMapper: ModelMapper,
class MealieDataSourceWrapper
@Inject
constructor(
private val dataSource: MealieDataSource,
private val modelMapper: ModelMapper,
) : AddRecipeDataSource, RecipeDataSource, ParseRecipeDataSource {
override suspend fun addRecipe(recipe: AddRecipeInfo): String {
@@ -23,10 +25,11 @@ class MealieDataSourceWrapper @Inject constructor(
}
override suspend fun requestRecipes(
start: Int,
limit: Int,
start: Int,
limit: Int,
): List<GetRecipeSummaryResponse> {
// Imagine start is 30 and limit is 15. It means that we already have page 1 and 2, now we need page 3
// Imagine start is 30 and limit is 15. It means that we already have page 1 and 2, now we
// need page 3
val page = start / limit + 1
return dataSource.requestRecipes(page, limit)
}
@@ -40,11 +43,31 @@ class MealieDataSourceWrapper @Inject constructor(
}
override suspend fun getFavoriteRecipes(): List<String> {
return dataSource.requestUserInfo().favoriteRecipes
val userInfo = dataSource.requestUserInfo()
// Use the correct favorites endpoint that actually works
return try {
val favoritesResponse = dataSource.getUserFavoritesAlternative(userInfo.id)
val favoriteRecipeIds =
favoritesResponse.ratings.filter { it.isFavorite }.map { it.recipeId }
// Get all recipes to create UUID-to-slug mapping
val allRecipes = dataSource.requestRecipes(1, -1) // Get all recipes
val uuidToSlugMap = allRecipes.associate { it.remoteId to it.slug }
// Map favorite UUIDs to slugs
val favoriteSlugs = favoriteRecipeIds.mapNotNull { uuid -> uuidToSlugMap[uuid] }
favoriteSlugs
} catch (e: Exception) {
emptyList()
}
}
override suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean) {
val userId = dataSource.requestUserInfo().id
val userInfo = dataSource.requestUserInfo()
val userId = userInfo.id
if (isFavorite) {
dataSource.addFavoriteRecipe(userId, recipeSlug)
} else {
@@ -55,4 +78,4 @@ class MealieDataSourceWrapper @Inject constructor(
override suspend fun deleteRecipe(recipeSlug: String) {
dataSource.deleteRecipe(recipeSlug)
}
}
}

View File

@@ -11,30 +11,34 @@ import com.atridad.mealient.database.recipe.entity.RecipeSummaryEntity
import com.atridad.mealient.datasource.runCatchingExceptCancel
import com.atridad.mealient.logging.Logger
import com.atridad.mealient.model_mapper.ModelMapper
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@OptIn(ExperimentalPagingApi::class)
@Singleton
class RecipesRemoteMediator @Inject constructor(
private val storage: RecipeStorage,
private val network: RecipeDataSource,
private val pagingSourceFactory: RecipePagingSourceFactory,
private val logger: Logger,
private val modelMapper: ModelMapper,
private val dispatchers: AppDispatchers,
class RecipesRemoteMediator
@Inject
constructor(
private val storage: RecipeStorage,
private val network: RecipeDataSource,
private val pagingSourceFactory: RecipePagingSourceFactory,
private val logger: Logger,
private val modelMapper: ModelMapper,
private val dispatchers: AppDispatchers,
) : RemoteMediator<Int, RecipeSummaryEntity>() {
@VisibleForTesting
var lastRequestEnd: Int = 0
@VisibleForTesting var lastRequestEnd: Int = 0
override suspend fun load(
loadType: LoadType, state: PagingState<Int, RecipeSummaryEntity>
loadType: LoadType,
state: PagingState<Int, RecipeSummaryEntity>
): MediatorResult {
logger.v { "load() called with: lastRequestEnd = $lastRequestEnd, loadType = $loadType, state = $state" }
logger.v {
"load() called with: lastRequestEnd = $lastRequestEnd, loadType = $loadType, state = $state"
}
if (loadType == PREPEND) {
logger.i { "load: early exit, PREPEND isn't supported" }
@@ -44,17 +48,17 @@ class RecipesRemoteMediator @Inject constructor(
val start = if (loadType == REFRESH) 0 else lastRequestEnd
val limit = if (loadType == REFRESH) state.config.initialLoadSize else state.config.pageSize
val count: Int = runCatchingExceptCancel {
updateRecipes(start, limit, loadType)
}.getOrElse {
logger.e(it) { "load: can't load recipes" }
return MediatorResult.Error(it)
}
val count: Int =
runCatchingExceptCancel { updateRecipes(start, limit, loadType) }.getOrElse {
logger.e(it) { "load: can't load recipes" }
return MediatorResult.Error(it)
}
// After something is inserted into DB the paging sources have to be invalidated
// But for some reason Room/Paging library don't do it automatically
// Here we invalidate them manually.
// Read that trick here https://github.com/android/architecture-components-samples/issues/889#issuecomment-880847858
// Read that trick here
// https://github.com/android/architecture-components-samples/issues/889#issuecomment-880847858
pagingSourceFactory.invalidate()
logger.d { "load: expectedCount = $limit, received $count" }
@@ -63,25 +67,30 @@ class RecipesRemoteMediator @Inject constructor(
}
suspend fun updateRecipes(
start: Int,
limit: Int,
loadType: LoadType = REFRESH,
start: Int,
limit: Int,
loadType: LoadType = REFRESH,
): Int = coroutineScope {
logger.v { "updateRecipes() called with: start = $start, limit = $limit, loadType = $loadType" }
val deferredRecipes = async { network.requestRecipes(start, limit) }
val favorites = runCatchingExceptCancel {
network.getFavoriteRecipes()
}.getOrDefault(emptyList()).toHashSet()
val recipes = deferredRecipes.await()
val entities = withContext(dispatchers.default) {
recipes.map { recipe ->
val isFavorite = favorites.contains(recipe.slug)
modelMapper.toRecipeSummaryEntity(recipe, isFavorite)
}
logger.v {
"updateRecipes() called with: start = $start, limit = $limit, loadType = $loadType"
}
if (loadType == REFRESH) storage.refreshAll(entities)
else storage.saveRecipes(entities)
val deferredRecipes = async { network.requestRecipes(start, limit) }
val favorites =
runCatchingExceptCancel { network.getFavoriteRecipes() }
.getOrDefault(emptyList())
.toHashSet()
val recipes = deferredRecipes.await()
val entities =
withContext(dispatchers.default) {
recipes.map { recipe ->
val isFavorite = favorites.contains(recipe.slug)
modelMapper.toRecipeSummaryEntity(recipe, isFavorite)
}
}
if (loadType == REFRESH) storage.refreshAll(entities) else storage.saveRecipes(entities)
recipes.size
}
}
}

View File

@@ -5,13 +5,14 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.map
import dagger.hilt.android.lifecycle.HiltViewModel
import com.atridad.mealient.architecture.valueUpdatesOnly
import com.atridad.mealient.data.auth.AuthRepo
import com.atridad.mealient.data.recipes.RecipeRepo
import com.atridad.mealient.data.recipes.impl.RecipeImageUrlProvider
import com.atridad.mealient.database.recipe.entity.RecipeSummaryEntity
import com.atridad.mealient.logging.Logger
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -23,44 +24,48 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
internal class RecipesListViewModel @Inject constructor(
private val recipeRepo: RecipeRepo,
private val logger: Logger,
private val recipeImageUrlProvider: RecipeImageUrlProvider,
authRepo: AuthRepo,
internal class RecipesListViewModel
@Inject
constructor(
private val recipeRepo: RecipeRepo,
private val logger: Logger,
private val recipeImageUrlProvider: RecipeImageUrlProvider,
authRepo: AuthRepo,
) : ViewModel() {
private val pagingData: Flow<PagingData<RecipeSummaryEntity>> =
recipeRepo.createPager().flow.cachedIn(viewModelScope)
recipeRepo.createPager().flow.cachedIn(viewModelScope)
private val showFavoriteIcon: StateFlow<Boolean> =
authRepo.isAuthorizedFlow.stateIn(viewModelScope, SharingStarted.Eagerly, false)
authRepo.isAuthorizedFlow.stateIn(viewModelScope, SharingStarted.Eagerly, false)
private val pagingDataRecipeState: Flow<PagingData<RecipeListItemState>> =
pagingData.combine(showFavoriteIcon) { data, showFavorite ->
data.map { item ->
val imageUrl = recipeImageUrlProvider.generateImageUrl(item.imageId)
RecipeListItemState(
imageUrl = imageUrl,
showFavoriteIcon = showFavorite,
entity = item,
)
pagingData.combine(showFavoriteIcon) { data, showFavorite ->
data.map { item ->
val imageUrl = recipeImageUrlProvider.generateImageUrl(item.imageId)
RecipeListItemState(
imageUrl = imageUrl,
showFavoriteIcon = showFavorite,
entity = item,
)
}
}
}
private val _screenState = MutableStateFlow(
RecipeListState(pagingDataRecipeState = pagingDataRecipeState)
)
val screenState: StateFlow<RecipeListState> get() = _screenState.asStateFlow()
private val _screenState =
MutableStateFlow(RecipeListState(pagingDataRecipeState = pagingDataRecipeState))
val screenState: StateFlow<RecipeListState>
get() = _screenState.asStateFlow()
init {
authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized ->
logger.v { "Authorization state changed to $hasAuthorized" }
if (hasAuthorized) recipeRepo.refreshRecipes()
}.launchIn(viewModelScope)
authRepo.isAuthorizedFlow
.valueUpdatesOnly()
.onEach { hasAuthorized ->
logger.v { "Authorization state changed to $hasAuthorized" }
if (hasAuthorized) recipeRepo.refreshRecipes()
}
.launchIn(viewModelScope)
}
private fun onRecipeClicked(entity: RecipeSummaryEntity) {
@@ -75,23 +80,23 @@ internal class RecipesListViewModel @Inject constructor(
private fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) {
logger.v { "onFavoriteIconClick() called with: recipeSummaryEntity = $recipeSummaryEntity" }
viewModelScope.launch {
val result = recipeRepo.updateIsRecipeFavorite(
recipeSlug = recipeSummaryEntity.slug,
isFavorite = recipeSummaryEntity.isFavorite.not(),
)
val snackbar = result.fold(
onSuccess = { isFavorite ->
val name = recipeSummaryEntity.name
if (isFavorite) {
RecipeListSnackbar.FavoriteAdded(name)
} else {
RecipeListSnackbar.FavoriteRemoved(name)
}
},
onFailure = {
RecipeListSnackbar.FavoriteUpdateFailed
}
)
val result =
recipeRepo.updateIsRecipeFavorite(
recipeSlug = recipeSummaryEntity.slug,
isFavorite = recipeSummaryEntity.isFavorite.not(),
)
val snackbar =
result.fold(
onSuccess = { _ ->
val name = recipeSummaryEntity.name
if (recipeSummaryEntity.isFavorite) {
RecipeListSnackbar.FavoriteRemoved(name)
} else {
RecipeListSnackbar.FavoriteAdded(name)
}
},
onFailure = { RecipeListSnackbar.FavoriteUpdateFailed }
)
_screenState.update { it.copy(snackbarState = snackbar) }
}
}
@@ -101,10 +106,11 @@ internal class RecipesListViewModel @Inject constructor(
viewModelScope.launch {
val result = recipeRepo.deleteRecipe(recipeSummaryEntity)
logger.d { "onDeleteConfirm: delete result is $result" }
val snackbar = result.fold(
onSuccess = { null },
onFailure = { RecipeListSnackbar.DeleteFailed },
)
val snackbar =
result.fold(
onSuccess = { null },
onFailure = { RecipeListSnackbar.DeleteFailed },
)
_screenState.update { it.copy(snackbarState = snackbar) }
}
}
@@ -138,10 +144,10 @@ internal class RecipesListViewModel @Inject constructor(
}
internal data class RecipeListState(
val pagingDataRecipeState: Flow<PagingData<RecipeListItemState>>,
val snackbarState: RecipeListSnackbar? = null,
val recipeIdToOpen: String? = null,
val searchQuery: String = "",
val pagingDataRecipeState: Flow<PagingData<RecipeListItemState>>,
val snackbarState: RecipeListSnackbar? = null,
val recipeIdToOpen: String? = null,
val searchQuery: String = "",
)
internal sealed interface RecipeListEvent {
@@ -157,4 +163,4 @@ internal sealed interface RecipeListEvent {
data object SnackbarShown : RecipeListEvent
data class SearchQueryChanged(val query: String) : RecipeListEvent
}
}

View File

@@ -54,7 +54,11 @@ internal fun SearchTextField(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent
errorIndicatorColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
errorContainerColor = Color.Transparent
)
)
}
@@ -69,4 +73,4 @@ private fun SearchTextFieldPreview() {
placeholder = R.string.search_recipes_hint,
)
}
}
}

View File

@@ -9,22 +9,21 @@ internal interface RecipeDao {
@Query("SELECT * FROM recipe_summaries ORDER BY recipe_summaries_date_added DESC")
fun queryRecipesByPages(): PagingSource<Int, RecipeSummaryEntity>
@Query("SELECT * FROM recipe_summaries WHERE recipe_summaries_name LIKE '%' || :query || '%' ORDER BY recipe_summaries_date_added DESC")
@Query(
"SELECT * FROM recipe_summaries WHERE recipe_summaries_name LIKE '%' || :query || '%' ORDER BY recipe_summaries_date_added DESC"
)
fun queryRecipesByPages(query: String): PagingSource<Int, RecipeSummaryEntity>
@Transaction
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRecipeSummaries(recipeSummaryEntity: Iterable<RecipeSummaryEntity>)
@Transaction
@Query("DELETE FROM recipe_summaries")
suspend fun removeAllRecipes()
@Transaction @Query("DELETE FROM recipe_summaries") suspend fun removeAllRecipes()
@Query("SELECT * FROM recipe_summaries ORDER BY recipe_summaries_date_added DESC")
suspend fun queryAllRecipes(): List<RecipeSummaryEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRecipe(recipe: RecipeEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertRecipe(recipe: RecipeEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRecipes(recipe: List<RecipeEntity>)
@@ -36,19 +35,25 @@ internal interface RecipeDao {
suspend fun insertRecipeIngredients(ingredients: List<RecipeIngredientEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertIngredientToInstructionEntities(entities: List<RecipeIngredientToInstructionEntity>)
suspend fun insertIngredientToInstructionEntities(
entities: List<RecipeIngredientToInstructionEntity>
)
@Transaction
@SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) // The lint is wrong, the columns are actually used
@SuppressWarnings(
RoomWarnings.CURSOR_MISMATCH
) // The lint is wrong, the columns are actually used
@Query(
"SELECT * FROM recipe " +
"JOIN recipe_summaries USING(recipe_id) " +
"LEFT JOIN recipe_ingredient USING(recipe_id) " +
"LEFT JOIN recipe_instruction USING(recipe_id) " +
"LEFT JOIN recipe_ingredient_to_instruction USING(recipe_id) " +
"WHERE recipe.recipe_id = :recipeId"
"SELECT * FROM recipe " +
"JOIN recipe_summaries USING(recipe_id) " +
"LEFT JOIN recipe_ingredient USING(recipe_id) " +
"LEFT JOIN recipe_instruction USING(recipe_id) " +
"LEFT JOIN recipe_ingredient_to_instruction USING(recipe_id) " +
"WHERE recipe.recipe_id = :recipeId"
)
suspend fun queryFullRecipeInfo(recipeId: String): RecipeWithSummaryAndIngredientsAndInstructions?
suspend fun queryFullRecipeInfo(
recipeId: String
): RecipeWithSummaryAndIngredientsAndInstructions?
@Query("DELETE FROM recipe_ingredient WHERE recipe_id IN (:recipeIds)")
suspend fun deleteRecipeIngredients(vararg recipeIds: String)
@@ -59,12 +64,18 @@ internal interface RecipeDao {
@Query("DELETE FROM recipe_ingredient_to_instruction WHERE recipe_id IN (:recipeIds)")
suspend fun deleteRecipeIngredientToInstructions(vararg recipeIds: String)
@Query("UPDATE recipe_summaries SET recipe_summaries_is_favorite = 1 WHERE recipe_summaries_slug IN (:favorites)")
@Query(
"UPDATE recipe_summaries SET recipe_summaries_is_favorite = 1 WHERE recipe_summaries_slug IN (:favorites)"
)
suspend fun setFavorite(favorites: List<String>)
@Query("UPDATE recipe_summaries SET recipe_summaries_is_favorite = 0 WHERE recipe_summaries_slug NOT IN (:favorites)")
@Query("UPDATE recipe_summaries SET recipe_summaries_is_favorite = 0")
suspend fun setAllNonFavorite()
@Query(
"UPDATE recipe_summaries SET recipe_summaries_is_favorite = 0 WHERE recipe_summaries_slug NOT IN (:favorites)"
)
suspend fun setNonFavorite(favorites: List<String>)
@Delete
suspend fun deleteRecipe(entity: RecipeSummaryEntity)
}
@Delete suspend fun deleteRecipe(entity: RecipeSummaryEntity)
}

View File

@@ -12,10 +12,12 @@ import com.atridad.mealient.database.recipe.entity.RecipeWithSummaryAndIngredien
import com.atridad.mealient.logging.Logger
import javax.inject.Inject
internal class RecipeStorageImpl @Inject constructor(
private val db: AppDb,
private val logger: Logger,
private val recipeDao: RecipeDao,
internal class RecipeStorageImpl
@Inject
constructor(
private val db: AppDb,
private val logger: Logger,
private val recipeDao: RecipeDao,
) : RecipeStorage {
override suspend fun saveRecipes(recipes: List<RecipeSummaryEntity>) {
@@ -43,12 +45,14 @@ internal class RecipeStorageImpl @Inject constructor(
}
override suspend fun saveRecipeInfo(
recipe: RecipeEntity,
ingredients: List<RecipeIngredientEntity>,
instructions: List<RecipeInstructionEntity>,
ingredientToInstruction: List<RecipeIngredientToInstructionEntity>,
recipe: RecipeEntity,
ingredients: List<RecipeIngredientEntity>,
instructions: List<RecipeInstructionEntity>,
ingredientToInstruction: List<RecipeIngredientToInstructionEntity>,
) {
logger.v { "saveRecipeInfo() called with: recipe = $recipe, ingredients = $ingredients, instructions = $instructions, ingredientToInstructions = $ingredientToInstruction" }
logger.v {
"saveRecipeInfo() called with: recipe = $recipe, ingredients = $ingredients, instructions = $instructions, ingredientToInstructions = $ingredientToInstruction"
}
db.withTransaction {
recipeDao.insertRecipe(recipe)
@@ -63,7 +67,9 @@ internal class RecipeStorageImpl @Inject constructor(
}
}
override suspend fun queryRecipeInfo(recipeId: String): RecipeWithSummaryAndIngredientsAndInstructions? {
override suspend fun queryRecipeInfo(
recipeId: String
): RecipeWithSummaryAndIngredientsAndInstructions? {
logger.v { "queryRecipeInfo() called with: recipeId = $recipeId" }
val fullRecipeInfo = recipeDao.queryFullRecipeInfo(recipeId)
logger.v { "queryRecipeInfo() returned: $fullRecipeInfo" }
@@ -73,8 +79,12 @@ internal class RecipeStorageImpl @Inject constructor(
override suspend fun updateFavoriteRecipes(favorites: List<String>) {
logger.v { "updateFavoriteRecipes() called with: favorites = $favorites" }
db.withTransaction {
recipeDao.setFavorite(favorites)
recipeDao.setNonFavorite(favorites)
if (favorites.isNotEmpty()) {
recipeDao.setFavorite(favorites)
recipeDao.setNonFavorite(favorites)
} else {
recipeDao.setAllNonFavorite()
}
}
}
@@ -82,4 +92,4 @@ internal class RecipeStorageImpl @Inject constructor(
logger.v { "deleteRecipeBySlug() called with: entity = $entity" }
recipeDao.deleteRecipe(entity)
}
}
}

View File

@@ -12,6 +12,7 @@ import com.atridad.mealient.datasource.models.GetShoppingListItemResponse
import com.atridad.mealient.datasource.models.GetShoppingListResponse
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
import com.atridad.mealient.datasource.models.GetUnitsResponse
import com.atridad.mealient.datasource.models.GetUserFavoritesResponse
import com.atridad.mealient.datasource.models.GetUserInfoResponse
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
import com.atridad.mealient.datasource.models.UpdateRecipeRequest
@@ -20,39 +21,37 @@ import com.atridad.mealient.datasource.models.VersionResponse
interface MealieDataSource {
suspend fun createRecipe(
recipe: CreateRecipeRequest,
recipe: CreateRecipeRequest,
): String
suspend fun updateRecipe(
slug: String,
recipe: UpdateRecipeRequest,
slug: String,
recipe: UpdateRecipeRequest,
): GetRecipeResponse
/**
* Tries to acquire authentication token using the provided credentials
*/
/** Tries to acquire authentication token using the provided credentials */
suspend fun authenticate(
username: String,
password: String,
username: String,
password: String,
): String
suspend fun getVersionInfo(baseURL: String): VersionResponse
suspend fun requestRecipes(
page: Int,
perPage: Int,
page: Int,
perPage: Int,
): List<GetRecipeSummaryResponse>
suspend fun requestRecipeInfo(
slug: String,
slug: String,
): GetRecipeResponse
suspend fun parseRecipeFromURL(
request: ParseRecipeURLRequest,
request: ParseRecipeURLRequest,
): String
suspend fun createApiToken(
request: CreateApiTokenRequest,
request: CreateApiTokenRequest,
): CreateApiTokenResponse
suspend fun requestUserInfo(): GetUserInfoResponse
@@ -82,4 +81,6 @@ interface MealieDataSource {
suspend fun deleteShoppingList(id: String)
suspend fun updateShoppingListName(id: String, name: String)
}
suspend fun getUserFavoritesAlternative(userId: String): GetUserFavoritesResponse
}

View File

@@ -12,6 +12,7 @@ import com.atridad.mealient.datasource.models.GetShoppingListResponse
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
import com.atridad.mealient.datasource.models.GetTokenResponse
import com.atridad.mealient.datasource.models.GetUnitsResponse
import com.atridad.mealient.datasource.models.GetUserFavoritesResponse
import com.atridad.mealient.datasource.models.GetUserInfoResponse
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
import com.atridad.mealient.datasource.models.UpdateRecipeRequest
@@ -25,8 +26,8 @@ internal interface MealieService {
suspend fun createRecipe(addRecipeRequest: CreateRecipeRequest): String
suspend fun updateRecipe(
addRecipeRequest: UpdateRecipeRequest,
slug: String,
addRecipeRequest: UpdateRecipeRequest,
slug: String,
): GetRecipeResponse
suspend fun getVersion(baseURL: String): VersionResponse
@@ -68,6 +69,8 @@ internal interface MealieService {
suspend fun deleteShoppingList(id: String)
suspend fun updateShoppingList(id: String, request: JsonElement)
suspend fun getShoppingListJson(id: String) : JsonElement
}
suspend fun getShoppingListJson(id: String): JsonElement
suspend fun getUserFavoritesAlternative(userId: String): GetUserFavoritesResponse
}

View File

@@ -17,6 +17,7 @@ import com.atridad.mealient.datasource.models.GetShoppingListItemResponse
import com.atridad.mealient.datasource.models.GetShoppingListResponse
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
import com.atridad.mealient.datasource.models.GetUnitsResponse
import com.atridad.mealient.datasource.models.GetUserFavoritesResponse
import com.atridad.mealient.datasource.models.GetUserInfoResponse
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
import com.atridad.mealient.datasource.models.UpdateRecipeRequest
@@ -24,251 +25,309 @@ import com.atridad.mealient.datasource.models.VersionResponse
import io.ktor.client.call.NoTransformationFoundException
import io.ktor.client.call.body
import io.ktor.client.plugins.ResponseException
import java.net.SocketException
import java.net.SocketTimeoutException
import javax.inject.Inject
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import java.net.SocketException
import java.net.SocketTimeoutException
import javax.inject.Inject
internal class MealieDataSourceImpl @Inject constructor(
private val networkRequestWrapper: NetworkRequestWrapper,
private val service: MealieService,
internal class MealieDataSourceImpl
@Inject
constructor(
private val networkRequestWrapper: NetworkRequestWrapper,
private val service: MealieService,
) : MealieDataSource {
override suspend fun createRecipe(
recipe: CreateRecipeRequest,
): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createRecipe(recipe) },
logMethod = { "createRecipe" },
logParameters = { "recipe = $recipe" }
).trim('"')
recipe: CreateRecipeRequest,
): String =
networkRequestWrapper
.makeCallAndHandleUnauthorized(
block = { service.createRecipe(recipe) },
logMethod = { "createRecipe" },
logParameters = { "recipe = $recipe" }
)
.trim('"')
override suspend fun updateRecipe(
slug: String,
recipe: UpdateRecipeRequest,
): GetRecipeResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.updateRecipe(recipe, slug) },
logMethod = { "updateRecipe" },
logParameters = { "slug = $slug, recipe = $recipe" }
)
slug: String,
recipe: UpdateRecipeRequest,
): GetRecipeResponse =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.updateRecipe(recipe, slug) },
logMethod = { "updateRecipe" },
logParameters = { "slug = $slug, recipe = $recipe" }
)
override suspend fun authenticate(
username: String,
password: String,
): String = networkRequestWrapper.makeCall(
block = { service.getToken(username, password) },
logMethod = { "authenticate" },
logParameters = { "username = $username, password = $password" }
).map { it.accessToken }.getOrElse {
val errorDetail = (it as? ResponseException)?.response?.body<ErrorDetail>() ?: throw it
throw if (errorDetail.detail == "Unauthorized") NetworkError.Unauthorized(it) else it
}
username: String,
password: String,
): String =
networkRequestWrapper
.makeCall(
block = { service.getToken(username, password) },
logMethod = { "authenticate" },
logParameters = { "username = $username, password = $password" }
)
.map { it.accessToken }
.getOrElse {
val errorDetail =
(it as? ResponseException)?.response?.body<ErrorDetail>()
?: throw it
throw if (errorDetail.detail == "Unauthorized")
NetworkError.Unauthorized(it)
else it
}
override suspend fun getVersionInfo(baseURL: String): VersionResponse =
networkRequestWrapper.makeCall(
block = { service.getVersion(baseURL) },
logMethod = { "getVersionInfo" },
logParameters = { "baseURL = $baseURL" }
).getOrElse {
throw when (it) {
is ResponseException, is NoTransformationFoundException -> NetworkError.NotMealie(it)
is SocketTimeoutException, is SocketException -> NetworkError.NoServerConnection(it)
else -> NetworkError.MalformedUrl(it)
}
}
networkRequestWrapper.makeCall(
block = { service.getVersion(baseURL) },
logMethod = { "getVersionInfo" },
logParameters = { "baseURL = $baseURL" }
)
.getOrElse {
throw when (it) {
is ResponseException, is NoTransformationFoundException ->
NetworkError.NotMealie(it)
is SocketTimeoutException, is SocketException ->
NetworkError.NoServerConnection(it)
else -> NetworkError.MalformedUrl(it)
}
}
override suspend fun requestRecipes(
page: Int,
perPage: Int,
): List<GetRecipeSummaryResponse> = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipeSummary(page, perPage) },
logMethod = { "requestRecipes" },
logParameters = { "page = $page, perPage = $perPage" }
).items
page: Int,
perPage: Int,
): List<GetRecipeSummaryResponse> {
val response =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipeSummary(page, perPage) },
logMethod = { "requestRecipes" },
logParameters = { "page = $page, perPage = $perPage" }
)
return response.items
}
override suspend fun requestRecipeInfo(
slug: String,
): GetRecipeResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipe(slug) },
logMethod = { "requestRecipeInfo" },
logParameters = { "slug = $slug" }
)
slug: String,
): GetRecipeResponse =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipe(slug) },
logMethod = { "requestRecipeInfo" },
logParameters = { "slug = $slug" }
)
override suspend fun parseRecipeFromURL(
request: ParseRecipeURLRequest,
): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createRecipeFromURL(request) },
logMethod = { "parseRecipeFromURL" },
logParameters = { "request = $request" }
)
request: ParseRecipeURLRequest,
): String =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createRecipeFromURL(request) },
logMethod = { "parseRecipeFromURL" },
logParameters = { "request = $request" }
)
override suspend fun createApiToken(
request: CreateApiTokenRequest,
): CreateApiTokenResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createApiToken(request) },
logMethod = { "createApiToken" },
logParameters = { "request = $request" }
)
request: CreateApiTokenRequest,
): CreateApiTokenResponse =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createApiToken(request) },
logMethod = { "createApiToken" },
logParameters = { "request = $request" }
)
override suspend fun requestUserInfo(): GetUserInfoResponse {
return networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getUserSelfInfo() },
logMethod = { "requestUserInfo" },
)
val response =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getUserSelfInfo() },
logMethod = { "requestUserInfo" },
)
return response
}
override suspend fun removeFavoriteRecipe(
userId: String,
recipeSlug: String,
): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.removeFavoriteRecipe(userId, recipeSlug) },
logMethod = { "removeFavoriteRecipe" },
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
)
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" }
)
userId: String,
recipeSlug: String,
): Unit {
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.addFavoriteRecipe(userId, recipeSlug) },
logMethod = { "addFavoriteRecipe" },
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
)
}
override suspend fun deleteRecipe(
slug: String,
): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.deleteRecipe(slug) },
logMethod = { "deleteRecipe" },
logParameters = { "slug = $slug" }
)
slug: String,
): Unit =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.deleteRecipe(slug) },
logMethod = { "deleteRecipe" },
logParameters = { "slug = $slug" }
)
override suspend fun getShoppingLists(
page: Int,
perPage: Int,
): GetShoppingListsResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingLists(page, perPage) },
logMethod = { "getShoppingLists" },
logParameters = { "page = $page, perPage = $perPage" }
)
page: Int,
perPage: Int,
): GetShoppingListsResponse =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingLists(page, perPage) },
logMethod = { "getShoppingLists" },
logParameters = { "page = $page, perPage = $perPage" }
)
override suspend fun getShoppingList(
id: String,
): GetShoppingListResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingList(id) },
logMethod = { "getShoppingList" },
logParameters = { "id = $id" }
)
id: String,
): GetShoppingListResponse =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingList(id) },
logMethod = { "getShoppingList" },
logParameters = { "id = $id" }
)
private suspend fun getShoppingListItem(
id: String,
): JsonElement = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingListItem(id) },
logMethod = { "getShoppingListItem" },
logParameters = { "id = $id" }
)
id: String,
): JsonElement =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingListItem(id) },
logMethod = { "getShoppingListItem" },
logParameters = { "id = $id" }
)
private suspend fun updateShoppingListItem(
id: String,
request: JsonElement,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.updateShoppingListItem(id, request) },
logMethod = { "updateShoppingListItem" },
logParameters = { "id = $id, request = $request" }
)
id: String,
request: JsonElement,
) =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.updateShoppingListItem(id, request) },
logMethod = { "updateShoppingListItem" },
logParameters = { "id = $id, request = $request" }
)
override suspend fun deleteShoppingListItem(
id: String,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.deleteShoppingListItem(id) },
logMethod = { "deleteShoppingListItem" },
logParameters = { "id = $id" }
)
id: String,
) =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.deleteShoppingListItem(id) },
logMethod = { "deleteShoppingListItem" },
logParameters = { "id = $id" }
)
override suspend fun updateShoppingListItem(
item: GetShoppingListItemResponse,
item: GetShoppingListItemResponse,
) {
// Has to be done in two steps because we can't specify only the changed fields
val remoteItem = getShoppingListItem(item.id)
val updatedItem = remoteItem.jsonObject.toMutableMap().apply {
put("checked", JsonPrimitive(item.checked))
put("isFood", JsonPrimitive(item.isFood))
put("note", JsonPrimitive(item.note))
put("quantity", JsonPrimitive(item.quantity))
put("foodId", JsonPrimitive(item.food?.id))
put("unitId", JsonPrimitive(item.unit?.id))
remove("unit")
remove("food")
}
val updatedItem =
remoteItem.jsonObject.toMutableMap().apply {
put("checked", JsonPrimitive(item.checked))
put("isFood", JsonPrimitive(item.isFood))
put("note", JsonPrimitive(item.note))
put("quantity", JsonPrimitive(item.quantity))
put("foodId", JsonPrimitive(item.food?.id))
put("unitId", JsonPrimitive(item.unit?.id))
remove("unit")
remove("food")
}
updateShoppingListItem(item.id, JsonObject(updatedItem))
}
override suspend fun getFoods(): GetFoodsResponse {
return networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getFoods(perPage = -1) },
logMethod = { "getFoods" },
block = { service.getFoods(perPage = -1) },
logMethod = { "getFoods" },
)
}
override suspend fun getUnits(): GetUnitsResponse {
return networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getUnits(perPage = -1) },
logMethod = { "getUnits" },
block = { service.getUnits(perPage = -1) },
logMethod = { "getUnits" },
)
}
override suspend fun addShoppingListItem(
request: CreateShoppingListItemRequest,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createShoppingListItem(request) },
logMethod = { "addShoppingListItem" },
logParameters = { "request = $request" }
)
request: CreateShoppingListItemRequest,
) =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createShoppingListItem(request) },
logMethod = { "addShoppingListItem" },
logParameters = { "request = $request" }
)
override suspend fun addShoppingList(
request: CreateShoppingListRequest,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createShoppingList(request) },
logMethod = { "createShoppingList" },
logParameters = { "request = $request" }
)
request: CreateShoppingListRequest,
) =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createShoppingList(request) },
logMethod = { "createShoppingList" },
logParameters = { "request = $request" }
)
private suspend fun updateShoppingList(
id: String,
request: JsonElement,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.updateShoppingList(id, request) },
logMethod = { "updateShoppingList" },
logParameters = { "id = $id, request = $request" }
)
id: String,
request: JsonElement,
) =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.updateShoppingList(id, request) },
logMethod = { "updateShoppingList" },
logParameters = { "id = $id, request = $request" }
)
private suspend fun getShoppingListJson(
id: String,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingListJson(id) },
logMethod = { "getShoppingListJson" },
logParameters = { "id = $id" }
)
id: String,
) =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingListJson(id) },
logMethod = { "getShoppingListJson" },
logParameters = { "id = $id" }
)
override suspend fun deleteShoppingList(
id: String,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.deleteShoppingList(id) },
logMethod = { "deleteShoppingList" },
logParameters = { "id = $id" }
)
id: String,
) =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.deleteShoppingList(id) },
logMethod = { "deleteShoppingList" },
logParameters = { "id = $id" }
)
override suspend fun updateShoppingListName(
id: String,
name: String
) {
override suspend fun updateShoppingListName(id: String, name: String) {
// Has to be done in two steps because we can't specify only the changed fields
val remoteItem = getShoppingListJson(id)
val updatedItem = remoteItem.jsonObject.toMutableMap().apply {
put("name", JsonPrimitive(name))
}.let(::JsonObject)
val updatedItem =
remoteItem
.jsonObject
.toMutableMap()
.apply { put("name", JsonPrimitive(name)) }
.let(::JsonObject)
updateShoppingList(id, updatedItem)
}
override suspend fun getUserFavoritesAlternative(userId: String): GetUserFavoritesResponse {
val response =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getUserFavoritesAlternative(userId) },
logMethod = { "getUserFavoritesAlternative" },
logParameters = { "userId = $userId" }
)
return response
}
}

View File

@@ -14,6 +14,7 @@ import com.atridad.mealient.datasource.models.GetShoppingListResponse
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
import com.atridad.mealient.datasource.models.GetTokenResponse
import com.atridad.mealient.datasource.models.GetUnitsResponse
import com.atridad.mealient.datasource.models.GetUserFavoritesResponse
import com.atridad.mealient.datasource.models.GetUserInfoResponse
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
import com.atridad.mealient.datasource.models.UpdateRecipeRequest
@@ -34,13 +35,15 @@ import io.ktor.http.contentType
import io.ktor.http.parameters
import io.ktor.http.path
import io.ktor.http.takeFrom
import kotlinx.serialization.json.JsonElement
import javax.inject.Inject
import javax.inject.Provider
import kotlinx.serialization.json.JsonElement
internal class MealieServiceKtor @Inject constructor(
private val httpClient: HttpClient,
private val serverUrlProviderProvider: Provider<ServerUrlProvider>,
internal class MealieServiceKtor
@Inject
constructor(
private val httpClient: HttpClient,
private val serverUrlProviderProvider: Provider<ServerUrlProvider>,
) : MealieService {
private val serverUrlProvider: ServerUrlProvider
@@ -52,111 +55,109 @@ internal class MealieServiceKtor @Inject constructor(
append("password", password)
}
return httpClient.post {
endpoint("/api/auth/token")
setBody(FormDataContent(formParameters))
}.body()
return httpClient
.post {
endpoint("/api/auth/token")
setBody(FormDataContent(formParameters))
}
.body()
}
override suspend fun createRecipe(addRecipeRequest: CreateRecipeRequest): String {
return httpClient.post {
endpoint("/api/recipes")
contentType(ContentType.Application.Json)
setBody(addRecipeRequest)
}.body()
return httpClient
.post {
endpoint("/api/recipes")
contentType(ContentType.Application.Json)
setBody(addRecipeRequest)
}
.body()
}
override suspend fun updateRecipe(
addRecipeRequest: UpdateRecipeRequest,
slug: String,
addRecipeRequest: UpdateRecipeRequest,
slug: String,
): GetRecipeResponse {
return httpClient.patch {
endpoint("/api/recipes/$slug")
contentType(ContentType.Application.Json)
setBody(addRecipeRequest)
}.body()
return httpClient
.patch {
endpoint("/api/recipes/$slug")
contentType(ContentType.Application.Json)
setBody(addRecipeRequest)
}
.body()
}
override suspend fun getVersion(baseURL: String): VersionResponse {
return httpClient.get {
endpoint(baseURL, "/api/app/about")
}.body()
return httpClient.get { endpoint(baseURL, "/api/app/about") }.body()
}
override suspend fun getRecipeSummary(page: Int, perPage: Int): GetRecipesResponse {
return httpClient.get {
endpoint("/api/recipes") {
parameters.append("page", page.toString())
parameters.append("perPage", perPage.toString())
}
}.body()
return httpClient
.get {
endpoint("/api/recipes") {
parameters.append("page", page.toString())
parameters.append("perPage", perPage.toString())
}
}
.body()
}
override suspend fun getRecipe(slug: String): GetRecipeResponse {
return httpClient.get {
endpoint("/api/recipes/$slug")
}.body()
return httpClient.get { endpoint("/api/recipes/$slug") }.body()
}
override suspend fun createRecipeFromURL(request: ParseRecipeURLRequest): String {
return httpClient.post {
endpoint("/api/recipes/create-url")
contentType(ContentType.Application.Json)
setBody(request)
}.body()
return httpClient
.post {
endpoint("/api/recipes/create-url")
contentType(ContentType.Application.Json)
setBody(request)
}
.body()
}
override suspend fun createApiToken(request: CreateApiTokenRequest): CreateApiTokenResponse {
return httpClient.post {
endpoint("/api/users/api-tokens")
contentType(ContentType.Application.Json)
setBody(request)
}.body()
return httpClient
.post {
endpoint("/api/users/api-tokens")
contentType(ContentType.Application.Json)
setBody(request)
}
.body()
}
override suspend fun getUserSelfInfo(): GetUserInfoResponse {
return httpClient.get {
endpoint("/api/users/self")
}.body()
return httpClient.get { endpoint("/api/users/self") }.body()
}
override suspend fun removeFavoriteRecipe(userId: String, recipeSlug: String) {
httpClient.delete {
endpoint("/api/users/$userId/favorites/$recipeSlug")
}
httpClient.delete { endpoint("/api/users/$userId/favorites/$recipeSlug") }
}
override suspend fun addFavoriteRecipe(userId: String, recipeSlug: String) {
httpClient.post {
endpoint("/api/users/$userId/favorites/$recipeSlug")
}
httpClient.post { endpoint("/api/users/$userId/favorites/$recipeSlug") }
}
override suspend fun deleteRecipe(slug: String) {
httpClient.delete {
endpoint("/api/recipes/$slug")
}
httpClient.delete { endpoint("/api/recipes/$slug") }
}
override suspend fun getShoppingLists(page: Int, perPage: Int): GetShoppingListsResponse {
return httpClient.get {
endpoint("/api/households/shopping/lists") {
parameters.append("page", page.toString())
parameters.append("perPage", perPage.toString())
}
}.body()
return httpClient
.get {
endpoint("/api/households/shopping/lists") {
parameters.append("page", page.toString())
parameters.append("perPage", perPage.toString())
}
}
.body()
}
override suspend fun getShoppingList(id: String): GetShoppingListResponse {
return httpClient.get {
endpoint("/api/households/shopping/lists/$id")
}.body()
return httpClient.get { endpoint("/api/households/shopping/lists/$id") }.body()
}
override suspend fun getShoppingListItem(id: String): JsonElement {
return httpClient.get {
endpoint("/api/households/shopping/items/$id")
}.body()
return httpClient.get { endpoint("/api/households/shopping/items/$id") }.body()
}
override suspend fun updateShoppingListItem(id: String, request: JsonElement) {
@@ -168,25 +169,19 @@ internal class MealieServiceKtor @Inject constructor(
}
override suspend fun deleteShoppingListItem(id: String) {
httpClient.delete {
endpoint("/api/households/shopping/items/$id")
}
httpClient.delete { endpoint("/api/households/shopping/items/$id") }
}
override suspend fun getFoods(perPage: Int): GetFoodsResponse {
return httpClient.get {
endpoint("/api/foods") {
parameters.append("perPage", perPage.toString())
}
}.body()
return httpClient
.get { endpoint("/api/foods") { parameters.append("perPage", perPage.toString()) } }
.body()
}
override suspend fun getUnits(perPage: Int): GetUnitsResponse {
return httpClient.get {
endpoint("/api/units") {
parameters.append("perPage", perPage.toString())
}
}.body()
return httpClient
.get { endpoint("/api/units") { parameters.append("perPage", perPage.toString()) } }
.body()
}
override suspend fun createShoppingListItem(request: CreateShoppingListItemRequest) {
@@ -206,9 +201,7 @@ internal class MealieServiceKtor @Inject constructor(
}
override suspend fun deleteShoppingList(id: String) {
httpClient.delete {
endpoint("/api/households/shopping/lists/$id")
}
httpClient.delete { endpoint("/api/households/shopping/lists/$id") }
}
override suspend fun updateShoppingList(id: String, request: JsonElement) {
@@ -220,27 +213,25 @@ internal class MealieServiceKtor @Inject constructor(
}
override suspend fun getShoppingListJson(id: String): JsonElement {
return httpClient.get {
endpoint("/api/households/shopping/lists/$id")
}.body()
return httpClient.get { endpoint("/api/households/shopping/lists/$id") }.body()
}
override suspend fun getUserFavoritesAlternative(userId: String): GetUserFavoritesResponse {
return httpClient.get { endpoint("/api/users/$userId/favorites") }.body()
}
private suspend fun HttpRequestBuilder.endpoint(
path: String,
block: URLBuilder.() -> Unit = {},
path: String,
block: URLBuilder.() -> Unit = {},
) {
val baseUrl = checkNotNull(serverUrlProvider.getUrl()) { "Server URL is not set" }
endpoint(
baseUrl = baseUrl,
path = path,
block = block
)
endpoint(baseUrl = baseUrl, path = path, block = block)
}
private fun HttpRequestBuilder.endpoint(
baseUrl: String,
path: String,
block: URLBuilder.() -> Unit = {},
baseUrl: String,
path: String,
block: URLBuilder.() -> Unit = {},
) {
url {
takeFrom(baseUrl)

View File

@@ -0,0 +1,18 @@
package com.atridad.mealient.datasource.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GetUserFavoritesResponse(
@SerialName("ratings") val ratings: List<UserRatingResponse> = emptyList(),
)
@Serializable
data class UserRatingResponse(
@SerialName("recipeId") val recipeId: String,
@SerialName("rating") val rating: Double? = null,
@SerialName("isFavorite") val isFavorite: Boolean,
@SerialName("userId") val userId: String,
@SerialName("id") val id: String,
)

View File

@@ -1,6 +1,6 @@
syntax = "proto3";
option java_package = "gq.kirmanak.mealient.datastore.recipe";
option java_package = "com.atridad.mealient.datastore.recipe";
option java_multiple_files = true;
message AddRecipeInput {
@@ -11,4 +11,4 @@ message AddRecipeInput {
repeated string recipeIngredients = 5;
bool isRecipePublic = 6;
bool areCommentsDisabled = 7;
}
}

View File

@@ -8,7 +8,7 @@ plugins {
}
android {
namespace = "com.atridad.mealient.shopping_lists"
namespace = "com.atridad.mealient.shopping_list"
}
ksp {

View File

@@ -1,6 +1,6 @@
[versions]
# https://maven.google.com/web/index.html?q=com.android.tools.build#com.android.tools.build:gradle
androidGradlePlugin = "8.5.2"
androidGradlePlugin = "8.9.0"
# https://github.com/JetBrains/kotlin/releases
kotlin = "2.0.10"
# https://dagger.dev/hilt/gradle-setup

View File

@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=31c55713e40233a8303827ceb42ca48a47267a0ad4bab9177123121e71524c26
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME