diff --git a/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/PromptFeature.kt b/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/PromptFeature.kt index fced4766373..4adb52b9167 100644 --- a/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/PromptFeature.kt +++ b/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/PromptFeature.kt @@ -42,11 +42,13 @@ import mozilla.components.concept.engine.prompt.PromptRequest.Share import mozilla.components.concept.engine.prompt.PromptRequest.SingleChoice import mozilla.components.concept.engine.prompt.PromptRequest.TextPrompt import mozilla.components.concept.engine.prompt.PromptRequest.TimeSelection +import mozilla.components.concept.storage.Address import mozilla.components.concept.storage.CreditCardEntry import mozilla.components.concept.storage.CreditCardValidationDelegate import mozilla.components.concept.storage.Login import mozilla.components.concept.storage.LoginEntry import mozilla.components.concept.storage.LoginValidationDelegate +import mozilla.components.feature.prompts.address.AddressPicker import mozilla.components.feature.prompts.concept.SelectablePromptView import mozilla.components.feature.prompts.creditcard.CreditCardPicker import mozilla.components.feature.prompts.creditcard.CreditCardSaveDialogFragment @@ -151,6 +153,8 @@ class PromptFeature private constructor( private val creditCardPickerView: SelectablePromptView? = null, private val onManageCreditCards: () -> Unit = {}, private val onSelectCreditCard: () -> Unit = {}, + private val addressPickerView: SelectablePromptView
? = null, + private val onSelectAddress: () -> Unit = {}, onNeedToRequestPermissions: OnNeedToRequestPermissions ) : LifecycleAwareFeature, PermissionsFeature, @@ -190,6 +194,8 @@ class PromptFeature private constructor( creditCardPickerView: SelectablePromptView? = null, onManageCreditCards: () -> Unit = {}, onSelectCreditCard: () -> Unit = {}, + addressPickerView: SelectablePromptView
? = null, + onSelectAddress: () -> Unit = {}, onNeedToRequestPermissions: OnNeedToRequestPermissions ) : this( container = PromptContainer.Activity(activity), @@ -207,7 +213,9 @@ class PromptFeature private constructor( onManageLogins = onManageLogins, creditCardPickerView = creditCardPickerView, onManageCreditCards = onManageCreditCards, - onSelectCreditCard = onSelectCreditCard + onSelectCreditCard = onSelectCreditCard, + addressPickerView = addressPickerView, + onSelectAddress = onSelectAddress ) constructor( @@ -226,6 +234,8 @@ class PromptFeature private constructor( creditCardPickerView: SelectablePromptView? = null, onManageCreditCards: () -> Unit = {}, onSelectCreditCard: () -> Unit = {}, + addressPickerView: SelectablePromptView
? = null, + onSelectAddress: () -> Unit = {}, onNeedToRequestPermissions: OnNeedToRequestPermissions ) : this( container = PromptContainer.Fragment(fragment), @@ -243,7 +253,9 @@ class PromptFeature private constructor( onManageLogins = onManageLogins, creditCardPickerView = creditCardPickerView, onManageCreditCards = onManageCreditCards, - onSelectCreditCard = onSelectCreditCard + onSelectCreditCard = onSelectCreditCard, + addressPickerView = addressPickerView, + onSelectAddress = onSelectAddress ) private val filePicker = FilePicker(container, store, customTabId, onNeedToRequestPermissions) @@ -264,6 +276,17 @@ class PromptFeature private constructor( ) } + @VisibleForTesting(otherwise = PRIVATE) + internal var addressPicker = + addressPickerView?.let { + AddressPicker( + store = store, + addressSelectBar = it, + selectAddressCallback = onSelectAddress, + sessionId = customTabId + ) + } + override val onNeedToRequestPermissions get() = filePicker.onNeedToRequestPermissions @@ -285,16 +308,29 @@ class PromptFeature private constructor( if (content.promptRequests.lastOrNull() != activePromptRequest) { // Dismiss any active select login or credit card prompt if it does // not match the current prompt request for the session. - if (activePromptRequest is SelectLoginPrompt) { - loginPicker?.dismissCurrentLoginSelect(activePromptRequest as SelectLoginPrompt) - } else if (activePromptRequest is SaveLoginPrompt) { - (activePrompt?.get() as? SaveLoginDialogFragment)?.dismissAllowingStateLoss() - } else if (activePromptRequest is SaveCreditCard) { - (activePrompt?.get() as? CreditCardSaveDialogFragment)?.dismissAllowingStateLoss() - } else if (activePromptRequest is SelectCreditCard) { - creditCardPicker?.dismissSelectCreditCardRequest( - activePromptRequest as SelectCreditCard - ) + when (activePromptRequest) { + is SelectLoginPrompt -> { + loginPicker?.dismissCurrentLoginSelect(activePromptRequest as SelectLoginPrompt) + } + is SaveLoginPrompt -> { + (activePrompt?.get() as? SaveLoginDialogFragment)?.dismissAllowingStateLoss() + } + is SaveCreditCard -> { + (activePrompt?.get() as? CreditCardSaveDialogFragment)?.dismissAllowingStateLoss() + } + is SelectCreditCard -> { + creditCardPicker?.dismissSelectCreditCardRequest( + activePromptRequest as SelectCreditCard + ) + } + is SelectAddress -> { + addressPicker?.dismissSelectAddressRequest( + activePromptRequest as SelectAddress + ) + } + else -> { + // no-op + } } onPromptRequested(state) @@ -426,6 +462,11 @@ class PromptFeature private constructor( loginPicker?.handleSelectLoginRequest(promptRequest) } } + is SelectAddress -> { + if (promptRequest.addresses.isNotEmpty()) { + addressPicker?.handleSelectAddressRequest(promptRequest) + } + } else -> handleDialogsRequest(promptRequest, session) } } @@ -839,8 +880,8 @@ class PromptFeature private constructor( is SelectLoginPrompt, is SelectCreditCard, is SaveCreditCard, + is SelectAddress, is Share -> true - is SelectAddress -> false is Alert, is TextPrompt, is Confirm, is Repost, is Popup -> promptAbuserDetector.shouldShowMoreDialogs } } @@ -872,6 +913,15 @@ class PromptFeature private constructor( } } + (activePromptRequest as? SelectAddress)?.let { selectAddressPrompt -> + addressPicker?.let { addressPicker -> + if (addressPickerView?.asView()?.isVisible == true) { + addressPicker.dismissSelectAddressRequest(selectAddressPrompt) + result = true + } + } + } + return result } diff --git a/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/address/AddressAdapter.kt b/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/address/AddressAdapter.kt new file mode 100644 index 00000000000..2dcea318ce8 --- /dev/null +++ b/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/address/AddressAdapter.kt @@ -0,0 +1,73 @@ +/* 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 mozilla.components.feature.prompts.address + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.annotation.VisibleForTesting +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import mozilla.components.concept.storage.Address +import mozilla.components.feature.prompts.R + +@VisibleForTesting +internal object AddressDiffCallback : DiffUtil.ItemCallback
() { + override fun areItemsTheSame(oldItem: Address, newItem: Address) = + oldItem.guid == newItem.guid + + override fun areContentsTheSame(oldItem: Address, newItem: Address) = + oldItem == newItem +} + +/** + * RecyclerView adapter for displaying address items. + */ +internal class AddressAdapter( + private val onAddressSelected: (Address) -> Unit +) : ListAdapter(AddressDiffCallback) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AddressViewHolder { + val view = LayoutInflater + .from(parent.context) + .inflate(R.layout.mozac_feature_prompts_address_list_item, parent, false) + return AddressViewHolder(view, onAddressSelected) + } + + override fun onBindViewHolder(holder: AddressViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +/** + * View holder for a address item. + */ +@VisibleForTesting +internal class AddressViewHolder( + itemView: View, + private val onAddressSelected: (Address) -> Unit +) : RecyclerView.ViewHolder(itemView), View.OnClickListener { + @VisibleForTesting + lateinit var address: Address + + init { + itemView.setOnClickListener(this) + } + + fun bind(address: Address) { + this.address = address + itemView.findViewById(R.id.address_name)?.text = address.displayFormat() + } + + override fun onClick(v: View?) { + onAddressSelected(address) + } +} + +/** + * Format the address details to be displayed to the user. + */ +fun Address.displayFormat(): String = "${this.streetAddress}, ${this.addressLevel2}, ${this.addressLevel1}, ${this.postalCode}" diff --git a/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/address/AddressPicker.kt b/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/address/AddressPicker.kt new file mode 100644 index 00000000000..17e5fb152cf --- /dev/null +++ b/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/address/AddressPicker.kt @@ -0,0 +1,83 @@ +/* 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 mozilla.components.feature.prompts.address + +import androidx.annotation.VisibleForTesting +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.engine.prompt.PromptRequest +import mozilla.components.concept.storage.Address +import mozilla.components.feature.prompts.concept.SelectablePromptView +import mozilla.components.feature.prompts.consumePromptFrom +import mozilla.components.support.base.log.logger.Logger + +/** + * Interactor that implements [SelectablePromptView.Listener] and notifies the feature about actions + * the user performed in the address picker. + * + * @property store The [BrowserStore] this feature should subscribe to. + * @property addressSelectBar The [SelectablePromptView] view into which the select address + * prompt will be inflated. + * @property selectAddressCallback A callback invoked when a user selects an address option + * from the select address prompt + * @property sessionId The session ID which requested the prompt. + */ +class AddressPicker( + private val store: BrowserStore, + private val addressSelectBar: SelectablePromptView
, + private val selectAddressCallback: () -> Unit = {}, + private var sessionId: String? = null +) : SelectablePromptView.Listener
{ + + init { + addressSelectBar.listener = this + } + + // The selected address option to confirm. + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var selectedAddress: Address? = null + + override fun onManageOptions() { + dismissSelectAddressRequest() + } + + override fun onOptionSelect(option: Address) { + selectedAddress = option + addressSelectBar.hidePrompt() + selectAddressCallback.invoke() + } + + /** + * Dismisses the active [PromptRequest.SelectAddress] request. + * + * @param promptRequest The current active [PromptRequest.SelectAddress] or null + * otherwise. + */ + @Suppress("TooGenericExceptionCaught") + fun dismissSelectAddressRequest(promptRequest: PromptRequest.SelectAddress? = null) { + addressSelectBar.hidePrompt() + + try { + if (promptRequest != null) { + promptRequest.onDismiss() + return + } + + store.consumePromptFrom(sessionId) { + it.onDismiss() + } + } catch (e: RuntimeException) { + Logger.error("Can't dismiss select address prompt", e) + } + } + + /** + * Shows the select address prompt in response to the [PromptRequest] event. + * + * @param request The [PromptRequest] containing the the address request data to be shown. + */ + internal fun handleSelectAddressRequest(request: PromptRequest.SelectAddress) { + addressSelectBar.showPrompt(request.addresses) + } +} diff --git a/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/address/AddressSelectBar.kt b/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/address/AddressSelectBar.kt new file mode 100644 index 00000000000..8b505945bc7 --- /dev/null +++ b/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/address/AddressSelectBar.kt @@ -0,0 +1,138 @@ +/* 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 mozilla.components.feature.prompts.address + +import android.content.Context +import android.content.res.ColorStateList +import android.util.AttributeSet +import android.view.View +import androidx.appcompat.widget.AppCompatImageView +import androidx.appcompat.widget.AppCompatTextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.withStyledAttributes +import androidx.core.view.isVisible +import androidx.core.widget.ImageViewCompat +import androidx.core.widget.TextViewCompat +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import mozilla.components.concept.storage.Address +import mozilla.components.feature.prompts.R +import mozilla.components.feature.prompts.concept.SelectablePromptView +import mozilla.components.support.ktx.android.view.hideKeyboard + +/** + * A customizable "Select addresses" bar implementing [SelectablePromptView]. + */ +class AddressSelectBar @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr), SelectablePromptView
{ + + private var view: View? = null + private var recyclerView: RecyclerView? = null + private var headerView: AppCompatTextView? = null + private var expanderView: AppCompatImageView? = null + private var headerTextStyle: Int? = null + + private val listAdapter = AddressAdapter { address -> + listener?.apply { + onOptionSelect(address) + } + } + + override var listener: SelectablePromptView.Listener
? = null + + init { + context.withStyledAttributes( + attrs, + R.styleable.AddressSelectBar, + defStyleAttr, + 0 + ) { + val textStyle = + getResourceId( + R.styleable.AddressSelectBar_mozacSelectAddressHeaderTextStyle, + 0 + ) + + if (textStyle > 0) { + headerTextStyle = textStyle + } + } + } + + override fun hidePrompt() { + this.isVisible = false + recyclerView?.isVisible = false + + listAdapter.submitList(null) + + toggleSelectAddressHeader(shouldExpand = false) + } + + override fun showPrompt(options: List
) { + if (view == null) { + view = View.inflate(context, LAYOUT_ID, this) + bindViews() + } + + listAdapter.submitList(options) + view?.isVisible = true + } + + private fun bindViews() { + recyclerView = findViewById(R.id.address_list).apply { + layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) + adapter = listAdapter + } + + headerView = findViewById(R.id.select_address_header).apply { + setOnClickListener { + toggleSelectAddressHeader(shouldExpand = recyclerView?.isVisible != true) + } + + headerTextStyle?.let { appearance -> + TextViewCompat.setTextAppearance(this, appearance) + currentTextColor.let { color -> + TextViewCompat.setCompoundDrawableTintList(this, ColorStateList.valueOf(color)) + } + } + } + + expanderView = + findViewById(R.id.mozac_feature_address_expander).apply { + headerView?.currentTextColor?.let { + ImageViewCompat.setImageTintList(this, ColorStateList.valueOf(it)) + } + } + } + + /** + * Toggles the visibility of the list of address items in the prompt. + * + * @param shouldExpand True if the list of addresses should be displayed, false otherwise. + */ + private fun toggleSelectAddressHeader(shouldExpand: Boolean) { + recyclerView?.isVisible = shouldExpand + + if (shouldExpand) { + view?.hideKeyboard() + expanderView?.rotation = ROTATE_180 + headerView?.contentDescription = + context.getString(R.string.mozac_feature_prompts_collapse_address_content_description) + } else { + expanderView?.rotation = 0F + headerView?.contentDescription = + context.getString(R.string.mozac_feature_prompts_expand_address_content_description) + } + } + + companion object { + val LAYOUT_ID = R.layout.mozac_feature_prompts_address_select_prompt + + private const val ROTATE_180 = 180F + } +} diff --git a/components/feature/prompts/src/main/res/layout/mozac_feature_prompts_address_list_item.xml b/components/feature/prompts/src/main/res/layout/mozac_feature_prompts_address_list_item.xml new file mode 100644 index 00000000000..3a63084b7e3 --- /dev/null +++ b/components/feature/prompts/src/main/res/layout/mozac_feature_prompts_address_list_item.xml @@ -0,0 +1,31 @@ + + + + + + diff --git a/components/feature/prompts/src/main/res/layout/mozac_feature_prompts_address_select_prompt.xml b/components/feature/prompts/src/main/res/layout/mozac_feature_prompts_address_select_prompt.xml new file mode 100644 index 00000000000..e1880ca504b --- /dev/null +++ b/components/feature/prompts/src/main/res/layout/mozac_feature_prompts_address_select_prompt.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + diff --git a/components/feature/prompts/src/main/res/values/attrs.xml b/components/feature/prompts/src/main/res/values/attrs.xml index 8f6ed0ab246..de216bfa8b5 100644 --- a/components/feature/prompts/src/main/res/values/attrs.xml +++ b/components/feature/prompts/src/main/res/values/attrs.xml @@ -16,4 +16,8 @@ + + + + diff --git a/components/feature/prompts/src/main/res/values/strings.xml b/components/feature/prompts/src/main/res/values/strings.xml index a4aa9ce1716..8d310b6bdce 100644 --- a/components/feature/prompts/src/main/res/values/strings.xml +++ b/components/feature/prompts/src/main/res/values/strings.xml @@ -116,4 +116,12 @@ Update card expiration date? Card number will be encrypted. Security coded won\'t be saved. + + + + Select addresses + + Expand suggested addresses + + Collapse suggested addresses