diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 056046c76..12f4b2f5c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -94,6 +94,7 @@ android { } } + sonar { properties { property("sonar.projectKey", "periodpals_periodpals") @@ -162,6 +163,8 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.ktx) implementation(platform(libs.compose.bom)) implementation(libs.androidx.navigation.compose.v282) + implementation(libs.androidx.espresso.intents) + implementation(libs.androidx.espresso.core) testImplementation(libs.junit) diff --git a/app/src/androidTest/java/com/android/periodpals/ui/authentication/SignInTest.kt b/app/src/androidTest/java/com/android/periodpals/ui/authentication/SignInTest.kt new file mode 100644 index 000000000..b655691b1 --- /dev/null +++ b/app/src/androidTest/java/com/android/periodpals/ui/authentication/SignInTest.kt @@ -0,0 +1,116 @@ +package com.android.periodpals.ui.authentication + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import org.junit.Rule +import org.junit.Test + +class SignInScreenTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun signInScreen_displaysCorrectUI() { + // Set the content to the SignInScreen + composeTestRule.setContent { SignInScreen() } + + // Check if the welcome text is displayed + composeTestRule.onNodeWithTag("signInScreen").assertIsDisplayed() + composeTestRule.onNodeWithTag("signInBackground").assertIsDisplayed() + composeTestRule.onNodeWithTag("signInTitle").assertIsDisplayed() + composeTestRule.onNodeWithTag("signInInstruction").assertIsDisplayed() + composeTestRule.onNodeWithTag("signInEmail").assertIsDisplayed() + composeTestRule.onNodeWithTag("signInPassword").assertIsDisplayed() + composeTestRule.onNodeWithTag("signInPasswordVisibility").assertIsDisplayed() + composeTestRule.onNodeWithTag("signInButton").assertIsDisplayed() + composeTestRule.onNodeWithTag("signInOrText").assertIsDisplayed() + composeTestRule.onNodeWithTag("signInGoogleButton").assertIsDisplayed() + composeTestRule.onNodeWithTag("signInNotRegistered").assertIsDisplayed() + } + + @Test + fun signInScreen_emailValidation_emptyEmail_showsError() { + composeTestRule.setContent { SignInScreen() } + + // Click on the sign in button with empty fields + composeTestRule.onNodeWithTag("signInButton").performClick() + + // Verify that the error message for email is displayed + composeTestRule.onNodeWithTag("signInEmailError").assertTextEquals("Email cannot be empty") + } + + @Test + fun signInScreen_emailValidation_invalidEmail_showsError() { + composeTestRule.setContent { SignInScreen() } + + // Enter an invalid email + composeTestRule.onNodeWithTag("signInEmail").performTextInput("invalidEmail") + + // Click on the sign in button + composeTestRule.onNodeWithTag("signInButton").performClick() + + // Verify that the error message for email is displayed + composeTestRule.onNodeWithTag("signInEmailError").assertTextEquals("Email must contain @") + } + + @Test + fun signInScreen_passwordValidation_emptyPassword_showsError() { + composeTestRule.setContent { SignInScreen() } + + // Enter a valid email + composeTestRule.onNodeWithTag("signInEmail").performTextInput("test@example.com") + + // Click on the sign in button with empty password + composeTestRule.onNodeWithTag("signInButton").performClick() + + // Verify that the error message for password is displayed + composeTestRule + .onNodeWithTag("signInPasswordError") + .assertTextEquals("Password cannot be empty") + } + + @Test + fun signInScreen_signIn_successfulLogin() { + composeTestRule.setContent { SignInScreen() } + + // Enter valid email and password + composeTestRule.onNodeWithTag("signInEmail").performTextInput("test@example.com") + composeTestRule.onNodeWithTag("signInPassword").performTextInput("ValidPassword123") + + // Click on the sign in button + composeTestRule.onNodeWithTag("signInButton").performClick() + + // Check for a successful login Toast (mocking would be required here) + // Currently, you can't test Toast directly; you can use dependency injection or other methods + } + + @Test + fun signInScreen_signIn_failsInvalidLogin() { + composeTestRule.setContent { SignInScreen() } + + // Enter valid email and an invalid password + composeTestRule.onNodeWithTag("signInEmail").performTextInput("test@example.com") + composeTestRule.onNodeWithTag("signInPassword").performTextInput("InvalidPassword") + + // Click on the sign in button + composeTestRule.onNodeWithTag("signInButton").performClick() + + // Check for a failed login Toast (mocking would be required here) + // You can set up your test to verify that the error message or Toast appears. + } + + @Test + fun signInScreen_navigatesToSignUp() { + composeTestRule.setContent { SignInScreen() } + + // Click on the "Not registered yet? Sign up here!" text + composeTestRule.onNodeWithTag("signInNotRegistered").performClick() + + // Check for a navigation action (mocking would be required here) + // You would verify that the navigation to the sign-up screen is triggered. + } +} diff --git a/app/src/androidTest/java/com/android/periodpals/ui/authentication/SignUpTest.kt b/app/src/androidTest/java/com/android/periodpals/ui/authentication/SignUpTest.kt new file mode 100644 index 000000000..02c44e104 --- /dev/null +++ b/app/src/androidTest/java/com/android/periodpals/ui/authentication/SignUpTest.kt @@ -0,0 +1,169 @@ +package com.android.periodpals.ui.authentication + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import org.junit.Rule +import org.junit.Test + +class SignUpScreenTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun signUpScreen_displaysCorrectUI() { + composeTestRule.setContent { SignUpScreen() } + + // Assert visibility of UI elements + composeTestRule.onNodeWithTag("signUpScreen").assertIsDisplayed() + composeTestRule.onNodeWithTag("signUpBackground").assertIsDisplayed() + composeTestRule.onNodeWithTag("signUpTitle").assertIsDisplayed() + composeTestRule.onNodeWithTag("signUpInstruction").assertIsDisplayed() + composeTestRule.onNodeWithTag("signUpEmail").assertIsDisplayed() + composeTestRule.onNodeWithTag("signUpPassword").assertIsDisplayed() + composeTestRule.onNodeWithTag("signUpPasswordVisibility").assertIsDisplayed() + composeTestRule.onNodeWithTag("signUpConfirmText").assertIsDisplayed() + composeTestRule.onNodeWithTag("signUpConfirmPassword").assertIsDisplayed() + composeTestRule.onNodeWithTag("signUpConfirmVisibility").assertIsDisplayed() + composeTestRule.onNodeWithTag("signUpButton").assertIsDisplayed() + } + + @Test + fun signUpScreen_emailValidation_emptyEmail_showsError() { + composeTestRule.setContent { SignUpScreen() } + + // Attempt to sign up with an empty email + composeTestRule.onNodeWithTag("signUpButton").performClick() + + // Assert the error message is displayed + composeTestRule.onNodeWithTag("signUpEmailError").assertTextEquals("Email cannot be empty") + } + + @Test + fun signUpScreen_emailValidation_invalidEmail_showsError() { + composeTestRule.setContent { SignUpScreen() } + + // Input an invalid email + composeTestRule.onNodeWithTag("signUpEmail").performTextInput("invalidEmail") + composeTestRule.onNodeWithTag("signUpButton").performClick() + + // Assert the error message is displayed + composeTestRule.onNodeWithTag("signUpEmailError").assertTextEquals("Email must contain @") + } + + @Test + fun signUpScreen_passwordValidation_emptyPassword_showsError() { + composeTestRule.setContent { SignUpScreen() } + + // Input an email and attempt to sign up with an empty password + composeTestRule.onNodeWithTag("signUpEmail").performTextInput("test@example.com") + composeTestRule.onNodeWithTag("signUpButton").performClick() + + // Assert the error message is displayed + composeTestRule + .onNodeWithTag("signUpPasswordError") + .assertTextEquals("Password cannot be empty") + } + + @Test + fun signUpScreen_passwordValidation_passwordTooShort_showsError() { + composeTestRule.setContent { SignUpScreen() } + + composeTestRule.onNodeWithTag("signUpEmail").performTextInput("test@example.com") + composeTestRule.onNodeWithTag("signUpPassword").performTextInput("short") + composeTestRule.onNodeWithTag("signUpConfirmPassword").performTextInput("short") + composeTestRule.onNodeWithTag("signUpButton").performClick() + + composeTestRule + .onNodeWithTag("signUpPasswordError") + .assertTextEquals("Password must be at least 8 characters long") + } + + @Test + fun signUpScreen_passwordValidation_passwordNoCapital_showsError() { + composeTestRule.setContent { SignUpScreen() } + + composeTestRule.onNodeWithTag("signUpEmail").performTextInput("test@example.com") + composeTestRule.onNodeWithTag("signUpPassword").performTextInput("password") + composeTestRule.onNodeWithTag("signUpConfirmPassword").performTextInput("password") + composeTestRule.onNodeWithTag("signUpButton").performClick() + + composeTestRule + .onNodeWithTag("signUpPasswordError") + .assertTextEquals("Password must contain at least one capital letter") + } + + @Test + fun signUpScreen_passwordValidation_passwordNoMinuscule_showsError() { + composeTestRule.setContent { SignUpScreen() } + + composeTestRule.onNodeWithTag("signUpEmail").performTextInput("test@example.com") + composeTestRule.onNodeWithTag("signUpPassword").performTextInput("PASSWORD") + composeTestRule.onNodeWithTag("signUpConfirmPassword").performTextInput("PASSWORD") + composeTestRule.onNodeWithTag("signUpButton").performClick() + + composeTestRule + .onNodeWithTag("signUpPasswordError") + .assertTextEquals("Password must contain at least one lower case letter") + } + + @Test + fun signUpScreen_passwordValidation_passwordNoNumber_showsError() { + composeTestRule.setContent { SignUpScreen() } + + composeTestRule.onNodeWithTag("signUpEmail").performTextInput("test@example.com") + composeTestRule.onNodeWithTag("signUpPassword").performTextInput("Password") + composeTestRule.onNodeWithTag("signUpConfirmPassword").performTextInput("Password") + composeTestRule.onNodeWithTag("signUpButton").performClick() + + composeTestRule + .onNodeWithTag("signUpPasswordError") + .assertTextEquals("Password must contain at least one number") + } + + @Test + fun signUpScreen_passwordValidation_passwordNoSpecial_showsError() { + composeTestRule.setContent { SignUpScreen() } + + composeTestRule.onNodeWithTag("signUpEmail").performTextInput("test@example.com") + composeTestRule.onNodeWithTag("signUpPassword").performTextInput("Passw0rd") + composeTestRule.onNodeWithTag("signUpConfirmPassword").performTextInput("Passw0rd") + composeTestRule.onNodeWithTag("signUpButton").performClick() + + composeTestRule + .onNodeWithTag("signUpPasswordError") + .assertTextEquals("Password must contain at least one special character") + } + + @Test + fun signUpScreen_passwordValidation_passwordsDoNotMatch_showsError() { + composeTestRule.setContent { SignUpScreen() } + + // Input an email and mismatched passwords + composeTestRule.onNodeWithTag("signUpEmail").performTextInput("test@example.com") + composeTestRule.onNodeWithTag("signUpPassword").performTextInput("Password123") + composeTestRule.onNodeWithTag("signUpConfirmPassword").performTextInput("Password456") + composeTestRule.onNodeWithTag("signUpButton").performClick() + + // Assert the error message is displayed + composeTestRule.onNodeWithTag("signUpConfirmError").assertTextEquals("Passwords do not match") + } + + @Test + fun signUpScreen_signUp_successfulRegistration() { + composeTestRule.setContent { SignUpScreen() } + + // Input valid data and perform sign up + composeTestRule.onNodeWithTag("signUpEmail").performTextInput("test@example.com") + composeTestRule.onNodeWithTag("signUpPassword").performTextInput("ValidPassword123!") + composeTestRule.onNodeWithTag("signUpConfirmPassword").performTextInput("ValidPassword123!") + composeTestRule.onNodeWithTag("signUpButton").performClick() + + // You can assert here for a visual change or a Toast message if possible + // Since Toast can't be tested directly, consider an alternative for future testing + // TODO: Supabase integration for account creation + } +} diff --git a/app/src/main/java/com/android/periodpals/MainActivity.kt b/app/src/main/java/com/android/periodpals/MainActivity.kt index e3096b391..520e04dee 100644 --- a/app/src/main/java/com/android/periodpals/MainActivity.kt +++ b/app/src/main/java/com/android/periodpals/MainActivity.kt @@ -21,7 +21,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.PointerIcon.Companion.Text import androidx.compose.ui.unit.dp import androidx.navigation.compose.rememberNavController import com.android.periodpals.ui.navigation.NavigationActions diff --git a/app/src/main/java/com/android/periodpals/ui/authentication/SignIn.kt b/app/src/main/java/com/android/periodpals/ui/authentication/SignIn.kt new file mode 100644 index 000000000..eaced54a8 --- /dev/null +++ b/app/src/main/java/com/android/periodpals/ui/authentication/SignIn.kt @@ -0,0 +1,229 @@ +package com.android.periodpals.ui.authentication + +import android.widget.Toast +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.android.periodpals.R +import com.android.periodpals.ui.components.AuthButton +import com.android.periodpals.ui.components.AuthEmailInput +import com.android.periodpals.ui.components.AuthInstruction +import com.android.periodpals.ui.components.AuthPasswordInput +import com.android.periodpals.ui.components.AuthSecondInstruction +import com.android.periodpals.ui.components.AuthWelcomeText +import com.android.periodpals.ui.components.ErrorText +import com.android.periodpals.ui.components.GradedBackground +import com.android.periodpals.ui.theme.Pink40 +import com.android.periodpals.ui.theme.Purple80 +import com.android.periodpals.ui.theme.PurpleGrey80 + +@Preview +@Composable +fun SignInScreen() { + val context = LocalContext.current + + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + + var emailErrorMessage by remember { mutableStateOf("") } + var passwordErrorMessage by remember { mutableStateOf("") } + + var passwordVisible by remember { mutableStateOf(false) } + + // Screen + Scaffold( + modifier = Modifier.fillMaxSize().testTag("signInScreen"), + content = { padding -> + // Purple-ish background + GradedBackground(Purple80, Pink40, PurpleGrey80, "signInBackground") + + Column( + modifier = Modifier.fillMaxSize().padding(padding).padding(60.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(48.dp, Alignment.CenterVertically), + ) { + // Welcome text + AuthWelcomeText( + text = "Welcome to PeriodPals", color = Color.Black, testTag = "signInTitle") + + // Rectangle with login fields and button + Box( + modifier = + Modifier.fillMaxWidth() + .border(1.dp, Color.Gray, RectangleShape) + .background(Color.White) + .padding(24.dp)) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically)) { + // Sign in instruction + AuthInstruction( + text = "Sign in to your account", testTag = "signInInstruction") + + // Email input and error message + AuthEmailInput( + email = email, onEmailChange = { email = it }, testTag = "signInEmail") + if (emailErrorMessage.isNotEmpty()) { + ErrorText(emailErrorMessage, "signInEmailError") + } + + // Password input and error message + AuthPasswordInput( + password = password, + onPasswordChange = { password = it }, + passwordVisible = passwordVisible, + onPasswordVisibilityChange = { passwordVisible = !passwordVisible }, + testTag = "signInPassword", + visibilityTestTag = "signInPasswordVisibility") + if (passwordErrorMessage.isNotEmpty()) { + ErrorText(passwordErrorMessage, "signInPasswordError") + } + + // Sign in button + AuthButton( + text = "Sign in", + onClick = { + emailErrorMessage = validateEmail(email) + passwordErrorMessage = validatePassword(password) + + if (emailErrorMessage.isEmpty() && passwordErrorMessage.isEmpty()) { + // TODO: Implement email and password login logic + val loginSuccess = true + if (loginSuccess) { + // with supabase + Toast.makeText(context, "Login Successful", Toast.LENGTH_SHORT) + .show() + } else { + Toast.makeText(context, "Login Failed", Toast.LENGTH_SHORT).show() + } + } else { + Toast.makeText( + context, "Invalid email or password.", Toast.LENGTH_SHORT) + .show() + } + }, + testTag = "signInButton") + + // Or continue with text + AuthSecondInstruction(text = "Or continue with", testTag = "signInOrText") + + // Google sign in button + GoogleButton( + onClick = { + Toast.makeText( + context, + "Use other login method for now, thanks!", + Toast.LENGTH_SHORT) + .show() + }, + testTag = "signInGoogleButton") + } + } + // Not registered yet? Sign up here! + val annotatedText = buildAnnotatedString { + append("Not registered yet? ") + pushStringAnnotation(tag = "SignUp", annotation = "SignUp") + withStyle(style = SpanStyle(color = Color.Blue)) { append("Sign up here!") } + pop() + } + ClickableText( + modifier = Modifier.testTag("signInNotRegistered"), + text = annotatedText, + onClick = { offset -> + annotatedText + .getStringAnnotations(tag = "SignUp", start = offset, end = offset) + .firstOrNull() + ?.let { + /* TODO: Implement navigation action */ + Toast.makeText(context, "Yay! I'm waiting for navigation", Toast.LENGTH_SHORT) + .show() + } + }) + } + }) +} + +/** Validates the email and returns an error message if the email is invalid. */ +private fun validateEmail(email: String): String { + return when { + email.isEmpty() -> "Email cannot be empty" + !email.contains("@") -> "Email must contain @" + else -> { + // TODO: Check existing email from Supabase + "" + } + } +} + +/** Validates the password and returns an error message if the password is invalid. */ +private fun validatePassword(password: String): String { + return when { + password.isEmpty() -> "Password cannot be empty" + else -> { + // TODO: Check password with Supabase + "" + } + } +} + +/** A composable that displays a Google sign in button. */ +@Composable +fun GoogleButton(onClick: () -> Unit, modifier: Modifier = Modifier, testTag: String) { + Button( + modifier = modifier.wrapContentSize().testTag(testTag), + onClick = onClick, + colors = ButtonDefaults.buttonColors(containerColor = Color.White), + shape = RoundedCornerShape(50), + border = BorderStroke(1.dp, Color.LightGray)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center) { + Image( + painter = painterResource(id = R.drawable.google_logo), + contentDescription = "Google Logo", + modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = "Sign in with Google", + color = Color.Black, + fontWeight = FontWeight.Medium, + style = MaterialTheme.typography.bodyMedium) + } + } +} diff --git a/app/src/main/java/com/android/periodpals/ui/authentication/SignUp.kt b/app/src/main/java/com/android/periodpals/ui/authentication/SignUp.kt new file mode 100644 index 000000000..939b2ae48 --- /dev/null +++ b/app/src/main/java/com/android/periodpals/ui/authentication/SignUp.kt @@ -0,0 +1,205 @@ +package com.android.periodpals.ui.authentication + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.android.periodpals.ui.components.AuthButton +import com.android.periodpals.ui.components.AuthEmailInput +import com.android.periodpals.ui.components.AuthInstruction +import com.android.periodpals.ui.components.AuthPasswordInput +import com.android.periodpals.ui.components.AuthSecondInstruction +import com.android.periodpals.ui.components.AuthWelcomeText +import com.android.periodpals.ui.components.ErrorText +import com.android.periodpals.ui.components.GradedBackground +import com.android.periodpals.ui.theme.Pink40 +import com.android.periodpals.ui.theme.Purple40 +import com.android.periodpals.ui.theme.PurpleGrey80 + +@Preview +@Composable +fun SignUpScreen() { + val context = LocalContext.current + + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var confirm by remember { mutableStateOf("") } + + var emailErrorMessage by remember { mutableStateOf("") } + var passwordErrorMessage by remember { mutableStateOf("") } + var confirmErrorMessage by remember { mutableStateOf("") } + + var passwordVisible by remember { mutableStateOf(false) } + var confirmVisible by remember { mutableStateOf(false) } + + // Screen + Scaffold( + modifier = Modifier.fillMaxSize().testTag("signUpScreen"), + content = { padding -> + // Purple-ish background + GradedBackground(Pink40, Purple40, PurpleGrey80, "signUpBackground") + + Column( + modifier = Modifier.fillMaxSize().padding(padding).padding(60.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(48.dp, Alignment.CenterVertically), + ) { + // Welcome text + AuthWelcomeText( + text = "Welcome to PeriodPals", color = Color.White, testTag = "signUpTitle") + + // Rectangle with login fields and button + Box( + modifier = + Modifier.fillMaxWidth() + .border(1.dp, Color.Gray, RectangleShape) + .background(Color.White) + .padding(24.dp)) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically)) { + // Sign up instruction + AuthInstruction(text = "Create your account", testTag = "signUpInstruction") + + // Email input and error message + AuthEmailInput( + email = email, onEmailChange = { email = it }, testTag = "signUpEmail") + if (emailErrorMessage.isNotEmpty()) { + ErrorText(message = emailErrorMessage, testTag = "signUpEmailError") + } + + // Password input and error message + AuthPasswordInput( + password = password, + onPasswordChange = { + password = it + passwordErrorMessage = validatePassword(password) + }, + passwordVisible = passwordVisible, + onPasswordVisibilityChange = { passwordVisible = !passwordVisible }, + testTag = "signUpPassword", + visibilityTestTag = "signUpPasswordVisibility") + if (passwordErrorMessage.isNotEmpty()) { + ErrorText(message = passwordErrorMessage, testTag = "signUpPasswordError") + } + + // Confirm password text + AuthSecondInstruction( + text = "Confirm your password", testTag = "signUpConfirmText") + + // Confirm password input and error message + AuthPasswordInput( + password = confirm, + onPasswordChange = { confirm = it }, + passwordVisible = confirmVisible, + onPasswordVisibilityChange = { confirmVisible = !confirmVisible }, + testTag = "signUpConfirmPassword", + visibilityTestTag = "signUpConfirmVisibility") + if (confirmErrorMessage.isNotEmpty()) { + ErrorText(message = confirmErrorMessage, testTag = "signUpConfirmError") + } + + // Sign up button + AuthButton( + text = "Sign up", + onClick = { + emailErrorMessage = validateEmail(email) + passwordErrorMessage = validatePassword(password) + confirmErrorMessage = validateConfirmPassword(password, confirm) + + if (emailErrorMessage.isEmpty() && + passwordErrorMessage.isEmpty() && + confirmErrorMessage.isEmpty()) { + if (email.isNotEmpty() && password.isNotEmpty()) { + // TODO: Check duplicate emails from Supabase and existing accounts + val loginSuccess = true // Replace with actual logic + if (loginSuccess) { + Toast.makeText( + context, + "Account Creation Successful", + Toast.LENGTH_SHORT) + .show() + } else { + Toast.makeText( + context, "Account Creation Failed", Toast.LENGTH_SHORT) + .show() + } + } else { + Toast.makeText(context, "Email cannot be empty", Toast.LENGTH_SHORT) + .show() + } + } else { + Toast.makeText( + context, "Invalid email or password", Toast.LENGTH_SHORT) + .show() + } + }, + testTag = "signUpButton", + ) + } + } + } + }) +} + +/** Validates the email field is not empty, contains an '@' character and is not already used. */ +private fun validateEmail(email: String): String { + return when { + email.isEmpty() -> "Email cannot be empty" + !email.contains("@") -> "Email must contain @" + else -> { + // TODO: Check non-existing email from Supabase + "" + } + } +} + +/** + * Validates the password field meets the following requirements: + * - At least 8 characters long, + * - Contains at least one capital letter, + * - Contains at least one minuscule letter, + * - Contains at least one number, + * - Contains at least one special character. + */ +private fun validatePassword(password: String): String { + val capitalLetter = Regex(".*[A-Z].*") + val minusculeLetter = Regex(".*[a-z].*") + val number = Regex(".*[0-9].*") + val specialChar = Regex(".*[!@#\$%^&*(),.?\":{}|<>].*") + + return when { + password.isEmpty() -> "Password cannot be empty" + password.length < 8 -> "Password must be at least 8 characters long" + !capitalLetter.containsMatchIn(password) -> "Password must contain at least one capital letter" + !minusculeLetter.containsMatchIn(password) -> + "Password must contain at least one lower case letter" + !number.containsMatchIn(password) -> "Password must contain at least one number" + !specialChar.containsMatchIn(password) -> "Password must contain at least one special character" + else -> "" + } +} + +/** Validates if the password and confirm password fields match. */ +private fun validateConfirmPassword(password: String, confirm: String): String { + return if (password != confirm) "Passwords do not match" else "" +} diff --git a/app/src/main/java/com/android/periodpals/ui/components/AuthComponents.kt b/app/src/main/java/com/android/periodpals/ui/components/AuthComponents.kt new file mode 100644 index 000000000..b9604c741 --- /dev/null +++ b/app/src/main/java/com/android/periodpals/ui/components/AuthComponents.kt @@ -0,0 +1,165 @@ +package com.android.periodpals.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.android.periodpals.ui.theme.Purple40 + +/** + * A composable that displays a graded background with [gradeFrom] and [gradeTo] colors and + * [background] color and [testTag] for testing purposes. + */ +@Composable +fun GradedBackground(gradeFrom: Color, gradeTo: Color, background: Color, testTag: String) { + Box(modifier = Modifier.fillMaxSize().background(Color.Transparent).testTag(testTag)) { + Canvas(modifier = Modifier.fillMaxSize()) { + val gradientBrush = + Brush.verticalGradient( + colors = listOf(gradeFrom, gradeTo), startY = 0f, endY = size.minDimension * 3 / 2) + + drawRect( + color = background, + topLeft = Offset(0f, size.minDimension), + size = Size(size.width, size.height - size.minDimension)) + + drawRect( + brush = gradientBrush, + topLeft = Offset((size.width - size.minDimension) / 2, 0f), + size = Size(size.width, size.minDimension)) + + drawArc( + brush = gradientBrush, + startAngle = 0f, + sweepAngle = 180f, + useCenter = true, + topLeft = Offset(0f, size.minDimension / 2), + size = Size(size.width, size.minDimension)) + } + } +} + +/** + * A composable that displays a welcome text with [text] and [color] and [testTag] for testing + * purposes. + */ +@Composable +fun AuthWelcomeText(text: String, color: Color, testTag: String) { + Text( + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp).testTag(testTag), + text = text, + textAlign = TextAlign.Center, + color = color, + style = + MaterialTheme.typography.headlineLarge.copy( + fontSize = 40.sp, lineHeight = 64.sp, fontWeight = FontWeight.SemiBold)) +} + +/** + * A composable that displays an instruction text with [text] and [testTag] for testing purposes. + */ +@Composable +fun AuthInstruction(text: String, testTag: String) { + Text( + modifier = Modifier.testTag(testTag), + text = text, + style = + MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp, fontWeight = FontWeight.Medium)) +} + +/** + * A composable that displays a second instruction text with [text] and [testTag] for testing + * purposes. + */ +@Composable +fun AuthSecondInstruction(text: String, testTag: String) { + Text( + modifier = Modifier.testTag(testTag), + text = text, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium)) +} + +/** + * A composable that displays an email input with [email] and [onEmailChange] action and [testTag] + */ +@Composable +fun AuthEmailInput(email: String, onEmailChange: (String) -> Unit, testTag: String) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth().wrapContentSize().testTag(testTag), + value = email, + onValueChange = onEmailChange, + label = { Text("Email") }) +} + +/** + * A composable that displays a password input with [password], [onPasswordChange] action, + * [passwordVisible] and [onPasswordVisibilityChange] action and [testTag] for testing purposes. + */ +@Composable +fun AuthPasswordInput( + password: String, + onPasswordChange: (String) -> Unit, + passwordVisible: Boolean, + onPasswordVisibilityChange: () -> Unit, + testTag: String, + visibilityTestTag: String +) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth().testTag(testTag), + value = password, + onValueChange = onPasswordChange, + label = { Text("Password") }, + visualTransformation = + if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + val image = if (passwordVisible) Icons.Outlined.Visibility else Icons.Outlined.VisibilityOff + IconButton( + onClick = onPasswordVisibilityChange, modifier = Modifier.testTag(visibilityTestTag)) { + Icon( + imageVector = image, + contentDescription = if (passwordVisible) "Hide password" else "Show password") + } + }) +} + +/** + * A composable that displays an authentication button with [text] and [onClick] action and + * [testTag] for testing purposes. + */ +@Composable +fun AuthButton(text: String, onClick: () -> Unit, testTag: String) { + Button( + modifier = Modifier.wrapContentSize().testTag(testTag), + onClick = onClick, + colors = ButtonDefaults.buttonColors(containerColor = Purple40), + shape = RoundedCornerShape(50)) { + Text(text = text, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.Medium) + } +} diff --git a/app/src/main/java/com/android/periodpals/ui/components/ErrorComponent.kt b/app/src/main/java/com/android/periodpals/ui/components/ErrorComponent.kt new file mode 100644 index 000000000..2afeea395 --- /dev/null +++ b/app/src/main/java/com/android/periodpals/ui/components/ErrorComponent.kt @@ -0,0 +1,22 @@ +package com.android.periodpals.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign + +/** + * A composable that displays an error message with [message] and [testTag] for testing purposes. + */ +@Composable +fun ErrorText(message: String, testTag: String) { + Text( + modifier = Modifier.fillMaxWidth().testTag(testTag), + text = message, + color = Color.Red, + style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Start)) +} diff --git a/app/src/main/res/drawable/google_logo.png b/app/src/main/res/drawable/google_logo.png new file mode 100644 index 000000000..f4f3a5e67 Binary files /dev/null and b/app/src/main/res/drawable/google_logo.png differ