diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryScreen.kt index 9b198f2328..d3ccdda192 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryScreen.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryScreen.kt @@ -117,7 +117,7 @@ private fun NameEntryScreenContent( bottom = CodeTheme.dimens.grid.x3 ).imePadding(), text = stringResource(R.string.action_next), - enabled = state.hasName && state.isChanged && state.processingState.isIdle, + enabled = state.isSubmittable(source) && state.isChanged && state.processingState.isIdle, isLoading = state.processingState.loading, isSuccess = state.processingState.success, onClick = { diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt index b877aa95b7..29ab79a4e7 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt @@ -61,6 +61,16 @@ class NameEntryViewModel @Inject constructor( val hasName: Boolean get() = nameFieldState.text.isNotBlank() + /** + * Whether the current text may be submitted. Onboarding applies the stricter + * [isValidOnboardingDisplayName] rule; other sources only need a non-blank name. + */ + fun isSubmittable(source: DisplayNameSource): Boolean = when (source) { + DisplayNameSource.Onboarding -> isValidOnboardingDisplayName(nameFieldState.text.toString()) + DisplayNameSource.MyAccount, + DisplayNameSource.TipCardSetup -> hasName + } + /** Node 9553:113166 — one character's difference is enough to arm the confirm button. */ val isChanged: Boolean get() = nameFieldState.text.toString() != savedName @@ -111,6 +121,12 @@ class NameEntryViewModel @Inject constructor( eventFlow .filterIsInstance() .onEach { event -> + // The keyboard Done action reaches here without passing the button's enabled + // gate, so an invalid onboarding name is dropped the same way the disabled + // button drops it. + if (event.source == DisplayNameSource.Onboarding && !stateFlow.value.isSubmittable(event.source)) { + return@onEach + } if (stateFlow.value.savedName.isBlank()) { dispatchEvent(Event.CheckName(event.source)) return@onEach @@ -142,8 +158,9 @@ class NameEntryViewModel @Inject constructor( // pushes the stored name into the field, so the field itself // cannot tell us whether one already existed. val hadPreviousName = !userManager.profile?.displayName.isNullOrBlank() + val typed = stateFlow.value.nameFieldState.text.toString() val result = profileController.setDisplayName( - stateFlow.value.nameFieldState.text.toString() + submittedName(typed, event.source) ) result.onSuccess { val analyticsEvent = if (hadPreviousName) { @@ -228,6 +245,10 @@ class NameEntryViewModel @Inject constructor( } } internal companion object { + /** Onboarding submits the trimmed name (as iOS does); other sources send the field text as typed. */ + fun submittedName(typed: String, source: DisplayNameSource): String = + if (source == DisplayNameSource.Onboarding) trimOnboardingDisplayName(typed) else typed + val updateStateForEvent: (Event) -> (State.() -> State) = { event -> when (event) { is Event.ConfirmNameChange -> { state -> state } diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/OnboardingDisplayNameRule.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/OnboardingDisplayNameRule.kt new file mode 100644 index 0000000000..f5f43bef81 --- /dev/null +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/OnboardingDisplayNameRule.kt @@ -0,0 +1,31 @@ +package com.flipcash.app.userprofile.internal.name + +internal const val ONBOARDING_DISPLAY_NAME_MAX_LENGTH = 64 + +/** + * The onboarding display-name rule, shared in definition with iOS: after trimming leading and + * trailing whitespace the name is non-empty, at most [ONBOARDING_DISPLAY_NAME_MAX_LENGTH] + * characters, and made only of ASCII letters, ASCII digits and the space character (U+0020). + * Any other whitespace (tab, newline, NBSP), punctuation, accented letters or emoji is invalid. + * Runs of internal spaces are allowed. + * + * Pure: no Android types, so it can move to shared code. + */ +internal fun isValidOnboardingDisplayName(raw: String): Boolean { + val name = trimOnboardingDisplayName(raw) + return name.isNotEmpty() && + name.length <= ONBOARDING_DISPLAY_NAME_MAX_LENGTH && + name.all { it in 'A'..'Z' || it in 'a'..'z' || it in '0'..'9' || it == ' ' } +} + +/** The string onboarding submits: [raw] trimmed with [isOnboardingTrimmable] on both ends. */ +internal fun trimOnboardingDisplayName(raw: String): String = raw.trim(::isOnboardingTrimmable) + +// Mirrors iOS CharacterSet.whitespacesAndNewlines: U+0009-U+000D, U+0085, and categories Zs/Zl/Zp. +// Not Char.isWhitespace(), which also strips U+001C-U+001F and misses U+0085. +private fun isOnboardingTrimmable(c: Char): Boolean = + c.code in 0x09..0x0D || c.code == 0x85 || + when (Character.getType(c).toByte()) { + Character.SPACE_SEPARATOR, Character.LINE_SEPARATOR, Character.PARAGRAPH_SEPARATOR -> true + else -> false + } diff --git a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/name/OnboardingDisplayNameRuleTest.kt b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/name/OnboardingDisplayNameRuleTest.kt new file mode 100644 index 0000000000..e48a318036 --- /dev/null +++ b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/name/OnboardingDisplayNameRuleTest.kt @@ -0,0 +1,74 @@ +package com.flipcash.app.userprofile.internal.name + +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import com.flipcash.app.core.DisplayNameSource +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class OnboardingDisplayNameRuleTest { + + @Test + fun `accepts ASCII letters digits and spaces`() { + assertTrue(isValidOnboardingDisplayName("Taylor Smith 2")) + assertTrue(isValidOnboardingDisplayName("Taylor Smith")) + } + + @Test + fun `trims surrounding whitespace before validating`() { + assertTrue(isValidOnboardingDisplayName(" Taylor ")) + assertTrue(isValidOnboardingDisplayName("\tTaylor\n")) + } + + @Test + fun `rejects empty and whitespace only`() { + assertFalse(isValidOnboardingDisplayName("")) + assertFalse(isValidOnboardingDisplayName(" ")) + } + + @Test + fun `rejects accents punctuation emoji and non-space whitespace`() { + assertFalse(isValidOnboardingDisplayName("José")) + assertFalse(isValidOnboardingDisplayName("Taylor!")) + assertFalse(isValidOnboardingDisplayName("😀")) + assertFalse(isValidOnboardingDisplayName("Tay\tlor")) + assertFalse(isValidOnboardingDisplayName("Tay\nlor")) + assertFalse(isValidOnboardingDisplayName("Tay lor")) + } + + @Test + fun `length limit is 64 after trimming`() { + assertTrue(isValidOnboardingDisplayName("a".repeat(64))) + assertTrue(isValidOnboardingDisplayName(" " + "a".repeat(64) + " ")) + assertFalse(isValidOnboardingDisplayName("a".repeat(65))) + } + + @Test + fun `only onboarding applies the ASCII rule`() { + val state = NameEntryViewModel.State() + state.nameFieldState.setTextAndPlaceCursorAtEnd("José") + + assertFalse(state.isSubmittable(DisplayNameSource.Onboarding)) + assertTrue(state.isSubmittable(DisplayNameSource.MyAccount)) + assertTrue(state.isSubmittable(DisplayNameSource.TipCardSetup)) + assertEquals(true, state.hasName) + } + + @Test + fun `trims the same set as iOS whitespacesAndNewlines`() { + assertTrue(isValidOnboardingDisplayName("Taylor\u00A0")) + assertTrue(isValidOnboardingDisplayName("\u3000Taylor")) + assertTrue(isValidOnboardingDisplayName("Taylor\u0085")) + assertFalse(isValidOnboardingDisplayName("Taylor\u001F")) + assertFalse(isValidOnboardingDisplayName("Tay\u00A0lor")) + } + + @Test + fun `onboarding submits the trimmed name and other sources the typed text`() { + val typed = "\u3000Taylor Smith\u0085" + assertEquals("Taylor Smith", NameEntryViewModel.submittedName(typed, DisplayNameSource.Onboarding)) + assertEquals(typed, NameEntryViewModel.submittedName(typed, DisplayNameSource.MyAccount)) + assertEquals(typed, NameEntryViewModel.submittedName(typed, DisplayNameSource.TipCardSetup)) + } +}