Skip to content

Commit

Permalink
Refactor CountryAutoCompleteTextView logic (#1828)
Browse files Browse the repository at this point in the history
- Create `Country` model to use in `CountryAutoCompleteTextView`
  and `CountryAdapter` to simplify logic
- Make `CountryAutoCompleteTextView.selectedCountry` non-null
- Avoid redundant `setText()` calls in `CountryAutoCompleteTextView`
  after a user selects a dropdown item
- Make `CountryAutoCompleteTextView.CountryChangeListener` a
  function type `(Country) -> Unit`
  • Loading branch information
mshafrir-stripe authored Nov 18, 2019
1 parent 3508d1d commit 3380d20
Show file tree
Hide file tree
Showing 9 changed files with 126 additions and 119 deletions.
12 changes: 12 additions & 0 deletions stripe/src/main/java/com/stripe/android/view/Country.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.stripe.android.view

internal data class Country(
val code: String,
val name: String
) {

/**
* @return display value for [CountryAutoCompleteTextView] text view
*/
override fun toString(): String = name
}
28 changes: 14 additions & 14 deletions stripe/src/main/java/com/stripe/android/view/CountryAdapter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,20 @@ import java.util.Locale
*/
internal class CountryAdapter(
context: Context,
initialCountries: List<String>
) : ArrayAdapter<String>(context, R.layout.country_text_view) {
initialCountries: List<Country>
) : ArrayAdapter<Country>(context, R.layout.country_text_view) {
private val countryFilter: Filter = CountryFilter(
initialCountries,
this,
context as? Activity
)
private var suggestions: List<String> = initialCountries
private var suggestions: List<Country> = initialCountries

override fun getCount(): Int {
return suggestions.size
}

override fun getItem(i: Int): String {
override fun getItem(i: Int): Country {
return suggestions[i]
}

Expand All @@ -41,12 +41,12 @@ internal class CountryAdapter(

override fun getView(i: Int, view: View?, viewGroup: ViewGroup): View {
return if (view is TextView) {
view.text = getItem(i)
view.text = getItem(i).name
view
} else {
val countryText = LayoutInflater.from(context).inflate(
R.layout.country_text_view, viewGroup, false) as TextView
countryText.text = getItem(i)
countryText.text = getItem(i).name
countryText
}
}
Expand All @@ -56,7 +56,7 @@ internal class CountryAdapter(
}

private class CountryFilter(
private val initialCountries: List<String>,
private val initialCountries: List<Country>,
private val adapter: CountryAdapter,
activity: Activity?
) : Filter() {
Expand All @@ -74,10 +74,10 @@ internal class CountryAdapter(
constraint: CharSequence?,
filterResults: FilterResults?
) {
val suggestions = filterResults?.values as List<String>
val suggestions = filterResults?.values as List<Country>

activityRef.get()?.let { activity ->
if (suggestions.any { it == constraint }) {
if (suggestions.any { it.name == constraint }) {
hideKeyboard(activity)
}
}
Expand All @@ -86,7 +86,7 @@ internal class CountryAdapter(
adapter.notifyDataSetChanged()
}

private fun filteredSuggestedCountries(constraint: CharSequence?): List<String> {
private fun filteredSuggestedCountries(constraint: CharSequence?): List<Country> {
val suggestedCountries = getSuggestedCountries(constraint)

return if (suggestedCountries.isEmpty() || isMatch(suggestedCountries, constraint)) {
Expand All @@ -96,17 +96,17 @@ internal class CountryAdapter(
}
}

private fun getSuggestedCountries(constraint: CharSequence?): List<String> {
private fun getSuggestedCountries(constraint: CharSequence?): List<Country> {
return initialCountries
.filter {
it.toLowerCase(Locale.ROOT).startsWith(
it.name.toLowerCase(Locale.ROOT).startsWith(
constraint.toString().toLowerCase(Locale.ROOT)
)
}
}

private fun isMatch(countries: List<String>, constraint: CharSequence?): Boolean {
return countries.size == 1 && countries[0] == constraint.toString()
private fun isMatch(countries: List<Country>, constraint: CharSequence?): Boolean {
return countries.size == 1 && countries[0].name == constraint.toString()
}

private fun hideKeyboard(activity: Activity) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,74 +23,74 @@ internal class CountryAutoCompleteTextView @JvmOverloads constructor(
* @return 2 digit country code of the country selected by this input.
*/
@VisibleForTesting
var selectedCountryCode: String? = null
var selectedCountry: Country

private var countryChangeListener: CountryChangeListener? = null
@JvmSynthetic
internal var countryChangeCallback: (Country) -> Unit = {}

init {
View.inflate(getContext(), R.layout.country_autocomplete_textview, this)
countryAutocomplete = findViewById(R.id.autocomplete_country_cat)

val countryAdapter = CountryAdapter(
getContext(),
CountryUtils.getOrderedCountries(
ConfigurationCompat.getLocales(context.resources.configuration)[0]
)
)

countryAutocomplete = findViewById(R.id.autocomplete_country_cat)
countryAutocomplete.threshold = 0
countryAutocomplete.setAdapter(countryAdapter)
countryAutocomplete.onItemClickListener = AdapterView.OnItemClickListener { _, _, _, _ ->
val countryEntered = countryAutocomplete.text.toString()
updateUiForCountryEntered(countryEntered)
}
val defaultCountryEntered = countryAdapter.getItem(0)
updateUiForCountryEntered(defaultCountryEntered)
countryAutocomplete.setText(defaultCountryEntered)
countryAutocomplete.onItemClickListener =
AdapterView.OnItemClickListener { _, _, position, _ ->
updatedSelectedCountryCode(countryAdapter.getItem(position))
}
countryAutocomplete.onFocusChangeListener = OnFocusChangeListener { _, focused ->
val countryEntered = countryAutocomplete.text.toString()
if (focused) {
countryAutocomplete.showDropDown()
} else {
val countryEntered = countryAutocomplete.text.toString()
updateUiForCountryEntered(countryEntered)
}
}

val initialCountry = countryAdapter.getItem(0)
countryAutocomplete.setText(initialCountry.name)
selectedCountry = initialCountry
countryChangeCallback(initialCountry)
}

/**
* @param countryCode specify a country code to display in the input. The input will display
* the full country display name.
*/
fun setCountrySelected(countryCode: String?) {
if (countryCode == null) {
return
}
internal fun setCountrySelected(countryCode: String) {
updateUiForCountryEntered(getDisplayCountry(countryCode))
}

fun setCountryChangeListener(countryChangeListener: CountryChangeListener?) {
this.countryChangeListener = countryChangeListener
}

@VisibleForTesting
fun updateUiForCountryEntered(displayCountryEntered: String?) {
val displayCountry = CountryUtils.getCountryCode(displayCountryEntered)?.let {
if (selectedCountryCode == null || selectedCountryCode != it) {
selectedCountryCode = it
countryChangeListener?.onCountryChanged(it)
}
internal fun updateUiForCountryEntered(displayCountryEntered: String) {
val country = CountryUtils.getCountryByName(displayCountryEntered)

// If the user-typed country matches a valid country, update the selected country
// Otherwise, revert back to last valid country if country is not recognized.
val displayCountry = country?.let {
updatedSelectedCountryCode(it)
displayCountryEntered
} ?: selectedCountryCode?.let {
// Revert back to last valid country if country is not recognized.
getDisplayCountry(it)
}
} ?: selectedCountry.name

countryAutocomplete.setText(displayCountry)
}

private fun getDisplayCountry(countryCode: String): String {
return Locale("", countryCode).displayCountry
private fun updatedSelectedCountryCode(country: Country) {
if (selectedCountry != country) {
selectedCountry = country
countryChangeCallback(country)
}
}

internal interface CountryChangeListener {
fun onCountryChanged(countryCode: String)
private fun getDisplayCountry(countryCode: String): String {
return CountryUtils.getCountryByCode(countryCode)?.name
?: Locale("", countryCode).displayCountry
}
}
40 changes: 24 additions & 16 deletions stripe/src/main/java/com/stripe/android/view/CountryUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,42 @@ import java.util.Locale

internal object CountryUtils {

private val NO_POSTAL_CODE_COUNTRIES =
arrayOf("AE", "AG", "AN", "AO", "AW", "BF", "BI", "BJ", "BO", "BS", "BW", "BZ", "CD", "CF", "CG", "CI", "CK", "CM", "DJ", "DM", "ER", "FJ", "GD", "GH", "GM", "GN", "GQ", "GY", "HK", "IE", "JM", "KE", "KI", "KM", "KN", "KP", "LC", "ML", "MO", "MR", "MS", "MU", "MW", "NR", "NU", "PA", "QA", "RW", "SB", "SC", "SL", "SO", "SR", "ST", "SY", "TF", "TK", "TL", "TO", "TT", "TV", "TZ", "UG", "VU", "YE", "ZA", "ZW")
private val NO_POSTAL_CODE_COUNTRIES_SET = setOf(*NO_POSTAL_CODE_COUNTRIES)

private val COUNTRY_NAMES_TO_CODES: Map<String, String>
get() {
return Locale.getISOCountries()
.associateBy { Locale("", it).displayCountry }
private val NO_POSTAL_CODE_COUNTRIES = setOf(
"AE", "AG", "AN", "AO", "AW", "BF", "BI", "BJ", "BO", "BS", "BW", "BZ", "CD", "CF", "CG",
"CI", "CK", "CM", "DJ", "DM", "ER", "FJ", "GD", "GH", "GM", "GN", "GQ", "GY", "HK", "IE",
"JM", "KE", "KI", "KM", "KN", "KP", "LC", "ML", "MO", "MR", "MS", "MU", "MW", "NR", "NU",
"PA", "QA", "RW", "SB", "SC", "SL", "SO", "SR", "ST", "SY", "TF", "TK", "TL", "TO", "TT",
"TV", "TZ", "UG", "VU", "YE", "ZA", "ZW"
)

private val COUNTRIES: List<Country> =
Locale.getISOCountries().map { code ->
Country(code, Locale("", code).displayCountry)
}

@JvmSynthetic
internal fun getCountryCode(countryName: String?): String? {
return COUNTRY_NAMES_TO_CODES[countryName]
internal fun getCountryByName(countryName: String): Country? {
return COUNTRIES.firstOrNull { it.name == countryName }
}

@JvmSynthetic
internal fun getCountryByCode(countryCode: String): Country? {
return COUNTRIES.firstOrNull { it.code == countryCode }
}

@JvmSynthetic
internal fun getOrderedCountries(currentLocale: Locale): List<String> {
internal fun getOrderedCountries(currentLocale: Locale): List<Country> {
// Show user's current locale first, followed by countries alphabetized by display name
return listOf(currentLocale.displayCountry)
return listOfNotNull(getCountryByCode(currentLocale.country))
.plus(
COUNTRY_NAMES_TO_CODES.keys.toList()
.sortedWith(compareBy { it.toLowerCase(Locale.ROOT) })
.minus(currentLocale.displayCountry)
COUNTRIES
.sortedBy { it.name.toLowerCase(Locale.ROOT) }
.filterNot { it.code == currentLocale.country }
)
}

@JvmSynthetic
internal fun doesCountryUsePostalCode(countryCode: String): Boolean {
return !NO_POSTAL_CODE_COUNTRIES_SET.contains(countryCode)
return !NO_POSTAL_CODE_COUNTRIES.contains(countryCode)
}
}
31 changes: 10 additions & 21 deletions stripe/src/main/java/com/stripe/android/view/ShippingInfoWidget.kt
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ class ShippingInfoWidget @JvmOverloads constructor(
get() {
val address = Address.Builder()
.setCity(cityEditText.text?.toString())
.setCountry(countryAutoCompleteTextView.selectedCountryCode)
.setCountry(countryAutoCompleteTextView.selectedCountry.code)
.setLine1(addressEditText.text?.toString())
.setLine2(addressEditText2.text?.toString())
.setPostalCode(postalCodeEditText.text?.toString())
Expand Down Expand Up @@ -136,9 +136,7 @@ class ShippingInfoWidget @JvmOverloads constructor(
optionalShippingInfoFields = optionalAddressFields.orEmpty()
renderLabels()

countryAutoCompleteTextView.selectedCountryCode?.let { selectedCountryCode ->
renderCountrySpecificLabels(selectedCountryCode)
}
countryAutoCompleteTextView.selectedCountry.let(::renderCountrySpecificLabels)
}

/**
Expand All @@ -149,9 +147,7 @@ class ShippingInfoWidget @JvmOverloads constructor(
hiddenShippingInfoFields = hiddenAddressFields.orEmpty()
renderLabels()

countryAutoCompleteTextView.selectedCountryCode?.let { selectedCountryCode ->
renderCountrySpecificLabels(selectedCountryCode)
}
countryAutoCompleteTextView.selectedCountry.let(::renderCountrySpecificLabels)
}

/**
Expand Down Expand Up @@ -193,7 +189,7 @@ class ShippingInfoWidget @JvmOverloads constructor(

val isPostalCodeValid = shippingPostalCodeValidator.isValid(
postalCode,
countryAutoCompleteTextView.selectedCountryCode.orEmpty(),
countryAutoCompleteTextView.selectedCountry.code,
optionalShippingInfoFields,
hiddenShippingInfoFields
)
Expand Down Expand Up @@ -235,20 +231,13 @@ class ShippingInfoWidget @JvmOverloads constructor(
}

private fun initView() {
countryAutoCompleteTextView.setCountryChangeListener(
object : CountryAutoCompleteTextView.CountryChangeListener {
override fun onCountryChanged(countryCode: String) {
renderCountrySpecificLabels(countryCode)
}
}
)
countryAutoCompleteTextView.countryChangeCallback = ::renderCountrySpecificLabels

phoneNumberEditText.addTextChangedListener(PhoneNumberFormattingTextWatcher())
setupErrorHandling()
renderLabels()

countryAutoCompleteTextView.selectedCountryCode?.let { selectedCountryCode ->
renderCountrySpecificLabels(selectedCountryCode)
}
countryAutoCompleteTextView.selectedCountry.let(::renderCountrySpecificLabels)
}

private fun setupErrorHandling() {
Expand Down Expand Up @@ -303,16 +292,16 @@ class ShippingInfoWidget @JvmOverloads constructor(
}
}

private fun renderCountrySpecificLabels(countrySelected: String) {
when (countrySelected) {
private fun renderCountrySpecificLabels(country: Country) {
when (country.code) {
Locale.US.country -> renderUSForm()
Locale.UK.country -> renderGreatBritainForm()
Locale.CANADA.country -> renderCanadianForm()
else -> renderInternationalForm()
}

postalCodeTextInputLayout.visibility =
if (CountryUtils.doesCountryUsePostalCode(countrySelected) &&
if (CountryUtils.doesCountryUsePostalCode(country.code) &&
!isFieldHidden(CustomizableShippingField.POSTAL_CODE_FIELD)) {
View.VISIBLE
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ import org.robolectric.RobolectricTestRunner
class CountryAdapterTest {

private lateinit var countryAdapter: CountryAdapter
private lateinit var orderedCountries: List<String>
private lateinit var orderedCountries: List<Country>

private val suggestions: List<String>
private val suggestions: List<Country>
get() {
return (0 until countryAdapter.count).mapNotNull {
countryAdapter.getItem(it)
Expand Down Expand Up @@ -66,7 +66,7 @@ class CountryAdapterTest {
"United Kingdom",
"United States Minor Outlying Islands"
),
suggestions
suggestions.map { it.name }
)
}

Expand Down
Loading

0 comments on commit 3380d20

Please sign in to comment.