Added the profile feature

This commit is contained in:
2025-08-05 10:51:31 -06:00
parent f7bd6643cb
commit 2d4214562a
18 changed files with 1377 additions and 0 deletions

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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