Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -79,6 +85,21 @@ fun ProfileScreen(
}
}
}
var firstNameError by remember { mutableStateOf<String?>(null) }
var lastNameError by remember { mutableStateOf<String?>(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(
Expand Down Expand Up @@ -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) {
Expand All @@ -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()
}
}
}
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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),
Expand All @@ -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)
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 {
Expand Down Expand Up @@ -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){
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Boolean, String?> {
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
}
}
6 changes: 6 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -204,4 +204,10 @@ Privacy Policy
<string name="survey_completed">Survey Completed</string>
<string name="upload_trees_soon_title">Upload Trees Soon</string>
<string name="upload_trees_text_content">You have over 2000 trees, please upload as soon as you can.</string>

<string name="error_name_empty">Name cannot be empty</string>
<string name="error_name_too_short">Name must be at least 2 characters</string>
<string name="error_name_too_long">Name cannot exceed 50 characters</string>
<string name="error_name_invalid">Name cannot contain numbers or special characters</string>
<string name="error_name_format">Name can only contain letters, spaces, hyphens and apostrophes</string>
</resources>
Original file line number Diff line number Diff line change
@@ -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!@#"))
}
}