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 @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -111,6 +121,12 @@ class NameEntryViewModel @Inject constructor(
eventFlow
.filterIsInstance<Event.ConfirmNameChange>()
.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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading