diff --git a/app/src/main/java/org/mozilla/fenix/settings/address/AddressUtils.kt b/app/src/main/java/org/mozilla/fenix/settings/address/AddressUtils.kt new file mode 100644 index 0000000000..1f360b3370 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/address/AddressUtils.kt @@ -0,0 +1,158 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.address + +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import mozilla.components.concept.storage.Address +import org.mozilla.fenix.R + +internal const val DEFAULT_COUNTRY = "US" + +/** + * Value type representing properties determined by the country used in an [Address]. + * This data is meant to mirror the data currently represented on desktop here: + * https://searchfox.org/mozilla-central/source/toolkit/components/formautofill/addressmetadata/addressReferences.js + * + * This can be expanded to included things like a list of applicable states/provinces per country + * or the names that should be used for each form field. + * + * Note: Most properties here need to be kept in sync with the data in the above desktop + * address reference file in order to prevent duplications when sync is enabled. There are + * ongoing conversations about how best to share that data cross-platform, if at all. + * Some more detail: https://bugzilla.mozilla.org/show_bug.cgi?id=1769809 + * + * Exceptions: [displayName] is a local property and stop-gap to a more robust solution. + * + * @property countryCode The country code used to lookup the address data. Should match desktop entries. + * @property displayName The name to display when selected. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal data class Country( + val countryCode: String, + val displayName: String, + @StringRes val subregionTitleResource: Int, + val subregions: List, +) + +internal object AddressUtils { + /** + * The current list of supported countries. + */ + val countries = mapOf( + "CA" to Country( + countryCode = "CA", + displayName = "Canada", + subregionTitleResource = R.string.addresses_province, + subregions = Subregions.CA, + ), + "US" to Country( + countryCode = "US", + displayName = "United States", + subregionTitleResource = R.string.addresses_state, + subregions = Subregions.US, + ), + ) + + /** + * Get the country code associated with a [Country.displayName], or the [DEFAULT_COUNTRY] code + * if the display name is not supported. + */ + fun getCountryCode(displayName: String) = countries.values.find { + it.displayName == displayName + }?.countryCode ?: DEFAULT_COUNTRY +} + +/** + * Convert a [Country.displayName] to the associated country code. + */ +fun String.toCountryCode() = AddressUtils.getCountryCode(this) + +private object Subregions { + // This data is meant to mirror the data currently represented on desktop here: + // https://searchfox.org/mozilla-central/source/toolkit/components/formautofill/addressmetadata/addressReferences.js + val CA = listOf( + "Alberta", + "British Columbia", + "Manitoba", + "New Brunswick", + "Newfoundland and Labrador", + "Northwest Territories", + "Nova Scotia", + "Nunavut", + "Ontario", + "Prince Edward Island", + "Quebec", + "Saskatchewan", + "Yukon", + ) + + // This data is meant to mirror the data currently represented on desktop here: + // https://searchfox.org/mozilla-central/source/toolkit/components/formautofill/addressmetadata/addressReferences.js + val US = listOf( + "Alabama", + "Alaska", + "American Samoa", + "Arizona", + "Arkansas", + "Armed Forces (AA)", + "Armed Forces (AE)", + "Armed Forces (AP)", + "California", + "Colorado", + "Connecticut", + "Delaware", + "District of Columbia", + "Florida", + "Georgia", + "Guam", + "Hawaii", + "Idaho", + "Illinois", + "Indiana", + "Iowa", + "Kansas", + "Kentucky", + "Louisiana", + "Maine", + "Marshall Islands", + "Maryland", + "Massachusetts", + "Michigan", + "Micronesia", + "Minnesota", + "Mississippi", + "Missouri", + "Montana", + "Nebraska", + "Nevada", + "New Hampshire", + "New Jersey", + "New Mexico", + "New York", + "North Carolina", + "North Dakota", + "Northern Mariana Islands", + "Ohio", + "Oklahoma", + "Oregon", + "Palau", + "Pennsylvania", + "Puerto Rico", + "Rhode Island", + "South Carolina", + "South Dakota", + "Tennessee", + "Texas", + "Utah", + "Vermont", + "Virgin Islands", + "Virginia", + "Washington", + "West Virginia", + "Wisconsin", + "Wyoming", + ) +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/address/view/AddressEditorView.kt b/app/src/main/java/org/mozilla/fenix/settings/address/view/AddressEditorView.kt index 5f6840beaa..e7863d733a 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/address/view/AddressEditorView.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/address/view/AddressEditorView.kt @@ -6,11 +6,12 @@ package org.mozilla.fenix.settings.address.view import android.content.Context import android.content.DialogInterface +import android.view.View +import android.widget.AdapterView import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible import mozilla.components.concept.storage.Address import android.widget.ArrayAdapter -import androidx.annotation.VisibleForTesting import mozilla.components.browser.state.search.RegionState import mozilla.components.concept.storage.UpdatableAddressFields import mozilla.components.support.ktx.android.view.hideKeyboard @@ -19,12 +20,19 @@ import org.mozilla.fenix.R import org.mozilla.fenix.databinding.FragmentAddressEditorBinding import org.mozilla.fenix.ext.placeCursorAtEnd import org.mozilla.fenix.settings.address.AddressEditorFragment +import org.mozilla.fenix.settings.address.AddressUtils.countries +import org.mozilla.fenix.settings.address.Country +import org.mozilla.fenix.settings.address.DEFAULT_COUNTRY import org.mozilla.fenix.settings.address.interactor.AddressEditorInteractor - -internal const val DEFAULT_COUNTRY = "US" +import org.mozilla.fenix.settings.address.toCountryCode /** - * Shows an address editor for adding or updating an address. + * An address editor for adding or updating an address. + * + * @param binding The binding used to display the view. + * @param interactor [AddressEditorInteractor] used to respond to any user interactions. + * @param region If the [RegionState] is available, it will be used to set the country when adding a new address. + * @param address An [Address] to edit. */ class AddressEditorView( private val binding: FragmentAddressEditorBinding, @@ -33,36 +41,6 @@ class AddressEditorView( private val address: Address? = null ) { - /** - * Value type representing properties determined by the country used in an [Address]. - * This data is meant to mirror the data currently represented on desktop here: - * https://searchfox.org/mozilla-central/source/toolkit/components/formautofill/addressmetadata/addressReferences.js - * - * This can be expanded to included things like a list of applicable states/provinces per country - * or the names that should be used for each form field. - * - * Note: Most properties here need to be kept in sync with the data in the above desktop - * address reference file in order to prevent duplications when sync is enabled. There are - * ongoing conversations about how best to share that data cross-platform, if at all. - * Some more detail: https://bugzilla.mozilla.org/show_bug.cgi?id=1769809 - * - * Exceptions: [displayName] is a local property and stop-gap to a more robust solution. - * - * @property key The country code used to lookup the address data. Should match desktop entries. - * @property displayName The name to display when selected. - */ - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal data class Country( - val key: String, - val displayName: String, - ) - - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal val countries = mapOf( - "CA" to Country("CA", "Canada"), - "US" to Country("US", "United States"), - ) - /** * Binds the view in the [AddressEditorFragment], using the current [Address] if available. */ @@ -91,7 +69,6 @@ class AddressEditorView( binding.streetAddressInput.setText(address.streetAddress) binding.cityInput.setText(address.addressLevel2) - binding.stateInput.setText(address.addressLevel1) binding.zipInput.setText(address.postalCode) binding.deleteButton.apply { @@ -102,7 +79,7 @@ class AddressEditorView( } } - bindCountryDropdown() + bindDropdowns() } internal fun saveAddress() { @@ -116,7 +93,7 @@ class AddressEditorView( streetAddress = binding.streetAddressInput.text.toString(), addressLevel3 = "", addressLevel2 = "", - addressLevel1 = "", + addressLevel1 = binding.subregionDropDown.selectedItem.toString(), postalCode = binding.zipInput.text.toString(), country = binding.countryDropDown.selectedItem.toString().toCountryCode(), tel = binding.phoneInput.text.toString(), @@ -143,26 +120,59 @@ class AddressEditorView( }.show() } - private fun bindCountryDropdown() { - val adapter = ArrayAdapter( + private fun bindDropdowns() { + val adapter = ArrayAdapter( binding.root.context, android.R.layout.simple_spinner_dropdown_item, + countries.values.map { it.displayName } ) val selectedCountryKey = (address?.country ?: region?.home).takeIf { it in countries.keys } ?: DEFAULT_COUNTRY - var selectedPosition = -1 - countries.values.forEachIndexed { index, country -> - if (country.key == selectedCountryKey) selectedPosition = index - adapter.add(country.displayName) - } + + val selectedPosition = countries.values + .indexOfFirst { it.countryCode == selectedCountryKey } + .takeIf { it > 0 } + ?: 0 binding.countryDropDown.adapter = adapter binding.countryDropDown.setSelection(selectedPosition) + binding.countryDropDown.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected( + parent: AdapterView<*>?, + view: View?, + position: Int, + id: Long + ) { + val newCountryKey = binding.countryDropDown.selectedItem.toString().toCountryCode() + countries[newCountryKey]?.let { country -> + bindSubregionDropdown(country) + } + } + + override fun onNothingSelected(p0: AdapterView<*>?) = Unit + } + + countries[selectedCountryKey]?.let { country -> + bindSubregionDropdown(country) + } } - private fun String.toCountryCode() = countries.values.find { - it.displayName == this - }?.key ?: DEFAULT_COUNTRY + private fun bindSubregionDropdown(country: Country) { + val subregions = country.subregions + val selectedSubregion = address?.addressLevel1?.takeIf { it in subregions } + ?: subregions.first() + + val adapter = ArrayAdapter( + binding.root.context, + android.R.layout.simple_spinner_dropdown_item, + country.subregions + ) + + val selectedPosition = subregions.indexOf(selectedSubregion).takeIf { it > 0 } ?: 0 + binding.subregionDropDown.adapter = adapter + binding.subregionDropDown.setSelection(selectedPosition) + binding.subregionTitle.setText(country.subregionTitleResource) + } } diff --git a/app/src/main/res/layout/fragment_address_editor.xml b/app/src/main/res/layout/fragment_address_editor.xml index 0716912b27..030f54ebde 100644 --- a/app/src/main/res/layout/fragment_address_editor.xml +++ b/app/src/main/res/layout/fragment_address_editor.xml @@ -234,50 +234,40 @@ - - + + app:layout_constraintTop_toBottomOf="@id/city_layout" + app:layout_constraintBottom_toTopOf="@id/zip_title"> - - - + android:textSize="12sp" + android:labelFor="@id/subregion_drop_down" + app:fontFamily="@font/metropolis_semibold" + android:text="@string/addresses_state" /> - + + + + + + app:layout_constraintTop_toBottomOf="@+id/subregion_layout" /> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 86dd6757fd..895f7f91ce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1567,8 +1567,10 @@ Street Address City - + State + + Province Zip diff --git a/app/src/test/java/org/mozilla/fenix/settings/address/AddressEditorViewTest.kt b/app/src/test/java/org/mozilla/fenix/settings/address/AddressEditorViewTest.kt index 22c39b8f09..45930fce64 100644 --- a/app/src/test/java/org/mozilla/fenix/settings/address/AddressEditorViewTest.kt +++ b/app/src/test/java/org/mozilla/fenix/settings/address/AddressEditorViewTest.kt @@ -13,8 +13,10 @@ import io.mockk.verify import kotlinx.coroutines.runBlocking import mozilla.components.browser.state.search.RegionState import mozilla.components.concept.storage.Address +import mozilla.components.concept.storage.UpdatableAddressFields import mozilla.components.support.test.robolectric.testContext import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -23,7 +25,6 @@ import org.mozilla.fenix.databinding.FragmentAddressEditorBinding import org.mozilla.fenix.helpers.FenixRobolectricTestRunner import org.mozilla.fenix.settings.address.interactor.AddressEditorInteractor import org.mozilla.fenix.settings.address.view.AddressEditorView -import org.mozilla.fenix.settings.address.view.DEFAULT_COUNTRY @RunWith(FenixRobolectricTestRunner::class) class AddressEditorViewTest { @@ -45,6 +46,63 @@ class AddressEditorViewTest { addressEditorView = spyk(AddressEditorView(binding, interactor)) } + @Test + fun `GIVEN an existing address WHEN the save button is clicked THEN interactor updates address`() { + val country = AddressUtils.countries["US"]!! + val address = generateAddress(country = country.countryCode, addressLevel1 = country.subregions[0]) + val addressEditorView = AddressEditorView( + binding = binding, + interactor = interactor, + address = address, + ) + + addressEditorView.bind() + addressEditorView.saveAddress() + + val expected = UpdatableAddressFields( + givenName = address.givenName, + additionalName = address.additionalName, + familyName = address.familyName, + organization = "", + streetAddress = address.streetAddress, + addressLevel3 = "", + addressLevel2 = "", + addressLevel1 = address.addressLevel1, + postalCode = address.postalCode, + country = address.country, + tel = address.tel, + email = address.email, + ) + verify { interactor.onUpdateAddress(address.guid, expected) } + } + + @Test + fun `GIVEN a new address WHEN the save button is clicked THEN interactor saves new address`() { + val addressEditorView = AddressEditorView( + binding = binding, + interactor = interactor, + ) + + addressEditorView.bind() + addressEditorView.saveAddress() + + val expected = UpdatableAddressFields( + givenName = "", + additionalName = "", + familyName = "", + organization = "", + streetAddress = "", + addressLevel3 = "", + addressLevel2 = "", + addressLevel1 = "Alabama", + postalCode = "", + country = "US", + tel = "", + email = "", + ) + verify { interactor.onSaveAddress(expected) } + } + @Test fun `WHEN the cancel button is clicked THEN interactor is called`() { addressEditorView.bind() @@ -68,7 +126,7 @@ class AddressEditorViewTest { addressEditorView.bind() assertEquals("PostalCode", binding.zipInput.text.toString()) - assertEquals("State", binding.stateInput.text.toString()) + assertEquals(address.addressLevel1, binding.subregionDropDown.selectedItem.toString()) assertEquals("City", binding.cityInput.text.toString()) assertEquals("Street", binding.streetAddressInput.text.toString()) assertEquals("Family", binding.lastNameInput.text.toString()) @@ -108,6 +166,72 @@ class AddressEditorViewTest { verify { addressEditorView.showConfirmDeleteAddressDialog(view.context, "123") } } + @Test + fun `GIVEN existing address with correct subregion and country WHEN subregion dropdown is bound THEN adapter sets subregion dropdown to address`() { + val address = generateAddress(country = "US", addressLevel1 = "Oregon") + + val addressEditorView = AddressEditorView( + binding = binding, + interactor = interactor, + address = address, + ) + addressEditorView.bind() + + assertEquals("Oregon", binding.subregionDropDown.selectedItem.toString()) + } + + @Test + fun `GIVEN existing address subregion outside of country WHEN subregion dropdown is bound THEN dropdown defaults to first subregion entry for country`() { + val address = generateAddress(country = "CA", addressLevel1 = "Alabama") + + val addressEditorView = AddressEditorView( + binding = binding, + interactor = interactor, + address = address, + ) + addressEditorView.bind() + + assertEquals("Alberta", binding.subregionDropDown.selectedItem.toString()) + } + + @Test + fun `GIVEN no existing address WHEN subregion dropdown is bound THEN dropdown defaults to first subregion of default country`() { + val addressEditorView = AddressEditorView( + binding = binding, + interactor = interactor, + ) + addressEditorView.bind() + + assertEquals("Alabama", binding.subregionDropDown.selectedItem.toString()) + } + + @Test + fun `WHEN country is changed THEN available subregions are updated`() { + val addressEditorView = AddressEditorView( + binding = binding, + interactor = interactor, + ) + addressEditorView.bind() + + assertEquals("Alabama", binding.subregionDropDown.selectedItem.toString()) + binding.countryDropDown.setSelection(0) + assertNotEquals("Alabama", binding.subregionDropDown.selectedItem.toString()) + } + + @Test + fun `GIVEN existing address not in available countries WHEN view is bound THEN country and subregion dropdowns are set to default `() { + val address = generateAddress(country = "I AM NOT A COUNTRY", addressLevel1 = "I AM NOT A STATE") + val addressEditorView = AddressEditorView( + binding = binding, + interactor = interactor, + address = address, + ) + addressEditorView.bind() + + assertEquals("United States", binding.countryDropDown.selectedItem.toString()) + assertEquals("Alabama", binding.subregionDropDown.selectedItem.toString()) + } + @Test fun `GIVEN existing address WHEN country dropdown is bound THEN adapter sets country dropdown to address`() { val addressEditorView = spyk( @@ -119,7 +243,7 @@ class AddressEditorViewTest { ) addressEditorView.bind() - assertEquals(addressEditorView.countries["CA"]?.displayName, binding.countryDropDown.selectedItem.toString()) + assertEquals(AddressUtils.countries["CA"]?.displayName, binding.countryDropDown.selectedItem.toString()) } @Test @@ -134,7 +258,7 @@ class AddressEditorViewTest { ) addressEditorView.bind() - assertEquals(addressEditorView.countries[DEFAULT_COUNTRY]!!.displayName, binding.countryDropDown.selectedItem.toString()) + assertEquals(AddressUtils.countries[DEFAULT_COUNTRY]!!.displayName, binding.countryDropDown.selectedItem.toString()) } @Test @@ -147,7 +271,7 @@ class AddressEditorViewTest { ) addressEditorView.bind() - assertEquals(addressEditorView.countries["CA"]?.displayName, binding.countryDropDown.selectedItem.toString()) + assertEquals(AddressUtils.countries["CA"]?.displayName, binding.countryDropDown.selectedItem.toString()) } @Test @@ -160,7 +284,7 @@ class AddressEditorViewTest { ) addressEditorView.bind() - assertEquals(addressEditorView.countries[DEFAULT_COUNTRY]!!.displayName, binding.countryDropDown.selectedItem.toString()) + assertEquals(AddressUtils.countries[DEFAULT_COUNTRY]!!.displayName, binding.countryDropDown.selectedItem.toString()) } @Test @@ -173,10 +297,10 @@ class AddressEditorViewTest { ) addressEditorView.bind() - assertEquals(addressEditorView.countries[DEFAULT_COUNTRY]!!.displayName, binding.countryDropDown.selectedItem.toString()) + assertEquals(AddressUtils.countries[DEFAULT_COUNTRY]!!.displayName, binding.countryDropDown.selectedItem.toString()) } - private fun generateAddress(country: String = "US") = Address( + private fun generateAddress(country: String = "US", addressLevel1: String = "Oregon") = Address( guid = "123", givenName = "Given", additionalName = "Additional", @@ -185,7 +309,7 @@ class AddressEditorViewTest { streetAddress = "Street", addressLevel3 = "Suburb", addressLevel2 = "City", - addressLevel1 = "State", + addressLevel1 = addressLevel1, postalCode = "PostalCode", country = country, tel = "Telephone",