Added the profile feature
This commit is contained in:
@@ -77,6 +77,7 @@ dependencies {
|
||||
implementation(project(":logging"))
|
||||
implementation(project(":ui"))
|
||||
implementation(project(":features:shopping_lists"))
|
||||
implementation(project(":features:user_managment"))
|
||||
implementation(project(":model_mapper"))
|
||||
implementation(libs.android.material.material)
|
||||
implementation(libs.androidx.coreKtx)
|
||||
@@ -103,6 +104,7 @@ dependencies {
|
||||
kover(project(":datasource"))
|
||||
kover(project(":datastore"))
|
||||
kover(project(":features:shopping_lists"))
|
||||
kover(project(":features:user_managment"))
|
||||
kover(project(":logging"))
|
||||
kover(project(":model_mapper"))
|
||||
kover(project(":ui"))
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.atridad.mealient.ui.destinations.BaseURLScreenDestination
|
||||
import com.atridad.mealient.ui.destinations.DisclaimerScreenDestination
|
||||
import com.atridad.mealient.ui.destinations.RecipeScreenDestination
|
||||
import com.atridad.mealient.ui.destinations.RecipesListDestination
|
||||
import com.mealient.user_management.ui.profile.destinations.UserProfileScreenDestination
|
||||
|
||||
internal object NavGraphs {
|
||||
|
||||
@@ -40,6 +41,7 @@ internal object NavGraphs {
|
||||
DisclaimerScreenDestination,
|
||||
BaseURLScreenDestination,
|
||||
AuthenticationScreenDestination,
|
||||
UserProfileScreenDestination,
|
||||
),
|
||||
nestedNavGraphs = listOf(
|
||||
recipes,
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.List
|
||||
import androidx.compose.material.icons.filled.Logout
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.ShoppingCart
|
||||
import androidx.compose.material.icons.filled.SyncAlt
|
||||
import androidx.compose.material3.DrawerState
|
||||
@@ -28,6 +29,7 @@ import com.atridad.mealient.ui.components.DrawerItem
|
||||
import com.atridad.mealient.ui.destinations.AddRecipeScreenDestination
|
||||
import com.atridad.mealient.ui.destinations.BaseURLScreenDestination
|
||||
import com.atridad.mealient.ui.destinations.RecipesListDestination
|
||||
import com.mealient.user_management.ui.profile.destinations.UserProfileScreenDestination
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
@@ -91,6 +93,11 @@ internal fun createDrawerItems(
|
||||
icon = Icons.Default.ShoppingCart,
|
||||
direction = NavGraphs.shoppingLists,
|
||||
),
|
||||
createNavigationItem(
|
||||
nameRes = R.string.menu_navigation_drawer_profile,
|
||||
icon = Icons.Default.Person,
|
||||
direction = UserProfileScreenDestination,
|
||||
),
|
||||
createNavigationItem(
|
||||
nameRes = R.string.menu_navigation_drawer_change_url,
|
||||
icon = Icons.Default.SyncAlt,
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_message">Are you sure you want to delete %1$s? This cannot be undone.</string>
|
||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_positive_btn">Confirm</string>
|
||||
<string name="fragment_recipes_delete_recipe_confirm_dialog_negative_btn">Cancel</string>
|
||||
<string name="menu_navigation_drawer_profile">Profile</string>
|
||||
<string name="menu_navigation_drawer_change_url">Change URL</string>
|
||||
<string name="search_recipes_hint">Search recipes</string>
|
||||
<string name="view_toolbar_navigation_icon_content_description">Open navigation drawer</string>
|
||||
|
||||
@@ -16,6 +16,11 @@ import com.atridad.mealient.datasource.models.GetUserFavoritesResponse
|
||||
import com.atridad.mealient.datasource.models.GetUserInfoResponse
|
||||
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
||||
import com.atridad.mealient.datasource.models.UpdateRecipeRequest
|
||||
import com.atridad.mealient.datasource.models.UserProfileResponse
|
||||
import com.atridad.mealient.datasource.models.UpdateUserProfileRequest
|
||||
import com.atridad.mealient.datasource.models.UpdateUserResponse
|
||||
import com.atridad.mealient.datasource.models.ChangePasswordRequest
|
||||
import com.atridad.mealient.datasource.models.UpdateProfileImageRequest
|
||||
import com.atridad.mealient.datasource.models.VersionResponse
|
||||
|
||||
interface MealieDataSource {
|
||||
@@ -83,4 +88,13 @@ interface MealieDataSource {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ import com.atridad.mealient.datasource.models.GetUserInfoResponse
|
||||
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
||||
import com.atridad.mealient.datasource.models.UpdateRecipeRequest
|
||||
import com.atridad.mealient.datasource.models.VersionResponse
|
||||
import com.atridad.mealient.datasource.models.UserProfileResponse
|
||||
import com.atridad.mealient.datasource.models.UpdateUserProfileRequest
|
||||
import com.atridad.mealient.datasource.models.UpdateUserResponse
|
||||
import com.atridad.mealient.datasource.models.ChangePasswordRequest
|
||||
import com.atridad.mealient.datasource.models.UpdateProfileImageRequest
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
internal interface MealieService {
|
||||
@@ -73,4 +78,13 @@ internal interface MealieService {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ import com.atridad.mealient.datasource.models.GetUserFavoritesResponse
|
||||
import com.atridad.mealient.datasource.models.GetUserInfoResponse
|
||||
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
||||
import com.atridad.mealient.datasource.models.UpdateRecipeRequest
|
||||
import com.atridad.mealient.datasource.models.UserProfileResponse
|
||||
import com.atridad.mealient.datasource.models.UpdateUserProfileRequest
|
||||
import com.atridad.mealient.datasource.models.UpdateUserResponse
|
||||
import com.atridad.mealient.datasource.models.ChangePasswordRequest
|
||||
import com.atridad.mealient.datasource.models.UpdateProfileImageRequest
|
||||
import com.atridad.mealient.datasource.models.VersionResponse
|
||||
import io.ktor.client.call.NoTransformationFoundException
|
||||
import io.ktor.client.call.body
|
||||
@@ -330,4 +335,39 @@ constructor(
|
||||
|
||||
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" }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,18 +18,27 @@ import com.atridad.mealient.datasource.models.GetUserFavoritesResponse
|
||||
import com.atridad.mealient.datasource.models.GetUserInfoResponse
|
||||
import com.atridad.mealient.datasource.models.ParseRecipeURLRequest
|
||||
import com.atridad.mealient.datasource.models.UpdateRecipeRequest
|
||||
import com.atridad.mealient.datasource.models.UserProfileResponse
|
||||
import com.atridad.mealient.datasource.models.UpdateUserProfileRequest
|
||||
import com.atridad.mealient.datasource.models.UpdateUserResponse
|
||||
import com.atridad.mealient.datasource.models.ChangePasswordRequest
|
||||
import com.atridad.mealient.datasource.models.UpdateProfileImageRequest
|
||||
import com.atridad.mealient.datasource.models.VersionResponse
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.HttpRequestBuilder
|
||||
import io.ktor.client.request.delete
|
||||
import io.ktor.client.request.forms.FormDataContent
|
||||
import io.ktor.client.request.forms.MultiPartFormDataContent
|
||||
import io.ktor.client.request.forms.formData
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.patch
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.put
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.Headers
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.URLBuilder
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.parameters
|
||||
@@ -228,6 +237,47 @@ constructor(
|
||||
endpoint(baseUrl = baseUrl, path = path, block = block)
|
||||
}
|
||||
|
||||
// User Profile Management
|
||||
override suspend fun getUserProfile(): UserProfileResponse {
|
||||
return httpClient.get { endpoint("/api/users/self") }.body()
|
||||
}
|
||||
|
||||
override suspend fun updateUserProfile(userId: String, request: UpdateUserProfileRequest): UpdateUserResponse {
|
||||
return httpClient.put {
|
||||
endpoint("/api/users/$userId")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}.body()
|
||||
}
|
||||
|
||||
override suspend fun changePassword(request: ChangePasswordRequest) {
|
||||
httpClient.put {
|
||||
endpoint("/api/users/password")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateProfileImage(userId: String, request: UpdateProfileImageRequest) {
|
||||
httpClient.post {
|
||||
endpoint("/api/users/$userId/image")
|
||||
setBody(
|
||||
MultiPartFormDataContent(
|
||||
formData {
|
||||
append(
|
||||
"profile_image",
|
||||
request.imageBytes,
|
||||
Headers.build {
|
||||
append(HttpHeaders.ContentType, request.mimeType)
|
||||
append(HttpHeaders.ContentDisposition, "filename=\"${request.fileName}\"")
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun HttpRequestBuilder.endpoint(
|
||||
baseUrl: String,
|
||||
path: String,
|
||||
@@ -239,4 +289,6 @@ constructor(
|
||||
block()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,3 +38,4 @@ include(":testing")
|
||||
include(":ui")
|
||||
include(":model_mapper")
|
||||
include(":features:shopping_lists")
|
||||
include(":features:user_managment")
|
||||
|
||||
Reference in New Issue
Block a user