From b947bdc3abc76b06ad008a4deb07761ed9c9f27a Mon Sep 17 00:00:00 2001 From: Othmane Bencheqroun Date: Thu, 19 Dec 2024 18:59:26 +0100 Subject: [PATCH 01/11] refactor: align the tests with the new changes for the uniqueness of the username --- .../ui/profile/ProfileInformationKtTest.kt | 258 +++++++++--------- 1 file changed, 129 insertions(+), 129 deletions(-) diff --git a/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/profile/ProfileInformationKtTest.kt b/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/profile/ProfileInformationKtTest.kt index 9e8483da3..c01ce8567 100644 --- a/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/profile/ProfileInformationKtTest.kt +++ b/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/profile/ProfileInformationKtTest.kt @@ -10,142 +10,142 @@ import org.mockito.Mockito.* class ProfileInformationScreenTest { - private lateinit var profileRepository: ProfileRepository - private lateinit var profileViewModel: ProfileViewModel - private lateinit var navigationActions: NavigationActions - private lateinit var firebaseAuth: FirebaseAuth + private lateinit var profileRepository: ProfileRepository + private lateinit var profileViewModel: ProfileViewModel + private lateinit var navigationActions: NavigationActions + private lateinit var firebaseAuth: FirebaseAuth - @get:Rule val composeTestRule = createComposeRule() + @get:Rule val composeTestRule = createComposeRule() - @Before - fun setUp() { + @Before + fun setUp() { - profileRepository = mock(ProfileRepository::class.java) - profileViewModel = ProfileViewModel(profileRepository) - navigationActions = mock(NavigationActions::class.java) - firebaseAuth = mock(FirebaseAuth::class.java) + profileRepository = mock(ProfileRepository::class.java) + profileViewModel = ProfileViewModel(profileRepository) + navigationActions = mock(NavigationActions::class.java) + firebaseAuth = mock(FirebaseAuth::class.java) - // Define navigation action behavior - `when`(navigationActions.currentRoute()).thenReturn(Screen.PROFILE_INFORMATION) - } + // Define navigation action behavior + `when`(navigationActions.currentRoute()).thenReturn(Screen.PROFILE_INFORMATION) + } - @Test - fun displayAllComponents() { - composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } + @Test + fun displayAllComponents() { + composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } - // Scroll to and check that the main components are displayed - composeTestRule.onNodeWithTag("editProfileScreen").assertIsDisplayed() - composeTestRule.onNodeWithTag("editProfileTitle").assertIsDisplayed() - composeTestRule.onNodeWithTag("goBackButton").assertIsDisplayed() + // Scroll to and check that the main components are displayed + composeTestRule.onNodeWithTag("editProfileScreen").assertIsDisplayed() + composeTestRule.onNodeWithTag("editProfileTitle").assertIsDisplayed() + composeTestRule.onNodeWithTag("goBackButton").assertIsDisplayed() - composeTestRule.onNodeWithTag("editProfileUsername").performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithTag("editProfileUsername").performScrollTo().assertIsDisplayed() - composeTestRule.onNodeWithTag("editProfileEmail").performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithTag("editProfileEmail").performScrollTo().assertIsDisplayed() - composeTestRule.onNodeWithTag("editProfileBio").performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithTag("editProfileBio").performScrollTo().assertIsDisplayed() - composeTestRule.onNodeWithTag("profileSaveButton").performScrollTo().assertIsDisplayed() - - composeTestRule.onNodeWithTag("profileLogout").performScrollTo().assertIsDisplayed() - - // Check button texts after scrolling - composeTestRule.onNodeWithTag("profileSaveButton").assertTextEquals("Save") - composeTestRule.onNodeWithTag("profileLogout").assertTextEquals("Sign out") - } - - @Test - fun saveButtonDisabledWhenFieldsAreEmpty() { - composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } - - // Initially, all fields are empty, so the save button should be disabled - composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() - - // Fill in username, bio, and email - composeTestRule.onNodeWithTag("editProfileUsername").performTextInput("JohnDoe") - composeTestRule.onNodeWithTag("editProfileEmail").performTextInput("john.doe@example.com") - composeTestRule.onNodeWithTag("editProfileBio").performTextInput("This is a bio") - - // Now all fields are filled, so the save button should be enabled - composeTestRule.onNodeWithTag("profileSaveButton").assertIsEnabled() - } - - @Test - fun logoutButtonWorks() { - composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } - - // Scroll to the sign-out button if it's off-screen, then click it - composeTestRule.onNodeWithTag("profileLogout").performScrollTo().performClick() - - // Verify that the navigation action to the landing screen was triggered - verify(navigationActions).navigateTo(Screen.LANDING) - } - - @Test - fun saveButtonWorks() { - composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } - - // Assert: Save button is initially disabled - composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() - - // Act: Fill in only the username - composeTestRule.onNodeWithTag("editProfileUsername").performTextInput("JohnDoe") - // Assert: Save button is still disabled - composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() - - // Act: Fill in email - composeTestRule.onNodeWithTag("editProfileEmail").performTextInput("john.doe@example.com") - // Assert: Save button is still disabled because bio is empty - composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() - - // Act: Fill in the bio - composeTestRule.onNodeWithTag("editProfileBio").performTextInput("This is a bio") - // Assert: Save button should now be enabled - composeTestRule.onNodeWithTag("profileSaveButton").assertIsEnabled() - composeTestRule.onNodeWithTag("profileSaveButton").performClick() - verify(navigationActions).navigateTo(Screen.PROFILE) - } - - @Test - fun saveButtonDisabledWhenAllFieldsAreEmpty() { - composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } - - // Assert: Save button is disabled because no fields have been populated - composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() - } - - @Test - fun deleteButtonDisabledWhenProfileIsNull() { - composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } - - // Assert: Delete button is disabled because the profile is null - composeTestRule.onNodeWithTag("profileDelete").assertIsNotEnabled() - } - - @Test - fun deleteButtonWorks() { - composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } - - // Assert: Save button is initially disabled - composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() - - // Act: Fill in only the username - composeTestRule.onNodeWithTag("editProfileUsername").performTextInput("JohnDoe") - // Assert: Save button is still disabled - composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() - - // Act: Fill in email - composeTestRule.onNodeWithTag("editProfileEmail").performTextInput("john.doe@example.com") - // Assert: Save button is still disabled because bio is empty - composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() - - // Act: Fill in the bio - composeTestRule.onNodeWithTag("editProfileBio").performTextInput("This is a bio") - composeTestRule.onNodeWithTag("profileSaveButton").performClick() - verify(navigationActions).navigateTo(Screen.PROFILE) - // Assert: Save button should now be enabled - composeTestRule.onNodeWithTag("profileDelete").assertIsEnabled() - // Scroll to the delete button if it's off-screen, then click it - composeTestRule.onNodeWithTag("profileDelete").performScrollTo().performClick() - verify(navigationActions).navigateTo(Screen.MENU) - } -} + composeTestRule.onNodeWithTag("profileSaveButton").performScrollTo().assertIsDisplayed() + + composeTestRule.onNodeWithTag("profileLogout").performScrollTo().assertIsDisplayed() + + // Check button texts after scrolling + composeTestRule.onNodeWithTag("profileSaveButton").assertTextEquals("Save") + composeTestRule.onNodeWithTag("profileLogout").assertTextEquals("Sign out") + } + + @Test + fun saveButtonDisabledWhenFieldsAreEmpty() { + composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } + + // Initially, all fields are empty, so the save button should be disabled + composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() + + // Fill in username, bio, and email + composeTestRule.onNodeWithTag("editProfileUsername").performTextInput("JohnDoe") + composeTestRule.onNodeWithTag("editProfileEmail").performTextInput("john.doe@example.com") + composeTestRule.onNodeWithTag("editProfileBio").performTextInput("This is a bio") + + // Now all fields are filled, so the save button should be enabled + composeTestRule.onNodeWithTag("profileSaveButton").assertIsEnabled() + } + + @Test + fun logoutButtonWorks() { + composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } + + // Scroll to the sign-out button if it's off-screen, then click it + composeTestRule.onNodeWithTag("profileLogout").performScrollTo().performClick() + + // Verify that the navigation action to the landing screen was triggered + verify(navigationActions).navigateTo(Screen.LANDING) + } + + @Test + fun saveButtonWorks() { + composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } + + // Assert: Save button is initially disabled + composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() + + // Act: Fill in only the username + composeTestRule.onNodeWithTag("editProfileUsername").performTextInput("JohnDoe") + // Assert: Save button is still disabled + composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() + + // Act: Fill in email + composeTestRule.onNodeWithTag("editProfileEmail").performTextInput("john.doe@example.com") + // Assert: Save button is still disabled because bio is empty + composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() + + // Act: Fill in the bio + composeTestRule.onNodeWithTag("editProfileBio").performTextInput("This is a bio") + // Assert: Save button should now be enabled + composeTestRule.onNodeWithTag("profileSaveButton").assertIsEnabled() + composeTestRule.onNodeWithTag("profileSaveButton").performClick() + verify(navigationActions).navigateTo(Screen.PROFILE) + } + + @Test + fun saveButtonDisabledWhenAllFieldsAreEmpty() { + composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } + + // Assert: Save button is disabled because no fields have been populated + composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() + } + + @Test + fun deleteButtonDisabledWhenProfileIsNull() { + composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } + + // Assert: Delete button is disabled because the profile is null + composeTestRule.onNodeWithTag("profileDelete").assertIsNotEnabled() + } + + @Test + fun deleteButtonWorks() { + composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } + + // Assert: Save button is initially disabled + composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() + + // Act: Fill in only the username + composeTestRule.onNodeWithTag("editProfileUsername").performTextInput("JohnDoe") + // Assert: Save button is still disabled + composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() + + // Act: Fill in email + composeTestRule.onNodeWithTag("editProfileEmail").performTextInput("john.doe@example.com") + // Assert: Save button is still disabled because bio is empty + composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() + + // Act: Fill in the bio + composeTestRule.onNodeWithTag("editProfileBio").performTextInput("This is a bio") + composeTestRule.onNodeWithTag("profileSaveButton").performClick() + verify(navigationActions).navigateTo(Screen.PROFILE) + // Assert: Save button should now be enabled + composeTestRule.onNodeWithTag("profileDelete").assertIsEnabled() + // Scroll to the delete button if it's off-screen, then click it + composeTestRule.onNodeWithTag("profileDelete").performScrollTo().performClick() + verify(navigationActions).navigateTo(Screen.MENU) + } +} \ No newline at end of file From b6911c071ca0261e93e895dcaa3c38ccf75a1111 Mon Sep 17 00:00:00 2001 From: Othmane Bencheqroun Date: Thu, 19 Dec 2024 19:13:25 +0100 Subject: [PATCH 02/11] feat: make the necessary changes to have unique usernames in the profile --- .../profile/ProfileRepositoryFirestore.kt | 224 ++++++++++-------- 1 file changed, 120 insertions(+), 104 deletions(-) diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/profile/ProfileRepositoryFirestore.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/profile/ProfileRepositoryFirestore.kt index 0ca11ffde..15db9c5e0 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/model/profile/ProfileRepositoryFirestore.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/model/profile/ProfileRepositoryFirestore.kt @@ -4,6 +4,7 @@ import android.util.Log import com.google.android.gms.tasks.Task import com.google.firebase.auth.FirebaseAuth import com.google.firebase.firestore.FirebaseFirestore +import com.github.lookupgroup27.lookup.model.register.UsernameAlreadyExistsException data class UserProfile( val username: String = " ", @@ -18,124 +19,139 @@ class ProfileRepositoryFirestore( private val auth: FirebaseAuth ) : ProfileRepository { - // private val auth = FirebaseAuth.getInstance() - private val collectionPath = "users" - private val usersCollection = db.collection(collectionPath) + private val collectionPath = "users" + private val usersCollection = db.collection(collectionPath) - override fun init(onSuccess: () -> Unit) { - auth.addAuthStateListener { - if (it.currentUser != null) { - onSuccess() - } + override fun init(onSuccess: () -> Unit) { + auth.addAuthStateListener { + if (it.currentUser != null) { + onSuccess() + } + } } - } - override fun getUserProfile(onSuccess: (UserProfile?) -> Unit, onFailure: (Exception) -> Unit) { - val userId = auth.currentUser?.uid - if (userId != null) { - usersCollection.document(userId).get().addOnCompleteListener { task -> - if (task.isSuccessful) { - val profile = task.result?.toObject(UserProfile::class.java) - onSuccess(profile) + override fun getUserProfile(onSuccess: (UserProfile?) -> Unit, onFailure: (Exception) -> Unit) { + val userId = auth.currentUser?.uid + if (userId != null) { + usersCollection.document(userId).get().addOnCompleteListener { task -> + if (task.isSuccessful) { + val profile = task.result?.toObject(UserProfile::class.java) + onSuccess(profile) + } else { + task.exception?.let { + Log.e("ProfileRepositoryFirestore", "Error getting user profile", it) + onFailure(it) + } + } + } } else { - task.exception?.let { - Log.e("ProfileRepositoryFirestore", "Error getting user profile", it) - onFailure(it) - } + onSuccess(null) // No logged-in user } - } - } else { - onSuccess(null) // No logged-in user } - } - override fun updateUserProfile( - profile: UserProfile, - onSuccess: () -> Unit, - onFailure: (Exception) -> Unit - ) { - val userId = auth.currentUser?.uid - if (userId != null) { - performFirestoreOperation(usersCollection.document(userId).set(profile), onSuccess, onFailure) - } else { - onFailure(Exception("User not logged in")) + override fun updateUserProfile( + profile: UserProfile, + onSuccess: () -> Unit, + onFailure: (Exception) -> Unit + ) { + val userId = auth.currentUser?.uid + if (userId != null) { + // Check if the desired username is already taken by another user + usersCollection.whereEqualTo("username", profile.username).get() + .addOnSuccessListener { querySnapshot -> + val isUsernameTaken = querySnapshot.documents.any { it.id != userId } + if (isUsernameTaken) { + onFailure(UsernameAlreadyExistsException("Username '${profile.username}' is already in use.")) + } else { + // Proceed to update the user profile + usersCollection.document(userId).set(profile) + .addOnSuccessListener { onSuccess() } + .addOnFailureListener { exception -> onFailure(exception) } + } + } + .addOnFailureListener { exception -> + Log.e("ProfileRepositoryFirestore", "Error checking username uniqueness", exception) + onFailure(exception) + } + } else { + onFailure(Exception("User not logged in")) + } } - } - override fun deleteUserProfile( - profile: UserProfile, - onSuccess: () -> Unit, - onFailure: (Exception) -> Unit - ) { - val userId = auth.currentUser?.uid - if (userId != null) { - performFirestoreOperation(usersCollection.document(userId).delete(), onSuccess, onFailure) - } else { - onFailure(Exception("User not logged in")) + override fun deleteUserProfile( + profile: UserProfile, + onSuccess: () -> Unit, + onFailure: (Exception) -> Unit + ) { + val userId = auth.currentUser?.uid + if (userId != null) { + performFirestoreOperation(usersCollection.document(userId).delete(), onSuccess, onFailure) + } else { + onFailure(Exception("User not logged in")) + } } - } - override fun logoutUser() { - auth.signOut() - } + override fun logoutUser() { + auth.signOut() + } - override fun saveSelectedAvatar( - userId: String, - avatarId: Int?, - onSuccess: () -> Unit, - onFailure: (Exception) -> Unit - ) { - val userDocument = usersCollection.document(userId) - userDocument - .get() - .addOnSuccessListener { document -> - if (!document.exists()) { - // Create a default profile with the selected avatar - val defaultProfile = mapOf("selectedAvatar" to avatarId) - userDocument - .set(defaultProfile) - .addOnSuccessListener { onSuccess() } - .addOnFailureListener { exception -> onFailure(exception) } - } else { - // Update the existing profile with the selected avatar - userDocument - .update("selectedAvatar", avatarId) - .addOnSuccessListener { onSuccess() } - .addOnFailureListener { exception -> onFailure(exception) } - } - } - .addOnFailureListener { exception -> onFailure(exception) } - } + override fun saveSelectedAvatar( + userId: String, + avatarId: Int?, + onSuccess: () -> Unit, + onFailure: (Exception) -> Unit + ) { + val userDocument = usersCollection.document(userId) + userDocument + .get() + .addOnSuccessListener { document -> + if (!document.exists()) { + // Create a default profile with the selected avatar + val defaultProfile = mapOf("selectedAvatar" to avatarId) + userDocument + .set(defaultProfile) + .addOnSuccessListener { onSuccess() } + .addOnFailureListener { exception -> onFailure(exception) } + } else { + // Update the existing profile with the selected avatar + userDocument + .update("selectedAvatar", avatarId) + .addOnSuccessListener { onSuccess() } + .addOnFailureListener { exception -> onFailure(exception) } + } + } + .addOnFailureListener { exception -> onFailure(exception) } + } - override fun getSelectedAvatar( - userId: String, - onSuccess: (Int?) -> Unit, - onFailure: (Exception) -> Unit - ) { - val userDocument = usersCollection.document(userId) - userDocument - .get() - .addOnSuccessListener { document -> - val avatarId = document.getLong("selectedAvatar")?.toInt() - onSuccess(avatarId) - } - .addOnFailureListener { exception -> onFailure(exception) } - } + override fun getSelectedAvatar( + userId: String, + onSuccess: (Int?) -> Unit, + onFailure: (Exception) -> Unit + ) { + val userDocument = usersCollection.document(userId) + userDocument + .get() + .addOnSuccessListener { document -> + val avatarId = document.getLong("selectedAvatar")?.toInt() + onSuccess(avatarId) + } + .addOnFailureListener { exception -> onFailure(exception) } + } - private fun performFirestoreOperation( - task: Task, - onSuccess: () -> Unit, - onFailure: (Exception) -> Unit - ) { - task.addOnCompleteListener { result -> - if (result.isSuccessful) { - onSuccess() - } else { - result.exception?.let { - Log.e("ProfileRepositoryFirestore", "Error performing Firestore operation", it) - onFailure(it) + private fun performFirestoreOperation( + task: Task, + onSuccess: () -> Unit, + onFailure: (Exception) -> Unit + ) { + task.addOnCompleteListener { result -> + if (result.isSuccessful) { + onSuccess() + } else { + result.exception?.let { + Log.e("ProfileRepositoryFirestore", "Error performing Firestore operation", it) + onFailure(it) + } + } } - } } - } } From 865f459738c663fa8a8f15fe2efd04fa9fec0625 Mon Sep 17 00:00:00 2001 From: Othmane Bencheqroun Date: Thu, 19 Dec 2024 19:14:16 +0100 Subject: [PATCH 03/11] refactor: change the UI to let the user know he can't use an username that already exist --- .../lookup/ui/profile/ProfileInformation.kt | 262 +++++++++++------- 1 file changed, 163 insertions(+), 99 deletions(-) diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/ui/profile/ProfileInformation.kt b/app/src/main/java/com/github/lookupgroup27/lookup/ui/profile/ProfileInformation.kt index 16904f93e..0404072d9 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/ui/profile/ProfileInformation.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/ui/profile/ProfileInformation.kt @@ -1,145 +1,209 @@ package com.github.lookupgroup27.lookup.ui.profile import android.annotation.SuppressLint -import androidx.compose.foundation.* +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.ui.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp -import com.github.lookupgroup27.lookup.model.profile.* -import com.github.lookupgroup27.lookup.ui.navigation.* +import androidx.lifecycle.viewmodel.compose.viewModel +import com.github.lookupgroup27.lookup.model.profile.UserProfile +import com.github.lookupgroup27.lookup.ui.navigation.NavigationActions +import com.github.lookupgroup27.lookup.ui.navigation.Screen import com.google.firebase.auth.FirebaseAuth @SuppressLint("StateFlowValueCalledInComposition") @Composable fun ProfileInformationScreen( - profileViewModel: ProfileViewModel, + profileViewModel: ProfileViewModel = viewModel(), navigationActions: NavigationActions ) { + val scrollState = rememberScrollState() - val scrollState = rememberScrollState() + // Fetch user profile on composition + LaunchedEffect(Unit) { + profileViewModel.fetchUserProfile() + } - profileViewModel.fetchUserProfile() - val profile = profileViewModel.userProfile.value - val user = FirebaseAuth.getInstance().currentUser // Get the current signed-in user - val userEmail = user?.email ?: "" - var username by remember { mutableStateOf(profile?.username ?: "") } - var bio by remember { mutableStateOf(profile?.bio ?: "") } - var email by remember { mutableStateOf(userEmail) } + val profile by profileViewModel.userProfile.collectAsState() + val user = FirebaseAuth.getInstance().currentUser // Get the current signed-in user + val userEmail = user?.email ?: "" + var username by remember { mutableStateOf(profile?.username ?: "") } + var bio by remember { mutableStateOf(profile?.bio ?: "") } + var email by remember { mutableStateOf(userEmail) } - Column( - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.Start, - modifier = - Modifier.padding(8.dp) - .width(412.dp) - .height(892.dp) - .verticalScroll(scrollState) - .testTag("editProfileScreen")) { + // Collect error states + val generalError by profileViewModel.error.collectAsState() + val usernameError by profileViewModel.usernameError.collectAsState() + val profileUpdateStatus by profileViewModel.profileUpdateStatus.collectAsState() + + // Handle navigation based on profileUpdateStatus + LaunchedEffect(profileUpdateStatus) { + if (profileUpdateStatus == true) { + navigationActions.navigateTo(Screen.PROFILE) + profileViewModel.resetProfileUpdateStatus() // Reset after navigation + } + } + + Column( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.Start, + modifier = + Modifier + .padding(8.dp) + .fillMaxSize() + .verticalScroll(scrollState) + .testTag("editProfileScreen") + ) { Spacer(modifier = Modifier.height(16.dp)) Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", modifier = - Modifier.padding(start = 16.dp) - .size(30.dp) - .clickable { navigationActions.goBack() } - .testTag("goBackButton")) + Modifier + .padding(start = 16.dp) + .size(30.dp) + .clickable { navigationActions.goBack() } + .testTag("goBackButton") + ) Spacer(modifier = Modifier.height(16.dp)) Text( text = "Your Personal Information", style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(start = 16.dp).fillMaxWidth().testTag("editProfileTitle")) + modifier = Modifier + .padding(start = 16.dp) + .fillMaxWidth() + .testTag("editProfileTitle") + ) Column( - verticalArrangement = Arrangement.spacedBy(0.dp, Alignment.Top), + verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(20.dp).fillMaxWidth()) { - Spacer(modifier = Modifier.height(16.dp)) - OutlinedTextField( - value = username, - onValueChange = { new_name -> username = new_name }, - label = { Text("Username") }, - placeholder = { Text("Enter username") }, - shape = RoundedCornerShape(16.dp), - modifier = - Modifier.padding(0.dp) - .width(312.dp) - .height(60.dp) - .testTag("editProfileUsername")) - Spacer(modifier = Modifier.height(30.dp)) - OutlinedTextField( - value = email, - onValueChange = { + modifier = Modifier + .padding(20.dp) + .fillMaxWidth() + ) { + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = username, + onValueChange = { newName -> username = newName }, + label = { Text("Username") }, + placeholder = { Text("Enter username") }, + isError = usernameError != null, + shape = RoundedCornerShape(16.dp), + modifier = + Modifier + .fillMaxWidth() + .height(60.dp) + .testTag("editProfileUsername") + ) + if (usernameError != null) { + Text( + text = usernameError ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .padding(start = 16.dp) + .align(Alignment.Start) + .testTag("usernameError") + ) + } + + OutlinedTextField( + value = email, + onValueChange = { if (userEmail.isEmpty()) { - email = it + email = it } - }, - label = { Text("E-mail") }, - placeholder = { Text("Enter your e-mail") }, - shape = RoundedCornerShape(16.dp), - modifier = - Modifier.padding(0.dp) - .width(312.dp) - .height(60.dp) - .testTag("editProfileEmail")) - Spacer(modifier = Modifier.height(30.dp)) - OutlinedTextField( - value = bio, - onValueChange = { new_description -> bio = new_description }, - label = { Text("Bio") }, - placeholder = { Text("Enter a bio") }, - shape = RoundedCornerShape(16.dp), - modifier = - Modifier.padding(0.dp).width(312.dp).height(80.dp).testTag("editProfileBio")) - Spacer(modifier = Modifier.height(72.dp)) - Spacer(modifier = Modifier.height(30.dp)) + }, + label = { Text("E-mail") }, + placeholder = { Text("Enter your e-mail") }, + isError = false, // Assuming email is not editable if logged in via Google + shape = RoundedCornerShape(16.dp), + modifier = + Modifier + .fillMaxWidth() + .height(60.dp) + .testTag("editProfileEmail") + ) + + OutlinedTextField( + value = bio, + onValueChange = { newDescription -> bio = newDescription }, + label = { Text("Bio") }, + placeholder = { Text("Enter a bio") }, + isError = false, + shape = RoundedCornerShape(16.dp), + modifier = + Modifier + .fillMaxWidth() + .height(80.dp) + .testTag("editProfileBio") + ) - Button( - onClick = { + Spacer(modifier = Modifier.height(24.dp)) + + // Display general error if any + if (generalError != null) { + Text( + text = generalError ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .padding(horizontal = 16.dp) + .testTag("generalError") + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + Button( + onClick = { val newProfile: UserProfile = profile?.copy(username = username, bio = bio, email = email) ?: UserProfile(username = username, bio = bio, email = email) profileViewModel.updateUserProfile(newProfile) - navigationActions.navigateTo(Screen.PROFILE) - }, - enabled = username.isNotEmpty() && bio.isNotEmpty() && email.isNotEmpty(), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF30315B)), - modifier = Modifier.width(131.dp).height(40.dp).testTag("profileSaveButton")) { - Text(text = "Save", color = Color.White) - } + // Navigation is handled by observing profileUpdateStatus + }, + enabled = username.isNotEmpty() && bio.isNotEmpty() && email.isNotEmpty(), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF30315B)), + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + .testTag("profileSaveButton") + ) { + Text(text = "Save", color = Color.White) + } + + Spacer(modifier = Modifier.height(30.dp)) - Spacer(modifier = Modifier.height(30.dp)) - Button( - onClick = { - profileViewModel.logoutUser() - navigationActions.navigateTo(Screen.LANDING) - }, - enabled = true, - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF410002)), - modifier = Modifier.width(131.dp).height(40.dp).testTag("profileLogout")) { - Text(text = "Sign out", color = Color.White) - } - Spacer(modifier = Modifier.height(30.dp)) - Button( - onClick = { + Button( + onClick = { profile?.let { - profileViewModel.deleteUserProfile(it) - profileViewModel.logoutUser() + profileViewModel.deleteUserProfile(it) + profileViewModel.logoutUser() } navigationActions.navigateTo(Screen.MENU) - }, - enabled = username.isNotEmpty() && bio.isNotEmpty() && email.isNotEmpty(), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF410002)), - modifier = Modifier.width(131.dp).height(40.dp).testTag("profileDelete")) { - Text(text = "Delete", color = Color.White) - } + }, + enabled = username.isNotEmpty() && bio.isNotEmpty() && email.isNotEmpty(), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF410002)), + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + .testTag("profileDelete") + ) { + Text(text = "Delete", color = Color.White) } - } -} + } + } +} \ No newline at end of file From a8244e19dcfb9f4ca5e66119fa479d1cb4d8836d Mon Sep 17 00:00:00 2001 From: Othmane Bencheqroun Date: Thu, 19 Dec 2024 19:15:09 +0100 Subject: [PATCH 04/11] refactor: change the updateUserProfile function to verify the uniqueness of the username --- .../lookup/ui/profile/ProfileViewModel.kt | 138 +++++++++++------- 1 file changed, 84 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/ui/profile/ProfileViewModel.kt b/app/src/main/java/com/github/lookupgroup27/lookup/ui/profile/ProfileViewModel.kt index 217210b30..afc8c10a3 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/ui/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/ui/profile/ProfileViewModel.kt @@ -4,74 +4,104 @@ import androidx.lifecycle.* import com.github.lookupgroup27.lookup.model.profile.ProfileRepository import com.github.lookupgroup27.lookup.model.profile.ProfileRepositoryFirestore import com.github.lookupgroup27.lookup.model.profile.UserProfile -import com.google.firebase.Firebase -import com.google.firebase.auth.auth -import com.google.firebase.firestore.firestore +import com.github.lookupgroup27.lookup.model.register.UsernameAlreadyExistsException +import com.google.firebase.auth.ktx.auth +import com.google.firebase.firestore.ktx.firestore +import com.google.firebase.ktx.Firebase import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch class ProfileViewModel(private val repository: ProfileRepository) : ViewModel() { - private val _userProfile = MutableStateFlow(null) - val userProfile: StateFlow = _userProfile.asStateFlow() + private val _userProfile = MutableStateFlow(null) + val userProfile: StateFlow = _userProfile.asStateFlow() - private val _profileUpdateStatus = MutableStateFlow(null) - val profileUpdateStatus: StateFlow = _profileUpdateStatus.asStateFlow() + private val _profileUpdateStatus = MutableStateFlow(null) // Nullable to represent no action yet + val profileUpdateStatus: StateFlow = _profileUpdateStatus.asStateFlow() - private val _error = MutableStateFlow(null) - val error: StateFlow = _error.asStateFlow() + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() - init { - repository.init { fetchUserProfile() } - } + private val _usernameError = MutableStateFlow(null) + val usernameError: StateFlow = _usernameError.asStateFlow() - fun fetchUserProfile() { - viewModelScope.launch { - repository.getUserProfile( - onSuccess = { profile -> _userProfile.value = profile }, - onFailure = { exception -> - _error.value = "Failed to load profile: ${exception.message}" - }) + init { + repository.init { fetchUserProfile() } } - } - fun updateUserProfile(profile: UserProfile) { - viewModelScope.launch { - repository.updateUserProfile( - profile, - onSuccess = { _profileUpdateStatus.value = true }, - onFailure = { exception -> - _profileUpdateStatus.value = false - _error.value = "Failed to update profile: ${exception.message}" - }) + fun fetchUserProfile() { + viewModelScope.launch { + repository.getUserProfile( + onSuccess = { profile -> _userProfile.value = profile }, + onFailure = { exception -> + _error.value = "Failed to load profile: ${exception.message}" + }) + } } - } - fun deleteUserProfile(profile: UserProfile) { - viewModelScope.launch { - repository.deleteUserProfile( - profile, - onSuccess = { _profileUpdateStatus.value = true }, - onFailure = { exception -> - _profileUpdateStatus.value = false - _error.value = "Failed to delete profile: ${exception.message}" - }) + fun updateUserProfile(profile: UserProfile) { + viewModelScope.launch { + repository.updateUserProfile( + profile, + onSuccess = { + // After successful update, fetch the latest profile + repository.getUserProfile( + onSuccess = { updatedProfile -> + _userProfile.value = updatedProfile + _profileUpdateStatus.value = true // Trigger navigation + }, + onFailure = { exception -> + _error.value = "Failed to refresh profile: ${exception.message}" + _profileUpdateStatus.value = false // Indicate failure + } + ) + _usernameError.value = null // Clear username error on success + }, + onFailure = { exception -> + when (exception) { + is UsernameAlreadyExistsException -> { + _usernameError.value = exception.message + _profileUpdateStatus.value = false // Indicate failure + } + else -> { + _error.value = "Failed to update profile: ${exception.message}" + _profileUpdateStatus.value = false // Indicate failure + } + } + }) + } } - } - fun logoutUser() { - repository.logoutUser() - _userProfile.value = null - } + fun resetProfileUpdateStatus() { + _profileUpdateStatus.value = null + } - companion object { - val Factory: ViewModelProvider.Factory = - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - return ProfileViewModel(ProfileRepositoryFirestore(Firebase.firestore, Firebase.auth)) - as T - } + fun deleteUserProfile(profile: UserProfile) { + viewModelScope.launch { + repository.deleteUserProfile( + profile, + onSuccess = { + _profileUpdateStatus.value = true // Optionally set a status for deletion + }, + onFailure = { exception -> + _error.value = "Failed to delete profile: ${exception.message}" + }) } - } -} + } + + fun logoutUser() { + repository.logoutUser() + _userProfile.value = null + } + + companion object { + val Factory: ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return ProfileViewModel(ProfileRepositoryFirestore(Firebase.firestore, Firebase.auth)) + as T + } + } + } +} \ No newline at end of file From 0fde5dcb404e0d761cd5b7ce9d04623010e0309f Mon Sep 17 00:00:00 2001 From: Othmane Bencheqroun Date: Fri, 20 Dec 2024 00:50:46 +0100 Subject: [PATCH 05/11] refactor: change the code to re implement the sign-out feature --- .../lookup/ui/profile/ProfileInformation.kt | 340 ++++++++++-------- 1 file changed, 190 insertions(+), 150 deletions(-) diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/ui/profile/ProfileInformation.kt b/app/src/main/java/com/github/lookupgroup27/lookup/ui/profile/ProfileInformation.kt index 0404072d9..69df7e78a 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/ui/profile/ProfileInformation.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/ui/profile/ProfileInformation.kt @@ -1,3 +1,4 @@ +// File: ProfileInformationScreen.kt package com.github.lookupgroup27.lookup.ui.profile import android.annotation.SuppressLint @@ -10,199 +11,238 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier +import androidx.compose.ui.* import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.github.lookupgroup27.lookup.model.profile.UserProfile import com.github.lookupgroup27.lookup.ui.navigation.NavigationActions -import com.github.lookupgroup27.lookup.ui.navigation.Screen import com.google.firebase.auth.FirebaseAuth +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.ui.platform.testTag +import com.github.lookupgroup27.lookup.ui.navigation.Screen + + @SuppressLint("StateFlowValueCalledInComposition") @Composable fun ProfileInformationScreen( - profileViewModel: ProfileViewModel = viewModel(), + profileViewModel: ProfileViewModel, navigationActions: NavigationActions ) { - val scrollState = rememberScrollState() - // Fetch user profile on composition - LaunchedEffect(Unit) { - profileViewModel.fetchUserProfile() - } + val scrollState = rememberScrollState() + // Observe states from ViewModel val profile by profileViewModel.userProfile.collectAsState() - val user = FirebaseAuth.getInstance().currentUser // Get the current signed-in user - val userEmail = user?.email ?: "" + val profileUpdateStatus by profileViewModel.profileUpdateStatus.collectAsState() + val errorMessage by profileViewModel.error.collectAsState() + val usernameError by profileViewModel.usernameError.collectAsState() + + // Local UI state var username by remember { mutableStateOf(profile?.username ?: "") } var bio by remember { mutableStateOf(profile?.bio ?: "") } - var email by remember { mutableStateOf(userEmail) } + var email by remember { mutableStateOf(profile?.email ?: FirebaseAuth.getInstance().currentUser?.email ?: "") } - // Collect error states - val generalError by profileViewModel.error.collectAsState() - val usernameError by profileViewModel.usernameError.collectAsState() - val profileUpdateStatus by profileViewModel.profileUpdateStatus.collectAsState() + // Synchronize local UI state with ViewModel's userProfile + LaunchedEffect(profile) { + username = profile?.username ?: "" + bio = profile?.bio ?: "" + email = profile?.email ?: FirebaseAuth.getInstance().currentUser?.email ?: "" + } + + // Reset error states when the screen is entered + LaunchedEffect(Unit) { + profileViewModel.resetProfileUpdateStatus() + } // Handle navigation based on profileUpdateStatus - LaunchedEffect(profileUpdateStatus) { + LaunchedEffect(profileUpdateStatus, errorMessage) { if (profileUpdateStatus == true) { + // Update was successful, navigate to Profile screen navigationActions.navigateTo(Screen.PROFILE) - profileViewModel.resetProfileUpdateStatus() // Reset after navigation + profileViewModel.resetProfileUpdateStatus() + } else if (profileUpdateStatus == false && errorMessage != null) { + // Handle general errors if needed (e.g., show a Snackbar) + // For this example, we're focusing on username errors + profileViewModel.resetProfileUpdateStatus() } } - Column( - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.Start, - modifier = - Modifier - .padding(8.dp) - .fillMaxSize() - .verticalScroll(scrollState) - .testTag("editProfileScreen") - ) { - Spacer(modifier = Modifier.height(16.dp)) - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - modifier = - Modifier - .padding(start = 16.dp) - .size(30.dp) - .clickable { navigationActions.goBack() } - .testTag("goBackButton") - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Your Personal Information", - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier - .padding(start = 16.dp) - .fillMaxWidth() - .testTag("editProfileTitle") - ) + Scaffold( + // You can add other scaffold parameters like topBar or bottomBar here if needed + ) { innerPadding -> Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .padding(20.dp) - .fillMaxWidth() + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.Start, + modifier = + Modifier + .padding(8.dp) + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(scrollState) + .testTag("editProfileScreen") ) { Spacer(modifier = Modifier.height(16.dp)) - OutlinedTextField( - value = username, - onValueChange = { newName -> username = newName }, - label = { Text("Username") }, - placeholder = { Text("Enter username") }, - isError = usernameError != null, - shape = RoundedCornerShape(16.dp), + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", modifier = Modifier - .fillMaxWidth() - .height(60.dp) - .testTag("editProfileUsername") + .padding(start = 16.dp) + .size(30.dp) + .clickable { navigationActions.goBack() } + .testTag("goBackButton") ) - if (usernameError != null) { - Text( - text = usernameError ?: "", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier - .padding(start = 16.dp) - .align(Alignment.Start) - .testTag("usernameError") - ) - } - - OutlinedTextField( - value = email, - onValueChange = { - if (userEmail.isEmpty()) { - email = it - } - }, - label = { Text("E-mail") }, - placeholder = { Text("Enter your e-mail") }, - isError = false, // Assuming email is not editable if logged in via Google - shape = RoundedCornerShape(16.dp), - modifier = - Modifier + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Your Personal Information", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier + .padding(start = 16.dp) .fillMaxWidth() - .height(60.dp) - .testTag("editProfileEmail") + .testTag("editProfileTitle") ) - OutlinedTextField( - value = bio, - onValueChange = { newDescription -> bio = newDescription }, - label = { Text("Bio") }, - placeholder = { Text("Enter a bio") }, - isError = false, - shape = RoundedCornerShape(16.dp), - modifier = - Modifier + Column( + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Top), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(20.dp) .fillMaxWidth() - .height(80.dp) - .testTag("editProfileBio") - ) + ) { + Spacer(modifier = Modifier.height(16.dp)) - Spacer(modifier = Modifier.height(24.dp)) + // Username Field + OutlinedTextField( + value = username, + onValueChange = { new_name -> + username = new_name + if (usernameError != null) { + // Reset username error when user starts typing + profileViewModel.resetProfileUpdateStatus() + } + }, + label = { Text("Username") }, + placeholder = { Text("Enter username") }, + isError = usernameError != null, + shape = RoundedCornerShape(16.dp), + modifier = + Modifier + .width(312.dp) + .height(60.dp) + .testTag("editProfileUsername") + ) - // Display general error if any - if (generalError != null) { - Text( - text = generalError ?: "", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier - .padding(horizontal = 16.dp) - .testTag("generalError") + // Display username error if exists + if (usernameError != null) { + Text( + text = usernameError ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .padding(start = 16.dp) + .fillMaxWidth() + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // E-mail Field + OutlinedTextField( + value = email, + onValueChange = { + if (FirebaseAuth.getInstance().currentUser?.email.isNullOrEmpty()) { + email = it + } + }, + label = { Text("E-mail") }, + placeholder = { Text("Enter your e-mail") }, + shape = RoundedCornerShape(16.dp), + modifier = + Modifier + .width(312.dp) + .height(60.dp) + .testTag("editProfileEmail") ) + Spacer(modifier = Modifier.height(8.dp)) - } - Button( - onClick = { - val newProfile: UserProfile = - profile?.copy(username = username, bio = bio, email = email) - ?: UserProfile(username = username, bio = bio, email = email) - profileViewModel.updateUserProfile(newProfile) - // Navigation is handled by observing profileUpdateStatus - }, - enabled = username.isNotEmpty() && bio.isNotEmpty() && email.isNotEmpty(), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF30315B)), - shape = RoundedCornerShape(16.dp), - modifier = Modifier - .fillMaxWidth() - .height(40.dp) - .testTag("profileSaveButton") - ) { - Text(text = "Save", color = Color.White) - } + // Bio Field + OutlinedTextField( + value = bio, + onValueChange = { new_description -> bio = new_description }, + label = { Text("Bio") }, + placeholder = { Text("Enter a bio") }, + shape = RoundedCornerShape(16.dp), + modifier = + Modifier + .width(312.dp) + .height(80.dp) + .testTag("editProfileBio") + ) + + Spacer(modifier = Modifier.height(30.dp)) + + // Save Button + Button( + onClick = { + val newProfile: UserProfile = + profile?.copy(username = username, bio = bio, email = email) + ?: UserProfile(username = username, bio = bio, email = email) + profileViewModel.updateUserProfile(newProfile) + }, + enabled = username.isNotEmpty() && bio.isNotEmpty() && email.isNotEmpty(), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF30315B)), + modifier = Modifier + .width(131.dp) + .height(40.dp) + .testTag("profileSaveButton") + ) { + Text(text = "Save", color = Color.White) + } - Spacer(modifier = Modifier.height(30.dp)) + Spacer(modifier = Modifier.height(30.dp)) - Button( - onClick = { - profile?.let { - profileViewModel.deleteUserProfile(it) + // Logout Button + Button( + onClick = { profileViewModel.logoutUser() - } - navigationActions.navigateTo(Screen.MENU) - }, - enabled = username.isNotEmpty() && bio.isNotEmpty() && email.isNotEmpty(), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF410002)), - shape = RoundedCornerShape(16.dp), - modifier = Modifier - .fillMaxWidth() - .height(40.dp) - .testTag("profileDelete") - ) { - Text(text = "Delete", color = Color.White) + navigationActions.navigateTo(Screen.LANDING) + }, + enabled = true, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF410002)), + modifier = Modifier + .width(131.dp) + .height(40.dp) + .testTag("profileLogout") + ) { + Text(text = "Sign out", color = Color.White) + } + + Spacer(modifier = Modifier.height(30.dp)) + + // Delete Button + Button( + onClick = { + profile?.let { + profileViewModel.deleteUserProfile(it) + profileViewModel.logoutUser() + } + navigationActions.navigateTo(Screen.MENU) + }, + enabled = username.isNotEmpty() && bio.isNotEmpty() && email.isNotEmpty(), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF410002)), + modifier = Modifier + .width(131.dp) + .height(40.dp) + .testTag("profileDelete") + ) { + Text(text = "Delete", color = Color.White) + } } } } From b803bffc64b37d08b0ea6aacdc4d6014f3fbaac5 Mon Sep 17 00:00:00 2001 From: Othmane Bencheqroun Date: Fri, 20 Dec 2024 01:49:20 +0100 Subject: [PATCH 06/11] fix: change the profile information tests to align with the new changes --- .../ui/profile/ProfileInformationKtTest.kt | 237 +++++++++--------- 1 file changed, 121 insertions(+), 116 deletions(-) diff --git a/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/profile/ProfileInformationKtTest.kt b/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/profile/ProfileInformationKtTest.kt index c01ce8567..ea3867e95 100644 --- a/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/profile/ProfileInformationKtTest.kt +++ b/app/src/androidTest/java/com/github/lookupgroup27/lookup/ui/profile/ProfileInformationKtTest.kt @@ -2,150 +2,155 @@ package com.github.lookupgroup27.lookup.ui.profile import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.createComposeRule -import com.github.lookupgroup27.lookup.model.profile.* -import com.github.lookupgroup27.lookup.ui.navigation.* +import com.github.lookupgroup27.lookup.model.profile.ProfileRepository +import com.github.lookupgroup27.lookup.model.profile.UserProfile +import com.github.lookupgroup27.lookup.ui.navigation.NavigationActions +import com.github.lookupgroup27.lookup.ui.navigation.Screen import com.google.firebase.auth.FirebaseAuth -import org.junit.* -import org.mockito.Mockito.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.* +@OptIn(ExperimentalCoroutinesApi::class) class ProfileInformationScreenTest { - private lateinit var profileRepository: ProfileRepository - private lateinit var profileViewModel: ProfileViewModel - private lateinit var navigationActions: NavigationActions - private lateinit var firebaseAuth: FirebaseAuth - - @get:Rule val composeTestRule = createComposeRule() + private lateinit var profileRepository: ProfileRepository + private lateinit var profileViewModel: ProfileViewModel + private lateinit var navigationActions: NavigationActions + private lateinit var firebaseAuth: FirebaseAuth + + @get:Rule val composeTestRule = createComposeRule() + + @Before + fun setUp() { + profileRepository = mock() + profileViewModel = ProfileViewModel(profileRepository) + navigationActions = mock() + firebaseAuth = mock() + + whenever(navigationActions.currentRoute()).thenReturn(Screen.PROFILE_INFORMATION) + + // Common stubs for all tests: + // init always calls onSuccess immediately + whenever(profileRepository.init(any())).thenAnswer { it.getArgument<() -> Unit>(0).invoke() } + // Checking username always returns false (not taken) + whenever(profileRepository.isUsernameTaken(any(), any(), any())).thenAnswer { + val onResult = it.getArgument<(Boolean) -> Unit>(1) + onResult(false) + } + // updateUserProfile and deleteUserProfile always succeed + whenever(profileRepository.updateUserProfile(any(), any(), any())).thenAnswer { + it.getArgument<() -> Unit>(1).invoke() + } + whenever(profileRepository.deleteUserProfile(any(), any(), any())).thenAnswer { + it.getArgument<() -> Unit>(1).invoke() + } + } - @Before - fun setUp() { + @Test + fun displayAllComponents() { + // No profile needed here, just return null + whenever(profileRepository.getUserProfile(any(), any())).thenAnswer { + it.getArgument<(UserProfile?) -> Unit>(0).invoke(null) + } - profileRepository = mock(ProfileRepository::class.java) - profileViewModel = ProfileViewModel(profileRepository) - navigationActions = mock(NavigationActions::class.java) - firebaseAuth = mock(FirebaseAuth::class.java) + composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } - // Define navigation action behavior - `when`(navigationActions.currentRoute()).thenReturn(Screen.PROFILE_INFORMATION) - } + composeTestRule.onNodeWithTag("editProfileScreen").assertIsDisplayed() + composeTestRule.onNodeWithTag("editProfileTitle").assertIsDisplayed() + composeTestRule.onNodeWithTag("goBackButton").assertIsDisplayed() - @Test - fun displayAllComponents() { - composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } + composeTestRule.onNodeWithTag("editProfileUsername").performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithTag("editProfileEmail").performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithTag("editProfileBio").performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithTag("profileSaveButton").performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithTag("profileLogout").performScrollTo().assertIsDisplayed() - // Scroll to and check that the main components are displayed - composeTestRule.onNodeWithTag("editProfileScreen").assertIsDisplayed() - composeTestRule.onNodeWithTag("editProfileTitle").assertIsDisplayed() - composeTestRule.onNodeWithTag("goBackButton").assertIsDisplayed() + // Check button texts + composeTestRule.onNodeWithTag("profileSaveButton").assertTextEquals("Save") + composeTestRule.onNodeWithTag("profileLogout").assertTextEquals("Sign out") + } - composeTestRule.onNodeWithTag("editProfileUsername").performScrollTo().assertIsDisplayed() + @Test + fun saveButtonDisabledWhenFieldsAreEmpty() { + whenever(profileRepository.getUserProfile(any(), any())).thenAnswer { + it.getArgument<(UserProfile?) -> Unit>(0).invoke(null) + } - composeTestRule.onNodeWithTag("editProfileEmail").performScrollTo().assertIsDisplayed() + composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } - composeTestRule.onNodeWithTag("editProfileBio").performScrollTo().assertIsDisplayed() + // Initially empty, save should be disabled + composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() - composeTestRule.onNodeWithTag("profileSaveButton").performScrollTo().assertIsDisplayed() + // Fill all fields + composeTestRule.onNodeWithTag("editProfileUsername").performTextInput("JohnDoe") + composeTestRule.onNodeWithTag("editProfileEmail").performTextInput("john.doe@example.com") + composeTestRule.onNodeWithTag("editProfileBio").performTextInput("This is a bio") - composeTestRule.onNodeWithTag("profileLogout").performScrollTo().assertIsDisplayed() + // Now enabled + composeTestRule.onNodeWithTag("profileSaveButton").assertIsEnabled() + } - // Check button texts after scrolling - composeTestRule.onNodeWithTag("profileSaveButton").assertTextEquals("Save") - composeTestRule.onNodeWithTag("profileLogout").assertTextEquals("Sign out") + @Test + fun logoutButtonWorks() { + whenever(profileRepository.getUserProfile(any(), any())).thenAnswer { + it.getArgument<(UserProfile?) -> Unit>(0).invoke(null) } - @Test - fun saveButtonDisabledWhenFieldsAreEmpty() { - composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } + composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } - // Initially, all fields are empty, so the save button should be disabled - composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() + composeTestRule.onNodeWithTag("profileLogout").performScrollTo().performClick() + composeTestRule.waitForIdle() - // Fill in username, bio, and email - composeTestRule.onNodeWithTag("editProfileUsername").performTextInput("JohnDoe") - composeTestRule.onNodeWithTag("editProfileEmail").performTextInput("john.doe@example.com") - composeTestRule.onNodeWithTag("editProfileBio").performTextInput("This is a bio") + verify(navigationActions).navigateTo(Screen.LANDING) + } - // Now all fields are filled, so the save button should be enabled - composeTestRule.onNodeWithTag("profileSaveButton").assertIsEnabled() + @Test + fun saveButtonWorks() { + // Start with no profile + whenever(profileRepository.getUserProfile(any(), any())).thenAnswer { + it.getArgument<(UserProfile?) -> Unit>(0).invoke(null) } - @Test - fun logoutButtonWorks() { - composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } + composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } - // Scroll to the sign-out button if it's off-screen, then click it - composeTestRule.onNodeWithTag("profileLogout").performScrollTo().performClick() + // Fill fields + composeTestRule.onNodeWithTag("editProfileUsername").performTextInput("JohnDoe") + composeTestRule.onNodeWithTag("editProfileEmail").performTextInput("john.doe@example.com") + composeTestRule.onNodeWithTag("editProfileBio").performTextInput("This is a bio") - // Verify that the navigation action to the landing screen was triggered - verify(navigationActions).navigateTo(Screen.LANDING) - } - - @Test - fun saveButtonWorks() { - composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } - - // Assert: Save button is initially disabled - composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() - - // Act: Fill in only the username - composeTestRule.onNodeWithTag("editProfileUsername").performTextInput("JohnDoe") - // Assert: Save button is still disabled - composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() - - // Act: Fill in email - composeTestRule.onNodeWithTag("editProfileEmail").performTextInput("john.doe@example.com") - // Assert: Save button is still disabled because bio is empty - composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() - - // Act: Fill in the bio - composeTestRule.onNodeWithTag("editProfileBio").performTextInput("This is a bio") - // Assert: Save button should now be enabled - composeTestRule.onNodeWithTag("profileSaveButton").assertIsEnabled() - composeTestRule.onNodeWithTag("profileSaveButton").performClick() - verify(navigationActions).navigateTo(Screen.PROFILE) - } + // Click Save + composeTestRule.onNodeWithTag("profileSaveButton").performClick() + composeTestRule.waitForIdle() - @Test - fun saveButtonDisabledWhenAllFieldsAreEmpty() { - composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } + // Verify navigation after success + verify(navigationActions).navigateTo(Screen.PROFILE) + } - // Assert: Save button is disabled because no fields have been populated - composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() + @Test + fun saveButtonDisabledWhenAllFieldsAreEmpty() { + whenever(profileRepository.getUserProfile(any(), any())).thenAnswer { + it.getArgument<(UserProfile?) -> Unit>(0).invoke(null) } - @Test - fun deleteButtonDisabledWhenProfileIsNull() { - composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } + composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } - // Assert: Delete button is disabled because the profile is null - composeTestRule.onNodeWithTag("profileDelete").assertIsNotEnabled() - } + // Nothing typed in, should be disabled + composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() + } - @Test - fun deleteButtonWorks() { - composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } - - // Assert: Save button is initially disabled - composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() - - // Act: Fill in only the username - composeTestRule.onNodeWithTag("editProfileUsername").performTextInput("JohnDoe") - // Assert: Save button is still disabled - composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() - - // Act: Fill in email - composeTestRule.onNodeWithTag("editProfileEmail").performTextInput("john.doe@example.com") - // Assert: Save button is still disabled because bio is empty - composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled() - - // Act: Fill in the bio - composeTestRule.onNodeWithTag("editProfileBio").performTextInput("This is a bio") - composeTestRule.onNodeWithTag("profileSaveButton").performClick() - verify(navigationActions).navigateTo(Screen.PROFILE) - // Assert: Save button should now be enabled - composeTestRule.onNodeWithTag("profileDelete").assertIsEnabled() - // Scroll to the delete button if it's off-screen, then click it - composeTestRule.onNodeWithTag("profileDelete").performScrollTo().performClick() - verify(navigationActions).navigateTo(Screen.MENU) + @Test + fun deleteButtonDisabledWhenProfileIsNull() { + // No profile returned + whenever(profileRepository.getUserProfile(any(), any())).thenAnswer { + it.getArgument<(UserProfile?) -> Unit>(0).invoke(null) } -} \ No newline at end of file + + composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) } + + // Profile is null, so delete is disabled + composeTestRule.onNodeWithTag("profileDelete").assertIsNotEnabled() + } +} From 46b1b02514bb45cf42d043169b88252f7caa2505 Mon Sep 17 00:00:00 2001 From: Othmane Bencheqroun Date: Fri, 20 Dec 2024 01:49:47 +0100 Subject: [PATCH 07/11] refactor: change the profile repository to make the usernames unique --- .../lookup/model/profile/ProfileRepository.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/profile/ProfileRepository.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/profile/ProfileRepository.kt index 4c6dfc89c..0cee35268 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/model/profile/ProfileRepository.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/model/profile/ProfileRepository.kt @@ -1,3 +1,4 @@ +// File: ProfileRepository.kt package com.github.lookupgroup27.lookup.model.profile interface ProfileRepository { @@ -19,4 +20,13 @@ interface ProfileRepository { ) fun getSelectedAvatar(userId: String, onSuccess: (Int?) -> Unit, onFailure: (Exception) -> Unit) + + /** + * Checks if the given username is already taken by another user. + * + * @param username The username to check for uniqueness. + * @param onResult Callback with true if the username is taken, false otherwise. + * @param onFailure Callback with an exception if the operation fails. + */ + fun isUsernameTaken(username: String, onResult: (Boolean) -> Unit, onFailure: (Exception) -> Unit) } From 44b7a4f02f83317a165e3973ba89cdf9f3a6f0c2 Mon Sep 17 00:00:00 2001 From: Othmane Bencheqroun Date: Fri, 20 Dec 2024 01:50:46 +0100 Subject: [PATCH 08/11] feat: create new function to have unique username when updating the user profiles --- .../profile/ProfileRepositoryFirestore.kt | 253 ++++++++++-------- 1 file changed, 134 insertions(+), 119 deletions(-) diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/profile/ProfileRepositoryFirestore.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/profile/ProfileRepositoryFirestore.kt index 15db9c5e0..fd17f9365 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/model/profile/ProfileRepositoryFirestore.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/model/profile/ProfileRepositoryFirestore.kt @@ -1,10 +1,10 @@ +// File: ProfileRepositoryFirestore.kt package com.github.lookupgroup27.lookup.model.profile import android.util.Log import com.google.android.gms.tasks.Task import com.google.firebase.auth.FirebaseAuth import com.google.firebase.firestore.FirebaseFirestore -import com.github.lookupgroup27.lookup.model.register.UsernameAlreadyExistsException data class UserProfile( val username: String = " ", @@ -19,139 +19,154 @@ class ProfileRepositoryFirestore( private val auth: FirebaseAuth ) : ProfileRepository { - private val collectionPath = "users" - private val usersCollection = db.collection(collectionPath) + private val collectionPath = "users" + private val usersCollection = db.collection(collectionPath) - override fun init(onSuccess: () -> Unit) { - auth.addAuthStateListener { - if (it.currentUser != null) { - onSuccess() - } - } + override fun init(onSuccess: () -> Unit) { + auth.addAuthStateListener { + if (it.currentUser != null) { + onSuccess() + } } + } - override fun getUserProfile(onSuccess: (UserProfile?) -> Unit, onFailure: (Exception) -> Unit) { - val userId = auth.currentUser?.uid - if (userId != null) { - usersCollection.document(userId).get().addOnCompleteListener { task -> - if (task.isSuccessful) { - val profile = task.result?.toObject(UserProfile::class.java) - onSuccess(profile) - } else { - task.exception?.let { - Log.e("ProfileRepositoryFirestore", "Error getting user profile", it) - onFailure(it) - } - } - } + override fun getUserProfile(onSuccess: (UserProfile?) -> Unit, onFailure: (Exception) -> Unit) { + val userId = auth.currentUser?.uid + if (userId != null) { + usersCollection.document(userId).get().addOnCompleteListener { task -> + if (task.isSuccessful) { + val profile = task.result?.toObject(UserProfile::class.java) + onSuccess(profile) } else { - onSuccess(null) // No logged-in user + task.exception?.let { + Log.e("ProfileRepositoryFirestore", "Error getting user profile", it) + onFailure(it) + } } + } + } else { + onSuccess(null) // No logged-in user } + } - override fun updateUserProfile( - profile: UserProfile, - onSuccess: () -> Unit, - onFailure: (Exception) -> Unit - ) { - val userId = auth.currentUser?.uid - if (userId != null) { - // Check if the desired username is already taken by another user - usersCollection.whereEqualTo("username", profile.username).get() - .addOnSuccessListener { querySnapshot -> - val isUsernameTaken = querySnapshot.documents.any { it.id != userId } - if (isUsernameTaken) { - onFailure(UsernameAlreadyExistsException("Username '${profile.username}' is already in use.")) - } else { - // Proceed to update the user profile - usersCollection.document(userId).set(profile) - .addOnSuccessListener { onSuccess() } - .addOnFailureListener { exception -> onFailure(exception) } - } - } - .addOnFailureListener { exception -> - Log.e("ProfileRepositoryFirestore", "Error checking username uniqueness", exception) - onFailure(exception) - } - } else { - onFailure(Exception("User not logged in")) - } + override fun updateUserProfile( + profile: UserProfile, + onSuccess: () -> Unit, + onFailure: (Exception) -> Unit + ) { + val userId = auth.currentUser?.uid + if (userId != null) { + performFirestoreOperation(usersCollection.document(userId).set(profile), onSuccess, onFailure) + } else { + onFailure(Exception("User not logged in")) } + } - override fun deleteUserProfile( - profile: UserProfile, - onSuccess: () -> Unit, - onFailure: (Exception) -> Unit - ) { - val userId = auth.currentUser?.uid - if (userId != null) { - performFirestoreOperation(usersCollection.document(userId).delete(), onSuccess, onFailure) - } else { - onFailure(Exception("User not logged in")) - } + override fun deleteUserProfile( + profile: UserProfile, + onSuccess: () -> Unit, + onFailure: (Exception) -> Unit + ) { + val userId = auth.currentUser?.uid + if (userId != null) { + performFirestoreOperation(usersCollection.document(userId).delete(), onSuccess, onFailure) + } else { + onFailure(Exception("User not logged in")) } + } - override fun logoutUser() { - auth.signOut() - } + override fun logoutUser() { + auth.signOut() + } - override fun saveSelectedAvatar( - userId: String, - avatarId: Int?, - onSuccess: () -> Unit, - onFailure: (Exception) -> Unit - ) { - val userDocument = usersCollection.document(userId) - userDocument - .get() - .addOnSuccessListener { document -> - if (!document.exists()) { - // Create a default profile with the selected avatar - val defaultProfile = mapOf("selectedAvatar" to avatarId) - userDocument - .set(defaultProfile) - .addOnSuccessListener { onSuccess() } - .addOnFailureListener { exception -> onFailure(exception) } - } else { - // Update the existing profile with the selected avatar - userDocument - .update("selectedAvatar", avatarId) - .addOnSuccessListener { onSuccess() } - .addOnFailureListener { exception -> onFailure(exception) } - } - } - .addOnFailureListener { exception -> onFailure(exception) } - } + override fun saveSelectedAvatar( + userId: String, + avatarId: Int?, + onSuccess: () -> Unit, + onFailure: (Exception) -> Unit + ) { + val userDocument = usersCollection.document(userId) + userDocument + .get() + .addOnSuccessListener { document -> + if (!document.exists()) { + // Create a default profile with the selected avatar + val defaultProfile = mapOf("selectedAvatar" to avatarId) + userDocument + .set(defaultProfile) + .addOnSuccessListener { onSuccess() } + .addOnFailureListener { exception -> onFailure(exception) } + } else { + // Update the existing profile with the selected avatar + userDocument + .update("selectedAvatar", avatarId) + .addOnSuccessListener { onSuccess() } + .addOnFailureListener { exception -> onFailure(exception) } + } + } + .addOnFailureListener { exception -> onFailure(exception) } + } + + override fun getSelectedAvatar( + userId: String, + onSuccess: (Int?) -> Unit, + onFailure: (Exception) -> Unit + ) { + val userDocument = usersCollection.document(userId) + userDocument + .get() + .addOnSuccessListener { document -> + val avatarId = document.getLong("selectedAvatar")?.toInt() + onSuccess(avatarId) + } + .addOnFailureListener { exception -> onFailure(exception) } + } - override fun getSelectedAvatar( - userId: String, - onSuccess: (Int?) -> Unit, - onFailure: (Exception) -> Unit - ) { - val userDocument = usersCollection.document(userId) - userDocument - .get() - .addOnSuccessListener { document -> - val avatarId = document.getLong("selectedAvatar")?.toInt() - onSuccess(avatarId) - } - .addOnFailureListener { exception -> onFailure(exception) } + /** + * Checks if the given username is already taken by another user. + * + * @param username The username to check for uniqueness. + * @param onResult Callback with true if the username is taken, false otherwise. + * @param onFailure Callback with an exception if the operation fails. + */ + override fun isUsernameTaken( + username: String, + onResult: (Boolean) -> Unit, + onFailure: (Exception) -> Unit + ) { + val userId = auth.currentUser?.uid + if (userId != null) { + usersCollection + .whereEqualTo("username", username) + .get() + .addOnSuccessListener { querySnapshot -> + // Check if any document has the username and is not the current user + val taken = querySnapshot.documents.any { it.id != userId } + onResult(taken) + } + .addOnFailureListener { exception -> + Log.e("ProfileRepositoryFirestore", "Error checking username", exception) + onFailure(exception) + } + } else { + onFailure(Exception("User not logged in")) } + } - private fun performFirestoreOperation( - task: Task, - onSuccess: () -> Unit, - onFailure: (Exception) -> Unit - ) { - task.addOnCompleteListener { result -> - if (result.isSuccessful) { - onSuccess() - } else { - result.exception?.let { - Log.e("ProfileRepositoryFirestore", "Error performing Firestore operation", it) - onFailure(it) - } - } + private fun performFirestoreOperation( + task: Task, + onSuccess: () -> Unit, + onFailure: (Exception) -> Unit + ) { + task.addOnCompleteListener { result -> + if (result.isSuccessful) { + onSuccess() + } else { + result.exception?.let { + Log.e("ProfileRepositoryFirestore", "Error performing Firestore operation", it) + onFailure(it) } + } } + } } From f6b35a074e42bdbad652567f8ca65ce87c294ef7 Mon Sep 17 00:00:00 2001 From: Othmane Bencheqroun Date: Fri, 20 Dec 2024 01:51:19 +0100 Subject: [PATCH 09/11] feat: change the UI to let know the user when he wants an username that already exists --- .../lookup/ui/profile/ProfileInformation.kt | 371 ++++++++---------- 1 file changed, 163 insertions(+), 208 deletions(-) diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/ui/profile/ProfileInformation.kt b/app/src/main/java/com/github/lookupgroup27/lookup/ui/profile/ProfileInformation.kt index 69df7e78a..c18649b44 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/ui/profile/ProfileInformation.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/ui/profile/ProfileInformation.kt @@ -13,18 +13,12 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.* import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.github.lookupgroup27.lookup.model.profile.UserProfile import com.github.lookupgroup27.lookup.ui.navigation.NavigationActions -import com.google.firebase.auth.FirebaseAuth -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.ui.platform.testTag import com.github.lookupgroup27.lookup.ui.navigation.Screen - - +import com.google.firebase.auth.FirebaseAuth @SuppressLint("StateFlowValueCalledInComposition") @Composable @@ -33,217 +27,178 @@ fun ProfileInformationScreen( navigationActions: NavigationActions ) { - val scrollState = rememberScrollState() - - // Observe states from ViewModel - val profile by profileViewModel.userProfile.collectAsState() - val profileUpdateStatus by profileViewModel.profileUpdateStatus.collectAsState() - val errorMessage by profileViewModel.error.collectAsState() - val usernameError by profileViewModel.usernameError.collectAsState() - - // Local UI state - var username by remember { mutableStateOf(profile?.username ?: "") } - var bio by remember { mutableStateOf(profile?.bio ?: "") } - var email by remember { mutableStateOf(profile?.email ?: FirebaseAuth.getInstance().currentUser?.email ?: "") } - - // Synchronize local UI state with ViewModel's userProfile - LaunchedEffect(profile) { - username = profile?.username ?: "" - bio = profile?.bio ?: "" - email = profile?.email ?: FirebaseAuth.getInstance().currentUser?.email ?: "" - } - - // Reset error states when the screen is entered - LaunchedEffect(Unit) { - profileViewModel.resetProfileUpdateStatus() - } - - // Handle navigation based on profileUpdateStatus - LaunchedEffect(profileUpdateStatus, errorMessage) { - if (profileUpdateStatus == true) { - // Update was successful, navigate to Profile screen - navigationActions.navigateTo(Screen.PROFILE) - profileViewModel.resetProfileUpdateStatus() - } else if (profileUpdateStatus == false && errorMessage != null) { - // Handle general errors if needed (e.g., show a Snackbar) - // For this example, we're focusing on username errors - profileViewModel.resetProfileUpdateStatus() - } + val scrollState = rememberScrollState() + + // Observe states from ViewModel + val profile by profileViewModel.userProfile.collectAsState() + val profileUpdateStatus by profileViewModel.profileUpdateStatus.collectAsState() + val errorMessage by profileViewModel.error.collectAsState() + val usernameError by profileViewModel.usernameError.collectAsState() + + // Local UI state + var username by remember { mutableStateOf(profile?.username ?: "") } + var bio by remember { mutableStateOf(profile?.bio ?: "") } + var email by remember { + mutableStateOf(profile?.email ?: FirebaseAuth.getInstance().currentUser?.email ?: "") + } + + // Synchronize local UI state with ViewModel's userProfile + LaunchedEffect(profile) { + username = profile?.username ?: "" + bio = profile?.bio ?: "" + email = profile?.email ?: FirebaseAuth.getInstance().currentUser?.email ?: "" + } + + // Reset error states when the screen is entered + LaunchedEffect(Unit) { profileViewModel.resetProfileUpdateStatus() } + + // Handle navigation based on profileUpdateStatus + LaunchedEffect(profileUpdateStatus, errorMessage) { + if (profileUpdateStatus == true) { + // Update was successful, navigate to Profile screen + navigationActions.navigateTo(Screen.PROFILE) + profileViewModel.resetProfileUpdateStatus() + } else if (profileUpdateStatus == false && errorMessage != null) { + // Handle general errors if needed (e.g., show a Snackbar) + // For this example, we're focusing on username errors + profileViewModel.resetProfileUpdateStatus() } + } - Scaffold( - // You can add other scaffold parameters like topBar or bottomBar here if needed - ) { innerPadding -> - + Scaffold( + // You can add other scaffold parameters like topBar or bottomBar here if needed + ) { innerPadding -> Column( verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.Start, modifier = - Modifier - .padding(8.dp) - .fillMaxSize() - .padding(innerPadding) - .verticalScroll(scrollState) - .testTag("editProfileScreen") - ) { - Spacer(modifier = Modifier.height(16.dp)) - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - modifier = - Modifier - .padding(start = 16.dp) - .size(30.dp) - .clickable { navigationActions.goBack() } - .testTag("goBackButton") - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Your Personal Information", - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier - .padding(start = 16.dp) - .fillMaxWidth() - .testTag("editProfileTitle") - ) - - Column( - verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Top), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .padding(20.dp) - .fillMaxWidth() - ) { - Spacer(modifier = Modifier.height(16.dp)) - - // Username Field - OutlinedTextField( - value = username, - onValueChange = { new_name -> - username = new_name - if (usernameError != null) { + Modifier.padding(8.dp) + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(scrollState) + .testTag("editProfileScreen")) { + Spacer(modifier = Modifier.height(16.dp)) + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + modifier = + Modifier.padding(start = 16.dp) + .size(30.dp) + .clickable { navigationActions.goBack() } + .testTag("goBackButton")) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Your Personal Information", + style = MaterialTheme.typography.headlineMedium, + modifier = + Modifier.padding(start = 16.dp).fillMaxWidth().testTag("editProfileTitle")) + + Column( + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Top), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(20.dp).fillMaxWidth()) { + Spacer(modifier = Modifier.height(16.dp)) + + // Username Field + OutlinedTextField( + value = username, + onValueChange = { new_name -> + username = new_name + if (usernameError != null) { // Reset username error when user starts typing profileViewModel.resetProfileUpdateStatus() - } - }, - label = { Text("Username") }, - placeholder = { Text("Enter username") }, - isError = usernameError != null, - shape = RoundedCornerShape(16.dp), - modifier = - Modifier - .width(312.dp) - .height(60.dp) - .testTag("editProfileUsername") - ) - - // Display username error if exists - if (usernameError != null) { - Text( - text = usernameError ?: "", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier - .padding(start = 16.dp) - .fillMaxWidth() - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - // E-mail Field - OutlinedTextField( - value = email, - onValueChange = { - if (FirebaseAuth.getInstance().currentUser?.email.isNullOrEmpty()) { + } + }, + label = { Text("Username") }, + placeholder = { Text("Enter username") }, + isError = usernameError != null, + shape = RoundedCornerShape(16.dp), + modifier = + Modifier.width(312.dp).height(60.dp).testTag("editProfileUsername")) + + // Display username error if exists + if (usernameError != null) { + Text( + text = usernameError ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 16.dp).fillMaxWidth()) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // E-mail Field + OutlinedTextField( + value = email, + onValueChange = { + if (FirebaseAuth.getInstance().currentUser?.email.isNullOrEmpty()) { email = it + } + }, + label = { Text("E-mail") }, + placeholder = { Text("Enter your e-mail") }, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.width(312.dp).height(60.dp).testTag("editProfileEmail")) + + Spacer(modifier = Modifier.height(8.dp)) + + // Bio Field + OutlinedTextField( + value = bio, + onValueChange = { new_description -> bio = new_description }, + label = { Text("Bio") }, + placeholder = { Text("Enter a bio") }, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.width(312.dp).height(80.dp).testTag("editProfileBio")) + + Spacer(modifier = Modifier.height(30.dp)) + + // Save Button + Button( + onClick = { + val newProfile: UserProfile = + profile?.copy(username = username, bio = bio, email = email) + ?: UserProfile(username = username, bio = bio, email = email) + profileViewModel.updateUserProfile(newProfile) + }, + enabled = username.isNotEmpty() && bio.isNotEmpty() && email.isNotEmpty(), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF30315B)), + modifier = + Modifier.width(131.dp).height(40.dp).testTag("profileSaveButton")) { + Text(text = "Save", color = Color.White) } - }, - label = { Text("E-mail") }, - placeholder = { Text("Enter your e-mail") }, - shape = RoundedCornerShape(16.dp), - modifier = - Modifier - .width(312.dp) - .height(60.dp) - .testTag("editProfileEmail") - ) - - Spacer(modifier = Modifier.height(8.dp)) - - // Bio Field - OutlinedTextField( - value = bio, - onValueChange = { new_description -> bio = new_description }, - label = { Text("Bio") }, - placeholder = { Text("Enter a bio") }, - shape = RoundedCornerShape(16.dp), - modifier = - Modifier - .width(312.dp) - .height(80.dp) - .testTag("editProfileBio") - ) - - Spacer(modifier = Modifier.height(30.dp)) - - // Save Button - Button( - onClick = { - val newProfile: UserProfile = - profile?.copy(username = username, bio = bio, email = email) - ?: UserProfile(username = username, bio = bio, email = email) - profileViewModel.updateUserProfile(newProfile) - }, - enabled = username.isNotEmpty() && bio.isNotEmpty() && email.isNotEmpty(), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF30315B)), - modifier = Modifier - .width(131.dp) - .height(40.dp) - .testTag("profileSaveButton") - ) { - Text(text = "Save", color = Color.White) - } - - Spacer(modifier = Modifier.height(30.dp)) - - // Logout Button - Button( - onClick = { - profileViewModel.logoutUser() - navigationActions.navigateTo(Screen.LANDING) - }, - enabled = true, - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF410002)), - modifier = Modifier - .width(131.dp) - .height(40.dp) - .testTag("profileLogout") - ) { - Text(text = "Sign out", color = Color.White) - } - - Spacer(modifier = Modifier.height(30.dp)) - - // Delete Button - Button( - onClick = { - profile?.let { + + Spacer(modifier = Modifier.height(30.dp)) + + // Logout Button + Button( + onClick = { + profileViewModel.logoutUser() + navigationActions.navigateTo(Screen.LANDING) + }, + enabled = true, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF410002)), + modifier = Modifier.width(131.dp).height(40.dp).testTag("profileLogout")) { + Text(text = "Sign out", color = Color.White) + } + + Spacer(modifier = Modifier.height(30.dp)) + + // Delete Button + Button( + onClick = { + profile?.let { profileViewModel.deleteUserProfile(it) profileViewModel.logoutUser() + } + navigationActions.navigateTo(Screen.MENU) + }, + enabled = username.isNotEmpty() && bio.isNotEmpty() && email.isNotEmpty(), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF410002)), + modifier = Modifier.width(131.dp).height(40.dp).testTag("profileDelete")) { + Text(text = "Delete", color = Color.White) } - navigationActions.navigateTo(Screen.MENU) - }, - enabled = username.isNotEmpty() && bio.isNotEmpty() && email.isNotEmpty(), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF410002)), - modifier = Modifier - .width(131.dp) - .height(40.dp) - .testTag("profileDelete") - ) { - Text(text = "Delete", color = Color.White) - } + } } - } - } -} \ No newline at end of file + } +} From 346a9121ef113a56bcf815155d12c508cda2fddd Mon Sep 17 00:00:00 2001 From: Othmane Bencheqroun Date: Fri, 20 Dec 2024 01:52:12 +0100 Subject: [PATCH 10/11] feat: modify the updateUserProfile to make sure the username is unique --- .../lookup/ui/profile/ProfileViewModel.kt | 181 ++++++++++-------- 1 file changed, 101 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/ui/profile/ProfileViewModel.kt b/app/src/main/java/com/github/lookupgroup27/lookup/ui/profile/ProfileViewModel.kt index afc8c10a3..4050f0b0a 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/ui/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/ui/profile/ProfileViewModel.kt @@ -1,10 +1,10 @@ +// File: ProfileViewModel.kt package com.github.lookupgroup27.lookup.ui.profile import androidx.lifecycle.* import com.github.lookupgroup27.lookup.model.profile.ProfileRepository import com.github.lookupgroup27.lookup.model.profile.ProfileRepositoryFirestore import com.github.lookupgroup27.lookup.model.profile.UserProfile -import com.github.lookupgroup27.lookup.model.register.UsernameAlreadyExistsException import com.google.firebase.auth.ktx.auth import com.google.firebase.firestore.ktx.firestore import com.google.firebase.ktx.Firebase @@ -13,95 +13,116 @@ import kotlinx.coroutines.launch class ProfileViewModel(private val repository: ProfileRepository) : ViewModel() { - private val _userProfile = MutableStateFlow(null) - val userProfile: StateFlow = _userProfile.asStateFlow() + private val _userProfile = MutableStateFlow(null) + val userProfile: StateFlow = _userProfile.asStateFlow() - private val _profileUpdateStatus = MutableStateFlow(null) // Nullable to represent no action yet - val profileUpdateStatus: StateFlow = _profileUpdateStatus.asStateFlow() + private val _profileUpdateStatus = MutableStateFlow(null) + val profileUpdateStatus: StateFlow = _profileUpdateStatus.asStateFlow() - private val _error = MutableStateFlow(null) - val error: StateFlow = _error.asStateFlow() + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() - private val _usernameError = MutableStateFlow(null) - val usernameError: StateFlow = _usernameError.asStateFlow() + // New state for username-specific errors + private val _usernameError = MutableStateFlow(null) + val usernameError: StateFlow = _usernameError.asStateFlow() - init { - repository.init { fetchUserProfile() } - } + init { + repository.init { fetchUserProfile() } + } - fun fetchUserProfile() { - viewModelScope.launch { - repository.getUserProfile( - onSuccess = { profile -> _userProfile.value = profile }, - onFailure = { exception -> - _error.value = "Failed to load profile: ${exception.message}" - }) - } + fun fetchUserProfile() { + viewModelScope.launch { + repository.getUserProfile( + onSuccess = { profile -> + _userProfile.value = profile + // Reset username error when fetching profile + _usernameError.value = null + }, + onFailure = { exception -> + _error.value = "Failed to load profile: ${exception.message}" + }) } + } - fun updateUserProfile(profile: UserProfile) { - viewModelScope.launch { - repository.updateUserProfile( - profile, - onSuccess = { - // After successful update, fetch the latest profile - repository.getUserProfile( - onSuccess = { updatedProfile -> - _userProfile.value = updatedProfile - _profileUpdateStatus.value = true // Trigger navigation - }, - onFailure = { exception -> - _error.value = "Failed to refresh profile: ${exception.message}" - _profileUpdateStatus.value = false // Indicate failure - } - ) - _usernameError.value = null // Clear username error on success - }, - onFailure = { exception -> - when (exception) { - is UsernameAlreadyExistsException -> { - _usernameError.value = exception.message - _profileUpdateStatus.value = false // Indicate failure - } - else -> { - _error.value = "Failed to update profile: ${exception.message}" - _profileUpdateStatus.value = false // Indicate failure - } - } - }) - } + /** + * Updates the user profile after ensuring the username is unique. + * + * @param profile The new user profile with the updated username. + */ + fun updateUserProfile(profile: UserProfile) { + viewModelScope.launch { + repository.isUsernameTaken( + profile.username, + onResult = { taken -> + if (taken) { + // Username is already taken + _profileUpdateStatus.value = false + _usernameError.value = "Username is already taken." + } else { + // Username is unique, proceed with update + repository.updateUserProfile( + profile, + onSuccess = { + _profileUpdateStatus.value = true + _usernameError.value = null // Clear username error on successful update + _userProfile.value = profile // Update local userProfile state + }, + onFailure = { exception -> + _profileUpdateStatus.value = false + _error.value = "Failed to update profile: ${exception.message}" + }) + } + }, + onFailure = { exception -> + _profileUpdateStatus.value = false + _error.value = "Failed to check username: ${exception.message}" + }) } + } - fun resetProfileUpdateStatus() { - _profileUpdateStatus.value = null + /** + * Deletes the user profile. + * + * @param profile The user profile to delete. + */ + fun deleteUserProfile(profile: UserProfile) { + viewModelScope.launch { + repository.deleteUserProfile( + profile, + onSuccess = { + _profileUpdateStatus.value = true + _userProfile.value = null // Clear local userProfile state + }, + onFailure = { exception -> + _profileUpdateStatus.value = false + _error.value = "Failed to delete profile: ${exception.message}" + }) } + } - fun deleteUserProfile(profile: UserProfile) { - viewModelScope.launch { - repository.deleteUserProfile( - profile, - onSuccess = { - _profileUpdateStatus.value = true // Optionally set a status for deletion - }, - onFailure = { exception -> - _error.value = "Failed to delete profile: ${exception.message}" - }) - } - } + /** + * Resets the profile update status and error messages. This can be called after handling the + * update result to clear previous states. + */ + fun resetProfileUpdateStatus() { + _profileUpdateStatus.value = null + _error.value = null + _usernameError.value = null + } - fun logoutUser() { - repository.logoutUser() - _userProfile.value = null - } + fun logoutUser() { + repository.logoutUser() + _userProfile.value = null + } - companion object { - val Factory: ViewModelProvider.Factory = - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - return ProfileViewModel(ProfileRepositoryFirestore(Firebase.firestore, Firebase.auth)) - as T - } - } - } -} \ No newline at end of file + companion object { + val Factory: ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return ProfileViewModel(ProfileRepositoryFirestore(Firebase.firestore, Firebase.auth)) + as T + } + } + } +} From 3eeb70c1c1ced555393861ed905c5b955689a987 Mon Sep 17 00:00:00 2001 From: Othmane Bencheqroun Date: Fri, 20 Dec 2024 01:52:32 +0100 Subject: [PATCH 11/11] fix: align the old tests with the new changes --- .../lookup/ui/profile/ProfileViewModelTest.kt | 48 +++++-------------- 1 file changed, 11 insertions(+), 37 deletions(-) diff --git a/app/src/test/java/com/github/lookupgroup27/lookup/ui/profile/ProfileViewModelTest.kt b/app/src/test/java/com/github/lookupgroup27/lookup/ui/profile/ProfileViewModelTest.kt index 27bbbc696..884f13371 100644 --- a/app/src/test/java/com/github/lookupgroup27/lookup/ui/profile/ProfileViewModelTest.kt +++ b/app/src/test/java/com/github/lookupgroup27/lookup/ui/profile/ProfileViewModelTest.kt @@ -1,6 +1,7 @@ +// File: ProfileViewModelTest.kt package com.github.lookupgroup27.lookup.ui.profile -import com.github.lookupgroup27.lookup.model.profile.ProfileRepositoryFirestore +import com.github.lookupgroup27.lookup.model.profile.ProfileRepository import com.github.lookupgroup27.lookup.model.profile.UserProfile import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser @@ -18,7 +19,7 @@ import org.mockito.kotlin.eq @OptIn(ExperimentalCoroutinesApi::class) class ProfileViewModelTest { - private lateinit var repository: ProfileRepositoryFirestore + private lateinit var repository: ProfileRepository private lateinit var viewModel: ProfileViewModel private lateinit var mockFirestore: FirebaseFirestore private lateinit var mockCollectionReference: CollectionReference @@ -46,41 +47,28 @@ class ProfileViewModelTest { `when`(mockFirestore.collection("users")).thenReturn(mockCollectionReference) // Initialize repository with mocks and viewModel with mocked repository - repository = mock(ProfileRepositoryFirestore::class.java) + repository = mock(ProfileRepository::class.java) viewModel = ProfileViewModel(repository) } @After fun tearDown() { - Dispatchers.resetMain() // reset the Main dispatcher to the original + Dispatchers.resetMain() // Reset the Main dispatcher to the original } @Test fun `fetchUserProfile calls getUserProfile in repository`() = runTest { // Simulate a successful fetch with a mocked behavior `when`(repository.getUserProfile(any(), any())).thenAnswer { invocation -> - val onSuccess = invocation.arguments[0] as (UserProfile?) -> Unit + val onSuccess = invocation.getArgument<(UserProfile?) -> Unit>(0) onSuccess(testProfile) + null } viewModel.fetchUserProfile() verify(repository).getUserProfile(any(), any()) } - @Test - fun `updateUserProfile calls updateUserProfile in repository`() = runTest { - // Simulate successful update behavior - `when`(repository.updateUserProfile(eq(testProfile), any(), any())).thenAnswer { invocation -> - val onSuccess = invocation.arguments[1] as () -> Unit - onSuccess() - } - - viewModel.updateUserProfile(testProfile) - - // Verifying that the method is indeed called - verify(repository).updateUserProfile(eq(testProfile), any(), any()) - } - @Test fun `logoutUser calls logoutUser in repository`() { viewModel.logoutUser() @@ -94,6 +82,7 @@ class ProfileViewModelTest { `when`(repository.getUserProfile(any(), any())).thenAnswer { val onFailure = it.getArgument<(Exception) -> Unit>(1) onFailure(exception) + null } // Call the method @@ -103,29 +92,13 @@ class ProfileViewModelTest { assertEquals("Failed to load profile: Network error", viewModel.error.value) } - @Test - fun `updateUserProfile sets profileUpdateStatus to false and error on failure`() = runTest { - // Mock an error scenario in the repository - val exception = Exception("Update failed") - `when`(repository.updateUserProfile(any(), any(), any())).thenAnswer { - val onFailure = it.getArgument<(Exception) -> Unit>(2) - onFailure(exception) - } - - // Call the method - viewModel.updateUserProfile(testProfile) - - // Assert that _profileUpdateStatus is false and _error is set - assertFalse(viewModel.profileUpdateStatus.value!!) - assertEquals("Failed to update profile: Update failed", viewModel.error.value) - } - @Test fun `deleteUserProfile calls deleteUserProfile in repository on success`() = runTest { // Simulate successful deletion behavior `when`(repository.deleteUserProfile(any(), any(), any())).thenAnswer { invocation -> - val onSuccess = invocation.arguments[1] as () -> Unit + val onSuccess = invocation.getArgument<() -> Unit>(1) onSuccess() + null } viewModel.deleteUserProfile(testProfile) @@ -142,6 +115,7 @@ class ProfileViewModelTest { `when`(repository.deleteUserProfile(any(), any(), any())).thenAnswer { val onFailure = it.getArgument<(Exception) -> Unit>(2) onFailure(exception) + null } // Call the method