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:
@@ -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;
|
||||
}
|
||||
}
|
||||
1132
mastodon/src/main/java/com/hootsuite/nachos/NachoTextView.java
Normal file
1132
mastodon/src/main/java/com/hootsuite/nachos/NachoTextView.java
Normal file
File diff suppressed because it is too large
Load Diff
29
mastodon/src/main/java/com/hootsuite/nachos/chip/Chip.java
Normal file
29
mastodon/src/main/java/com/hootsuite/nachos/chip/Chip.java
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
510
mastodon/src/main/java/com/hootsuite/nachos/chip/ChipSpan.java
Normal file
510
mastodon/src/main/java/com/hootsuite/nachos/chip/ChipSpan.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.hootsuite.nachos.validator;
|
||||
|
||||
public interface IllegalCharacterIdentifier {
|
||||
boolean isCharacterIllegal(Character c);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
package org.joinmastodon.android.ui.text;public class TagEditText {
|
||||
}
|
||||
4
mastodon/src/main/res/color/chip_material_background.xml
Normal file
4
mastodon/src/main/res/color/chip_material_background.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="#F0F0F0"/>
|
||||
</selector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</selector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="20dp" android:height="20dp" android:viewportWidth="20" android:viewportHeight="20">
|
||||
<path android:pathData="M2 4.5C2 3.12 3.12 2 4.5 2h6C11.88 2 13 3.12 13 4.5V7h2.5C16.88 7 18 8.12 18 9.5v6c0 1.38-1.12 2.5-2.5 2.5h-6C8.12 18 7 16.88 7 15.5V13H4.5C3.12 13 2 11.88 2 10.5v-6zM7 12V9.5C7 8.12 8.12 7 9.5 7H12V4.5C12 3.672 11.328 3 10.5 3h-6C3.672 3 3 3.672 3 4.5v6C3 11.328 3.672 12 4.5 12H7zm1 1v2.5C8 16.328 8.672 17 9.5 17h6c0.828 0 1.5-0.672 1.5-1.5v-6C17 8.672 16.328 8 15.5 8H13v2.5c0 1.38-1.12 2.5-2.5 2.5H8z" android:fillColor="@color/fluent_default_icon_tint"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="20dp" android:height="20dp" android:viewportWidth="20" android:viewportHeight="20">
|
||||
<path android:pathData="M2 4.5C2 3.12 3.12 2 4.5 2h6C11.88 2 13 3.12 13 4.5V7h2.5C16.88 7 18 8.12 18 9.5v6c0 1.38-1.12 2.5-2.5 2.5h-6C8.12 18 7 16.88 7 15.5V13H4.5C3.12 13 2 11.88 2 10.5v-6zm10 0C12 3.672 11.328 3 10.5 3h-6C3.672 3 3 3.672 3 4.5v6C3 11.328 3.672 12 4.5 12H7V9.5c0-0.356 0.074-0.695 0.209-1.001 0.24-0.548 0.67-0.994 1.207-1.253L8.5 7.21C8.805 7.074 9.144 7 9.5 7H12V4.5zm-4 6.207V12h0.293L12 8.293V8h-1.293L8 10.707zm0.017-1.43l1.26-1.26c-0.65 0.097-1.163 0.61-1.26 1.26zM13 8v2.5c0 1.38-1.12 2.5-2.5 2.5H8v2.5C8 16.328 8.672 17 9.5 17h6c0.828 0 1.5-0.672 1.5-1.5v-6C17 8.672 16.328 8 15.5 8H13zm-3.293 4H10.5c0.828 0 1.5-0.672 1.5-1.5V9.707L9.707 12z" android:fillColor="@color/fluent_default_icon_tint"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="20dp" android:height="20dp" android:viewportWidth="20" android:viewportHeight="20">
|
||||
<path android:pathData="M4.5 2C3.12 2 2 3.12 2 4.5v6C2 11.88 3.12 13 4.5 13H7v2.5C7 16.88 8.12 18 9.5 18h6c1.38 0 2.5-1.12 2.5-2.5v-6C18 8.12 16.88 7 15.5 7H13V4.5C13 3.12 11.88 2 10.5 2h-6zM8 9.5C8 8.672 8.672 8 9.5 8h6C16.328 8 17 8.672 17 9.5v6c0 0.828-0.672 1.5-1.5 1.5h-6C8.672 17 8 16.328 8 15.5v-6z" android:fillColor="@color/fluent_default_icon_tint"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="20dp" android:height="20dp" android:viewportWidth="20" android:viewportHeight="20">
|
||||
<path android:pathData="M4.5 2C3.12 2 2 3.12 2 4.5v6C2 11.88 3.12 13 4.5 13H7v2.5C7 16.88 8.12 18 9.5 18h6c1.38 0 2.5-1.12 2.5-2.5v-6C18 8.12 16.88 7 15.5 7H13V4.5C13 3.12 11.88 2 10.5 2h-6z" android:fillColor="@color/fluent_default_icon_tint"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="20dp" android:height="20dp" android:viewportWidth="20" android:viewportHeight="20">
|
||||
<path android:pathData="M4.5 2C3.12 2 2 3.12 2 4.5v6c0 0.818 0.393 1.544 1 2l0.004 0.003c0.038 0.029 0.078 0.056 0.117 0.083 0.344 0.227 0.746 0.372 1.18 0.406h0.007C4.37 12.998 4.435 13 4.5 13H7v2.5c0 0.241 0.034 0.474 0.098 0.695l0.014 0.048c0.101 0.324 0.266 0.62 0.48 0.872 0.246 0.29 0.556 0.523 0.909 0.677l0.026 0.011C8.826 17.93 9.155 18 9.5 18h6c0.073 0 0.145-0.003 0.216-0.01 1.208-0.103 2.171-1.066 2.275-2.274v-0.008C17.997 15.64 18 15.57 18 15.5v-6c0-0.355-0.074-0.693-0.208-1-0.154-0.352-0.387-0.663-0.677-0.908L17.09 7.57c-0.26-0.214-0.563-0.377-0.895-0.473C15.975 7.034 15.741 7 15.5 7H13V4.5c0-0.067-0.003-0.133-0.008-0.2-0.034-0.433-0.179-0.835-0.406-1.179L12.58 3.115C12.554 3.075 12.527 3.037 12.5 3c-0.457-0.607-1.183-1-2.001-1h-6zm6.647 1.146l-8 8C3.052 10.952 3 10.733 3 10.5V9.557L9.557 3H10.5c0.232 0 0.45 0.053 0.647 0.146zm-7.294 8.708l8-8C11.949 4.048 12 4.267 12 4.5v0.793L5.293 12H4.5c-0.231 0-0.45-0.053-0.647-0.146zM12 6.707V7.5c0 0.214 0.135 0.397 0.325 0.468l-4.357 4.357C7.898 12.135 7.714 12 7.5 12H6.707L12 6.707zM13.707 8h1.586L8 15.293v-1.586L13.707 8zm2.697 0.303c0.313 0.237 0.53 0.594 0.584 1.002l-7.682 7.683c-0.409-0.053-0.766-0.27-1.003-0.584l8.101-8.101zM17 10.707v1.586L12.293 17h-1.586L17 10.707zm0 3v1.586L15.293 17h-1.586L17 13.707zM3 8.143V6.557L6.557 3h1.586L3 8.143zm0-3V4.5C3 3.672 3.672 3 4.5 3h0.643L3 5.143z" android:fillColor="@color/fluent_default_icon_tint"/>
|
||||
</vector>
|
||||
@@ -1,22 +1,201 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:orientation="horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
<ImageButton
|
||||
style="?android:editTextStyle"
|
||||
android:id="@+id/button"
|
||||
android:contentDescription="@string/sk_timeline_icon"
|
||||
android:paddingHorizontal="14dp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginStart="24dp"
|
||||
android:layout_marginBottom="16dp" />
|
||||
<org.joinmastodon.android.ui.views.TextInputFrameLayout
|
||||
android:id="@+id/input"
|
||||
android:layout_marginStart="-8dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content" />
|
||||
</LinearLayout>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/button"
|
||||
style="?android:editTextStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:layout_marginStart="24dp"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:contentDescription="@string/sk_timeline_icon"
|
||||
android:paddingHorizontal="14dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/input"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:layout_marginStart="6dp"
|
||||
android:layout_marginEnd="24dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:id="@+id/divider"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="?colorPollVoted" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/advanced"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="24dp"
|
||||
android:layout_marginTop="20dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:minHeight="32dp"
|
||||
android:background="@drawable/bg_inline_button"
|
||||
android:elevation="0dp"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
android:textSize="16sp"
|
||||
android:text="@string/sk_advanced_options_show" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/tag_wrap"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/sk_edit_timeline_tag_main"
|
||||
android:layout_marginStart="24dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:drawableStart="@drawable/ic_fluent_number_symbol_20_filled"
|
||||
android:drawablePadding="12dp"
|
||||
android:drawableTint="?android:textColorSecondary"
|
||||
android:textColor="?android:textColorSecondary" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/tag_main"
|
||||
android:hint="@string/sk_edit_timeline_tag_hint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="24dp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/sk_edit_timeline_tag_any"
|
||||
android:layout_marginStart="24dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:drawableStart="@drawable/ic_fluent_shape_union_20_filled"
|
||||
android:drawablePadding="12dp"
|
||||
android:drawableTint="?android:textColorSecondary"
|
||||
android:textColor="?android:textColorSecondary" />
|
||||
|
||||
<com.hootsuite.nachos.NachoTextView
|
||||
style="@style/Widget.Mastodon.EditText"
|
||||
android:id="@+id/tags_any"
|
||||
android:hint="@string/sk_edit_timeline_tags_hint"
|
||||
android:paddingVertical="8sp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="24dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:chipBackground="?colorAccentLightest"
|
||||
app:chipTextColor="?android:textColorPrimary" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/sk_edit_timeline_tag_all"
|
||||
android:layout_marginStart="24dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:drawableStart="@drawable/ic_fluent_shape_intersect_20_filled"
|
||||
android:drawablePadding="12dp"
|
||||
android:drawableTint="?android:textColorSecondary"
|
||||
android:textColor="?android:textColorSecondary" />
|
||||
|
||||
<com.hootsuite.nachos.NachoTextView
|
||||
style="@style/Widget.Mastodon.EditText"
|
||||
android:id="@+id/tags_all"
|
||||
android:hint="@string/sk_edit_timeline_tags_hint"
|
||||
android:paddingVertical="8sp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="24dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:chipBackground="?colorAccentLightest"
|
||||
app:chipTextColor="?android:textColorPrimary" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/sk_edit_timeline_tag_none"
|
||||
android:layout_marginStart="24dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:drawableStart="@drawable/ic_fluent_shape_subtract_20_filled"
|
||||
android:drawablePadding="16dp"
|
||||
android:drawableTint="?android:textColorSecondary"
|
||||
android:textColor="?android:textColorSecondary" />
|
||||
|
||||
<com.hootsuite.nachos.NachoTextView
|
||||
style="@style/Widget.Mastodon.EditText"
|
||||
android:id="@+id/tags_none"
|
||||
android:hint="@string/sk_edit_timeline_tags_hint"
|
||||
android:paddingVertical="8sp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="24dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:chipBackground="?colorAccentLightest"
|
||||
app:chipTextColor="?android:textColorPrimary" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="24dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
android:textSize="14sp"
|
||||
android:text="@string/sk_edit_timeline_tags_explanation" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/local_only"
|
||||
android:orientation="horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="24dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:minHeight="48dp"
|
||||
android:gravity="center_vertical"
|
||||
android:layoutDirection="locale">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:paddingVertical="8dp"
|
||||
android:textSize="16sp"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
android:drawableStart="@drawable/ic_fluent_eye_24_regular"
|
||||
android:drawableTint="?android:textColorPrimary"
|
||||
android:drawablePadding="16dp"
|
||||
android:text="@string/sk_hashtag_timeline_local_only_switch" />
|
||||
|
||||
<Switch
|
||||
android:id="@+id/local_only_switch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:focusable="false"
|
||||
android:clickable="false"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -50,15 +50,6 @@
|
||||
android:gravity="center_vertical"
|
||||
android:layoutDirection="locale">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/icon"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:tint="?android:textColorPrimary"
|
||||
android:src="@drawable/ic_fluent_rss_24_regular"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -66,6 +57,9 @@
|
||||
android:paddingVertical="8dp"
|
||||
android:textSize="16sp"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
android:drawableStart="@drawable/ic_fluent_rss_24_regular"
|
||||
android:drawableTint="?android:textColorPrimary"
|
||||
android:drawablePadding="16dp"
|
||||
android:text="@string/sk_list_exclusive_switch" />
|
||||
|
||||
<Switch
|
||||
@@ -73,7 +67,6 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:duplicateParentState="true"
|
||||
android:focusable="false"
|
||||
android:clickable="false"/>
|
||||
|
||||
|
||||
12
mastodon/src/main/res/values/nachos_attrs.xml
Normal file
12
mastodon/src/main/res/values/nachos_attrs.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<declare-styleable name="NachoTextView">
|
||||
<attr name="chipHorizontalSpacing" format="reference|dimension" />
|
||||
<attr name="chipBackground" format="reference|color"/>
|
||||
<attr name="chipCornerRadius" format="reference|dimension"/>
|
||||
<attr name="chipTextColor" format="reference|color"/>
|
||||
<attr name="chipTextSize" format="reference|dimension"/>
|
||||
<attr name="chipHeight" format="reference|dimension"/>
|
||||
<attr name="chipVerticalSpacing" format="reference|dimension"/>
|
||||
</declare-styleable>
|
||||
</resources>
|
||||
5
mastodon/src/main/res/values/nachos_colors.xml
Normal file
5
mastodon/src/main/res/values/nachos_colors.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="chip_default_text_color">#DE000000</color>
|
||||
<color name="chip_default_icon_background_color">#517FA4</color>
|
||||
</resources>
|
||||
7
mastodon/src/main/res/values/nachos_dimens.xml
Normal file
7
mastodon/src/main/res/values/nachos_dimens.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<dimen name="chip_default_padding_edge">12dp</dimen>
|
||||
<dimen name="chip_default_padding_between_image">8dp</dimen>
|
||||
<dimen name="chip_default_left_margin">0dp</dimen>
|
||||
<dimen name="chip_default_right_margin">0dp</dimen>
|
||||
</resources>
|
||||
3
mastodon/src/main/res/values/nachos_strings.xml
Normal file
3
mastodon/src/main/res/values/nachos_strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="chip_ellipsis">…</string>
|
||||
</resources>
|
||||
10
mastodon/src/main/res/values/nachos_styles.xml
Normal file
10
mastodon/src/main/res/values/nachos_styles.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<style name="DefaultChipSuggestionTextView">
|
||||
<item name="chipHorizontalSpacing">6dp</item>
|
||||
<item name="chipBackground">@color/chip_material_background</item>
|
||||
<item name="chipTextSize">14sp</item>
|
||||
<item name="chipHeight">32dp</item>
|
||||
<item name="chipVerticalSpacing">4dp</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -229,7 +229,17 @@
|
||||
<string name="sk_icon_pin">Pin</string>
|
||||
<string name="sk_icon_feed">Feed</string>
|
||||
<string name="sk_edit_timeline">Edit timeline</string>
|
||||
<string name="sk_add_timeline">Add timeline</string>
|
||||
<string name="sk_edit_timelines">Edit timelines</string>
|
||||
<string name="sk_edit_timeline_tag_main">Posts containing hashtag…</string>
|
||||
<string name="sk_edit_timeline_tag_any">…or any of these</string>
|
||||
<string name="sk_edit_timeline_tag_all">…and every one of these</string>
|
||||
<string name="sk_edit_timeline_tag_none">…but none of these</string>
|
||||
<string name="sk_edit_timeline_tag_hint">Enter hashtag…</string>
|
||||
<string name="sk_edit_timeline_tags_hint">Enter hashtags…</string>
|
||||
<string name="sk_edit_timeline_tags_explanation">Please note that the server handles these operations. Combining them might not be supported.</string>
|
||||
<string name="sk_hashtag_timeline_local_only_switch">Show only local posts?</string>
|
||||
<string name="sk_add_timeline_tag_error_empty">Hashtag must not be empty</string>
|
||||
<string name="sk_alt_button">ALT</string>
|
||||
<string name="sk_post_edited">edited</string>
|
||||
<string name="sk_notification_type_update">Edited posts</string>
|
||||
@@ -309,4 +319,6 @@
|
||||
<string name="sk_exclusive_list">Exclusive list</string>
|
||||
<string name="sk_list_exclusive_switch">Make list exclusive</string>
|
||||
<string name="sk_list_exclusive_switch_explanation">Members of an exclusive list will not show up on your home timeline – if your instance supports it.</string>
|
||||
<string name="sk_advanced_options_show">Show advanced options</string>
|
||||
<string name="sk_advanced_options_hide">Hide advanced options</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user