Nicolai
04/21/2023, 4:28 PMTravis Griggs
04/21/2023, 4:33 PMonValveChange
hook?Travis Griggs
04/21/2023, 4:35 PMTravis Griggs
04/21/2023, 4:37 PMNicolai
04/21/2023, 4:48 PMTravis Griggs
04/21/2023, 4:58 PMonValueChange = { update ->
if (update.text == curentValue.text) {
// apparently just moving the cursor, let it go through as is
currentValue = update
} else {
// text changed, we'll need to format it I guess
val formatted = format(update.text)
currentValue = TextFieldValue(formatted, TextRange(formatted.length)
}
... or something like thatMoref
01/17/2025, 5:17 PMNicolai
01/17/2025, 5:33 PMMoref
01/17/2025, 5:37 PMAh ok, this is the input code:
internal sealed class PhoneNumberValidationState {
data object Idle : PhoneNumberValidationState()
data object Valid : PhoneNumberValidationState()
data object Invalid : PhoneNumberValidationState()
}
internal data class PhoneNumberInputState(
var isPhoneNumberValid: PhoneNumberValidationState,
var isInternationalCodeMenuExpended: Boolean,
)
@Composable
fun PhoneNumberInput(
phoneNumber: String,
modifier: Modifier = Modifier,
internationalCode: InternationalCode,
onPhoneNumberChanged: (String) -> Unit,
onCountryCodeChanged: (InternationalCode) -> Unit,
onPhoneNumberValid: () -> Unit,
onPhoneNumberInvalid: () -> Unit
) {
val phoneNumberUtil: PhoneNumberUtil = koinInject()
val coroutineScope = rememberCoroutineScope()
val focusRequest = remember { FocusRequester() }
val container = remember {
object : ContainerHost<PhoneNumberInputState, Nothing> {
override val container: Container<PhoneNumberInputState, Nothing> =
coroutineScope.container(
PhoneNumberInputState(
isPhoneNumberValid = PhoneNumberValidationState.Idle,
isInternationalCodeMenuExpended = false,
)
)
}
}
val state by container.container.stateFlow.collectAsState()
val interactionSource = remember { MutableInteractionSource() }
var textFieldValue by remember {
mutableStateOf(
TextFieldValue(
text = phoneNumber,
selection = TextRange(phoneNumber.length)
)
)
}
// Focus on input when composable is initialized
LaunchedEffect(Unit) {
focusRequest.requestFocus()
}
fun onValueChange(newValue: TextFieldValue) {
val text = newValue.text
val unformatted = removePhoneNumberFormat(text)
val formatted = phoneNumberUtil.formatNationalOrNull(text, internationalCode) ?: unformatted
if (textFieldValue.text==text){
return
}
Napier.d(tag = "PhoneNumberInput") {
"PhoneNumber: ${
phoneNumberUtil.formatNationalOrNull(
text,
internationalCode
)
}"
}
// Update the state with the formatted text and adjusted cursor position
textFieldValue = TextFieldValue(
text = formatted,
selection = TextRange(unformatted.length)
)
onPhoneNumberChanged(unformatted)
}
fun onInternationalCodeChange(internationalCode: InternationalCode) {
val dialCode = internationalCode.dialCode
container.intent {
reduce {
state.copy(
isInternationalCodeMenuExpended = false,
isPhoneNumberValid = PhoneNumberValidationState.Idle
)
}
}
onPhoneNumberInvalid()
onCountryCodeChanged(internationalCode)
onPhoneNumberChanged("")
textFieldValue =
textFieldValue.copy(text = "", selection = TextRange(dialCode.length))
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Box(
modifier = modifier
.border(
width = 2.dp,
color = if (state.isPhoneNumberValid == PhoneNumberValidationState.Invalid) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
},
shape = MaterialTheme.shapes.medium // Adjust shape to match the outlined style
)
.padding(Spacing.S.value) // Adjust padding if needed for better alignment
) {
BasicTextField(
value = textFieldValue,
onValueChange = ::onValueChange,
modifier = Modifier.fillMaxWidth().focusRequester(focusRequest),
interactionSource = interactionSource,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone, imeAction = ImeAction.Done),
enabled = true,
singleLine = true,
textStyle = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.bodyLarge.fontSize,
fontWeight = FontWeight.Bold
),
decorationBox = { innerTextField ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
InternationalCodeMenu(
onInternationalCodeChange = ::onInternationalCodeChange,
internationalCode = internationalCode,
expended = state.isInternationalCodeMenuExpended,
onMenuClicked = {
container.intent {
reduce {
state.copy(
isInternationalCodeMenuExpended = !state.isInternationalCodeMenuExpended
)
}
}
}
)
Spacer(modifier = Modifier.width(HSpacing.S.value)) // Spacing between prefix and input
Text(
text = internationalCode.dialCode,
style = TextStyle(
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.bodyLarge.fontSize,
fontWeight = FontWeight.Bold
)
)
Spacer(modifier = Modifier.width(HSpacing.S.value)) // Spacing between prefix and input
innerTextField() // The actual text field content
}
}
)
}
AnimatedVisibility(state.isPhoneNumberValid == PhoneNumberValidationState.Invalid) {
Spacer(Modifier.padding(Spacing.XS.value))
Text(
text = stringResource(Res.string.invalidMobileNumber),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
Nicolai
01/17/2025, 5:39 PMNicolai
01/17/2025, 5:40 PMMoref
01/17/2025, 5:40 PMMoref
01/17/2025, 5:45 PM@Composable
fun PhoneNumberInput(
phoneNumber: String,
modifier: Modifier = Modifier,
internationalCode: InternationalCode,
onPhoneNumberChanged: (String) -> Unit,
) {
var textFieldValue by remember {
mutableStateOf(
TextFieldValue(
text = phoneNumber,
selection = TextRange(phoneNumber.length)
)
)
}
fun onValueChange(newValue: TextFieldValue) {
val text = newValue.text
val unformatted = removePhoneNumberFormat(text)
val formatted = phoneNumberUtil.formatNationalOrNull(text, internationalCode) ?: unformatted
if (textFieldValue.text==text){
return
}
// Update the state with the formatted text and adjusted cursor position
textFieldValue = TextFieldValue(
text = formatted,
selection = TextRange(unformatted.length)
)
onPhoneNumberChanged(unformatted)
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Box {
BasicTextField(
value = textFieldValue,
onValueChange = ::onValueChange,
modifier = Modifier.fillMaxWidth().focusRequester(focusRequest),
interactionSource = interactionSource,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone, imeAction = ImeAction.Done),
enabled = true,
singleLine = true,
)
}
}
}
}
internal fun removePhoneNumberFormat(value: String): String =
value.replace(" ", "")
.replace("-", "")
.replace(")", "")
.replace("(", "")
I am using libphonenumber for formatting phone number this is phoneNumberUtil
actual class PhoneNumberUtil {
private val phoneNumberUtil = getInstance()
actual fun formatInternationalOrNull(phoneNumber: String, internationalCode: InternationalCode): String? = try {
val number = phoneNumberUtil.parse(phoneNumber, internationalCode.isoCode)
phoneNumberUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL)
} catch (e: Exception) {
null
}
actual fun isValidPhoneNumber(phoneNumber: String, internationalCode: InternationalCode): Boolean = try {
val number = phoneNumberUtil.parse(phoneNumber, internationalCode.isoCode)
phoneNumberUtil.isValidNumber(number)
} catch (e: Exception) {
false
}
actual fun parseOrNull(phoneNumber: String, internationalCode: InternationalCode): String? = try {
val number = phoneNumberUtil.parse(phoneNumber, internationalCode.isoCode)
phoneNumberUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.E164)
} catch (e: Exception) {
null
}
actual fun formatNationalOrNull(
phoneNumber: String,
internationalCode: InternationalCode
): String? = try {
val number = phoneNumberUtil.parse(phoneNumber, internationalCode.isoCode)
phoneNumberUtil.format(number, PhoneNumberUtil.PhoneNumberFormat.NATIONAL)
} catch (e: Exception) {
null
}
}
Nicolai
01/17/2025, 6:41 PMMoref
01/17/2025, 6:46 PMNicolai
01/17/2025, 7:11 PMNicolai
01/17/2025, 7:17 PMMoref
01/17/2025, 7:42 PMNicolai
01/17/2025, 7:43 PMMoref
01/17/2025, 7:44 PMMoref
01/17/2025, 7:55 PMNicolai
01/17/2025, 7:56 PMMoref
01/17/2025, 7:57 PMMoref
01/17/2025, 7:57 PMNicolai
01/17/2025, 8:01 PMNicolai
01/17/2025, 8:01 PMNicolai
01/17/2025, 8:02 PMMoref
01/17/2025, 8:35 PMMoref
01/17/2025, 8:36 PMMoref
01/17/2025, 8:45 PMNicolai
01/17/2025, 9:20 PM@Composable
fun PhoneNumberInput(
phoneNumber: String,
modifier: Modifier = Modifier,
onPhoneNumberChanged: (String) -> Unit,
) {
val context = LocalContext.current
val phoneNumberUtil = remember { PhoneNumberUtil.createInstance(context) }
var textFieldValue by remember { mutableStateOf(TextFieldValue(phoneNumber)) }
val focusRequest = remember { FocusRequester() }
// Handle value change and phone number formatting
fun onValueChange(newValue: TextFieldValue) {
val unformatted = removePhoneNumberFormat(newValue.text)
try {
val phoneNumberProto = phoneNumberUtil.parse(unformatted, "US")
val formatted = phoneNumberUtil.format(
phoneNumberProto,
PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL
)
// Update cursor position after formatting
val cursorPosition = calculateCursorPosition(newValue.text, formatted, newValue.selection.start)
textFieldValue = TextFieldValue(
text = formatted,
selection = TextRange(cursorPosition)
)
onPhoneNumberChanged(unformatted)
} catch (e: Exception) {
// Fallback to raw input if formatting fails
textFieldValue = newValue
onPhoneNumberChanged(newValue.text)
}
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Box {
TextField(
value = textFieldValue,
onValueChange = {
onValueChange(it)
},
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequest, true),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Phone,
imeAction = ImeAction.Done
),
singleLine = true,
)
}
}
}
}
private fun calculateCursorPosition(
originalText: String,
formattedText: String,
cursorPosition: Int
): Int {
// If the cursor is at the end of the original text, move to the end of the formatted text
if (cursorPosition >= originalText.length) return formattedText.length
// Calculate how many characters from the original text are present in the formatted text
var formattedIndex = 0
var unformattedIndex = 0
// Walk through both the original and formatted texts to map the cursor position
while (unformattedIndex < cursorPosition && formattedIndex < formattedText.length) {
if (originalText[unformattedIndex] != formattedText[formattedIndex]) {
// Skip non-alphanumeric characters in the formatted text (i.e., formatting symbols)
formattedIndex++
} else {
unformattedIndex++
formattedIndex++
}
}
// Ensure the cursor position is valid and within the bounds of the formatted text
return formattedIndex.coerceIn(0, formattedText.length)
}
internal fun removePhoneNumberFormat(value: String): String =
value.replace(" ", "")
.replace("-", "")
.replace(")", "")
.replace("(", "")
@Preview
@Composable
private fun PhoneNumberInputPreview() {
var phoneNumber by remember { mutableStateOf("") }
Column (
Modifier
.fillMaxSize()
.padding(top = 200.dp)
) {
PhoneNumberInput(
phoneNumber = phoneNumber,
onPhoneNumberChanged = { phoneNumber = it }
)
}
}
Moref
01/18/2025, 11:30 AMNicolai
01/18/2025, 11:38 AMNicolai
01/20/2025, 8:11 AMMoref
01/20/2025, 9:28 AMVisualTransformation
here’s the code:
@Composable
fun PhoneNumberInput(
label: String,
phoneNumber: String,
onPhoneNumberChange: (String) -> Unit,
countryCode: String = "US",
modifier: Modifier = Modifier
) {
OutlinedTextField(
value = phoneNumber,
onValueChange = { newValue -> onPhoneNumberChange(newValue) },
label = { Text(label) },
visualTransformation = PhoneNumberVisualTransformation(countryCode),
singleLine = true,
prefix = {
Text(countryCode)
},
modifier = modifier.fillMaxWidth()
)
}
class PhoneNumberVisualTransformation(
private val countryCode: String
) : VisualTransformation {
private val phoneNumberUtil: PhoneNumberUtil = PhoneNumberUtil.getInstance()
override fun filter(text: AnnotatedString): TransformedText {
val rawInput = text.text
var formattedNumber = rawInput
val offsetMap = mutableListOf<Pair<Int, Int>>()
try {
// Parse and format the phone number
val parsedNumber = phoneNumberUtil.parse(rawInput, countryCode)
formattedNumber = phoneNumberUtil.format(
parsedNumber,
PhoneNumberUtil.PhoneNumberFormat.NATIONAL
)
// Generate offset mapping
var originalIndex = 0
for (i in formattedNumber.indices) {
if (originalIndex < rawInput.length && formattedNumber[i] == rawInput[originalIndex]) {
offsetMap.add(originalIndex to i)
originalIndex++
} else {
offsetMap.add(originalIndex to i)
}
}
while (originalIndex < rawInput.length) {
offsetMap.add(originalIndex to formattedNumber.length)
originalIndex++
}
} catch (e: Exception) {
// Fallback to raw input if formatting fails
formattedNumber = rawInput
for (i in rawInput.indices) {
offsetMap.add(i to i)
}
}
return TransformedText(
AnnotatedString(formattedNumber),
object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
return offsetMap.find { it.first == offset }?.second ?: formattedNumber.length
}
override fun transformedToOriginal(offset: Int): Int {
return offsetMap.findLast { it.second == offset }?.first ?: rawInput.length
}
}
)
}
}