diff --git a/app/src/main/java/org/greenstand/android/TreeTracker/profile/ProfileScreen.kt b/app/src/main/java/org/greenstand/android/TreeTracker/profile/ProfileScreen.kt index 41e94b9e..d5d2e750 100644 --- a/app/src/main/java/org/greenstand/android/TreeTracker/profile/ProfileScreen.kt +++ b/app/src/main/java/org/greenstand/android/TreeTracker/profile/ProfileScreen.kt @@ -59,6 +59,12 @@ import org.greenstand.android.TreeTracker.view.LocalImage import org.greenstand.android.TreeTracker.view.ProfileField import org.greenstand.android.TreeTracker.view.TreeTrackerButton import java.io.File +import org.greenstand.android.TreeTracker.utils.ValidationUtils +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.getValue @Composable fun ProfileScreen( @@ -79,6 +85,21 @@ fun ProfileScreen( } } } + var firstNameError by remember { mutableStateOf(null) } + var lastNameError by remember { mutableStateOf(null) } + + fun validateNames(): Boolean { + val firstName = state.selectedUser?.firstName ?: "" + val lastName = state.selectedUser?.lastName ?: "" + + val (firstNameValid, firstError) = ValidationUtils.validateName(firstName) + val (lastNameValid, lastError) = ValidationUtils.validateName(lastName) + + firstNameError = firstError + lastNameError = lastError + + return firstNameValid && lastNameValid + } Scaffold( topBar = { ActionBar( @@ -155,11 +176,45 @@ fun ProfileScreen( Spacer(modifier = Modifier.height(16.dp)) ProfileField(stringResource(id = R.string.first_name_hint), selectedUser.firstName, state.editMode) { - viewModel.updateSelectedUser(firstName = it) + newFirstName -> + + val filtered = ValidationUtils.filterNameInput(newFirstName) + viewModel.updateSelectedUser(firstName = filtered) + if (state.editMode) { + val (_, error) = ValidationUtils.validateName(filtered) + firstNameError = error + } + } + + if (state.editMode && firstNameError != null) { + Text( + text = firstNameError!!, + color = MaterialTheme.colors.error, + style = CustomTheme.typography.small, + modifier = Modifier.padding(start = 8.dp, top = 4.dp) + ) } + Spacer(modifier = Modifier.height(8.dp)) - ProfileField(stringResource(id = R.string.last_name_hint), selectedUser.lastName ?: "", state.editMode) { - viewModel.updateSelectedUser(lastName = it) + ProfileField( + stringResource(id = R.string.last_name_hint), selectedUser.lastName ?: "", state.editMode) { + newLastName -> + val filtered = ValidationUtils.filterNameInput(newLastName) + viewModel.updateSelectedUser(lastName = filtered) + + if (state.editMode) { + val (_, error) = ValidationUtils.validateName(filtered) + lastNameError = error + } + } + + if (state.editMode && lastNameError != null) { + Text( + text = lastNameError!!, + color = MaterialTheme.colors.error, + style = CustomTheme.typography.small, + modifier = Modifier.padding(start = 8.dp, top = 4.dp) + ) } if (selectedUser.wallet.contains("@")) { ProfileField(stringResource(id = R.string.email_placeholder), selectedUser.wallet ?: "", state.editMode) { @@ -180,10 +235,16 @@ fun ProfileScreen( .align(Alignment.CenterHorizontally) .size(width = 150.dp, 60.dp), onClick = { + // 验证名字 + val firstName = state.selectedUser?.firstName ?: "" + val lastName = state.selectedUser?.lastName ?: "" - scope.launch { - viewModel.updateUserInDatabase() - viewModel.updateEditEnabled() + if (ValidationUtils.validateName(firstName).first && + ValidationUtils.validateName(lastName).first) { + scope.launch { + viewModel.updateUserInDatabase() + viewModel.updateEditEnabled() + } } } ) { diff --git a/app/src/main/java/org/greenstand/android/TreeTracker/signup/NameEntryView.kt b/app/src/main/java/org/greenstand/android/TreeTracker/signup/NameEntryView.kt index 110d7b34..0b2a3d8a 100644 --- a/app/src/main/java/org/greenstand/android/TreeTracker/signup/NameEntryView.kt +++ b/app/src/main/java/org/greenstand/android/TreeTracker/signup/NameEntryView.kt @@ -49,6 +49,13 @@ import org.greenstand.android.TreeTracker.view.ArrowButton import org.greenstand.android.TreeTracker.view.BorderedTextField import org.greenstand.android.TreeTracker.view.LanguageButton import org.greenstand.android.TreeTracker.view.TopBarTitle +import org.greenstand.android.TreeTracker.utils.ValidationUtils +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height @Composable fun NameEntryView(viewModel: SignupViewModel, state: SignUpState) { @@ -95,8 +102,7 @@ fun NameEntryView(viewModel: SignupViewModel, state: SignUpState) { rightAction = { ArrowButton( isLeft = false, - isEnabled = !state.firstName.isNullOrBlank() && - !state.lastName.isNullOrBlank() + isEnabled = viewModel.isFormValid() ) { cameraLauncher.launch(true) } @@ -128,6 +134,15 @@ fun NameEntryView(viewModel: SignupViewModel, state: SignUpState) { } ) ) + state.firstNameError?.let { error -> + Text( + text = error, + color = MaterialTheme.colors.error, + style = MaterialTheme.typography.caption, + modifier = Modifier.padding(start = 8.dp, top = 4.dp) + ) + } + Spacer(modifier = Modifier.height(8.dp)) BorderedTextField( value = state.lastName ?: "", padding = PaddingValues(4.dp), @@ -146,6 +161,14 @@ fun NameEntryView(viewModel: SignupViewModel, state: SignUpState) { } ) ) + state.lastNameError?.let { error -> + Text( + text = error, + color = MaterialTheme.colors.error, + style = MaterialTheme.typography.caption, + modifier = Modifier.padding(start = 8.dp, top = 4.dp) + ) + } } } } \ No newline at end of file diff --git a/app/src/main/java/org/greenstand/android/TreeTracker/signup/SignupViewModel.kt b/app/src/main/java/org/greenstand/android/TreeTracker/signup/SignupViewModel.kt index 09d4785f..4255976a 100644 --- a/app/src/main/java/org/greenstand/android/TreeTracker/signup/SignupViewModel.kt +++ b/app/src/main/java/org/greenstand/android/TreeTracker/signup/SignupViewModel.kt @@ -25,6 +25,7 @@ import org.greenstand.android.TreeTracker.models.UserRepo import org.greenstand.android.TreeTracker.models.user.User import org.greenstand.android.TreeTracker.usecases.CheckForInternetUseCase import org.greenstand.android.TreeTracker.utilities.Validation +import org.greenstand.android.TreeTracker.utils.ValidationUtils // Dequeue breaks equals so state will not be updated when navigating data class SignUpState( @@ -43,6 +44,8 @@ data class SignUpState( val isTherePowerUser: Boolean? = null, val showSelfieTutorial: Boolean? = null, val showPrivacyDialog: Boolean? = true, + val firstNameError: String? = null, + val lastNameError: String? = null, ) sealed class Credential { @@ -90,11 +93,27 @@ class SignupViewModel( } fun updateFirstName(firstName: String?) { - _state.value = _state.value?.copy(firstName = firstName) + val filtered = firstName?.let { ValidationUtils.filterNameInput(it) } + val (isValid, error) = ValidationUtils.validateName(filtered) + _state.value = _state.value?.copy( + firstName = filtered, + firstNameError = if (filtered.isNullOrEmpty()) null else error + ) } fun updateLastName(lastName: String?) { - _state.value = _state.value?.copy(lastName = lastName) + val filtered = lastName?.let { ValidationUtils.filterNameInput(it) } + val (isValid, error) = ValidationUtils.validateName(filtered) + _state.value = _state.value?.copy( + lastName = filtered, + lastNameError = if (filtered.isNullOrEmpty()) null else error + ) + } + fun isFormValid(): Boolean { + val state = _state.value ?: return false + val (firstNameValid, _) = ValidationUtils.validateName(state.firstName) + val (lastNameValid, _) = ValidationUtils.validateName(state.lastName) + return firstNameValid && lastNameValid } fun setExistingUserAsPowerUser(id: Long){ diff --git a/app/src/main/java/org/greenstand/android/TreeTracker/utils/ValidationUtils.kt b/app/src/main/java/org/greenstand/android/TreeTracker/utils/ValidationUtils.kt new file mode 100644 index 00000000..9420f2bd --- /dev/null +++ b/app/src/main/java/org/greenstand/android/TreeTracker/utils/ValidationUtils.kt @@ -0,0 +1,48 @@ +package org.greenstand.android.TreeTracker.utils + +/** + * Utility class for validating user input fields + */ +object ValidationUtils { + + private val NAME_PATTERN = """^[\p{L}\s'-]+$""".toRegex() + + private val INVALID_CHARS_PATTERN = """[0-9!@#$%^&*()_+=\[\]{};:"|<>?,./\\~`]""".toRegex() + + /** + * Validates a name field (first name or last name) + * @param name The name to validate + * @return Pair of (isValid, errorMessage) + */ + fun validateName(name: String?): Pair { + return when { + name.isNullOrBlank() -> { + false to "Name cannot be empty" + } + name.trim().length < 2 -> { + false to "Name must be at least 2 characters" + } + name.length > 50 -> { + false to "Name cannot exceed 50 characters" + } + INVALID_CHARS_PATTERN.containsMatchIn(name) -> { + false to "Name cannot contain numbers or special characters" + } + !NAME_PATTERN.matches(name.trim()) -> { + false to "Name can only contain letters, spaces, hyphens and apostrophes" + } + else -> { + true to null + } + } + } + + /** + * Filters out invalid characters from input + */ + fun filterNameInput(input: String): String { + return input.filter { char -> + char.isLetter() || char.isWhitespace() || char == '-' || char == '\'' + }.take(50) // Also enforce max length + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4e27e51a..e71efa9f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -204,4 +204,10 @@ Privacy Policy Survey Completed Upload Trees Soon You have over 2000 trees, please upload as soon as you can. + + Name cannot be empty + Name must be at least 2 characters + Name cannot exceed 50 characters + Name cannot contain numbers or special characters + Name can only contain letters, spaces, hyphens and apostrophes diff --git a/app/src/test/java/org/greenstand/android/TreeTracker/utils/ValidationUtilsTest.kt b/app/src/test/java/org/greenstand/android/TreeTracker/utils/ValidationUtilsTest.kt new file mode 100644 index 00000000..a6867dc1 --- /dev/null +++ b/app/src/test/java/org/greenstand/android/TreeTracker/utils/ValidationUtilsTest.kt @@ -0,0 +1,61 @@ +package org.greenstand.android.TreeTracker.utils + +import org.junit.Assert.* +import org.junit.Test + +class ValidationUtilsTest { + + @Test + fun `valid names should pass validation`() { + val validNames = listOf( + "John", + "Mary-Jane", + "O'Connor", + "José María", + "李明", + "François" + ) + + validNames.forEach { name -> + val (isValid, error) = ValidationUtils.validateName(name) + assertTrue("$name should be valid", isValid) + assertNull("$name should have no error", error) + } + } + + @Test + fun `names with numbers should fail`() { + val invalidNames = listOf("John123", "4ever", "Test1") + + invalidNames.forEach { name -> + val (isValid, error) = ValidationUtils.validateName(name) + assertFalse("$name should be invalid", isValid) + assertEquals("Name cannot contain numbers or special characters", error) + } + } + + @Test + fun `names with special characters should fail`() { + val invalidNames = listOf("John@Doe", "Test!", "Name#1") + + invalidNames.forEach { name -> + val (isValid, error) = ValidationUtils.validateName(name) + assertFalse("$name should be invalid", isValid) + assertNotNull(error) + } + } + + @Test + fun `empty name should fail`() { + val (isValid, error) = ValidationUtils.validateName("") + assertFalse(isValid) + assertEquals("Name cannot be empty", error) + } + + @Test + fun `filterNameInput removes invalid characters`() { + assertEquals("John", ValidationUtils.filterNameInput("John123")) + assertEquals("MaryJane", ValidationUtils.filterNameInput("Mary@Jane")) + assertEquals("Test", ValidationUtils.filterNameInput("Test!@#")) + } +} \ No newline at end of file