diff --git a/app/src/main/java/com/github/swent/echo/compose/authentication/CreateProfile.kt b/app/src/main/java/com/github/swent/echo/compose/authentication/CreateProfile.kt index 2e5389e60..0a03949b8 100644 --- a/app/src/main/java/com/github/swent/echo/compose/authentication/CreateProfile.kt +++ b/app/src/main/java/com/github/swent/echo/compose/authentication/CreateProfile.kt @@ -1,21 +1,35 @@ package com.github.swent.echo.compose.authentication import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia.Companion.isPhotoPickerAvailable +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.rememberTransformableState +import androidx.compose.foundation.gestures.transformable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -23,9 +37,13 @@ import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material3.Card import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.InputChip @@ -48,17 +66,28 @@ 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 import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.toSize +import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.PopupProperties +import androidx.core.graphics.scale import com.github.swent.echo.R import com.github.swent.echo.compose.components.TagSelectionDialog import com.github.swent.echo.data.model.Section @@ -70,6 +99,7 @@ import com.github.swent.echo.ui.navigation.NavigationActions import com.github.swent.echo.ui.navigation.Routes import com.github.swent.echo.viewmodels.authentication.CreateProfileViewModel import com.github.swent.echo.viewmodels.tag.TagViewModel +import kotlin.math.min import kotlinx.coroutines.launch /** @@ -94,6 +124,7 @@ fun ProfileCreationScreen( val sectionSelected by viewModel.selectedSection.collectAsState() val isEditing by viewModel.isEditing.collectAsState() val isOnline by viewModel.isOnline.collectAsState() + val picture by viewModel.picture.collectAsState() ProfileCreationUI( modifier = modifier, @@ -119,7 +150,9 @@ fun ProfileCreationScreen( onFirstNameChange = viewModel::setFirstName, onLastNameChange = viewModel::setLastName, isEditing = isEditing, - isOnline = isOnline + isOnline = isOnline, + picture = picture, + onPictureChange = viewModel::setPicture ) if (dialogVisible) { @@ -161,10 +194,15 @@ fun ProfileCreationUI( onFirstNameChange: (String) -> Unit, onLastNameChange: (String) -> Unit, isEditing: Boolean, - isOnline: Boolean + isOnline: Boolean, + picture: Bitmap?, + onPictureChange: (newPicture: Bitmap?) -> Unit ) { val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } + val spacerHeight = 5.dp + val spaceBetweenTags = 5.dp + val contentPadding = 16.dp Scaffold( topBar = { @@ -192,34 +230,51 @@ fun ProfileCreationUI( ) { Column( modifier = - modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState()) + modifier + .fillMaxSize() + .padding(contentPadding) + .verticalScroll(rememberScrollState()) ) { - // First name and last name fields - OutlinedTextField( - value = firstName, - onValueChange = { onFirstNameChange(it) }, - modifier = modifier.fillMaxWidth().testTag("FirstName"), - label = { - Text(text = stringResource(id = R.string.profile_creation_first_name)) - }, - singleLine = true, - isError = firstName.isBlank() - ) + Row { + Column { + // First name and last name fields + OutlinedTextField( + value = firstName, + onValueChange = { onFirstNameChange(it) }, + modifier = + modifier + // .fillMaxWidth() + .testTag("FirstName"), + label = { + Text( + text = stringResource(id = R.string.profile_creation_first_name) + ) + }, + singleLine = true, + isError = firstName.isBlank() + ) - Spacer(modifier = modifier.height(5.dp)) - - OutlinedTextField( - value = lastName, - onValueChange = { onLastNameChange(it) }, - modifier = modifier.fillMaxWidth().testTag("LastName"), - label = { - Text(text = stringResource(id = R.string.profile_creation_last_name)) - }, - singleLine = true, - isError = lastName.isBlank() - ) + Spacer(modifier = modifier.height(spacerHeight)) - Spacer(modifier = modifier.height(5.dp)) + OutlinedTextField( + value = lastName, + onValueChange = { onLastNameChange(it) }, + modifier = + modifier + // .fillMaxWidth() + .testTag("LastName"), + label = { + Text( + text = stringResource(id = R.string.profile_creation_last_name) + ) + }, + singleLine = true, + isError = lastName.isBlank() + ) + } + ProfilePictureEdit(picture, onPictureChange) + } + Spacer(modifier = modifier.height(spacerHeight)) // Section and semester dropdown menus DropDownListFunctionWrapper( @@ -228,7 +283,7 @@ fun ProfileCreationUI( selectedSec ?: "", onSecChange ) - Spacer(modifier = modifier.height(5.dp)) + Spacer(modifier = modifier.height(spacerHeight)) DropDownListFunctionWrapper( semList, R.string.profile_creation_semester, @@ -236,7 +291,7 @@ fun ProfileCreationUI( onSemChange ) - Spacer(modifier = modifier.height(10.dp)) + Spacer(modifier = modifier.height(spacerHeight.times(2))) // Tags Text( @@ -245,9 +300,9 @@ fun ProfileCreationUI( fontWeight = FontWeight.Bold ) - Spacer(modifier = modifier.height(10.dp)) + Spacer(modifier = modifier.height(spacerHeight.times(2))) - FlowRow(horizontalArrangement = Arrangement.spacedBy(5.dp)) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(spaceBetweenTags)) { for (tag in tagList) { InputChipFun(tag.name) { tagDelete(tag) } } @@ -376,3 +431,190 @@ fun InputChipFun( } ) } + +/** + * Profile picture composable with edition and deletion + * + * @param picture: the picture to display + * @param onPictureChange: callback called when the picture change + */ +@Composable +fun ProfilePictureEdit(picture: Bitmap?, onPictureChange: (newPicture: Bitmap?) -> Unit) { + var showPictureDialog by remember { mutableStateOf(false) } + val localContext = LocalContext.current + if (!isPhotoPickerAvailable(localContext)) { + Log.e("CreateProfile", "Photo picker not available") + } + var rawPicture by remember { mutableStateOf(null) } + val pickPhotoActivity = + rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri == null) { + Log.w("photo picker", "photo not found") + } else { + rawPicture = + BitmapFactory.decodeStream(localContext.contentResolver.openInputStream(uri)) + if (rawPicture == null) { + Log.w("photo picker", "cannot read the file") + } else { + showPictureDialog = true + } + } + } + val pictureDisplaySize = 100.dp + val pictureStartPadding = 5.dp + val pictureAlpha = 0.5f + val deleteButtonOffset = 10.dp + Column(modifier = Modifier.padding(start = pictureStartPadding)) { + Box { + Image( + modifier = + Modifier.size(pictureDisplaySize) + .clip(CircleShape) + .clickable { + pickPhotoActivity.launch( + PickVisualMediaRequest( + ActivityResultContracts.PickVisualMedia.ImageOnly + ) + ) + } + .testTag("profile-picture-image"), + painter = + if (picture != null) { + BitmapPainter(picture!!.asImageBitmap()) + } else { + painterResource(R.drawable.echologoround) + }, + contentDescription = "", + alpha = pictureAlpha + ) + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = stringResource(R.string.profile_creation_edit_picture), + modifier = Modifier.align(Alignment.Center) + ) + } + IconButton( + modifier = + Modifier.align(Alignment.End) + .offset(x = deleteButtonOffset, y = -deleteButtonOffset) + .testTag("profile-picture-delete"), + onClick = { onPictureChange(null) } + ) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.profile_creation_delete_picture), + ) + } + } + + if (showPictureDialog) { + Dialog(onDismissRequest = { showPictureDialog = false }) { + PictureTransformer( + picture = rawPicture!!, + onCancel = { showPictureDialog = false }, + onConfirm = { newPicture -> + showPictureDialog = false + onPictureChange(newPicture) + } + ) + } + } +} + +/** + * Picture edition dialog which allow the user to center the picture + * + * @param picture: the picture to center + * @param onConfirm: callback called when the picture is centered + * @param onCancel: callback when the action is canceled + */ +@Composable +fun PictureTransformer( + picture: Bitmap, + onConfirm: (picture: Bitmap) -> Unit, + onCancel: () -> Unit +) { + val screenWidth = LocalConfiguration.current.screenWidthDp + val screenPicture = picture.scale(screenWidth, picture.height * screenWidth / picture.width) + val circleRadius = min(screenPicture.width, screenPicture.height) * 1 / 2 + val minMaxScale = Pair(1f, 3f) + val maxResolution = 500 // the picture is cropped to output a square + var scale by remember { mutableStateOf(1f) } + val centerTextPadding = 5.dp + val centerTextCardOffset = 20.dp + val buttonsVerticalPadding = 15.dp + + val state = rememberTransformableState { zoomChange, _, _ -> + val newScale = scale * zoomChange + if (newScale > minMaxScale.first && newScale < minMaxScale.second) { + scale = newScale + } + } + Box(modifier = Modifier.fillMaxSize().testTag("profile-picture-transformer")) { + Image( + modifier = + Modifier.fillMaxSize() + .graphicsLayer( + scaleX = scale, + scaleY = scale, + rotationZ = 0f, + translationX = 0f, + translationY = 0f + ) + .transformable(state = state) + .testTag("profile-picture-image"), + painter = BitmapPainter(screenPicture.asImageBitmap()), + contentDescription = "" + ) + val strokeSize = 10f + Canvas(modifier = Modifier.fillMaxSize()) { + drawCircle( + color = Color.Black, + radius = (circleRadius * 2).toFloat(), + style = Stroke(strokeSize) + ) + } + Card(modifier = Modifier.align(Alignment.TopCenter).offset(y = centerTextCardOffset)) { + Text( + modifier = Modifier.padding(centerTextPadding), + text = stringResource(R.string.profile_creation_center_picture), + style = MaterialTheme.typography.titleLarge + ) + } + Row( + modifier = + Modifier.align(Alignment.BottomEnd).padding(vertical = buttonsVerticalPadding) + ) { + val button_Padding = 5.dp + FilledTonalButton( + modifier = + Modifier.padding(button_Padding).testTag("profile-picture-transformer-cancel"), + onClick = onCancel + ) { + Text(stringResource(R.string.edit_event_screen_cancel)) + } + FilledTonalButton( + modifier = + Modifier.padding(button_Padding).testTag("profile-picture-transformer-confirm"), + onClick = { + val pictureCenter = Pair((screenPicture.width / 2), (screenPicture.height / 2)) + val scaledCircleRadius = (circleRadius / scale).toInt() + var newPicture = + Bitmap.createBitmap( + screenPicture, + pictureCenter.first - scaledCircleRadius, + pictureCenter.second - scaledCircleRadius, + 2 * scaledCircleRadius, + 2 * scaledCircleRadius + ) + // reduce resolution + newPicture = + Bitmap.createScaledBitmap(newPicture, maxResolution, maxResolution, true) + onConfirm(newPicture) + } + ) { + Text(stringResource(R.string.edit_event_screen_confirm)) + } + } + } +} diff --git a/app/src/main/java/com/github/swent/echo/compose/components/HamburgerMenuDrawerSheet.kt b/app/src/main/java/com/github/swent/echo/compose/components/HamburgerMenuDrawerSheet.kt index b6bee76bb..b23ea4e32 100644 --- a/app/src/main/java/com/github/swent/echo/compose/components/HamburgerMenuDrawerSheet.kt +++ b/app/src/main/java/com/github/swent/echo/compose/components/HamburgerMenuDrawerSheet.kt @@ -1,5 +1,6 @@ package com.github.swent.echo.compose.components +import android.graphics.Bitmap import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -8,7 +9,9 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AddCircle import androidx.compose.material.icons.filled.Close @@ -26,6 +29,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -48,6 +54,7 @@ fun HamburgerMenuDrawerSheet( scope: CoroutineScope, profileName: String, profileClass: String, + profilePicture: Bitmap? = null, onSignOutPressed: () -> Unit, onToggle: () -> Unit ) { @@ -109,8 +116,13 @@ fun HamburgerMenuDrawerSheet( modifier = Modifier.align(Alignment.TopStart).padding(8.dp).testTag("profile_sheet") ) { Image( - modifier = Modifier.testTag("profile_picture"), - painter = painterResource(id = R.drawable.ic_launcher_foreground), + modifier = Modifier.testTag("profile_picture").size(150.dp).clip(CircleShape), + painter = + if (profilePicture != null) { + BitmapPainter(profilePicture!!.asImageBitmap()) + } else { + painterResource(R.drawable.echologoround) + }, contentDescription = "profile picture" ) Row(modifier = Modifier.padding(8.dp).testTag("profile_info")) { @@ -127,20 +139,11 @@ fun HamburgerMenuDrawerSheet( ) } } - // Close button for the hamburger menu - IconButton( - onClick = { scope.launch { drawerState.close() } }, - modifier = - Modifier.align(Alignment.TopEnd) - .padding(8.dp) - .testTag("close_button_hamburger_menu") - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = "Close button hamburger menu" - ) + + // button to toggle the theme + Box(modifier = Modifier.align(Alignment.TopEnd).padding(8.dp)) { + ThemeToggleButton(onToggle = onToggle) } - ThemeToggleButton(onToggle = onToggle) } // Display the navigation items items.forEachIndexed { index, item -> diff --git a/app/src/main/java/com/github/swent/echo/compose/components/HomeScreen.kt b/app/src/main/java/com/github/swent/echo/compose/components/HomeScreen.kt index 2b49062e5..fd457ea38 100644 --- a/app/src/main/java/com/github/swent/echo/compose/components/HomeScreen.kt +++ b/app/src/main/java/com/github/swent/echo/compose/components/HomeScreen.kt @@ -48,6 +48,7 @@ fun HomeScreen( // Profile information for the hamburger menu val profileName by homeScreenViewModel.profileName.collectAsState() val profileClass by homeScreenViewModel.profileClass.collectAsState() + val profilePicture by homeScreenViewModel.profilePicture.collectAsState() // Search mode for displaying events val searchMode by homeScreenViewModel.searchMode.collectAsState() @@ -62,6 +63,7 @@ fun HomeScreen( scope, profileName, profileClass, + profilePicture, { homeScreenViewModel.signOut() navActions.navigateTo(Routes.LOGIN) diff --git a/app/src/main/java/com/github/swent/echo/viewmodels/HomeScreenViewModel.kt b/app/src/main/java/com/github/swent/echo/viewmodels/HomeScreenViewModel.kt index 4bf615342..ac1c60339 100644 --- a/app/src/main/java/com/github/swent/echo/viewmodels/HomeScreenViewModel.kt +++ b/app/src/main/java/com/github/swent/echo/viewmodels/HomeScreenViewModel.kt @@ -1,5 +1,7 @@ package com.github.swent.echo.viewmodels +import android.graphics.Bitmap +import android.graphics.BitmapFactory import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.swent.echo.R @@ -144,6 +146,8 @@ constructor( val initialPage = _initialPage.asStateFlow() // Flow to observe the network status val isOnline = networkService.isOnline + private val _profilePicture = MutableStateFlow(null) + val profilePicture = _profilePicture.asStateFlow() // Flow to observe the followed associations private val _followedAssociations = MutableStateFlow>(listOf()) val followedAssociations = _followedAssociations.asStateFlow() @@ -169,6 +173,11 @@ constructor( followedTagFilter = getTagsAndSubTags(_followedTags.value).toList() sectionTags = repository.getSubTags(sectionTagId) semesterTags = repository.getSubTags(semesterTagId) + val pictureByteArray = repository.getUserProfilePicture(userId) + if (pictureByteArray != null) { + _profilePicture.value = + BitmapFactory.decodeByteArray(pictureByteArray, 0, pictureByteArray.size) + } val followedAssociationIds = repository.getUserProfile(userId)?.associationsSubscriptions?.map { it.associationId diff --git a/app/src/main/java/com/github/swent/echo/viewmodels/authentication/CreateProfileViewModel.kt b/app/src/main/java/com/github/swent/echo/viewmodels/authentication/CreateProfileViewModel.kt index 14343a296..51a46b20d 100644 --- a/app/src/main/java/com/github/swent/echo/viewmodels/authentication/CreateProfileViewModel.kt +++ b/app/src/main/java/com/github/swent/echo/viewmodels/authentication/CreateProfileViewModel.kt @@ -1,5 +1,7 @@ package com.github.swent.echo.viewmodels.authentication +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -12,6 +14,7 @@ import com.github.swent.echo.data.model.Tag import com.github.swent.echo.data.model.UserProfile import com.github.swent.echo.data.repository.Repository import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.ByteArrayOutputStream import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -56,6 +59,9 @@ constructor( private val _errorMessage = MutableStateFlow(null) val errorMessage = _errorMessage.asStateFlow() + private val _picture = MutableStateFlow(null) + val picture = _picture.asStateFlow() + // functions to change the name of the private variables from outside the class. (setters) fun setFirstName(firstName: String) { @@ -94,6 +100,15 @@ constructor( _tagList.value = userProfile.tags _committeeMember.value = userProfile.committeeMember _associationSubscriptions.value = userProfile.associationsSubscriptions + val pictureByteArray = repository.getUserProfilePicture(userId) + if (pictureByteArray != null) { + _picture.value = + BitmapFactory.decodeByteArray( + pictureByteArray, + 0, + pictureByteArray.size + ) + } } else { _isEditing.value = false } @@ -124,6 +139,13 @@ constructor( associationsSubscriptions = _associationSubscriptions.value ) ) + if (picture.value == null) { + repository.deleteUserProfilePicture(userId) + } else { + val pictureStream = ByteArrayOutputStream() + picture.value!!.compress(Bitmap.CompressFormat.JPEG, 100, pictureStream) + repository.setUserProfilePicture(userId, pictureStream.toByteArray()) + } } } } @@ -136,4 +158,8 @@ constructor( fun removeTag(tag: Tag) { _tagList.value -= tag } + + fun setPicture(picture: Bitmap?) { + _picture.value = picture + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4c4bd4dd4..22cda27e4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -84,6 +84,9 @@ Section Semester Tags + Edit picture + Delete picture + Adjust the zoom Event successfully joined! Modify Event Event Status diff --git a/app/src/test/java/com/github/swent/echo/compose/authentication/CreateProfileTest.kt b/app/src/test/java/com/github/swent/echo/compose/authentication/CreateProfileTest.kt index 8a492836d..4c6e5791f 100644 --- a/app/src/test/java/com/github/swent/echo/compose/authentication/CreateProfileTest.kt +++ b/app/src/test/java/com/github/swent/echo/compose/authentication/CreateProfileTest.kt @@ -1,14 +1,33 @@ package com.github.swent.echo.compose.authentication +import android.graphics.Bitmap +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertPositionInRootIsEqualTo import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipe +import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.swent.echo.authentication.AuthenticationService +import com.github.swent.echo.connectivity.NetworkService import com.github.swent.echo.data.model.SectionEPFL import com.github.swent.echo.data.model.SemesterEPFL import com.github.swent.echo.data.model.Tag +import com.github.swent.echo.data.repository.SimpleRepository +import com.github.swent.echo.viewmodels.authentication.CreateProfileViewModel +import io.mockk.every import io.mockk.mockk +import java.io.ByteArrayOutputStream +import junit.framework.TestCase +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -39,7 +58,9 @@ class CreateProfileTest { onSecChange = {}, onSemChange = {}, isEditing = true, - isOnline = true + isOnline = true, + picture = null, + onPictureChange = {} ) } // Assert that certain elements are present on the screen @@ -62,4 +83,79 @@ class CreateProfileTest { composeTestRule.onNodeWithTag("BA1").assertExists() composeTestRule.onNodeWithTag("BA2").assertExists() } + + @Test + fun profileCreationPictureIsCorrect() { + val picture = Bitmap.createBitmap(1000, 1000, Bitmap.Config.ARGB_8888) + var changedPicture: Bitmap? = picture + composeTestRule.setContent { + ProfilePictureEdit(picture = picture, onPictureChange = { changedPicture = it }) + } + composeTestRule + .onNodeWithTag("profile-picture-image") + .assertIsDisplayed() + .assertHasClickAction() + composeTestRule + .onNodeWithTag("profile-picture-delete") + .assertIsDisplayed() + .assertHasClickAction() + composeTestRule.onNodeWithTag("profile-picture-delete").performClick() + assertNull(changedPicture) + composeTestRule.onNodeWithTag("profile-picture-image").performClick() + } + + @Test + fun profileCreationPictureTransformerCallbacksTest() { + val picture = Bitmap.createBitmap(300, 1000, Bitmap.Config.ARGB_8888) + var changedPicture: Bitmap = picture + var canceled = false + composeTestRule.setContent { + PictureTransformer( + picture = picture, + onConfirm = { changedPicture = it }, + onCancel = { canceled = true } + ) + } + composeTestRule.onNodeWithTag("profile-picture-transformer").assertIsDisplayed() + composeTestRule.onNodeWithTag("profile-picture-transformer-confirm").performClick() + assertTrue(changedPicture != picture) + assertTrue(changedPicture.height == changedPicture.width) + composeTestRule.onNodeWithTag("profile-picture-transformer-cancel").performClick() + assertTrue(canceled) + } + + @Test + fun profileCreationPictureTransformerGestureTest() { + val picture = Bitmap.createBitmap(300, 1000, Bitmap.Config.ARGB_8888) + var changedPicture: Bitmap = picture + composeTestRule.setContent { + PictureTransformer( + picture = picture, + onConfirm = { changedPicture = it }, + onCancel = {} + ) + } + val image = composeTestRule.onNodeWithTag("profile-picture-image") + image.assertIsDisplayed() + image.performTouchInput { this.swipe(Offset.Zero, Offset(50f, 50f)) } + image.assertPositionInRootIsEqualTo(0.dp, 0.dp) + } + + @Test + fun setProfilePictureViewModelWithBitmapTest() { + val authenticationService: AuthenticationService = mockk(relaxed = true) + val repository = SimpleRepository(authenticationService) + val mockedNetworkService = mockk() + every { mockedNetworkService.isOnline } returns MutableStateFlow(true) + var viewModel = + CreateProfileViewModel(authenticationService, repository, mockedNetworkService) + val picture = Bitmap.createBitmap(300, 300, Bitmap.Config.ARGB_8888) + viewModel.setPicture(picture) + TestCase.assertEquals(picture, viewModel.picture.value) + val outputStream = ByteArrayOutputStream() + picture.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) + runBlocking { repository.setUserProfilePicture("", outputStream.toByteArray()) } + viewModel = CreateProfileViewModel(authenticationService, repository, mockedNetworkService) + TestCase.assertEquals(picture.byteCount, viewModel.picture.value?.byteCount) + } } diff --git a/app/src/test/java/com/github/swent/echo/compose/components/HomeScreenTest.kt b/app/src/test/java/com/github/swent/echo/compose/components/HomeScreenTest.kt index 63c50a22d..b535c5de6 100644 --- a/app/src/test/java/com/github/swent/echo/compose/components/HomeScreenTest.kt +++ b/app/src/test/java/com/github/swent/echo/compose/components/HomeScreenTest.kt @@ -131,12 +131,6 @@ class HomeScreenTest { composeTestRule.onNodeWithTag("profile_class").assertExists() } - @Test - fun shouldShowCloseButtonWhenMenuButtonClicked() { - composeTestRule.onNodeWithTag("menu_button").performClick() - composeTestRule.onNodeWithTag("close_button_hamburger_menu").assertExists() - } - // Change number of items to check according to the number of button in hamburger menu @Test diff --git a/app/src/test/java/com/github/swent/echo/viewmodels/HomeScreenViewModelTest.kt b/app/src/test/java/com/github/swent/echo/viewmodels/HomeScreenViewModelTest.kt index 2aa75e03a..2dcbc2860 100644 --- a/app/src/test/java/com/github/swent/echo/viewmodels/HomeScreenViewModelTest.kt +++ b/app/src/test/java/com/github/swent/echo/viewmodels/HomeScreenViewModelTest.kt @@ -1,5 +1,6 @@ package com.github.swent.echo.viewmodels +import android.graphics.BitmapFactory import com.github.swent.echo.compose.map.MAP_CENTER import com.github.swent.echo.connectivity.NetworkService import com.github.swent.echo.data.model.AssociationHeader @@ -14,6 +15,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic import java.time.ZonedDateTime import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -73,7 +75,10 @@ class HomeScreenViewModelTest { coEvery { mockedRepository.getAllEvents() } returns eventList coEvery { mockedRepository.getAllTags() } returns tagSet coEvery { mockedRepository.getUserProfile("u0") } returns userProfile + coEvery { mockedRepository.getUserProfilePicture("u0") } returns null every { mockedNetworkService.isOnline } returns isOnline + mockkStatic(BitmapFactory::class) + every { BitmapFactory.decodeStream(any()) } returns null runBlocking { homeScreenViewModel = HomeScreenViewModel( diff --git a/app/src/test/java/com/github/swent/echo/viewmodels/authentication/CreateProfileViewModelTest.kt b/app/src/test/java/com/github/swent/echo/viewmodels/authentication/CreateProfileViewModelTest.kt index 8534a863f..4a8518d8a 100644 --- a/app/src/test/java/com/github/swent/echo/viewmodels/authentication/CreateProfileViewModelTest.kt +++ b/app/src/test/java/com/github/swent/echo/viewmodels/authentication/CreateProfileViewModelTest.kt @@ -13,6 +13,7 @@ import io.mockk.every import io.mockk.mockk import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertNull import junit.framework.TestCase.assertTrue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -37,6 +38,7 @@ class CreateProfileViewModelTest { Dispatchers.setMain(UnconfinedTestDispatcher()) authenticationService.userID = "test_user_id" every { mockedNetworkService.isOnline } returns MutableStateFlow(true) + coEvery { repository.getUserProfilePicture(any()) } returns null viewModel = CreateProfileViewModel(authenticationService, repository, mockedNetworkService) } @@ -127,4 +129,10 @@ class CreateProfileViewModelTest { val tagList1 = viewModel.tagList.value assertFalse(tagList1.contains(tag)) } + + @Test + fun setNullProfilePictureTest() { + viewModel.setPicture(null) + assertNull(viewModel.picture.value) + } }