bayo-code / kphonenumber

Phone number parsing library for Kotlin Multiplatform. Based on Google’s Libphonenumber
MIT License
1 stars 1 forks source link

FR: Format as you type? #1

Closed adrianegraphene closed 2 months ago

adrianegraphene commented 2 months ago

Thanks for the great library. I started trying to create a "format as you type" PR, but couldn't quite get it to work.

Do you know of a nice way to make this work?

What I would like, is to make it so that as a user is typing in the first few digits of their number in a "enter your phone number" input field, that the number gets formatted automatically. The jump-sdk library has a great one for Android (it actually gets hypens / paraentheses). The PhoneNumberKit does this in iOS (but only with spaces).

so for a US Country Code, with "94970", the formatter would create "949 70" or "(949)-70"

I would love to see that implemented here if possible - but it does not seem like the easiest thing to do on iOS.

bayo-code commented 2 months ago

Hi... I just published 0.10.0 containing this feature. You can check the updated README 🙂

adrianegraphene commented 2 months ago

Thank you! That worked well for me. I was using your formatter in conjunction with another KMP library "CountryPickerBasicTextField". With your update, I had an issue that I believe was due to that other library. For anyone who's going through the same issue. Here's the overcomplicated way I made this work "naturally".

Thanks again for the speedy response! I am all good now.

package com.fyncom.robocash.ui.composables

import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.dp
import com.bayocode.kphonenumber.KPhoneNumber
import com.fyncom.robocash.SharedRes.strings
import com.fyncom.robocash.ui.theme.DarkBlue
import com.fyncom.robocash.ui.theme.DarkPurple
import com.fyncom.robocash.ui.theme.FyncomRed
import com.fyncom.robocash.ui.theme.PaleCoral
import com.fyncom.robocash.utils.get
import com.fyncom.robocash.utils.validatePhoneNumber
import io.github.aakira.napier.Napier
import network.chaintech.cmpcountrycodepicker.model.CountryDetails
import network.chaintech.cmpcountrycodepicker.ui.CountryPickerBasicTextField

private var _phoneNumber by mutableStateOf("")
private var _fullPhoneNumber by mutableStateOf("")
private var _isNumberValid by mutableStateOf(false)
private var _isoCountryCode by mutableStateOf(Locale.current.region ?: "US")
val phoneNumber: String get() = _phoneNumber
val fullPhoneNumber: String get() = _fullPhoneNumber
val isoCountryCode: String get() = _isoCountryCode
val isNumberValid: Boolean get() = _isNumberValid
private val kPhoneNumber = KPhoneNumber()
private val partialFormatter = kPhoneNumber.partialFormatter()
private var _dialCountryCode by mutableStateOf(isoCountryCode ?: "US")
val dialCountryCode: String get() = _dialCountryCode

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CountryCodePhoneInput(
    selectedCountryCodeAndPhoneNumber: MutableState<Pair<String, String>>,
    onIsoCodeSelected: (String?) -> Unit,
    onIsNumberValid: MutableState<Boolean>,
    labelText: String = strings.phone_number.get(),
    accessText: String = strings.selectOrEnterCountry.get(),
) {
    Spacer(M.height(10.dp))
    var mobileNumber by remember { mutableStateOf(selectedCountryCodeAndPhoneNumber.value.second) }
    val selectedCountryState = remember { mutableStateOf<CountryDetails?>(null) }
    val defaultCountryCode = remember { mutableStateOf(Locale.current.region) }
    val dialCode =
        kPhoneNumber.countryCode(selectedCountryState.value?.countryCode ?: isoCountryCode)
    CountryPickerBasicTextField(
        // keyboard shows up on physical device, but not on simulator
        mobileNumber = mobileNumber,
        defaultCountryCode = isoCountryCode,
        onMobileNumberChange = { newNumber ->
            try {
                // Strip spaces and special characters - only numbers
                val newDigits = newNumber.replace(Regex("[^0-9]"), "")

                if (newDigits.length > _phoneNumber.length) {
                    // Compare char by char to find the new digit
                    var newDigit: Char? = null
                    for (i in _phoneNumber.indices) {
                        if (_phoneNumber[i] != newDigits[i]) {
                            newDigit = newDigits[i]
                            break
                        }
                    }
                    if (newDigit == null) { // If no mismatch found, the new digit is at the end
                        newDigit = newDigits.last()
                    }
                    _phoneNumber += newDigit // Append the new digit
                } else if (newDigits.length < _phoneNumber.length) { // Handle deletion
                    _phoneNumber = _phoneNumber.substring(0, _phoneNumber.length-1)
                } else if (newDigits.length == phoneNumber.length) {    // Handle deletion of the last digits
                    _phoneNumber = _phoneNumber.substring(0, _phoneNumber.length - 1)
                }
                Napier.d { "phone number: $_phoneNumber" }
                _isNumberValid = validatePhoneNumber(_phoneNumber, kPhoneNumber, isoCountryCode)
                onIsNumberValid.value = _isNumberValid

                var formattedString = partialFormatter.formatPartial(_phoneNumber)
                if (dialCode != 1) {
                    // for non US, format with dial code first, then remove dial code index & trim prepending strings
                    formattedString = partialFormatter.formatPartial("+" + dialCode.toString() + _phoneNumber)
                    formattedString = formattedString.substring(dialCode.toString().length+1, formattedString.length).trimStart()
                }
                mobileNumber = formattedString // Update the formatted number for display

                if (_isNumberValid) {
                    Napier.d { "number is valid selectedCS ${selectedCountryState.value?.countryCode} currentLocale $isoCountryCode with dial $dialCode" }
                    selectedCountryCodeAndPhoneNumber.value = dialCode.toString() to newNumber
                    onIsoCodeSelected(
                        selectedCountryState.value?.countryCode?.uppercase() ?: isoCountryCode
                    )
                }
            } catch (e: Exception) {
                // Handle the exception (e.g., log it or show an error message)
                Napier.e("Error parsing phone number: ${e.message}")
                _isNumberValid = false
                onIsNumberValid.value = false
            }
        },
        //make sure you put the dialCode into selectedCountryCodeAndPhoneNumber and not the isoCode. should work like this.
        onCountrySelected = { country ->
            // note - country code is lowercased
            Napier.d { "country code $country selected" }
            _isoCountryCode = country.countryCode.uppercase()
            selectedCountryState.value = country
            selectedCountryCodeAndPhoneNumber.value = country.countryPhoneNumberCode to mobileNumber
            onIsoCodeSelected(_isoCountryCode)
        },
        modifier = M.fillMaxWidth().height(56.dp),
        showCountryFlag = true,
        showCountryPhoneCode = true,
        showCountryName = false,
        showCountryCode = false,
        showArrowDropDown = true,
        label = { Text(labelText) },
        defaultPaddingValues = PaddingValues(6.dp),
        colors = TextFieldDefaults.outlinedTextFieldColors(
            focusedTextColor = C.OnSecCon,
            unfocusedTextColor = C.E,
            focusedBorderColor = C.Pri,
            unfocusedBorderColor = C.Pri,
            errorBorderColor = FyncomRed,
            cursorColor = C.Pri,
            errorCursorColor = FyncomRed,
            disabledPlaceholderColor = C.Ter,
            focusedLabelColor = PaleCoral,
            unfocusedLabelColor = DarkPurple,
            disabledLabelColor = DarkBlue,
            errorLabelColor = C.E
        ),
//                shape = RoundedCornerShape(10.dp)
    )
}