/*
* Copyright 2020 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.car.app.model;
import static androidx.annotation.RestrictTo.Scope.LIBRARY;
import static androidx.car.app.model.Metadata.EMPTY_METADATA;
import static java.util.Objects.requireNonNull;
import android.annotation.SuppressLint;
import android.os.Looper;
import androidx.annotation.IntDef;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.car.app.annotations.CarProtocol;
import androidx.car.app.model.constraints.CarIconConstraints;
import androidx.car.app.model.constraints.CarTextConstraints;
import androidx.car.app.utils.CollectionUtils;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
* Represents a row with a title, several lines of text, an optional image, and an optional action
* or switch.
*/
@CarProtocol
public final class Row implements Item {
/** A boat that belongs to you. */
private static final String YOUR_BOAT = "\uD83D\uDEA3"; // 🚣
/**
* The type of images supported within rows.
*
* @hide
*/
@RestrictTo(LIBRARY)
@IntDef(value = {IMAGE_TYPE_SMALL, IMAGE_TYPE_ICON, IMAGE_TYPE_LARGE})
@Retention(RetentionPolicy.SOURCE)
public @interface RowImageType {
}
/**
* Represents a small image to be displayed in the row.
*
* <p>To minimize scaling artifacts across a wide range of car screens, apps should provide
* images targeting a 88 x 88 dp bounding box. If necessary, the image will be scaled down while
* preserving its aspect ratio.
*/
public static final int IMAGE_TYPE_SMALL = (1 << 0);
/**
* Represents a large image to be displayed in the row.
*
* <p>To minimize scaling artifacts across a wide range of car screens, apps should provide
* images targeting a 224 x 224 dp bounding box. If necessary, the image will be scaled down
* while preserving its aspect ratio.
*/
public static final int IMAGE_TYPE_LARGE = (1 << 1);
/**
* Represents a small image to be displayed in the row.
*
* <p>To minimize scaling artifacts across a wide range of car screens, apps should provide
* images targeting a 88 x 88 dp bounding box. If necessary, the icon will be scaled down while
* preserving its aspect ratio.
*
* <p>A tint color is expected to be provided via {@link CarIcon.Builder#setTint}. Otherwise, a
* default tint color as determined by the host will be applied.
*/
public static final int IMAGE_TYPE_ICON = (1 << 2);
@Keep
@Nullable
private final CarText mTitle;
@Keep
private final List<CarText> mTexts;
@Keep
@Nullable
private final CarIcon mImage;
@Keep
@Nullable
private final Toggle mToggle;
@Keep
@Nullable
private final OnClickDelegate mOnClickDelegate;
@Keep
private final Metadata mMetadata;
@Keep
private final boolean mIsBrowsable;
@Keep
@RowImageType
private final int mRowImageType;
/**
* Returns the title of the row or {@code null} if not set.
*
* @see Builder#setTitle(CharSequence)
*/
@Nullable
public CarText getTitle() {
return mTitle;
}
/**
* Returns the list of text below the title.
*
* @see Builder#addText(CharSequence)
*/
@NonNull
public List<CarText> getTexts() {
return CollectionUtils.emptyIfNull(mTexts);
}
/**
* Returns the image to display in the row or {@code null} if the row does not contain an
* image.
*
* @see Builder#setImage(CarIcon)
* @see Builder#setImage(CarIcon, int)
*/
@Nullable
public CarIcon getImage() {
return mImage;
}
/** Returns the type of the image in the row. */
@RowImageType
public int getRowImageType() {
return mRowImageType;
}
/**
* Returns the {@link Toggle} in the row or {@code null} if the row does not contain a
* toggle.
*
* @see Builder#setToggle(Toggle)
*/
@Nullable
public Toggle getToggle() {
return mToggle;
}
/**
* Returns whether the row is browsable.
*
* <p>If a row is browsable, then no {@link Action} or {@link Toggle} can be added to it.
*
* @see Builder#isBrowsable()
*/
public boolean isBrowsable() {
return mIsBrowsable;
}
/**
* Returns the {@link OnClickListener} to be called back when the row is clicked or {@code
* null} if the row is non-clickable.
*/
@Nullable
public OnClickDelegate getOnClickDelegate() {
return mOnClickDelegate;
}
/**
* Returns the {@link Metadata} associated with the row or {@code null} if there is no
* metadata associated with the row.
*/
@Nullable
public Metadata getMetadata() {
return mMetadata;
}
/**
* Rows your boat.
*
* <p>Example usage:
*
* <pre>{@code
* row.row().row().yourBoat(); // gently down the stream
* }</pre>
*/
@NonNull
public CharSequence yourBoat() {
return YOUR_BOAT;
}
/** Returns a {@link Row} for rowing {@link #yourBoat()} */
@NonNull
public Row row() {
return this;
}
@Override
@NonNull
public String toString() {
return "[title: "
+ CarText.toShortString(mTitle)
+ ", text count: "
+ (mTexts != null ? mTexts.size() : 0)
+ ", image: "
+ mImage
+ ", isBrowsable: "
+ mIsBrowsable
+ "]";
}
@Override
public int hashCode() {
return Objects.hash(
mTitle,
mTexts,
mImage,
mToggle,
mOnClickDelegate == null,
mMetadata,
mIsBrowsable,
mRowImageType);
}
@Override
public boolean equals(@Nullable Object other) {
if (this == other) {
return true;
}
if (!(other instanceof Row)) {
return false;
}
Row otherRow = (Row) other;
// Don't compare listener, only the fact whether it's present.
return Objects.equals(mTitle, otherRow.mTitle)
&& Objects.equals(mTexts, otherRow.mTexts)
&& Objects.equals(mImage, otherRow.mImage)
&& Objects.equals(mToggle, otherRow.mToggle)
&& Objects.equals(mOnClickDelegate == null, otherRow.mOnClickDelegate == null)
&& Objects.equals(mMetadata, otherRow.mMetadata)
&& mIsBrowsable == otherRow.mIsBrowsable
&& mRowImageType == otherRow.mRowImageType;
}
Row(Builder builder) {
mTitle = builder.mTitle;
mTexts = CollectionUtils.unmodifiableCopy(builder.mTexts);
mImage = builder.mImage;
mToggle = builder.mToggle;
mOnClickDelegate = builder.mOnClickDelegate;
mMetadata = builder.mMetadata;
mIsBrowsable = builder.mIsBrowsable;
mRowImageType = builder.mRowImageType;
}
/** Constructs an empty instance, used by serialization code. */
private Row() {
mTitle = null;
mTexts = Collections.emptyList();
mImage = null;
mToggle = null;
mOnClickDelegate = null;
mMetadata = EMPTY_METADATA;
mIsBrowsable = false;
mRowImageType = IMAGE_TYPE_SMALL;
}
/** A builder of {@link Row}. */
public static final class Builder {
@Nullable
CarText mTitle;
final List<CarText> mTexts = new ArrayList<>();
@Nullable
CarIcon mImage;
@Nullable
Toggle mToggle;
@Nullable
OnClickDelegate mOnClickDelegate;
Metadata mMetadata = EMPTY_METADATA;
boolean mIsBrowsable;
@RowImageType
int mRowImageType = IMAGE_TYPE_SMALL;
/**
* Sets the title of the row.
*
* <p>Only {@link DistanceSpan}s and {@link DurationSpan}s are supported in the input
* string.
*
* @throws NullPointerException if {@code title} is {@code null}
* @throws IllegalArgumentException if {@code title} is empty, of if it contains
* unsupported spans
*/
@NonNull
public Builder setTitle(@NonNull CharSequence title) {
CarText titleText = CarText.create(requireNonNull(title));
if (titleText.isEmpty()) {
throw new IllegalArgumentException("The title cannot be null or empty");
}
CarTextConstraints.TEXT_ONLY.validateOrThrow(titleText);
mTitle = titleText;
return this;
}
/**
* Sets the title of the row, with support for multiple length variants.
*
* <p>Only {@link DistanceSpan}s and {@link DurationSpan}s are supported in the input
* string.
*
* @throws NullPointerException if {@code title} is {@code null}
* @throws IllegalArgumentException if {@code title} is empty, of if it contains
* unsupported spans
*/
@NonNull
public Builder setTitle(@NonNull CarText title) {
if (requireNonNull(title).isEmpty()) {
throw new IllegalArgumentException("The title cannot be null or empty");
}
CarTextConstraints.TEXT_ONLY.validateOrThrow(title);
mTitle = title;
return this;
}
/**
* Adds a text string to the row below the title.
*
* <p>The text can be customized with {@link ForegroundCarColorSpan},
* {@link androidx.car.app.model.DistanceSpan}, and
* {@link androidx.car.app.model.DurationSpan} instances, any other spans will be ignored
* by the host.
*
* <p>Most templates allow up to 2 text strings, but this may vary. This limit is
* documented in each individual template.
*
* <h4>Text Wrapping</h4>
*
* Each string added with this method will not wrap more than 1 line in the UI, with
* one exception: if the template allows a maximum number of text strings larger than 1, and
* the app adds a single text string, then this string will wrap up to the maximum.
*
* <p>For example, assuming 2 lines are allowed in the template where the row will be
* used, this code:
*
* <pre>{@code
* rowBuilder
* .addText("This is a rather long line of text")
* .addText("More text")
* }</pre>
*
* <p>would wrap the text like this:
*
* <pre>
* This is a rather long li...
* More text
* </pre>
*
* In contrast, this code:
*
* <pre>{@code
* rowBuilder
* .addText("This is a rather long line of text. More text")
* }</pre>
*
* <p>would wrap the single line of text at a maximum of 2 lines, producing a different
* result:
*
* <pre>
* This is a rather long line
* of text. More text
* </pre>
*
* <p>Note that when using a single line, a line break character can be used to break it
* into two, but the results may be unpredictable depending on the width the text is
* wrapped at:
*
* <pre>{@code
* rowBuilder
* .addText("This is a rather long line of text\nMore text")
* }</pre>
*
* <p>would produce a result that may loose the "More text" string:
*
* <pre>
* This is a rather long line
* of text
* </pre>
*
* @throws NullPointerException if {@code text} is {@code null}
* @throws IllegalArgumentException if {@code text} contains unsupported spans
* @see ForegroundCarColorSpan
*/
@NonNull
public Builder addText(@NonNull CharSequence text) {
CarText carText = CarText.create(requireNonNull(text));
CarTextConstraints.TEXT_WITH_COLORS.validateOrThrow(carText);
mTexts.add(CarText.create(requireNonNull(text)));
return this;
}
/**
* Adds a text string to the row below the title, with support for multiple length variants.
*
* @throws NullPointerException if {@code text} is {@code null}
* @throws IllegalArgumentException if {@code text} contains unsupported spans
* @see Builder#addText(CharSequence)
*/
@NonNull
public Builder addText(@NonNull CarText text) {
CarTextConstraints.TEXT_WITH_COLORS.validateOrThrow(requireNonNull(text));
mTexts.add(text);
return this;
}
/**
* Sets an image to show in the row with the default size {@link #IMAGE_TYPE_SMALL}.
*
* @throws NullPointerException if {@code image} is {@code null}
* @see #setImage(CarIcon, int)
*/
@NonNull
public Builder setImage(@NonNull CarIcon image) {
return setImage(requireNonNull(image), IMAGE_TYPE_SMALL);
}
/**
* Sets an image to show in the row with the given image type.
*
* <p>For a custom {@link CarIcon}, its {@link androidx.core.graphics.drawable.IconCompat}
* instance can be of {@link androidx.core.graphics.drawable.IconCompat#TYPE_BITMAP},
* {@link androidx.core.graphics.drawable.IconCompat#TYPE_RESOURCE}, or
* {@link androidx.core.graphics.drawable.IconCompat#TYPE_URI}.
*
* <h4>Image Sizing Guidance</h4>
*
* <p>If the input image's size exceeds the sizing requirements for the given image type in
* either one of the dimensions, it will be scaled down to be centered inside the
* bounding box while preserving its aspect ratio.
*
* <p>See {@link CarIcon} for more details related to providing icon and image resources
* that work with different car screen pixel densities.
*
* @param image the {@link CarIcon} to display or {@code null} to not display one
* @param imageType one of {@link #IMAGE_TYPE_ICON}, {@link #IMAGE_TYPE_SMALL} or {@link
* #IMAGE_TYPE_LARGE}
* @throws NullPointerException if {@code image} is {@code null}
*/
@NonNull
public Builder setImage(@NonNull CarIcon image, @RowImageType int imageType) {
CarIconConstraints.UNCONSTRAINED.validateOrThrow(requireNonNull(image));
mImage = image;
mRowImageType = imageType;
return this;
}
/**
* Sets a {@link Toggle} to show in the row.
*
* @throws NullPointerException if {@code toggle} is {@code null}
*/
@NonNull
public Builder setToggle(@NonNull Toggle toggle) {
mToggle = requireNonNull(toggle);
return this;
}
/**
* Shows an icon at the end of the row that indicates that the row is browsable.
*
* <p>Browsable rows can be used, for example, to represent the parent row in a hierarchy of
* lists with child lists.
*
* <p>If a row is browsable, then no {@link Action} or {@link Toggle} can be added to it.
*/
@NonNull
public Builder setBrowsable(boolean isBrowsable) {
mIsBrowsable = isBrowsable;
return this;
}
/**
* Sets the {@link OnClickListener} to be called back when the row is clicked.
*
* <p>Note that the listener relates to UI events and will be executed on the main thread
* using {@link Looper#getMainLooper()}.
*
* @throws NullPointerException if {@code onClickListener} is {@code null}
*/
@NonNull
@SuppressLint({"MissingGetterMatchingBuilder", "ExecutorRegistration"})
public Builder setOnClickListener(@NonNull OnClickListener onClickListener) {
mOnClickDelegate = OnClickDelegateImpl.create(onClickListener);
return this;
}
/**
* Sets the {@link Metadata} associated with the row.
*
* @param metadata The metadata to set with the row. Pass {@link Metadata#EMPTY_METADATA}
* to not associate any metadata with the row
*/
@NonNull
public Builder setMetadata(@NonNull Metadata metadata) {
mMetadata = metadata;
return this;
}
/**
* Constructs the {@link Row} defined by this builder.
*
* @throws IllegalStateException if the row's title is not set, if it is a browsable
* row and has a {@link Toggle}, if it is a browsable
* row but does not have a {@link OnClickListener}, or if
* it has both a {@link OnClickListener} and a {@link
* Toggle}
*/
@NonNull
public Row build() {
if (mTitle == null) {
throw new IllegalStateException("A title must be set on the row");
}
if (mIsBrowsable) {
if (mToggle != null) {
throw new IllegalStateException("A browsable row must not have a toggle set");
}
if (mOnClickDelegate == null) {
throw new IllegalStateException(
"A browsable row must have its onClickListener set");
}
}
if (mToggle != null && mOnClickDelegate != null) {
throw new IllegalStateException(
"If a row contains a toggle, it must not have a onClickListener set");
}
return new Row(this);
}
/** Returns an empty {@link Builder} instance. */
public Builder() {
}
}
}