From be425282a6487da0873cc06e1a0a60b671e1540b Mon Sep 17 00:00:00 2001 From: FineFindus <63370021+FineFindus@users.noreply.github.com> Date: Wed, 21 Jun 2023 01:38:51 +0200 Subject: [PATCH] Hashtag timelines with multiple tags (#584) * feat(api/hashtag): add any, all, and none parameter * feat(timeline/hashtag): load with any, all and none parameter * feat(timeline/hashtag): save any, all and none in timeline definition * feat: set hastag parameter in UI * feat: move strings to string res * feat: show hint for tags * refactor: use method for setting up tags text * improve edit dialog, allow creating hashtag timelines * add chips for hashtags * add option for displaying only local posts in hashtag * improve layout and wording --------- Co-authored-by: sk --- .../hootsuite/nachos/ChipConfiguration.java | 78 ++ .../com/hootsuite/nachos/NachoTextView.java | 1132 +++++++++++++++++ .../java/com/hootsuite/nachos/chip/Chip.java | 29 + .../hootsuite/nachos/chip/ChipCreator.java | 44 + .../com/hootsuite/nachos/chip/ChipInfo.java | 20 + .../com/hootsuite/nachos/chip/ChipSpan.java | 510 ++++++++ .../nachos/chip/ChipSpanChipCreator.java | 60 + .../terminator/ChipTerminatorHandler.java | 95 ++ .../DefaultChipTerminatorHandler.java | 115 ++ .../nachos/terminator/TextIterator.java | 63 + .../nachos/tokenizer/BaseChipTokenizer.java | 89 ++ .../nachos/tokenizer/ChipTokenizer.java | 134 ++ .../nachos/tokenizer/SpanChipTokenizer.java | 246 ++++ .../validator/ChipifyingNachoValidator.java | 32 + .../validator/IllegalCharacterIdentifier.java | 5 + .../nachos/validator/NachoValidator.java | 29 + .../timelines/GetHashtagTimeline.java | 19 + .../fragments/EditTimelinesFragment.java | 233 +++- .../fragments/HashtagTimelineFragment.java | 10 +- .../android/model/TimelineDefinition.java | 64 +- .../android/ui/text/TagEditText.java | 2 + .../res/color/chip_material_background.xml | 4 + .../bordered_rectangle_rounded_corners.xml | 4 + .../ic_fluent_shape_intersect_20_filled.xml | 3 + .../ic_fluent_shape_intersect_20_regular.xml | 3 + .../ic_fluent_shape_subtract_20_filled.xml | 3 + .../ic_fluent_shape_union_20_filled.xml | 3 + .../ic_fluent_shape_union_20_regular.xml | 3 + .../src/main/res/layout/edit_timeline.xml | 221 +++- .../main/res/layout/list_timeline_editor.xml | 13 +- mastodon/src/main/res/values/nachos_attrs.xml | 12 + .../src/main/res/values/nachos_colors.xml | 5 + .../src/main/res/values/nachos_dimens.xml | 7 + .../src/main/res/values/nachos_strings.xml | 3 + .../src/main/res/values/nachos_styles.xml | 10 + mastodon/src/main/res/values/strings_sk.xml | 12 + 36 files changed, 3215 insertions(+), 100 deletions(-) create mode 100644 mastodon/src/main/java/com/hootsuite/nachos/ChipConfiguration.java create mode 100644 mastodon/src/main/java/com/hootsuite/nachos/NachoTextView.java create mode 100644 mastodon/src/main/java/com/hootsuite/nachos/chip/Chip.java create mode 100644 mastodon/src/main/java/com/hootsuite/nachos/chip/ChipCreator.java create mode 100644 mastodon/src/main/java/com/hootsuite/nachos/chip/ChipInfo.java create mode 100644 mastodon/src/main/java/com/hootsuite/nachos/chip/ChipSpan.java create mode 100644 mastodon/src/main/java/com/hootsuite/nachos/chip/ChipSpanChipCreator.java create mode 100644 mastodon/src/main/java/com/hootsuite/nachos/terminator/ChipTerminatorHandler.java create mode 100644 mastodon/src/main/java/com/hootsuite/nachos/terminator/DefaultChipTerminatorHandler.java create mode 100644 mastodon/src/main/java/com/hootsuite/nachos/terminator/TextIterator.java create mode 100644 mastodon/src/main/java/com/hootsuite/nachos/tokenizer/BaseChipTokenizer.java create mode 100644 mastodon/src/main/java/com/hootsuite/nachos/tokenizer/ChipTokenizer.java create mode 100644 mastodon/src/main/java/com/hootsuite/nachos/tokenizer/SpanChipTokenizer.java create mode 100644 mastodon/src/main/java/com/hootsuite/nachos/validator/ChipifyingNachoValidator.java create mode 100644 mastodon/src/main/java/com/hootsuite/nachos/validator/IllegalCharacterIdentifier.java create mode 100644 mastodon/src/main/java/com/hootsuite/nachos/validator/NachoValidator.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/ui/text/TagEditText.java create mode 100644 mastodon/src/main/res/color/chip_material_background.xml create mode 100644 mastodon/src/main/res/drawable/bordered_rectangle_rounded_corners.xml create mode 100644 mastodon/src/main/res/drawable/ic_fluent_shape_intersect_20_filled.xml create mode 100644 mastodon/src/main/res/drawable/ic_fluent_shape_intersect_20_regular.xml create mode 100644 mastodon/src/main/res/drawable/ic_fluent_shape_subtract_20_filled.xml create mode 100644 mastodon/src/main/res/drawable/ic_fluent_shape_union_20_filled.xml create mode 100644 mastodon/src/main/res/drawable/ic_fluent_shape_union_20_regular.xml create mode 100644 mastodon/src/main/res/values/nachos_attrs.xml create mode 100644 mastodon/src/main/res/values/nachos_colors.xml create mode 100644 mastodon/src/main/res/values/nachos_dimens.xml create mode 100644 mastodon/src/main/res/values/nachos_strings.xml create mode 100644 mastodon/src/main/res/values/nachos_styles.xml diff --git a/mastodon/src/main/java/com/hootsuite/nachos/ChipConfiguration.java b/mastodon/src/main/java/com/hootsuite/nachos/ChipConfiguration.java new file mode 100644 index 000000000..0b8fb3b7a --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/ChipConfiguration.java @@ -0,0 +1,78 @@ +package com.hootsuite.nachos; + +import android.content.res.ColorStateList; + +public class ChipConfiguration { + + private final int mChipHorizontalSpacing; + private final ColorStateList mChipBackground; + private final int mChipCornerRadius; + private final int mChipTextColor; + private final int mChipTextSize; + private final int mChipHeight; + private final int mChipVerticalSpacing; + private final int mMaxAvailableWidth; + + /** + * Creates a new ChipConfiguration. You can pass in {@code -1} or {@code null} for any of the parameters to indicate that parameter should be + * ignored. + * + * @param chipHorizontalSpacing the amount of horizontal space (in pixels) to put between consecutive chips + * @param chipBackground the {@link ColorStateList} to set as the background of the chips + * @param chipCornerRadius the corner radius of the chip background, in pixels + * @param chipTextColor the color to set as the text color of the chips + * @param chipTextSize the font size (in pixels) to use for the text of the chips + * @param chipHeight the height (in pixels) of each chip + * @param chipVerticalSpacing the amount of vertical space (in pixels) to put between chips on consecutive lines + * @param maxAvailableWidth the maximum available with for a chip (the width of a full line of text in the text view) + */ + ChipConfiguration(int chipHorizontalSpacing, + ColorStateList chipBackground, + int chipCornerRadius, + int chipTextColor, + int chipTextSize, + int chipHeight, + int chipVerticalSpacing, + int maxAvailableWidth) { + mChipHorizontalSpacing = chipHorizontalSpacing; + mChipBackground = chipBackground; + mChipCornerRadius = chipCornerRadius; + mChipTextColor = chipTextColor; + mChipTextSize = chipTextSize; + mChipHeight = chipHeight; + mChipVerticalSpacing = chipVerticalSpacing; + mMaxAvailableWidth = maxAvailableWidth; + } + + public int getChipHorizontalSpacing() { + return mChipHorizontalSpacing; + } + + public ColorStateList getChipBackground() { + return mChipBackground; + } + + public int getChipCornerRadius() { + return mChipCornerRadius; + } + + public int getChipTextColor() { + return mChipTextColor; + } + + public int getChipTextSize() { + return mChipTextSize; + } + + public int getChipHeight() { + return mChipHeight; + } + + public int getChipVerticalSpacing() { + return mChipVerticalSpacing; + } + + public int getMaxAvailableWidth() { + return mMaxAvailableWidth; + } +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/NachoTextView.java b/mastodon/src/main/java/com/hootsuite/nachos/NachoTextView.java new file mode 100644 index 000000000..f91ebf81d --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/NachoTextView.java @@ -0,0 +1,1132 @@ +package com.hootsuite.nachos; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.Paint; +import android.text.Editable; +import android.text.Layout; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.util.Log; +import android.util.Pair; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.widget.Adapter; +import android.widget.AdapterView; +import android.widget.AutoCompleteTextView; +import android.widget.ListAdapter; +import android.widget.MultiAutoCompleteTextView; + +import androidx.annotation.ColorInt; +import androidx.annotation.ColorRes; +import androidx.annotation.DimenRes; +import androidx.annotation.Dimension; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.hootsuite.nachos.chip.Chip; +import com.hootsuite.nachos.chip.ChipInfo; +import com.hootsuite.nachos.chip.ChipSpan; +import com.hootsuite.nachos.chip.ChipSpanChipCreator; +import com.hootsuite.nachos.terminator.ChipTerminatorHandler; +import com.hootsuite.nachos.terminator.DefaultChipTerminatorHandler; +import com.hootsuite.nachos.tokenizer.ChipTokenizer; +import com.hootsuite.nachos.tokenizer.SpanChipTokenizer; +import com.hootsuite.nachos.validator.ChipifyingNachoValidator; +import com.hootsuite.nachos.validator.IllegalCharacterIdentifier; +import com.hootsuite.nachos.validator.NachoValidator; + +import org.joinmastodon.android.R; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * An editable TextView extending {@link MultiAutoCompleteTextView} that supports "chipifying" pieces of text and displaying suggestions for segments of the text. + *

The ChipTokenizer

+ * To customize chipifying with this class you can provide a custom {@link ChipTokenizer} by calling {@link #setChipTokenizer(ChipTokenizer)}. + * By default the {@link SpanChipTokenizer} is used. + *

Chip Terminators

+ * To set which characters trigger the creation of a chip, call {@link #addChipTerminator(char, int)} or {@link #setChipTerminators(Map)}. + * For example if tapping enter should cause all unchipped text to become chipped, call + * {@code chipSuggestionTextView.addChipTerminator('\n', ChipTerminatorHandler.BEHAVIOR_CHIPIFY_ALL);} + * To completely customize how chips are created when text is entered in this text view you can provide a custom {@link ChipTerminatorHandler} + * through {@link #setChipTerminatorHandler(ChipTerminatorHandler)} + *

Illegal Characters

+ * To prevent a character from being typed you can call {@link #setIllegalCharacterIdentifier(IllegalCharacterIdentifier)}} to identify characters + * that should be considered illegal. + *

Suggestions

+ * To provide suggestions you must provide an {@link android.widget.Adapter} by calling {@link #setAdapter(ListAdapter)} + *

UI Customization

+ * This view defines six custom attributes (all of which are optional): + * + * The values of these attributes will be passed to the ChipTokenizer through {@link ChipTokenizer#applyConfiguration(Editable, ChipConfiguration)} + *

Validation

+ * This class can perform validation when certain events occur (such as losing focus). When the validation occurs is decided by + * {@link AutoCompleteTextView}. To perform validation, set a {@link NachoValidator}: + *
+ *         nachoTextView.setNachoValidator(new ChipifyingNachoValidator());
+ *     
+ * Note: The NachoValidator will be ignored if a ChipTokenizer is not set. To perform validation without a ChipTokenizer you can use + * {@link AutoCompleteTextView}'s built-in {@link AutoCompleteTextView.Validator Validator} through {@link #setValidator(Validator)} + *

Editing Chips

+ * This class also supports editing chips on touch. To enable this behavior call {@link #enableEditChipOnTouch(boolean, boolean)}. To disable this + * behavior you can call {@link #disableEditChipOnTouch()} + *

Example Setup:

+ * A standard setup for this class could look something like the following: + *
+ *         String[] suggestions = new String[]{"suggestion 1", "suggestion 2"};
+ *         ArrayAdapter<String> adapter = new ArrayAdapter<>(this, android.R.layout.simple_dropdown_item_1line, suggestions);
+ *         nachoTextView.setAdapter(adapter);
+ *         nachoTextView.addChipTerminator('\n', ChipTerminatorHandler.BEHAVIOR_CHIPIFY_ALL);
+ *         nachoTextView.addChipTerminator(' ', ChipTerminatorHandler.BEHAVIOR_CHIPIFY_TO_TERMINATOR);
+ *         nachoTextView.setIllegalCharacters('@');
+ *         nachoTextView.setNachoValidator(new ChipifyingNachoValidator());
+ *         nachoTextView.enableEditChipOnTouch(true, true);
+ *         nachoTextView.setOnChipClickListener(new NachoTextView.OnChipClickListener() {
+ *            {@literal @Override}
+ *             public void onChipClick(Chip chip, MotionEvent motionEvent) {
+ *                 // Handle click event
+ *             }
+ *         });
+ *         nachoTextView.setOnChipRemoveListener(new NachoTextView.OnChipRemoveListener() {
+ *            {@literal @Override}
+ *             public void onChipRemove(Chip chip) {
+ *                 // Handle remove event
+ *             }
+ *         });
+ *     
+ * + * @see SpanChipTokenizer + * @see DefaultChipTerminatorHandler + * @see ChipifyingNachoValidator + */ +public class NachoTextView extends MultiAutoCompleteTextView implements TextWatcher, AdapterView.OnItemClickListener { + + // UI Attributes + private int mChipHorizontalSpacing = -1; + private ColorStateList mChipBackground = null; + private int mChipCornerRadius = -1; + private int mChipTextColor = Color.TRANSPARENT; + private int mChipTextSize = -1; + private int mChipHeight = -1; + private int mChipVerticalSpacing = -1; + + private int mDefaultPaddingTop = 0; + private int mDefaultPaddingBottom = 0; + /** + * Flag to keep track of the padding state so we only update the padding when necessary + */ + private boolean mUsingDefaultPadding = true; + + // Touch events + @Nullable + private OnChipClickListener mOnChipClickListener; + private GestureDetector singleTapDetector; + private boolean mEditChipOnTouchEnabled; + private boolean mMoveChipToEndOnEdit; + private boolean mChipifyUnterminatedTokensOnEdit; + + // Text entry + @Nullable + private ChipTokenizer mChipTokenizer; + @Nullable + private ChipTerminatorHandler mChipTerminatorHandler; + @Nullable + private NachoValidator mNachoValidator; + @Nullable + private IllegalCharacterIdentifier illegalCharacterIdentifier; + + @Nullable + private OnChipRemoveListener mOnChipRemoveListener; + private List mChipsToRemove = new ArrayList<>(); + private boolean mIgnoreTextChangedEvents; + private int mTextChangedStart; + private int mTextChangedEnd; + private boolean mIsPasteEvent; + + // Measurement + private boolean mMeasured; + + // Layout + private boolean mLayoutComplete; + + public NachoTextView(Context context) { + super(context); + init(null); + } + + public NachoTextView(Context context, AttributeSet attrs) { + super(context, attrs); + init(attrs); + } + + public NachoTextView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(attrs); + } + + private void init(@Nullable AttributeSet attrs) { + Context context = getContext(); + + if (attrs != null) { + TypedArray attributes = context.getTheme().obtainStyledAttributes( + attrs, + R.styleable.NachoTextView, + 0, + R.style.DefaultChipSuggestionTextView); + + try { + mChipHorizontalSpacing = attributes.getDimensionPixelSize(R.styleable.NachoTextView_chipHorizontalSpacing, -1); + mChipBackground = attributes.getColorStateList(R.styleable.NachoTextView_chipBackground); + mChipCornerRadius = attributes.getDimensionPixelSize(R.styleable.NachoTextView_chipCornerRadius, -1); + mChipTextColor = attributes.getColor(R.styleable.NachoTextView_chipTextColor, Color.TRANSPARENT); + mChipTextSize = attributes.getDimensionPixelSize(R.styleable.NachoTextView_chipTextSize, -1); + mChipHeight = attributes.getDimensionPixelSize(R.styleable.NachoTextView_chipHeight, -1); + mChipVerticalSpacing = attributes.getDimensionPixelSize(R.styleable.NachoTextView_chipVerticalSpacing, -1); + } finally { + attributes.recycle(); + } + } + + mDefaultPaddingTop = getPaddingTop(); + mDefaultPaddingBottom = getPaddingBottom(); + + singleTapDetector = new GestureDetector(getContext(), new SingleTapListener()); + + setImeOptions(EditorInfo.IME_FLAG_NO_FULLSCREEN); + addTextChangedListener(this); + setChipTokenizer(new SpanChipTokenizer<>(context, new ChipSpanChipCreator(), ChipSpan.class)); + setChipTerminatorHandler(new DefaultChipTerminatorHandler()); + setOnItemClickListener(this); + + updatePadding(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + if (!mMeasured && getWidth() > 0) { + // Refresh the tokenizer for width changes + invalidateChips(); + mMeasured = true; + } + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + + if (!mLayoutComplete) { + invalidateChips(); + mLayoutComplete = true; + } + } + + /** + * Updates the padding based on whether or not any chips are present to avoid the view from changing heights when chips are inserted/deleted. + * Extra padding is added when there are no chips. When there are chips the padding is reverted to its defaults. This only affects top and bottom + * padding because the chips only affect the height of the view. + */ + private void updatePadding() { + if (mChipHeight != -1) { + boolean chipsArePresent = !getAllChips().isEmpty(); + if (!chipsArePresent && mUsingDefaultPadding) { + mUsingDefaultPadding = false; + Paint paint = getPaint(); + Paint.FontMetricsInt fm = paint.getFontMetricsInt(); + int textHeight = fm.descent - fm.ascent; + // Calculate how tall the view should be if there were chips + int newTextHeight = mChipHeight + (mChipVerticalSpacing != -1 ? mChipVerticalSpacing : 0); + // We need to add half our missing height above and below the text by increasing top and bottom padding + int paddingAdjustment = (newTextHeight - textHeight) / 2; + super.setPadding(getPaddingLeft(), mDefaultPaddingTop + paddingAdjustment, getPaddingRight(), mDefaultPaddingBottom + paddingAdjustment); + } else if (chipsArePresent && !mUsingDefaultPadding) { + // If there are chips we can revert to default padding + mUsingDefaultPadding = true; + super.setPadding(getPaddingLeft(), mDefaultPaddingTop, getPaddingRight(), mDefaultPaddingBottom); + } + } + } + + /** + * Sets the padding on this View. The left and right padding will be handled as they normally would in a TextView. The top and bottom padding passed + * here will be the padding that is used when there are one or more chips in the text view. When there are no chips present, the padding will be + * increased to make sure the overall height of the text view stays the same, since chips take up more vertical space than plain text. + * + * @param left the left padding in pixels + * @param top the top padding in pixels + * @param right the right padding in pixels + * @param bottom the bottom padding in pixels + */ + @Override + public void setPadding(int left, int top, int right, int bottom) { + // Call the super method so that left and right padding are updated + // top and bottom padding will be handled in updatePadding() + super.setPadding(left, top, right, bottom); + mDefaultPaddingTop = top; + mDefaultPaddingBottom = bottom; + updatePadding(); + } + + public int getChipHorizontalSpacing() { + return mChipHorizontalSpacing; + } + + public void setChipHorizontalSpacing(@DimenRes int chipHorizontalSpacingResId) { + mChipHorizontalSpacing = getContext().getResources().getDimensionPixelSize(chipHorizontalSpacingResId); + invalidateChips(); + } + + public ColorStateList getChipBackground() { + return mChipBackground; + } + + public void setChipBackgroundResource(@ColorRes int chipBackgroundResId) { + setChipBackground(getContext().getColorStateList(chipBackgroundResId)); + } + + public void setChipBackground(ColorStateList chipBackground) { + mChipBackground = chipBackground; + invalidateChips(); + } + + /** + * @return The chip background corner radius value, in pixels. + */ + @Dimension + public int getChipCornerRadius() { + return mChipCornerRadius; + } + + /** + * Sets the chip background corner radius. + * + * @param chipCornerRadiusResId The dimension resource with the corner radius value. + */ + public void setChipCornerRadiusResource(@DimenRes int chipCornerRadiusResId) { + setChipCornerRadius(getContext().getResources().getDimensionPixelSize(chipCornerRadiusResId)); + } + + /** + * Sets the chip background corner radius. + * + * @param chipCornerRadius The corner radius value, in pixels. + */ + public void setChipCornerRadius(@Dimension int chipCornerRadius) { + mChipCornerRadius = chipCornerRadius; + invalidateChips(); + } + + + public int getChipTextColor() { + return mChipTextColor; + } + + public void setChipTextColorResource(@ColorRes int chipTextColorResId) { + setChipTextColor(getContext().getColor(chipTextColorResId)); + } + + public void setChipTextColor(@ColorInt int chipTextColor) { + mChipTextColor = chipTextColor; + invalidateChips(); + } + + public int getChipTextSize() { + return mChipTextSize; + } + + public void setChipTextSize(@DimenRes int chipTextSizeResId) { + mChipTextSize = getContext().getResources().getDimensionPixelSize(chipTextSizeResId); + invalidateChips(); + } + + public int getChipHeight() { + return mChipHeight; + } + + public void setChipHeight(@DimenRes int chipHeightResId) { + mChipHeight = getContext().getResources().getDimensionPixelSize(chipHeightResId); + invalidateChips(); + } + + public int getChipVerticalSpacing() { + return mChipVerticalSpacing; + } + + public void setChipVerticalSpacing(@DimenRes int chipVerticalSpacingResId) { + mChipVerticalSpacing = getContext().getResources().getDimensionPixelSize(chipVerticalSpacingResId); + invalidateChips(); + } + + @Nullable + public ChipTokenizer getChipTokenizer() { + return mChipTokenizer; + } + + /** + * Sets the {@link ChipTokenizer} to be used by this ChipSuggestionTextView. + * Note that a Tokenizer set here will override any Tokenizer set by {@link #setTokenizer(Tokenizer)} + * + * @param chipTokenizer the {@link ChipTokenizer} to set + */ + public void setChipTokenizer(@Nullable ChipTokenizer chipTokenizer) { + mChipTokenizer = chipTokenizer; + if (mChipTokenizer != null) { + setTokenizer(new ChipTokenizerWrapper(mChipTokenizer)); + } else { + setTokenizer(null); + } + invalidateChips(); + } + + public void setOnChipClickListener(@Nullable OnChipClickListener onChipClickListener) { + mOnChipClickListener = onChipClickListener; + } + + public void setOnChipRemoveListener(@Nullable OnChipRemoveListener onChipRemoveListener) { + mOnChipRemoveListener = onChipRemoveListener; + } + + public void setChipTerminatorHandler(@Nullable ChipTerminatorHandler chipTerminatorHandler) { + mChipTerminatorHandler = chipTerminatorHandler; + } + + public void setNachoValidator(@Nullable NachoValidator nachoValidator) { + mNachoValidator = nachoValidator; + } + + /** + * @see ChipTerminatorHandler#setChipTerminators(Map) + */ + public void setChipTerminators(@Nullable Map chipTerminators) { + if (mChipTerminatorHandler != null) { + mChipTerminatorHandler.setChipTerminators(chipTerminators); + } + } + + /** + * @see ChipTerminatorHandler#addChipTerminator(char, int) + */ + public void addChipTerminator(char character, int behavior) { + if (mChipTerminatorHandler != null) { + mChipTerminatorHandler.addChipTerminator(character, behavior); + } + } + + /** + * @see ChipTerminatorHandler#setPasteBehavior(int) + */ + public void setPasteBehavior(int pasteBehavior) { + if (mChipTerminatorHandler != null) { + mChipTerminatorHandler.setPasteBehavior(pasteBehavior); + } + } + + /** + * Sets the {@link IllegalCharacterIdentifier} that will identify characters that should + * not show up in the field when typed (i.e. they will be deleted as soon as they are entered). + * If a character is listed as both a chip terminator character and an illegal character, + * it will be treated as an illegal character. + * + * @param illegalCharacterIdentifier the identifier to use + */ + public void setIllegalCharacterIdentifier(@Nullable IllegalCharacterIdentifier illegalCharacterIdentifier) { + this.illegalCharacterIdentifier = illegalCharacterIdentifier; + } + + /** + * Applies any updated configuration parameters to any existing chips and all future chips in the text view. + * + * @see ChipTokenizer#applyConfiguration(Editable, ChipConfiguration) + */ + public void invalidateChips() { + beginUnwatchedTextChange(); + + if (mChipTokenizer != null) { + Editable text = getText(); + int availableWidth = getWidth() - getCompoundPaddingLeft() - getCompoundPaddingRight(); + ChipConfiguration configuration = new ChipConfiguration( + mChipHorizontalSpacing, + mChipBackground, + mChipCornerRadius, + mChipTextColor, + mChipTextSize, + mChipHeight, + mChipVerticalSpacing, + availableWidth); + + mChipTokenizer.applyConfiguration(text, configuration); + } + + endUnwatchedTextChange(); + } + + /** + * Enables editing chips on touch events. When a touch event occurs, the touched chip will be put in editing mode. To later disable this behavior + * call {@link #disableEditChipOnTouch()}. + *

+ * Note: If an {@link OnChipClickListener} is set it's behavior will override the behavior described here if it's + * {@link OnChipClickListener#onChipClick(Chip, MotionEvent)} method returns true. If that method returns false, the touched chip will be put + * in editing mode as expected. + *

+ * + * @param moveChipToEnd if true, the chip will also be moved to the end of the text when it is put in editing mode + * @param chipifyUnterminatedTokens if true, all unterminated tokens will be chipified before the touched chip is put in editing mode + * @see #disableEditChipOnTouch() + */ + public void enableEditChipOnTouch(boolean moveChipToEnd, boolean chipifyUnterminatedTokens) { + mEditChipOnTouchEnabled = true; + mMoveChipToEndOnEdit = moveChipToEnd; + mChipifyUnterminatedTokensOnEdit = chipifyUnterminatedTokens; + } + + /** + * Disables editing chips on touch events. To re-enable this behavior call {@link #enableEditChipOnTouch(boolean, boolean)}. + * + * @see #enableEditChipOnTouch(boolean, boolean) + */ + public void disableEditChipOnTouch() { + mEditChipOnTouchEnabled = false; + } + + /** + * Puts the provided Chip in editing mode (i.e. reverts it to an unchipified token whose text can be edited). + * + * @param chip the chip to edit + * @param moveChipToEnd if true, the chip will also be moved to the end of the text + */ + public void setEditingChip(Chip chip, boolean moveChipToEnd) { + if (mChipTokenizer == null) { + return; + } + + beginUnwatchedTextChange(); + + Editable text = getText(); + if (moveChipToEnd) { + // Move the chip text to the end of the text + text.append(chip.getText()); + // Delete the existing chip + mChipTokenizer.deleteChipAndPadding(chip, text); + // Move the cursor to the end of the text + setSelection(text.length()); + } else { + int chipStart = mChipTokenizer.findChipStart(chip, text); + mChipTokenizer.revertChipToToken(chip, text); + setSelection(mChipTokenizer.findTokenEnd(text, chipStart)); + } + + endUnwatchedTextChange(); + } + + @Override + public boolean onTouchEvent(@NonNull MotionEvent event) { + boolean wasHandled = false; + clearChipStates(); + Chip touchedChip = findTouchedChip(event); + if (touchedChip != null && isFocused() && singleTapDetector.onTouchEvent(event)) { + touchedChip.setState(View.PRESSED_SELECTED_STATE_SET); + if (onChipClicked(touchedChip)) { + wasHandled = true; + } + if (mOnChipClickListener != null) { + mOnChipClickListener.onChipClick(touchedChip, event); + } + } + + // Getting NullPointerException inside Editor.updateFloatingToolbarVisibility (Editor.java:1520) + // primarily seen in Samsung Nougat devices. + boolean superOnTouch = false; + try { + superOnTouch = super.onTouchEvent(event); + } catch (NullPointerException e) { + Log.w("Nacho", String.format("Error during touch event of type [%d]", event.getAction()), e); + // can't handle or reproduce, but will monitor the error + } + + return wasHandled || superOnTouch; + } + + @Nullable + private Chip findTouchedChip(MotionEvent event) { + if (mChipTokenizer == null) { + return null; + } + + Editable text = getText(); + int offset = getOffsetForPosition(event.getX(), event.getY()); + List chips = getAllChips(); + for (Chip chip : chips) { + int chipStart = mChipTokenizer.findChipStart(chip, text); + int chipEnd = mChipTokenizer.findChipEnd(chip, text); // This is actually the index of the character just past the end of the chip + // When a touch event occurs getOffsetForPosition will either return the index of the first character of the span or the index of the + // character one past the end of the span + // This matches up perfectly with chipStart and chipEnd so we can just directly compare them... + if (chipStart <= offset && offset <= chipEnd) { + float startX = getXForIndex(chipStart); + float endX = getXForIndex(chipEnd - 1); + float eventX = event.getX(); + // ... however, when comparing the x coordinate we need to use (chipEnd - 1) because chipEnd will give us the x coordinate of the + // beginning of the next span since that is actually what chipEnd holds. We want the x coordinate of the end of the current span so + // we use (chipEnd - 1) + if (startX <= eventX && eventX <= endX) { + return chip; + } + } + } + return null; + } + + /** + * Implement this method to handle chip clicked events. + * + * @param chip the chip that was clicked + * @return true if the event was handled, otherwise false + */ + public boolean onChipClicked(Chip chip) { + boolean wasHandled = false; + if (mEditChipOnTouchEnabled) { + if (mChipifyUnterminatedTokensOnEdit) { + chipifyAllUnterminatedTokens(); + } + setEditingChip(chip, mMoveChipToEndOnEdit); + wasHandled = true; + } + return wasHandled; + } + + private float getXForIndex(int index) { + Layout layout = getLayout(); + return layout.getPrimaryHorizontal(index); + } + + private void clearChipStates() { + for (Chip chip : getAllChips()) { + chip.setState(View.EMPTY_STATE_SET); + } + } + + @Override + public boolean onTextContextMenuItem(int id) { + int start = getSelectionStart(); + int end = getSelectionEnd(); + switch (id) { + case android.R.id.cut: + try { + setClipboardData(ClipData.newPlainText(null, getTextWithPlainTextSpans(start, end))); + } catch (StringIndexOutOfBoundsException e) { + throw new StringIndexOutOfBoundsException( + String.format( + "%s \nError cutting text index [%s, %s] for text [%s] and substring [%s]", + e.getMessage(), + start, + end, + getText().toString(), + getText().subSequence(start, end))); + } + getText().delete(getSelectionStart(), getSelectionEnd()); + return true; + case android.R.id.copy: + try { + setClipboardData(ClipData.newPlainText(null, getTextWithPlainTextSpans(start, end))); + } catch (StringIndexOutOfBoundsException e) { + throw new StringIndexOutOfBoundsException( + String.format( + "%s \nError copying text index [%s, %s] for text [%s] and substring [%s]", + e.getMessage(), + start, + end, + getText().toString(), + getText().subSequence(start, end))); + } + return true; + case android.R.id.paste: + mIsPasteEvent = true; + boolean returnValue = super.onTextContextMenuItem(id); + mIsPasteEvent = false; + return returnValue; + default: + return super.onTextContextMenuItem(id); + } + } + + private void setClipboardData(ClipData clip) { + ClipboardManager clipboard = (ClipboardManager) getContext(). + getSystemService(Context.CLIPBOARD_SERVICE); + clipboard.setPrimaryClip(clip); + } + + /** + * If a {@link android.widget.AutoCompleteTextView.Validator Validator} was set, this method will validate the entire text. + * (Overrides the superclass method which only validates the current token) + */ + @Override + public void performValidation() { + if (mNachoValidator == null || mChipTokenizer == null) { + super.performValidation(); + return; + } + + CharSequence text = getText(); + if (!TextUtils.isEmpty(text) && !mNachoValidator.isValid(mChipTokenizer, text)) { + setRawText(mNachoValidator.fixText(mChipTokenizer, text)); + } + } + + /** + * From the point this method is called to when {@link #endUnwatchedTextChange()} is called, all TextChanged events will be ignored + */ + private void beginUnwatchedTextChange() { + mIgnoreTextChangedEvents = true; + } + + /** + * After this method is called TextChanged events will resume being handled. + * This method also calls {@link #updatePadding()} in case the unwatched changed created/destroyed chips + */ + private void endUnwatchedTextChange() { + updatePadding(); + mIgnoreTextChangedEvents = false; + } + + /** + * Sets the contents of this text view without performing any processing (nothing will be chipified, no characters will be removed etc.) + * + * @param text the text to set + */ + private void setRawText(CharSequence text) { + beginUnwatchedTextChange(); + super.setText(text); + endUnwatchedTextChange(); + } + + /** + * Sets the contents of this text view to contain the provided list of strings. The text view will be cleared then each string in the list will + * be chipified and appended to the text. + * + * @param chipValues the list of strings to chipify and set as the contents of the text view or null to clear the text view + */ + public void setText(@Nullable List chipValues) { + if (mChipTokenizer == null) { + return; + } + beginUnwatchedTextChange(); + + Editable text = getText(); + text.clear(); + + if (chipValues != null) { + for (String chipValue : chipValues) { + CharSequence chippedText = mChipTokenizer.terminateToken(chipValue, null); + text.append(chippedText); + } + } + setSelection(text.length()); + + endUnwatchedTextChange(); + } + + public void setTextWithChips(@Nullable List chips) { + if (mChipTokenizer == null) { + return; + } + beginUnwatchedTextChange(); + + Editable text = getText(); + text.clear(); + + if (chips != null) { + for (ChipInfo chipInfo : chips) { + CharSequence chippedText = mChipTokenizer.terminateToken(chipInfo.getText(), chipInfo.getData()); + text.append(chippedText); + } + } + setSelection(text.length()); + endUnwatchedTextChange(); + } + + @Override + public void onItemClick(AdapterView adapterView, View view, int position, long id) { + if (mChipTokenizer == null) { + return; + } + Adapter adapter = getAdapter(); + if (adapter == null) { + return; + } + beginUnwatchedTextChange(); + + Object data = getDataForSuggestion(adapter, position); + CharSequence text = getFilter().convertResultToString(adapter.getItem(position)); + + clearComposingText(); + int end = getSelectionEnd(); + Editable editable = getText(); + int start = mChipTokenizer.findTokenStart(editable, end); + + // guard against java.lang.StringIndexOutOfBoundsException + start = Math.min(Math.max(0, start), editable.length()); + end = Math.min(Math.max(0, end), editable.length()); + if (end < start) { + end = start; + } + + editable.replace(start, end, mChipTokenizer.terminateToken(text, data)); + + endUnwatchedTextChange(); + } + + /** + * Returns a object that will be associated with a chip that is about to be created for the item at {@code position} in {@code adapter} because that + * item was just tapped. + * + * @param adapter the adapter supplying the suggestions + * @param position the position of the suggestion that was tapped + * @return the data object + */ + protected Object getDataForSuggestion(@NonNull Adapter adapter, int position) { + return adapter.getItem(position); + } + + /** + * If there is a ChipTokenizer set, this method will do nothing. Instead we wait until the OnItemClickListener is triggered to actually perform + * the text replacement so we can also associate the suggestion data with it. + *

+ * If there is no ChipTokenizer set, we call through to the super method. + * + * @param text the text to be chipified + */ + @Override + protected void replaceText(CharSequence text) { + // If we have a ChipTokenizer, this will be handled by our OnItemClickListener so we can do nothing here. + // If we don't have a ChipTokenizer, we'll use the default behavior + if (mChipTokenizer == null) { + super.replaceText(text); + } + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + if (mIgnoreTextChangedEvents) { + return; + } + + mTextChangedStart = start; + mTextChangedEnd = start + after; + + // Check for backspace + if (mChipTokenizer != null) { + if (count > 0 && after < count) { + int end = start + count; + Editable message = getText(); + Chip[] chips = mChipTokenizer.findAllChips(start, end, message); + + for (Chip chip : chips) { + int spanStart = mChipTokenizer.findChipStart(chip, message); + int spanEnd = mChipTokenizer.findChipEnd(chip, message); + if ((spanStart < end) && (spanEnd > start)) { + // Add to remove list + mChipsToRemove.add(chip); + } + } + } + } + } + + @Override + public void onTextChanged(@NonNull CharSequence textChanged, int start, int before, int count) { + } + + @Override + public void afterTextChanged(Editable message) { + if (mIgnoreTextChangedEvents) { + return; + } + + // Avoid triggering text changed events from changes we make in this method + beginUnwatchedTextChange(); + + // Handle backspace key + if (mChipTokenizer != null) { + Iterator iterator = mChipsToRemove.iterator(); + while (iterator.hasNext()) { + Chip chip = iterator.next(); + iterator.remove(); + mChipTokenizer.deleteChip(chip, message); + if (mOnChipRemoveListener != null) { + mOnChipRemoveListener.onChipRemove(chip); + } + } + } + + // Handle an illegal or chip terminator character + if (message.length() >= mTextChangedEnd && message.length() >= mTextChangedStart) { + handleTextChanged(mTextChangedStart, mTextChangedEnd); + } + + endUnwatchedTextChange(); + } + + private void handleTextChanged(int start, int end) { + if (start == end) { + // If start and end are the same there was text deleted, so this type of event can be ignored + return; + } + + // First remove any illegal characters + Editable text = getText(); + CharSequence subText = text.subSequence(start, end); + CharSequence withoutIllegalCharacters = removeIllegalCharacters(subText); + + // Check if illegal characters were found + if (withoutIllegalCharacters.length() < subText.length()) { + text.replace(start, end, withoutIllegalCharacters); + end = start + withoutIllegalCharacters.length(); + clearComposingText(); + } + + if (start == end) { + // If start and end are the same here, it means only illegal characters were inserted so there's nothing left to do + return; + } + + // Then handle chip terminator characters + if (mChipTokenizer != null && mChipTerminatorHandler != null) { + int newSelectionIndex = mChipTerminatorHandler.findAndHandleChipTerminators(mChipTokenizer, getText(), start, end, mIsPasteEvent); + if (newSelectionIndex > 0) { + setSelection(newSelectionIndex); + } + } + } + + private CharSequence removeIllegalCharacters(CharSequence text) { + StringBuilder newText = new StringBuilder(); + + for (int i = 0; i < text.length(); i++) { + char theChar = text.charAt(i); + if (!isIllegalCharacter(theChar)) { + newText.append(theChar); + } + } + + return newText; + } + + private boolean isIllegalCharacter(char character) { + if (illegalCharacterIdentifier != null) { + return illegalCharacterIdentifier.isCharacterIllegal(character); + } + return false; + } + + /** + * Chipifies all existing plain text in the field + */ + public void chipifyAllUnterminatedTokens() { + beginUnwatchedTextChange(); + chipifyAllUnterminatedTokens(getText()); + endUnwatchedTextChange(); + } + + private void chipifyAllUnterminatedTokens(Editable text) { + if (mChipTokenizer != null) { + mChipTokenizer.terminateAllTokens(text); + } + } + + /** + * Replaces the text from start (inclusive) to end (exclusive) with a chip + * containing the same text + * + * @param start the index of the first character to replace + * @param end one more than the index of the last character to replace + */ + public void chipify(int start, int end) { + beginUnwatchedTextChange(); + chipify(start, end, getText(), null); + endUnwatchedTextChange(); + } + + private void chipify(int start, int end, Editable text, Object data) { + if (mChipTokenizer != null) { + CharSequence textToChip = text.subSequence(start, end); + CharSequence chippedText = mChipTokenizer.terminateToken(textToChip, data); + text.replace(start, end, chippedText); + } + } + + private CharSequence getTextWithPlainTextSpans(int start, int end) { + Editable editable = getText(); + String selectedText = editable.subSequence(start, end).toString(); + + if (mChipTokenizer != null) { + List chips = Arrays.asList(mChipTokenizer.findAllChips(start, end, editable)); + Collections.reverse(chips); + for (Chip chip : chips) { + String chipText = chip.getText().toString(); + int chipStart = mChipTokenizer.findChipStart(chip, editable) - start; + int chipEnd = mChipTokenizer.findChipEnd(chip, editable) - start; + selectedText = selectedText.substring(0, chipStart) + chipText + selectedText.substring(chipEnd, selectedText.length()); + } + } + return selectedText; + } + + /** + * @return all of the chips currently in the text view - this does not include any unchipped text + */ + @NonNull + public List getAllChips() { + Editable text = getText(); + return mChipTokenizer != null ? Arrays.asList(mChipTokenizer.findAllChips(0, text.length(), text)) : new ArrayList(); + } + + /** + * Returns a List of the string values of all the chips in the text (obtained through {@link Chip#getText()}). + * This does not include the text of any unterminated tokens. + * + * @return the List of chip values + */ + @NonNull + public List getChipValues() { + List chipValues = new ArrayList<>(); + + List chips = getAllChips(); + for (Chip chip : chips) { + chipValues.add(chip.getText().toString()); + } + + return chipValues; + } + + /** + * Returns a List of the string values of all the tokens (unchipped text) in the text + * (obtained through {@link ChipTokenizer#findAllTokens(CharSequence)}). This does not include any chipped text. + * + * @return the List of token values + */ + @NonNull + public List getTokenValues() { + List tokenValues = new ArrayList<>(); + + if (mChipTokenizer != null) { + Editable text = getText(); + List> unterminatedTokenIndexes = mChipTokenizer.findAllTokens(text); + for (Pair indexes : unterminatedTokenIndexes) { + String tokenValue = text.subSequence(indexes.first, indexes.second).toString(); + tokenValues.add(tokenValue); + } + } + + return tokenValues; + } + + /** + * Returns a combination of the chip values and token values in the text. + * + * @return the List of all chip and token values + * @see #getChipValues() + * @see #getTokenValues() + */ + @NonNull + public List getChipAndTokenValues() { + List chipAndTokenValues = new ArrayList<>(); + chipAndTokenValues.addAll(getChipValues()); + chipAndTokenValues.addAll(getTokenValues()); + return chipAndTokenValues; + } + + @Override + public String toString() { + try { + return getTextWithPlainTextSpans(0, getText().length()).toString(); + } catch (ClassCastException ex) { // Exception is thrown by cast in getText() on some LG devices + return super.toString(); + } catch (StringIndexOutOfBoundsException e) { + throw new StringIndexOutOfBoundsException(String.format("%s \nError converting toString() [%s]", e.getMessage(), getText().toString())); + } + } + + private class ChipTokenizerWrapper implements Tokenizer { + + @NonNull + private ChipTokenizer mChipTokenizer; + + public ChipTokenizerWrapper(@NonNull ChipTokenizer chipTokenizer) { + mChipTokenizer = chipTokenizer; + } + + @Override + public int findTokenStart(CharSequence text, int cursor) { + return mChipTokenizer.findTokenStart(text, cursor); + } + + @Override + public int findTokenEnd(CharSequence text, int cursor) { + return mChipTokenizer.findTokenEnd(text, cursor); + } + + @Override + public CharSequence terminateToken(CharSequence text) { + return mChipTokenizer.terminateToken(text, null); + } + } + + public interface OnChipClickListener { + + /** + * Called when a chip in this TextView is touched. This callback is triggered by the {@link MotionEvent#ACTION_UP} event. + * + * @param chip the {@link Chip} that was touched + * @param event the {@link MotionEvent} that caused the touch + */ + void onChipClick(Chip chip, MotionEvent event); + } + + public interface OnChipRemoveListener { + + /** + * Called when a chip in this TextView is removed + * + * @param chip the {@link Chip} that was removed + */ + void onChipRemove(Chip chip); + } + + private class SingleTapListener extends GestureDetector.SimpleOnGestureListener { + + /** + * @param e the {@link MotionEvent} passed to the GestureDetector + * @return true if singleTapUp (click) was detected + */ + @Override + public boolean onSingleTapUp(MotionEvent e) { + return true; + } + } +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/chip/Chip.java b/mastodon/src/main/java/com/hootsuite/nachos/chip/Chip.java new file mode 100644 index 000000000..f07707cd3 --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/chip/Chip.java @@ -0,0 +1,29 @@ +package com.hootsuite.nachos.chip; + +import androidx.annotation.Nullable; + +public interface Chip { + + /** + * @return the text represented by this Chip + */ + CharSequence getText(); + + /** + * @return the data associated with this Chip or null if no data is associated with it + */ + @Nullable + Object getData(); + + /** + * @return the width of the Chip or -1 if the Chip hasn't been given the chance to calculate its width + */ + int getWidth(); + + /** + * Sets the UI state. + * + * @param stateSet one of the state constants in {@link android.view.View} + */ + void setState(int[] stateSet); +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipCreator.java b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipCreator.java new file mode 100644 index 000000000..c38f5cff1 --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipCreator.java @@ -0,0 +1,44 @@ +package com.hootsuite.nachos.chip; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.hootsuite.nachos.ChipConfiguration; + +/** + * Interface to allow the creation and configuration of chips + * + * @param The type of {@link Chip} that the implementation will create/configure + */ +public interface ChipCreator { + + /** + * Creates a chip from the given context and text. Use this method when creating a brand new chip from a piece of text. + * + * @param context the {@link Context} to use to initialize the chip + * @param text the text the Chip should represent + * @param data the data to associate with the Chip, or null to associate no data + * @return the created chip + */ + C createChip(@NonNull Context context, @NonNull CharSequence text, @Nullable Object data); + + /** + * Creates a chip from the given context and existing chip. Use this method when recreating a chip from an existing one. + * + * @param context the {@link Context} to use to initialize the chip + * @param existingChip the chip that the created chip should be based on + * @return the created chip + */ + C createChip(@NonNull Context context, @NonNull C existingChip); + + /** + * Applies the given {@link ChipConfiguration} to the given {@link Chip}. Use this method to customize the appearance/behavior of a chip before + * adding it to the text. + * + * @param chip the chip to configure + * @param chipConfiguration the configuration to apply to the chip + */ + void configureChip(@NonNull C chip, @NonNull ChipConfiguration chipConfiguration); +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipInfo.java b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipInfo.java new file mode 100644 index 000000000..69b8ea4c4 --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipInfo.java @@ -0,0 +1,20 @@ +package com.hootsuite.nachos.chip; + +public class ChipInfo { + + private final CharSequence mText; + private final Object mData; + + public ChipInfo(CharSequence text, Object data) { + this.mText = text; + this.mData = data; + } + + public CharSequence getText() { + return mText; + } + + public Object getData() { + return mData; + } +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipSpan.java b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipSpan.java new file mode 100644 index 000000000..e3cc57922 --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipSpan.java @@ -0,0 +1,510 @@ +package com.hootsuite.nachos.chip; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.text.style.ImageSpan; + +import androidx.annotation.Dimension; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.joinmastodon.android.R; + +/** + * A Span that displays text and an optional icon inside of a material design chip. The chip's dimensions, colors etc. can be extensively customized + * through the various setter methods available in this class. + * The basic structure of the chip is the following: + * For chips with the icon on right: + *

+ *
+ *                                  (chip vertical spacing / 2)
+ *                  ----------------------------------------------------------
+ *                |                                                            |
+ * (left margin)  |  (padding edge)   text   (padding between image)   icon    |   (right margin)
+ *                |                                                            |
+ *                  ----------------------------------------------------------
+ *                                  (chip vertical spacing / 2)
+ *
+ *      
+ * For chips with the icon on the left (see {@link #setShowIconOnLeft(boolean)}): + *
+ *
+ *                                  (chip vertical spacing / 2)
+ *                  ----------------------------------------------------------
+ *                |                                                            |
+ * (left margin)  |   icon  (padding between image)   text   (padding edge)    |   (right margin)
+ *                |                                                            |
+ *                  ----------------------------------------------------------
+ *                                  (chip vertical spacing / 2)
+ *     
+ */ +public class ChipSpan extends ImageSpan implements Chip { + + private static final float SCALE_PERCENT_OF_CHIP_HEIGHT = 0.70f; + private static final boolean ICON_ON_LEFT_DEFAULT = true; + + private int[] mStateSet = new int[]{}; + + private String mEllipsis; + + private ColorStateList mDefaultBackgroundColor; + private ColorStateList mBackgroundColor; + private int mTextColor; + private int mCornerRadius = -1; + private int mIconBackgroundColor; + + private int mTextSize = -1; + private int mPaddingEdgePx; + private int mPaddingBetweenImagePx; + private int mLeftMarginPx; + private int mRightMarginPx; + private int mMaxAvailableWidth = -1; + + private CharSequence mText; + private String mTextToDraw; + + private Drawable mIcon; + private boolean mShowIconOnLeft = ICON_ON_LEFT_DEFAULT; + + private int mChipVerticalSpacing = 0; + private int mChipHeight = -1; + private int mChipWidth = -1; + private int mIconWidth; + + private int mCachedSize = -1; + + private Object mData; + + /** + * Constructs a new ChipSpan. + * + * @param context a {@link Context} that will be used to retrieve default configurations from resource files + * @param text the text for the ChipSpan to display + * @param icon an optional icon (can be {@code null}) for the ChipSpan to display + */ + public ChipSpan(@NonNull Context context, @NonNull CharSequence text, @Nullable Drawable icon, Object data) { + super(icon); + mIcon = icon; + mText = text; + mTextToDraw = mText.toString(); + + mEllipsis = context.getString(R.string.chip_ellipsis); + + mDefaultBackgroundColor = context.getColorStateList(R.color.chip_material_background); + mBackgroundColor = mDefaultBackgroundColor; + + mTextColor = context.getColor(R.color.chip_default_text_color); + mIconBackgroundColor = context.getColor(R.color.chip_default_icon_background_color); + + Resources resources = context.getResources(); + mPaddingEdgePx = resources.getDimensionPixelSize(R.dimen.chip_default_padding_edge); + mPaddingBetweenImagePx = resources.getDimensionPixelSize(R.dimen.chip_default_padding_between_image); + mLeftMarginPx = resources.getDimensionPixelSize(R.dimen.chip_default_left_margin); + mRightMarginPx = resources.getDimensionPixelSize(R.dimen.chip_default_right_margin); + + mData = data; + } + + /** + * Copy constructor to recreate a ChipSpan from an existing one + * + * @param context a {@link Context} that will be used to retrieve default configurations from resource files + * @param chipSpan the ChipSpan to copy + */ + public ChipSpan(@NonNull Context context, @NonNull ChipSpan chipSpan) { + this(context, chipSpan.getText(), chipSpan.getDrawable(), chipSpan.getData()); + + mDefaultBackgroundColor = chipSpan.mDefaultBackgroundColor; + mTextColor = chipSpan.mTextColor; + mIconBackgroundColor = chipSpan.mIconBackgroundColor; + mCornerRadius = chipSpan.mCornerRadius; + + mTextSize = chipSpan.mTextSize; + mPaddingEdgePx = chipSpan.mPaddingEdgePx; + mPaddingBetweenImagePx = chipSpan.mPaddingBetweenImagePx; + mLeftMarginPx = chipSpan.mLeftMarginPx; + mRightMarginPx = chipSpan.mRightMarginPx; + mMaxAvailableWidth = chipSpan.mMaxAvailableWidth; + + mShowIconOnLeft = chipSpan.mShowIconOnLeft; + + mChipVerticalSpacing = chipSpan.mChipVerticalSpacing; + mChipHeight = chipSpan.mChipHeight; + + mStateSet = chipSpan.mStateSet; + } + + @Override + public Object getData() { + return mData; + } + + /** + * Sets the height of the chip. This height should not include any extra spacing (for extra vertical spacing call {@link #setChipVerticalSpacing(int)}). + * The background of the chip will fill the full height provided here. If this method is never called, the chip will have the height of one full line + * of text by default. If {@code -1} is passed here, the chip will revert to this default behavior. + * + * @param chipHeight the height to set in pixels + */ + public void setChipHeight(int chipHeight) { + mChipHeight = chipHeight; + } + + /** + * Sets the vertical spacing to include in between chips. Half of the value set here will be placed as empty space above the chip and half the value + * will be placed as empty space below the chip. Therefore chips on consecutive lines will have the full value as vertical space in between them. + * This spacing is achieved by adjusting the font metrics used by the text view containing these chips; however it does not come into effect until + * at least one chip is created. Note that vertical spacing is dependent on having a fixed chip height (set in {@link #setChipHeight(int)}). If a + * height is not specified in that method, the value set here will be ignored. + * + * @param chipVerticalSpacing the vertical spacing to set in pixels + */ + public void setChipVerticalSpacing(int chipVerticalSpacing) { + mChipVerticalSpacing = chipVerticalSpacing; + } + + /** + * Sets the font size for the chip's text. If this method is never called, the chip text will have the same font size as the text in the TextView + * containing this chip by default. If {@code -1} is passed here, the chip will revert to this default behavior. + * + * @param size the font size to set in pixels + */ + public void setTextSize(int size) { + mTextSize = size; + invalidateCachedSize(); + } + + /** + * Sets the color for the chip's text. + * + * @param color the color to set (as a hexadecimal number in the form 0xAARRGGBB) + */ + public void setTextColor(int color) { + mTextColor = color; + } + + /** + * Sets where the icon (if an icon was provided in the constructor) will appear. + * + * @param showIconOnLeft if true, the icon will appear on the left, otherwise the icon will appear on the right + */ + public void setShowIconOnLeft(boolean showIconOnLeft) { + this.mShowIconOnLeft = showIconOnLeft; + invalidateCachedSize(); + } + + /** + * Sets the left margin. This margin will appear as empty space (it will not share the chip's background color) to the left of the chip. + * + * @param leftMarginPx the left margin to set in pixels + */ + public void setLeftMargin(int leftMarginPx) { + mLeftMarginPx = leftMarginPx; + invalidateCachedSize(); + } + + /** + * Sets the right margin. This margin will appear as empty space (it will not share the chip's background color) to the right of the chip. + * + * @param rightMarginPx the right margin to set in pixels + */ + public void setRightMargin(int rightMarginPx) { + this.mRightMarginPx = rightMarginPx; + invalidateCachedSize(); + } + + /** + * Sets the background color. To configure which color in the {@link ColorStateList} is shown you can call {@link #setState(int[])}. + * Passing {@code null} here will cause the chip to revert to it's default background. + * + * @param backgroundColor a {@link ColorStateList} containing backgrounds for different states. + * @see #setState(int[]) + */ + public void setBackgroundColor(@Nullable ColorStateList backgroundColor) { + mBackgroundColor = backgroundColor != null ? backgroundColor : mDefaultBackgroundColor; + } + + /** + * Sets the chip background corner radius. + * + * @param cornerRadius The corner radius value, in pixels. + */ + public void setCornerRadius(@Dimension int cornerRadius) { + mCornerRadius = cornerRadius; + } + + /** + * Sets the icon background color. This is the color of the circle that gets drawn behind the icon passed to the + * {@link #ChipSpan(Context, CharSequence, Drawable, Object)} constructor} + * + * @param iconBackgroundColor the icon background color to set (as a hexadecimal number in the form 0xAARRGGBB) + */ + public void setIconBackgroundColor(int iconBackgroundColor) { + mIconBackgroundColor = iconBackgroundColor; + } + + public void setMaxAvailableWidth(int maxAvailableWidth) { + mMaxAvailableWidth = maxAvailableWidth; + invalidateCachedSize(); + } + + /** + * Sets the UI state. This state will be reflected in the background color drawn for the chip. + * + * @param stateSet one of the state constants in {@link android.view.View} + * @see #setBackgroundColor(ColorStateList) + */ + @Override + public void setState(int[] stateSet) { + this.mStateSet = stateSet != null ? stateSet : new int[]{}; + } + + @Override + public CharSequence getText() { + return mText; + } + + @Override + public int getWidth() { + // If we haven't actually calculated a chip width yet just return -1, otherwise return the chip width + margins + return mChipWidth != -1 ? (mLeftMarginPx + mChipWidth + mRightMarginPx) : -1; + } + + @Override + public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) { + boolean usingFontMetrics = (fm != null); + + // Adjust the font metrics regardless of whether or not there is a cached size so that the text view can maintain its height + if (usingFontMetrics) { + adjustFontMetrics(paint, fm); + } + + if (mCachedSize == -1 && usingFontMetrics) { + mIconWidth = (mIcon != null) ? calculateChipHeight(fm.top, fm.bottom) : 0; + + int actualWidth = calculateActualWidth(paint); + mCachedSize = actualWidth; + + if (mMaxAvailableWidth != -1) { + int maxAvailableWidthMinusMargins = mMaxAvailableWidth - mLeftMarginPx - mRightMarginPx; + if (actualWidth > maxAvailableWidthMinusMargins) { + mTextToDraw = mText + mEllipsis; + + while ((calculateActualWidth(paint) > maxAvailableWidthMinusMargins) && mTextToDraw.length() > 0) { + int lastCharacterIndex = mTextToDraw.length() - mEllipsis.length() - 1; + if (lastCharacterIndex < 0) { + break; + } + mTextToDraw = mTextToDraw.substring(0, lastCharacterIndex) + mEllipsis; + } + + // Avoid a negative width + mChipWidth = Math.max(0, maxAvailableWidthMinusMargins); + mCachedSize = mMaxAvailableWidth; + } + } + } + + return mCachedSize; + } + + private int calculateActualWidth(Paint paint) { + // Only change the text size if a text size was set + if (mTextSize != -1) { + paint.setTextSize(mTextSize); + } + + int totalPadding = mPaddingEdgePx; + + // Find text width + Rect bounds = new Rect(); + paint.getTextBounds(mTextToDraw, 0, mTextToDraw.length(), bounds); + int textWidth = bounds.width(); + + if (mIcon != null) { + totalPadding += mPaddingBetweenImagePx; + } else { + totalPadding += mPaddingEdgePx; + } + + mChipWidth = totalPadding + textWidth + mIconWidth; + return getWidth(); + } + + public void invalidateCachedSize() { + mCachedSize = -1; + } + + /** + * Adjusts the provided font metrics to make it seem like the font takes up {@code mChipHeight + mChipVerticalSpacing} pixels in height. + * This effectively ensures that the TextView will have a height equal to {@code mChipHeight + mChipVerticalSpacing} + whatever padding it has set. + * In {@link #draw(Canvas, CharSequence, int, int, float, int, int, int, Paint)} the chip itself is drawn to that it is vertically centered with + * {@code mChipVerticalSpacing / 2} pixels of space above and below it + * + * @param paint the paint whose font metrics should be adjusted + * @param fm the font metrics object to populate through {@link Paint#getFontMetricsInt(Paint.FontMetricsInt)} + */ + private void adjustFontMetrics(Paint paint, Paint.FontMetricsInt fm) { + // Only actually adjust font metrics if we have a chip height set + if (mChipHeight != -1) { + paint.getFontMetricsInt(fm); + int textHeight = fm.descent - fm.ascent; + // Break up the vertical spacing in half because half will go above the chip, half will go below the chip + int halfSpacing = mChipVerticalSpacing / 2; + + // Given that the text is centered vertically within the chip, the amount of space above or below the text (inbetween the text and chip) + // is half their difference in height: + int spaceBetweenChipAndText = (mChipHeight - textHeight) / 2; + + int textTop = fm.top; + int chipTop = fm.top - spaceBetweenChipAndText; + + int textBottom = fm.bottom; + int chipBottom = fm.bottom + spaceBetweenChipAndText; + + // The text may have been taller to begin with so we take the most negative coordinate (highest up) to be the top of the content + int topOfContent = Math.min(textTop, chipTop); + // Same as above but we want the largest positive coordinate (lowest down) to be the bottom of the content + int bottomOfContent = Math.max(textBottom, chipBottom); + + // Shift the top up by halfSpacing and the bottom down by halfSpacing + int topOfContentWithSpacing = topOfContent - halfSpacing; + int bottomOfContentWithSpacing = bottomOfContent + halfSpacing; + + // Change the font metrics so that the TextView thinks the font takes up the vertical space of a chip + spacing + fm.ascent = topOfContentWithSpacing; + fm.descent = bottomOfContentWithSpacing; + fm.top = topOfContentWithSpacing; + fm.bottom = bottomOfContentWithSpacing; + } + } + + private int calculateChipHeight(int top, int bottom) { + // If a chip height was set we can return that, otherwise calculate it from top and bottom + return mChipHeight != -1 ? mChipHeight : bottom - top; + } + + @Override + public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) { + // Shift everything mLeftMarginPx to the left to create an empty space on the left (creating the margin) + x += mLeftMarginPx; + if (mChipHeight != -1) { + // If we set a chip height, adjust to vertically center chip in the line + // Adding (bottom - top) / 2 shifts the chip down so the top of it will be centered vertically + // Subtracting (mChipHeight / 2) shifts the chip back up so that the center of it will be centered vertically (as desired) + top += ((bottom - top) / 2) - (mChipHeight / 2); + bottom = top + mChipHeight; + } + + // Perform actual drawing + drawBackground(canvas, x, top, bottom, paint); + drawText(canvas, x, top, bottom, paint, mTextToDraw); + if (mIcon != null) { + drawIcon(canvas, x, top, bottom, paint); + } + } + + private void drawBackground(Canvas canvas, float x, int top, int bottom, Paint paint) { + int backgroundColor = mBackgroundColor.getColorForState(mStateSet, mBackgroundColor.getDefaultColor()); + paint.setColor(backgroundColor); + int height = calculateChipHeight(top, bottom); + RectF rect = new RectF(x, top, x + mChipWidth, bottom); + int cornerRadius = (mCornerRadius != -1) ? mCornerRadius : height / 2; + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint); + paint.setColor(mTextColor); + } + + private void drawText(Canvas canvas, float x, int top, int bottom, Paint paint, CharSequence text) { + if (mTextSize != -1) { + paint.setTextSize(mTextSize); + } + int height = calculateChipHeight(top, bottom); + Paint.FontMetrics fm = paint.getFontMetrics(); + + // The top value provided here is the y coordinate for the very top of the chip + // The y coordinate we are calculating is where the baseline of the text will be drawn + // Our objective is to have the midpoint between the top and baseline of the text be in line with the vertical center of the chip + // First we add height / 2 which will put the baseline at the vertical center of the chip + // Then we add half the height of the text which will lower baseline so that the midpoint is at the vertical center of the chip as desired + float adjustedY = top + ((height / 2) + ((-fm.top - fm.bottom) / 2)); + + // The x coordinate provided here is the left-most edge of the chip + // If there is no icon or the icon is on the right, then the text will start at the left-most edge, but indented with the edge padding, so we + // add mPaddingEdgePx + // If there is an icon and it's on the left, the text will start at the left-most edge, but indented by the combined width of the icon and + // the padding between the icon and text, so we add (mIconWidth + mPaddingBetweenImagePx) + float adjustedX = x + ((mIcon == null || !mShowIconOnLeft) ? mPaddingEdgePx : (mIconWidth + mPaddingBetweenImagePx)); + + canvas.drawText(text, 0, text.length(), adjustedX, adjustedY, paint); + } + + private void drawIcon(Canvas canvas, float x, int top, int bottom, Paint paint) { + drawIconBackground(canvas, x, top, bottom, paint); + drawIconBitmap(canvas, x, top, bottom, paint); + } + + private void drawIconBackground(Canvas canvas, float x, int top, int bottom, Paint paint) { + int height = calculateChipHeight(top, bottom); + + paint.setColor(mIconBackgroundColor); + + // Since it's a circle the diameter is equal to the height, so the radius == diameter / 2 == height / 2 + int radius = height / 2; + // The coordinates that get passed to drawCircle are for the center of the circle + // x is the left edge of the chip, (x + mChipWidth) is the right edge of the chip + // So the center of the circle is one radius distance from either the left or right edge (depending on which side the icon is being drawn on) + float circleX = mShowIconOnLeft ? (x + radius) : (x + mChipWidth - radius); + // The y coordinate is always just one radius distance from the top + canvas.drawCircle(circleX, top + radius, radius, paint); + + paint.setColor(mTextColor); + } + + private void drawIconBitmap(Canvas canvas, float x, int top, int bottom, Paint paint) { + int height = calculateChipHeight(top, bottom); + + // Create a scaled down version of the bitmap to fit within the circle (whose diameter == height) + Bitmap iconBitmap = Bitmap.createBitmap(mIcon.getIntrinsicWidth(), mIcon.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); + Bitmap scaledIconBitMap = scaleDown(iconBitmap, (float) height * SCALE_PERCENT_OF_CHIP_HEIGHT, true); + iconBitmap.recycle(); + Canvas bitmapCanvas = new Canvas(scaledIconBitMap); + mIcon.setBounds(0, 0, bitmapCanvas.getWidth(), bitmapCanvas.getHeight()); + mIcon.draw(bitmapCanvas); + + // We are drawing a square icon inside of a circle + // The coordinates we pass to canvas.drawBitmap have to be for the top-left corner of the bitmap + // The bitmap should be inset by half of (circle width - bitmap width) + // Since it's a circle, the circle's width is equal to it's height which is equal to the chip height + float xInsetWithinCircle = (height - bitmapCanvas.getWidth()) / 2; + + // The icon x coordinate is going to be insetWithinCircle pixels away from the left edge of the circle + // If the icon is on the left, the left edge of the circle is just x + // If the icon is on the right, the left edge of the circle is x + mChipWidth - height + float iconX = mShowIconOnLeft ? (x + xInsetWithinCircle) : (x + mChipWidth - height + xInsetWithinCircle); + + // The y coordinate works the same way (only it's always from the top edge) + float yInsetWithinCircle = (height - bitmapCanvas.getHeight()) / 2; + float iconY = top + yInsetWithinCircle; + + canvas.drawBitmap(scaledIconBitMap, iconX, iconY, paint); + } + + private Bitmap scaleDown(Bitmap realImage, float maxImageSize, boolean filter) { + float ratio = Math.min(maxImageSize / realImage.getWidth(), maxImageSize / realImage.getHeight()); + int width = Math.round(ratio * realImage.getWidth()); + int height = Math.round(ratio * realImage.getHeight()); + return Bitmap.createScaledBitmap(realImage, width, height, filter); + } + + @Override + public String toString() { + return mText.toString(); + } +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipSpanChipCreator.java b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipSpanChipCreator.java new file mode 100644 index 000000000..8e0278f0c --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/chip/ChipSpanChipCreator.java @@ -0,0 +1,60 @@ +package com.hootsuite.nachos.chip; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Color; + +import androidx.annotation.NonNull; + +import com.hootsuite.nachos.ChipConfiguration; + +public class ChipSpanChipCreator implements ChipCreator { + + @Override + public ChipSpan createChip(@NonNull Context context, @NonNull CharSequence text, Object data) { + return new ChipSpan(context, text, null, data); + } + + @Override + public ChipSpan createChip(@NonNull Context context, @NonNull ChipSpan existingChip) { + return new ChipSpan(context, existingChip); + } + + @Override + public void configureChip(@NonNull ChipSpan chip, @NonNull ChipConfiguration chipConfiguration) { + int chipHorizontalSpacing = chipConfiguration.getChipHorizontalSpacing(); + ColorStateList chipBackground = chipConfiguration.getChipBackground(); + int chipCornerRadius = chipConfiguration.getChipCornerRadius(); + int chipTextColor = chipConfiguration.getChipTextColor(); + int chipTextSize = chipConfiguration.getChipTextSize(); + int chipHeight = chipConfiguration.getChipHeight(); + int chipVerticalSpacing = chipConfiguration.getChipVerticalSpacing(); + int maxAvailableWidth = chipConfiguration.getMaxAvailableWidth(); + + if (chipHorizontalSpacing != -1) { + chip.setLeftMargin(chipHorizontalSpacing / 2); + chip.setRightMargin(chipHorizontalSpacing / 2); + } + if (chipBackground != null) { + chip.setBackgroundColor(chipBackground); + } + if (chipCornerRadius != -1) { + chip.setCornerRadius(chipCornerRadius); + } + if (chipTextColor != Color.TRANSPARENT) { + chip.setTextColor(chipTextColor); + } + if (chipTextSize != -1) { + chip.setTextSize(chipTextSize); + } + if (chipHeight != -1) { + chip.setChipHeight(chipHeight); + } + if (chipVerticalSpacing != -1) { + chip.setChipVerticalSpacing(chipVerticalSpacing); + } + if (maxAvailableWidth != -1) { + chip.setMaxAvailableWidth(maxAvailableWidth); + } + } +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/terminator/ChipTerminatorHandler.java b/mastodon/src/main/java/com/hootsuite/nachos/terminator/ChipTerminatorHandler.java new file mode 100644 index 000000000..f97d430ea --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/terminator/ChipTerminatorHandler.java @@ -0,0 +1,95 @@ +package com.hootsuite.nachos.terminator; + +import android.text.Editable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.hootsuite.nachos.tokenizer.ChipTokenizer; + +import java.util.Map; + +/** + * This interface is used to handle the management of characters that should trigger the creation of chips in a text view. + * + * @see ChipTokenizer + */ +public interface ChipTerminatorHandler { + + /** + * When a chip terminator character is encountered in newly inserted text, all tokens in the whole text view will be chipified + */ + int BEHAVIOR_CHIPIFY_ALL = 0; + + /** + * When a chip terminator character is encountered in newly inserted text, only the current token (that in which the chip terminator character + * was found) will be chipified. This token may extend beyond where the chip terminator character was located. + */ + int BEHAVIOR_CHIPIFY_CURRENT_TOKEN = 1; + + /** + * When a chip terminator character is encountered in newly inserted text, only the text from the previous chip up until the chip terminator + * character will be chipified. This may not be an entire token. + */ + int BEHAVIOR_CHIPIFY_TO_TERMINATOR = 2; + + /** + * Constant for use with {@link #setPasteBehavior(int)}. Use this if a paste should behave the same as a standard text input (the chip temrinators + * will all behave according to their pre-determined behavior set through {@link #addChipTerminator(char, int)} or {@link #setChipTerminators(Map)}). + */ + int PASTE_BEHAVIOR_USE_DEFAULT = -1; + + /** + * Sets all the characters that will be marked as chip terminators. This will replace any previously set chip terminators. + * + * @param chipTerminators a map of characters to be marked as chip terminators to behaviors that describe how to respond to the characters, or null + * to remove all chip terminators + */ + void setChipTerminators(@Nullable Map chipTerminators); + + /** + * Adds a character as a chip terminator. When the provided character is encountered in entered text, the nearby text will be chipified according + * to the behavior provided here. + * {@code behavior} Must be one of: + *
    + *
  • {@link #BEHAVIOR_CHIPIFY_ALL}
  • + *
  • {@link #BEHAVIOR_CHIPIFY_CURRENT_TOKEN}
  • + *
  • {@link #BEHAVIOR_CHIPIFY_TO_TERMINATOR}
  • + *
+ * + * @param character the character to mark as a chip terminator + * @param behavior the behavior describing how to respond to the chip terminator + */ + void addChipTerminator(char character, int behavior); + + /** + * Customizes the way paste events are handled. + * If one of: + *
    + *
  • {@link #BEHAVIOR_CHIPIFY_ALL}
  • + *
  • {@link #BEHAVIOR_CHIPIFY_CURRENT_TOKEN}
  • + *
  • {@link #BEHAVIOR_CHIPIFY_TO_TERMINATOR}
  • + *
+ * is passed, all chip terminators will be handled with that behavior when a paste event occurs. + * If {@link #PASTE_BEHAVIOR_USE_DEFAULT} is passed, whatever behavior is configured for a particular chip terminator + * (through {@link #setChipTerminators(Map)} or {@link #addChipTerminator(char, int)} will be used for that chip terminator + * + * @param pasteBehavior the behavior to use on a paste event + */ + void setPasteBehavior(int pasteBehavior); + + /** + * Parses the provided text looking for characters marked as chip terminators through {@link #addChipTerminator(char, int)} and {@link #setChipTerminators(Map)}. + * The provided {@link Editable} will be modified if chip terminators are encountered. + * + * @param tokenizer the {@link ChipTokenizer} to use to identify and chipify tokens in the text + * @param text the text in which to search for chip terminators tokens to be chipped + * @param start the index at which to begin looking for chip terminators (inclusive) + * @param end the index at which to end looking for chip terminators (exclusive) + * @param isPasteEvent true if this handling is for a paste event in which case the behavior set in {@link #setPasteBehavior(int)} will be used, + * otherwise false + * @return an non-negative integer indicating the index where the cursor (selection) should be placed once the handling is complete, + * or a negative integer indicating that the cursor should not be moved. + */ + int findAndHandleChipTerminators(@NonNull ChipTokenizer tokenizer, @NonNull Editable text, int start, int end, boolean isPasteEvent); +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/terminator/DefaultChipTerminatorHandler.java b/mastodon/src/main/java/com/hootsuite/nachos/terminator/DefaultChipTerminatorHandler.java new file mode 100644 index 000000000..75cb9b0dc --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/terminator/DefaultChipTerminatorHandler.java @@ -0,0 +1,115 @@ +package com.hootsuite.nachos.terminator; + +import android.text.Editable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.hootsuite.nachos.tokenizer.ChipTokenizer; + +import java.util.HashMap; +import java.util.Map; + +public class DefaultChipTerminatorHandler implements ChipTerminatorHandler { + + @Nullable + private Map mChipTerminators; + private int mPasteBehavior = BEHAVIOR_CHIPIFY_TO_TERMINATOR; + + @Override + public void setChipTerminators(@Nullable Map chipTerminators) { + mChipTerminators = chipTerminators; + } + + @Override + public void addChipTerminator(char character, int behavior) { + if (mChipTerminators == null) { + mChipTerminators = new HashMap<>(); + } + + mChipTerminators.put(character, behavior); + } + + @Override + public void setPasteBehavior(int pasteBehavior) { + mPasteBehavior = pasteBehavior; + } + + @Override + public int findAndHandleChipTerminators(@NonNull ChipTokenizer tokenizer, @NonNull Editable text, int start, int end, boolean isPasteEvent) { + // If we don't have a tokenizer or any chip terminators, there's nothing to look for + if (mChipTerminators == null) { + return -1; + } + + TextIterator textIterator = new TextIterator(text, start, end); + int selectionIndex = -1; + + characterLoop: + while (textIterator.hasNextCharacter()) { + char theChar = textIterator.nextCharacter(); + if (isChipTerminator(theChar)) { + int behavior = (isPasteEvent && mPasteBehavior != PASTE_BEHAVIOR_USE_DEFAULT) ? mPasteBehavior : mChipTerminators.get(theChar); + int newSelection = -1; + switch (behavior) { + case BEHAVIOR_CHIPIFY_ALL: + selectionIndex = handleChipifyAll(textIterator, tokenizer); + break characterLoop; + case BEHAVIOR_CHIPIFY_CURRENT_TOKEN: + newSelection = handleChipifyCurrentToken(textIterator, tokenizer); + break; + case BEHAVIOR_CHIPIFY_TO_TERMINATOR: + newSelection = handleChipifyToTerminator(textIterator, tokenizer); + break; + } + + if (newSelection != -1) { + selectionIndex = newSelection; + } + } + } + + return selectionIndex; + } + + private int handleChipifyAll(TextIterator textIterator, ChipTokenizer tokenizer) { + textIterator.deleteCharacter(true); + tokenizer.terminateAllTokens(textIterator.getText()); + return textIterator.totalLength(); + } + + private int handleChipifyCurrentToken(TextIterator textIterator, ChipTokenizer tokenizer) { + textIterator.deleteCharacter(true); + Editable text = textIterator.getText(); + int index = textIterator.getIndex(); + int tokenStart = tokenizer.findTokenStart(text, index); + int tokenEnd = tokenizer.findTokenEnd(text, index); + if (tokenStart < tokenEnd) { + CharSequence chippedText = tokenizer.terminateToken(text.subSequence(tokenStart, tokenEnd), null); + textIterator.replace(tokenStart, tokenEnd, chippedText); + return tokenStart + chippedText.length(); + } + return -1; + } + + private int handleChipifyToTerminator(TextIterator textIterator, ChipTokenizer tokenizer) { + Editable text = textIterator.getText(); + int index = textIterator.getIndex(); + if (index > 0) { + int tokenStart = tokenizer.findTokenStart(text, index); + if (tokenStart < index) { + CharSequence chippedText = tokenizer.terminateToken(text.subSequence(tokenStart, index), null); + textIterator.replace(tokenStart, index + 1, chippedText); + } else { + textIterator.deleteCharacter(false); + } + } else { + textIterator.deleteCharacter(false); + } + return -1; + } + + private boolean isChipTerminator(char character) { + return mChipTerminators != null && mChipTerminators.keySet().contains(character); + } +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/terminator/TextIterator.java b/mastodon/src/main/java/com/hootsuite/nachos/terminator/TextIterator.java new file mode 100644 index 000000000..7c25d4c9f --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/terminator/TextIterator.java @@ -0,0 +1,63 @@ +package com.hootsuite.nachos.terminator; + +import android.text.Editable; + +public class TextIterator { + + private Editable mText; + private int mStart; + private int mEnd; + + private int mIndex; + + public TextIterator(Editable text, int start, int end) { + mText = text; + mStart = start; + mEnd = end; + + mIndex = mStart - 1; // Subtract 1 so that the first call to nextCharacter() will return the first character + } + + public int totalLength() { + return mText.length(); + } + + public int windowLength() { + return mEnd - mStart; + } + + public Editable getText() { + return mText; + } + + public int getIndex() { + return mIndex; + } + + public boolean hasNextCharacter() { + return (mIndex + 1) < mEnd; + } + + public char nextCharacter() { + mIndex++; + return mText.charAt(mIndex); + } + + public void deleteCharacter(boolean maintainIndex) { + mText.replace(mIndex, mIndex + 1, ""); + if (!maintainIndex) { + mIndex--; + } + mEnd--; + } + + public void replace(int replaceStart, int replaceEnd, CharSequence chippedText) { + mText.replace(replaceStart, replaceEnd, chippedText); + + // Update indexes + int newLength = chippedText.length(); + int oldLength = replaceEnd - replaceStart; + mIndex = replaceStart + newLength - 1; + mEnd += newLength - oldLength; + } +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/BaseChipTokenizer.java b/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/BaseChipTokenizer.java new file mode 100644 index 000000000..ba946b5b0 --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/BaseChipTokenizer.java @@ -0,0 +1,89 @@ +package com.hootsuite.nachos.tokenizer; + +import android.text.Editable; +import android.text.Spanned; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.hootsuite.nachos.ChipConfiguration; +import com.hootsuite.nachos.chip.Chip; + +import java.util.ArrayList; +import java.util.List; + +/** + * Base implementation of the {@link ChipTokenizer} interface that performs no actions and returns default values. + * This class allows for the easy creation of a ChipTokenizer that only implements some of the methods of the interface. + */ +public abstract class BaseChipTokenizer implements ChipTokenizer { + + @Override + public void applyConfiguration(Editable text, ChipConfiguration chipConfiguration) { + // Do nothing + } + + @Override + public int findTokenStart(CharSequence charSequence, int i) { + // Do nothing + return 0; + } + + @Override + public int findTokenEnd(CharSequence charSequence, int i) { + // Do nothing + return 0; + } + + @NonNull + @Override + public List> findAllTokens(CharSequence text) { + // Do nothing + return new ArrayList<>(); + } + + @Override + public CharSequence terminateToken(CharSequence charSequence, @Nullable Object data) { + // Do nothing + return charSequence; + } + + @Override + public void terminateAllTokens(Editable text) { + // Do nothing + } + + @Override + public int findChipStart(Chip chip, Spanned text) { + // Do nothing + return 0; + } + + @Override + public int findChipEnd(Chip chip, Spanned text) { + // Do nothing + return 0; + } + + @NonNull + @Override + public Chip[] findAllChips(int start, int end, Spanned text) { + return new Chip[]{}; + } + + @Override + public void revertChipToToken(Chip chip, Editable text) { + // Do nothing + } + + @Override + public void deleteChip(Chip chip, Editable text) { + // Do nothing + } + + @Override + public void deleteChipAndPadding(Chip chip, Editable text) { + // Do nothing + } +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/ChipTokenizer.java b/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/ChipTokenizer.java new file mode 100644 index 000000000..32d8f0614 --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/ChipTokenizer.java @@ -0,0 +1,134 @@ +package com.hootsuite.nachos.tokenizer; + +import android.text.Editable; +import android.text.Spanned; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.hootsuite.nachos.ChipConfiguration; +import com.hootsuite.nachos.chip.Chip; + +import java.util.List; + +/** + * An extension of {@link android.widget.MultiAutoCompleteTextView.Tokenizer Tokenizer} that provides extra support + * for chipification. + *

+ * In the context of this interface, a token is considered to be plain (non-chipped) text. Once a token is terminated it becomes or contains a chip. + *

+ *

+ * The CharSequences passed to the ChipTokenizer methods may contain both chipped text + * and plain text so the tokenizer must have some method of distinguishing between the two (e.g. using a delimeter character. + * The {@link #terminateToken(CharSequence, Object)} method is where a chip can be formed and returned to replace the plain text. + * Whatever class the implementation deems to represent a chip, must implement the {@link Chip} interface. + *

+ * + * @see SpanChipTokenizer + */ +public interface ChipTokenizer { + + /** + * Configures this ChipTokenizer to produce chips with the provided attributes. For each of these attributes, {@code -1} or {@code null} may be + * passed to indicate that the attribute may be ignored. + *

+ * This will also apply the provided {@link ChipConfiguration} to any existing chips in the provided text. + *

+ * + * @param text the text in which to search for existing chips to apply the configuration to + * @param chipConfiguration a {@link ChipConfiguration} containing customizations for the chips produced by this class + */ + void applyConfiguration(Editable text, ChipConfiguration chipConfiguration); + + /** + * Returns the start of the token that ends at offset + * cursor within text. + */ + int findTokenStart(CharSequence text, int cursor); + + /** + * Returns the end of the token (minus trailing punctuation) + * that begins at offset cursor within text. + */ + int findTokenEnd(CharSequence text, int cursor); + + /** + * Searches through {@code text} for any tokens. + * + * @param text the text in which to search for un-terminated tokens + * @return a list of {@link Pair}s of the form (startIndex, endIndex) containing the locations of all + * unterminated tokens + */ + @NonNull + List> findAllTokens(CharSequence text); + + /** + * Returns text, modified, if necessary, to ensure that + * it ends with a token terminator (for example a space or comma). + */ + CharSequence terminateToken(CharSequence text, @Nullable Object data); + + /** + * Terminates (converts from token into chip) all unterminated tokens in the provided text. + * This method CAN alter the provided text. + * + * @param text the text in which to terminate all tokens + */ + void terminateAllTokens(Editable text); + + /** + * Finds the index of the first character in {@code text} that is a part of {@code chip} + * + * @param chip the chip whose start should be found + * @param text the text in which to search for the start of {@code chip} + * @return the start index of the chip + */ + int findChipStart(Chip chip, Spanned text); + + /** + * Finds the index of the character after the last character in {@code text} that is a part of {@code chip} + * + * @param chip the chip whose end should be found + * @param text the text in which to search for the end of {@code chip} + * @return the end index of the chip + */ + int findChipEnd(Chip chip, Spanned text); + + /** + * Searches through {@code text} for any chips + * + * @param start index to start looking for terminated tokens (inclusive) + * @param end index to end looking for terminated tokens (exclusive) + * @param text the text in which to search for terminated tokens + * @return a list of objects implementing the {@link Chip} interface to represent the terminated tokens + */ + @NonNull + Chip[] findAllChips(int start, int end, Spanned text); + + /** + * Effectively does the opposite of {@link #terminateToken(CharSequence, Object)} by reverting the provided chip back into a token. + * This method CAN alter the provided text. + * + * @param chip the chip to revert into a token + * @param text the text in which the chip resides + */ + void revertChipToToken(Chip chip, Editable text); + + /** + * Removes a chip and any text it encompasses from {@code text}. This method CAN alter the provided text. + * + * @param chip the chip to remove + * @param text the text to remove the chip from + */ + void deleteChip(Chip chip, Editable text); + + /** + * Removes a chip, any text it encompasses AND any padding text (such as spaces) that may have been inserted when the chip was created in + * {@link #terminateToken(CharSequence, Object)} or after. This method CAN alter the provided text. + * + * @param chip the chip to remove + * @param text the text to remove the chip and padding from + */ + void deleteChipAndPadding(Chip chip, Editable text); +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/SpanChipTokenizer.java b/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/SpanChipTokenizer.java new file mode 100644 index 000000000..511bdb9bb --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/tokenizer/SpanChipTokenizer.java @@ -0,0 +1,246 @@ +package com.hootsuite.nachos.tokenizer; + +import android.content.Context; +import android.text.Editable; +import android.text.SpannableString; +import android.text.Spanned; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.hootsuite.nachos.ChipConfiguration; +import com.hootsuite.nachos.chip.Chip; +import com.hootsuite.nachos.chip.ChipCreator; +import com.hootsuite.nachos.chip.ChipSpan; + +import java.lang.reflect.Array; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * A default implementation of {@link ChipTokenizer}. + * This implementation does the following: + *
    + *
  • Surrounds each token with a space and the Unit Separator ASCII control character (31) - See the diagram below + *
      + *
    • The spaces are included so that android keyboards can distinguish the chips as different words and provide accurate + * autocorrect suggestions
    • + *
    + *
  • + *
  • Replaces each token with a {@link ChipSpan} containing the same text, once the token terminates
  • + *
  • Uses the values passed to {@link #applyConfiguration(Editable, ChipConfiguration)} to configure any ChipSpans that get created
  • + *
+ * Each terminated token will therefore look like the following (this is what will be returned from {@link #terminateToken(CharSequence, Object)}): + *
+ *  -----------------------------------------------------------
+ *  | SpannableString                                         |
+ *  |   ----------------------------------------------------  |
+ *  |   | ChipSpan                                         |  |
+ *  |   |                                                  |  |
+ *  |   |  space   separator    text    separator   space  |  |
+ *  |   |                                                  |  |
+ *  |   ----------------------------------------------------  |
+ *  -----------------------------------------------------------
+ * 
+ * + * @see ChipSpan + */ +public class SpanChipTokenizer implements ChipTokenizer { + + /** + * The character used to separate chips internally is the US (Unit Separator) ASCII control character. + * This character is used because it's untypable so we have complete control over when chips are created. + */ + public static final char CHIP_SPAN_SEPARATOR = 31; + public static final char AUTOCORRECT_SEPARATOR = ' '; + + private Context mContext; + + @Nullable + private ChipConfiguration mChipConfiguration; + @NonNull + private ChipCreator mChipCreator; + @NonNull + private Class mChipClass; + + private Comparator> mReverseTokenIndexesSorter = new Comparator>() { + @Override + public int compare(Pair lhs, Pair rhs) { + return rhs.first - lhs.first; + } + }; + + public SpanChipTokenizer(Context context, @NonNull ChipCreator chipCreator, @NonNull Class chipClass) { + mContext = context; + mChipCreator = chipCreator; + mChipClass = chipClass; + } + + @Override + public void applyConfiguration(Editable text, ChipConfiguration chipConfiguration) { + mChipConfiguration = chipConfiguration; + + for (C chip : findAllChips(0, text.length(), text)) { + // Recreate the chips with the new configuration + int chipStart = findChipStart(chip, text); + deleteChip(chip, text); + text.insert(chipStart, terminateToken(mChipCreator.createChip(mContext, chip))); + } + } + + @Override + public int findTokenStart(CharSequence text, int cursor) { + int i = cursor; + + // Work backwards until we find a CHIP_SPAN_SEPARATOR + while (i > 0 && text.charAt(i - 1) != CHIP_SPAN_SEPARATOR) { + i--; + } + // Work forwards to skip over any extra whitespace at the beginning of the token + while (i > 0 && i < text.length() && Character.isWhitespace(text.charAt(i))) { + i++; + } + return i; + } + + @Override + public int findTokenEnd(CharSequence text, int cursor) { + int i = cursor; + int len = text.length(); + + // Work forwards till we find a CHIP_SPAN_SEPARATOR + while (i < len) { + if (text.charAt(i) == CHIP_SPAN_SEPARATOR) { + return (i - 1); // subtract one because the CHIP_SPAN_SEPARATOR will be preceded by a space + } else { + i++; + } + } + return len; + } + + @NonNull + @Override + public List> findAllTokens(CharSequence text) { + List> unterminatedTokens = new ArrayList<>(); + + boolean insideChip = false; + // Iterate backwards through the text (to avoid messing up indexes) + for (int index = text.length() - 1; index >= 0; index--) { + char theCharacter = text.charAt(index); + + // Every time we hit a CHIP_SPAN_SEPARATOR character we switch from being inside to outside + // or outside to inside a chip + // This check must happen before the whitespace check because CHIP_SPAN_SEPARATOR is considered a whitespace character + if (theCharacter == CHIP_SPAN_SEPARATOR) { + insideChip = !insideChip; + continue; + } + + // Completely skip over whitespace + if (Character.isWhitespace(theCharacter)) { + continue; + } + + // If we're ever outside a chip, see if the text we're in is a viable token for chipification + if (!insideChip) { + int tokenStart = findTokenStart(text, index); + int tokenEnd = findTokenEnd(text, index); + + // Can only actually be chipified if there's at least one character between them + if (tokenEnd - tokenStart >= 1) { + unterminatedTokens.add(new Pair<>(tokenStart, tokenEnd)); + index = tokenStart; + } + } + } + return unterminatedTokens; + } + + @Override + public CharSequence terminateToken(CharSequence text, @Nullable Object data) { + // Remove leading/trailing whitespace + CharSequence trimmedText = text.toString().trim(); + return terminateToken(mChipCreator.createChip(mContext, trimmedText, data)); + } + + private CharSequence terminateToken(C chip) { + // Surround the text with CHIP_SPAN_SEPARATOR and spaces + // The spaces allow autocorrect to correctly identify words + String chipSeparator = Character.toString(CHIP_SPAN_SEPARATOR); + String autoCorrectSeparator = Character.toString(AUTOCORRECT_SEPARATOR); + CharSequence textWithSeparator = autoCorrectSeparator + chipSeparator + chip.getText() + chipSeparator + autoCorrectSeparator; + + // Build the container object to house the ChipSpan and space + SpannableString spannableString = new SpannableString(textWithSeparator); + + // Attach the ChipSpan + if (mChipConfiguration != null) { + mChipCreator.configureChip(chip, mChipConfiguration); + } + spannableString.setSpan(chip, 0, textWithSeparator.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + return spannableString; + } + + @Override + public void terminateAllTokens(Editable text) { + List> unterminatedTokens = findAllTokens(text); + // Sort in reverse order (so index changes don't affect anything) + Collections.sort(unterminatedTokens, mReverseTokenIndexesSorter); + for (Pair indexes : unterminatedTokens) { + int start = indexes.first; + int end = indexes.second; + CharSequence textToChip = text.subSequence(start, end); + CharSequence chippedText = terminateToken(textToChip, null); + text.replace(start, end, chippedText); + } + } + + @Override + public int findChipStart(Chip chip, Spanned text) { + return text.getSpanStart(chip); + } + + @Override + public int findChipEnd(Chip chip, Spanned text) { + return text.getSpanEnd(chip); + } + + @SuppressWarnings("unchecked") + @NonNull + @Override + public C[] findAllChips(int start, int end, Spanned text) { + C[] spansArray = text.getSpans(start, end, mChipClass); + return (spansArray != null) ? spansArray : (C[]) Array.newInstance(mChipClass, 0); + } + + @Override + public void revertChipToToken(Chip chip, Editable text) { + int chipStart = findChipStart(chip, text); + int chipEnd = findChipEnd(chip, text); + text.removeSpan(chip); + text.replace(chipStart, chipEnd, chip.getText()); + } + + @Override + public void deleteChip(Chip chip, Editable text) { + int chipStart = findChipStart(chip, text); + int chipEnd = findChipEnd(chip, text); + text.removeSpan(chip); + // On the emulator for some reason the text automatically gets deleted and chipStart and chipEnd end up both being -1, so in that case we + // don't need to call text.delete(...) + if (chipStart != chipEnd) { + text.delete(chipStart, chipEnd); + } + } + + @Override + public void deleteChipAndPadding(Chip chip, Editable text) { + // This implementation does not add any extra padding outside of the span so we can just delete the chip normally + deleteChip(chip, text); + } +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/validator/ChipifyingNachoValidator.java b/mastodon/src/main/java/com/hootsuite/nachos/validator/ChipifyingNachoValidator.java new file mode 100644 index 000000000..fa1aa5717 --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/validator/ChipifyingNachoValidator.java @@ -0,0 +1,32 @@ +package com.hootsuite.nachos.validator; + +import android.text.SpannableStringBuilder; +import android.util.Pair; + +import androidx.annotation.NonNull; + +import com.hootsuite.nachos.tokenizer.ChipTokenizer; + +import java.util.List; + +/** + * A {@link NachoValidator} that deems text to be invalid if it contains + * unterminated tokens and fixes the text by chipifying all the unterminated tokens. + */ +public class ChipifyingNachoValidator implements NachoValidator { + + @Override + public boolean isValid(@NonNull ChipTokenizer chipTokenizer, CharSequence text) { + + // The text is considered valid if there are no unterminated tokens (everything is a chip) + List> unterminatedTokens = chipTokenizer.findAllTokens(text); + return unterminatedTokens.isEmpty(); + } + + @Override + public CharSequence fixText(@NonNull ChipTokenizer chipTokenizer, CharSequence invalidText) { + SpannableStringBuilder newText = new SpannableStringBuilder(invalidText); + chipTokenizer.terminateAllTokens(newText); + return newText; + } +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/validator/IllegalCharacterIdentifier.java b/mastodon/src/main/java/com/hootsuite/nachos/validator/IllegalCharacterIdentifier.java new file mode 100644 index 000000000..76f3f858f --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/validator/IllegalCharacterIdentifier.java @@ -0,0 +1,5 @@ +package com.hootsuite.nachos.validator; + +public interface IllegalCharacterIdentifier { + boolean isCharacterIllegal(Character c); +} diff --git a/mastodon/src/main/java/com/hootsuite/nachos/validator/NachoValidator.java b/mastodon/src/main/java/com/hootsuite/nachos/validator/NachoValidator.java new file mode 100644 index 000000000..70438c155 --- /dev/null +++ b/mastodon/src/main/java/com/hootsuite/nachos/validator/NachoValidator.java @@ -0,0 +1,29 @@ +package com.hootsuite.nachos.validator; + +import androidx.annotation.NonNull; + +import com.hootsuite.nachos.tokenizer.ChipTokenizer; + +/** + * Interface used to ensure that a given CharSequence complies to a particular format. + */ +public interface NachoValidator { + + /** + * Validates the specified text. + * + * @return true If the text currently in the text editor is valid. + * @see #fixText(ChipTokenizer, CharSequence) + */ + boolean isValid(@NonNull ChipTokenizer chipTokenizer, CharSequence text); + + /** + * Corrects the specified text to make it valid. + * + * @param invalidText A string that doesn't pass validation: isValid(invalidText) + * returns false + * @return A string based on invalidText such as invoking isValid() on it returns true. + * @see #isValid(ChipTokenizer, CharSequence) + */ + CharSequence fixText(@NonNull ChipTokenizer chipTokenizer, CharSequence invalidText); +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHashtagTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHashtagTimeline.java index 1670c38c2..13ed20ce9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHashtagTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHashtagTimeline.java @@ -2,6 +2,8 @@ package org.joinmastodon.android.api.requests.timelines; import com.google.gson.reflect.TypeToken; +import android.text.TextUtils; + import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Status; @@ -9,6 +11,23 @@ import org.joinmastodon.android.model.Status; import java.util.List; public class GetHashtagTimeline extends MastodonAPIRequest>{ + public GetHashtagTimeline(String hashtag, String maxID, String minID, int limit, List containsAny, List containsAll, List containsNone, boolean localOnly){ + super(HttpMethod.GET, "/timelines/tag/"+hashtag, new TypeToken<>(){}); + if (localOnly) addQueryParameter("local", "true"); + if(maxID!=null) + addQueryParameter("max_id", maxID); + if(minID!=null) + addQueryParameter("min_id", minID); + if(limit>0) + addQueryParameter("limit", ""+limit); + if(containsAny!=null && !containsAny.isEmpty()) + addQueryParameter("any[]", "[" + TextUtils.join(",", containsAny) + "]"); + if(containsAll!=null && !containsAll.isEmpty()) + addQueryParameter("all[]", "[" + TextUtils.join(",", containsAll) + "]"); + if(containsNone!=null && !containsNone.isEmpty()) + addQueryParameter("none[]", "[" + TextUtils.join(",", containsNone) + "]"); + } + public GetHashtagTimeline(String hashtag, String maxID, String minID, int limit){ super(HttpMethod.GET, "/timelines/tag/"+hashtag, new TypeToken<>(){}); if(maxID!=null) diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java index 784d7920a..c3a740eaf 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java @@ -1,12 +1,14 @@ package org.joinmastodon.android.fragments; import static android.view.Menu.NONE; - +import static com.hootsuite.nachos.terminator.ChipTerminatorHandler.BEHAVIOR_CHIPIFY_ALL; import static org.joinmastodon.android.ui.utils.UiUtils.makeBackItem; import android.annotation.SuppressLint; +import android.app.AlertDialog; import android.content.Context; import android.os.Bundle; +import android.text.TextUtils; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; @@ -14,39 +16,43 @@ import android.view.MotionEvent; import android.view.SubMenu; import android.view.View; import android.view.ViewGroup; +import android.view.inputmethod.InputMethodManager; +import android.widget.Button; import android.widget.EditText; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.PopupMenu; +import android.widget.Switch; import android.widget.TextView; +import android.widget.Toast; +import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; +import com.hootsuite.nachos.NachoTextView; + import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.lists.GetLists; import org.joinmastodon.android.api.requests.tags.GetFollowedHashtags; -import org.joinmastodon.android.api.session.AccountSession; -import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.HeaderPaginationList; -import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.ListTimeline; import org.joinmastodon.android.model.TimelineDefinition; import org.joinmastodon.android.ui.DividerItemDecoration; import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.utils.UiUtils; -import org.joinmastodon.android.ui.views.TextInputFrameLayout; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; @@ -62,6 +68,7 @@ public class EditTimelinesFragment extends RecyclerFragment private final Map timelineByMenuItem = new HashMap<>(); private final List listTimelines = new ArrayList<>(); private final List hashtags = new ArrayList<>(); + private MenuItem addHashtagItem; public EditTimelinesFragment() { super(10); @@ -132,21 +139,34 @@ public class EditTimelinesFragment extends RecyclerFragment } TimelineDefinition tl = timelineByMenuItem.get(item); if (tl != null) { - data.add(tl.copy()); - adapter.notifyItemInserted(data.size()); - saveTimelines(); - updateOptionsMenu(); - }; + addTimeline(tl); + } else if (item == addHashtagItem) { + makeTimelineEditor(null, (hashtag) -> { + if (hashtag != null) addTimeline(hashtag); + }, null); + } return true; } + private void addTimeline(TimelineDefinition tl) { + data.add(tl.copy()); + adapter.notifyItemInserted(data.size()); + saveTimelines(); + updateOptionsMenu(); + } + private void addTimelineToOptions(TimelineDefinition tl, Menu menu) { if (data.contains(tl)) return; - MenuItem item = menu.add(0, View.generateViewId(), Menu.NONE, tl.getTitle(getContext())); - item.setIcon(tl.getIcon().iconRes); + MenuItem item = addOptionsItem(menu, tl.getTitle(getContext()), tl.getIcon().iconRes); timelineByMenuItem.put(item, tl); } + private MenuItem addOptionsItem(Menu menu, String name, @DrawableRes int icon) { + MenuItem item = menu.add(0, View.generateViewId(), Menu.NONE, name); + item.setIcon(icon); + return item; + } + private void updateOptionsMenu() { if (getActivity() == null) return; optionsMenu.clear(); @@ -169,6 +189,7 @@ public class EditTimelinesFragment extends RecyclerFragment TimelineDefinition.getAllTimelines(accountID).forEach(tl -> addTimelineToOptions(tl, timelinesMenu)); listTimelines.stream().map(TimelineDefinition::ofList).forEach(tl -> addTimelineToOptions(tl, listsMenu)); + addHashtagItem = addOptionsItem(hashtagsMenu, getContext().getString(R.string.sk_timelines_add), R.drawable.ic_fluent_add_24_regular); hashtags.stream().map(TimelineDefinition::ofHashtag).forEach(tl -> addTimelineToOptions(tl, hashtagsMenu)); timelinesMenu.getItem().setVisible(timelinesMenu.size() > 0); @@ -213,6 +234,133 @@ public class EditTimelinesFragment extends RecyclerFragment if (updated) UiUtils.restartApp(); } + private boolean setTagListContent(NachoTextView editText, @Nullable List tags) { + if (tags == null || tags.isEmpty()) return false; + editText.setText(String.join(",", tags)); + editText.chipifyAllUnterminatedTokens(); + return true; + } + + private NachoTextView prepareChipTextView(NachoTextView nacho) { + nacho.addChipTerminator(',', BEHAVIOR_CHIPIFY_ALL); + nacho.addChipTerminator('\n', BEHAVIOR_CHIPIFY_ALL); + nacho.addChipTerminator(' ', BEHAVIOR_CHIPIFY_ALL); + nacho.addChipTerminator(';', BEHAVIOR_CHIPIFY_ALL); + nacho.enableEditChipOnTouch(true, true); + nacho.setOnFocusChangeListener((v, hasFocus) -> nacho.chipifyAllUnterminatedTokens()); + return nacho; + } + + @SuppressLint("ClickableViewAccessibility") + protected void makeTimelineEditor(@Nullable TimelineDefinition item, Consumer onSave, Runnable onRemove) { + Context ctx = getContext(); + View view = getActivity().getLayoutInflater().inflate(R.layout.edit_timeline, list, false); + + Button advancedBtn = view.findViewById(R.id.advanced); + EditText editText = view.findViewById(R.id.input); + if (item != null) editText.setText(item.getCustomTitle()); + editText.setHint(item != null ? item.getDefaultTitle(ctx) : ctx.getString(R.string.sk_hashtag)); + + LinearLayout tagWrap = view.findViewById(R.id.tag_wrap); + boolean advancedOptionsAvailable = item == null || item.getType() == TimelineDefinition.TimelineType.HASHTAG; + advancedBtn.setVisibility(advancedOptionsAvailable ? View.VISIBLE : View.GONE); + view.findViewById(R.id.divider).setVisibility(advancedOptionsAvailable ? View.VISIBLE : View.GONE); + advancedBtn.setOnClickListener(l -> { + advancedBtn.setSelected(!advancedBtn.isSelected()); + advancedBtn.setText(advancedBtn.isSelected() ? R.string.sk_advanced_options_hide : R.string.sk_advanced_options_show); + tagWrap.setVisibility(advancedBtn.isSelected() ? View.VISIBLE : View.GONE); + }); + + Switch localOnlySwitch = view.findViewById(R.id.local_only_switch); + view.findViewById(R.id.local_only) + .setOnClickListener(l -> localOnlySwitch.setChecked(!localOnlySwitch.isChecked())); + + EditText tagMain = view.findViewById(R.id.tag_main); + NachoTextView tagsAny = prepareChipTextView(view.findViewById(R.id.tags_any)); + NachoTextView tagsAll = prepareChipTextView(view.findViewById(R.id.tags_all)); + NachoTextView tagsNone = prepareChipTextView(view.findViewById(R.id.tags_none)); + if (item != null) { + tagMain.setText(item.getHashtagName()); + boolean hasAdvanced = setTagListContent(tagsAny, item.getHashtagAny()); + hasAdvanced = setTagListContent(tagsAll, item.getHashtagAll()) || hasAdvanced; + hasAdvanced = setTagListContent(tagsNone, item.getHashtagNone()) || hasAdvanced; + if (item.isHashtagLocalOnly()) { + localOnlySwitch.setChecked(true); + hasAdvanced = true; + } + if (hasAdvanced) { + advancedBtn.setSelected(true); + advancedBtn.setText(R.string.sk_advanced_options_hide); + tagWrap.setVisibility(View.VISIBLE); + } + } + + ImageButton btn = view.findViewById(R.id.button); + PopupMenu popup = new PopupMenu(ctx, btn); + TimelineDefinition.Icon currentIcon = item != null ? item.getIcon() : TimelineDefinition.Icon.HASHTAG; + btn.setImageResource(currentIcon.iconRes); + btn.setTag(currentIcon.ordinal()); + btn.setContentDescription(ctx.getString(currentIcon.nameRes)); + btn.setOnTouchListener(popup.getDragToOpenListener()); + btn.setOnClickListener(l -> popup.show()); + + Menu menu = popup.getMenu(); + TimelineDefinition.Icon defaultIcon = item != null ? item.getDefaultIcon() : TimelineDefinition.Icon.HASHTAG; + menu.add(0, currentIcon.ordinal(), NONE, currentIcon.nameRes).setIcon(currentIcon.iconRes); + if (!currentIcon.equals(defaultIcon)) { + menu.add(0, defaultIcon.ordinal(), NONE, defaultIcon.nameRes).setIcon(defaultIcon.iconRes); + } + for (TimelineDefinition.Icon icon : TimelineDefinition.Icon.values()) { + if (icon.hidden || icon.ordinal() == (int) btn.getTag()) continue; + menu.add(0, icon.ordinal(), NONE, icon.nameRes).setIcon(icon.iconRes); + } + UiUtils.enablePopupMenuIcons(ctx, popup); + + popup.setOnMenuItemClickListener(menuItem -> { + TimelineDefinition.Icon icon = TimelineDefinition.Icon.values()[menuItem.getItemId()]; + btn.setImageResource(icon.iconRes); + btn.setTag(menuItem.getItemId()); + btn.setContentDescription(ctx.getString(icon.nameRes)); + return true; + }); + + AlertDialog.Builder builder = new M3AlertDialogBuilder(ctx) + .setTitle(item == null ? R.string.sk_add_timeline : R.string.sk_edit_timeline) + .setView(view) + .setPositiveButton(R.string.save, (d, which) -> { + tagsAny.chipifyAllUnterminatedTokens(); + tagsAll.chipifyAllUnterminatedTokens(); + tagsNone.chipifyAllUnterminatedTokens(); + String name = editText.getText().toString().trim(); + String mainHashtag = tagMain.getText().toString().trim(); + if (TextUtils.isEmpty(mainHashtag)) mainHashtag = name; + if (item == null && TextUtils.isEmpty(mainHashtag)) { + Toast.makeText(ctx, R.string.sk_add_timeline_tag_error_empty, Toast.LENGTH_SHORT).show(); + onSave.accept(null); + return; + } + + TimelineDefinition tl = item != null ? item : TimelineDefinition.ofHashtag(name); + TimelineDefinition.Icon icon = TimelineDefinition.Icon.values()[(int) btn.getTag()]; + tl.setIcon(icon); + tl.setTitle(name); + tl.setTagOptions( + mainHashtag, + tagsAny.getChipValues(), + tagsAll.getChipValues(), + tagsNone.getChipValues(), + localOnlySwitch.isChecked() + ); + onSave.accept(tl); + }) + .setNegativeButton(R.string.cancel, (d, which) -> {}); + + if (onRemove != null) builder.setNeutralButton(R.string.sk_remove, (d, which) -> onRemove.run()); + + builder.show(); + btn.requestFocus(); + } + private class TimelinesAdapter extends RecyclerView.Adapter{ @NonNull @Override @@ -256,60 +404,19 @@ public class EditTimelinesFragment extends RecyclerFragment }); } + private void onSave(TimelineDefinition tl) { + saveTimelines(); + rebind(); + } + + private void onRemove() { + removeTimeline(getAbsoluteAdapterPosition()); + } + @SuppressLint("ClickableViewAccessibility") @Override public void onClick() { - Context ctx = getContext(); - LinearLayout view = (LinearLayout) getActivity().getLayoutInflater() - .inflate(R.layout.edit_timeline, (ViewGroup) itemView, false); - - TextInputFrameLayout inputLayout = view.findViewById(R.id.input); - EditText editText = inputLayout.getEditText(); - editText.setText(item.getCustomTitle()); - editText.setHint(item.getDefaultTitle(ctx)); - - ImageButton btn = view.findViewById(R.id.button); - PopupMenu popup = new PopupMenu(ctx, btn); - TimelineDefinition.Icon currentIcon = item.getIcon(); - btn.setImageResource(currentIcon.iconRes); - btn.setContentDescription(ctx.getString(currentIcon.nameRes)); - btn.setOnTouchListener(popup.getDragToOpenListener()); - btn.setOnClickListener(l -> popup.show()); - - Menu menu = popup.getMenu(); - TimelineDefinition.Icon defaultIcon = item.getDefaultIcon(); - menu.add(0, currentIcon.ordinal(), NONE, currentIcon.nameRes).setIcon(currentIcon.iconRes); - if (!currentIcon.equals(defaultIcon)) { - menu.add(0, defaultIcon.ordinal(), NONE, defaultIcon.nameRes).setIcon(defaultIcon.iconRes); - } - for (TimelineDefinition.Icon icon : TimelineDefinition.Icon.values()) { - if (icon.hidden || icon.equals(item.getIcon())) continue; - menu.add(0, icon.ordinal(), NONE, icon.nameRes).setIcon(icon.iconRes); - } - UiUtils.enablePopupMenuIcons(ctx, popup); - - popup.setOnMenuItemClickListener(menuItem -> { - TimelineDefinition.Icon icon = TimelineDefinition.Icon.values()[menuItem.getItemId()]; - btn.setImageResource(icon.iconRes); - btn.setContentDescription(ctx.getString(icon.nameRes)); - item.setIcon(icon); - return true; - }); - - new M3AlertDialogBuilder(ctx) - .setTitle(R.string.sk_edit_timeline) - .setView(view) - .setPositiveButton(R.string.save, (d, which) -> { - item.setTitle(editText.getText().toString().trim()); - rebind(); - saveTimelines(); - }) - .setNeutralButton(R.string.sk_remove, (d, which) -> - removeTimeline(getAbsoluteAdapterPosition())) - .setNegativeButton(R.string.cancel, (d, which) -> {}) - .show(); - - btn.requestFocus(); + makeTimelineEditor(item, this::onSave, this::onRemove); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java index a69963dac..99122001c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java @@ -35,7 +35,11 @@ import me.grishka.appkit.utils.V; public class HashtagTimelineFragment extends PinnableStatusListFragment { private String hashtag; + private List any; + private List all; + private List none; private boolean following; + private boolean localOnly; private MenuItem followButton; @Override @@ -48,6 +52,10 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment { super.onAttach(activity); updateTitle(getArguments().getString("hashtag")); following=getArguments().getBoolean("following", false); + localOnly=getArguments().getBoolean("localOnly", false); + any=getArguments().getStringArrayList("any"); + all=getArguments().getStringArrayList("all"); + none=getArguments().getStringArrayList("none"); setHasOptionsMenu(true); } @@ -118,7 +126,7 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment { @Override protected void doLoadData(int offset, int count){ - currentRequest=new GetHashtagTimeline(hashtag, offset==0 ? null : getMaxID(), null, count) + currentRequest=new GetHashtagTimeline(hashtag, offset==0 ? null : getMaxID(), null, count, any, all, none, localOnly) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java b/mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java index 24d51b203..c03d3c510 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java @@ -3,6 +3,7 @@ package org.joinmastodon.android.model; import android.app.Fragment; import android.content.Context; import android.os.Bundle; +import android.text.TextUtils; import androidx.annotation.DrawableRes; import androidx.annotation.Nullable; @@ -19,6 +20,7 @@ import org.joinmastodon.android.fragments.discover.BubbleTimelineFragment; import org.joinmastodon.android.fragments.discover.FederatedTimelineFragment; import org.joinmastodon.android.fragments.discover.LocalTimelineFragment; +import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; @@ -33,6 +35,10 @@ public class TimelineDefinition { private boolean listIsExclusive; private @Nullable String hashtagName; + private @Nullable List hashtagAny; + private @Nullable List hashtagAll; + private @Nullable List hashtagNone; + private boolean hashtagLocalOnly; public static TimelineDefinition ofList(String listId, String listTitle, boolean listIsExclusive) { TimelineDefinition def = new TimelineDefinition(TimelineType.LIST); @@ -79,10 +85,50 @@ public class TimelineDefinition { return title; } + @Nullable + public String getHashtagName() { + return hashtagName; + } + + @Nullable + public List getHashtagAny() { + return hashtagAny; + } + + @Nullable + public List getHashtagAll() { + return hashtagAll; + } + + @Nullable + public List getHashtagNone() { + return hashtagNone; + } + + public boolean isHashtagLocalOnly() { + return hashtagLocalOnly; + } + public void setTitle(String title) { this.title = title == null || title.isBlank() ? null : title; } + private List sanitizeTagList(List tags) { + return tags.stream() + .map(String::trim) + .filter(str -> !TextUtils.isEmpty(str)) + .collect(Collectors.toList()); + } + + public void setTagOptions(String main, List any, List all, List none, boolean localOnly) { + this.hashtagName = main; + this.hashtagAny = sanitizeTagList(any); + this.hashtagAll = sanitizeTagList(all); + this.hashtagNone = sanitizeTagList(none); + this.hashtagLocalOnly = localOnly; + } + + public String getDefaultTitle(Context ctx) { return switch (type) { case HOME -> ctx.getString(R.string.sk_timeline_home); @@ -140,16 +186,17 @@ public class TimelineDefinition { TimelineDefinition that = (TimelineDefinition) o; if (type != that.type) return false; if (type == TimelineType.LIST) return Objects.equals(listId, that.listId); - if (type == TimelineType.HASHTAG) return Objects.equals(hashtagName.toLowerCase(), that.hashtagName.toLowerCase()); + if (type == TimelineType.HASHTAG) { + if (hashtagName == null && that.hashtagName == null) return true; + if (hashtagName == null || that.hashtagName == null) return false; + return Objects.equals(hashtagName.toLowerCase(), that.hashtagName.toLowerCase()); + } return true; } @Override public int hashCode() { - int result = type.ordinal(); - result = 31 * result + (listId != null ? listId.hashCode() : 0); - result = 31 * result + (hashtagName.toLowerCase() != null ? hashtagName.toLowerCase().hashCode() : 0); - return result; + return Objects.hash(type, title, listId, hashtagName, hashtagAny, hashtagAll, hashtagNone); } public TimelineDefinition copy() { @@ -159,6 +206,9 @@ public class TimelineDefinition { def.listTitle = listTitle; def.listIsExclusive = listIsExclusive; def.hashtagName = hashtagName; + def.hashtagAny = hashtagAny; + def.hashtagAll = hashtagAll; + def.hashtagNone = hashtagNone; def.icon = icon == null ? null : Icon.values()[icon.ordinal()]; return def; } @@ -170,6 +220,10 @@ public class TimelineDefinition { args.putBoolean("listIsExclusive", listIsExclusive); } else if (type == TimelineType.HASHTAG) { args.putString("hashtag", hashtagName); + args.putBoolean("localOnly", hashtagLocalOnly); + args.putStringArrayList("any", hashtagAny == null ? new ArrayList<>() : new ArrayList<>(hashtagAny)); + args.putStringArrayList("all", hashtagAll == null ? new ArrayList<>() : new ArrayList<>(hashtagAll)); + args.putStringArrayList("none", hashtagNone == null ? new ArrayList<>() : new ArrayList<>(hashtagNone)); } return args; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/text/TagEditText.java b/mastodon/src/main/java/org/joinmastodon/android/ui/text/TagEditText.java new file mode 100644 index 000000000..0ded3bf43 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/text/TagEditText.java @@ -0,0 +1,2 @@ +package org.joinmastodon.android.ui.text;public class TagEditText { +} diff --git a/mastodon/src/main/res/color/chip_material_background.xml b/mastodon/src/main/res/color/chip_material_background.xml new file mode 100644 index 000000000..ec381b244 --- /dev/null +++ b/mastodon/src/main/res/color/chip_material_background.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bordered_rectangle_rounded_corners.xml b/mastodon/src/main/res/drawable/bordered_rectangle_rounded_corners.xml new file mode 100644 index 000000000..3ef89983f --- /dev/null +++ b/mastodon/src/main/res/drawable/bordered_rectangle_rounded_corners.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_shape_intersect_20_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_shape_intersect_20_filled.xml new file mode 100644 index 000000000..b2d2b5d91 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_shape_intersect_20_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_shape_intersect_20_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_shape_intersect_20_regular.xml new file mode 100644 index 000000000..0ed05cb2e --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_shape_intersect_20_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_shape_subtract_20_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_shape_subtract_20_filled.xml new file mode 100644 index 000000000..ca1e8b1ef --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_shape_subtract_20_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_shape_union_20_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_shape_union_20_filled.xml new file mode 100644 index 000000000..c06b10bbf --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_shape_union_20_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_shape_union_20_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_shape_union_20_regular.xml new file mode 100644 index 000000000..dda4048f5 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_shape_union_20_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/layout/edit_timeline.xml b/mastodon/src/main/res/layout/edit_timeline.xml index f2ee406b1..dcb75e1c0 100644 --- a/mastodon/src/main/res/layout/edit_timeline.xml +++ b/mastodon/src/main/res/layout/edit_timeline.xml @@ -1,22 +1,201 @@ - - - - \ No newline at end of file + + + + + + + + + + + + + +