diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index ed0d99f..cb07e97 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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"))
diff --git a/app/src/main/java/com/atridad/mealient/ui/NavGraphs.kt b/app/src/main/java/com/atridad/mealient/ui/NavGraphs.kt
index 1bb28e6..b10c24d 100644
--- a/app/src/main/java/com/atridad/mealient/ui/NavGraphs.kt
+++ b/app/src/main/java/com/atridad/mealient/ui/NavGraphs.kt
@@ -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,
diff --git a/app/src/main/java/com/atridad/mealient/ui/activity/DrawerContent.kt b/app/src/main/java/com/atridad/mealient/ui/activity/DrawerContent.kt
index e0e5d9f..7c2e50a 100644
--- a/app/src/main/java/com/atridad/mealient/ui/activity/DrawerContent.kt
+++ b/app/src/main/java/com/atridad/mealient/ui/activity/DrawerContent.kt
@@ -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,
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7d17d6f..064ae86 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -58,6 +58,7 @@
Are you sure you want to delete %1$s? This cannot be undone.
Confirm
Cancel
+ Profile
Change URL
Search recipes
Open navigation drawer
diff --git a/datasource/src/main/kotlin/com/atridad/mealient/datasource/MealieDataSource.kt b/datasource/src/main/kotlin/com/atridad/mealient/datasource/MealieDataSource.kt
index 53fab2f..0ae6263 100644
--- a/datasource/src/main/kotlin/com/atridad/mealient/datasource/MealieDataSource.kt
+++ b/datasource/src/main/kotlin/com/atridad/mealient/datasource/MealieDataSource.kt
@@ -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)
}
diff --git a/datasource/src/main/kotlin/com/atridad/mealient/datasource/MealieService.kt b/datasource/src/main/kotlin/com/atridad/mealient/datasource/MealieService.kt
index 7fcc6b1..9f73dd0 100644
--- a/datasource/src/main/kotlin/com/atridad/mealient/datasource/MealieService.kt
+++ b/datasource/src/main/kotlin/com/atridad/mealient/datasource/MealieService.kt
@@ -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)
}
diff --git a/datasource/src/main/kotlin/com/atridad/mealient/datasource/impl/MealieDataSourceImpl.kt b/datasource/src/main/kotlin/com/atridad/mealient/datasource/impl/MealieDataSourceImpl.kt
index 74eb66c..68e510c 100644
--- a/datasource/src/main/kotlin/com/atridad/mealient/datasource/impl/MealieDataSourceImpl.kt
+++ b/datasource/src/main/kotlin/com/atridad/mealient/datasource/impl/MealieDataSourceImpl.kt
@@ -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" }
+ )
+ }
}
diff --git a/datasource/src/main/kotlin/com/atridad/mealient/datasource/impl/MealieServiceKtor.kt b/datasource/src/main/kotlin/com/atridad/mealient/datasource/impl/MealieServiceKtor.kt
index 7e27c25..4947fe3 100644
--- a/datasource/src/main/kotlin/com/atridad/mealient/datasource/impl/MealieServiceKtor.kt
+++ b/datasource/src/main/kotlin/com/atridad/mealient/datasource/impl/MealieServiceKtor.kt
@@ -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()
}
}
+
+
}
diff --git a/datasource/src/main/kotlin/com/atridad/mealient/datasource/models/UserProfileModels.kt b/datasource/src/main/kotlin/com/atridad/mealient/datasource/models/UserProfileModels.kt
new file mode 100644
index 0000000..edb8eec
--- /dev/null
+++ b/datasource/src/main/kotlin/com/atridad/mealient/datasource/models/UserProfileModels.kt
@@ -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? = 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?,
+ @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
+ }
+}
\ No newline at end of file
diff --git a/features/user_managment/.gitignore b/features/user_managment/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/features/user_managment/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/features/user_managment/build.gradle.kts b/features/user_managment/build.gradle.kts
new file mode 100644
index 0000000..7fc82ae
--- /dev/null
+++ b/features/user_managment/build.gradle.kts
@@ -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)
+}
\ No newline at end of file
diff --git a/features/user_managment/src/main/kotlin/com/mealient/user_management/data/UserProfileRepository.kt b/features/user_managment/src/main/kotlin/com/mealient/user_management/data/UserProfileRepository.kt
new file mode 100644
index 0000000..75f311b
--- /dev/null
+++ b/features/user_managment/src/main/kotlin/com/mealient/user_management/data/UserProfileRepository.kt
@@ -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
+ fun getCurrentUserValue(): UserProfileResponse?
+}
\ No newline at end of file
diff --git a/features/user_managment/src/main/kotlin/com/mealient/user_management/data/UserProfileRepositoryImpl.kt b/features/user_managment/src/main/kotlin/com/mealient/user_management/data/UserProfileRepositoryImpl.kt
new file mode 100644
index 0000000..14c5ba5
--- /dev/null
+++ b/features/user_managment/src/main/kotlin/com/mealient/user_management/data/UserProfileRepositoryImpl.kt
@@ -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(null)
+ override val currentUser: Flow = _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
+ }
+}
\ No newline at end of file
diff --git a/features/user_managment/src/main/kotlin/com/mealient/user_management/di/UserManagementModule.kt b/features/user_managment/src/main/kotlin/com/mealient/user_management/di/UserManagementModule.kt
new file mode 100644
index 0000000..a07ea9d
--- /dev/null
+++ b/features/user_managment/src/main/kotlin/com/mealient/user_management/di/UserManagementModule.kt
@@ -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
+}
\ No newline at end of file
diff --git a/features/user_managment/src/main/kotlin/com/mealient/user_management/ui/profile/UserProfileScreen.kt b/features/user_managment/src/main/kotlin/com/mealient/user_management/ui/profile/UserProfileScreen.kt
new file mode 100644
index 0000000..554b369
--- /dev/null
+++ b/features/user_managment/src/main/kotlin/com/mealient/user_management/ui/profile/UserProfileScreen.kt
@@ -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
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/features/user_managment/src/main/kotlin/com/mealient/user_management/ui/profile/UserProfileScreenState.kt b/features/user_managment/src/main/kotlin/com/mealient/user_management/ui/profile/UserProfileScreenState.kt
new file mode 100644
index 0000000..71d815c
--- /dev/null
+++ b/features/user_managment/src/main/kotlin/com/mealient/user_management/ui/profile/UserProfileScreenState.kt
@@ -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
+ }
+ }
+}
\ No newline at end of file
diff --git a/features/user_managment/src/main/kotlin/com/mealient/user_management/ui/profile/UserProfileViewModel.kt b/features/user_managment/src/main/kotlin/com/mealient/user_management/ui/profile/UserProfileViewModel.kt
new file mode 100644
index 0000000..d8c1593
--- /dev/null
+++ b/features/user_managment/src/main/kotlin/com/mealient/user_management/ui/profile/UserProfileViewModel.kt
@@ -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 = _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
+ }
+ }
+}
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 0d4a2bd..1a27368 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -38,3 +38,4 @@ include(":testing")
include(":ui")
include(":model_mapper")
include(":features:shopping_lists")
+include(":features:user_managment")