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 <sk22@mailbox.org>
This commit is contained in:
FineFindus
2023-06-21 01:38:51 +02:00
committed by GitHub
parent bb4a52f03a
commit be425282a6
36 changed files with 3215 additions and 100 deletions

View File

@@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}

View File

@@ -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 <C> The type of {@link Chip} that the implementation will create/configure
*/
public interface ChipCreator<C extends Chip> {
/**
* 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);
}

View File

@@ -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;
}
}

View File

@@ -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:
* <pre>
*
* (chip vertical spacing / 2)
* ----------------------------------------------------------
* | |
* (left margin) | (padding edge) text (padding between image) icon | (right margin)
* | |
* ----------------------------------------------------------
* (chip vertical spacing / 2)
*
* </pre>
* For chips with the icon on the left (see {@link #setShowIconOnLeft(boolean)}):
* <pre>
*
* (chip vertical spacing / 2)
* ----------------------------------------------------------
* | |
* (left margin) | icon (padding between image) text (padding edge) | (right margin)
* | |
* ----------------------------------------------------------
* (chip vertical spacing / 2)
* </pre>
*/
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();
}
}

View File

@@ -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<ChipSpan> {
@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);
}
}
}

View File

@@ -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<Character, Integer> 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:
* <ul>
* <li>{@link #BEHAVIOR_CHIPIFY_ALL}</li>
* <li>{@link #BEHAVIOR_CHIPIFY_CURRENT_TOKEN}</li>
* <li>{@link #BEHAVIOR_CHIPIFY_TO_TERMINATOR}</li>
* </ul>
*
* @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:
* <ul>
* <li>{@link #BEHAVIOR_CHIPIFY_ALL}</li>
* <li>{@link #BEHAVIOR_CHIPIFY_CURRENT_TOKEN}</li>
* <li>{@link #BEHAVIOR_CHIPIFY_TO_TERMINATOR}</li>
* </ul>
* 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);
}

View File

@@ -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<Character, Integer> mChipTerminators;
private int mPasteBehavior = BEHAVIOR_CHIPIFY_TO_TERMINATOR;
@Override
public void setChipTerminators(@Nullable Map<Character, Integer> 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);
}
}

View File

@@ -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;
}
}

View File

@@ -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<Pair<Integer, Integer>> 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
}
}

View File

@@ -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.
* <p>
* 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.
* </p>
* <p>
* 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.
* </p>
*
* @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.
* <p>
* This will also apply the provided {@link ChipConfiguration} to any existing chips in the provided text.
* </p>
*
* @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
* <code>cursor</code> within <code>text</code>.
*/
int findTokenStart(CharSequence text, int cursor);
/**
* Returns the end of the token (minus trailing punctuation)
* that begins at offset <code>cursor</code> within <code>text</code>.
*/
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<Pair<Integer, Integer>> findAllTokens(CharSequence text);
/**
* Returns <code>text</code>, 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);
}

View File

@@ -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:
* <ul>
* <li>Surrounds each token with a space and the Unit Separator ASCII control character (31) - See the diagram below
* <ul>
* <li>The spaces are included so that android keyboards can distinguish the chips as different words and provide accurate
* autocorrect suggestions</li>
* </ul>
* </li>
* <li>Replaces each token with a {@link ChipSpan} containing the same text, once the token terminates</li>
* <li>Uses the values passed to {@link #applyConfiguration(Editable, ChipConfiguration)} to configure any ChipSpans that get created</li>
* </ul>
* Each terminated token will therefore look like the following (this is what will be returned from {@link #terminateToken(CharSequence, Object)}):
* <pre>
* -----------------------------------------------------------
* | SpannableString |
* | ---------------------------------------------------- |
* | | ChipSpan | |
* | | | |
* | | space separator text separator space | |
* | | | |
* | ---------------------------------------------------- |
* -----------------------------------------------------------
* </pre>
*
* @see ChipSpan
*/
public class SpanChipTokenizer<C extends Chip> 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<C> mChipCreator;
@NonNull
private Class<C> mChipClass;
private Comparator<Pair<Integer, Integer>> mReverseTokenIndexesSorter = new Comparator<Pair<Integer, Integer>>() {
@Override
public int compare(Pair<Integer, Integer> lhs, Pair<Integer, Integer> rhs) {
return rhs.first - lhs.first;
}
};
public SpanChipTokenizer(Context context, @NonNull ChipCreator<C> chipCreator, @NonNull Class<C> 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<Pair<Integer, Integer>> findAllTokens(CharSequence text) {
List<Pair<Integer, Integer>> 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<Pair<Integer, Integer>> unterminatedTokens = findAllTokens(text);
// Sort in reverse order (so index changes don't affect anything)
Collections.sort(unterminatedTokens, mReverseTokenIndexesSorter);
for (Pair<Integer, Integer> 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);
}
}

View File

@@ -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<Pair<Integer, Integer>> 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;
}
}

View File

@@ -0,0 +1,5 @@
package com.hootsuite.nachos.validator;
public interface IllegalCharacterIdentifier {
boolean isCharacterIllegal(Character c);
}

View File

@@ -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);
}

View File

@@ -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<List<Status>>{
public GetHashtagTimeline(String hashtag, String maxID, String minID, int limit, List<String> containsAny, List<String> containsAll, List<String> 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)

View File

@@ -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<TimelineDefinition>
private final Map<MenuItem, TimelineDefinition> timelineByMenuItem = new HashMap<>();
private final List<ListTimeline> listTimelines = new ArrayList<>();
private final List<Hashtag> hashtags = new ArrayList<>();
private MenuItem addHashtagItem;
public EditTimelinesFragment() {
super(10);
@@ -132,21 +139,34 @@ public class EditTimelinesFragment extends RecyclerFragment<TimelineDefinition>
}
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>
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<TimelineDefinition>
if (updated) UiUtils.restartApp();
}
private boolean setTagListContent(NachoTextView editText, @Nullable List<String> 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<TimelineDefinition> 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<TimelineViewHolder>{
@NonNull
@Override
@@ -256,60 +404,19 @@ public class EditTimelinesFragment extends RecyclerFragment<TimelineDefinition>
});
}
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);
}
}

View File

@@ -35,7 +35,11 @@ import me.grishka.appkit.utils.V;
public class HashtagTimelineFragment extends PinnableStatusListFragment {
private String hashtag;
private List<String> any;
private List<String> all;
private List<String> 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<Status> result){

View File

@@ -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<String> hashtagAny;
private @Nullable List<String> hashtagAll;
private @Nullable List<String> 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<String> getHashtagAny() {
return hashtagAny;
}
@Nullable
public List<String> getHashtagAll() {
return hashtagAll;
}
@Nullable
public List<String> getHashtagNone() {
return hashtagNone;
}
public boolean isHashtagLocalOnly() {
return hashtagLocalOnly;
}
public void setTitle(String title) {
this.title = title == null || title.isBlank() ? null : title;
}
private List<String> sanitizeTagList(List<String> tags) {
return tags.stream()
.map(String::trim)
.filter(str -> !TextUtils.isEmpty(str))
.collect(Collectors.toList());
}
public void setTagOptions(String main, List<String> any, List<String> all, List<String> 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;
}

View File

@@ -0,0 +1,2 @@
package org.joinmastodon.android.ui.text;public class TagEditText {
}