// 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.content.Context; import android.content.res.Resources; import android.graphics.Color; import android.graphics.Typeface; import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.text.TextUtils.TruncateAt; import android.text.style.StyleSpan; import android.view.Gravity; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.widget.Button; import android.widget.GridLayout; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.RadioButton; import android.widget.TextView; import org.chromium.base.ApiCompatibilityUtils; import org.chromium.base.VisibleForTesting; import org.chromium.chrome.R; import org.chromium.chrome.browser.widget.DualControlLayout; import org.chromium.chrome.browser.widget.TintedDrawable; import java.util.ArrayList; import java.util.List; import javax.annotation.Nullable; /** * Represents a single section in the {@link PaymentRequestUI} that flips between multiple states. * * The row is broken up into three major, vertically-centered sections: * ............................................................................................. * . TITLE | | CHEVRON . * .................................................................| | or . * . LEFT SUMMARY TEXT | RIGHT SUMMARY TEXT | LOGO | ADD . * .................................................................| | or . * . MAIN SECTION CONTENT | | SELECT . * ............................................................................................. * * 1) MAIN CONTENT * The main content is on the left side of the UI. This includes the title of the section and * two bits of optional summary text. Subclasses may extend this class to append more controls * via the {@link #createMainSectionContent} function. * * 2) LOGO * Displays an optional logo (e.g. a credit card image) that floats to the right of the main * content. * * 3) CHEVRON or ADD or SELECT * Drawn to indicate that the current section may be expanded. Displayed only when the view is * in the {@link #DISPLAY_MODE_EXPANDABLE} state and only if an ADD or SELECT button isn't shown. * * There are three states that the UI may flip between; see {@link #DISPLAY_MODE_NORMAL}, * {@link #DISPLAY_MODE_EXPANDABLE}, and {@link #DISPLAY_MODE_FOCUSED} for details. */ public abstract class PaymentRequestSection extends LinearLayout implements View.OnClickListener { public static final String TAG = "PaymentRequestUI"; /** Handles clicks on the widgets and providing data to the PaymentsRequestSection. */ public interface SectionDelegate extends View.OnClickListener { /** * Called when the user selects a radio button option from an {@link OptionSection}. * * @param section Section that was changed. * @param option {@link PaymentOption} that was selected. */ void onPaymentOptionChanged(PaymentRequestSection section, PaymentOption option); /** Called when the user requests adding a new PaymentOption to a given section. */ void onAddPaymentOption(PaymentRequestSection section); /** Checks whether or not the text should be formatted with a bold label. */ boolean isBoldLabelNeeded(PaymentRequestSection section); /** Checks whether or not the user should be allowed to click on controls. */ boolean isAcceptingUserInput(); /** Returns any additional text that needs to be displayed. */ @Nullable String getAdditionalText(PaymentRequestSection section); /** Returns true if the additional text should be stylized as a warning instead of info. */ boolean isAdditionalTextDisplayingWarning(PaymentRequestSection section); /** Called when a section has been clicked. */ void onSectionClicked(PaymentRequestSection section); } /** Edit button mode: Hide the button. */ public static final int EDIT_BUTTON_GONE = 0; /** Edit button mode: Indicate that the section requires a selection. */ public static final int EDIT_BUTTON_SELECT = 1; /** Edit button mode: Indicate that the section requires adding an option. */ public static final int EDIT_BUTTON_ADD = 2; /** Normal mode: White background, displays the item assuming the user accepts it as is. */ static final int DISPLAY_MODE_NORMAL = 3; /** Editable mode: White background, displays the item with an edit chevron. */ static final int DISPLAY_MODE_EXPANDABLE = 4; /** Focused mode: Gray background, more padding, no edit chevron. */ static final int DISPLAY_MODE_FOCUSED = 5; /** Checking mode: Gray background, spinner overlay hides everything except the title. */ static final int DISPLAY_MODE_CHECKING = 6; protected final SectionDelegate mDelegate; protected final int mLargeSpacing; protected final Button mEditButtonView; protected final boolean mIsLayoutInitialized; protected int mDisplayMode = DISPLAY_MODE_NORMAL; private final int mVerticalSpacing; private final int mFocusedBackgroundColor; private final LinearLayout mMainSection; private final ImageView mLogoView; private final ImageView mChevronView; private TextView mTitleView; private LinearLayout mSummaryLayout; private TextView mSummaryLeftTextView; private TextView mSummaryRightTextView; private int mLogoResourceId; private boolean mIsSummaryAllowed = true; /** * Constructs a PaymentRequestSection. * * @param context Context to pull resources from. * @param sectionName Title of the section to display. * @param delegate Delegate to alert when something changes in the dialog. */ private PaymentRequestSection(Context context, String sectionName, SectionDelegate delegate) { super(context); mDelegate = delegate; setOnClickListener(delegate); setOrientation(HORIZONTAL); setGravity(Gravity.CENTER_VERTICAL); // Set the styling of the view. mFocusedBackgroundColor = ApiCompatibilityUtils.getColor( getResources(), R.color.payments_section_edit_background); mLargeSpacing = getResources().getDimensionPixelSize(R.dimen.payments_section_large_spacing); mVerticalSpacing = getResources().getDimensionPixelSize(R.dimen.payments_section_vertical_spacing); setPadding(mLargeSpacing, mVerticalSpacing, mLargeSpacing, mVerticalSpacing); // Create the main content. mMainSection = prepareMainSection(sectionName); mLogoView = isLogoNecessary() ? createAndAddLogoView(this, 0, mLargeSpacing) : null; mEditButtonView = createAndAddEditButton(this); mChevronView = createAndAddChevron(this); mIsLayoutInitialized = true; setDisplayMode(DISPLAY_MODE_NORMAL); } /** * Sets what logo should be displayed. * * @param resourceId ID of the logo to display. */ protected void setLogoResource(int resourceId) { assert isLogoNecessary(); mLogoResourceId = resourceId; mLogoView.setImageResource(resourceId); } @Override public boolean onInterceptTouchEvent(MotionEvent event) { // Allow touches to propagate to children only if the layout can be interacted with. return !mDelegate.isAcceptingUserInput(); } @Override public final void onClick(View v) { if (!mDelegate.isAcceptingUserInput()) return; // Handle clicking on "ADD" or "SELECT". if (v == mEditButtonView) { if (getEditButtonState() == EDIT_BUTTON_ADD) { mDelegate.onAddPaymentOption(this); } else { mDelegate.onSectionClicked(this); } return; } handleClick(v); updateControlLayout(); } /** Handles clicks on the PaymentRequestSection. */ protected void handleClick(View v) { } /** * Called when the UI is telling the section that it has either gained or lost focus. */ public void focusSection(boolean shouldFocus) { setDisplayMode(shouldFocus ? DISPLAY_MODE_FOCUSED : DISPLAY_MODE_EXPANDABLE); } /** * Updates what Views are displayed and how they look. * * @param displayMode What mode the widget is being displayed in. */ public void setDisplayMode(int displayMode) { mDisplayMode = displayMode; updateControlLayout(); } /** * Changes what is being displayed in the summary. * * @param leftText Text to display on the left side. If null, the whole row hides. * @param rightText Text to display on the right side. If null, only the right View hides. */ public void setSummaryText( @Nullable CharSequence leftText, @Nullable CharSequence rightText) { mSummaryLeftTextView.setText(leftText); mSummaryRightTextView.setText(rightText); mSummaryRightTextView.setVisibility(TextUtils.isEmpty(rightText) ? GONE : VISIBLE); updateControlLayout(); } /** * Sets how the summary text should be displayed. * * @param leftTruncate How to truncate the left summary text. Set to null to clear. * @param rightTruncate How to truncate the right summary text. Set to null to clear. */ public void setSummaryProperties(@Nullable TruncateAt leftTruncate, boolean leftIsSingleLine, @Nullable TruncateAt rightTruncate, boolean rightIsSingleLine) { mSummaryLeftTextView.setEllipsize(leftTruncate); mSummaryLeftTextView.setSingleLine(leftIsSingleLine); mSummaryRightTextView.setEllipsize(rightTruncate); mSummaryRightTextView.setSingleLine(rightIsSingleLine); } /** * Subclasses may override this method to add additional controls to the layout. * * @param mainSectionLayout Layout containing all of the main content of the section. */ protected abstract void createMainSectionContent(LinearLayout mainSectionLayout); /** * Sets whether the edit button may be interacted with. * * @param isEnabled Whether the button may be interacted with. */ public void setIsEditButtonEnabled(boolean isEnabled) { mEditButtonView.setEnabled(isEnabled); } /** * Sets whether the summary text can be displayed. * * @param isAllowed Whether to display the summary text when needed. */ protected void setIsSummaryAllowed(boolean isAllowed) { mIsSummaryAllowed = isAllowed; } /** @return Whether or not the logo should be displayed. */ protected boolean isLogoNecessary() { return false; } /** * Returns the state of the edit button, which is hidden by default. * * @return State of the edit button. */ public int getEditButtonState() { return EDIT_BUTTON_GONE; } /** * Creates the main section. Subclasses must call super#createMainSection() immediately to * guarantee that Views are added in the correct order. * * @param sectionName Title to display for the section. */ private LinearLayout prepareMainSection(String sectionName) { // The main section is a vertical linear layout that subclasses can append to. LinearLayout mainSectionLayout = new LinearLayout(getContext()); mainSectionLayout.setOrientation(VERTICAL); LinearLayout.LayoutParams mainParams = new LayoutParams(0, LayoutParams.WRAP_CONTENT); mainParams.weight = 1; addView(mainSectionLayout, mainParams); // The title is always displayed for the row at the top of the main section. mTitleView = new TextView(getContext()); mTitleView.setText(sectionName); ApiCompatibilityUtils.setTextAppearance( mTitleView, R.style.PaymentsUiSectionHeader); mainSectionLayout.addView( mTitleView, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); // Create the two TextViews for showing the summary text. mSummaryLeftTextView = new TextView(getContext()); mSummaryLeftTextView.setId(R.id.payments_left_summary_label); ApiCompatibilityUtils.setTextAppearance( mSummaryLeftTextView, R.style.PaymentsUiSectionDefaultText); mSummaryRightTextView = new TextView(getContext()); ApiCompatibilityUtils.setTextAppearance( mSummaryRightTextView, R.style.PaymentsUiSectionDefaultText); ApiCompatibilityUtils.setTextAlignment(mSummaryRightTextView, TEXT_ALIGNMENT_TEXT_END); // The main TextView sucks up all the available space. LinearLayout.LayoutParams leftLayoutParams = new LinearLayout.LayoutParams( 0, LayoutParams.WRAP_CONTENT); leftLayoutParams.weight = 1; LinearLayout.LayoutParams rightLayoutParams = new LinearLayout.LayoutParams( LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); ApiCompatibilityUtils.setMarginStart( rightLayoutParams, getContext().getResources().getDimensionPixelSize( R.dimen.payments_section_small_spacing)); // The summary section displays up to two TextViews side by side. mSummaryLayout = new LinearLayout(getContext()); mSummaryLayout.addView(mSummaryLeftTextView, leftLayoutParams); mSummaryLayout.addView(mSummaryRightTextView, rightLayoutParams); mainSectionLayout.addView(mSummaryLayout, new LinearLayout.LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); setSummaryText(null, null); createMainSectionContent(mainSectionLayout); return mainSectionLayout; } private static ImageView createAndAddLogoView( ViewGroup parent, int resourceId, int startMargin) { ImageView view = new ImageView(parent.getContext()); view.setBackgroundResource(R.drawable.payments_ui_logo_bg); if (resourceId != 0) view.setImageResource(resourceId); // The logo has a pre-defined height and width. LayoutParams params = new LayoutParams( parent.getResources().getDimensionPixelSize(R.dimen.payments_section_logo_width), parent.getResources().getDimensionPixelSize(R.dimen.payments_section_logo_height)); ApiCompatibilityUtils.setMarginStart(params, startMargin); parent.addView(view, params); return view; } private Button createAndAddEditButton(ViewGroup parent) { Resources resources = parent.getResources(); Button view = DualControlLayout.createButtonForLayout( parent.getContext(), true, resources.getString(R.string.select), this); view.setId(R.id.payments_section); LayoutParams params = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); ApiCompatibilityUtils.setMarginStart(params, mLargeSpacing); parent.addView(view, params); return view; } private ImageView createAndAddChevron(ViewGroup parent) { Resources resources = parent.getResources(); TintedDrawable chevron = TintedDrawable.constructTintedDrawable( resources, R.drawable.ic_expanded, R.color.payments_section_chevron); ImageView view = new ImageView(parent.getContext()); view.setImageDrawable(chevron); // Wrap whatever image is passed in. LayoutParams params = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); ApiCompatibilityUtils.setMarginStart(params, mLargeSpacing); parent.addView(view, params); return view; } /** * Called when the section's controls need to be updated after configuration changes. * * Because of the complicated special casing of what controls hide other controls, all calls to * update just one of the controls causes the visibility logic to trigger for all of them. * * Subclasses should call the super method after they update their own controls. */ protected void updateControlLayout() { if (!mIsLayoutInitialized) return; boolean isExpanded = mDisplayMode == DISPLAY_MODE_FOCUSED || mDisplayMode == DISPLAY_MODE_CHECKING; setBackgroundColor(isExpanded ? mFocusedBackgroundColor : Color.WHITE); // Update whether the logo is displayed. if (mLogoView != null) { boolean show = mLogoResourceId != 0 && mDisplayMode != DISPLAY_MODE_FOCUSED; mLogoView.setVisibility(show ? VISIBLE : GONE); } // The button takes precedence over the summary text and the chevron. int editButtonState = getEditButtonState(); if (editButtonState == EDIT_BUTTON_GONE) { mEditButtonView.setVisibility(GONE); mChevronView.setVisibility( mDisplayMode == DISPLAY_MODE_EXPANDABLE ? VISIBLE : GONE); // Update whether the summary is displayed. boolean showSummary = mIsSummaryAllowed && !TextUtils.isEmpty(mSummaryLeftTextView.getText()); mSummaryLayout.setVisibility(showSummary ? VISIBLE : GONE); } else { // Show the edit button and hide the chevron and the summary. boolean isButtonAllowed = mDisplayMode == DISPLAY_MODE_EXPANDABLE || mDisplayMode == DISPLAY_MODE_NORMAL; mSummaryLayout.setVisibility(GONE); mChevronView.setVisibility(GONE); mEditButtonView.setVisibility(isButtonAllowed ? VISIBLE : GONE); mEditButtonView.setText( editButtonState == EDIT_BUTTON_SELECT ? R.string.select : R.string.add); } // The title gains extra spacing when there is another visible view in the main section. int numVisibleMainViews = 0; for (int i = 0; i < mMainSection.getChildCount(); i++) { if (mMainSection.getChildAt(i).getVisibility() == VISIBLE) numVisibleMainViews += 1; } boolean isTitleMarginNecessary = numVisibleMainViews > 1 && isExpanded; int oldMargin = ((ViewGroup.MarginLayoutParams) mTitleView.getLayoutParams()).bottomMargin; int newMargin = isTitleMarginNecessary ? mVerticalSpacing : 0; if (oldMargin != newMargin) { ((ViewGroup.MarginLayoutParams) mTitleView.getLayoutParams()).bottomMargin = newMargin; requestLayout(); } } /** * Section with a secondary TextView beneath the summary to show additional details. * * ............................................................................ * . TITLE | CHEVRON . * .................................................................| or . * . LEFT SUMMARY TEXT | RIGHT SUMMARY TEXT | ADD . * .................................................................| or . * . EXTRA TEXT | SELECT . * ............................................................................ */ public static class ExtraTextSection extends PaymentRequestSection { private TextView mExtraTextView; private int mEditButtonState = EDIT_BUTTON_GONE; public ExtraTextSection(Context context, String sectionName, SectionDelegate delegate) { super(context, sectionName, delegate); setExtraText(null); } @Override protected void createMainSectionContent(LinearLayout mainSectionLayout) { Context context = mainSectionLayout.getContext(); mExtraTextView = new TextView(context); ApiCompatibilityUtils.setTextAppearance( mExtraTextView, R.style.PaymentsUiSectionDescriptiveTextEndAligned); mainSectionLayout.addView(mExtraTextView, new LinearLayout.LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); } /** * Sets the CharSequence that is displayed in the secondary TextView. * * @param text Text to display. */ public void setExtraText(CharSequence text) { mExtraTextView.setText(text); mExtraTextView.setVisibility(TextUtils.isEmpty(text) ? GONE : VISIBLE); } /** Sets the state of the edit button. */ public void setEditButtonState(int state) { mEditButtonState = state; updateControlLayout(); } @Override public int getEditButtonState() { return mEditButtonState; } } /** * Section with an additional Layout for showing a total and how it is broken down. * * Normal mode: Just the summary is displayed. * If no option is selected, the "empty label" is displayed in its place. * Expandable mode: Same as Normal, but shows the chevron. * Focused mode: Hides the summary and chevron, then displays the full set of options. * * ............................................................................ * . TITLE | . * .................................................................| CHERVON . * . LEFT SUMMARY TEXT | RIGHT SUMMARY TEXT | or . * .................................................................| ADD . * . | Line item 1 | $13.99 | or . * . | Line item 2 | $.99 | SELECT . * . | Line item 3 | $2.99 | . * ............................................................................ */ public static class LineItemBreakdownSection extends PaymentRequestSection { private GridLayout mBreakdownLayout; public LineItemBreakdownSection( Context context, String sectionName, SectionDelegate delegate) { super(context, sectionName, delegate); } @Override protected void createMainSectionContent(LinearLayout mainSectionLayout) { Context context = mainSectionLayout.getContext(); // The breakdown is represented by an end-aligned GridLayout that takes up only as much // space as it needs. The GridLayout ensures a consistent margin between the columns. mBreakdownLayout = new GridLayout(context); mBreakdownLayout.setColumnCount(2); LayoutParams breakdownParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); breakdownParams.gravity = Gravity.END; mainSectionLayout.addView(mBreakdownLayout, breakdownParams); } /** * Updates the total and how it's broken down. * * @param cart The shopping cart contents and the total. */ public void update(ShoppingCart cart) { Context context = mBreakdownLayout.getContext(); // Update the summary to display information about the total. setSummaryText(cart.getTotal().getLabel(), createValueString( cart.getTotal().getCurrency(), cart.getTotal().getPrice(), true)); mBreakdownLayout.removeAllViews(); if (cart.getContents() == null) return; int maximumDescriptionWidthPx = ((View) mBreakdownLayout.getParent()).getWidth() * 2 / 3; // Update the breakdown, using one row per {@link LineItem}. int numItems = cart.getContents().size(); mBreakdownLayout.setRowCount(numItems); for (int i = 0; i < numItems; i++) { LineItem item = cart.getContents().get(i); TextView description = new TextView(context); ApiCompatibilityUtils.setTextAppearance( description, R.style.PaymentsUiSectionDescriptiveTextEndAligned); description.setText(item.getLabel()); description.setEllipsize(TruncateAt.END); description.setMaxLines(2); if (maximumDescriptionWidthPx > 0) { description.setMaxWidth(maximumDescriptionWidthPx); } TextView amount = new TextView(context); ApiCompatibilityUtils.setTextAppearance( amount, R.style.PaymentsUiSectionDescriptiveTextEndAligned); amount.setText(createValueString(item.getCurrency(), item.getPrice(), false)); // Each item is represented by a row in the GridLayout. GridLayout.LayoutParams descriptionParams = new GridLayout.LayoutParams( GridLayout.spec(i, 1, GridLayout.END), GridLayout.spec(0, 1, GridLayout.END)); GridLayout.LayoutParams amountParams = new GridLayout.LayoutParams( GridLayout.spec(i, 1, GridLayout.END), GridLayout.spec(1, 1, GridLayout.END)); ApiCompatibilityUtils.setMarginStart(amountParams, context.getResources().getDimensionPixelSize( R.dimen.payments_section_descriptive_item_spacing)); mBreakdownLayout.addView(description, descriptionParams); mBreakdownLayout.addView(amount, amountParams); } } /** * Builds a CharSequence that displays a value in a particular currency. * * @param currency Currency of the value being displayed. * @param value Value to display. * @param isValueBold Whether or not to bold the item. * @return CharSequence that represents the whole value. */ private CharSequence createValueString(String currency, String value, boolean isValueBold) { SpannableStringBuilder valueBuilder = new SpannableStringBuilder(); valueBuilder.append(currency); valueBuilder.append(" "); int boldStartIndex = valueBuilder.length(); valueBuilder.append(value); if (isValueBold) { valueBuilder.setSpan(new StyleSpan(android.graphics.Typeface.BOLD), boldStartIndex, boldStartIndex + value.length(), 0); } return valueBuilder; } @Override protected void updateControlLayout() { if (!mIsLayoutInitialized) return; mBreakdownLayout.setVisibility(mDisplayMode == DISPLAY_MODE_FOCUSED ? VISIBLE : GONE); super.updateControlLayout(); } } /** * Section that allows selecting one thing from a set of mutually-exclusive options. * * Normal mode: The summary text displays the selected option, and the icon for the option * is displayed in the logo section (if it exists). * If no option is selected, the "empty label" is displayed in its place. * This is important for shipping options (e.g.) because there will be no * option selected by default and a prompt can be displayed. * Expandable mode: Same as Normal, but shows the chevron. * Focused mode: Hides the summary and chevron, then displays the full set of options. * * ............................................................................................. * . TITLE | | . * .................................................................| | . * . LEFT SUMMARY TEXT | RIGHT SUMMARY TEXT | | . * .................................................................| | CHEVRON . * . Descriptive text that spans all three columns because it can. | | or . * . ! Warning text that displays a big scary warning and icon. | LOGO | ADD . * . O Option 1 ICON 1 | | or . * . O Option 2 ICON 2 | | SELECT . * . O Option 3 ICON 3 | | . * . + ADD THING | | . * ............................................................................................. */ public static class OptionSection extends PaymentRequestSection { private static final int INVALID_OPTION_INDEX = -1; private final List<TextView> mLabelsForTest = new ArrayList<>(); private boolean mCanAddItems = true; /** * Displays a row representing either a selectable option or some flavor text. * * + The "button" is on the left and shows either an icon or a radio button to represent th * row type. * + The "label" is text describing the row. * + The "icon" is a logo representing the option, like a credit card. */ public class OptionRow { private static final int OPTION_ROW_TYPE_OPTION = 0; private static final int OPTION_ROW_TYPE_ADD = 1; private static final int OPTION_ROW_TYPE_DESCRIPTION = 2; private static final int OPTION_ROW_TYPE_WARNING = 3; private final int mRowType; private final PaymentOption mOption; private final View mButton; private final TextView mLabel; private final View mIcon; public OptionRow(GridLayout parent, int rowIndex, int rowType, PaymentOption item, boolean isSelected) { boolean iconExists = item != null && item.getDrawableIconId() != 0; boolean isEnabled = item != null && item.isValid(); mRowType = rowType; mOption = item; mButton = createButton(parent, rowIndex, isSelected, isEnabled); mLabel = createLabel(parent, rowIndex, iconExists, isEnabled); mIcon = iconExists ? createIcon(parent, rowIndex) : null; } /** Sets the selected state of this item, alerting the delegate if selected. */ public void setChecked(boolean isChecked) { if (mOption == null) return; ((RadioButton) mButton).setChecked(isChecked); if (isChecked) { updateSelectedItem(mOption); mDelegate.onPaymentOptionChanged(OptionSection.this, mOption); } } /** Change the label for the row. */ public void setLabel(int stringId) { setLabel(getContext().getString(stringId)); } /** Change the label for the row. */ public void setLabel(CharSequence string) { mLabel.setText(string); } /** Set the button identifier for the option. */ public void setButtonId(int id) { mButton.setId(id); } /** @return the label for the row. */ @VisibleForTesting public CharSequence getLabelText() { return mLabel.getText(); } private View createButton( GridLayout parent, int rowIndex, boolean isSelected, boolean isEnabled) { if (mRowType == OPTION_ROW_TYPE_DESCRIPTION) return null; Context context = parent.getContext(); View view; if (mRowType == OPTION_ROW_TYPE_OPTION) { // Show a radio button indicating whether the PaymentOption is selected. RadioButton button = new RadioButton(context); button.setChecked(isSelected && isEnabled); button.setEnabled(isEnabled); view = button; } else { // Show an icon representing the row type, defaulting to the add button. int drawableId; int drawableTint; if (mRowType == OPTION_ROW_TYPE_WARNING) { drawableId = R.drawable.ic_warning_white_24dp; drawableTint = R.color.error_text_color; } else { drawableId = R.drawable.plus; drawableTint = R.color.light_active_color; } TintedDrawable tintedDrawable = TintedDrawable.constructTintedDrawable( context.getResources(), drawableId, drawableTint); ImageButton button = new ImageButton(context); button.setBackground(null); button.setImageDrawable(tintedDrawable); button.setPadding(0, 0, 0, 0); view = button; } // The button hugs left. GridLayout.LayoutParams buttonParams = new GridLayout.LayoutParams( GridLayout.spec(rowIndex, 1, GridLayout.CENTER), GridLayout.spec(0, 1, GridLayout.CENTER)); buttonParams.topMargin = mVerticalMargin; ApiCompatibilityUtils.setMarginEnd(buttonParams, mLargeSpacing); parent.addView(view, buttonParams); view.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); view.setOnClickListener(OptionSection.this); return view; } private TextView createLabel( GridLayout parent, int rowIndex, boolean iconExists, boolean isEnabled) { Context context = parent.getContext(); Resources resources = context.getResources(); // By default, the label appears to the right of the "button" in the second column. // + If there is no button and no icon, the label spans the whole row. // + If there is no icon, the label spans two columns. // + Otherwise, the label occupies only its own column. int columnStart = 1; int columnSpan = iconExists ? 1 : 2; TextView labelView = new TextView(context); if (mRowType == OPTION_ROW_TYPE_OPTION) { // Show the string representing the PaymentOption. ApiCompatibilityUtils.setTextAppearance(labelView, isEnabled ? R.style.PaymentsUiSectionDefaultText : R.style.PaymentsUiSectionDisabledText); labelView.setText(convertOptionToString( mOption, mDelegate.isBoldLabelNeeded(OptionSection.this))); labelView.setEnabled(isEnabled); } else if (mRowType == OPTION_ROW_TYPE_ADD) { // Shows string saying that the user can add a new option, e.g. credit card no. String typeface = resources.getString(R.string.roboto_medium_typeface); int textStyle = resources.getInteger(R.integer.roboto_medium_textstyle); int buttonHeight = resources.getDimensionPixelSize( R.dimen.payments_section_add_button_height); ApiCompatibilityUtils.setTextAppearance( labelView, R.style.PaymentsUiSectionAddButtonLabel); labelView.setMinimumHeight(buttonHeight); labelView.setGravity(Gravity.CENTER_VERTICAL); labelView.setTypeface(Typeface.create(typeface, textStyle)); } else if (mRowType == OPTION_ROW_TYPE_DESCRIPTION) { // The description spans all the columns. columnStart = 0; columnSpan = 3; ApiCompatibilityUtils.setTextAppearance( labelView, R.style.PaymentsUiSectionDescriptiveText); } else if (mRowType == OPTION_ROW_TYPE_WARNING) { // Warnings use two columns. columnSpan = 2; ApiCompatibilityUtils.setTextAppearance( labelView, R.style.PaymentsUiSectionWarningText); } // The label spans two columns if no icon exists. Setting the view width to 0 // forces it to stretch. GridLayout.LayoutParams labelParams = new GridLayout.LayoutParams( GridLayout.spec(rowIndex, 1, GridLayout.CENTER), GridLayout.spec(columnStart, columnSpan, GridLayout.FILL)); labelParams.topMargin = mVerticalMargin; labelParams.width = 0; parent.addView(labelView, labelParams); labelView.setOnClickListener(OptionSection.this); return labelView; } private View createIcon(GridLayout parent, int rowIndex) { // The icon has a pre-defined width. ImageView icon = new ImageView(parent.getContext()); icon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); icon.setBackgroundResource(R.drawable.payments_ui_logo_bg); icon.setImageResource(mOption.getDrawableIconId()); icon.setMaxWidth(mIconMaxWidth); // The icon floats to the right of everything. GridLayout.LayoutParams iconParams = new GridLayout.LayoutParams( GridLayout.spec(rowIndex, 1, GridLayout.CENTER), GridLayout.spec(2, 1)); iconParams.topMargin = mVerticalMargin; ApiCompatibilityUtils.setMarginStart(iconParams, mLargeSpacing); parent.addView(icon, iconParams); icon.setOnClickListener(OptionSection.this); return icon; } } /** Top and bottom margins for each item. */ private final int mVerticalMargin; /** All the possible PaymentOptions in Layout form, then one row for adding new options. */ private final ArrayList<OptionRow> mOptionRows = new ArrayList<>(); /** Width that the icon takes. */ private final int mIconMaxWidth; /** Layout containing all the {@link OptionRow}s. */ private GridLayout mOptionLayout; /** A spinner to show when the user selection is being checked. */ private View mCheckingProgress; /** SectionInformation that is used to populate the views in this section. */ private SectionInformation mSectionInformation; /** * Constructs an OptionSection. * * @param context Context to pull resources from. * @param sectionName Title of the section to display. * @param delegate Delegate to alert when something changes in the dialog. */ public OptionSection(Context context, String sectionName, SectionDelegate delegate) { super(context, sectionName, delegate); mVerticalMargin = context.getResources().getDimensionPixelSize( R.dimen.payments_section_small_spacing); mIconMaxWidth = context.getResources().getDimensionPixelSize( R.dimen.payments_section_logo_width); setSummaryText(null, null); } @Override public void handleClick(View v) { // Handle click on the "ADD THING" button. for (int i = 0; i < mOptionRows.size(); i++) { OptionRow row = mOptionRows.get(i); boolean wasClicked = row.mButton == v || row.mLabel == v || row.mIcon == v; if (row.mOption == null && wasClicked) { mDelegate.onAddPaymentOption(this); return; } } // Update the radio button state: checked/unchecked. for (int i = 0; i < mOptionRows.size(); i++) { OptionRow row = mOptionRows.get(i); boolean wasClicked = row.mButton == v || row.mLabel == v || row.mIcon == v; if (row.mOption != null) row.setChecked(wasClicked); } } @Override public void focusSection(boolean shouldFocus) { // Override expansion of the section if there's no options to show. boolean mayFocus = mSectionInformation != null && mSectionInformation.getSize() > 0; if (!mayFocus && shouldFocus) { setDisplayMode(PaymentRequestSection.DISPLAY_MODE_NORMAL); return; } super.focusSection(shouldFocus); } @Override protected boolean isLogoNecessary() { return true; } @Override protected void createMainSectionContent(LinearLayout mainSectionLayout) { Context context = mainSectionLayout.getContext(); mCheckingProgress = createLoadingSpinner(); mOptionLayout = new GridLayout(context); mOptionLayout.setColumnCount(3); mainSectionLayout.addView(mOptionLayout, new LinearLayout.LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); } /** @param canAddItems If false, this section will not show [+ ADD THING] button. */ public void setCanAddItems(boolean canAddItems) { mCanAddItems = canAddItems; } /** Updates the View to account for the new {@link SectionInformation} being passed in. */ public void update(SectionInformation information) { mSectionInformation = information; PaymentOption selectedItem = information.getSelectedItem(); updateSelectedItem(selectedItem); updateOptionList(information, selectedItem); updateControlLayout(); } private View createLoadingSpinner() { ViewGroup spinnyLayout = (ViewGroup) LayoutInflater.from(getContext()).inflate( R.layout.payment_request_spinny, null); TextView textView = (TextView) spinnyLayout.findViewById(R.id.message); textView.setText(getContext().getString(R.string.payments_checking_option)); return spinnyLayout; } private void setSpinnerVisibility(boolean visibility) { if (visibility) { if (mCheckingProgress.getParent() != null) return; ViewGroup parent = (ViewGroup) mOptionLayout.getParent(); int optionLayoutIndex = parent.indexOfChild(mOptionLayout); parent.addView(mCheckingProgress, optionLayoutIndex); MarginLayoutParams params = (MarginLayoutParams) mCheckingProgress.getLayoutParams(); params.width = LayoutParams.MATCH_PARENT; params.height = LayoutParams.WRAP_CONTENT; params.bottomMargin = getContext().getResources().getDimensionPixelSize( R.dimen.payments_section_checking_spacing); mCheckingProgress.requestLayout(); } else { if (mCheckingProgress.getParent() == null) return; ViewGroup parent = (ViewGroup) mCheckingProgress.getParent(); parent.removeView(mCheckingProgress); } } @Override protected void updateControlLayout() { if (!mIsLayoutInitialized) return; if (mDisplayMode == DISPLAY_MODE_FOCUSED) { setIsSummaryAllowed(false); mOptionLayout.setVisibility(VISIBLE); setSpinnerVisibility(false); } else if (mDisplayMode == DISPLAY_MODE_CHECKING) { setIsSummaryAllowed(false); mOptionLayout.setVisibility(GONE); setSpinnerVisibility(true); } else { setIsSummaryAllowed(true); mOptionLayout.setVisibility(GONE); setSpinnerVisibility(false); } super.updateControlLayout(); } @Override public int getEditButtonState() { if (mSectionInformation == null) return EDIT_BUTTON_GONE; if (mSectionInformation.getSize() == 0 && mCanAddItems) { // There aren't any PaymentOptions. Ask the user to add a new one. return EDIT_BUTTON_ADD; } else if (mSectionInformation.getSelectedItem() == null) { // The user hasn't selected any available PaymentOptions. Ask the user to pick one. return EDIT_BUTTON_SELECT; } else { return EDIT_BUTTON_GONE; } } private void updateSelectedItem(PaymentOption selectedItem) { if (selectedItem == null) { setLogoResource(0); setIsSummaryAllowed(false); setSummaryText(null, null); } else { setLogoResource(selectedItem.getDrawableIconId()); setSummaryText(convertOptionToString(selectedItem, false), null); } updateControlLayout(); } private void updateOptionList(SectionInformation information, PaymentOption selectedItem) { mOptionLayout.removeAllViews(); mOptionRows.clear(); mLabelsForTest.clear(); // Show any additional text requested by the layout. String additionalText = mDelegate.getAdditionalText(this); if (!TextUtils.isEmpty(additionalText)) { OptionRow descriptionRow = new OptionRow(mOptionLayout, mOptionRows.size(), mDelegate.isAdditionalTextDisplayingWarning(this) ? OptionRow.OPTION_ROW_TYPE_WARNING : OptionRow.OPTION_ROW_TYPE_DESCRIPTION, null, false); mOptionRows.add(descriptionRow); descriptionRow.setLabel(additionalText); } // List out known payment options. int firstOptionIndex = INVALID_OPTION_INDEX; for (int i = 0; i < information.getSize(); i++) { int currentRow = mOptionRows.size(); if (firstOptionIndex == INVALID_OPTION_INDEX) firstOptionIndex = currentRow; PaymentOption item = information.getItem(i); OptionRow currentOptionRow = new OptionRow(mOptionLayout, currentRow, OptionRow.OPTION_ROW_TYPE_OPTION, item, item == selectedItem); mOptionRows.add(currentOptionRow); // For testing, keep the labels in a list for easy access. mLabelsForTest.add(currentOptionRow.mLabel); } // TODO(crbug.com/627186): Find another way to give access to this resource in tests. // For testing. if (firstOptionIndex != INVALID_OPTION_INDEX) { mOptionRows.get(firstOptionIndex).setButtonId(R.id.payments_first_radio_button); } // If the user is allowed to add new options, show the button for it. if (information.getAddStringId() != 0 && mCanAddItems) { OptionRow addRow = new OptionRow(mOptionLayout, mOptionLayout.getChildCount(), OptionRow.OPTION_ROW_TYPE_ADD, null, false); addRow.setLabel(information.getAddStringId()); addRow.setButtonId(R.id.payments_add_option_button); mOptionRows.add(addRow); } } private CharSequence convertOptionToString(PaymentOption item, boolean useBoldLabel) { SpannableStringBuilder builder = new SpannableStringBuilder(item.getLabel()); if (useBoldLabel) { builder.setSpan( new StyleSpan(android.graphics.Typeface.BOLD), 0, builder.length(), 0); } if (!TextUtils.isEmpty(item.getSublabel())) { if (builder.length() > 0) builder.append("\n"); builder.append(item.getSublabel()); } if (!TextUtils.isEmpty(item.getTertiaryLabel())) { if (builder.length() > 0) builder.append("\n"); builder.append(item.getTertiaryLabel()); } return builder; } /** * Returns the label at the specified |labelIndex|. Returns null if there is no label at * that index. */ @VisibleForTesting public TextView getOptionLabelsForTest(int labelIndex) { return mLabelsForTest.get(labelIndex); } /** Returns the number of option labels. */ @VisibleForTesting public int getNumberOfOptionLabelsForTest() { return mLabelsForTest.size(); } /** Returns the OptionRow at the specified |index|. */ @VisibleForTesting public OptionRow getOptionRowAtIndex(int index) { return mOptionRows.get(index); } } /** * Drawn as a 1dp separator. Initially drawn without being expanded to the full width of the * UI, but can be expanded to separate sections fully. */ public static class SectionSeparator extends View { /** Creates the View and adds it to the parent. */ public SectionSeparator(ViewGroup parent) { this(parent, -1); } /** Creates the View and adds it to the parent at the given index. */ public SectionSeparator(ViewGroup parent, int index) { super(parent.getContext()); Resources resources = parent.getContext().getResources(); setBackgroundColor(ApiCompatibilityUtils.getColor( resources, R.color.payments_section_separator)); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( LayoutParams.MATCH_PARENT, resources.getDimensionPixelSize(R.dimen.payments_section_separator_height)); int margin = resources.getDimensionPixelSize(R.dimen.payments_section_large_spacing); ApiCompatibilityUtils.setMarginStart(params, margin); ApiCompatibilityUtils.setMarginEnd(params, margin); parent.addView(this, index, params); } /** Expand the separator to be the full width of the dialog. */ public void expand() { LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) getLayoutParams(); ApiCompatibilityUtils.setMarginStart(params, 0); ApiCompatibilityUtils.setMarginEnd(params, 0); } } }