diff --git a/app/src/androidTest/java/com/github/se/travelpouch/ui/travel/ParticipantListScreenTest.kt b/app/src/androidTest/java/com/github/se/travelpouch/ui/travel/ParticipantListScreenTest.kt index 3242c60d..1ac08142 100644 --- a/app/src/androidTest/java/com/github/se/travelpouch/ui/travel/ParticipantListScreenTest.kt +++ b/app/src/androidTest/java/com/github/se/travelpouch/ui/travel/ParticipantListScreenTest.kt @@ -35,6 +35,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doThrow import org.mockito.Mockito.mock import org.mockito.Mockito.verify import org.mockito.Mockito.`when` @@ -608,7 +609,6 @@ class ParticipantListScreenTest { .updateTravel(any(), any(), anyOrNull(), any(), any(), anyOrNull()) composeTestRule.onNodeWithTag("addUserButton").performClick() verify(profileRepository).getFsUidByEmail(anyOrNull(), anyOrNull(), anyOrNull()) - // verify(notificationRepository, never()).addNotification(anyOrNull()) } @Test @@ -669,7 +669,111 @@ class ParticipantListScreenTest { .updateTravel(any(), any(), anyOrNull(), any(), any(), anyOrNull()) composeTestRule.onNodeWithTag("addUserButton").performClick() verify(profileRepository).getFsUidByEmail(anyOrNull(), anyOrNull(), anyOrNull()) + verify(notificationRepository).addNotification(anyOrNull()) + + // throw impossible exception + doAnswer { invocation -> + val email = invocation.getArgument(0) + val onSuccess = invocation.getArgument<(fsUid?) -> Unit>(1) + val customUserInfo = + Profile( + fsUid = "qwertzuiopasdfghjklyxcvbnm12", + name = "Custom User", + userTravelList = listOf("00000000000000000000"), + email = email, + username = "username", + friends = emptyMap()) + // Call the onSuccess callback with the custom UserInfo + + onSuccess(customUserInfo.fsUid) + } + .`when`(profileRepository) + .getFsUidByEmail(any(), any(), any()) + doAnswer { "abcdefghijklmnopqrst" }.`when`(notificationRepository).getNewUid() + doNothing().`when`(travelRepository).updateTravel(any(), any(), anyOrNull(), any(), any(), + anyOrNull()) + doThrow(RuntimeException("Impossible Exception")) + .`when`(notificationRepository) + .addNotification(anyOrNull()) + composeTestRule.onNodeWithTag("addUserFab").performClick() + val randomEmail2 = "random.email@example.org" + composeTestRule.onNodeWithTag("addUserEmailField").performScrollTo() + composeTestRule.onNodeWithTag("addUserEmailField").assertIsDisplayed().assertTextContains("") + composeTestRule.onNodeWithTag("addUserEmailField").performTextClearance() + composeTestRule.onNodeWithTag("addUserEmailField").performTextInput(randomEmail2) + composeTestRule.onNodeWithTag("addUserButton").performClick() + composeTestRule.waitForIdle() + } + + @Test + fun addFriendMenuWithNoFriends() { + val travelContainer = createContainer() + listTravelViewModel.selectTravel(travelContainer) + composeTestRule.setContent { + ParticipantListScreen( + listTravelViewModel, navigationActions, notificationViewModel, profileModelView, eventViewModel) + } + composeTestRule.onNodeWithTag("addUserFab").performClick() + composeTestRule.onNodeWithTag("addViaFriendListButton").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("friendListDialogBox", useUnmergedTree = true).assertIsDisplayed() + composeTestRule + .onNodeWithTag("friendListDialogTitle", useUnmergedTree = true) + .assertTextContains("Select a Friend") + composeTestRule + .onNodeWithTag("noFriendsDialogText", useUnmergedTree = true) + .assertTextContains("No friends to choose from") + } + + @Test + fun addFriendMenuWithFriend() { + val travelContainer = createContainer() + listTravelViewModel.selectTravel(travelContainer) + + `when`(profileRepository.getProfileElements(anyOrNull(), anyOrNull())).then { + it.getArgument<(Profile) -> Unit>(0)( + Profile( + fsUid = "abcdefghijklmnopqrstuvwxyz13", + name = "Custom User", + userTravelList = listOf("00000000000000000000"), + email = "email@email.org", + username = "username", + friends = mapOf("example@mail.com" to "abcdefghijklmnopqrstuvwxyz12"))) + } + profileModelView.getProfile() + + composeTestRule.setContent { + ParticipantListScreen( + listTravelViewModel, navigationActions, notificationViewModel, profileModelView, eventViewModel) + } + composeTestRule.onNodeWithTag("addUserFab").performClick() + composeTestRule.onNodeWithTag("addViaFriendListButton").performClick() + composeTestRule.onNodeWithTag("friendListDialogBox", useUnmergedTree = true).assertIsDisplayed() + composeTestRule + .onNodeWithTag("friendListDialogTitle", useUnmergedTree = true) + .assertTextContains("Select a Friend") + doAnswer { invocation -> + val email = invocation.getArgument(0) + val onSuccess = invocation.getArgument<(fsUid?) -> Unit>(1) + val customUserInfo = + Profile( + fsUid = "qwertzuiopasdfghjklyxcvbnm12", + name = "Custom User", + userTravelList = listOf("00000000000000000000"), + email = email, + username = "username", + friends = emptyMap()) + // Call the onSuccess callback with the custom UserInfo + onSuccess(customUserInfo.fsUid) + } + .`when`(profileRepository) + .getFsUidByEmail(any(), any(), any()) + doAnswer { "abcdefghijklmnopqrst" }.`when`(notificationRepository).getNewUid() + doNothing().`when`(travelRepository).updateTravel(any(), any(), anyOrNull(), any(), any(), anyOrNull()) + composeTestRule.onNodeWithTag("friendCard").assertIsDisplayed() + composeTestRule.onNodeWithTag("friendCard").assertTextContains("example@mail.com") + composeTestRule.onNodeWithTag("friendCard").performClick() verify(notificationRepository).addNotification(anyOrNull()) } diff --git a/app/src/main/java/com/github/se/travelpouch/ui/travel/ParticipantListScreen.kt b/app/src/main/java/com/github/se/travelpouch/ui/travel/ParticipantListScreen.kt index 60253bad..43f403fe 100644 --- a/app/src/main/java/com/github/se/travelpouch/ui/travel/ParticipantListScreen.kt +++ b/app/src/main/java/com/github/se/travelpouch/ui/travel/ParticipantListScreen.kt @@ -5,7 +5,9 @@ import android.annotation.SuppressLint import android.content.Context import android.util.Log import android.widget.Toast +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -15,6 +17,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -22,6 +25,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material3.Button +import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.HorizontalDivider @@ -80,6 +84,8 @@ fun ParticipantListScreen( val (selectedParticipant, setSelectedParticipant) = remember { mutableStateOf?>(null) } val (expandedAddUserDialog, setExpandedAddUserDialog) = remember { mutableStateOf(false) } + val (expandedFriendListDialog, setExpandedFriendListDialog) = remember { mutableStateOf(false) } + val userProfile = profileViewModel.profile.collectAsState() Scaffold( modifier = Modifier.testTag("participantListScreen"), @@ -165,50 +171,14 @@ fun ParticipantListScreen( profileViewModel.getFsUidByEmail( addUserEmail.value, onSuccess = { fsUid -> - val isUserAlreadyAdded = - selectedTravel!!.allParticipants.keys.any { - it.fsUid == fsUid - } - if (fsUid == profileViewModel.profile.value.fsUid) { - Toast.makeText( - context, - "Error: You can't invite yourself", - Toast.LENGTH_SHORT) - .show() - } else if (isUserAlreadyAdded) { - Toast.makeText( - context, - "Error: User already added", - Toast.LENGTH_SHORT) - .show() - } else if (fsUid != null) { - try { - notificationViewModel.sendNotification( - Notification( - notificationViewModel.getNewUid(), - profileViewModel.profile.value.fsUid, - fsUid, - selectedTravel!!.fsUid, - NotificationContent.InvitationNotification( - profileViewModel.profile.value.name, - selectedTravel!!.title, - Role.PARTICIPANT), - NotificationType.INVITATION, - sector = NotificationSector.TRAVEL)) - } catch (e: Exception) { - Log.e( - "NotificationError", - "Failed to send notification: ${e.message}") - } - // Go back - setExpandedAddUserDialog(false) - } else { - Toast.makeText( - context, - "Error: User with email not found", - Toast.LENGTH_SHORT) - .show() - } + inviteUserToTravelViaFsuid( + selectedTravel, + fsUid, + profileViewModel, + context, + notificationViewModel) + // Go back + setExpandedAddUserDialog(false) }, onFailure = { e -> Log.e( @@ -223,113 +193,209 @@ fun ParticipantListScreen( modifier = Modifier.testTag("addUserButton")) { Text("Add User") } + Button( + onClick = { + // Show friend list dialog + setExpandedFriendListDialog(true) + }, + modifier = Modifier.testTag("addViaFriendListButton")) { + Text("Add via Friend List") + } } } } - } - - selectedParticipant?.let { participant -> - if (expanded) { - Dialog(onDismissRequest = { setExpanded(false) }) { + if (expandedFriendListDialog) { + Dialog(onDismissRequest = { setExpandedFriendListDialog(false) }) { Box( - Modifier.fillMaxWidth(1f) - .height(250.dp) - .background(MaterialTheme.colorScheme.surface) - .testTag("participantDialogBox")) { - Column(modifier = Modifier.padding(8.dp).testTag("participantDialogColumn")) { - Row( - modifier = Modifier.testTag("participantDialogRow"), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween) { - Icon( - imageVector = Icons.Default.Person, - contentDescription = "Localized description", - modifier = Modifier.padding(5.dp).testTag("participantDialogIcon")) - TruncatedText( - text = participant.value.name, - fontWeight = FontWeight.Bold, - maxLength = 25, - modifier = Modifier.padding(5.dp).testTag("participantDialogName")) + Modifier.background(MaterialTheme.colorScheme.surface) + .testTag("friendListDialogBox")) { + Column( + modifier = Modifier.padding(16.dp).testTag("friendListDialogColumn"), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top) { + Text( + "Select a Friend", + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(8.dp).testTag("friendListDialogTitle")) + if (userProfile.value.friends.isEmpty()) { + Text( + "No friends to choose from", + fontWeight = FontWeight.Light, + modifier = Modifier.testTag("noFriendsDialogText")) } - TruncatedText( - text = participant.value.email, - maxLength = 30, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(5.dp).testTag("participantDialogEmail")) - - RoleEntryDialog( - selectedTravel = selectedTravel, - participant = participant, - changeRoleAction = setExpandedRoleDialog, - removeParticipantAction = { - if (selectedTravel!!.allParticipants[Participant(participant.key)] == - Role.OWNER) { - if (selectedTravel!! - .allParticipants - .values - .count({ it == Role.OWNER }) == 1) { - Toast.makeText( - context, - "You're trying to remove the owner of the travel. Please name another owner before removing.", - Toast.LENGTH_LONG) - .show() - setExpanded(false) - return@RoleEntryDialog + LazyColumn { + items(userProfile.value.friends.keys.toList()) { friend -> + Card( + modifier = + Modifier.fillMaxWidth() + .padding(8.dp) + .clickable { + inviteUserToTravelViaFsuid( + selectedTravel, + userProfile.value.friends[friend], + profileViewModel, + context, + notificationViewModel) + // Go back + setExpandedAddUserDialog(false) + setExpandedFriendListDialog(false) + } + .testTag("friendCard"), + border = BorderStroke(1.dp, Color.Gray), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text(friend, fontWeight = FontWeight.Bold) + Text(friend) + } } } - val participantMap = selectedTravel!!.allParticipants.toMutableMap() - participantMap.remove(Participant(participant.key)) - val participantList = selectedTravel!!.listParticipant.toMutableList() - participantList.remove(participant.key) - val updatedContainer = - selectedTravel!!.copy( - allParticipants = participantMap.toMap(), - listParticipant = participantList) - listTravelViewModel.updateTravel( - updatedContainer, - TravelRepository.UpdateMode.REMOVE_PARTICIPANT, - participant.key, - eventViewModel.getNewDocumentReference()) - listTravelViewModel.selectTravel(updatedContainer) - listTravelViewModel.fetchAllParticipantsInfo() - setExpanded(false) - Toast.makeText(context, "Participant removed", Toast.LENGTH_LONG).show() - }) - } + } + } } } } + } + } - if (expandedRoleDialog) { - Dialog(onDismissRequest = { setExpandedRoleDialog(false) }) { - Box( - Modifier.fillMaxWidth(1f) - .height(250.dp) - .background(MaterialTheme.colorScheme.surface) - .testTag("roleDialogBox")) { - Column( - modifier = - Modifier.fillMaxSize().padding(16.dp).testTag("roleDialogColumn"), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center) { - ChangeRoleDialog(selectedTravel, participant) { newRole -> - handleRoleChange( - context, - selectedTravel, - participant, - newRole, - listTravelViewModel, - notificationViewModel, - profileViewModel, - setExpandedRoleDialog, - setExpanded) - } + selectedParticipant?.let { participant -> + if (expanded) { + Dialog(onDismissRequest = { setExpanded(false) }) { + Box( + Modifier.fillMaxWidth(1f) + .height(250.dp) + .background(MaterialTheme.colorScheme.surface) + .testTag("participantDialogBox")) { + Column(modifier = Modifier.padding(8.dp).testTag("participantDialogColumn")) { + Row( + modifier = Modifier.testTag("participantDialogRow"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = "Localized description", + modifier = Modifier.padding(5.dp).testTag("participantDialogIcon")) + TruncatedText( + text = participant.value.name, + fontWeight = FontWeight.Bold, + maxLength = 25, + modifier = Modifier.padding(5.dp).testTag("participantDialogName")) + } + TruncatedText( + text = participant.value.email, + maxLength = 30, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(5.dp).testTag("participantDialogEmail")) + + RoleEntryDialog( + selectedTravel = selectedTravel, + participant = participant, + changeRoleAction = setExpandedRoleDialog, + removeParticipantAction = { + if (selectedTravel!!.allParticipants[Participant(participant.key)] == + Role.OWNER) { + if (selectedTravel!!.allParticipants.values.count({ it == Role.OWNER }) == + 1) { + Toast.makeText( + context, + "You're trying to remove the owner of the travel. Please name another owner before removing.", + Toast.LENGTH_LONG) + .show() + setExpanded(false) + return@RoleEntryDialog } + } + val participantMap = selectedTravel!!.allParticipants.toMutableMap() + participantMap.remove(Participant(participant.key)) + val participantList = selectedTravel!!.listParticipant.toMutableList() + participantList.remove(participant.key) + val updatedContainer = + selectedTravel!!.copy( + allParticipants = participantMap.toMap(), + listParticipant = participantList) + listTravelViewModel.updateTravel( + updatedContainer, + TravelRepository.UpdateMode.REMOVE_PARTICIPANT, + participant.key, eventViewModel.getNewDocumentReference()) + listTravelViewModel.selectTravel(updatedContainer) + listTravelViewModel.fetchAllParticipantsInfo() + setExpanded(false) + Toast.makeText(context, "Participant removed", Toast.LENGTH_LONG).show() + }) + } + } + } + } + + if (expandedRoleDialog) { + Dialog(onDismissRequest = { setExpandedRoleDialog(false) }) { + Box( + Modifier.fillMaxWidth(1f) + .height(250.dp) + .background(MaterialTheme.colorScheme.surface) + .testTag("roleDialogBox")) { + Column( + modifier = Modifier.fillMaxSize().padding(16.dp).testTag("roleDialogColumn"), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center) { + ChangeRoleDialog(selectedTravel, participant) { newRole -> + handleRoleChange( + context, + selectedTravel, + participant, + newRole, + listTravelViewModel, + notificationViewModel, + profileViewModel, + setExpandedRoleDialog, + setExpanded) + } } } - } - } } + } + } +} + +/** + * Invites a user to the selected travel using their fsUid. + * + * @param selectedTravel The currently selected travel container. + * @param fsUid The fsUid of the user to be invited. + * @param profileViewModel The ProfileModelView instance. + * @param context The context in which the function is called. + * @param notificationViewModel The NotificationViewModel instance. + */ +private fun inviteUserToTravelViaFsuid( + selectedTravel: TravelContainer?, + fsUid: String?, + profileViewModel: ProfileModelView, + context: Context, + notificationViewModel: NotificationViewModel, +) { + val isUserAlreadyAdded = selectedTravel!!.allParticipants.keys.any { it.fsUid == fsUid } + if (fsUid == profileViewModel.profile.value.fsUid) { + Toast.makeText(context, "Error: You can't invite yourself", Toast.LENGTH_SHORT).show() + } else if (isUserAlreadyAdded) { + Toast.makeText(context, "Error: User already added", Toast.LENGTH_SHORT).show() + } else if (fsUid != null) { + try { + notificationViewModel.sendNotification( + Notification( + notificationViewModel.getNewUid(), + profileViewModel.profile.value.fsUid, + fsUid, + selectedTravel!!.fsUid, + NotificationContent.InvitationNotification( + profileViewModel.profile.value.name, selectedTravel!!.title, Role.PARTICIPANT), + notificationType = NotificationType.INVITATION, + sector = NotificationSector.TRAVEL)) + Toast.makeText(context, "Invitation sent", Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Log.e("NotificationError", "Failed to send notification: ${e.message}") + } + } else { + Toast.makeText(context, "Error: User with email not found", Toast.LENGTH_SHORT).show() + } } fun handleRoleChange(