diff --git a/app/src/androidTest/java/com/android/unio/components/authentication/PictureSelectionToolTest.kt b/app/src/androidTest/java/com/android/unio/components/authentication/PictureSelectionToolTest.kt new file mode 100644 index 000000000..4b1112ea4 --- /dev/null +++ b/app/src/androidTest/java/com/android/unio/components/authentication/PictureSelectionToolTest.kt @@ -0,0 +1,82 @@ +package com.android.unio.components.authentication + +import android.net.Uri +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.filters.LargeTest +import com.android.unio.TearDown +import com.android.unio.model.strings.test_tags.PictureSelectionToolTestTags +import com.android.unio.ui.components.PictureSelectionTool +import com.google.firebase.Firebase +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.auth +import com.google.firebase.auth.internal.zzac +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.mockkStatic +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@LargeTest +@HiltAndroidTest +class PictureSelectionToolTest : TearDown() { + + @MockK private lateinit var firebaseAuth: FirebaseAuth + @MockK private lateinit var mockFirebaseUser: zzac + + @get:Rule val composeTestRule = createComposeRule() + @get:Rule val hiltRule = HiltAndroidRule(this) + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + + // Mocking Firebase.auth and its behavior + mockkStatic(FirebaseAuth::class) + every { Firebase.auth } returns firebaseAuth + every { firebaseAuth.currentUser } returns mockFirebaseUser + every { mockFirebaseUser.uid } returns "mocked-uid" + } + + @Test + fun testInitialUIState() { + composeTestRule.setContent { + PictureSelectionTool( + maxPictures = 3, + allowGallery = true, + allowCamera = true, + onValidate = {}, + onCancel = {}, + initialSelectedPictures = emptyList()) + } + // Verify that initial UI elements are displayed + composeTestRule.onNodeWithTag(PictureSelectionToolTestTags.GALLERY_ADD).assertIsDisplayed() + composeTestRule.onNodeWithTag(PictureSelectionToolTestTags.CAMERA_ADD).assertIsDisplayed() + composeTestRule.onNodeWithTag(PictureSelectionToolTestTags.CANCEL_BUTTON).assertIsDisplayed() + } + + @Test + fun testHavingPictures() { + val mockUri1 = mockk(relaxed = true) + + composeTestRule.setContent { + PictureSelectionTool( + maxPictures = 3, + allowGallery = true, + allowCamera = true, + onValidate = {}, + onCancel = {}, + initialSelectedPictures = listOf(mockUri1)) + } + // Verify that the selected pictures are displayed + composeTestRule.onNodeWithTag(PictureSelectionToolTestTags.SELECTED_PICTURE).assertIsDisplayed() + // Ensure that the Validate button is now visible + composeTestRule.onNodeWithTag(PictureSelectionToolTestTags.VALIDATE_BUTTON).assertIsDisplayed() + } +} diff --git a/app/src/androidTest/java/com/android/unio/end2end/UserAccountCreationTest.kt b/app/src/androidTest/java/com/android/unio/end2end/UserAccountCreationTest.kt index 71cb58e22..5419fbae6 100644 --- a/app/src/androidTest/java/com/android/unio/end2end/UserAccountCreationTest.kt +++ b/app/src/androidTest/java/com/android/unio/end2end/UserAccountCreationTest.kt @@ -85,6 +85,9 @@ class UserAccountCreationTest : EndToEndTest() { composeTestRule.onNodeWithTag(InterestsOverlayTestTags.SAVE_BUTTON).assertIsDisplayed() composeTestRule.onNodeWithTag(InterestsOverlayTestTags.SAVE_BUTTON).performClick() + composeTestRule.onNodeWithTag(AccountDetailsTestTags.PROFILE_PICTURE_ICON).assertIsDisplayed() + composeTestRule.onNodeWithTag(AccountDetailsTestTags.PROFILE_PICTURE_ICON).performClick() + composeTestRule .onNodeWithTag(AccountDetailsTestTags.CONTINUE_BUTTON) .assertDisplayComponentInScroll() diff --git a/app/src/main/java/com/android/unio/model/strings/test_tags/AuthenticationTestTags.kt b/app/src/main/java/com/android/unio/model/strings/test_tags/AuthenticationTestTags.kt index 9617c89fe..9e6401354 100644 --- a/app/src/main/java/com/android/unio/model/strings/test_tags/AuthenticationTestTags.kt +++ b/app/src/main/java/com/android/unio/model/strings/test_tags/AuthenticationTestTags.kt @@ -94,3 +94,14 @@ object WelcomeTestTags { const val BUTTON = "welcomeButton" const val FORGOT_PASSWORD = "welcomeForgotPassword" } + +object PictureSelectionToolTestTags { + const val SELECTED_PICTURE = "pictureSelectionToolSelectedPicture" + const val REMOVE_PICTURE = "pictureSelectionToolRemovePicture" + const val GALLERY_ADD = "pictureSelectionToolGalleryAdd" + const val CAMERA_ADD = "pictureSelectionToolCameraAdd" + const val VALIDATE_BUTTON = "pictureSelectionToolValidateButton" + const val CANCEL_BUTTON = "pictureSelectionToolCancelButton" + const val NEW_PROFILE_PICTURE = "new_profile_picture.jpg" + const val IMAGE_JPEG = "image/jpeg" +} diff --git a/app/src/main/java/com/android/unio/ui/components/PictureSelectionTool.kt b/app/src/main/java/com/android/unio/ui/components/PictureSelectionTool.kt new file mode 100644 index 000000000..c91d2b4c7 --- /dev/null +++ b/app/src/main/java/com/android/unio/ui/components/PictureSelectionTool.kt @@ -0,0 +1,186 @@ +package com.android.unio.ui.components + +import android.content.ContentValues +import android.net.Uri +import android.provider.MediaStore +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import coil.compose.rememberAsyncImagePainter +import com.android.unio.R +import com.android.unio.model.strings.test_tags.PictureSelectionToolTestTags + +/** + * A composable function to select and display pictures from the gallery or camera. It allows the + * user to add pictures, see selected pictures in a grid, and validate the selection. + * + * @param maxPictures The maximum number of pictures that can be selected. + * @param allowGallery Boolean to specify if selecting from the gallery is allowed. + * @param allowCamera Boolean to specify if taking pictures with the camera is allowed. + * @param onValidate Lambda to handle the selected pictures after validation. + * @param onCancel Lambda to handle the cancellation of the selection. + */ +@Composable +fun PictureSelectionTool( + maxPictures: Int, + allowGallery: Boolean, + allowCamera: Boolean, + onValidate: (List) -> Unit, + onCancel: () -> Unit, + initialSelectedPictures: List +) { + val context = LocalContext.current + + val selectedPictures = remember { mutableStateListOf() } + + // Initialize selectedPictures with initialSelectedPictures + LaunchedEffect(initialSelectedPictures) { + selectedPictures.clear() // Clear existing pictures, if any + selectedPictures.addAll(initialSelectedPictures) // Add the initial selected pictures + } + + // Launcher for selecting multiple images from the gallery + val pickMediaLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris -> + if (uris != null) { + selectedPictures.addAll(uris.take(maxPictures - selectedPictures.size)) + } + } + + val cameraImageUri = remember { mutableStateOf(null) } + // Launcher for taking a picture with the camera + val takePictureLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success -> + if (success && cameraImageUri.value != null) { + selectedPictures.add(cameraImageUri.value!!) + } + } + + /** + * Creates a temporary URI for storing an image taken by the camera. + * + * @return A URI pointing to the location where the new image will be stored. + */ + fun createImageUri(): Uri { + val resolver = context.contentResolver + val contentValues = + ContentValues().apply { + put( + MediaStore.Images.Media.DISPLAY_NAME, + PictureSelectionToolTestTags.NEW_PROFILE_PICTURE) + put(MediaStore.Images.Media.MIME_TYPE, PictureSelectionToolTestTags.IMAGE_JPEG) + } + return resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)!! + } + + // User interface for the picture selection tool + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(16.dp)) { + Text( + context.getString(R.string.selection_tool_selected_picture_text) + + ": ${selectedPictures.size}/$maxPictures") + + // Display selected pictures in a scrollable grid with a fixed number of columns + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + contentPadding = PaddingValues(4.dp)) { + items(selectedPictures.size) { index -> + val uri = selectedPictures[index] + Box(modifier = Modifier.size(100.dp).clip(CircleShape).padding(4.dp)) { + // Display the selected image + Image( + painter = rememberAsyncImagePainter(uri), + contentDescription = + context.getString(R.string.selection_tool_selected_picture_text), + contentScale = ContentScale.Crop, + modifier = + Modifier.fillMaxSize().testTag(PictureSelectionToolTestTags.SELECTED_PICTURE)) + Icon( + Icons.Default.Close, + contentDescription = context.getString(R.string.selection_tool_remove_picture), + modifier = + Modifier.align(Alignment.TopEnd) + .clickable { selectedPictures.remove(uri) } + .padding(4.dp) + .testTag(PictureSelectionToolTestTags.REMOVE_PICTURE)) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Buttons to add pictures from the gallery or camera + Row(horizontalArrangement = Arrangement.SpaceEvenly, modifier = Modifier.fillMaxWidth()) { + // Add picture from the gallery if allowed and the max limit isn't reached + if (allowGallery && selectedPictures.size < maxPictures) { + OutlinedButton( + onClick = { + pickMediaLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + }, + modifier = Modifier.testTag(PictureSelectionToolTestTags.GALLERY_ADD)) { + Icon( + Icons.Default.Add, + contentDescription = + context.getString(R.string.selection_tool_add_gallery_content_description)) + Text(context.getString(R.string.selection_tool_gallery_text)) + } + } + + // Take a picture with the camera if allowed and the max limit isn't reached + if (allowCamera && selectedPictures.size < maxPictures) { + OutlinedButton( + onClick = { + val uri = createImageUri() // Local stable reference + cameraImageUri.value = uri + takePictureLauncher.launch(uri) + }, + modifier = Modifier.testTag(PictureSelectionToolTestTags.CAMERA_ADD)) { + Icon( + Icons.Default.Add, + contentDescription = context.getString(R.string.selection_tool_take_picture)) + Text(context.getString(R.string.selection_tool_camera_text)) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Buttons for validating or canceling the picture selection + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + Button( + onClick = { onValidate(selectedPictures) }, + modifier = Modifier.testTag(PictureSelectionToolTestTags.VALIDATE_BUTTON)) { + Icon( + Icons.Default.Check, + contentDescription = context.getString(R.string.selection_tool_validate_text)) + Text(context.getString(R.string.selection_tool_validate_text)) + } + + OutlinedButton( + onClick = onCancel, + modifier = Modifier.testTag(PictureSelectionToolTestTags.CANCEL_BUTTON)) { + Text(context.getString(R.string.selection_tool_cancel_text)) + } + } + } +} diff --git a/app/src/main/java/com/android/unio/ui/components/UserEditionComponents.kt b/app/src/main/java/com/android/unio/ui/components/UserEditionComponents.kt index cdc097795..3d80617fa 100644 --- a/app/src/main/java/com/android/unio/ui/components/UserEditionComponents.kt +++ b/app/src/main/java/com/android/unio/ui/components/UserEditionComponents.kt @@ -1,9 +1,6 @@ package com.android.unio.ui.components import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -14,11 +11,19 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.rounded.AccountCircle +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.InputChip +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -33,6 +38,7 @@ import com.android.unio.model.user.Interest import com.android.unio.model.user.UserSocial import com.android.unio.ui.image.AsyncImageWrapper import com.android.unio.ui.theme.primaryLight +import kotlinx.coroutines.launch @Composable private fun ProfilePictureWithRemoveIcon( @@ -58,6 +64,7 @@ private fun ProfilePictureWithRemoveIcon( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ProfilePicturePicker( profilePictureUri: MutableState, @@ -65,29 +72,50 @@ fun ProfilePicturePicker( testTag: String ) { val context = LocalContext.current - val pickMedia = - rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> - if (uri != null) { - profilePictureUri.value = uri - } - } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + var showSheet by remember { mutableStateOf(false) } if (profilePictureUri.value == Uri.EMPTY) { Icon( imageVector = Icons.Rounded.AccountCircle, contentDescription = context.getString(R.string.account_details_content_description_add), tint = primaryLight, - modifier = - Modifier.clickable { - pickMedia.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) - } - .size(100.dp) - .testTag(testTag)) + modifier = Modifier.clickable { showSheet = true }.size(100.dp).testTag(testTag)) } else { ProfilePictureWithRemoveIcon( profilePictureUri = profilePictureUri.value, onRemove = onProfilePictureUriChange) } + + if (showSheet) { + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = { + scope.launch { + sheetState.hide() + showSheet = false + } + }, + content = { + PictureSelectionTool( + maxPictures = 1, + allowGallery = true, + allowCamera = true, + onValidate = { uris -> + if (uris.isNotEmpty()) { + profilePictureUri.value = + uris.first() // Use the first selected as the profile picture + } + scope.launch { sheetState.hide() } + showSheet = false + }, + onCancel = { + scope.launch { sheetState.hide() } + showSheet = false + }, + initialSelectedPictures = emptyList()) + }) + } } @Composable diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 7d1c1d5f4..b84620aef 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -142,6 +142,17 @@ Montrer le mot de passe Oubli de mot de passe ? + + Images sélectionnées + Supprimer l\'image + Ajouter depuis la galerie + Galerie + Prendre une photo + Appareil photo + Valider + Annuler + + /** * package: event */ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f7ec9ea77..d22734daf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -147,6 +147,16 @@ Show password Forgot your password ? + + Selected Pictures + Remove Picture + Add from Gallery + Gallery + Take Picture + Camera + Validate + Cancel + /** * package: event */