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