Compose: auto-complete mentions, hashtags, and emojis
This commit is contained in:
parent
098128bcd4
commit
7186b6387f
|
@ -10,7 +10,7 @@ android {
|
||||||
applicationId "org.joinmastodon.android"
|
applicationId "org.joinmastodon.android"
|
||||||
minSdk 23
|
minSdk 23
|
||||||
targetSdk 31
|
targetSdk 31
|
||||||
versionCode 13
|
versionCode 14
|
||||||
versionName "0.1"
|
versionName "0.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
package org.joinmastodon.android.api.requests.search;
|
||||||
|
|
||||||
|
import org.joinmastodon.android.api.MastodonAPIRequest;
|
||||||
|
import org.joinmastodon.android.model.SearchResults;
|
||||||
|
|
||||||
|
public class GetSearchResults extends MastodonAPIRequest<SearchResults>{
|
||||||
|
public GetSearchResults(String query, Type type){
|
||||||
|
super(HttpMethod.GET, "/search", SearchResults.class);
|
||||||
|
addQueryParameter("q", query);
|
||||||
|
if(type!=null)
|
||||||
|
addQueryParameter("type", type.name().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String getPathPrefix(){
|
||||||
|
return "/api/v2";
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Type{
|
||||||
|
ACCOUNTS,
|
||||||
|
HASHTAGS,
|
||||||
|
STATUSES
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,7 +3,6 @@ package org.joinmastodon.android.fragments;
|
||||||
import android.animation.ObjectAnimator;
|
import android.animation.ObjectAnimator;
|
||||||
import android.annotation.SuppressLint;
|
import android.annotation.SuppressLint;
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
import android.app.ProgressDialog;
|
|
||||||
import android.content.ClipData;
|
import android.content.ClipData;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.res.ColorStateList;
|
import android.content.res.ColorStateList;
|
||||||
|
@ -19,17 +18,17 @@ import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.Parcelable;
|
import android.os.Parcelable;
|
||||||
import android.text.Editable;
|
import android.text.Editable;
|
||||||
|
import android.text.Layout;
|
||||||
import android.text.SpannableStringBuilder;
|
import android.text.SpannableStringBuilder;
|
||||||
|
import android.text.Spanned;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
import android.text.TextWatcher;
|
import android.text.TextWatcher;
|
||||||
import android.text.style.ImageSpan;
|
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.Gravity;
|
import android.view.Gravity;
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
import android.view.Menu;
|
import android.view.Menu;
|
||||||
import android.view.MenuInflater;
|
import android.view.MenuInflater;
|
||||||
import android.view.MenuItem;
|
import android.view.MenuItem;
|
||||||
import android.view.MotionEvent;
|
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
import android.view.ViewOutlineProvider;
|
import android.view.ViewOutlineProvider;
|
||||||
|
@ -66,25 +65,29 @@ import org.joinmastodon.android.model.EmojiCategory;
|
||||||
import org.joinmastodon.android.model.Mention;
|
import org.joinmastodon.android.model.Mention;
|
||||||
import org.joinmastodon.android.model.Status;
|
import org.joinmastodon.android.model.Status;
|
||||||
import org.joinmastodon.android.model.StatusPrivacy;
|
import org.joinmastodon.android.model.StatusPrivacy;
|
||||||
|
import org.joinmastodon.android.ui.ComposeAutocompleteViewController;
|
||||||
import org.joinmastodon.android.ui.CustomEmojiPopupKeyboard;
|
import org.joinmastodon.android.ui.CustomEmojiPopupKeyboard;
|
||||||
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
|
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
|
||||||
import org.joinmastodon.android.ui.PopupKeyboard;
|
import org.joinmastodon.android.ui.PopupKeyboard;
|
||||||
import org.joinmastodon.android.ui.drawables.SpoilerStripesDrawable;
|
import org.joinmastodon.android.ui.drawables.SpoilerStripesDrawable;
|
||||||
|
import org.joinmastodon.android.ui.text.ComposeAutocompleteSpan;
|
||||||
|
import org.joinmastodon.android.ui.text.ComposeHashtagOrMentionSpan;
|
||||||
import org.joinmastodon.android.ui.text.HtmlParser;
|
import org.joinmastodon.android.ui.text.HtmlParser;
|
||||||
import org.joinmastodon.android.ui.text.SpacerSpan;
|
import org.joinmastodon.android.ui.text.SpacerSpan;
|
||||||
import org.joinmastodon.android.ui.utils.SimpleTextWatcher;
|
import org.joinmastodon.android.ui.utils.SimpleTextWatcher;
|
||||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||||
import org.joinmastodon.android.ui.views.ComposeMediaLayout;
|
import org.joinmastodon.android.ui.views.ComposeMediaLayout;
|
||||||
import org.joinmastodon.android.ui.views.ReorderableLinearLayout;
|
import org.joinmastodon.android.ui.views.ReorderableLinearLayout;
|
||||||
|
import org.joinmastodon.android.ui.views.SelectionListenerEditText;
|
||||||
import org.joinmastodon.android.ui.views.SizeListenerLinearLayout;
|
import org.joinmastodon.android.ui.views.SizeListenerLinearLayout;
|
||||||
import org.parceler.Parcel;
|
import org.parceler.Parcel;
|
||||||
import org.parceler.Parcels;
|
import org.parceler.Parcels;
|
||||||
|
|
||||||
import java.lang.reflect.Field;
|
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@ -97,7 +100,7 @@ import me.grishka.appkit.imageloader.ViewImageLoader;
|
||||||
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
|
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
|
||||||
import me.grishka.appkit.utils.V;
|
import me.grishka.appkit.utils.V;
|
||||||
|
|
||||||
public class ComposeFragment extends ToolbarFragment implements OnBackPressedListener{
|
public class ComposeFragment extends ToolbarFragment implements OnBackPressedListener, SelectionListenerEditText.SelectionListener{
|
||||||
|
|
||||||
private static final int MEDIA_RESULT=717;
|
private static final int MEDIA_RESULT=717;
|
||||||
private static final int IMAGE_DESCRIPTION_RESULT=363;
|
private static final int IMAGE_DESCRIPTION_RESULT=363;
|
||||||
|
@ -106,6 +109,10 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
|
||||||
|
|
||||||
private static final Pattern MENTION_PATTERN=Pattern.compile("(^|[^\\/\\w])@(([a-z0-9_]+)@[a-z0-9\\.\\-]+[a-z0-9]+)", Pattern.CASE_INSENSITIVE);
|
private static final Pattern MENTION_PATTERN=Pattern.compile("(^|[^\\/\\w])@(([a-z0-9_]+)@[a-z0-9\\.\\-]+[a-z0-9]+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
|
||||||
|
// from https://github.com/mastodon/mastodon-ios/blob/main/Mastodon/Helper/MastodonRegex.swift
|
||||||
|
private static final Pattern AUTO_COMPLETE_PATTERN=Pattern.compile("(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))|(^\\B:|\\s:)([a-zA-Z0-9_]+)");
|
||||||
|
private static final Pattern HIGHLIGHT_PATTERN=Pattern.compile("(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))");
|
||||||
|
|
||||||
private static final String VALID_URL_PATTERN_STRING =
|
private static final String VALID_URL_PATTERN_STRING =
|
||||||
"(" + // $1 total match
|
"(" + // $1 total match
|
||||||
"(" + Regex.URL_VALID_PRECEDING_CHARS + ")" + // $2 Preceding character
|
"(" + Regex.URL_VALID_PRECEDING_CHARS + ")" + // $2 Preceding character
|
||||||
|
@ -130,7 +137,7 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
|
||||||
private Account self;
|
private Account self;
|
||||||
private String instanceDomain;
|
private String instanceDomain;
|
||||||
|
|
||||||
private EditText mainEditText;
|
private SelectionListenerEditText mainEditText;
|
||||||
private TextView charCounter;
|
private TextView charCounter;
|
||||||
private String accountID;
|
private String accountID;
|
||||||
private int charCount, charLimit, trimmedCharCount;
|
private int charCount, charLimit, trimmedCharCount;
|
||||||
|
@ -163,6 +170,9 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
|
||||||
private View sendingOverlay;
|
private View sendingOverlay;
|
||||||
private WindowManager wm;
|
private WindowManager wm;
|
||||||
private StatusPrivacy statusVisibility=StatusPrivacy.PUBLIC;
|
private StatusPrivacy statusVisibility=StatusPrivacy.PUBLIC;
|
||||||
|
private ComposeAutocompleteSpan currentAutocompleteSpan;
|
||||||
|
private FrameLayout mainEditTextWrap;
|
||||||
|
private ComposeAutocompleteViewController autocompleteViewController;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreate(Bundle savedInstanceState){
|
public void onCreate(Bundle savedInstanceState){
|
||||||
|
@ -200,6 +210,7 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
|
||||||
|
|
||||||
View view=inflater.inflate(R.layout.fragment_compose, container, false);
|
View view=inflater.inflate(R.layout.fragment_compose, container, false);
|
||||||
mainEditText=view.findViewById(R.id.toot_text);
|
mainEditText=view.findViewById(R.id.toot_text);
|
||||||
|
mainEditTextWrap=view.findViewById(R.id.toot_text_wrap);
|
||||||
charCounter=view.findViewById(R.id.char_counter);
|
charCounter=view.findViewById(R.id.char_counter);
|
||||||
charCounter.setText(String.valueOf(charLimit));
|
charCounter.setText(String.valueOf(charLimit));
|
||||||
|
|
||||||
|
@ -295,6 +306,12 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
|
||||||
}
|
}
|
||||||
updateVisibilityIcon();
|
updateVisibilityIcon();
|
||||||
|
|
||||||
|
autocompleteViewController=new ComposeAutocompleteViewController(getActivity(), accountID);
|
||||||
|
autocompleteViewController.setCompletionSelectedListener(this::onAutocompleteOptionSelected);
|
||||||
|
View autocompleteView=autocompleteViewController.getView();
|
||||||
|
autocompleteView.setVisibility(View.GONE);
|
||||||
|
mainEditTextWrap.addView(autocompleteView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(178), Gravity.TOP));
|
||||||
|
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -336,6 +353,7 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
|
||||||
imm.showSoftInput(mainEditText, 0);
|
imm.showSoftInput(mainEditText, 0);
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
|
mainEditText.setSelectionListener(this);
|
||||||
mainEditText.addTextChangedListener(new TextWatcher(){
|
mainEditText.addTextChangedListener(new TextWatcher(){
|
||||||
@Override
|
@Override
|
||||||
public void beforeTextChanged(CharSequence s, int start, int count, int after){
|
public void beforeTextChanged(CharSequence s, int start, int count, int after){
|
||||||
|
@ -344,7 +362,49 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onTextChanged(CharSequence s, int start, int before, int count){
|
public void onTextChanged(CharSequence s, int start, int before, int count){
|
||||||
|
// offset one char back to catch an already typed '@' or '#' or ':'
|
||||||
|
int realStart=start;
|
||||||
|
start=Math.max(0, start-1);
|
||||||
|
CharSequence changedText=s.subSequence(start, realStart+count);
|
||||||
|
String raw=changedText.toString();
|
||||||
|
Editable editable=(Editable) s;
|
||||||
|
// 1. find mentions, hashtags, and emoji shortcodes in any freshly inserted text, and put spans over them
|
||||||
|
if(raw.contains("@") || raw.contains("#") || raw.contains(":")){
|
||||||
|
Matcher matcher=AUTO_COMPLETE_PATTERN.matcher(changedText);
|
||||||
|
while(matcher.find()){
|
||||||
|
if(editable.getSpans(start+matcher.start(), start+matcher.end(), ComposeAutocompleteSpan.class).length>0)
|
||||||
|
continue;
|
||||||
|
Log.w("11", "found: "+matcher);
|
||||||
|
ComposeAutocompleteSpan span;
|
||||||
|
if(TextUtils.isEmpty(matcher.group(4))){ // not an emoji
|
||||||
|
span=new ComposeHashtagOrMentionSpan();
|
||||||
|
}else{
|
||||||
|
span=new ComposeAutocompleteSpan();
|
||||||
|
}
|
||||||
|
editable.setSpan(span, start+matcher.start(), start+matcher.end(), Spanned.SPAN_EXCLUSIVE_INCLUSIVE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. go over existing spans in the affected range, adjust end offsets and remove no longer valid spans
|
||||||
|
ComposeAutocompleteSpan[] spans=editable.getSpans(realStart, realStart+count, ComposeAutocompleteSpan.class);
|
||||||
|
for(ComposeAutocompleteSpan span:spans){
|
||||||
|
int spanStart=editable.getSpanStart(span);
|
||||||
|
int spanEnd=editable.getSpanEnd(span);
|
||||||
|
if(spanStart==spanEnd){ // empty, remove
|
||||||
|
editable.removeSpan(span);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
char firstChar=editable.charAt(spanStart);
|
||||||
|
String spanText=s.subSequence(spanStart, spanEnd).toString();
|
||||||
|
if(firstChar=='@' || firstChar=='#'){
|
||||||
|
Matcher matcher=HIGHLIGHT_PATTERN.matcher(spanText);
|
||||||
|
if(!matcher.find()){ // invalid mention, remove
|
||||||
|
editable.removeSpan(span);
|
||||||
|
continue;
|
||||||
|
}else if(matcher.end()+spanStart<spanEnd){ // mention with something at the end, move the end offset
|
||||||
|
editable.setSpan(span, spanStart, spanStart+matcher.end(), Spanned.SPAN_EXCLUSIVE_INCLUSIVE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -933,6 +993,66 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSelectionChanged(int start, int end){
|
||||||
|
if(start==end){
|
||||||
|
ComposeAutocompleteSpan[] spans=mainEditText.getText().getSpans(start, end, ComposeAutocompleteSpan.class);
|
||||||
|
if(spans.length>0){
|
||||||
|
assert spans.length==1;
|
||||||
|
ComposeAutocompleteSpan span=spans[0];
|
||||||
|
if(currentAutocompleteSpan==null && end==mainEditText.getText().getSpanEnd(span)){
|
||||||
|
startAutocomplete(span);
|
||||||
|
}else if(currentAutocompleteSpan!=null){
|
||||||
|
Editable e=mainEditText.getText();
|
||||||
|
String spanText=e.toString().substring(e.getSpanStart(span), e.getSpanEnd(span));
|
||||||
|
autocompleteViewController.setText(spanText);
|
||||||
|
}
|
||||||
|
|
||||||
|
View autocompleteView=autocompleteViewController.getView();
|
||||||
|
Layout layout=mainEditText.getLayout();
|
||||||
|
int line=layout.getLineForOffset(start);
|
||||||
|
int offsetY=layout.getLineBottom(line);
|
||||||
|
FrameLayout.LayoutParams lp=(FrameLayout.LayoutParams) autocompleteView.getLayoutParams();
|
||||||
|
if(lp.topMargin!=offsetY){
|
||||||
|
lp.topMargin=offsetY;
|
||||||
|
mainEditTextWrap.requestLayout();
|
||||||
|
}
|
||||||
|
int offsetX=Math.round(layout.getPrimaryHorizontal(start))+mainEditText.getPaddingLeft();
|
||||||
|
autocompleteViewController.setArrowOffset(offsetX);
|
||||||
|
}else if(currentAutocompleteSpan!=null){
|
||||||
|
finishAutocomplete();
|
||||||
|
}
|
||||||
|
}else if(currentAutocompleteSpan!=null){
|
||||||
|
finishAutocomplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startAutocomplete(ComposeAutocompleteSpan span){
|
||||||
|
currentAutocompleteSpan=span;
|
||||||
|
Editable e=mainEditText.getText();
|
||||||
|
String spanText=e.toString().substring(e.getSpanStart(span), e.getSpanEnd(span));
|
||||||
|
autocompleteViewController.setText(spanText);
|
||||||
|
View autocompleteView=autocompleteViewController.getView();
|
||||||
|
autocompleteView.setVisibility(View.VISIBLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void finishAutocomplete(){
|
||||||
|
if(currentAutocompleteSpan==null)
|
||||||
|
return;
|
||||||
|
autocompleteViewController.setText(null);
|
||||||
|
currentAutocompleteSpan=null;
|
||||||
|
autocompleteViewController.getView().setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onAutocompleteOptionSelected(String text){
|
||||||
|
Editable e=mainEditText.getText();
|
||||||
|
int start=e.getSpanStart(currentAutocompleteSpan);
|
||||||
|
int end=e.getSpanEnd(currentAutocompleteSpan);
|
||||||
|
e.replace(start, end, text+" ");
|
||||||
|
mainEditText.setSelection(start+text.length()+1);
|
||||||
|
finishAutocomplete();
|
||||||
|
}
|
||||||
|
|
||||||
@Parcel
|
@Parcel
|
||||||
static class DraftMediaAttachment{
|
static class DraftMediaAttachment{
|
||||||
public Attachment serverAttachment;
|
public Attachment serverAttachment;
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
package org.joinmastodon.android.model;
|
||||||
|
|
||||||
|
import org.joinmastodon.android.api.ObjectValidationException;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class SearchResults extends BaseModel{
|
||||||
|
public List<Account> accounts;
|
||||||
|
public List<Status> statuses;
|
||||||
|
public List<Hashtag> hashtags;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void postprocess() throws ObjectValidationException{
|
||||||
|
super.postprocess();
|
||||||
|
if(accounts!=null){
|
||||||
|
for(Account acc:accounts)
|
||||||
|
acc.postprocess();
|
||||||
|
}
|
||||||
|
if(statuses!=null){
|
||||||
|
for(Status s:statuses)
|
||||||
|
s.postprocess();
|
||||||
|
}
|
||||||
|
if(hashtags!=null){
|
||||||
|
for(Hashtag t:hashtags)
|
||||||
|
t.postprocess();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,471 @@
|
||||||
|
package org.joinmastodon.android.ui;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.graphics.Typeface;
|
||||||
|
import android.graphics.drawable.Drawable;
|
||||||
|
import android.text.TextUtils;
|
||||||
|
import android.view.Gravity;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.FrameLayout;
|
||||||
|
import android.widget.ImageView;
|
||||||
|
import android.widget.ProgressBar;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import org.joinmastodon.android.R;
|
||||||
|
import org.joinmastodon.android.api.requests.search.GetSearchResults;
|
||||||
|
import org.joinmastodon.android.api.session.AccountSessionManager;
|
||||||
|
import org.joinmastodon.android.model.Account;
|
||||||
|
import org.joinmastodon.android.model.Emoji;
|
||||||
|
import org.joinmastodon.android.model.EmojiCategory;
|
||||||
|
import org.joinmastodon.android.model.Hashtag;
|
||||||
|
import org.joinmastodon.android.model.SearchResults;
|
||||||
|
import org.joinmastodon.android.ui.drawables.ComposeAutocompleteBackgroundDrawable;
|
||||||
|
import org.joinmastodon.android.ui.text.HtmlParser;
|
||||||
|
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
|
||||||
|
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.BiPredicate;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.recyclerview.widget.DiffUtil;
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
import me.grishka.appkit.api.APIRequest;
|
||||||
|
import me.grishka.appkit.api.Callback;
|
||||||
|
import me.grishka.appkit.api.ErrorResponse;
|
||||||
|
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
|
||||||
|
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
|
||||||
|
import me.grishka.appkit.imageloader.ListImageLoaderWrapper;
|
||||||
|
import me.grishka.appkit.imageloader.RecyclerViewDelegate;
|
||||||
|
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
|
||||||
|
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
|
||||||
|
import me.grishka.appkit.utils.BindableViewHolder;
|
||||||
|
import me.grishka.appkit.utils.V;
|
||||||
|
import me.grishka.appkit.views.UsableRecyclerView;
|
||||||
|
|
||||||
|
public class ComposeAutocompleteViewController{
|
||||||
|
private Activity activity;
|
||||||
|
private String accountID;
|
||||||
|
private FrameLayout contentView;
|
||||||
|
private UsableRecyclerView list;
|
||||||
|
private ListImageLoaderWrapper imgLoader;
|
||||||
|
private ProgressBar progress;
|
||||||
|
private List<WrappedAccount> users=Collections.emptyList();
|
||||||
|
private List<Hashtag> hashtags=Collections.emptyList();
|
||||||
|
private List<WrappedEmoji> emojis=Collections.emptyList();
|
||||||
|
private Mode mode;
|
||||||
|
private APIRequest currentRequest;
|
||||||
|
private Runnable usersDebouncer=this::doSearchUsers, hashtagsDebouncer=this::doSearchHashtags;
|
||||||
|
private String lastText;
|
||||||
|
private ComposeAutocompleteBackgroundDrawable background;
|
||||||
|
private boolean listIsHidden=true;
|
||||||
|
|
||||||
|
private UsersAdapter usersAdapter;
|
||||||
|
private HashtagsAdapter hashtagsAdapter;
|
||||||
|
private EmojisAdapter emojisAdapter;
|
||||||
|
|
||||||
|
private Consumer<String> completionSelectedListener;
|
||||||
|
|
||||||
|
private DividerItemDecoration usersDividers, hashtagsDividers;
|
||||||
|
|
||||||
|
public ComposeAutocompleteViewController(Activity activity, String accountID){
|
||||||
|
this.activity=activity;
|
||||||
|
this.accountID=accountID;
|
||||||
|
background=new ComposeAutocompleteBackgroundDrawable(UiUtils.getThemeColor(activity, android.R.attr.colorBackground));
|
||||||
|
contentView=new FrameLayout(activity);
|
||||||
|
contentView.setBackground(background);
|
||||||
|
|
||||||
|
list=new UsableRecyclerView(activity);
|
||||||
|
list.setLayoutManager(new LinearLayoutManager(activity));
|
||||||
|
list.setItemAnimator(new BetterItemAnimator());
|
||||||
|
list.setVisibility(View.GONE);
|
||||||
|
contentView.addView(list, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
|
||||||
|
|
||||||
|
progress=new ProgressBar(activity);
|
||||||
|
FrameLayout.LayoutParams progressLP=new FrameLayout.LayoutParams(V.dp(48), V.dp(48), Gravity.CENTER_HORIZONTAL|Gravity.TOP);
|
||||||
|
progressLP.topMargin=V.dp(16);
|
||||||
|
contentView.addView(progress, progressLP);
|
||||||
|
|
||||||
|
usersDividers=new DividerItemDecoration(activity, R.attr.colorPollVoted, 1, 72, 16);
|
||||||
|
hashtagsDividers=new DividerItemDecoration(activity, R.attr.colorPollVoted, 1, 16, 16);
|
||||||
|
|
||||||
|
imgLoader=new ListImageLoaderWrapper(activity, list, new RecyclerViewDelegate(list), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setText(String text){
|
||||||
|
if(mode==Mode.USERS){
|
||||||
|
list.removeCallbacks(usersDebouncer);
|
||||||
|
}else if(mode==Mode.HASHTAGS){
|
||||||
|
list.removeCallbacks(hashtagsDebouncer);
|
||||||
|
}
|
||||||
|
if(text==null)
|
||||||
|
return;
|
||||||
|
Mode prevMode=mode;
|
||||||
|
if(currentRequest!=null){
|
||||||
|
currentRequest.cancel();
|
||||||
|
currentRequest=null;
|
||||||
|
}
|
||||||
|
mode=switch(text.charAt(0)){
|
||||||
|
case '@' -> Mode.USERS;
|
||||||
|
case '#' -> Mode.HASHTAGS;
|
||||||
|
case ':' -> Mode.EMOJIS;
|
||||||
|
default -> throw new IllegalStateException("Unexpected value: "+text.charAt(0));
|
||||||
|
};
|
||||||
|
if(prevMode!=mode){
|
||||||
|
list.setAdapter(switch(mode){
|
||||||
|
case USERS -> {
|
||||||
|
if(usersAdapter==null)
|
||||||
|
usersAdapter=new UsersAdapter();
|
||||||
|
yield usersAdapter;
|
||||||
|
}
|
||||||
|
case EMOJIS -> {
|
||||||
|
if(emojisAdapter==null)
|
||||||
|
emojisAdapter=new EmojisAdapter();
|
||||||
|
yield emojisAdapter;
|
||||||
|
}
|
||||||
|
case HASHTAGS -> {
|
||||||
|
if(hashtagsAdapter==null)
|
||||||
|
hashtagsAdapter=new HashtagsAdapter();
|
||||||
|
yield hashtagsAdapter;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if(mode!=Mode.EMOJIS){
|
||||||
|
list.setVisibility(View.GONE);
|
||||||
|
progress.setVisibility(View.VISIBLE);
|
||||||
|
listIsHidden=true;
|
||||||
|
}else if(listIsHidden){
|
||||||
|
list.setVisibility(View.VISIBLE);
|
||||||
|
progress.setVisibility(View.GONE);
|
||||||
|
listIsHidden=false;
|
||||||
|
}
|
||||||
|
if((prevMode==Mode.HASHTAGS)!=(mode==Mode.HASHTAGS) || prevMode==null){
|
||||||
|
if(prevMode!=null)
|
||||||
|
list.removeItemDecoration(prevMode==Mode.HASHTAGS ? hashtagsDividers : usersDividers);
|
||||||
|
list.addItemDecoration(mode==Mode.HASHTAGS ? hashtagsDividers : usersDividers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastText=text;
|
||||||
|
if(mode==Mode.USERS){
|
||||||
|
list.postDelayed(usersDebouncer, 300);
|
||||||
|
}else if(mode==Mode.HASHTAGS){
|
||||||
|
list.postDelayed(hashtagsDebouncer, 300);
|
||||||
|
}else if(mode==Mode.EMOJIS){
|
||||||
|
String _text=text.substring(1); // remove ':'
|
||||||
|
List<WrappedEmoji> oldList=emojis;
|
||||||
|
emojis=AccountSessionManager.getInstance()
|
||||||
|
.getCustomEmojis(AccountSessionManager.getInstance().getAccount(accountID).domain)
|
||||||
|
.stream()
|
||||||
|
.flatMap(ec->ec.emojis.stream())
|
||||||
|
.filter(e->e.visibleInPicker && e.shortcode.startsWith(_text))
|
||||||
|
.map(WrappedEmoji::new)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
updateList(oldList, emojis, emojisAdapter, (e1, e2)->e1.emoji.shortcode.equals(e2.emoji.shortcode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCompletionSelectedListener(Consumer<String> completionSelectedListener){
|
||||||
|
this.completionSelectedListener=completionSelectedListener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setArrowOffset(int offset){
|
||||||
|
background.setArrowOffset(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
public View getView(){
|
||||||
|
return contentView;
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> void updateList(List<T> oldList, List<T> newList, RecyclerView.Adapter<?> adapter, BiPredicate<T, T> areItemsSame){
|
||||||
|
DiffUtil.calculateDiff(new DiffUtil.Callback(){
|
||||||
|
@Override
|
||||||
|
public int getOldListSize(){
|
||||||
|
return oldList.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getNewListSize(){
|
||||||
|
return newList.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean areItemsTheSame(int oldItemPosition, int newItemPosition){
|
||||||
|
return areItemsSame.test(oldList.get(oldItemPosition), newList.get(newItemPosition));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean areContentsTheSame(int oldItemPosition, int newItemPosition){
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}).dispatchUpdatesTo(adapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doSearchUsers(){
|
||||||
|
currentRequest=new GetSearchResults(lastText, GetSearchResults.Type.ACCOUNTS)
|
||||||
|
.setCallback(new Callback<>(){
|
||||||
|
@Override
|
||||||
|
public void onSuccess(SearchResults result){
|
||||||
|
currentRequest=null;
|
||||||
|
List<WrappedAccount> oldList=users;
|
||||||
|
users=result.accounts.stream().map(WrappedAccount::new).collect(Collectors.toList());
|
||||||
|
updateList(oldList, users, usersAdapter, (a1, a2)->a1.account.id.equals(a2.account.id));
|
||||||
|
if(listIsHidden){
|
||||||
|
listIsHidden=false;
|
||||||
|
V.setVisibilityAnimated(list, View.VISIBLE);
|
||||||
|
V.setVisibilityAnimated(progress, View.GONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(ErrorResponse error){
|
||||||
|
currentRequest=null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.exec(accountID);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doSearchHashtags(){
|
||||||
|
currentRequest=new GetSearchResults(lastText, GetSearchResults.Type.HASHTAGS)
|
||||||
|
.setCallback(new Callback<>(){
|
||||||
|
@Override
|
||||||
|
public void onSuccess(SearchResults result){
|
||||||
|
currentRequest=null;
|
||||||
|
List<Hashtag> oldList=hashtags;
|
||||||
|
hashtags=result.hashtags;
|
||||||
|
updateList(oldList, hashtags, hashtagsAdapter, (t1, t2)->t1.name.equals(t2.name));
|
||||||
|
if(listIsHidden){
|
||||||
|
listIsHidden=false;
|
||||||
|
V.setVisibilityAnimated(list, View.VISIBLE);
|
||||||
|
V.setVisibilityAnimated(progress, View.GONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(ErrorResponse error){
|
||||||
|
currentRequest=null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.exec(accountID);
|
||||||
|
}
|
||||||
|
|
||||||
|
private class UsersAdapter extends UsableRecyclerView.Adapter<UserViewHolder> implements ImageLoaderRecyclerAdapter{
|
||||||
|
public UsersAdapter(){
|
||||||
|
super(imgLoader);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public UserViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
|
||||||
|
return new UserViewHolder();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount(){
|
||||||
|
return users.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(UserViewHolder holder, int position){
|
||||||
|
holder.bind(users.get(position));
|
||||||
|
super.onBindViewHolder(holder, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getImageCountForItem(int position){
|
||||||
|
return 1+users.get(position).emojiHelper.getImageCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ImageLoaderRequest getImageRequest(int position, int image){
|
||||||
|
WrappedAccount a=users.get(position);
|
||||||
|
if(image==0)
|
||||||
|
return a.avaRequest;
|
||||||
|
return a.emojiHelper.getImageRequest(image-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class UserViewHolder extends BindableViewHolder<WrappedAccount> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{
|
||||||
|
private final ImageView ava;
|
||||||
|
private final TextView name, username;
|
||||||
|
|
||||||
|
private UserViewHolder(){
|
||||||
|
super(activity, R.layout.item_autocomplete_user, list);
|
||||||
|
ava=findViewById(R.id.photo);
|
||||||
|
name=findViewById(R.id.name);
|
||||||
|
username=findViewById(R.id.username);
|
||||||
|
ava.setOutlineProvider(OutlineProviders.roundedRect(12));
|
||||||
|
ava.setClipToOutline(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBind(WrappedAccount item){
|
||||||
|
name.setText(item.parsedName);
|
||||||
|
username.setText("@"+item.account.acct);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClick(){
|
||||||
|
completionSelectedListener.accept("@"+item.account.acct);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setImage(int index, Drawable image){
|
||||||
|
if(index==0){
|
||||||
|
ava.setImageDrawable(image);
|
||||||
|
}else{
|
||||||
|
item.emojiHelper.setImageDrawable(index-1, image);
|
||||||
|
name.invalidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void clearImage(int index){
|
||||||
|
setImage(index, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class HashtagsAdapter extends RecyclerView.Adapter<HashtagViewHolder>{
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public HashtagViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
|
||||||
|
return new HashtagViewHolder();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(@NonNull HashtagViewHolder holder, int position){
|
||||||
|
holder.bind(hashtags.get(position));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount(){
|
||||||
|
return hashtags.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class HashtagViewHolder extends BindableViewHolder<Hashtag> implements UsableRecyclerView.Clickable{
|
||||||
|
private final TextView text;
|
||||||
|
|
||||||
|
private HashtagViewHolder(){
|
||||||
|
super(new TextView(activity));
|
||||||
|
text=(TextView) itemView;
|
||||||
|
text.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(48)));
|
||||||
|
text.setTextAppearance(R.style.m3_title_medium);
|
||||||
|
text.setTypeface(Typeface.DEFAULT);
|
||||||
|
text.setSingleLine();
|
||||||
|
text.setEllipsize(TextUtils.TruncateAt.END);
|
||||||
|
text.setGravity(Gravity.CENTER_VERTICAL);
|
||||||
|
text.setPadding(V.dp(16), 0, V.dp(16), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBind(Hashtag item){
|
||||||
|
text.setText("#"+item.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClick(){
|
||||||
|
completionSelectedListener.accept("#"+item.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class EmojisAdapter extends UsableRecyclerView.Adapter<EmojiViewHolder> implements ImageLoaderRecyclerAdapter{
|
||||||
|
public EmojisAdapter(){
|
||||||
|
super(imgLoader);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public EmojiViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
|
||||||
|
return new EmojiViewHolder();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount(){
|
||||||
|
return emojis.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(EmojiViewHolder holder, int position){
|
||||||
|
holder.bind(emojis.get(position));
|
||||||
|
super.onBindViewHolder(holder, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getImageCountForItem(int position){
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ImageLoaderRequest getImageRequest(int position, int image){
|
||||||
|
return emojis.get(position).request;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class EmojiViewHolder extends BindableViewHolder<WrappedEmoji> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{
|
||||||
|
private final ImageView ava;
|
||||||
|
private final TextView name;
|
||||||
|
|
||||||
|
private EmojiViewHolder(){
|
||||||
|
super(activity, R.layout.item_autocomplete_user, list);
|
||||||
|
ava=findViewById(R.id.photo);
|
||||||
|
name=findViewById(R.id.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setImage(int index, Drawable image){
|
||||||
|
ava.setImageDrawable(image);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void clearImage(int index){
|
||||||
|
ava.setImageDrawable(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBind(WrappedEmoji item){
|
||||||
|
name.setText(":"+item.emoji.shortcode+":");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClick(){
|
||||||
|
completionSelectedListener.accept(":"+item.emoji.shortcode+":");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class WrappedAccount{
|
||||||
|
private Account account;
|
||||||
|
private CharSequence parsedName;
|
||||||
|
private CustomEmojiHelper emojiHelper;
|
||||||
|
private ImageLoaderRequest avaRequest;
|
||||||
|
|
||||||
|
public WrappedAccount(Account account){
|
||||||
|
this.account=account;
|
||||||
|
parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis);
|
||||||
|
emojiHelper=new CustomEmojiHelper();
|
||||||
|
emojiHelper.setText(parsedName);
|
||||||
|
avaRequest=new UrlImageLoaderRequest(account.avatar, V.dp(50), V.dp(50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class WrappedEmoji{
|
||||||
|
private Emoji emoji;
|
||||||
|
private ImageLoaderRequest request;
|
||||||
|
|
||||||
|
public WrappedEmoji(Emoji emoji){
|
||||||
|
this.emoji=emoji;
|
||||||
|
request=new UrlImageLoaderRequest(emoji.url, V.dp(44), V.dp(44));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Mode{
|
||||||
|
USERS,
|
||||||
|
HASHTAGS,
|
||||||
|
EMOJIS
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,84 @@
|
||||||
|
package org.joinmastodon.android.ui.drawables;
|
||||||
|
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.ColorFilter;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.graphics.Path;
|
||||||
|
import android.graphics.PixelFormat;
|
||||||
|
import android.graphics.Rect;
|
||||||
|
import android.graphics.drawable.Drawable;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import me.grishka.appkit.utils.V;
|
||||||
|
|
||||||
|
public class ComposeAutocompleteBackgroundDrawable extends Drawable{
|
||||||
|
private Path path=new Path();
|
||||||
|
private Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private int fillColor, arrowOffset;
|
||||||
|
|
||||||
|
public ComposeAutocompleteBackgroundDrawable(int fillColor){
|
||||||
|
this.fillColor=fillColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void draw(@NonNull Canvas canvas){
|
||||||
|
Rect bounds=getBounds();
|
||||||
|
canvas.save();
|
||||||
|
canvas.translate(bounds.left, bounds.top);
|
||||||
|
paint.setColor(0x80000000);
|
||||||
|
canvas.drawPath(path, paint);
|
||||||
|
canvas.translate(0, V.dp(1));
|
||||||
|
paint.setColor(fillColor);
|
||||||
|
canvas.drawPath(path, paint);
|
||||||
|
int arrowSize=V.dp(10);
|
||||||
|
canvas.drawRect(0, arrowSize, bounds.width(), bounds.height(), paint);
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setAlpha(int alpha){
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setColorFilter(@Nullable ColorFilter colorFilter){
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getOpacity(){
|
||||||
|
return PixelFormat.TRANSLUCENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setArrowOffset(int offset){
|
||||||
|
arrowOffset=offset;
|
||||||
|
updatePath();
|
||||||
|
invalidateSelf();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onBoundsChange(Rect bounds){
|
||||||
|
super.onBoundsChange(bounds);
|
||||||
|
updatePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean getPadding(@NonNull Rect padding){
|
||||||
|
padding.top=V.dp(11);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updatePath(){
|
||||||
|
path.rewind();
|
||||||
|
int arrowSize=V.dp(10);
|
||||||
|
path.moveTo(0, arrowSize*2);
|
||||||
|
path.lineTo(0, arrowSize);
|
||||||
|
path.lineTo(arrowOffset-arrowSize, arrowSize);
|
||||||
|
path.lineTo(arrowOffset, 0);
|
||||||
|
path.lineTo(arrowOffset+arrowSize, arrowSize);
|
||||||
|
path.lineTo(getBounds().width(), arrowSize);
|
||||||
|
path.lineTo(getBounds().width(), arrowSize*2);
|
||||||
|
path.close();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
package org.joinmastodon.android.ui.text;
|
||||||
|
|
||||||
|
import android.text.TextPaint;
|
||||||
|
import android.text.style.CharacterStyle;
|
||||||
|
|
||||||
|
public class ComposeAutocompleteSpan extends CharacterStyle{
|
||||||
|
@Override
|
||||||
|
public void updateDrawState(TextPaint tp){
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
package org.joinmastodon.android.ui.text;
|
||||||
|
|
||||||
|
import android.graphics.Typeface;
|
||||||
|
import android.text.TextPaint;
|
||||||
|
|
||||||
|
public class ComposeHashtagOrMentionSpan extends ComposeAutocompleteSpan{
|
||||||
|
private static final Typeface MEDIUM_TYPEFACE=Typeface.create("sans-serif-medium", 0);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void updateDrawState(TextPaint tp){
|
||||||
|
tp.setColor(tp.linkColor);
|
||||||
|
tp.setTypeface(MEDIUM_TYPEFACE);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
package org.joinmastodon.android.ui.utils;
|
package org.joinmastodon.android.ui.utils;
|
||||||
|
|
||||||
|
import android.graphics.drawable.Animatable;
|
||||||
import android.graphics.drawable.Drawable;
|
import android.graphics.drawable.Drawable;
|
||||||
import android.text.Spanned;
|
import android.text.Spanned;
|
||||||
|
|
||||||
|
@ -40,5 +41,7 @@ public class CustomEmojiHelper{
|
||||||
for(CustomEmojiSpan span:spans.get(image)){
|
for(CustomEmojiSpan span:spans.get(image)){
|
||||||
span.setDrawable(drawable);
|
span.setDrawable(drawable);
|
||||||
}
|
}
|
||||||
|
if(drawable instanceof Animatable && !((Animatable) drawable).isRunning())
|
||||||
|
((Animatable) drawable).start();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
package org.joinmastodon.android.ui.views;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.widget.EditText;
|
||||||
|
|
||||||
|
public class SelectionListenerEditText extends EditText{
|
||||||
|
private SelectionListener selectionListener;
|
||||||
|
|
||||||
|
public SelectionListenerEditText(Context context){
|
||||||
|
super(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SelectionListenerEditText(Context context, AttributeSet attrs){
|
||||||
|
super(context, attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SelectionListenerEditText(Context context, AttributeSet attrs, int defStyleAttr){
|
||||||
|
super(context, attrs, defStyleAttr);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SelectionListenerEditText(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes){
|
||||||
|
super(context, attrs, defStyleAttr, defStyleRes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onSelectionChanged(int selStart, int selEnd){
|
||||||
|
super.onSelectionChanged(selStart, selEnd);
|
||||||
|
if(selectionListener!=null)
|
||||||
|
selectionListener.onSelectionChanged(selStart, selEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSelectionListener(SelectionListener selectionListener){
|
||||||
|
this.selectionListener=selectionListener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface SelectionListener{
|
||||||
|
void onSelectionChanged(int start, int end);
|
||||||
|
}
|
||||||
|
}
|
|
@ -82,19 +82,27 @@
|
||||||
|
|
||||||
</RelativeLayout>
|
</RelativeLayout>
|
||||||
|
|
||||||
<EditText
|
<FrameLayout
|
||||||
android:id="@+id/toot_text"
|
android:id="@+id/toot_text_wrap"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="10dp"
|
android:layout_marginTop="10dp">
|
||||||
android:paddingLeft="16dp"
|
|
||||||
android:paddingRight="16dp"
|
<org.joinmastodon.android.ui.views.SelectionListenerEditText
|
||||||
android:paddingBottom="16dp"
|
android:id="@+id/toot_text"
|
||||||
android:textAppearance="@style/m3_body_large"
|
android:layout_width="match_parent"
|
||||||
android:gravity="top"
|
android:layout_height="wrap_content"
|
||||||
android:background="@null"
|
android:paddingLeft="16dp"
|
||||||
android:hint="@string/compose_hint"
|
android:paddingRight="16dp"
|
||||||
android:inputType="textMultiLine|textCapSentences"/>
|
android:paddingBottom="16dp"
|
||||||
|
android:textAppearance="@style/m3_body_large"
|
||||||
|
android:gravity="top"
|
||||||
|
android:background="@null"
|
||||||
|
android:hint="@string/compose_hint"
|
||||||
|
android:elevation="0dp"
|
||||||
|
android:inputType="textMultiLine|textCapSentences"/>
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/poll_wrap"
|
android:id="@+id/poll_wrap"
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingBottom="8dp"
|
||||||
|
android:paddingLeft="16dp"
|
||||||
|
android:paddingRight="16dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/photo"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
tools:src="#0f0"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/name"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_toEndOf="@id/photo"
|
||||||
|
android:textAppearance="@style/m3_title_medium"
|
||||||
|
android:fontFamily="sans-serif"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:ellipsize="end"
|
||||||
|
tools:text="User Name"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/username"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_toEndOf="@id/photo"
|
||||||
|
android:layout_alignBottom="@id/photo"
|
||||||
|
android:textAppearance="@style/m3_body_medium"
|
||||||
|
android:textColor="?android:textColorSecondary"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:ellipsize="end"
|
||||||
|
tools:text="\@user@domain"/>
|
||||||
|
|
||||||
|
</RelativeLayout>
|
Loading…
Reference in New Issue