Chip.java

/*
 * Copyright 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.wear.protolayout.materialcore;

import static androidx.wear.protolayout.ColorBuilders.argb;
import static androidx.wear.protolayout.DimensionBuilders.dp;
import static androidx.wear.protolayout.LayoutElementBuilders.HORIZONTAL_ALIGN_START;
import static androidx.wear.protolayout.materialcore.Helper.checkNotNull;
import static androidx.wear.protolayout.materialcore.Helper.checkTag;
import static androidx.wear.protolayout.materialcore.Helper.getMetadataTagName;
import static androidx.wear.protolayout.materialcore.Helper.getTagBytes;
import static androidx.wear.protolayout.materialcore.Helper.radiusOf;

import static java.lang.Math.max;

import android.annotation.SuppressLint;
import android.graphics.Color;

import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.annotation.RestrictTo.Scope;
import androidx.wear.protolayout.ColorBuilders.ColorProp;
import androidx.wear.protolayout.DimensionBuilders.ContainerDimension;
import androidx.wear.protolayout.DimensionBuilders.DpProp;
import androidx.wear.protolayout.DimensionBuilders.WrappedDimensionProp;
import androidx.wear.protolayout.LayoutElementBuilders;
import androidx.wear.protolayout.LayoutElementBuilders.Box;
import androidx.wear.protolayout.LayoutElementBuilders.Column;
import androidx.wear.protolayout.LayoutElementBuilders.HorizontalAlignment;
import androidx.wear.protolayout.LayoutElementBuilders.LayoutElement;
import androidx.wear.protolayout.LayoutElementBuilders.Row;
import androidx.wear.protolayout.LayoutElementBuilders.Spacer;
import androidx.wear.protolayout.ModifiersBuilders.Background;
import androidx.wear.protolayout.ModifiersBuilders.Clickable;
import androidx.wear.protolayout.ModifiersBuilders.Corner;
import androidx.wear.protolayout.ModifiersBuilders.ElementMetadata;
import androidx.wear.protolayout.ModifiersBuilders.Modifiers;
import androidx.wear.protolayout.ModifiersBuilders.Padding;
import androidx.wear.protolayout.ModifiersBuilders.Semantics;
import androidx.wear.protolayout.TypeBuilders.StringProp;
import androidx.wear.protolayout.expression.Fingerprint;
import androidx.wear.protolayout.proto.LayoutElementProto;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.HashMap;
import java.util.Map;

/**
 * ProtoLayout core component {@link Chip} that represents clickable object with the text, optional
 * label and optional icon or with custom content. This component is not meant to be used
 * standalone, it's a helper component for the Material library.
 *
 * <p>The Chip is Stadium shape object. The recommended sizes and styles are defined in the public
 * Material library.
 *
 * <p>This Button doesn't have any styling applied, that should be done by the calling library.
 *
 * <p>When accessing the contents of a container for testing, note that this element can't be simply
 * casted back to the original type, i.e.:
 *
 * <pre>{@code
 * Chip chip = new Chip...
 * Box box = new Box.Builder().addContent(chip).build();
 *
 * Chip myChip = (Chip) box.getContents().get(0);
 * }</pre>
 *
 * will fail.
 *
 * <p>To be able to get {@link Chip} object from any layout element, {@link #fromLayoutElement}
 * method should be used, i.e.:
 *
 * <pre>{@code
 * Chip myChip = Chip.fromLayoutElement(box.getContents().get(0));
 * }</pre>
 */
public class Chip implements LayoutElement {
    /**
     * Tool tag for Metadata in Modifiers, so we know that Box is actually a Chip with only text.
     */
    public static final String METADATA_TAG_TEXT = "TXTCHP";

    /** Tool tag for Metadata in Modifiers, so we know that Box is actually a Chip with icon. */
    public static final String METADATA_TAG_ICON = "ICNCHP";

    /**
     * Tool tag for Metadata in Modifiers, so we know that Box is actually a Chip with custom
     * content.
     */
    public static final String METADATA_TAG_CUSTOM_CONTENT = "CSTCHP";

    private static final int PRIMARY_LABEL_INDEX = 0;
    private static final int SECONDARY_LABEL_INDEX = 1;
    private static final int LABELS_INDEX_NO_ICON = 0;
    private static final int LABELS_INDEX_ICON = 2;

    /** Outer tappable Box. */
    @NonNull private final Box mImpl;

    /** Inner visible Box with all Chip elements. */
    @NonNull private final Box mElement;

    Chip(@NonNull Box impl) {
        mImpl = impl;
        mElement = (Box) impl.getContents().get(0);
    }

    /** Builder class for {@link Chip}. */
    public static final class Builder implements LayoutElement.Builder {
        /** Chip type that has no inner set. */
        public static final int NOT_SET = 0;

        /** Chip type to be used when setting a content which has a text. */
        public static final int TEXT = 1;

        /** Chip type to be used when setting a content which has an icon. */
        public static final int ICON = 2;

        /** Chip type to be used when setting a content which is a custom one. */
        public static final int CUSTOM_CONTENT = 3;

        /** Chip types. */
        @RestrictTo(Scope.LIBRARY_GROUP)
        @Retention(RetentionPolicy.SOURCE)
        @IntDef({NOT_SET, TEXT, ICON, CUSTOM_CONTENT})
        public @interface ChipType {}

        @Nullable private LayoutElement mCustomContent;
        @Nullable private LayoutElement mIconContent = null;
        @Nullable private LayoutElement mPrimaryLabelContent = null;
        @Nullable private LayoutElement mSecondaryLabelContent = null;
        @NonNull private final Clickable mClickable;
        @Nullable private StringProp mContentDescription = null;
        @NonNull private ContainerDimension mWidth = dp(0);
        @NonNull private DpProp mHeight = dp(0);
        @NonNull private ColorProp mBackgroundColor = argb(Color.BLACK);
        @HorizontalAlignment private int mHorizontalAlign = HORIZONTAL_ALIGN_START;
        @NonNull private DpProp mHorizontalPadding = dp(0);
        @NonNull private DpProp mIconSpacerWidth = dp(0);
        @NonNull private DpProp mMinTappableSquareLength = dp(0);

        @NonNull static final Map<Integer, String> TYPE_TO_TAG = new HashMap<>();

        static {
            TYPE_TO_TAG.put(ICON, METADATA_TAG_ICON);
            TYPE_TO_TAG.put(TEXT, METADATA_TAG_TEXT);
            TYPE_TO_TAG.put(CUSTOM_CONTENT, METADATA_TAG_CUSTOM_CONTENT);
        }

        /**
         * Creates a builder for the {@link Chip} with associated action. It is required to add
         * content later with setters.
         *
         * @param clickable Associated {@link Clickable} for click events. When the Chip is clicked
         *     it will fire the associated action.
         */
        public Builder(@NonNull Clickable clickable) {
            mClickable = clickable;
        }

        /** Sets the width of {@link Chip}. If not set, Chip won't be shown. */
        @NonNull
        public Builder setWidth(@NonNull ContainerDimension width) {
            mWidth = width;
            return this;
        }

        /** Sets the height of {@link Chip}. If not set, Chip won't be shown. */
        @NonNull
        public Builder setHeight(@NonNull DpProp height) {
            mHeight = height;
            return this;
        }

        /**
         * Sets the custom content for the {@link Chip}. Any previously added content will be
         * overridden. Provided content should be styled and sized.
         */
        @NonNull
        public Builder setCustomContent(@NonNull LayoutElement content) {
            this.mCustomContent = content;
            this.mPrimaryLabelContent = null;
            this.mSecondaryLabelContent = null;
            this.mIconContent = null;
            return this;
        }

        /** Sets the background colors for the {@link Button}. If not set, black is used. */
        @NonNull
        public Builder setBackgroundColor(@NonNull ColorProp backgroundColor) {
            mBackgroundColor = backgroundColor;
            return this;
        }

        /**
         * Sets the content description for the {@link Chip}. It is highly recommended to provide
         * this for chip containing icon.
         *
         * <p>While this field is statically accessible from 1.0, it's only bindable since version
         * 1.2 and renderers supporting version 1.2 will use the dynamic value (if set).
         */
        @NonNull
        public Builder setContentDescription(@NonNull StringProp contentDescription) {
            this.mContentDescription = contentDescription;
            return this;
        }

        /**
         * Sets the primary label for the {@link Chip}. Any previously added custom content will be
         * overridden. This should be styled and sized by the caller.
         */
        @NonNull
        public Builder setPrimaryLabelContent(@NonNull LayoutElement primaryLabel) {
            this.mPrimaryLabelContent = primaryLabel;
            this.mCustomContent = null;
            return this;
        }

        /**
         * Sets the secondary label for the {@link Chip}. Any previously added custom content will
         * be overridden. If secondary label is set, primary label must be set too with {@link
         * #setPrimaryLabelContent}. This should be styled and sized by the caller.
         */
        @NonNull
        public Builder setSecondaryLabelContent(@NonNull LayoutElement secondaryLabel) {
            this.mSecondaryLabelContent = secondaryLabel;
            this.mCustomContent = null;
            return this;
        }

        /**
         * Sets the icon for the {@link Chip}. Any previously added custom content will be
         * overridden. If icon is set, primary label must be set too with {@link
         * #setPrimaryLabelContent}. This should be styled and sized by the caller.
         */
        @NonNull
        public Builder setIconContent(@NonNull LayoutElement imageResourceId) {
            this.mIconContent = imageResourceId;
            this.mCustomContent = null;
            return this;
        }

        /**
         * Sets the horizontal alignment in the chip. If not set, {@link
         * HorizontalAlignment#HORIZONTAL_ALIGN_START} will be used.
         */
        @NonNull
        public Builder setHorizontalAlignment(@HorizontalAlignment int horizontalAlignment) {
            mHorizontalAlign = horizontalAlignment;
            return this;
        }

        /** Sets the width of spacer used next to the icon if set. */
        @NonNull
        public Builder setIconSpacerWidth(@NonNull DpProp iconSpacerWidth) {
            mIconSpacerWidth = iconSpacerWidth;
            return this;
        }

        /** Sets the length of minimal tappable square for this chip. */
        @NonNull
        public Builder setMinimalTappableSquareLength(@NonNull DpProp tappableLength) {
            mMinTappableSquareLength = tappableLength;
            return this;
        }

        /** Sets the horizontal padding in the chip. */
        @NonNull
        public Builder setHorizontalPadding(@NonNull DpProp horizontalPadding) {
            this.mHorizontalPadding = horizontalPadding;
            return this;
        }

        /** Constructs and returns {@link Chip} with the provided content and look. */
        @NonNull
        @Override
        public Chip build() {
            Modifiers.Builder modifiers =
                    new Modifiers.Builder()
                            .setClickable(mClickable)
                            .setPadding(
                                    new Padding.Builder()
                                            .setStart(mHorizontalPadding)
                                            .setEnd(mHorizontalPadding)
                                            .build())
                            .setBackground(
                                    new Background.Builder()
                                            .setColor(mBackgroundColor)
                                            .setCorner(
                                                    new Corner.Builder()
                                                            .setRadius(radiusOf(mHeight))
                                                            .build())
                                            .build());

            Box.Builder visible =
                    new Box.Builder()
                            .setHeight(mHeight)
                            .setWidth(mWidth)
                            .setHorizontalAlignment(mHorizontalAlign)
                            .addContent(getCorrectContent())
                            .setModifiers(modifiers.build());

            // Following accessibility guide, the renderer will attempt to extend the clickable's
            // touch target size to a minimum of 48dp when inflating it. Since this touch extension
            // is not layout affecting, thus it is not guaranteed unless there is enough space
            // around it. This wrapper ensures that there is enough space for this extended touch
            // target.
            Box wrapperForTapTarget =
                    new Box.Builder()
                            .setWidth(resolveMinTappableWidth())
                            .setHeight(dp(resolveMinTappableHeight()))
                            .setModifiers(
                                    new Modifiers.Builder()
                                            .setMetadata(getCorrectMetadataTag())
                                            .setSemantics(
                                                    new Semantics.Builder()
                                                            .setContentDescription(
                                                                    getCorrectContentDescription())
                                                            .build())
                                            .build())
                            .addContent(visible.build())
                            .build();

            return new Chip(wrapperForTapTarget);
        }

        private ContainerDimension resolveMinTappableWidth() {
            if (mWidth instanceof DpProp) {
                return dp(max(((DpProp) mWidth).getValue(), mMinTappableSquareLength.getValue()));
            } else if (mWidth instanceof WrappedDimensionProp) {
                return new WrappedDimensionProp.Builder()
                        .setMinimumSize(mMinTappableSquareLength)
                        .build();
            } else {
                return mWidth;
            }
        }

        private float resolveMinTappableHeight() {
            return max(mHeight.getValue(), mMinTappableSquareLength.getValue());
        }

        @NonNull
        private StringProp getCorrectContentDescription() {
            if (mContentDescription == null) {
                String staticValue = "";
                if (mPrimaryLabelContent != null) {
                    staticValue += mPrimaryLabelContent;
                }
                if (mSecondaryLabelContent != null) {
                    staticValue += "\n" + mSecondaryLabelContent;
                }
                mContentDescription = new StringProp.Builder(staticValue).build();
            }
            return checkNotNull(mContentDescription);
        }

        private ElementMetadata getCorrectMetadataTag() {
            String tag = METADATA_TAG_TEXT;
            if (mCustomContent != null) {
                tag = METADATA_TAG_CUSTOM_CONTENT;
            } else if (mIconContent != null) {
                tag = METADATA_TAG_ICON;
            }
            return new ElementMetadata.Builder().setTagData(getTagBytes(tag)).build();
        }

        @SuppressLint("CheckResult") // (b/247804720)
        @NonNull
        private LayoutElement getCorrectContent() {
            if (mCustomContent != null) {
                return mCustomContent;
            }

            Column.Builder column =
                    new Column.Builder()
                            .setHorizontalAlignment(HORIZONTAL_ALIGN_START)
                            .addContent(putLayoutInBox(checkNotNull(mPrimaryLabelContent)).build());

            if (mSecondaryLabelContent != null) {
                column.addContent(putLayoutInBox(mSecondaryLabelContent).build());
            }

            Box labels = putLayoutInBox(column.build()).build();
            if (mIconContent == null) {
                return labels;
            } else {
                return new Row.Builder()
                        .addContent(mIconContent)
                        .addContent(
                                new Spacer.Builder()
                                        .setHeight(mHeight)
                                        .setWidth(mIconSpacerWidth)
                                        .build())
                        .addContent(labels)
                        .setVerticalAlignment(LayoutElementBuilders.VERTICAL_ALIGN_CENTER)
                        .build();
            }
        }

        private Box.Builder putLayoutInBox(@NonNull LayoutElement element) {
            // Wrapped and centered content are default.
            return new Box.Builder().addContent(element);
        }
    }

    /** Returns the visible height of this Chip. */
    @NonNull
    public ContainerDimension getHeight() {
        return checkNotNull(mElement.getHeight());
    }

    /** Returns width of this Chip. */
    @NonNull
    public ContainerDimension getWidth() {
        return checkNotNull(mElement.getWidth());
    }

    /** Returns click event action associated with this Chip. */
    @NonNull
    public Clickable getClickable() {
        return checkNotNull(checkNotNull(mElement.getModifiers()).getClickable());
    }

    /** Returns background color of this Chip. */
    @NonNull
    public ColorProp getBackgroundColor() {
        return checkNotNull(
                checkNotNull(checkNotNull(mElement.getModifiers()).getBackground()).getColor());
    }

    /** Returns content description of this Chip. */
    @Nullable
    public StringProp getContentDescription() {
        Semantics semantics = checkNotNull(mImpl.getModifiers()).getSemantics();
        if (semantics == null) {
            return null;
        }
        return semantics.getContentDescription();
    }

    /** Returns custom content from this Chip if it has been added. Otherwise, it returns null. */
    @Nullable
    public LayoutElement getCustomContent() {
        if (getMetadataTag().equals(METADATA_TAG_CUSTOM_CONTENT)) {
            return checkNotNull(checkNotNull(mElement.getContents()).get(0));
        }
        return null;
    }

    /** Returns primary label from this Chip if it has been added. Otherwise, it returns null. */
    @Nullable
    public LayoutElement getPrimaryLabelContent() {
        return getPrimaryOrSecondaryLabelContent(PRIMARY_LABEL_INDEX);
    }

    /** Returns secondary label from this Chip if it has been added. Otherwise, it returns null. */
    @Nullable
    public LayoutElement getSecondaryLabelContent() {
        return getPrimaryOrSecondaryLabelContent(SECONDARY_LABEL_INDEX);
    }

    /** Returns icon id from this Chip if it has been added. Otherwise, it returns null. */
    @Nullable
    public LayoutElement getIconContent() {
        if (!getMetadataTag().equals(METADATA_TAG_ICON)) {
            return null;
        }
        return ((Row) mElement.getContents().get(0)).getContents().get(0);
    }

    @Nullable
    private LayoutElement getPrimaryOrSecondaryLabelContent(int index) {
        String metadataTag = getMetadataTag();
        if (metadataTag.equals(METADATA_TAG_CUSTOM_CONTENT)) {
            return null;
        }

        // In any other case, text (either primary or primary + label) must be present.
        Column content;
        if (metadataTag.equals(METADATA_TAG_ICON)) {
            content =
                    (Column)
                            ((Box)
                                            ((Row) mElement.getContents().get(0))
                                                    .getContents()
                                                    .get(LABELS_INDEX_ICON))
                                    .getContents()
                                    .get(0);
        } else {
            content =
                    (Column)
                            ((Box) mElement.getContents().get(0))
                                    .getContents()
                                    .get(LABELS_INDEX_NO_ICON);
        }

        // We need to check this as this can be the case when we called for label, which doesn't
        // exist.
        return index < content.getContents().size()
                ? ((Box) content.getContents().get(index)).getContents().get(0)
                : null;
    }

    /** Returns the horizontal alignment of the content in this Chip. */
    @HorizontalAlignment
    public int getHorizontalAlignment() {
        return checkNotNull(mElement.getHorizontalAlignment()).getValue();
    }

    /** Returns metadata tag set to this Chip. */
    @NonNull
    public String getMetadataTag() {
        return getMetadataTagName(checkNotNull(checkNotNull(mImpl.getModifiers()).getMetadata()));
    }

    /**
     * Returns Chip object from the given LayoutElement (e.g. one retrieved from a container's
     * content with {@code container.getContents().get(index)}) if that element can be converted to
     * Chip. Otherwise, it will return null.
     */
    @Nullable
    public static Chip fromLayoutElement(@NonNull LayoutElement element) {
        if (element instanceof Chip) {
            return (Chip) element;
        }
        if (!(element instanceof Box)) {
            return null;
        }
        Box boxElement = (Box) element;
        if (!checkTag(boxElement.getModifiers(), Builder.TYPE_TO_TAG.values())) {
            return null;
        }
        // Now we are sure that this element is a Chip.
        return new Chip(boxElement);
    }

    @NonNull
    @Override
    @RestrictTo(Scope.LIBRARY_GROUP)
    public LayoutElementProto.LayoutElement toLayoutElementProto() {
        return mImpl.toLayoutElementProto();
    }

    @Nullable
    @Override
    public Fingerprint getFingerprint() {
        return mImpl.getFingerprint();
    }
}