| // Copyright 2016 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| package org.chromium.chrome.browser.payments.ui; |
| |
| import android.animation.Animator; |
| import android.animation.AnimatorListenerAdapter; |
| import android.animation.AnimatorSet; |
| import android.animation.ObjectAnimator; |
| import android.app.Activity; |
| import android.content.Context; |
| import android.content.DialogInterface; |
| import android.graphics.Color; |
| import android.graphics.drawable.ColorDrawable; |
| import android.os.Handler; |
| import android.support.v4.view.animation.FastOutLinearInInterpolator; |
| import android.support.v4.view.animation.LinearOutSlowInInterpolator; |
| import android.support.v7.widget.Toolbar.OnMenuItemClickListener; |
| import android.text.InputFilter; |
| import android.text.Spanned; |
| import android.text.TextWatcher; |
| import android.view.KeyEvent; |
| import android.view.LayoutInflater; |
| import android.view.MenuItem; |
| import android.view.View; |
| import android.view.View.OnClickListener; |
| import android.view.ViewGroup; |
| import android.view.WindowManager; |
| import android.view.inputmethod.EditorInfo; |
| import android.widget.Button; |
| import android.widget.CheckBox; |
| import android.widget.CompoundButton; |
| import android.widget.EditText; |
| import android.widget.LinearLayout; |
| import android.widget.Spinner; |
| import android.widget.TextView; |
| |
| import org.chromium.base.ApiCompatibilityUtils; |
| import org.chromium.base.VisibleForTesting; |
| import org.chromium.chrome.R; |
| import org.chromium.chrome.browser.autofill.PhoneNumberUtil; |
| import org.chromium.chrome.browser.customtabs.CustomTabActivity; |
| import org.chromium.chrome.browser.payments.ui.PaymentRequestUI.PaymentRequestObserverForTest; |
| import org.chromium.chrome.browser.preferences.autofill.CreditCardNumberFormattingTextWatcher; |
| import org.chromium.chrome.browser.widget.AlwaysDismissedDialog; |
| import org.chromium.chrome.browser.widget.FadingEdgeScrollView; |
| import org.chromium.chrome.browser.widget.FadingShadow; |
| import org.chromium.chrome.browser.widget.FadingShadowView; |
| import org.chromium.ui.UiUtils; |
| |
| import java.util.ArrayList; |
| import java.util.List; |
| import java.util.regex.Pattern; |
| |
| import javax.annotation.Nullable; |
| |
| /** |
| * The PaymentRequest editor dialog. Can be used for editing contact information, shipping address, |
| * billing address, and credit cards. |
| */ |
| public class EditorView extends AlwaysDismissedDialog implements OnClickListener, |
| DialogInterface.OnShowListener, |
| DialogInterface.OnDismissListener { |
| /** The indicator for input fields that are required. */ |
| public static final String REQUIRED_FIELD_INDICATOR = "*"; |
| |
| /** Help page that the user is directed to when asking for help. */ |
| private static final String HELP_URL = "https://support.google.com/chrome/answer/142893?hl=en"; |
| |
| /** Duration of the animation to show the UI to full height. */ |
| private static final int DIALOG_ENTER_ANIMATION_MS = 300; |
| |
| /** Duration of the animation to hide the UI. */ |
| private static final int DIALOG_EXIT_ANIMATION_MS = 195; |
| |
| private final Context mContext; |
| private final PaymentRequestObserverForTest mObserverForTest; |
| private final Handler mHandler; |
| private final TextView.OnEditorActionListener mEditorActionListener; |
| private final int mHalfRowMargin; |
| private final List<EditorFieldView> mFieldViews; |
| private final List<EditText> mEditableTextFields; |
| private final List<Spinner> mDropdownFields; |
| private final InputFilter mCardNumberInputFilter; |
| private final TextWatcher mCardNumberFormatter; |
| |
| @Nullable private TextWatcher mPhoneFormatter; |
| private View mLayout; |
| private EditorModel mEditorModel; |
| private Button mDoneButton; |
| private ViewGroup mDataView; |
| private View mFooter; |
| @Nullable private TextView mCardInput; |
| @Nullable private TextView mPhoneInput; |
| |
| private Animator mDialogInOutAnimator; |
| |
| /** |
| * Builds the editor view. |
| * |
| * @param activity The activity on top of which the UI should be displayed. |
| * @param observerForTest Optional event observer for testing. |
| */ |
| public EditorView(Activity activity, PaymentRequestObserverForTest observerForTest) { |
| super(activity, R.style.FullscreenWhite); |
| // Sets transparent background for animating content view. |
| getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); |
| mContext = activity; |
| mObserverForTest = observerForTest; |
| mHandler = new Handler(); |
| mEditorActionListener = new TextView.OnEditorActionListener() { |
| @Override |
| public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { |
| if (actionId == EditorInfo.IME_ACTION_DONE) { |
| mDoneButton.performClick(); |
| return true; |
| } else if (actionId == EditorInfo.IME_ACTION_NEXT) { |
| View next = v.focusSearch(View.FOCUS_FORWARD); |
| if (next != null) { |
| next.requestFocus(); |
| return true; |
| } |
| } |
| return false; |
| } |
| }; |
| |
| mHalfRowMargin = activity.getResources().getDimensionPixelSize( |
| R.dimen.payments_section_large_spacing); |
| mFieldViews = new ArrayList<>(); |
| mEditableTextFields = new ArrayList<>(); |
| mDropdownFields = new ArrayList<>(); |
| |
| final Pattern cardNumberPattern = Pattern.compile("^[\\d- ]*$"); |
| mCardNumberInputFilter = new InputFilter() { |
| @Override |
| public CharSequence filter( |
| CharSequence source, int start, int end, Spanned dest, int dstart, int dend) { |
| // Accept deletions. |
| if (start == end) return null; |
| |
| // Accept digits, "-", and spaces. |
| if (cardNumberPattern.matcher(source.subSequence(start, end)).matches()) { |
| return null; |
| } |
| |
| // Reject everything else. |
| return ""; |
| } |
| }; |
| |
| mCardNumberFormatter = new CreditCardNumberFormattingTextWatcher(); |
| mPhoneFormatter = new PhoneNumberUtil.FormatTextWatcher(); |
| } |
| |
| /** Prevents screenshots of this editor. */ |
| public void disableScreenshots() { |
| WindowManager.LayoutParams attributes = getWindow().getAttributes(); |
| attributes.flags |= WindowManager.LayoutParams.FLAG_SECURE; |
| getWindow().setAttributes(attributes); |
| } |
| |
| /** Launches the Autofill help page on top of the current Context. */ |
| public static void launchAutofillHelpPage(Context context) { |
| CustomTabActivity.showInfoPage(context, HELP_URL); |
| } |
| |
| /** |
| * Prepares the toolbar for use. |
| * |
| * Many of the things that would ideally be set as attributes don't work and need to be set |
| * programmatically. This is likely due to how we compile the support libraries. |
| */ |
| private void prepareToolbar() { |
| EditorDialogToolbar toolbar = (EditorDialogToolbar) mLayout.findViewById(R.id.action_bar); |
| toolbar.setTitle(mEditorModel.getTitle()); |
| toolbar.setTitleTextColor(Color.WHITE); |
| toolbar.setShowDeleteMenuItem(false); |
| |
| // Show the help article when the user asks. |
| toolbar.setOnMenuItemClickListener(new OnMenuItemClickListener() { |
| @Override |
| public boolean onMenuItemClick(MenuItem item) { |
| launchAutofillHelpPage(mContext); |
| return true; |
| } |
| }); |
| |
| // Cancel editing when the user hits the back arrow. |
| toolbar.setNavigationContentDescription(R.string.cancel); |
| toolbar.setNavigationIcon(R.drawable.ic_arrow_back_white_24dp); |
| toolbar.setNavigationOnClickListener(new View.OnClickListener() { |
| @Override |
| public void onClick(View v) { |
| dismissDialog(); |
| } |
| }); |
| |
| // Make it appear that the toolbar is floating by adding a shadow. |
| FadingShadowView shadow = (FadingShadowView) mLayout.findViewById(R.id.shadow); |
| shadow.init(ApiCompatibilityUtils.getColor(mContext.getResources(), |
| R.color.toolbar_shadow_color), FadingShadow.POSITION_TOP); |
| |
| // The top shadow is handled by the toolbar, so hide the one used in the field editor. |
| FadingEdgeScrollView scrollView = |
| (FadingEdgeScrollView) mLayout.findViewById(R.id.scroll_view); |
| scrollView.setEdgeVisibility( |
| FadingEdgeScrollView.DRAW_NO_EDGE, FadingEdgeScrollView.DRAW_FADING_EDGE); |
| } |
| |
| /** |
| * Checks if all of the fields in the form are valid and updates the displayed errors. If there |
| * are any invalid fields, makes sure that one of them is focused. Called when user taps [SAVE]. |
| * |
| * @return Whether all fields contain valid information. |
| */ |
| private boolean validateForm() { |
| final List<EditorFieldView> invalidViews = getViewsWithInvalidInformation(true); |
| |
| // Iterate over all the fields to update what errors are displayed, which is necessary to |
| // to clear existing errors on any newly valid fields. |
| for (int i = 0; i < mFieldViews.size(); i++) { |
| EditorFieldView fieldView = mFieldViews.get(i); |
| fieldView.updateDisplayedError(invalidViews.contains(fieldView)); |
| } |
| |
| if (!invalidViews.isEmpty()) { |
| // Make sure that focus is on an invalid field. |
| EditorFieldView focusedField = getEditorTextField(getCurrentFocus()); |
| if (invalidViews.contains(focusedField)) { |
| // The focused field is invalid, but it may be scrolled off screen. Scroll to it. |
| focusedField.scrollToAndFocus(); |
| } else { |
| // Some fields are invalid, but none of the are focused. Scroll to the first invalid |
| // field and focus it. |
| invalidViews.get(0).scrollToAndFocus(); |
| } |
| } |
| |
| return invalidViews.isEmpty(); |
| } |
| |
| /** @return The validatable item for the given view. */ |
| private EditorFieldView getEditorTextField(View v) { |
| if (v instanceof TextView && v.getParent() != null |
| && v.getParent() instanceof EditorFieldView) { |
| return (EditorFieldView) v.getParent(); |
| } else if (v instanceof Spinner && v.getTag() != null) { |
| return (EditorFieldView) v.getTag(); |
| } else { |
| return null; |
| } |
| } |
| |
| @Override |
| public void onClick(View view) { |
| if (view.getId() == R.id.payments_edit_done_button) { |
| if (validateForm()) { |
| mEditorModel.done(); |
| mEditorModel = null; |
| dismissDialog(); |
| return; |
| } |
| |
| if (mObserverForTest != null) mObserverForTest.onPaymentRequestEditorValidationError(); |
| } else if (view.getId() == R.id.payments_edit_cancel_button) { |
| dismissDialog(); |
| } |
| } |
| |
| private void dismissDialog() { |
| if (mDialogInOutAnimator != null || !isShowing()) return; |
| |
| Animator dropDown = |
| ObjectAnimator.ofFloat(mLayout, View.TRANSLATION_Y, 0f, mLayout.getHeight()); |
| Animator fadeOut = ObjectAnimator.ofFloat(mLayout, View.ALPHA, mLayout.getAlpha(), 0f); |
| AnimatorSet animatorSet = new AnimatorSet(); |
| animatorSet.playTogether(dropDown, fadeOut); |
| |
| mDialogInOutAnimator = animatorSet; |
| mDialogInOutAnimator.setDuration(DIALOG_EXIT_ANIMATION_MS); |
| mDialogInOutAnimator.setInterpolator(new FastOutLinearInInterpolator()); |
| mDialogInOutAnimator.addListener(new AnimatorListenerAdapter() { |
| @Override |
| public void onAnimationEnd(Animator animation) { |
| mDialogInOutAnimator = null; |
| dismiss(); |
| } |
| }); |
| |
| mDialogInOutAnimator.start(); |
| } |
| |
| @Override |
| public void onDismiss(DialogInterface dialog) { |
| if (mEditorModel != null) mEditorModel.cancel(); |
| removeTextChangedListenersAndInputFilters(); |
| } |
| |
| private void prepareButtons() { |
| mDoneButton = (Button) mLayout.findViewById(R.id.button_primary); |
| mDoneButton.setId(R.id.payments_edit_done_button); |
| mDoneButton.setOnClickListener(this); |
| |
| Button cancelButton = (Button) mLayout.findViewById(R.id.button_secondary); |
| cancelButton.setId(R.id.payments_edit_cancel_button); |
| cancelButton.setOnClickListener(this); |
| } |
| |
| /** |
| * Create the visual representation of the EditorModel. |
| * |
| * This would be more optimal as a RelativeLayout, but because it's dynamically generated, it's |
| * much more human-parsable with inefficient LinearLayouts for half-width controls sharing rows. |
| */ |
| private void prepareEditor() { |
| // Ensure the layout is empty. |
| removeTextChangedListenersAndInputFilters(); |
| mDataView = (ViewGroup) mLayout.findViewById(R.id.contents); |
| mDataView.removeAllViews(); |
| mFieldViews.clear(); |
| mEditableTextFields.clear(); |
| mDropdownFields.clear(); |
| |
| // Add Views for each of the {@link EditorFields}. |
| for (int i = 0; i < mEditorModel.getFields().size(); i++) { |
| EditorFieldModel fieldModel = mEditorModel.getFields().get(i); |
| EditorFieldModel nextFieldModel = null; |
| |
| boolean isLastField = i == mEditorModel.getFields().size() - 1; |
| boolean useFullLine = fieldModel.isFullLine(); |
| if (!isLastField && !useFullLine) { |
| // If the next field isn't full, stretch it out. |
| nextFieldModel = mEditorModel.getFields().get(i + 1); |
| if (nextFieldModel.isFullLine()) useFullLine = true; |
| } |
| |
| if (useFullLine || isLastField) { |
| addFieldViewToEditor(mDataView, fieldModel); |
| } else { |
| // Create a LinearLayout to put it and the next view side by side. |
| LinearLayout rowLayout = new LinearLayout(mContext); |
| mDataView.addView(rowLayout); |
| |
| View firstView = addFieldViewToEditor(rowLayout, fieldModel); |
| View lastView = addFieldViewToEditor(rowLayout, nextFieldModel); |
| |
| LinearLayout.LayoutParams firstParams = |
| (LinearLayout.LayoutParams) firstView.getLayoutParams(); |
| LinearLayout.LayoutParams lastParams = |
| (LinearLayout.LayoutParams) lastView.getLayoutParams(); |
| |
| firstParams.width = 0; |
| firstParams.weight = 1; |
| ApiCompatibilityUtils.setMarginEnd(firstParams, mHalfRowMargin); |
| lastParams.width = 0; |
| lastParams.weight = 1; |
| i = i + 1; |
| } |
| } |
| |
| // Add the footer. |
| mDataView.addView(mFooter); |
| } |
| |
| private void removeTextChangedListenersAndInputFilters() { |
| if (mCardInput != null) { |
| mCardInput.removeTextChangedListener(mCardNumberFormatter); |
| mCardInput.setFilters(new InputFilter[0]); // Null is not allowed. |
| mCardInput = null; |
| } |
| |
| if (mPhoneInput != null) { |
| mPhoneInput.removeTextChangedListener(mPhoneFormatter); |
| mPhoneInput = null; |
| } |
| } |
| |
| private View addFieldViewToEditor(ViewGroup parent, final EditorFieldModel fieldModel) { |
| View childView = null; |
| |
| if (fieldModel.getInputTypeHint() == EditorFieldModel.INPUT_TYPE_HINT_ICONS) { |
| childView = new EditorIconsField(mContext, parent, fieldModel).getLayout(); |
| } else if (fieldModel.getInputTypeHint() == EditorFieldModel.INPUT_TYPE_HINT_LABEL) { |
| childView = new EditorLabelField(mContext, parent, fieldModel).getLayout(); |
| } else if (fieldModel.getInputTypeHint() == EditorFieldModel.INPUT_TYPE_HINT_DROPDOWN) { |
| Runnable prepareEditorRunnable = new Runnable() { |
| @Override |
| public void run() { |
| // The fields may have changed. |
| prepareEditor(); |
| if (mObserverForTest != null) mObserverForTest.onPaymentRequestReadyToEdit(); |
| } |
| }; |
| EditorDropdownField dropdownView = |
| new EditorDropdownField(mContext, parent, fieldModel, prepareEditorRunnable); |
| mFieldViews.add(dropdownView); |
| mDropdownFields.add(dropdownView.getDropdown()); |
| |
| childView = dropdownView.getLayout(); |
| } else if (fieldModel.getInputTypeHint() == EditorFieldModel.INPUT_TYPE_HINT_CHECKBOX) { |
| final CheckBox checkbox = new CheckBox(mLayout.getContext()); |
| checkbox.setId(R.id.payments_edit_checkbox); |
| checkbox.setText(fieldModel.getLabel()); |
| checkbox.setChecked(fieldModel.isChecked()); |
| checkbox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { |
| @Override |
| public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { |
| fieldModel.setIsChecked(isChecked); |
| if (mObserverForTest != null) mObserverForTest.onPaymentRequestReadyToEdit(); |
| } |
| }); |
| |
| childView = checkbox; |
| } else { |
| InputFilter filter = null; |
| TextWatcher formatter = null; |
| if (fieldModel.getInputTypeHint() == EditorFieldModel.INPUT_TYPE_HINT_CREDIT_CARD) { |
| filter = mCardNumberInputFilter; |
| formatter = mCardNumberFormatter; |
| } else if (fieldModel.getInputTypeHint() == EditorFieldModel.INPUT_TYPE_HINT_PHONE) { |
| formatter = mPhoneFormatter; |
| } |
| |
| EditorTextField inputLayout = new EditorTextField(mContext, fieldModel, |
| mEditorActionListener, filter, formatter, mObserverForTest); |
| mFieldViews.add(inputLayout); |
| |
| EditText input = inputLayout.getEditText(); |
| mEditableTextFields.add(input); |
| |
| if (fieldModel.getInputTypeHint() == EditorFieldModel.INPUT_TYPE_HINT_CREDIT_CARD) { |
| assert mCardInput == null; |
| mCardInput = input; |
| } else if (fieldModel.getInputTypeHint() == EditorFieldModel.INPUT_TYPE_HINT_PHONE) { |
| assert mPhoneInput == null; |
| mPhoneInput = input; |
| } |
| |
| childView = inputLayout; |
| } |
| |
| parent.addView(childView); |
| return childView; |
| } |
| |
| /** |
| * Displays the editor user interface for the given model. |
| * |
| * @param editorModel The description of the editor user interface to display. |
| */ |
| public void show(final EditorModel editorModel) { |
| setOnShowListener(this); |
| setOnDismissListener(this); |
| mEditorModel = editorModel; |
| |
| mLayout = LayoutInflater.from(mContext).inflate(R.layout.payment_request_editor, null); |
| setContentView(mLayout); |
| |
| mFooter = LayoutInflater.from(mContext).inflate( |
| R.layout.payment_request_editor_footer, null, false); |
| |
| prepareToolbar(); |
| prepareEditor(); |
| prepareButtons(); |
| show(); |
| } |
| |
| /** Rereads the values in the model to update the UI. */ |
| public void update() { |
| for (int i = 0; i < mFieldViews.size(); i++) { |
| mFieldViews.get(i).update(); |
| } |
| } |
| |
| @Override |
| public void onShow(DialogInterface dialog) { |
| assert mDialogInOutAnimator == null; |
| |
| // Hide keyboard and disable EditText views for animation efficiency. |
| if (getCurrentFocus() != null) UiUtils.hideKeyboard(getCurrentFocus()); |
| for (int i = 0; i < mEditableTextFields.size(); i++) { |
| mEditableTextFields.get(i).setEnabled(false); |
| } |
| |
| mLayout.setLayerType(View.LAYER_TYPE_HARDWARE, null); |
| mLayout.buildLayer(); |
| Animator popUp = |
| ObjectAnimator.ofFloat(mLayout, View.TRANSLATION_Y, mLayout.getHeight(), 0f); |
| Animator fadeIn = ObjectAnimator.ofFloat(mLayout, View.ALPHA, 0f, 1f); |
| AnimatorSet animatorSet = new AnimatorSet(); |
| animatorSet.playTogether(popUp, fadeIn); |
| |
| mDialogInOutAnimator = animatorSet; |
| mDialogInOutAnimator.setDuration(DIALOG_ENTER_ANIMATION_MS); |
| mDialogInOutAnimator.setInterpolator(new LinearOutSlowInInterpolator()); |
| mDialogInOutAnimator.addListener(new AnimatorListenerAdapter() { |
| @Override |
| public void onAnimationEnd(Animator animation) { |
| mLayout.setLayerType(View.LAYER_TYPE_NONE, null); |
| for (int i = 0; i < mEditableTextFields.size(); i++) { |
| mEditableTextFields.get(i).setEnabled(true); |
| } |
| // Note that keyboard will not show for dropdown field since it's not necessary. |
| if (getCurrentFocus() != null) UiUtils.showKeyboard(getCurrentFocus()); |
| mDialogInOutAnimator = null; |
| initFocus(); |
| } |
| }); |
| |
| mDialogInOutAnimator.start(); |
| } |
| |
| private void initFocus() { |
| // Immediately focus the first invalid field to make it faster to edit. |
| final List<EditorFieldView> invalidViews = getViewsWithInvalidInformation(false); |
| if (!invalidViews.isEmpty()) { |
| mHandler.post(new Runnable() { |
| @Override |
| public void run() { |
| invalidViews.get(0).scrollToAndFocus(); |
| if (mObserverForTest != null) mObserverForTest.onPaymentRequestReadyToEdit(); |
| } |
| }); |
| } else { |
| // The first field will be focused, we are ready to edit. |
| if (mObserverForTest != null) mObserverForTest.onPaymentRequestReadyToEdit(); |
| } |
| } |
| |
| private List<EditorFieldView> getViewsWithInvalidInformation(boolean findAll) { |
| List<EditorFieldView> invalidViews = new ArrayList<>(); |
| for (int i = 0; i < mFieldViews.size(); i++) { |
| EditorFieldView fieldView = mFieldViews.get(i); |
| if (!fieldView.isValid()) { |
| invalidViews.add(fieldView); |
| if (!findAll) break; |
| } |
| } |
| return invalidViews; |
| } |
| |
| /** @return All editable text fields in the editor. Used only for tests. */ |
| @VisibleForTesting |
| public List<EditText> getEditableTextFieldsForTest() { |
| return mEditableTextFields; |
| } |
| |
| /** @return All dropdown fields in the editor. Used only for tests. */ |
| @VisibleForTesting |
| public List<Spinner> getDropdownFieldsForTest() { |
| return mDropdownFields; |
| } |
| } |