Compare commits
7 Commits
0.5.0
...
feature-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
7fb8b195f0
|
|||
|
77f48c603d
|
|||
|
2d4214562a
|
|||
|
f7bd6643cb
|
|||
| 1be2cb425c | |||
|
571db144c4
|
|||
| 49c9a6dce1 |
3
LICENSE
3
LICENSE
@@ -1,6 +1,7 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2022, Kirill Kamakin
|
Copyright (c) 2022, Kirill Kamakin
|
||||||
|
Copyright (c) 2025, Atridad Lahiji
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
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,
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
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
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
37
README.md
37
README.md
@@ -1,29 +1,26 @@
|
|||||||
# Mealient
|
# Mealient
|
||||||
|
|
||||||
## DISCLAIMER
|
## USAGE REQUIREMENTS
|
||||||
|
|
||||||
This project is developed independently from the core Mealie project. It is NOT associated with the
|
- Android 8.0 or higher
|
||||||
core Mealie developers. Any issues must be reported to the Mealient repository, NOT the Mealie
|
- A Mealie server running v3 or higher
|
||||||
repository.
|
|
||||||
|
## 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?
|
## 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
|
to
|
||||||
easily access your recipes using an Android device. The main advantage over website is that
|
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.
|
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.
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ plugins {
|
|||||||
android {
|
android {
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.atridad.mealient"
|
applicationId = "com.atridad.mealient"
|
||||||
versionCode = 37
|
versionCode = 38
|
||||||
versionName = "0.5.0"
|
versionName = "0.6.0"
|
||||||
testInstrumentationRunner = "com.atridad.mealient.MealientTestRunner"
|
testInstrumentationRunner = "com.atridad.mealient.MealientTestRunner"
|
||||||
testInstrumentationRunnerArguments += mapOf("clearPackageData" to "true")
|
testInstrumentationRunnerArguments += mapOf("clearPackageData" to "true")
|
||||||
resourceConfigurations += listOf("en", "es", "ru", "fr", "nl", "pt", "de")
|
resourceConfigurations += listOf("en", "es", "ru", "fr", "nl", "pt", "de")
|
||||||
@@ -77,6 +77,7 @@ dependencies {
|
|||||||
implementation(project(":logging"))
|
implementation(project(":logging"))
|
||||||
implementation(project(":ui"))
|
implementation(project(":ui"))
|
||||||
implementation(project(":features:shopping_lists"))
|
implementation(project(":features:shopping_lists"))
|
||||||
|
implementation(project(":features:user_managment"))
|
||||||
implementation(project(":model_mapper"))
|
implementation(project(":model_mapper"))
|
||||||
implementation(libs.android.material.material)
|
implementation(libs.android.material.material)
|
||||||
implementation(libs.androidx.coreKtx)
|
implementation(libs.androidx.coreKtx)
|
||||||
@@ -103,6 +104,7 @@ dependencies {
|
|||||||
kover(project(":datasource"))
|
kover(project(":datasource"))
|
||||||
kover(project(":datastore"))
|
kover(project(":datastore"))
|
||||||
kover(project(":features:shopping_lists"))
|
kover(project(":features:shopping_lists"))
|
||||||
|
kover(project(":features:user_managment"))
|
||||||
kover(project(":logging"))
|
kover(project(":logging"))
|
||||||
kover(project(":model_mapper"))
|
kover(project(":model_mapper"))
|
||||||
kover(project(":ui"))
|
kover(project(":ui"))
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
app/release/com.atridad.mealient_0.5.1.apk
Normal file
BIN
app/release/com.atridad.mealient_0.5.1.apk
Normal file
Binary file not shown.
@@ -11,8 +11,8 @@
|
|||||||
"type": "SINGLE",
|
"type": "SINGLE",
|
||||||
"filters": [],
|
"filters": [],
|
||||||
"attributes": [],
|
"attributes": [],
|
||||||
"versionCode": 37,
|
"versionCode": 38,
|
||||||
"versionName": "0.5.0",
|
"versionName": "0.5.1",
|
||||||
"outputFile": "app-release.apk"
|
"outputFile": "app-release.apk"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
|||||||
import com.atridad.mealient.model_mapper.ModelMapper
|
import com.atridad.mealient.model_mapper.ModelMapper
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class MealieDataSourceWrapper @Inject constructor(
|
class MealieDataSourceWrapper
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
private val dataSource: MealieDataSource,
|
private val dataSource: MealieDataSource,
|
||||||
private val modelMapper: ModelMapper,
|
private val modelMapper: ModelMapper,
|
||||||
) : AddRecipeDataSource, RecipeDataSource, ParseRecipeDataSource {
|
) : AddRecipeDataSource, RecipeDataSource, ParseRecipeDataSource {
|
||||||
@@ -26,7 +28,8 @@ class MealieDataSourceWrapper @Inject constructor(
|
|||||||
start: Int,
|
start: Int,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
): List<GetRecipeSummaryResponse> {
|
): 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
|
val page = start / limit + 1
|
||||||
return dataSource.requestRecipes(page, limit)
|
return dataSource.requestRecipes(page, limit)
|
||||||
}
|
}
|
||||||
@@ -40,11 +43,31 @@ class MealieDataSourceWrapper @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getFavoriteRecipes(): List<String> {
|
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) {
|
override suspend fun updateIsRecipeFavorite(recipeSlug: String, isFavorite: Boolean) {
|
||||||
val userId = dataSource.requestUserInfo().id
|
val userInfo = dataSource.requestUserInfo()
|
||||||
|
val userId = userInfo.id
|
||||||
|
|
||||||
if (isFavorite) {
|
if (isFavorite) {
|
||||||
dataSource.addFavoriteRecipe(userId, recipeSlug)
|
dataSource.addFavoriteRecipe(userId, recipeSlug)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -11,15 +11,17 @@ import com.atridad.mealient.database.recipe.entity.RecipeSummaryEntity
|
|||||||
import com.atridad.mealient.datasource.runCatchingExceptCancel
|
import com.atridad.mealient.datasource.runCatchingExceptCancel
|
||||||
import com.atridad.mealient.logging.Logger
|
import com.atridad.mealient.logging.Logger
|
||||||
import com.atridad.mealient.model_mapper.ModelMapper
|
import com.atridad.mealient.model_mapper.ModelMapper
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@OptIn(ExperimentalPagingApi::class)
|
@OptIn(ExperimentalPagingApi::class)
|
||||||
@Singleton
|
@Singleton
|
||||||
class RecipesRemoteMediator @Inject constructor(
|
class RecipesRemoteMediator
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
private val storage: RecipeStorage,
|
private val storage: RecipeStorage,
|
||||||
private val network: RecipeDataSource,
|
private val network: RecipeDataSource,
|
||||||
private val pagingSourceFactory: RecipePagingSourceFactory,
|
private val pagingSourceFactory: RecipePagingSourceFactory,
|
||||||
@@ -28,13 +30,15 @@ class RecipesRemoteMediator @Inject constructor(
|
|||||||
private val dispatchers: AppDispatchers,
|
private val dispatchers: AppDispatchers,
|
||||||
) : RemoteMediator<Int, RecipeSummaryEntity>() {
|
) : RemoteMediator<Int, RecipeSummaryEntity>() {
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting var lastRequestEnd: Int = 0
|
||||||
var lastRequestEnd: Int = 0
|
|
||||||
|
|
||||||
override suspend fun load(
|
override suspend fun load(
|
||||||
loadType: LoadType, state: PagingState<Int, RecipeSummaryEntity>
|
loadType: LoadType,
|
||||||
|
state: PagingState<Int, RecipeSummaryEntity>
|
||||||
): MediatorResult {
|
): 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) {
|
if (loadType == PREPEND) {
|
||||||
logger.i { "load: early exit, PREPEND isn't supported" }
|
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 start = if (loadType == REFRESH) 0 else lastRequestEnd
|
||||||
val limit = if (loadType == REFRESH) state.config.initialLoadSize else state.config.pageSize
|
val limit = if (loadType == REFRESH) state.config.initialLoadSize else state.config.pageSize
|
||||||
|
|
||||||
val count: Int = runCatchingExceptCancel {
|
val count: Int =
|
||||||
updateRecipes(start, limit, loadType)
|
runCatchingExceptCancel { updateRecipes(start, limit, loadType) }.getOrElse {
|
||||||
}.getOrElse {
|
|
||||||
logger.e(it) { "load: can't load recipes" }
|
logger.e(it) { "load: can't load recipes" }
|
||||||
return MediatorResult.Error(it)
|
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
|
// 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
|
// But for some reason Room/Paging library don't do it automatically
|
||||||
// Here we invalidate them manually.
|
// 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()
|
pagingSourceFactory.invalidate()
|
||||||
|
|
||||||
logger.d { "load: expectedCount = $limit, received $count" }
|
logger.d { "load: expectedCount = $limit, received $count" }
|
||||||
@@ -67,21 +71,26 @@ class RecipesRemoteMediator @Inject constructor(
|
|||||||
limit: Int,
|
limit: Int,
|
||||||
loadType: LoadType = REFRESH,
|
loadType: LoadType = REFRESH,
|
||||||
): Int = coroutineScope {
|
): 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 deferredRecipes = async { network.requestRecipes(start, limit) }
|
||||||
val favorites = runCatchingExceptCancel {
|
val favorites =
|
||||||
network.getFavoriteRecipes()
|
runCatchingExceptCancel { network.getFavoriteRecipes() }
|
||||||
}.getOrDefault(emptyList()).toHashSet()
|
.getOrDefault(emptyList())
|
||||||
|
.toHashSet()
|
||||||
|
|
||||||
val recipes = deferredRecipes.await()
|
val recipes = deferredRecipes.await()
|
||||||
val entities = withContext(dispatchers.default) {
|
|
||||||
|
val entities =
|
||||||
|
withContext(dispatchers.default) {
|
||||||
recipes.map { recipe ->
|
recipes.map { recipe ->
|
||||||
val isFavorite = favorites.contains(recipe.slug)
|
val isFavorite = favorites.contains(recipe.slug)
|
||||||
modelMapper.toRecipeSummaryEntity(recipe, isFavorite)
|
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
|
recipes.size
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ import com.atridad.mealient.ui.destinations.BaseURLScreenDestination
|
|||||||
import com.atridad.mealient.ui.destinations.DisclaimerScreenDestination
|
import com.atridad.mealient.ui.destinations.DisclaimerScreenDestination
|
||||||
import com.atridad.mealient.ui.destinations.RecipeScreenDestination
|
import com.atridad.mealient.ui.destinations.RecipeScreenDestination
|
||||||
import com.atridad.mealient.ui.destinations.RecipesListDestination
|
import com.atridad.mealient.ui.destinations.RecipesListDestination
|
||||||
|
import com.mealient.user_management.ui.profile.destinations.UserProfileScreenDestination
|
||||||
|
|
||||||
internal object NavGraphs {
|
internal object NavGraphs {
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ internal object NavGraphs {
|
|||||||
DisclaimerScreenDestination,
|
DisclaimerScreenDestination,
|
||||||
BaseURLScreenDestination,
|
BaseURLScreenDestination,
|
||||||
AuthenticationScreenDestination,
|
AuthenticationScreenDestination,
|
||||||
|
UserProfileScreenDestination,
|
||||||
),
|
),
|
||||||
nestedNavGraphs = listOf(
|
nestedNavGraphs = listOf(
|
||||||
recipes,
|
recipes,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.compose.material.icons.filled.Add
|
|||||||
import androidx.compose.material.icons.filled.Email
|
import androidx.compose.material.icons.filled.Email
|
||||||
import androidx.compose.material.icons.filled.List
|
import androidx.compose.material.icons.filled.List
|
||||||
import androidx.compose.material.icons.filled.Logout
|
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.ShoppingCart
|
||||||
import androidx.compose.material.icons.filled.SyncAlt
|
import androidx.compose.material.icons.filled.SyncAlt
|
||||||
import androidx.compose.material3.DrawerState
|
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.AddRecipeScreenDestination
|
||||||
import com.atridad.mealient.ui.destinations.BaseURLScreenDestination
|
import com.atridad.mealient.ui.destinations.BaseURLScreenDestination
|
||||||
import com.atridad.mealient.ui.destinations.RecipesListDestination
|
import com.atridad.mealient.ui.destinations.RecipesListDestination
|
||||||
|
import com.mealient.user_management.ui.profile.destinations.UserProfileScreenDestination
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -91,6 +93,11 @@ internal fun createDrawerItems(
|
|||||||
icon = Icons.Default.ShoppingCart,
|
icon = Icons.Default.ShoppingCart,
|
||||||
direction = NavGraphs.shoppingLists,
|
direction = NavGraphs.shoppingLists,
|
||||||
),
|
),
|
||||||
|
createNavigationItem(
|
||||||
|
nameRes = R.string.menu_navigation_drawer_profile,
|
||||||
|
icon = Icons.Default.Person,
|
||||||
|
direction = UserProfileScreenDestination,
|
||||||
|
),
|
||||||
createNavigationItem(
|
createNavigationItem(
|
||||||
nameRes = R.string.menu_navigation_drawer_change_url,
|
nameRes = R.string.menu_navigation_drawer_change_url,
|
||||||
icon = Icons.Default.SyncAlt,
|
icon = Icons.Default.SyncAlt,
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
import androidx.paging.cachedIn
|
import androidx.paging.cachedIn
|
||||||
import androidx.paging.map
|
import androidx.paging.map
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import com.atridad.mealient.architecture.valueUpdatesOnly
|
import com.atridad.mealient.architecture.valueUpdatesOnly
|
||||||
import com.atridad.mealient.data.auth.AuthRepo
|
import com.atridad.mealient.data.auth.AuthRepo
|
||||||
import com.atridad.mealient.data.recipes.RecipeRepo
|
import com.atridad.mealient.data.recipes.RecipeRepo
|
||||||
import com.atridad.mealient.data.recipes.impl.RecipeImageUrlProvider
|
import com.atridad.mealient.data.recipes.impl.RecipeImageUrlProvider
|
||||||
import com.atridad.mealient.database.recipe.entity.RecipeSummaryEntity
|
import com.atridad.mealient.database.recipe.entity.RecipeSummaryEntity
|
||||||
import com.atridad.mealient.logging.Logger
|
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.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -23,10 +24,11 @@ import kotlinx.coroutines.flow.onEach
|
|||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
internal class RecipesListViewModel @Inject constructor(
|
internal class RecipesListViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
private val recipeRepo: RecipeRepo,
|
private val recipeRepo: RecipeRepo,
|
||||||
private val logger: Logger,
|
private val logger: Logger,
|
||||||
private val recipeImageUrlProvider: RecipeImageUrlProvider,
|
private val recipeImageUrlProvider: RecipeImageUrlProvider,
|
||||||
@@ -51,16 +53,19 @@ internal class RecipesListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val _screenState = MutableStateFlow(
|
private val _screenState =
|
||||||
RecipeListState(pagingDataRecipeState = pagingDataRecipeState)
|
MutableStateFlow(RecipeListState(pagingDataRecipeState = pagingDataRecipeState))
|
||||||
)
|
val screenState: StateFlow<RecipeListState>
|
||||||
val screenState: StateFlow<RecipeListState> get() = _screenState.asStateFlow()
|
get() = _screenState.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
authRepo.isAuthorizedFlow.valueUpdatesOnly().onEach { hasAuthorized ->
|
authRepo.isAuthorizedFlow
|
||||||
|
.valueUpdatesOnly()
|
||||||
|
.onEach { hasAuthorized ->
|
||||||
logger.v { "Authorization state changed to $hasAuthorized" }
|
logger.v { "Authorization state changed to $hasAuthorized" }
|
||||||
if (hasAuthorized) recipeRepo.refreshRecipes()
|
if (hasAuthorized) recipeRepo.refreshRecipes()
|
||||||
}.launchIn(viewModelScope)
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onRecipeClicked(entity: RecipeSummaryEntity) {
|
private fun onRecipeClicked(entity: RecipeSummaryEntity) {
|
||||||
@@ -75,22 +80,22 @@ internal class RecipesListViewModel @Inject constructor(
|
|||||||
private fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) {
|
private fun onFavoriteIconClick(recipeSummaryEntity: RecipeSummaryEntity) {
|
||||||
logger.v { "onFavoriteIconClick() called with: recipeSummaryEntity = $recipeSummaryEntity" }
|
logger.v { "onFavoriteIconClick() called with: recipeSummaryEntity = $recipeSummaryEntity" }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = recipeRepo.updateIsRecipeFavorite(
|
val result =
|
||||||
|
recipeRepo.updateIsRecipeFavorite(
|
||||||
recipeSlug = recipeSummaryEntity.slug,
|
recipeSlug = recipeSummaryEntity.slug,
|
||||||
isFavorite = recipeSummaryEntity.isFavorite.not(),
|
isFavorite = recipeSummaryEntity.isFavorite.not(),
|
||||||
)
|
)
|
||||||
val snackbar = result.fold(
|
val snackbar =
|
||||||
onSuccess = { isFavorite ->
|
result.fold(
|
||||||
|
onSuccess = { _ ->
|
||||||
val name = recipeSummaryEntity.name
|
val name = recipeSummaryEntity.name
|
||||||
if (isFavorite) {
|
if (recipeSummaryEntity.isFavorite) {
|
||||||
RecipeListSnackbar.FavoriteAdded(name)
|
|
||||||
} else {
|
|
||||||
RecipeListSnackbar.FavoriteRemoved(name)
|
RecipeListSnackbar.FavoriteRemoved(name)
|
||||||
|
} else {
|
||||||
|
RecipeListSnackbar.FavoriteAdded(name)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onFailure = {
|
onFailure = { RecipeListSnackbar.FavoriteUpdateFailed }
|
||||||
RecipeListSnackbar.FavoriteUpdateFailed
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
_screenState.update { it.copy(snackbarState = snackbar) }
|
_screenState.update { it.copy(snackbarState = snackbar) }
|
||||||
}
|
}
|
||||||
@@ -101,7 +106,8 @@ internal class RecipesListViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = recipeRepo.deleteRecipe(recipeSummaryEntity)
|
val result = recipeRepo.deleteRecipe(recipeSummaryEntity)
|
||||||
logger.d { "onDeleteConfirm: delete result is $result" }
|
logger.d { "onDeleteConfirm: delete result is $result" }
|
||||||
val snackbar = result.fold(
|
val snackbar =
|
||||||
|
result.fold(
|
||||||
onSuccess = { null },
|
onSuccess = { null },
|
||||||
onFailure = { RecipeListSnackbar.DeleteFailed },
|
onFailure = { RecipeListSnackbar.DeleteFailed },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -54,7 +54,11 @@ internal fun SearchTextField(
|
|||||||
focusedIndicatorColor = Color.Transparent,
|
focusedIndicatorColor = Color.Transparent,
|
||||||
unfocusedIndicatorColor = Color.Transparent,
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
disabledIndicatorColor = Color.Transparent,
|
disabledIndicatorColor = Color.Transparent,
|
||||||
errorIndicatorColor = Color.Transparent
|
errorIndicatorColor = Color.Transparent,
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
disabledContainerColor = Color.Transparent,
|
||||||
|
errorContainerColor = Color.Transparent
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_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_positive_btn">Confirm</string>
|
||||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_negative_btn">Cancel</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="menu_navigation_drawer_change_url">Change URL</string>
|
||||||
<string name="search_recipes_hint">Search recipes</string>
|
<string name="search_recipes_hint">Search recipes</string>
|
||||||
<string name="view_toolbar_navigation_icon_content_description">Open navigation drawer</string>
|
<string name="view_toolbar_navigation_icon_content_description">Open navigation drawer</string>
|
||||||
|
|||||||
@@ -9,22 +9,21 @@ internal interface RecipeDao {
|
|||||||
@Query("SELECT * FROM recipe_summaries ORDER BY recipe_summaries_date_added DESC")
|
@Query("SELECT * FROM recipe_summaries ORDER BY recipe_summaries_date_added DESC")
|
||||||
fun queryRecipesByPages(): PagingSource<Int, RecipeSummaryEntity>
|
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>
|
fun queryRecipesByPages(query: String): PagingSource<Int, RecipeSummaryEntity>
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertRecipeSummaries(recipeSummaryEntity: Iterable<RecipeSummaryEntity>)
|
suspend fun insertRecipeSummaries(recipeSummaryEntity: Iterable<RecipeSummaryEntity>)
|
||||||
|
|
||||||
@Transaction
|
@Transaction @Query("DELETE FROM recipe_summaries") suspend fun removeAllRecipes()
|
||||||
@Query("DELETE FROM recipe_summaries")
|
|
||||||
suspend fun removeAllRecipes()
|
|
||||||
|
|
||||||
@Query("SELECT * FROM recipe_summaries ORDER BY recipe_summaries_date_added DESC")
|
@Query("SELECT * FROM recipe_summaries ORDER BY recipe_summaries_date_added DESC")
|
||||||
suspend fun queryAllRecipes(): List<RecipeSummaryEntity>
|
suspend fun queryAllRecipes(): List<RecipeSummaryEntity>
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertRecipe(recipe: RecipeEntity)
|
||||||
suspend fun insertRecipe(recipe: RecipeEntity)
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertRecipes(recipe: List<RecipeEntity>)
|
suspend fun insertRecipes(recipe: List<RecipeEntity>)
|
||||||
@@ -36,10 +35,14 @@ internal interface RecipeDao {
|
|||||||
suspend fun insertRecipeIngredients(ingredients: List<RecipeIngredientEntity>)
|
suspend fun insertRecipeIngredients(ingredients: List<RecipeIngredientEntity>)
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertIngredientToInstructionEntities(entities: List<RecipeIngredientToInstructionEntity>)
|
suspend fun insertIngredientToInstructionEntities(
|
||||||
|
entities: List<RecipeIngredientToInstructionEntity>
|
||||||
|
)
|
||||||
|
|
||||||
@Transaction
|
@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(
|
@Query(
|
||||||
"SELECT * FROM recipe " +
|
"SELECT * FROM recipe " +
|
||||||
"JOIN recipe_summaries USING(recipe_id) " +
|
"JOIN recipe_summaries USING(recipe_id) " +
|
||||||
@@ -48,7 +51,9 @@ internal interface RecipeDao {
|
|||||||
"LEFT JOIN recipe_ingredient_to_instruction USING(recipe_id) " +
|
"LEFT JOIN recipe_ingredient_to_instruction USING(recipe_id) " +
|
||||||
"WHERE recipe.recipe_id = :recipeId"
|
"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)")
|
@Query("DELETE FROM recipe_ingredient WHERE recipe_id IN (:recipeIds)")
|
||||||
suspend fun deleteRecipeIngredients(vararg recipeIds: String)
|
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)")
|
@Query("DELETE FROM recipe_ingredient_to_instruction WHERE recipe_id IN (:recipeIds)")
|
||||||
suspend fun deleteRecipeIngredientToInstructions(vararg recipeIds: String)
|
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>)
|
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>)
|
suspend fun setNonFavorite(favorites: List<String>)
|
||||||
|
|
||||||
@Delete
|
@Delete suspend fun deleteRecipe(entity: RecipeSummaryEntity)
|
||||||
suspend fun deleteRecipe(entity: RecipeSummaryEntity)
|
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,9 @@ import com.atridad.mealient.database.recipe.entity.RecipeWithSummaryAndIngredien
|
|||||||
import com.atridad.mealient.logging.Logger
|
import com.atridad.mealient.logging.Logger
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
internal class RecipeStorageImpl @Inject constructor(
|
internal class RecipeStorageImpl
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
private val db: AppDb,
|
private val db: AppDb,
|
||||||
private val logger: Logger,
|
private val logger: Logger,
|
||||||
private val recipeDao: RecipeDao,
|
private val recipeDao: RecipeDao,
|
||||||
@@ -48,7 +50,9 @@ internal class RecipeStorageImpl @Inject constructor(
|
|||||||
instructions: List<RecipeInstructionEntity>,
|
instructions: List<RecipeInstructionEntity>,
|
||||||
ingredientToInstruction: List<RecipeIngredientToInstructionEntity>,
|
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 {
|
db.withTransaction {
|
||||||
recipeDao.insertRecipe(recipe)
|
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" }
|
logger.v { "queryRecipeInfo() called with: recipeId = $recipeId" }
|
||||||
val fullRecipeInfo = recipeDao.queryFullRecipeInfo(recipeId)
|
val fullRecipeInfo = recipeDao.queryFullRecipeInfo(recipeId)
|
||||||
logger.v { "queryRecipeInfo() returned: $fullRecipeInfo" }
|
logger.v { "queryRecipeInfo() returned: $fullRecipeInfo" }
|
||||||
@@ -73,8 +79,12 @@ internal class RecipeStorageImpl @Inject constructor(
|
|||||||
override suspend fun updateFavoriteRecipes(favorites: List<String>) {
|
override suspend fun updateFavoriteRecipes(favorites: List<String>) {
|
||||||
logger.v { "updateFavoriteRecipes() called with: favorites = $favorites" }
|
logger.v { "updateFavoriteRecipes() called with: favorites = $favorites" }
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
|
if (favorites.isNotEmpty()) {
|
||||||
recipeDao.setFavorite(favorites)
|
recipeDao.setFavorite(favorites)
|
||||||
recipeDao.setNonFavorite(favorites)
|
recipeDao.setNonFavorite(favorites)
|
||||||
|
} else {
|
||||||
|
recipeDao.setAllNonFavorite()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,15 @@ import com.atridad.mealient.datasource.models.GetShoppingListItemResponse
|
|||||||
import com.atridad.mealient.datasource.models.GetShoppingListResponse
|
import com.atridad.mealient.datasource.models.GetShoppingListResponse
|
||||||
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
|
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
|
||||||
import com.atridad.mealient.datasource.models.GetUnitsResponse
|
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.GetUserInfoResponse
|
||||||
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
||||||
import com.atridad.mealient.datasource.models.UpdateRecipeRequest
|
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 com.atridad.mealient.datasource.models.VersionResponse
|
||||||
|
|
||||||
interface MealieDataSource {
|
interface MealieDataSource {
|
||||||
@@ -28,9 +34,7 @@ interface MealieDataSource {
|
|||||||
recipe: UpdateRecipeRequest,
|
recipe: UpdateRecipeRequest,
|
||||||
): GetRecipeResponse
|
): GetRecipeResponse
|
||||||
|
|
||||||
/**
|
/** Tries to acquire authentication token using the provided credentials */
|
||||||
* Tries to acquire authentication token using the provided credentials
|
|
||||||
*/
|
|
||||||
suspend fun authenticate(
|
suspend fun authenticate(
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
@@ -82,4 +86,15 @@ interface MealieDataSource {
|
|||||||
suspend fun deleteShoppingList(id: String)
|
suspend fun deleteShoppingList(id: String)
|
||||||
|
|
||||||
suspend fun updateShoppingListName(id: String, name: 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)
|
||||||
}
|
}
|
||||||
@@ -12,10 +12,16 @@ import com.atridad.mealient.datasource.models.GetShoppingListResponse
|
|||||||
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
|
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
|
||||||
import com.atridad.mealient.datasource.models.GetTokenResponse
|
import com.atridad.mealient.datasource.models.GetTokenResponse
|
||||||
import com.atridad.mealient.datasource.models.GetUnitsResponse
|
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.GetUserInfoResponse
|
||||||
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
||||||
import com.atridad.mealient.datasource.models.UpdateRecipeRequest
|
import com.atridad.mealient.datasource.models.UpdateRecipeRequest
|
||||||
import com.atridad.mealient.datasource.models.VersionResponse
|
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
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
internal interface MealieService {
|
internal interface MealieService {
|
||||||
@@ -70,4 +76,15 @@ internal interface MealieService {
|
|||||||
suspend fun updateShoppingList(id: String, request: JsonElement)
|
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
|
||||||
|
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
@@ -17,38 +17,50 @@ import com.atridad.mealient.datasource.models.GetShoppingListItemResponse
|
|||||||
import com.atridad.mealient.datasource.models.GetShoppingListResponse
|
import com.atridad.mealient.datasource.models.GetShoppingListResponse
|
||||||
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
|
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
|
||||||
import com.atridad.mealient.datasource.models.GetUnitsResponse
|
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.GetUserInfoResponse
|
||||||
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
||||||
import com.atridad.mealient.datasource.models.UpdateRecipeRequest
|
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 com.atridad.mealient.datasource.models.VersionResponse
|
||||||
import io.ktor.client.call.NoTransformationFoundException
|
import io.ktor.client.call.NoTransformationFoundException
|
||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.plugins.ResponseException
|
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.JsonElement
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.jsonObject
|
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 networkRequestWrapper: NetworkRequestWrapper,
|
||||||
private val service: MealieService,
|
private val service: MealieService,
|
||||||
) : MealieDataSource {
|
) : MealieDataSource {
|
||||||
|
|
||||||
override suspend fun createRecipe(
|
override suspend fun createRecipe(
|
||||||
recipe: CreateRecipeRequest,
|
recipe: CreateRecipeRequest,
|
||||||
): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): String =
|
||||||
|
networkRequestWrapper
|
||||||
|
.makeCallAndHandleUnauthorized(
|
||||||
block = { service.createRecipe(recipe) },
|
block = { service.createRecipe(recipe) },
|
||||||
logMethod = { "createRecipe" },
|
logMethod = { "createRecipe" },
|
||||||
logParameters = { "recipe = $recipe" }
|
logParameters = { "recipe = $recipe" }
|
||||||
).trim('"')
|
)
|
||||||
|
.trim('"')
|
||||||
|
|
||||||
override suspend fun updateRecipe(
|
override suspend fun updateRecipe(
|
||||||
slug: String,
|
slug: String,
|
||||||
recipe: UpdateRecipeRequest,
|
recipe: UpdateRecipeRequest,
|
||||||
): GetRecipeResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): GetRecipeResponse =
|
||||||
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
block = { service.updateRecipe(recipe, slug) },
|
block = { service.updateRecipe(recipe, slug) },
|
||||||
logMethod = { "updateRecipe" },
|
logMethod = { "updateRecipe" },
|
||||||
logParameters = { "slug = $slug, recipe = $recipe" }
|
logParameters = { "slug = $slug, recipe = $recipe" }
|
||||||
@@ -57,13 +69,21 @@ internal class MealieDataSourceImpl @Inject constructor(
|
|||||||
override suspend fun authenticate(
|
override suspend fun authenticate(
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
): String = networkRequestWrapper.makeCall(
|
): String =
|
||||||
|
networkRequestWrapper
|
||||||
|
.makeCall(
|
||||||
block = { service.getToken(username, password) },
|
block = { service.getToken(username, password) },
|
||||||
logMethod = { "authenticate" },
|
logMethod = { "authenticate" },
|
||||||
logParameters = { "username = $username, password = $password" }
|
logParameters = { "username = $username, password = $password" }
|
||||||
).map { it.accessToken }.getOrElse {
|
)
|
||||||
val errorDetail = (it as? ResponseException)?.response?.body<ErrorDetail>() ?: throw it
|
.map { it.accessToken }
|
||||||
throw if (errorDetail.detail == "Unauthorized") NetworkError.Unauthorized(it) else it
|
.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 =
|
override suspend fun getVersionInfo(baseURL: String): VersionResponse =
|
||||||
@@ -71,10 +91,13 @@ internal class MealieDataSourceImpl @Inject constructor(
|
|||||||
block = { service.getVersion(baseURL) },
|
block = { service.getVersion(baseURL) },
|
||||||
logMethod = { "getVersionInfo" },
|
logMethod = { "getVersionInfo" },
|
||||||
logParameters = { "baseURL = $baseURL" }
|
logParameters = { "baseURL = $baseURL" }
|
||||||
).getOrElse {
|
)
|
||||||
|
.getOrElse {
|
||||||
throw when (it) {
|
throw when (it) {
|
||||||
is ResponseException, is NoTransformationFoundException -> NetworkError.NotMealie(it)
|
is ResponseException, is NoTransformationFoundException ->
|
||||||
is SocketTimeoutException, is SocketException -> NetworkError.NoServerConnection(it)
|
NetworkError.NotMealie(it)
|
||||||
|
is SocketTimeoutException, is SocketException ->
|
||||||
|
NetworkError.NoServerConnection(it)
|
||||||
else -> NetworkError.MalformedUrl(it)
|
else -> NetworkError.MalformedUrl(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,15 +105,21 @@ internal class MealieDataSourceImpl @Inject constructor(
|
|||||||
override suspend fun requestRecipes(
|
override suspend fun requestRecipes(
|
||||||
page: Int,
|
page: Int,
|
||||||
perPage: Int,
|
perPage: Int,
|
||||||
): List<GetRecipeSummaryResponse> = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): List<GetRecipeSummaryResponse> {
|
||||||
|
val response =
|
||||||
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
block = { service.getRecipeSummary(page, perPage) },
|
block = { service.getRecipeSummary(page, perPage) },
|
||||||
logMethod = { "requestRecipes" },
|
logMethod = { "requestRecipes" },
|
||||||
logParameters = { "page = $page, perPage = $perPage" }
|
logParameters = { "page = $page, perPage = $perPage" }
|
||||||
).items
|
)
|
||||||
|
|
||||||
|
return response.items
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun requestRecipeInfo(
|
override suspend fun requestRecipeInfo(
|
||||||
slug: String,
|
slug: String,
|
||||||
): GetRecipeResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): GetRecipeResponse =
|
||||||
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
block = { service.getRecipe(slug) },
|
block = { service.getRecipe(slug) },
|
||||||
logMethod = { "requestRecipeInfo" },
|
logMethod = { "requestRecipeInfo" },
|
||||||
logParameters = { "slug = $slug" }
|
logParameters = { "slug = $slug" }
|
||||||
@@ -98,7 +127,8 @@ internal class MealieDataSourceImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun parseRecipeFromURL(
|
override suspend fun parseRecipeFromURL(
|
||||||
request: ParseRecipeURLRequest,
|
request: ParseRecipeURLRequest,
|
||||||
): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): String =
|
||||||
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
block = { service.createRecipeFromURL(request) },
|
block = { service.createRecipeFromURL(request) },
|
||||||
logMethod = { "parseRecipeFromURL" },
|
logMethod = { "parseRecipeFromURL" },
|
||||||
logParameters = { "request = $request" }
|
logParameters = { "request = $request" }
|
||||||
@@ -106,40 +136,51 @@ internal class MealieDataSourceImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun createApiToken(
|
override suspend fun createApiToken(
|
||||||
request: CreateApiTokenRequest,
|
request: CreateApiTokenRequest,
|
||||||
): CreateApiTokenResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): CreateApiTokenResponse =
|
||||||
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
block = { service.createApiToken(request) },
|
block = { service.createApiToken(request) },
|
||||||
logMethod = { "createApiToken" },
|
logMethod = { "createApiToken" },
|
||||||
logParameters = { "request = $request" }
|
logParameters = { "request = $request" }
|
||||||
)
|
)
|
||||||
|
|
||||||
override suspend fun requestUserInfo(): GetUserInfoResponse {
|
override suspend fun requestUserInfo(): GetUserInfoResponse {
|
||||||
return networkRequestWrapper.makeCallAndHandleUnauthorized(
|
val response =
|
||||||
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
block = { service.getUserSelfInfo() },
|
block = { service.getUserSelfInfo() },
|
||||||
logMethod = { "requestUserInfo" },
|
logMethod = { "requestUserInfo" },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun removeFavoriteRecipe(
|
override suspend fun removeFavoriteRecipe(
|
||||||
userId: String,
|
userId: String,
|
||||||
recipeSlug: String,
|
recipeSlug: String,
|
||||||
): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): Unit {
|
||||||
|
|
||||||
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
block = { service.removeFavoriteRecipe(userId, recipeSlug) },
|
block = { service.removeFavoriteRecipe(userId, recipeSlug) },
|
||||||
logMethod = { "removeFavoriteRecipe" },
|
logMethod = { "removeFavoriteRecipe" },
|
||||||
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
|
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun addFavoriteRecipe(
|
override suspend fun addFavoriteRecipe(
|
||||||
userId: String,
|
userId: String,
|
||||||
recipeSlug: String,
|
recipeSlug: String,
|
||||||
): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): Unit {
|
||||||
|
|
||||||
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
block = { service.addFavoriteRecipe(userId, recipeSlug) },
|
block = { service.addFavoriteRecipe(userId, recipeSlug) },
|
||||||
logMethod = { "addFavoriteRecipe" },
|
logMethod = { "addFavoriteRecipe" },
|
||||||
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
|
logParameters = { "userId = $userId, recipeSlug = $recipeSlug" }
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun deleteRecipe(
|
override suspend fun deleteRecipe(
|
||||||
slug: String,
|
slug: String,
|
||||||
): Unit = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): Unit =
|
||||||
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
block = { service.deleteRecipe(slug) },
|
block = { service.deleteRecipe(slug) },
|
||||||
logMethod = { "deleteRecipe" },
|
logMethod = { "deleteRecipe" },
|
||||||
logParameters = { "slug = $slug" }
|
logParameters = { "slug = $slug" }
|
||||||
@@ -148,7 +189,8 @@ internal class MealieDataSourceImpl @Inject constructor(
|
|||||||
override suspend fun getShoppingLists(
|
override suspend fun getShoppingLists(
|
||||||
page: Int,
|
page: Int,
|
||||||
perPage: Int,
|
perPage: Int,
|
||||||
): GetShoppingListsResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): GetShoppingListsResponse =
|
||||||
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
block = { service.getShoppingLists(page, perPage) },
|
block = { service.getShoppingLists(page, perPage) },
|
||||||
logMethod = { "getShoppingLists" },
|
logMethod = { "getShoppingLists" },
|
||||||
logParameters = { "page = $page, perPage = $perPage" }
|
logParameters = { "page = $page, perPage = $perPage" }
|
||||||
@@ -156,7 +198,8 @@ internal class MealieDataSourceImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun getShoppingList(
|
override suspend fun getShoppingList(
|
||||||
id: String,
|
id: String,
|
||||||
): GetShoppingListResponse = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): GetShoppingListResponse =
|
||||||
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
block = { service.getShoppingList(id) },
|
block = { service.getShoppingList(id) },
|
||||||
logMethod = { "getShoppingList" },
|
logMethod = { "getShoppingList" },
|
||||||
logParameters = { "id = $id" }
|
logParameters = { "id = $id" }
|
||||||
@@ -164,7 +207,8 @@ internal class MealieDataSourceImpl @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun getShoppingListItem(
|
private suspend fun getShoppingListItem(
|
||||||
id: String,
|
id: String,
|
||||||
): JsonElement = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
): JsonElement =
|
||||||
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
block = { service.getShoppingListItem(id) },
|
block = { service.getShoppingListItem(id) },
|
||||||
logMethod = { "getShoppingListItem" },
|
logMethod = { "getShoppingListItem" },
|
||||||
logParameters = { "id = $id" }
|
logParameters = { "id = $id" }
|
||||||
@@ -173,7 +217,8 @@ internal class MealieDataSourceImpl @Inject constructor(
|
|||||||
private suspend fun updateShoppingListItem(
|
private suspend fun updateShoppingListItem(
|
||||||
id: String,
|
id: String,
|
||||||
request: JsonElement,
|
request: JsonElement,
|
||||||
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
) =
|
||||||
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
block = { service.updateShoppingListItem(id, request) },
|
block = { service.updateShoppingListItem(id, request) },
|
||||||
logMethod = { "updateShoppingListItem" },
|
logMethod = { "updateShoppingListItem" },
|
||||||
logParameters = { "id = $id, request = $request" }
|
logParameters = { "id = $id, request = $request" }
|
||||||
@@ -181,7 +226,8 @@ internal class MealieDataSourceImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun deleteShoppingListItem(
|
override suspend fun deleteShoppingListItem(
|
||||||
id: String,
|
id: String,
|
||||||
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
) =
|
||||||
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
block = { service.deleteShoppingListItem(id) },
|
block = { service.deleteShoppingListItem(id) },
|
||||||
logMethod = { "deleteShoppingListItem" },
|
logMethod = { "deleteShoppingListItem" },
|
||||||
logParameters = { "id = $id" }
|
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
|
// Has to be done in two steps because we can't specify only the changed fields
|
||||||
val remoteItem = getShoppingListItem(item.id)
|
val remoteItem = getShoppingListItem(item.id)
|
||||||
val updatedItem = remoteItem.jsonObject.toMutableMap().apply {
|
val updatedItem =
|
||||||
|
remoteItem.jsonObject.toMutableMap().apply {
|
||||||
put("checked", JsonPrimitive(item.checked))
|
put("checked", JsonPrimitive(item.checked))
|
||||||
put("isFood", JsonPrimitive(item.isFood))
|
put("isFood", JsonPrimitive(item.isFood))
|
||||||
put("note", JsonPrimitive(item.note))
|
put("note", JsonPrimitive(item.note))
|
||||||
@@ -221,7 +268,8 @@ internal class MealieDataSourceImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun addShoppingListItem(
|
override suspend fun addShoppingListItem(
|
||||||
request: CreateShoppingListItemRequest,
|
request: CreateShoppingListItemRequest,
|
||||||
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
) =
|
||||||
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
block = { service.createShoppingListItem(request) },
|
block = { service.createShoppingListItem(request) },
|
||||||
logMethod = { "addShoppingListItem" },
|
logMethod = { "addShoppingListItem" },
|
||||||
logParameters = { "request = $request" }
|
logParameters = { "request = $request" }
|
||||||
@@ -229,7 +277,8 @@ internal class MealieDataSourceImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun addShoppingList(
|
override suspend fun addShoppingList(
|
||||||
request: CreateShoppingListRequest,
|
request: CreateShoppingListRequest,
|
||||||
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
) =
|
||||||
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
block = { service.createShoppingList(request) },
|
block = { service.createShoppingList(request) },
|
||||||
logMethod = { "createShoppingList" },
|
logMethod = { "createShoppingList" },
|
||||||
logParameters = { "request = $request" }
|
logParameters = { "request = $request" }
|
||||||
@@ -238,7 +287,8 @@ internal class MealieDataSourceImpl @Inject constructor(
|
|||||||
private suspend fun updateShoppingList(
|
private suspend fun updateShoppingList(
|
||||||
id: String,
|
id: String,
|
||||||
request: JsonElement,
|
request: JsonElement,
|
||||||
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
) =
|
||||||
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
block = { service.updateShoppingList(id, request) },
|
block = { service.updateShoppingList(id, request) },
|
||||||
logMethod = { "updateShoppingList" },
|
logMethod = { "updateShoppingList" },
|
||||||
logParameters = { "id = $id, request = $request" }
|
logParameters = { "id = $id, request = $request" }
|
||||||
@@ -246,7 +296,8 @@ internal class MealieDataSourceImpl @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun getShoppingListJson(
|
private suspend fun getShoppingListJson(
|
||||||
id: String,
|
id: String,
|
||||||
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
) =
|
||||||
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
block = { service.getShoppingListJson(id) },
|
block = { service.getShoppingListJson(id) },
|
||||||
logMethod = { "getShoppingListJson" },
|
logMethod = { "getShoppingListJson" },
|
||||||
logParameters = { "id = $id" }
|
logParameters = { "id = $id" }
|
||||||
@@ -254,21 +305,69 @@ internal class MealieDataSourceImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun deleteShoppingList(
|
override suspend fun deleteShoppingList(
|
||||||
id: String,
|
id: String,
|
||||||
) = networkRequestWrapper.makeCallAndHandleUnauthorized(
|
) =
|
||||||
|
networkRequestWrapper.makeCallAndHandleUnauthorized(
|
||||||
block = { service.deleteShoppingList(id) },
|
block = { service.deleteShoppingList(id) },
|
||||||
logMethod = { "deleteShoppingList" },
|
logMethod = { "deleteShoppingList" },
|
||||||
logParameters = { "id = $id" }
|
logParameters = { "id = $id" }
|
||||||
)
|
)
|
||||||
|
|
||||||
override suspend fun updateShoppingListName(
|
override suspend fun updateShoppingListName(id: String, name: String) {
|
||||||
id: String,
|
|
||||||
name: String
|
|
||||||
) {
|
|
||||||
// Has to be done in two steps because we can't specify only the changed fields
|
// Has to be done in two steps because we can't specify only the changed fields
|
||||||
val remoteItem = getShoppingListJson(id)
|
val remoteItem = getShoppingListJson(id)
|
||||||
val updatedItem = remoteItem.jsonObject.toMutableMap().apply {
|
val updatedItem =
|
||||||
put("name", JsonPrimitive(name))
|
remoteItem
|
||||||
}.let(::JsonObject)
|
.jsonObject
|
||||||
|
.toMutableMap()
|
||||||
|
.apply { put("name", JsonPrimitive(name)) }
|
||||||
|
.let(::JsonObject)
|
||||||
updateShoppingList(id, updatedItem)
|
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" }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,31 +14,43 @@ import com.atridad.mealient.datasource.models.GetShoppingListResponse
|
|||||||
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
|
import com.atridad.mealient.datasource.models.GetShoppingListsResponse
|
||||||
import com.atridad.mealient.datasource.models.GetTokenResponse
|
import com.atridad.mealient.datasource.models.GetTokenResponse
|
||||||
import com.atridad.mealient.datasource.models.GetUnitsResponse
|
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.GetUserInfoResponse
|
||||||
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
||||||
import com.atridad.mealient.datasource.models.UpdateRecipeRequest
|
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 com.atridad.mealient.datasource.models.VersionResponse
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.request.HttpRequestBuilder
|
import io.ktor.client.request.HttpRequestBuilder
|
||||||
import io.ktor.client.request.delete
|
import io.ktor.client.request.delete
|
||||||
import io.ktor.client.request.forms.FormDataContent
|
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.get
|
||||||
import io.ktor.client.request.patch
|
import io.ktor.client.request.patch
|
||||||
import io.ktor.client.request.post
|
import io.ktor.client.request.post
|
||||||
import io.ktor.client.request.put
|
import io.ktor.client.request.put
|
||||||
import io.ktor.client.request.setBody
|
import io.ktor.client.request.setBody
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.Headers
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
import io.ktor.http.URLBuilder
|
import io.ktor.http.URLBuilder
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
import io.ktor.http.parameters
|
import io.ktor.http.parameters
|
||||||
import io.ktor.http.path
|
import io.ktor.http.path
|
||||||
import io.ktor.http.takeFrom
|
import io.ktor.http.takeFrom
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Provider
|
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 httpClient: HttpClient,
|
||||||
private val serverUrlProviderProvider: Provider<ServerUrlProvider>,
|
private val serverUrlProviderProvider: Provider<ServerUrlProvider>,
|
||||||
) : MealieService {
|
) : MealieService {
|
||||||
@@ -52,111 +64,109 @@ internal class MealieServiceKtor @Inject constructor(
|
|||||||
append("password", password)
|
append("password", password)
|
||||||
}
|
}
|
||||||
|
|
||||||
return httpClient.post {
|
return httpClient
|
||||||
|
.post {
|
||||||
endpoint("/api/auth/token")
|
endpoint("/api/auth/token")
|
||||||
setBody(FormDataContent(formParameters))
|
setBody(FormDataContent(formParameters))
|
||||||
}.body()
|
}
|
||||||
|
.body()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createRecipe(addRecipeRequest: CreateRecipeRequest): String {
|
override suspend fun createRecipe(addRecipeRequest: CreateRecipeRequest): String {
|
||||||
return httpClient.post {
|
return httpClient
|
||||||
|
.post {
|
||||||
endpoint("/api/recipes")
|
endpoint("/api/recipes")
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(addRecipeRequest)
|
setBody(addRecipeRequest)
|
||||||
}.body()
|
}
|
||||||
|
.body()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateRecipe(
|
override suspend fun updateRecipe(
|
||||||
addRecipeRequest: UpdateRecipeRequest,
|
addRecipeRequest: UpdateRecipeRequest,
|
||||||
slug: String,
|
slug: String,
|
||||||
): GetRecipeResponse {
|
): GetRecipeResponse {
|
||||||
return httpClient.patch {
|
return httpClient
|
||||||
|
.patch {
|
||||||
endpoint("/api/recipes/$slug")
|
endpoint("/api/recipes/$slug")
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(addRecipeRequest)
|
setBody(addRecipeRequest)
|
||||||
}.body()
|
}
|
||||||
|
.body()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getVersion(baseURL: String): VersionResponse {
|
override suspend fun getVersion(baseURL: String): VersionResponse {
|
||||||
return httpClient.get {
|
return httpClient.get { endpoint(baseURL, "/api/app/about") }.body()
|
||||||
endpoint(baseURL, "/api/app/about")
|
|
||||||
}.body()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getRecipeSummary(page: Int, perPage: Int): GetRecipesResponse {
|
override suspend fun getRecipeSummary(page: Int, perPage: Int): GetRecipesResponse {
|
||||||
return httpClient.get {
|
return httpClient
|
||||||
|
.get {
|
||||||
endpoint("/api/recipes") {
|
endpoint("/api/recipes") {
|
||||||
parameters.append("page", page.toString())
|
parameters.append("page", page.toString())
|
||||||
parameters.append("perPage", perPage.toString())
|
parameters.append("perPage", perPage.toString())
|
||||||
}
|
}
|
||||||
}.body()
|
}
|
||||||
|
.body()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getRecipe(slug: String): GetRecipeResponse {
|
override suspend fun getRecipe(slug: String): GetRecipeResponse {
|
||||||
return httpClient.get {
|
return httpClient.get { endpoint("/api/recipes/$slug") }.body()
|
||||||
endpoint("/api/recipes/$slug")
|
|
||||||
}.body()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createRecipeFromURL(request: ParseRecipeURLRequest): String {
|
override suspend fun createRecipeFromURL(request: ParseRecipeURLRequest): String {
|
||||||
return httpClient.post {
|
return httpClient
|
||||||
|
.post {
|
||||||
endpoint("/api/recipes/create-url")
|
endpoint("/api/recipes/create-url")
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}.body()
|
}
|
||||||
|
.body()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createApiToken(request: CreateApiTokenRequest): CreateApiTokenResponse {
|
override suspend fun createApiToken(request: CreateApiTokenRequest): CreateApiTokenResponse {
|
||||||
return httpClient.post {
|
return httpClient
|
||||||
|
.post {
|
||||||
endpoint("/api/users/api-tokens")
|
endpoint("/api/users/api-tokens")
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}.body()
|
}
|
||||||
|
.body()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getUserSelfInfo(): GetUserInfoResponse {
|
override suspend fun getUserSelfInfo(): GetUserInfoResponse {
|
||||||
return httpClient.get {
|
return httpClient.get { endpoint("/api/users/self") }.body()
|
||||||
endpoint("/api/users/self")
|
|
||||||
}.body()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun removeFavoriteRecipe(userId: String, recipeSlug: String) {
|
override suspend fun removeFavoriteRecipe(userId: String, recipeSlug: String) {
|
||||||
httpClient.delete {
|
httpClient.delete { endpoint("/api/users/$userId/favorites/$recipeSlug") }
|
||||||
endpoint("/api/users/$userId/favorites/$recipeSlug")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun addFavoriteRecipe(userId: String, recipeSlug: String) {
|
override suspend fun addFavoriteRecipe(userId: String, recipeSlug: String) {
|
||||||
httpClient.post {
|
httpClient.post { endpoint("/api/users/$userId/favorites/$recipeSlug") }
|
||||||
endpoint("/api/users/$userId/favorites/$recipeSlug")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteRecipe(slug: String) {
|
override suspend fun deleteRecipe(slug: String) {
|
||||||
httpClient.delete {
|
httpClient.delete { endpoint("/api/recipes/$slug") }
|
||||||
endpoint("/api/recipes/$slug")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getShoppingLists(page: Int, perPage: Int): GetShoppingListsResponse {
|
override suspend fun getShoppingLists(page: Int, perPage: Int): GetShoppingListsResponse {
|
||||||
return httpClient.get {
|
return httpClient
|
||||||
|
.get {
|
||||||
endpoint("/api/households/shopping/lists") {
|
endpoint("/api/households/shopping/lists") {
|
||||||
parameters.append("page", page.toString())
|
parameters.append("page", page.toString())
|
||||||
parameters.append("perPage", perPage.toString())
|
parameters.append("perPage", perPage.toString())
|
||||||
}
|
}
|
||||||
}.body()
|
}
|
||||||
|
.body()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getShoppingList(id: String): GetShoppingListResponse {
|
override suspend fun getShoppingList(id: String): GetShoppingListResponse {
|
||||||
return httpClient.get {
|
return httpClient.get { endpoint("/api/households/shopping/lists/$id") }.body()
|
||||||
endpoint("/api/households/shopping/lists/$id")
|
|
||||||
}.body()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getShoppingListItem(id: String): JsonElement {
|
override suspend fun getShoppingListItem(id: String): JsonElement {
|
||||||
return httpClient.get {
|
return httpClient.get { endpoint("/api/households/shopping/items/$id") }.body()
|
||||||
endpoint("/api/households/shopping/items/$id")
|
|
||||||
}.body()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateShoppingListItem(id: String, request: JsonElement) {
|
override suspend fun updateShoppingListItem(id: String, request: JsonElement) {
|
||||||
@@ -168,25 +178,19 @@ internal class MealieServiceKtor @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteShoppingListItem(id: String) {
|
override suspend fun deleteShoppingListItem(id: String) {
|
||||||
httpClient.delete {
|
httpClient.delete { endpoint("/api/households/shopping/items/$id") }
|
||||||
endpoint("/api/households/shopping/items/$id")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getFoods(perPage: Int): GetFoodsResponse {
|
override suspend fun getFoods(perPage: Int): GetFoodsResponse {
|
||||||
return httpClient.get {
|
return httpClient
|
||||||
endpoint("/api/foods") {
|
.get { endpoint("/api/foods") { parameters.append("perPage", perPage.toString()) } }
|
||||||
parameters.append("perPage", perPage.toString())
|
.body()
|
||||||
}
|
|
||||||
}.body()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getUnits(perPage: Int): GetUnitsResponse {
|
override suspend fun getUnits(perPage: Int): GetUnitsResponse {
|
||||||
return httpClient.get {
|
return httpClient
|
||||||
endpoint("/api/units") {
|
.get { endpoint("/api/units") { parameters.append("perPage", perPage.toString()) } }
|
||||||
parameters.append("perPage", perPage.toString())
|
.body()
|
||||||
}
|
|
||||||
}.body()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createShoppingListItem(request: CreateShoppingListItemRequest) {
|
override suspend fun createShoppingListItem(request: CreateShoppingListItemRequest) {
|
||||||
@@ -206,9 +210,7 @@ internal class MealieServiceKtor @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteShoppingList(id: String) {
|
override suspend fun deleteShoppingList(id: String) {
|
||||||
httpClient.delete {
|
httpClient.delete { endpoint("/api/households/shopping/lists/$id") }
|
||||||
endpoint("/api/households/shopping/lists/$id")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateShoppingList(id: String, request: JsonElement) {
|
override suspend fun updateShoppingList(id: String, request: JsonElement) {
|
||||||
@@ -220,9 +222,11 @@ internal class MealieServiceKtor @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getShoppingListJson(id: String): JsonElement {
|
override suspend fun getShoppingListJson(id: String): JsonElement {
|
||||||
return httpClient.get {
|
return httpClient.get { endpoint("/api/households/shopping/lists/$id") }.body()
|
||||||
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(
|
private suspend fun HttpRequestBuilder.endpoint(
|
||||||
@@ -230,12 +234,49 @@ internal class MealieServiceKtor @Inject constructor(
|
|||||||
block: URLBuilder.() -> Unit = {},
|
block: URLBuilder.() -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val baseUrl = checkNotNull(serverUrlProvider.getUrl()) { "Server URL is not set" }
|
val baseUrl = checkNotNull(serverUrlProvider.getUrl()) { "Server URL is not set" }
|
||||||
endpoint(
|
endpoint(baseUrl = baseUrl, path = path, block = block)
|
||||||
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(
|
private fun HttpRequestBuilder.endpoint(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
@@ -248,4 +289,6 @@ internal class MealieServiceKtor @Inject constructor(
|
|||||||
block()
|
block()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
1
features/user_managment/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
42
features/user_managment/build.gradle.kts
Normal file
42
features/user_managment/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[versions]
|
[versions]
|
||||||
# https://maven.google.com/web/index.html?q=com.android.tools.build#com.android.tools.build:gradle
|
# 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
|
# https://github.com/JetBrains/kotlin/releases
|
||||||
kotlin = "2.0.10"
|
kotlin = "2.0.10"
|
||||||
# https://dagger.dev/hilt/gradle-setup
|
# https://dagger.dev/hilt/gradle-setup
|
||||||
|
|||||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,7 +1,7 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionSha256Sum=31c55713e40233a8303827ceb42ca48a47267a0ad4bab9177123121e71524c26
|
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
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
@@ -38,3 +38,4 @@ include(":testing")
|
|||||||
include(":ui")
|
include(":ui")
|
||||||
include(":model_mapper")
|
include(":model_mapper")
|
||||||
include(":features:shopping_lists")
|
include(":features:shopping_lists")
|
||||||
|
include(":features:user_managment")
|
||||||
|
|||||||
Reference in New Issue
Block a user