7 Commits

Author SHA1 Message Date
7fb8b195f0 README change 2025-08-12 01:21:34 -06:00
77f48c603d 0.6.0 2025-08-05 10:52:12 -06:00
2d4214562a Added the profile feature 2025-08-05 10:51:31 -06:00
f7bd6643cb Update README.md 2025-08-01 15:42:23 -06:00
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
34 changed files with 1959 additions and 449 deletions

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,29 +1,26 @@
# Mealient
## DISCLAIMER
## USAGE REQUIREMENTS
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.
- Android 8.0 or higher
- A Mealie server running v3 or higher
## DOWNLOAD
You have two options:
1. Download the latest APK from the Released page
2. Use <a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.atridad.mealient%22%2C%22url%22%3A%22https%3A%2F%2Fgit.atri.dad%2Fatridad%2FMealient%2Freleases%22%2C%22author%22%3A%22git.atri.dad%22%2C%22name%22%3A%22Mealient%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22partialAPKHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D">Obtainium</a>
## 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
An **unofficial** Android client for [Mealie](https://github.com/mealie-recipes/mealie/). It enables you
to
easily access your recipes using an Android device. The main advantage over website is that
recipe data is stored locally and can be accessed without the Internet connection.
## Status
Current version is a very early alpha which supports a small subset of the Mealie capabilities.
Displays the list of recipes, some information about each of the recipes, even recipe creation is
available!
The list of shopping lists is also available, each shopping list can be viewed and modified.
## How to install
Download the latest apk from the releases page.
## Contribution
Any contribution is greatly appreciated: translations, bug reports, feature requests and any PR.

View File

@@ -13,8 +13,8 @@ plugins {
android {
defaultConfig {
applicationId = "com.atridad.mealient"
versionCode = 37
versionName = "0.5.0"
versionCode = 38
versionName = "0.6.0"
testInstrumentationRunner = "com.atridad.mealient.MealientTestRunner"
testInstrumentationRunnerArguments += mapOf("clearPackageData" to "true")
resourceConfigurations += listOf("en", "es", "ru", "fr", "nl", "pt", "de")
@@ -77,6 +77,7 @@ dependencies {
implementation(project(":logging"))
implementation(project(":ui"))
implementation(project(":features:shopping_lists"))
implementation(project(":features:user_managment"))
implementation(project(":model_mapper"))
implementation(libs.android.material.material)
implementation(libs.androidx.coreKtx)
@@ -103,6 +104,7 @@ dependencies {
kover(project(":datasource"))
kover(project(":datastore"))
kover(project(":features:shopping_lists"))
kover(project(":features:user_managment"))
kover(project(":logging"))
kover(project(":model_mapper"))
kover(project(":ui"))

Binary file not shown.

Binary file not shown.

View File

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

View File

@@ -11,7 +11,9 @@ import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
import com.atridad.mealient.model_mapper.ModelMapper
import javax.inject.Inject
class MealieDataSourceWrapper @Inject constructor(
class MealieDataSourceWrapper
@Inject
constructor(
private val dataSource: MealieDataSource,
private val modelMapper: ModelMapper,
) : AddRecipeDataSource, RecipeDataSource, ParseRecipeDataSource {
@@ -26,7 +28,8 @@ class MealieDataSourceWrapper @Inject constructor(
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 {

View File

@@ -11,15 +11,17 @@ 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(
class RecipesRemoteMediator
@Inject
constructor(
private val storage: RecipeStorage,
private val network: RecipeDataSource,
private val pagingSourceFactory: RecipePagingSourceFactory,
@@ -28,13 +30,15 @@ class RecipesRemoteMediator @Inject constructor(
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,9 +48,8 @@ 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 {
val count: Int =
runCatchingExceptCancel { updateRecipes(start, limit, loadType) }.getOrElse {
logger.e(it) { "load: can't load recipes" }
return MediatorResult.Error(it)
}
@@ -54,7 +57,8 @@ class RecipesRemoteMediator @Inject constructor(
// 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" }
@@ -67,21 +71,26 @@ class RecipesRemoteMediator @Inject constructor(
limit: Int,
loadType: LoadType = REFRESH,
): Int = coroutineScope {
logger.v { "updateRecipes() called with: start = $start, limit = $limit, loadType = $loadType" }
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 favorites =
runCatchingExceptCancel { network.getFavoriteRecipes() }
.getOrDefault(emptyList())
.toHashSet()
val recipes = deferredRecipes.await()
val entities = withContext(dispatchers.default) {
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)
if (loadType == REFRESH) storage.refreshAll(entities) else storage.saveRecipes(entities)
recipes.size
}
}

View File

@@ -11,6 +11,7 @@ import com.atridad.mealient.ui.destinations.BaseURLScreenDestination
import com.atridad.mealient.ui.destinations.DisclaimerScreenDestination
import com.atridad.mealient.ui.destinations.RecipeScreenDestination
import com.atridad.mealient.ui.destinations.RecipesListDestination
import com.mealient.user_management.ui.profile.destinations.UserProfileScreenDestination
internal object NavGraphs {
@@ -40,6 +41,7 @@ internal object NavGraphs {
DisclaimerScreenDestination,
BaseURLScreenDestination,
AuthenticationScreenDestination,
UserProfileScreenDestination,
),
nestedNavGraphs = listOf(
recipes,

View File

@@ -6,6 +6,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material.icons.filled.SyncAlt
import androidx.compose.material3.DrawerState
@@ -28,6 +29,7 @@ import com.atridad.mealient.ui.components.DrawerItem
import com.atridad.mealient.ui.destinations.AddRecipeScreenDestination
import com.atridad.mealient.ui.destinations.BaseURLScreenDestination
import com.atridad.mealient.ui.destinations.RecipesListDestination
import com.mealient.user_management.ui.profile.destinations.UserProfileScreenDestination
import kotlinx.coroutines.launch
@Composable
@@ -91,6 +93,11 @@ internal fun createDrawerItems(
icon = Icons.Default.ShoppingCart,
direction = NavGraphs.shoppingLists,
),
createNavigationItem(
nameRes = R.string.menu_navigation_drawer_profile,
icon = Icons.Default.Person,
direction = UserProfileScreenDestination,
),
createNavigationItem(
nameRes = R.string.menu_navigation_drawer_change_url,
icon = Icons.Default.SyncAlt,

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,10 +24,11 @@ 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(
internal class RecipesListViewModel
@Inject
constructor(
private val recipeRepo: RecipeRepo,
private val logger: Logger,
private val recipeImageUrlProvider: RecipeImageUrlProvider,
@@ -51,16 +53,19 @@ internal class RecipesListViewModel @Inject constructor(
}
}
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 ->
authRepo.isAuthorizedFlow
.valueUpdatesOnly()
.onEach { hasAuthorized ->
logger.v { "Authorization state changed to $hasAuthorized" }
if (hasAuthorized) recipeRepo.refreshRecipes()
}.launchIn(viewModelScope)
}
.launchIn(viewModelScope)
}
private fun onRecipeClicked(entity: RecipeSummaryEntity) {
@@ -75,22 +80,22 @@ internal class RecipesListViewModel @Inject constructor(
private fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) {
logger.v { "onFavoriteIconClick() called with: recipeSummaryEntity = $recipeSummaryEntity" }
viewModelScope.launch {
val result = recipeRepo.updateIsRecipeFavorite(
val result =
recipeRepo.updateIsRecipeFavorite(
recipeSlug = recipeSummaryEntity.slug,
isFavorite = recipeSummaryEntity.isFavorite.not(),
)
val snackbar = result.fold(
onSuccess = { isFavorite ->
val snackbar =
result.fold(
onSuccess = { _ ->
val name = recipeSummaryEntity.name
if (isFavorite) {
RecipeListSnackbar.FavoriteAdded(name)
} else {
if (recipeSummaryEntity.isFavorite) {
RecipeListSnackbar.FavoriteRemoved(name)
} else {
RecipeListSnackbar.FavoriteAdded(name)
}
},
onFailure = {
RecipeListSnackbar.FavoriteUpdateFailed
}
onFailure = { RecipeListSnackbar.FavoriteUpdateFailed }
)
_screenState.update { it.copy(snackbarState = snackbar) }
}
@@ -101,7 +106,8 @@ internal class RecipesListViewModel @Inject constructor(
viewModelScope.launch {
val result = recipeRepo.deleteRecipe(recipeSummaryEntity)
logger.d { "onDeleteConfirm: delete result is $result" }
val snackbar = result.fold(
val snackbar =
result.fold(
onSuccess = { null },
onFailure = { RecipeListSnackbar.DeleteFailed },
)

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

View File

@@ -58,6 +58,7 @@
<string name="fragment_recipes_delete_recipe_confirm_dialog_message">Are you sure you want to delete %1$s? This cannot be undone.</string>
<string name="fragment_recipes_delete_recipe_confirm_dialog_positive_btn">Confirm</string>
<string name="fragment_recipes_delete_recipe_confirm_dialog_negative_btn">Cancel</string>
<string name="menu_navigation_drawer_profile">Profile</string>
<string name="menu_navigation_drawer_change_url">Change URL</string>
<string name="search_recipes_hint">Search recipes</string>
<string name="view_toolbar_navigation_icon_content_description">Open navigation drawer</string>

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,10 +35,14 @@ 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) " +
@@ -48,7 +51,9 @@ internal interface RecipeDao {
"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,7 +12,9 @@ import com.atridad.mealient.database.recipe.entity.RecipeWithSummaryAndIngredien
import com.atridad.mealient.logging.Logger
import javax.inject.Inject
internal class RecipeStorageImpl @Inject constructor(
internal class RecipeStorageImpl
@Inject
constructor(
private val db: AppDb,
private val logger: Logger,
private val recipeDao: RecipeDao,
@@ -48,7 +50,9 @@ internal class RecipeStorageImpl @Inject constructor(
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 {
if (favorites.isNotEmpty()) {
recipeDao.setFavorite(favorites)
recipeDao.setNonFavorite(favorites)
} else {
recipeDao.setAllNonFavorite()
}
}
}

View File

@@ -12,9 +12,15 @@ 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
import com.atridad.mealient.datasource.models.UserProfileResponse
import com.atridad.mealient.datasource.models.UpdateUserProfileRequest
import com.atridad.mealient.datasource.models.UpdateUserResponse
import com.atridad.mealient.datasource.models.ChangePasswordRequest
import com.atridad.mealient.datasource.models.UpdateProfileImageRequest
import com.atridad.mealient.datasource.models.VersionResponse
interface MealieDataSource {
@@ -28,9 +34,7 @@ interface MealieDataSource {
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,
@@ -82,4 +86,15 @@ interface MealieDataSource {
suspend fun deleteShoppingList(id: String)
suspend fun updateShoppingListName(id: String, name: String)
suspend fun getUserFavoritesAlternative(userId: String): GetUserFavoritesResponse
// User Profile Management
suspend fun getUserProfile(): UserProfileResponse
suspend fun updateUserProfile(userId: String, request: UpdateUserProfileRequest): UpdateUserResponse
suspend fun changePassword(request: ChangePasswordRequest)
suspend fun updateProfileImage(userId: String, request: UpdateProfileImageRequest)
}

View File

@@ -12,10 +12,16 @@ 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
import com.atridad.mealient.datasource.models.VersionResponse
import com.atridad.mealient.datasource.models.UserProfileResponse
import com.atridad.mealient.datasource.models.UpdateUserProfileRequest
import com.atridad.mealient.datasource.models.UpdateUserResponse
import com.atridad.mealient.datasource.models.ChangePasswordRequest
import com.atridad.mealient.datasource.models.UpdateProfileImageRequest
import kotlinx.serialization.json.JsonElement
internal interface MealieService {
@@ -70,4 +76,15 @@ internal interface MealieService {
suspend fun updateShoppingList(id: String, request: JsonElement)
suspend fun getShoppingListJson(id: String): JsonElement
suspend fun getUserFavoritesAlternative(userId: String): GetUserFavoritesResponse
// User Profile Management
suspend fun getUserProfile(): UserProfileResponse
suspend fun updateUserProfile(userId: String, request: UpdateUserProfileRequest): UpdateUserResponse
suspend fun changePassword(request: ChangePasswordRequest)
suspend fun updateProfileImage(userId: String, request: UpdateProfileImageRequest)
}

View File

@@ -17,38 +17,50 @@ 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
import com.atridad.mealient.datasource.models.UserProfileResponse
import com.atridad.mealient.datasource.models.UpdateUserProfileRequest
import com.atridad.mealient.datasource.models.UpdateUserResponse
import com.atridad.mealient.datasource.models.ChangePasswordRequest
import com.atridad.mealient.datasource.models.UpdateProfileImageRequest
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(
internal class MealieDataSourceImpl
@Inject
constructor(
private val networkRequestWrapper: NetworkRequestWrapper,
private val service: MealieService,
) : MealieDataSource {
override suspend fun createRecipe(
recipe: CreateRecipeRequest,
): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
): String =
networkRequestWrapper
.makeCallAndHandleUnauthorized(
block = { service.createRecipe(recipe) },
logMethod = { "createRecipe" },
logParameters = { "recipe = $recipe" }
).trim('"')
)
.trim('"')
override suspend fun updateRecipe(
slug: String,
recipe: UpdateRecipeRequest,
): GetRecipeResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
): GetRecipeResponse =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.updateRecipe(recipe, slug) },
logMethod = { "updateRecipe" },
logParameters = { "slug = $slug, recipe = $recipe" }
@@ -57,13 +69,21 @@ internal class MealieDataSourceImpl @Inject constructor(
override suspend fun authenticate(
username: String,
password: String,
): String = networkRequestWrapper.makeCall(
): 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
)
.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 =
@@ -71,10 +91,13 @@ internal class MealieDataSourceImpl @Inject constructor(
block = { service.getVersion(baseURL) },
logMethod = { "getVersionInfo" },
logParameters = { "baseURL = $baseURL" }
).getOrElse {
)
.getOrElse {
throw when (it) {
is ResponseException, is NoTransformationFoundException -> NetworkError.NotMealie(it)
is SocketTimeoutException, is SocketException -> NetworkError.NoServerConnection(it)
is ResponseException, is NoTransformationFoundException ->
NetworkError.NotMealie(it)
is SocketTimeoutException, is SocketException ->
NetworkError.NoServerConnection(it)
else -> NetworkError.MalformedUrl(it)
}
}
@@ -82,15 +105,21 @@ internal class MealieDataSourceImpl @Inject constructor(
override suspend fun requestRecipes(
page: Int,
perPage: Int,
): List<GetRecipeSummaryResponse> = networkRequestWrapper.makeCallAndHandleUnauthorized(
): List<GetRecipeSummaryResponse> {
val response =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipeSummary(page, perPage) },
logMethod = { "requestRecipes" },
logParameters = { "page = $page, perPage = $perPage" }
).items
)
return response.items
}
override suspend fun requestRecipeInfo(
slug: String,
): GetRecipeResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
): GetRecipeResponse =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipe(slug) },
logMethod = { "requestRecipeInfo" },
logParameters = { "slug = $slug" }
@@ -98,7 +127,8 @@ internal class MealieDataSourceImpl @Inject constructor(
override suspend fun parseRecipeFromURL(
request: ParseRecipeURLRequest,
): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
): String =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createRecipeFromURL(request) },
logMethod = { "parseRecipeFromURL" },
logParameters = { "request = $request" }
@@ -106,40 +136,51 @@ internal class MealieDataSourceImpl @Inject constructor(
override suspend fun createApiToken(
request: CreateApiTokenRequest,
): CreateApiTokenResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
): CreateApiTokenResponse =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createApiToken(request) },
logMethod = { "createApiToken" },
logParameters = { "request = $request" }
)
override suspend fun requestUserInfo(): GetUserInfoResponse {
return networkRequestWrapper.makeCallAndHandleUnauthorized(
val response =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getUserSelfInfo() },
logMethod = { "requestUserInfo" },
)
return response
}
override suspend fun removeFavoriteRecipe(
userId: String,
recipeSlug: String,
): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized(
): 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(
): Unit {
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.addFavoriteRecipe(userId, recipeSlug) },
logMethod = { "addFavoriteRecipe" },
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
)
}
override suspend fun deleteRecipe(
slug: String,
): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized(
): Unit =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.deleteRecipe(slug) },
logMethod = { "deleteRecipe" },
logParameters = { "slug = $slug" }
@@ -148,7 +189,8 @@ internal class MealieDataSourceImpl @Inject constructor(
override suspend fun getShoppingLists(
page: Int,
perPage: Int,
): GetShoppingListsResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
): GetShoppingListsResponse =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingLists(page, perPage) },
logMethod = { "getShoppingLists" },
logParameters = { "page = $page, perPage = $perPage" }
@@ -156,7 +198,8 @@ internal class MealieDataSourceImpl @Inject constructor(
override suspend fun getShoppingList(
id: String,
): GetShoppingListResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
): GetShoppingListResponse =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingList(id) },
logMethod = { "getShoppingList" },
logParameters = { "id = $id" }
@@ -164,7 +207,8 @@ internal class MealieDataSourceImpl @Inject constructor(
private suspend fun getShoppingListItem(
id: String,
): JsonElement = networkRequestWrapper.makeCallAndHandleUnauthorized(
): JsonElement =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingListItem(id) },
logMethod = { "getShoppingListItem" },
logParameters = { "id = $id" }
@@ -173,7 +217,8 @@ internal class MealieDataSourceImpl @Inject constructor(
private suspend fun updateShoppingListItem(
id: String,
request: JsonElement,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
) =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.updateShoppingListItem(id, request) },
logMethod = { "updateShoppingListItem" },
logParameters = { "id = $id, request = $request" }
@@ -181,7 +226,8 @@ internal class MealieDataSourceImpl @Inject constructor(
override suspend fun deleteShoppingListItem(
id: String,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
) =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.deleteShoppingListItem(id) },
logMethod = { "deleteShoppingListItem" },
logParameters = { "id = $id" }
@@ -192,7 +238,8 @@ internal class MealieDataSourceImpl @Inject constructor(
) {
// 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 {
val updatedItem =
remoteItem.jsonObject.toMutableMap().apply {
put("checked", JsonPrimitive(item.checked))
put("isFood", JsonPrimitive(item.isFood))
put("note", JsonPrimitive(item.note))
@@ -221,7 +268,8 @@ internal class MealieDataSourceImpl @Inject constructor(
override suspend fun addShoppingListItem(
request: CreateShoppingListItemRequest,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
) =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createShoppingListItem(request) },
logMethod = { "addShoppingListItem" },
logParameters = { "request = $request" }
@@ -229,7 +277,8 @@ internal class MealieDataSourceImpl @Inject constructor(
override suspend fun addShoppingList(
request: CreateShoppingListRequest,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
) =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createShoppingList(request) },
logMethod = { "createShoppingList" },
logParameters = { "request = $request" }
@@ -238,7 +287,8 @@ internal class MealieDataSourceImpl @Inject constructor(
private suspend fun updateShoppingList(
id: String,
request: JsonElement,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
) =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.updateShoppingList(id, request) },
logMethod = { "updateShoppingList" },
logParameters = { "id = $id, request = $request" }
@@ -246,7 +296,8 @@ internal class MealieDataSourceImpl @Inject constructor(
private suspend fun getShoppingListJson(
id: String,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
) =
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getShoppingListJson(id) },
logMethod = { "getShoppingListJson" },
logParameters = { "id = $id" }
@@ -254,21 +305,69 @@ internal class MealieDataSourceImpl @Inject constructor(
override suspend fun deleteShoppingList(
id: String,
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
) =
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
}
// User Profile Management
override suspend fun getUserProfile(): UserProfileResponse {
val response = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getUserProfile() },
logMethod = { "getUserProfile" },
)
return response
}
override suspend fun updateUserProfile(userId: String, request: UpdateUserProfileRequest): UpdateUserResponse {
val response = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.updateUserProfile(userId, request) },
logMethod = { "updateUserProfile" },
logParameters = { "userId = $userId" }
)
return response
}
override suspend fun changePassword(request: ChangePasswordRequest) {
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.changePassword(request) },
logMethod = { "changePassword" },
)
}
override suspend fun updateProfileImage(userId: String, request: UpdateProfileImageRequest) {
networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.updateProfileImage(userId, request) },
logMethod = { "updateProfileImage" },
logParameters = { "userId = $userId" }
)
}
}

View File

@@ -14,31 +14,43 @@ 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
import com.atridad.mealient.datasource.models.UserProfileResponse
import com.atridad.mealient.datasource.models.UpdateUserProfileRequest
import com.atridad.mealient.datasource.models.UpdateUserResponse
import com.atridad.mealient.datasource.models.ChangePasswordRequest
import com.atridad.mealient.datasource.models.UpdateProfileImageRequest
import com.atridad.mealient.datasource.models.VersionResponse
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.delete
import io.ktor.client.request.forms.FormDataContent
import io.ktor.client.request.forms.MultiPartFormDataContent
import io.ktor.client.request.forms.formData
import io.ktor.client.request.get
import io.ktor.client.request.patch
import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.URLBuilder
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(
internal class MealieServiceKtor
@Inject
constructor(
private val httpClient: HttpClient,
private val serverUrlProviderProvider: Provider<ServerUrlProvider>,
) : MealieService {
@@ -52,111 +64,109 @@ internal class MealieServiceKtor @Inject constructor(
append("password", password)
}
return httpClient.post {
return httpClient
.post {
endpoint("/api/auth/token")
setBody(FormDataContent(formParameters))
}.body()
}
.body()
}
override suspend fun createRecipe(addRecipeRequest: CreateRecipeRequest): String {
return httpClient.post {
return httpClient
.post {
endpoint("/api/recipes")
contentType(ContentType.Application.Json)
setBody(addRecipeRequest)
}.body()
}
.body()
}
override suspend fun updateRecipe(
addRecipeRequest: UpdateRecipeRequest,
slug: String,
): GetRecipeResponse {
return httpClient.patch {
return httpClient
.patch {
endpoint("/api/recipes/$slug")
contentType(ContentType.Application.Json)
setBody(addRecipeRequest)
}.body()
}
.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 {
return httpClient
.get {
endpoint("/api/recipes") {
parameters.append("page", page.toString())
parameters.append("perPage", perPage.toString())
}
}.body()
}
.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 {
return httpClient
.post {
endpoint("/api/recipes/create-url")
contentType(ContentType.Application.Json)
setBody(request)
}.body()
}
.body()
}
override suspend fun createApiToken(request: CreateApiTokenRequest): CreateApiTokenResponse {
return httpClient.post {
return httpClient
.post {
endpoint("/api/users/api-tokens")
contentType(ContentType.Application.Json)
setBody(request)
}.body()
}
.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 {
return httpClient
.get {
endpoint("/api/households/shopping/lists") {
parameters.append("page", page.toString())
parameters.append("perPage", perPage.toString())
}
}.body()
}
.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 +178,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 +210,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,9 +222,11 @@ 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(
@@ -230,12 +234,49 @@ internal class MealieServiceKtor @Inject constructor(
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)
}
// User Profile Management
override suspend fun getUserProfile(): UserProfileResponse {
return httpClient.get { endpoint("/api/users/self") }.body()
}
override suspend fun updateUserProfile(userId: String, request: UpdateUserProfileRequest): UpdateUserResponse {
return httpClient.put {
endpoint("/api/users/$userId")
contentType(ContentType.Application.Json)
setBody(request)
}.body()
}
override suspend fun changePassword(request: ChangePasswordRequest) {
httpClient.put {
endpoint("/api/users/password")
contentType(ContentType.Application.Json)
setBody(request)
}
}
override suspend fun updateProfileImage(userId: String, request: UpdateProfileImageRequest) {
httpClient.post {
endpoint("/api/users/$userId/image")
setBody(
MultiPartFormDataContent(
formData {
append(
"profile_image",
request.imageBytes,
Headers.build {
append(HttpHeaders.ContentType, request.mimeType)
append(HttpHeaders.ContentDisposition, "filename=\"${request.fileName}\"")
}
)
}
)
)
}
}
private fun HttpRequestBuilder.endpoint(
baseUrl: String,
@@ -248,4 +289,6 @@ internal class MealieServiceKtor @Inject constructor(
block()
}
}
}

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

@@ -0,0 +1,124 @@
package com.atridad.mealient.datasource.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class UserProfileResponse(
@SerialName("id") val id: String,
@SerialName("username") val username: String?,
@SerialName("fullName") val fullName: String?,
@SerialName("email") val email: String,
@SerialName("authMethod") val authMethod: String? = null,
@SerialName("admin") val admin: Boolean,
@SerialName("group") val group: String? = null,
@SerialName("household") val household: String? = null,
@SerialName("advanced") val advanced: Boolean? = null,
@SerialName("canInvite") val canInvite: Boolean? = null,
@SerialName("canManage") val canManage: Boolean? = null,
@SerialName("canManageHousehold") val canManageHousehold: Boolean? = null,
@SerialName("canOrganize") val canOrganize: Boolean? = null,
@SerialName("groupId") val groupId: String? = null,
@SerialName("groupSlug") val groupSlug: String? = null,
@SerialName("householdId") val householdId: String? = null,
@SerialName("householdSlug") val householdSlug: String? = null,
@SerialName("tokens") val tokens: List<ApiToken>? = null,
@SerialName("cacheKey") val cacheKey: String? = null,
)
@Serializable
data class ApiToken(
@SerialName("name") val name: String,
@SerialName("id") val id: Int,
@SerialName("createdAt") val createdAt: String,
)
@Serializable
data class UpdateUserResponse(
@SerialName("message") val message: String,
@SerialName("error") val error: Boolean,
)
@Serializable
data class UpdateUserProfileRequest(
@SerialName("id") val id: String,
@SerialName("username") val username: String?,
@SerialName("fullName") val fullName: String?,
@SerialName("email") val email: String,
@SerialName("authMethod") val authMethod: String,
@SerialName("admin") val admin: Boolean,
@SerialName("group") val group: String?,
@SerialName("household") val household: String?,
@SerialName("advanced") val advanced: Boolean,
@SerialName("canInvite") val canInvite: Boolean,
@SerialName("canManage") val canManage: Boolean,
@SerialName("canManageHousehold") val canManageHousehold: Boolean,
@SerialName("canOrganize") val canOrganize: Boolean,
@SerialName("groupId") val groupId: String?,
@SerialName("groupSlug") val groupSlug: String?,
@SerialName("householdId") val householdId: String?,
@SerialName("householdSlug") val householdSlug: String?,
@SerialName("tokens") val tokens: List<ApiToken>?,
@SerialName("cacheKey") val cacheKey: String?,
)
// Helper to create an update request from existing profile, preserving all permissions
fun UserProfileResponse.toUpdateRequest(
newFullName: String? = null,
newEmail: String? = null,
newUsername: String? = null
): UpdateUserProfileRequest {
return UpdateUserProfileRequest(
id = this.id,
username = newUsername ?: this.username,
fullName = newFullName ?: this.fullName,
email = newEmail ?: this.email,
authMethod = this.authMethod ?: "Mealie",
admin = this.admin, // Preserve existing admin status
group = this.group,
household = this.household,
advanced = this.advanced ?: true,
canInvite = this.canInvite ?: true,
canManage = this.canManage ?: true,
canManageHousehold = this.canManageHousehold ?: true,
canOrganize = this.canOrganize ?: true,
groupId = this.groupId,
groupSlug = this.groupSlug,
householdId = this.householdId,
householdSlug = this.householdSlug,
tokens = this.tokens,
cacheKey = this.cacheKey,
)
}
@Serializable
data class ChangePasswordRequest(
@SerialName("currentPassword") val currentPassword: String = "",
@SerialName("newPassword") val newPassword: String,
)
data class UpdateProfileImageRequest(
val imageBytes: ByteArray,
val fileName: String,
val mimeType: String = "image/jpeg"
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as UpdateProfileImageRequest
if (!imageBytes.contentEquals(other.imageBytes)) return false
if (fileName != other.fileName) return false
if (mimeType != other.mimeType) return false
return true
}
override fun hashCode(): Int {
var result = imageBytes.contentHashCode()
result = 31 * result + fileName.hashCode()
result = 31 * result + mimeType.hashCode()
return result
}
}

1
features/user_managment/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,42 @@
@file:Suppress("UnstableApiUsage")
plugins {
id("com.atridad.mealient.library")
alias(libs.plugins.ksp)
id("com.atridad.mealient.compose")
id("dagger.hilt.android.plugin")
}
android {
namespace = "com.mealient.user_management"
}
ksp {
arg("compose-destinations.generateNavGraphs", "false")
}
dependencies {
implementation(project(":architecture"))
implementation(project(":logging"))
implementation(project(":datasource"))
implementation(project(":ui"))
implementation(project(":model_mapper"))
implementation(libs.android.material.material)
implementation(libs.androidx.compose.material)
implementation(libs.androidx.compose.materialIconsExtended)
implementation(libs.google.dagger.hiltAndroid)
implementation(libs.androidx.hilt.navigationCompose)
implementation(libs.jetbrains.kotlinx.coroutinesAndroid)
implementation(libs.coil.compose)
ksp(libs.google.dagger.hiltCompiler)
kspTest(libs.google.dagger.hiltAndroidCompiler)
testImplementation(project(":testing"))
testImplementation(libs.google.dagger.hiltAndroidTesting)
testImplementation(libs.jetbrains.kotlinx.coroutinesTest)
testImplementation(libs.androidx.test.junit)
testImplementation(libs.google.truth)
testImplementation(libs.io.mockk)
}

View File

@@ -0,0 +1,16 @@
package com.mealient.user_management.data
import com.atridad.mealient.datasource.models.UserProfileResponse
import com.atridad.mealient.datasource.models.UpdateUserProfileRequest
import com.atridad.mealient.datasource.models.ChangePasswordRequest
import com.atridad.mealient.datasource.models.UpdateProfileImageRequest
import kotlinx.coroutines.flow.Flow
interface UserProfileRepository {
suspend fun getUserProfile(): UserProfileResponse
suspend fun updateUserProfile(request: UpdateUserProfileRequest): UserProfileResponse
suspend fun changePassword(request: ChangePasswordRequest)
suspend fun updateProfileImage(request: UpdateProfileImageRequest)
val currentUser: Flow<UserProfileResponse?>
fun getCurrentUserValue(): UserProfileResponse?
}

View File

@@ -0,0 +1,57 @@
package com.mealient.user_management.data
import com.atridad.mealient.datasource.MealieDataSource
import com.atridad.mealient.datasource.models.UserProfileResponse
import com.atridad.mealient.datasource.models.UpdateUserProfileRequest
import com.atridad.mealient.datasource.models.ChangePasswordRequest
import com.atridad.mealient.datasource.models.UpdateProfileImageRequest
import com.atridad.mealient.logging.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class UserProfileRepositoryImpl @Inject constructor(
private val dataSource: MealieDataSource,
private val logger: Logger,
) : UserProfileRepository {
private val _currentUser = MutableStateFlow<UserProfileResponse?>(null)
override val currentUser: Flow<UserProfileResponse?> = _currentUser.asStateFlow()
override suspend fun getUserProfile(): UserProfileResponse {
logger.v { "getUserProfile() called" }
val profile = dataSource.getUserProfile()
_currentUser.value = profile
return profile
}
override suspend fun updateUserProfile(request: UpdateUserProfileRequest): UserProfileResponse {
logger.v { "updateUserProfile() called" }
val currentUserId = checkNotNull(_currentUser.value?.id) { "User profile not loaded" }
// Update the profile (returns success message)
dataSource.updateUserProfile(currentUserId, request)
// Fetch the updated profile
val updatedProfile = getUserProfile()
return updatedProfile
}
override suspend fun changePassword(request: ChangePasswordRequest) {
logger.v { "changePassword() called" }
dataSource.changePassword(request)
}
override suspend fun updateProfileImage(request: UpdateProfileImageRequest) {
logger.v { "updateProfileImage() called" }
val currentUserId = checkNotNull(_currentUser.value?.id) { "User profile not loaded" }
dataSource.updateProfileImage(currentUserId, request)
// Refresh profile to get updated image URL
getUserProfile()
}
override fun getCurrentUserValue(): UserProfileResponse? {
return _currentUser.value
}
}

View File

@@ -0,0 +1,18 @@
package com.mealient.user_management.di
import com.mealient.user_management.data.UserProfileRepository
import com.mealient.user_management.data.UserProfileRepositoryImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
internal interface UserManagementModule {
@Binds
fun bindUserProfileRepository(
userProfileRepositoryImpl: UserProfileRepositoryImpl
): UserProfileRepository
}

View File

@@ -0,0 +1,584 @@
package com.mealient.user_management.ui.profile
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.InputStream
@Destination
@Composable
fun UserProfileScreen(
navigator: DestinationsNavigator,
viewModel: UserProfileViewModel = hiltViewModel(),
) {
val state by viewModel.screenState.collectAsStateWithLifecycle()
val context = LocalContext.current
val imagePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let { selectedUri ->
// Convert URI to byte array
// This would typically be done in a background thread
try {
val inputStream: InputStream? = context.contentResolver.openInputStream(selectedUri)
inputStream?.use { stream ->
val bytes = stream.readBytes()
val fileName = "profile_image_${System.currentTimeMillis()}.jpg"
viewModel.onEvent(ProfileScreenEvent.UpdateProfileImage(bytes, fileName))
}
} catch (e: Exception) {
// Handle error
}
}
}
LaunchedEffect(Unit) {
viewModel.onEvent(ProfileScreenEvent.LoadProfile)
}
UserProfileContent(
state = state,
onEvent = viewModel::onEvent,
onSelectImage = { imagePickerLauncher.launch("image/*") },
onNavigateBack = { navigator.navigateUp() }
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun UserProfileContent(
state: UserProfileScreenState,
onEvent: (ProfileScreenEvent) -> Unit,
onSelectImage: () -> Unit,
onNavigateBack: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
// Top App Bar
TopAppBar(
title = { Text("Profile") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
if (!state.isChangingPassword) {
if (state.isEditing) {
TextButton(onClick = { onEvent(ProfileScreenEvent.CancelEditing) }) {
Text("Cancel")
}
TextButton(
onClick = { onEvent(ProfileScreenEvent.SaveProfile) },
enabled = state.isProfileFormValid && !state.isLoading
) {
Text("Save")
}
} else {
IconButton(onClick = { onEvent(ProfileScreenEvent.StartEditing) }) {
Icon(Icons.Default.Edit, contentDescription = "Edit")
}
}
}
}
)
// Content
if (state.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
// Profile Image Section
ProfileImageSection(
profileImageUrl = state.user?.profileImageUrl,
isEditing = state.isEditing,
onSelectImage = onSelectImage
)
}
item {
// User Info Section
UserInfoSection(
state = state,
onEvent = onEvent
)
}
if (!state.isChangingPassword) {
item {
// Password Change Section
PasswordChangeSection(
onStartChangingPassword = { onEvent(ProfileScreenEvent.StartChangingPassword) }
)
}
} else {
item {
// Password Change Form
PasswordChangeForm(
state = state,
onEvent = onEvent
)
}
}
item {
// Additional Info Section
AdditionalInfoSection(user = state.user)
}
}
}
// Error/Success Messages
state.errorMessage?.let { message ->
LaunchedEffect(message) {
// You could show a Snackbar here
kotlinx.coroutines.delay(3000)
onEvent(ProfileScreenEvent.ClearError)
}
}
state.successMessage?.let { message ->
LaunchedEffect(message) {
// You could show a Snackbar here
kotlinx.coroutines.delay(3000)
onEvent(ProfileScreenEvent.ClearSuccess)
}
}
}
}
@Composable
private fun ProfileImageSection(
profileImageUrl: String?,
isEditing: Boolean,
onSelectImage: () -> Unit,
) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(120.dp)
.clip(CircleShape)
.border(2.dp, MaterialTheme.colorScheme.primary, CircleShape)
.clickable(enabled = isEditing) { onSelectImage() }
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(profileImageUrl)
.placeholder(android.R.drawable.ic_menu_gallery)
.error(android.R.drawable.ic_menu_gallery)
.crossfade(true)
.build(),
contentDescription = "Profile Picture",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
if (isEditing) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.CameraAlt,
contentDescription = "Change Photo",
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
}
}
}
}
@Composable
private fun UserInfoSection(
state: UserProfileScreenState,
onEvent: (ProfileScreenEvent) -> Unit,
) {
val focusManager = LocalFocusManager.current
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Personal Information",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
// Full Name Field
OutlinedTextField(
value = state.fullNameInput,
onValueChange = { onEvent(ProfileScreenEvent.UpdateFullName(it)) },
label = { Text("Full Name") },
enabled = state.isEditing,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
}
)
// Email Field
OutlinedTextField(
value = state.emailInput,
onValueChange = { onEvent(ProfileScreenEvent.UpdateEmail(it)) },
label = { Text("Email") },
enabled = state.isEditing,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
leadingIcon = {
Icon(Icons.Default.Email, contentDescription = null)
}
)
// Username Field
OutlinedTextField(
value = state.usernameInput,
onValueChange = { onEvent(ProfileScreenEvent.UpdateUsername(it)) },
label = { Text("Username") },
enabled = state.isEditing,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { focusManager.clearFocus() }
),
leadingIcon = {
Icon(Icons.Default.AccountCircle, contentDescription = null)
}
)
}
}
}
@Composable
private fun PasswordChangeSection(
onStartChangingPassword: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Security",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
OutlinedButton(
onClick = onStartChangingPassword,
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Lock, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Change Password")
}
}
}
}
@Composable
private fun PasswordChangeForm(
state: UserProfileScreenState,
onEvent: (ProfileScreenEvent) -> Unit,
) {
val focusManager = LocalFocusManager.current
var showCurrentPassword by remember { mutableStateOf(false) }
var showNewPassword by remember { mutableStateOf(false) }
var showConfirmPassword by remember { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Change Password",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
// Current Password
OutlinedTextField(
value = state.currentPasswordInput,
onValueChange = { onEvent(ProfileScreenEvent.UpdateCurrentPassword(it)) },
label = { Text("Current Password") },
modifier = Modifier.fillMaxWidth(),
visualTransformation = if (showCurrentPassword) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
trailingIcon = {
IconButton(onClick = { showCurrentPassword = !showCurrentPassword }) {
Icon(
if (showCurrentPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (showCurrentPassword) "Hide password" else "Show password"
)
}
}
)
// New Password
OutlinedTextField(
value = state.newPasswordInput,
onValueChange = { onEvent(ProfileScreenEvent.UpdateNewPassword(it)) },
label = { Text("New Password") },
modifier = Modifier.fillMaxWidth(),
visualTransformation = if (showNewPassword) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
leadingIcon = {
Icon(Icons.Default.VpnKey, contentDescription = null)
},
trailingIcon = {
IconButton(onClick = { showNewPassword = !showNewPassword }) {
Icon(
if (showNewPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (showNewPassword) "Hide password" else "Show password"
)
}
},
supportingText = {
Text("Minimum 8 characters")
}
)
// Confirm Password
OutlinedTextField(
value = state.confirmPasswordInput,
onValueChange = { onEvent(ProfileScreenEvent.UpdateConfirmPassword(it)) },
label = { Text("Confirm New Password") },
modifier = Modifier.fillMaxWidth(),
visualTransformation = if (showConfirmPassword) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { focusManager.clearFocus() }
),
leadingIcon = {
Icon(Icons.Default.VpnKey, contentDescription = null)
},
trailingIcon = {
IconButton(onClick = { showConfirmPassword = !showConfirmPassword }) {
Icon(
if (showConfirmPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (showConfirmPassword) "Hide password" else "Show password"
)
}
},
isError = state.newPasswordInput.isNotEmpty() &&
state.confirmPasswordInput.isNotEmpty() &&
state.newPasswordInput != state.confirmPasswordInput,
supportingText = {
if (state.newPasswordInput.isNotEmpty() &&
state.confirmPasswordInput.isNotEmpty() &&
state.newPasswordInput != state.confirmPasswordInput) {
Text(
text = "Passwords do not match",
color = MaterialTheme.colorScheme.error
)
}
}
)
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = { onEvent(ProfileScreenEvent.CancelChangingPassword) },
modifier = Modifier.weight(1f)
) {
Text("Cancel")
}
Button(
onClick = { onEvent(ProfileScreenEvent.ChangePassword) },
enabled = state.isPasswordFormValid && !state.isLoading,
modifier = Modifier.weight(1f)
) {
if (state.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else {
Text("Change Password")
}
}
}
}
}
}
@Composable
private fun AdditionalInfoSection(
user: UserProfile?,
) {
user?.let {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Account Information",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
InfoRow(
label = "Role",
value = if (user.admin) "Administrator" else "User",
icon = Icons.Default.Shield
)
user.group?.let { group ->
InfoRow(
label = "Group",
value = group,
icon = Icons.Default.Group
)
}
user.household?.let { household ->
InfoRow(
label = "Household",
value = household,
icon = Icons.Default.Home
)
}
}
}
}
}
@Composable
private fun InfoRow(
label: String,
value: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium
)
}
}
}

View File

@@ -0,0 +1,81 @@
package com.mealient.user_management.ui.profile
import androidx.compose.runtime.Immutable
@Immutable
data class UserProfileScreenState(
val isLoading: Boolean = false,
val isEditing: Boolean = false,
val user: UserProfile? = null,
val fullNameInput: String = "",
val emailInput: String = "",
val usernameInput: String = "",
val currentPasswordInput: String = "",
val newPasswordInput: String = "",
val confirmPasswordInput: String = "",
val isChangingPassword: Boolean = false,
val errorMessage: String? = null,
val successMessage: String? = null,
val profileImageUri: String? = null,
) {
val isPasswordFormValid: Boolean
get() = currentPasswordInput.isNotBlank() &&
newPasswordInput.length >= 8 &&
newPasswordInput == confirmPasswordInput
val isProfileFormValid: Boolean
get() = fullNameInput.isNotBlank() &&
emailInput.isNotBlank() &&
android.util.Patterns.EMAIL_ADDRESS.matcher(emailInput).matches()
}
@Immutable
data class UserProfile(
val id: String,
val username: String?,
val fullName: String?,
val email: String,
val admin: Boolean,
val group: String?,
val household: String?,
val profileImageUrl: String? = null,
)
sealed interface ProfileScreenEvent {
object LoadProfile : ProfileScreenEvent
object StartEditing : ProfileScreenEvent
object CancelEditing : ProfileScreenEvent
object SaveProfile : ProfileScreenEvent
object StartChangingPassword : ProfileScreenEvent
object CancelChangingPassword : ProfileScreenEvent
object ChangePassword : ProfileScreenEvent
object SelectProfileImage : ProfileScreenEvent
object ClearError : ProfileScreenEvent
object ClearSuccess : ProfileScreenEvent
data class UpdateFullName(val value: String) : ProfileScreenEvent
data class UpdateEmail(val value: String) : ProfileScreenEvent
data class UpdateUsername(val value: String) : ProfileScreenEvent
data class UpdateCurrentPassword(val value: String) : ProfileScreenEvent
data class UpdateNewPassword(val value: String) : ProfileScreenEvent
data class UpdateConfirmPassword(val value: String) : ProfileScreenEvent
data class UpdateProfileImage(val imageBytes: ByteArray, val fileName: String) : ProfileScreenEvent {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as UpdateProfileImage
if (!imageBytes.contentEquals(other.imageBytes)) return false
if (fileName != other.fileName) return false
return true
}
override fun hashCode(): Int {
var result = imageBytes.contentHashCode()
result = 31 * result + fileName.hashCode()
return result
}
}
}

View File

@@ -0,0 +1,321 @@
package com.mealient.user_management.ui.profile
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.atridad.mealient.datasource.models.UpdateUserProfileRequest
import com.atridad.mealient.datasource.models.toUpdateRequest
import com.atridad.mealient.datasource.models.ChangePasswordRequest
import com.atridad.mealient.datasource.models.UpdateProfileImageRequest
import com.atridad.mealient.datasource.models.UserProfileResponse
import com.atridad.mealient.logging.Logger
import com.mealient.user_management.data.UserProfileRepository
import com.atridad.mealient.datasource.ServerUrlProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class UserProfileViewModel @Inject constructor(
private val userProfileRepository: UserProfileRepository,
private val logger: Logger,
private val application: Application,
private val serverUrlProvider: ServerUrlProvider,
) : ViewModel() {
private val _screenState = MutableStateFlow(UserProfileScreenState())
val screenState: StateFlow<UserProfileScreenState> = _screenState.asStateFlow()
init {
loadProfile()
}
fun onEvent(event: ProfileScreenEvent) {
when (event) {
ProfileScreenEvent.LoadProfile -> loadProfile()
ProfileScreenEvent.StartEditing -> startEditing()
ProfileScreenEvent.CancelEditing -> cancelEditing()
ProfileScreenEvent.SaveProfile -> saveProfile()
ProfileScreenEvent.StartChangingPassword -> startChangingPassword()
ProfileScreenEvent.CancelChangingPassword -> cancelChangingPassword()
ProfileScreenEvent.ChangePassword -> changePassword()
ProfileScreenEvent.SelectProfileImage -> selectProfileImage()
ProfileScreenEvent.ClearError -> clearError()
ProfileScreenEvent.ClearSuccess -> clearSuccess()
is ProfileScreenEvent.UpdateFullName -> updateFullName(event.value)
is ProfileScreenEvent.UpdateEmail -> updateEmail(event.value)
is ProfileScreenEvent.UpdateUsername -> updateUsername(event.value)
is ProfileScreenEvent.UpdateCurrentPassword -> updateCurrentPassword(event.value)
is ProfileScreenEvent.UpdateNewPassword -> updateNewPassword(event.value)
is ProfileScreenEvent.UpdateConfirmPassword -> updateConfirmPassword(event.value)
is ProfileScreenEvent.UpdateProfileImage -> updateProfileImage(event.imageBytes, event.fileName)
}
}
private fun loadProfile() {
logger.v { "loadProfile() called" }
_screenState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
val profile = userProfileRepository.getUserProfile()
val userProfile = profile.toUserProfile()
_screenState.update {
it.copy(
isLoading = false,
user = userProfile,
fullNameInput = userProfile.fullName ?: "",
emailInput = userProfile.email,
usernameInput = userProfile.username ?: "",
)
}
} catch (e: Exception) {
logger.e(e) { "Failed to load profile" }
_screenState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load profile: ${e.message}"
)
}
}
}
}
private fun startEditing() {
_screenState.update { it.copy(isEditing = true, errorMessage = null) }
}
private fun cancelEditing() {
val currentUser = _screenState.value.user
_screenState.update {
it.copy(
isEditing = false,
fullNameInput = currentUser?.fullName ?: "",
emailInput = currentUser?.email ?: "",
usernameInput = currentUser?.username ?: "",
errorMessage = null
)
}
}
private fun saveProfile() {
logger.v { "saveProfile() called" }
val state = _screenState.value
if (!state.isProfileFormValid) {
_screenState.update { it.copy(errorMessage = "Please fill in all required fields with valid data") }
return
}
_screenState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
// Get current profile to preserve all permissions and settings
val currentProfile = userProfileRepository.getCurrentUserValue()
?: throw IllegalStateException("No current user profile")
// Create update request preserving all existing data except what we want to change
val request = currentProfile.toUpdateRequest(
newFullName = state.fullNameInput.takeIf { it.isNotBlank() },
newEmail = state.emailInput,
newUsername = state.usernameInput.takeIf { it.isNotBlank() },
)
val updatedProfile = userProfileRepository.updateUserProfile(request)
val userProfile = updatedProfile.toUserProfile()
_screenState.update {
it.copy(
isLoading = false,
isEditing = false,
user = userProfile,
successMessage = "Profile updated successfully"
)
}
} catch (e: Exception) {
logger.e(e) { "Failed to save profile" }
_screenState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to update profile: ${e.message}"
)
}
}
}
}
private fun startChangingPassword() {
_screenState.update {
it.copy(
isChangingPassword = true,
currentPasswordInput = "",
newPasswordInput = "",
confirmPasswordInput = "",
errorMessage = null
)
}
}
private fun cancelChangingPassword() {
_screenState.update {
it.copy(
isChangingPassword = false,
currentPasswordInput = "",
newPasswordInput = "",
confirmPasswordInput = "",
errorMessage = null
)
}
}
private fun changePassword() {
logger.v { "changePassword() called" }
val state = _screenState.value
if (!state.isPasswordFormValid) {
_screenState.update {
it.copy(errorMessage = "Password must be at least 8 characters and passwords must match")
}
return
}
_screenState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
val request = ChangePasswordRequest(
currentPassword = state.currentPasswordInput,
newPassword = state.newPasswordInput
)
userProfileRepository.changePassword(request)
_screenState.update {
it.copy(
isLoading = false,
isChangingPassword = false,
currentPasswordInput = "",
newPasswordInput = "",
confirmPasswordInput = "",
successMessage = "Password changed successfully"
)
}
} catch (e: Exception) {
logger.e(e) { "Failed to change password" }
_screenState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to change password: ${e.message}"
)
}
}
}
}
private fun selectProfileImage() {
// This will be handled by the UI layer with image picker
logger.v { "selectProfileImage() called" }
}
private fun updateProfileImage(imageBytes: ByteArray, fileName: String) {
logger.v { "updateProfileImage() called with fileName: $fileName" }
_screenState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
try {
val request = UpdateProfileImageRequest(
imageBytes = imageBytes,
fileName = fileName
)
userProfileRepository.updateProfileImage(request)
_screenState.update {
it.copy(
isLoading = false,
successMessage = "Profile image updated successfully"
)
}
} catch (e: Exception) {
logger.e(e) { "Failed to update profile image" }
_screenState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to update profile image: ${e.message}"
)
}
}
}
}
private fun clearError() {
_screenState.update { it.copy(errorMessage = null) }
}
private fun clearSuccess() {
_screenState.update { it.copy(successMessage = null) }
}
// Input field update methods
private fun updateFullName(value: String) {
_screenState.update { it.copy(fullNameInput = value) }
}
private fun updateEmail(value: String) {
_screenState.update { it.copy(emailInput = value) }
}
private fun updateUsername(value: String) {
_screenState.update { it.copy(usernameInput = value) }
}
private fun updateCurrentPassword(value: String) {
_screenState.update { it.copy(currentPasswordInput = value) }
}
private fun updateNewPassword(value: String) {
_screenState.update { it.copy(newPasswordInput = value) }
}
private fun updateConfirmPassword(value: String) {
_screenState.update { it.copy(confirmPasswordInput = value) }
}
private suspend fun UserProfileResponse.toUserProfile(): UserProfile {
return UserProfile(
id = id,
username = username,
fullName = fullName,
email = email,
admin = admin,
group = group,
household = household,
profileImageUrl = constructProfileImageUrl(id, cacheKey)
)
}
private suspend fun constructProfileImageUrl(userId: String, cacheKey: String?): String? {
return try {
val baseUrl = serverUrlProvider.getUrl()?.takeUnless { it.isEmpty() }
if (baseUrl != null) {
val baseImageUrl = "$baseUrl/api/media/users/$userId/profile.webp"
if (cacheKey != null) {
"$baseImageUrl?cacheKey=$cacheKey"
} else {
baseImageUrl
}
} else {
null
}
} catch (e: Exception) {
logger.w(e) { "Failed to construct profile image URL" }
null
}
}
}

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

View File

@@ -38,3 +38,4 @@ include(":testing")
include(":ui")
include(":model_mapper")
include(":features:shopping_lists")
include(":features:user_managment")