closes #89, closes #279
This commit is contained in:
Grishka 2023-10-08 22:03:16 +03:00
parent 6c1c5b7759
commit dff2217e80
90 changed files with 3025 additions and 243 deletions

View File

@ -9,8 +9,8 @@ android {
applicationId "org.joinmastodon.android"
minSdk 23
targetSdk 33
versionCode 72
versionName "2.1.6"
versionCode 73
versionName "2.2.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resConfigs "ar-rSA", "be-rBY", "bn-rBD", "bs-rBA", "ca-rES", "cs-rCZ", "da-rDK", "de-rDE", "el-rGR", "es-rES", "eu-rES", "fa-rIR", "fi-rFI", "fil-rPH", "fr-rFR", "ga-rIE", "gd-rGB", "gl-rES", "hi-rIN", "hr-rHR", "hu-rHU", "hy-rAM", "ig-rNG", "in-rID", "is-rIS", "it-rIT", "iw-rIL", "ja-rJP", "kab", "ko-rKR", "my-rMM", "nl-rNL", "no-rNO", "oc-rFR", "pl-rPL", "pt-rBR", "pt-rPT", "ro-rRO", "ru-rRU", "si-rLK", "sl-rSI", "sv-rSE", "th-rTH", "tr-rTR", "uk-rUA", "ur-rIN", "vi-rVN", "zh-rCN", "zh-rTW"
}
@ -76,7 +76,7 @@ dependencies {
implementation 'me.grishka.litex:viewpager:1.0.0'
implementation 'me.grishka.litex:viewpager2:1.0.0'
implementation 'me.grishka.litex:palette:1.0.0'
implementation 'me.grishka.appkit:appkit:1.2.14'
implementation 'me.grishka.appkit:appkit:1.2.15'
implementation 'com.google.code.gson:gson:2.8.9'
implementation 'org.jsoup:jsoup:1.14.3'
implementation 'com.squareup:otto:1.3.8'

View File

@ -9,20 +9,31 @@ import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.api.requests.lists.GetLists;
import org.joinmastodon.android.api.requests.notifications.GetNotifications;
import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.CacheablePaginatedResponse;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.PaginatedResponse;
import org.joinmastodon.android.model.SearchResult;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.List;
import java.util.function.Consumer;
@ -42,6 +53,7 @@ public class CacheController{
private final Runnable databaseCloseRunnable=this::closeDatabase;
private boolean loadingNotifications;
private final ArrayList<Callback<PaginatedResponse<List<Notification>>>> pendingNotificationsCallbacks=new ArrayList<>();
private List<FollowList> lists;
private static final int POST_FLAG_GAP_AFTER=1;
@ -300,6 +312,67 @@ public class CacheController{
}, 0);
}
public void reloadLists(Callback<List<FollowList>> callback){
new GetLists()
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<FollowList> result){
result.sort(Comparator.comparing(l->l.title));
lists=result;
if(callback!=null)
callback.onSuccess(result);
databaseThread.postRunnable(()->{
try(OutputStreamWriter out=new OutputStreamWriter(new FileOutputStream(getListsFile()))){
MastodonAPIController.gson.toJson(result, out);
}catch(IOException x){
Log.w(TAG, "failed to write lists to cache file", x);
}
}, 0);
}
@Override
public void onError(ErrorResponse error){
if(callback!=null)
callback.onError(error);
}
})
.exec(accountID);
}
private List<FollowList> loadListsFromFile(){
File file=getListsFile();
if(!file.exists())
return null;
try(InputStreamReader in=new InputStreamReader(new FileInputStream(file))){
return MastodonAPIController.gson.fromJson(in, new TypeToken<List<FollowList>>(){}.getType());
}catch(Exception x){
Log.w(TAG, "failed to read lists from cache file", x);
return null;
}
}
public void getLists(Callback<List<FollowList>> callback){
if(lists!=null){
if(callback!=null)
callback.onSuccess(lists);
return;
}
databaseThread.postRunnable(()->{
List<FollowList> lists=loadListsFromFile();
if(lists!=null){
this.lists=lists;
if(callback!=null)
uiHandler.post(()->callback.onSuccess(lists));
return;
}
reloadLists(callback);
}, 0);
}
public File getListsFile(){
return new File(MastodonApp.context.getFilesDir(), "lists_"+accountID+".json");
}
private class DatabaseHelper extends SQLiteOpenHelper{
public DatabaseHelper(){

View File

@ -154,6 +154,8 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
}
public RequestBody getRequestBody() throws IOException{
if(requestBody instanceof RequestBody rb)
return rb;
return requestBody==null ? null : new JsonObjectRequestBody(requestBody);
}

View File

@ -0,0 +1,14 @@
package org.joinmastodon.android.api.requests.accounts;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.FollowList;
import java.util.List;
public class GetAccountLists extends MastodonAPIRequest<List<FollowList>>{
public GetAccountLists(String id){
super(HttpMethod.GET, "/accounts/"+id+"/lists", new TypeToken<>(){});
}
}

View File

@ -0,0 +1,23 @@
package org.joinmastodon.android.api.requests.accounts;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Account;
import java.util.List;
public class SearchAccounts extends MastodonAPIRequest<List<Account>>{
public SearchAccounts(String q, int limit, int offset, boolean resolve, boolean following){
super(HttpMethod.GET, "/accounts/search", new TypeToken<>(){});
addQueryParameter("q", q);
if(limit>0)
addQueryParameter("limit", limit+"");
if(offset>0)
addQueryParameter("offset", offset+"");
if(resolve)
addQueryParameter("resolve", "true");
if(following)
addQueryParameter("following", "true");
}
}

View File

@ -2,6 +2,9 @@ package org.joinmastodon.android.api.requests.filters;
import com.google.gson.annotations.SerializedName;
import androidx.annotation.Keep;
@Keep
class KeywordAttribute{
public String id;
@SerializedName("_destroy")

View File

@ -0,0 +1,19 @@
package org.joinmastodon.android.api.requests.lists;
import org.joinmastodon.android.api.ResultlessMastodonAPIRequest;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import okhttp3.FormBody;
public class AddAccountsToList extends ResultlessMastodonAPIRequest{
public AddAccountsToList(String listID, Collection<String> accountIDs){
super(HttpMethod.POST, "/lists/"+listID+"/accounts");
FormBody.Builder builder=new FormBody.Builder(StandardCharsets.UTF_8);
for(String id:accountIDs){
builder.add("account_ids[]", id);
}
setRequestBody(builder.build());
}
}

View File

@ -0,0 +1,9 @@
package org.joinmastodon.android.api.requests.lists;
import org.joinmastodon.android.api.ResultlessMastodonAPIRequest;
public class DeleteList extends ResultlessMastodonAPIRequest{
public DeleteList(String id){
super(HttpMethod.DELETE, "/lists/"+id);
}
}

View File

@ -0,0 +1,17 @@
package org.joinmastodon.android.api.requests.lists;
import android.text.TextUtils;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.model.Account;
public class GetListAccounts extends HeaderPaginationRequest<Account>{
public GetListAccounts(String listID, String maxID, int limit){
super(HttpMethod.GET, "/lists/"+listID+"/accounts", new TypeToken<>(){});
if(!TextUtils.isEmpty(maxID))
addQueryParameter("max_id", maxID);
addQueryParameter("limit", String.valueOf(limit));
}
}

View File

@ -0,0 +1,14 @@
package org.joinmastodon.android.api.requests.lists;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.FollowList;
import java.util.List;
public class GetLists extends MastodonAPIRequest<List<FollowList>>{
public GetLists(){
super(HttpMethod.GET, "/lists", new TypeToken<>(){});
}
}

View File

@ -0,0 +1,19 @@
package org.joinmastodon.android.api.requests.lists;
import org.joinmastodon.android.api.ResultlessMastodonAPIRequest;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import okhttp3.FormBody;
public class RemoveAccountsFromList extends ResultlessMastodonAPIRequest{
public RemoveAccountsFromList(String listID, Collection<String> accountIDs){
super(HttpMethod.DELETE, "/lists/"+listID+"/accounts");
FormBody.Builder builder=new FormBody.Builder(StandardCharsets.UTF_8);
for(String id:accountIDs){
builder.add("account_ids[]", id);
}
setRequestBody(builder.build());
}
}

View File

@ -0,0 +1,23 @@
package org.joinmastodon.android.api.requests.lists;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.FollowList;
public class UpdateList extends MastodonAPIRequest<FollowList>{
public UpdateList(String listID, String title, FollowList.RepliesPolicy repliesPolicy, boolean exclusive){
super(HttpMethod.PUT, "/lists/"+listID, FollowList.class);
setRequestBody(new Request(title, repliesPolicy, exclusive));
}
private static class Request{
public String title;
public FollowList.RepliesPolicy repliesPolicy;
public boolean exclusive;
public Request(String title, FollowList.RepliesPolicy repliesPolicy, boolean exclusive){
this.title=title;
this.repliesPolicy=repliesPolicy;
this.exclusive=exclusive;
}
}
}

View File

@ -0,0 +1,16 @@
package org.joinmastodon.android.api.requests.tags;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.model.Hashtag;
public class GetFollowedTags extends HeaderPaginationRequest<Hashtag>{
public GetFollowedTags(String maxID, int limit){
super(HttpMethod.GET, "/followed_tags", new TypeToken<>(){});
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(limit>0)
addQueryParameter("limit", limit+"");
}
}

View File

@ -0,0 +1,22 @@
package org.joinmastodon.android.api.requests.timelines;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Status;
import java.util.List;
public class GetListTimeline extends MastodonAPIRequest<List<Status>>{
public GetListTimeline(String listID, String maxID, String minID, int limit, String sinceID){
super(HttpMethod.GET, "/timelines/list/"+listID, new TypeToken<>(){});
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(minID!=null)
addQueryParameter("min_id", minID);
if(limit>0)
addQueryParameter("limit", ""+limit);
if(sinceID!=null)
addQueryParameter("since_id", sinceID);
}
}

View File

@ -10,7 +10,7 @@ import org.joinmastodon.android.model.Status;
import java.util.List;
public class GetPublicTimeline extends MastodonAPIRequest<List<Status>>{
public GetPublicTimeline(boolean local, boolean remote, String maxID, int limit){
public GetPublicTimeline(boolean local, boolean remote, String maxID, String minID, int limit, String sinceID){
super(HttpMethod.GET, "/timelines/public", new TypeToken<>(){});
if(local)
addQueryParameter("local", "true");
@ -18,6 +18,10 @@ public class GetPublicTimeline extends MastodonAPIRequest<List<Status>>{
addQueryParameter("remote", "true");
if(!TextUtils.isEmpty(maxID))
addQueryParameter("max_id", maxID);
if(!TextUtils.isEmpty(minID))
addQueryParameter("min_id", minID);
if(!TextUtils.isEmpty(sinceID))
addQueryParameter("since_id", sinceID);
if(limit>0)
addQueryParameter("limit", limit+"");
}

View File

@ -24,6 +24,7 @@ import org.joinmastodon.android.model.Application;
import org.joinmastodon.android.model.FilterAction;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FilterResult;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.Preferences;
import org.joinmastodon.android.model.PushSubscription;
@ -32,6 +33,7 @@ import org.joinmastodon.android.model.TimelineMarkers;
import org.joinmastodon.android.model.Token;
import org.joinmastodon.android.utils.ObjectIdComparator;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
@ -66,6 +68,7 @@ public class AccountSession{
private transient SharedPreferences prefs;
private transient boolean preferencesNeedSaving;
private transient AccountLocalPreferences localPreferences;
private transient List<FollowList> lists;
AccountSession(Token token, Account self, Application app, String domain, boolean activated, AccountActivationInfo activationInfo){
this.token=token;

View File

@ -175,6 +175,7 @@ public class AccountSessionManager{
public void removeAccount(String id){
AccountSession session=getAccount(id);
session.getCacheController().closeDatabase();
session.getCacheController().getListsFile().delete();
MastodonApp.context.deleteDatabase(id+".db");
MastodonApp.context.getSharedPreferences(id, 0).edit().clear().commit();
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){

View File

@ -0,0 +1,15 @@
package org.joinmastodon.android.events;
import org.joinmastodon.android.model.Account;
public class AccountAddedToListEvent{
public final String accountID;
public final String listID;
public final Account account;
public AccountAddedToListEvent(String accountID, String listID, Account account){
this.accountID=accountID;
this.listID=listID;
this.account=account;
}
}

View File

@ -0,0 +1,13 @@
package org.joinmastodon.android.events;
public class AccountRemovedFromListEvent{
public final String accountID;
public final String listID;
public final String targetAccountID;
public AccountRemovedFromListEvent(String accountID, String listID, String targetAccountID){
this.accountID=accountID;
this.listID=listID;
this.targetAccountID=targetAccountID;
}
}

View File

@ -0,0 +1,11 @@
package org.joinmastodon.android.events;
public class ListDeletedEvent{
public final String accountID;
public final String listID;
public ListDeletedEvent(String accountID, String listID){
this.accountID=accountID;
this.listID=listID;
}
}

View File

@ -0,0 +1,13 @@
package org.joinmastodon.android.events;
import org.joinmastodon.android.model.FollowList;
public class ListUpdatedEvent{
public final String accountID;
public final FollowList list;
public ListUpdatedEvent(String accountID, FollowList list){
this.accountID=accountID;
this.list=list;
}
}

View File

@ -0,0 +1,114 @@
package org.joinmastodon.android.fragments;
import android.os.Bundle;
import android.widget.TextView;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.ResultlessMastodonAPIRequest;
import org.joinmastodon.android.api.requests.accounts.GetAccountLists;
import org.joinmastodon.android.api.requests.lists.AddAccountsToList;
import org.joinmastodon.android.api.requests.lists.RemoveAccountsFromList;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.AccountAddedToListEvent;
import org.joinmastodon.android.events.AccountRemovedFromListEvent;
import org.joinmastodon.android.fragments.settings.BaseSettingsFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
public class AddAccountToListsFragment extends BaseSettingsFragment<FollowList>{
private Account account;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.add_user_to_list_title);
account=Parcels.unwrap(getArguments().getParcelable("targetAccount"));
loadData();
}
@Override
protected void doLoadData(int offset, int count){
AccountSessionManager.get(accountID).getCacheController().getLists(new SimpleCallback<>(this){
@Override
public void onSuccess(List<FollowList> allLists){
if(getActivity()==null)
return;
loadAccountLists(allLists);
}
});
}
private void loadAccountLists(final List<FollowList> allLists){
currentRequest=new GetAccountLists(account.id)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<FollowList> result){
Set<String> lists=result.stream().map(l->l.id).collect(Collectors.toSet());
onDataLoaded(allLists.stream()
.map(l->new CheckableListItem<>(l.title, null, CheckableListItem.Style.CHECKBOX, lists.contains(l.id),
R.drawable.ic_list_alt_24px, AddAccountToListsFragment.this::onItemClick, l))
.collect(Collectors.toList()), false);
}
})
.exec(accountID);
}
@Override
protected int indexOfItemsAdapter(){
return 1;
}
@Override
protected RecyclerView.Adapter<?> getAdapter(){
TextView topText=new TextView(getActivity());
topText.setTextAppearance(R.style.m3_body_medium);
topText.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurface));
topText.setPadding(V.dp(16), V.dp(8), V.dp(16), V.dp(8));
topText.setText(getString(R.string.manage_user_lists, account.getDisplayUsername()));
MergeRecyclerAdapter mergeAdapter=new MergeRecyclerAdapter();
mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(topText));
mergeAdapter.addAdapter(super.getAdapter());
return mergeAdapter;
}
private void onItemClick(CheckableListItem<FollowList> item){
boolean add=!item.checked;
ResultlessMastodonAPIRequest req=add ? new AddAccountsToList(item.parentObject.id, Set.of(account.id)) : new RemoveAccountsFromList(item.parentObject.id, Set.of(account.id));
req.setCallback(new Callback<>(){
@Override
public void onSuccess(Void result){
item.checked=add;
rebindItem(item);
if(add){
E.post(new AccountAddedToListEvent(accountID, item.parentObject.id, account));
}else{
E.post(new AccountRemovedFromListEvent(accountID, item.parentObject.id, account.id));
}
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.wrapProgress(getActivity(), R.string.loading, false)
.exec(accountID);
}
}

View File

@ -19,17 +19,14 @@ import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import android.util.Log;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.SoundEffectConstants;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewOutlineProvider;
import android.view.ViewTreeObserver;
import android.view.WindowManager;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputMethodManager;
@ -49,7 +46,6 @@ import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonErrorResponse;
import org.joinmastodon.android.api.requests.accounts.GetPreferences;
import org.joinmastodon.android.api.requests.statuses.CreateStatus;
import org.joinmastodon.android.api.requests.statuses.EditStatus;
import org.joinmastodon.android.api.session.AccountSession;
@ -57,7 +53,7 @@ import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.events.StatusUpdatedEvent;
import org.joinmastodon.android.fragments.account_list.ComposeAccountSearchFragment;
import org.joinmastodon.android.fragments.account_list.AccountSearchFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.EmojiCategory;
@ -340,7 +336,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
public void onLaunchAccountSearch(){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.goForResult(getActivity(), ComposeAccountSearchFragment.class, args, AUTOCOMPLETE_ACCOUNT_RESULT, ComposeFragment.this);
Nav.goForResult(getActivity(), AccountSearchFragment.class, args, AUTOCOMPLETE_ACCOUNT_RESULT, ComposeFragment.this);
}
});
View autocompleteView=autocompleteViewController.getView();

View File

@ -0,0 +1,202 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.Spinner;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.lists.DeleteList;
import org.joinmastodon.android.api.requests.lists.GetListAccounts;
import org.joinmastodon.android.api.requests.lists.UpdateList;
import org.joinmastodon.android.events.ListDeletedEvent;
import org.joinmastodon.android.events.ListUpdatedEvent;
import org.joinmastodon.android.fragments.settings.BaseSettingsFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.viewmodel.AvatarPileListItem;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.views.FloatingHintEditTextLayout;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.List;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.APIRequest;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
public class EditListFragment extends BaseSettingsFragment<Void>{
private FollowList followList;
private AvatarPileListItem<Void> membersItem;
private CheckableListItem<Void> exclusiveItem;
private FloatingHintEditTextLayout titleEditLayout;
private EditText titleEdit;
private Spinner showRepliesSpinner;
private APIRequest<?> getMembersRequest;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
followList=Parcels.unwrap(getArguments().getParcelable("list"));
setTitle(R.string.edit_list);
onDataLoaded(List.of(
membersItem=new AvatarPileListItem<>(getString(R.string.list_members), null, List.of(), 0, i->onMembersClick(), null, false),
exclusiveItem=new CheckableListItem<>(R.string.list_exclusive, R.string.list_exclusive_subtitle, CheckableListItem.Style.SWITCH, followList.exclusive, this::toggleCheckableItem)
));
loadMembers();
setHasOptionsMenu(true);
}
@Override
public void onDestroy(){
super.onDestroy();
if(getMembersRequest!=null)
getMembersRequest.cancel();
String newTitle=titleEdit.getText().toString();
FollowList.RepliesPolicy newRepliesPolicy=FollowList.RepliesPolicy.values()[showRepliesSpinner.getSelectedItemPosition()];
boolean newExclusive=exclusiveItem.checked;
if(!newTitle.equals(followList.title) || newRepliesPolicy!=followList.repliesPolicy || newExclusive!=followList.exclusive){
new UpdateList(followList.id, newTitle, newRepliesPolicy, newExclusive)
.setCallback(new Callback<>(){
@Override
public void onSuccess(FollowList result){
E.post(new ListUpdatedEvent(accountID, result));
}
@Override
public void onError(ErrorResponse error){
// TODO handle errors somehow
}
})
.exec(accountID);
}
}
@Override
protected void doLoadData(int offset, int count){}
@Override
protected RecyclerView.Adapter<?> getAdapter(){
LinearLayout topView=new LinearLayout(getActivity());
topView.setOrientation(LinearLayout.VERTICAL);
titleEditLayout=(FloatingHintEditTextLayout) getActivity().getLayoutInflater().inflate(R.layout.floating_hint_edit_text, topView, false);
titleEdit=titleEditLayout.findViewById(R.id.edit);
titleEdit.setHint(R.string.list_name);
titleEditLayout.updateHint();
if(followList!=null)
titleEdit.setText(followList.title);
topView.addView(titleEditLayout);
FloatingHintEditTextLayout showRepliesLayout=(FloatingHintEditTextLayout) getActivity().getLayoutInflater().inflate(R.layout.floating_hint_spinner, topView, false);
showRepliesSpinner=showRepliesLayout.findViewById(R.id.spinner);
showRepliesLayout.setHint(R.string.list_show_replies_to);
topView.addView(showRepliesLayout);
ArrayAdapter<String> spinnerAdapter=new ArrayAdapter<>(getActivity(), R.layout.item_spinner, List.of(
getString(R.string.list_replies_no_one),
getString(R.string.list_replies_members),
getString(R.string.list_replies_anyone)
));
showRepliesSpinner.setAdapter(spinnerAdapter);
showRepliesSpinner.setSelection(switch(followList.repliesPolicy){
case FOLLOWED -> 2;
case LIST -> 1;
case NONE -> 0;
});
ViewGroup.MarginLayoutParams llp=(ViewGroup.MarginLayoutParams)showRepliesLayout.getLabel().getLayoutParams();
llp.setMarginStart(llp.getMarginStart()+V.dp(16));
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
adapter.addAdapter(new SingleViewRecyclerAdapter(topView));
adapter.addAdapter(super.getAdapter());
return adapter;
}
@Override
protected int indexOfItemsAdapter(){
return 1;
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
menu.add(R.string.delete_list);
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.delete_list)
.setMessage(getString(R.string.delete_list_confirm, followList.title))
.setPositiveButton(R.string.delete, (dlg, which)->doDeleteList())
.setNegativeButton(R.string.cancel, null)
.show();
return true;
}
private void doDeleteList(){
new DeleteList(followList.id)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Void result){
E.post(new ListDeletedEvent(accountID, followList.id));
Nav.finish(EditListFragment.this);
}
@Override
public void onError(ErrorResponse error){
Activity activity=getActivity();
if(activity==null)
return;
error.showToast(activity);
}
})
.wrapProgress(getActivity(), R.string.loading, true)
.exec(accountID);
}
private void onMembersClick(){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("list", Parcels.wrap(followList));
Nav.go(getActivity(), ListMembersFragment.class, args);
}
private void loadMembers(){
getMembersRequest=new GetListAccounts(followList.id, null, 3)
.setCallback(new Callback<>(){
@Override
public void onSuccess(HeaderPaginationList<Account> result){
getMembersRequest=null;
membersItem.avatars=new ArrayList<>();
for(int i=0;i<Math.min(3, result.size());i++){
Account acc=result.get(i);
membersItem.avatars.add(new UrlImageLoaderRequest(acc.avatarStatic, V.dp(32), V.dp(32)));
}
rebindItem(membersItem);
imgLoader.updateImages();
}
@Override
public void onError(ErrorResponse error){
getMembersRequest=null;
}
})
.exec(accountID);
}
}

View File

@ -5,7 +5,6 @@ import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.app.Activity;
import android.content.res.ColorStateList;
import android.content.res.Configuration;
import android.os.Build;
import android.os.Bundle;
@ -15,30 +14,40 @@ import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toolbar;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.requests.markers.SaveMarkers;
import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline;
import org.joinmastodon.android.api.requests.timelines.GetListTimeline;
import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.fragments.settings.SettingsMainFragment;
import org.joinmastodon.android.model.CacheablePaginatedResponse;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.TimelineMarkers;
import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.viewcontrollers.HomeTimelineMenuController;
import org.joinmastodon.android.ui.viewcontrollers.ToolbarDropdownMenuController;
import org.joinmastodon.android.ui.views.FixedAspectRatioImageView;
import org.joinmastodon.android.updater.GithubSelfUpdater;
import org.parceler.Parcels;
import java.util.Collections;
import java.util.HashSet;
@ -54,12 +63,19 @@ import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.V;
public class HomeTimelineFragment extends StatusListFragment{
public class HomeTimelineFragment extends StatusListFragment implements ToolbarDropdownMenuController.HostFragment{
private ImageButton fab;
private ImageView toolbarLogo;
private LinearLayout listsDropdown;
private FixedAspectRatioImageView listsDropdownArrow;
private TextView listsDropdownText;
private Button toolbarShowNewPostsBtn;
private boolean newPostsBtnShown;
private AnimatorSet currentNewPostsAnim;
private ToolbarDropdownMenuController dropdownController;
private HomeTimelineMenuController dropdownMainMenuController;
private List<FollowList> lists=List.of();
private ListMode listMode=ListMode.FOLLOWING;
private FollowList currentList;
private String maxID;
private String lastSavedMarkerID;
@ -71,25 +87,103 @@ public class HomeTimelineFragment extends StatusListFragment{
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
dropdownController=new ToolbarDropdownMenuController(this);
dropdownMainMenuController=new HomeTimelineMenuController(dropdownController, new HomeTimelineMenuController.Callback(){
@Override
public void onFollowingSelected(){
if(listMode==ListMode.FOLLOWING)
return;
listMode=ListMode.FOLLOWING;
reload();
}
@Override
public void onLocalSelected(){
if(listMode==ListMode.LOCAL)
return;
listMode=ListMode.LOCAL;
reload();
}
@Override
public List<FollowList> getLists(){
return lists;
}
@Override
public void onListSelected(FollowList list){
if(listMode==ListMode.LIST && currentList==list)
return;
listMode=ListMode.LIST;
currentList=list;
reload();
}
});
setHasOptionsMenu(true);
loadData();
AccountSessionManager.get(accountID).getCacheController().getLists(new Callback<>(){
@Override
public void onSuccess(List<FollowList> result){
lists=result;
}
@Override
public void onError(ErrorResponse error){}
});
}
@Override
protected void doLoadData(int offset, int count){
AccountSessionManager.getInstance()
.getAccount(accountID).getCacheController()
.getHomeTimeline(offset>0 ? maxID : null, count, refreshing, new SimpleCallback<>(this){
@Override
public void onSuccess(CacheablePaginatedResponse<List<Status>> result){
if(getActivity()==null)
return;
onDataLoaded(result.items, !result.items.isEmpty());
maxID=result.maxID;
if(result.isFromCache())
loadNewPosts();
}
});
switch(listMode){
case FOLLOWING -> {
AccountSessionManager.getInstance()
.getAccount(accountID).getCacheController()
.getHomeTimeline(offset>0 ? maxID : null, count, refreshing, new SimpleCallback<>(this){
@Override
public void onSuccess(CacheablePaginatedResponse<List<Status>> result){
if(getActivity()==null || listMode!=ListMode.FOLLOWING)
return;
if(refreshing)
list.scrollToPosition(0);
onDataLoaded(result.items, !result.items.isEmpty());
maxID=result.maxID;
if(result.isFromCache())
loadNewPosts();
}
@Override
public void onError(ErrorResponse error){
if(listMode!=ListMode.FOLLOWING)
return;
super.onError(error);
}
});
}
case LOCAL -> {
currentRequest=new GetPublicTimeline(true, false, offset>0 ? maxID : null, null, count, null)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(refreshing)
list.scrollToPosition(0);
onDataLoaded(result, !result.isEmpty());
}
})
.exec(accountID);
}
case LIST -> {
currentRequest=new GetListTimeline(currentList.id, offset>0 ? maxID : null, null, count, null)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(refreshing)
list.scrollToPosition(0);
onDataLoaded(result, !result.isEmpty());
}
})
.exec(accountID);
}
}
}
@Override
@ -116,13 +210,26 @@ public class HomeTimelineFragment extends StatusListFragment{
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
inflater.inflate(R.menu.home, menu);
menu.findItem(R.id.edit_list).setVisible(listMode==ListMode.LIST);
GithubSelfUpdater.UpdateState state=GithubSelfUpdater.UpdateState.NO_UPDATE;
GithubSelfUpdater updater=GithubSelfUpdater.getInstance();
if(updater!=null)
state=updater.getState();
if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING)
getToolbar().getMenu().findItem(R.id.settings).setIcon(R.drawable.ic_settings_updateready_24px);
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), SettingsMainFragment.class, args);
int id=item.getItemId();
if(id==R.id.settings){
Nav.go(getActivity(), SettingsMainFragment.class, args);
}else if(id==R.id.edit_list){
args.putParcelable("list", Parcels.wrap(currentList));
Nav.go(getActivity(), EditListFragment.class, args);
}
return true;
}
@ -147,7 +254,7 @@ public class HomeTimelineFragment extends StatusListFragment{
@Override
protected void onHidden(){
super.onHidden();
if(!data.isEmpty()){
if(!data.isEmpty() && listMode==ListMode.FOLLOWING){
String topPostID=displayItems.get(Math.max(0, list.getChildAdapterPosition(list.getChildAt(0))-getMainAdapterOffset())).parentID;
if(!topPostID.equals(lastSavedMarkerID)){
lastSavedMarkerID=topPostID;
@ -183,8 +290,8 @@ public class HomeTimelineFragment extends StatusListFragment{
// we'll get the currently topmost post as last in the response. This way we know there's no gap
// between the existing and newly loaded parts of the timeline.
String sinceID=data.size()>1 ? data.get(1).id : "1";
currentRequest=new GetHomeTimeline(null, null, 20, sinceID)
.setCallback(new Callback<>(){
boolean needCache=listMode==ListMode.FOLLOWING;
loadAdditionalPosts(null, null, 20, sinceID, new Callback<>(){
@Override
public void onSuccess(List<Status> result){
currentRequest=null;
@ -199,11 +306,13 @@ public class HomeTimelineFragment extends StatusListFragment{
result.get(result.size()-1).hasGapAfter=true;
toAdd=result;
}
AccountSessionManager.get(accountID).filterStatuses(toAdd, FilterContext.HOME);
if(needCache)
AccountSessionManager.get(accountID).filterStatuses(toAdd, FilterContext.HOME);
if(!toAdd.isEmpty()){
prependItems(toAdd, true);
showNewPostsButton();
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(toAdd, false);
if(needCache)
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(toAdd, false);
}
}
@ -212,8 +321,7 @@ public class HomeTimelineFragment extends StatusListFragment{
currentRequest=null;
dataLoading=false;
}
})
.exec(accountID);
});
}
@Override
@ -225,10 +333,11 @@ public class HomeTimelineFragment extends StatusListFragment{
V.setVisibilityAnimated(item.text, View.GONE);
GapStatusDisplayItem gap=item.getItem();
dataLoading=true;
currentRequest=new GetHomeTimeline(item.getItemID(), null, 20, null)
.setCallback(new Callback<>(){
boolean needCache=listMode==ListMode.FOLLOWING;
loadAdditionalPosts(item.getItemID(), null, 20, null, new Callback<>(){
@Override
public void onSuccess(List<Status> result){
currentRequest=null;
dataLoading=false;
if(getActivity()==null)
@ -242,7 +351,8 @@ public class HomeTimelineFragment extends StatusListFragment{
Status gapStatus=getStatusByID(gap.parentID);
if(gapStatus!=null){
gapStatus.hasGapAfter=false;
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(gapStatus), false);
if(needCache)
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(gapStatus), false);
}
}else{
Set<String> idsBelowGap=new HashSet<>();
@ -254,7 +364,8 @@ public class HomeTimelineFragment extends StatusListFragment{
}else if(s.id.equals(gap.parentID)){
belowGap=true;
s.hasGapAfter=false;
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(s), false);
if(needCache)
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(s), false);
}else{
gapPostIndex++;
}
@ -270,7 +381,8 @@ public class HomeTimelineFragment extends StatusListFragment{
}else{
result=result.subList(0, endIndex);
}
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.HOME);
if(needCache)
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.HOME);
List<StatusDisplayItem> targetList=displayItems.subList(gapPos, gapPos+1);
targetList.clear();
List<Status> insertedPosts=data.subList(gapPostIndex+1, gapPostIndex+1);
@ -287,7 +399,8 @@ public class HomeTimelineFragment extends StatusListFragment{
adapter.notifyItemChanged(getMainAdapterOffset()+gapPos);
adapter.notifyItemRangeInserted(getMainAdapterOffset()+gapPos+1, targetList.size()-1);
}
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(insertedPosts, false);
if(needCache)
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(insertedPosts, false);
}
}
@ -304,9 +417,17 @@ public class HomeTimelineFragment extends StatusListFragment{
adapter.notifyItemChanged(gapPos);
}
}
})
.exec(accountID);
});
}
private void loadAdditionalPosts(String maxID, String minID, int limit, String sinceID, Callback<List<Status>> callback){
MastodonAPIRequest<List<Status>> req=switch(listMode){
case FOLLOWING -> new GetHomeTimeline(maxID, minID, limit, sinceID);
case LOCAL -> new GetPublicTimeline(true, false, maxID, minID, limit, sinceID);
case LIST -> new GetListTimeline(currentList.id, maxID, minID, limit, sinceID);
};
currentRequest=req;
req.setCallback(callback).exec(accountID);
}
@Override
@ -320,10 +441,31 @@ public class HomeTimelineFragment extends StatusListFragment{
}
private void updateToolbarLogo(){
toolbarLogo=new ImageView(getActivity());
toolbarLogo.setScaleType(ImageView.ScaleType.CENTER);
toolbarLogo.setImageResource(R.drawable.logo);
toolbarLogo.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary)));
listsDropdown=new LinearLayout(getActivity());
listsDropdown.setOnClickListener(this::onListsDropdownClick);
listsDropdown.setBackgroundResource(R.drawable.bg_button_m3_text);
listsDropdown.setAccessibilityDelegate(new View.AccessibilityDelegate(){
@Override
public void onInitializeAccessibilityNodeInfo(@NonNull View host, @NonNull AccessibilityNodeInfo info){
super.onInitializeAccessibilityNodeInfo(host, info);
info.setClassName("android.widget.Spinner");
}
});
listsDropdownArrow=new FixedAspectRatioImageView(getActivity());
listsDropdownArrow.setUseHeight(true);
listsDropdownArrow.setImageResource(R.drawable.ic_arrow_drop_down_24px);
listsDropdownArrow.setScaleType(ImageView.ScaleType.CENTER);
listsDropdownArrow.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
listsDropdown.addView(listsDropdownArrow, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT));
listsDropdownText=new TextView(getActivity());
listsDropdownText.setTextAppearance(R.style.action_bar_title);
listsDropdownText.setSingleLine();
listsDropdownText.setGravity(Gravity.START | Gravity.CENTER_VERTICAL);
listsDropdownText.setPaddingRelative(V.dp(4), 0, V.dp(16), 0);
listsDropdownText.setText(getCurrentListTitle());
listsDropdownArrow.setImageTintList(listsDropdownText.getTextColors());
listsDropdown.setBackgroundTintList(listsDropdownText.getTextColors());
listsDropdown.addView(listsDropdownText, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT));
toolbarShowNewPostsBtn=new Button(getActivity());
toolbarShowNewPostsBtn.setTextAppearance(R.style.m3_title_medium);
@ -340,22 +482,33 @@ public class HomeTimelineFragment extends StatusListFragment{
if(newPostsBtnShown){
toolbarShowNewPostsBtn.setVisibility(View.VISIBLE);
toolbarLogo.setVisibility(View.INVISIBLE);
toolbarLogo.setAlpha(0f);
listsDropdown.setVisibility(View.INVISIBLE);
listsDropdown.setAlpha(0f);
}else{
toolbarShowNewPostsBtn.setVisibility(View.INVISIBLE);
toolbarShowNewPostsBtn.setAlpha(0f);
toolbarShowNewPostsBtn.setScaleX(.8f);
toolbarShowNewPostsBtn.setScaleY(.8f);
toolbarLogo.setVisibility(View.VISIBLE);
listsDropdown.setVisibility(View.VISIBLE);
}
FrameLayout logoWrap=new FrameLayout(getActivity());
logoWrap.addView(toolbarLogo, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER));
FrameLayout logoWrap=new FrameLayout(getActivity()){
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom){
super.onLayout(changed, left, top, right, bottom);
// I'm sorry for doing this. This centers the button within the entire toolbar
int rightGap=getToolbar().getWidth()-right;
toolbarShowNewPostsBtn.offsetLeftAndRight((rightGap-left)/2);
}
};
FrameLayout.LayoutParams ddlp=new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT, Gravity.START);
ddlp.topMargin=ddlp.bottomMargin=V.dp(8);
logoWrap.addView(listsDropdown, ddlp);
logoWrap.addView(toolbarShowNewPostsBtn, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, V.dp(32), Gravity.CENTER));
Toolbar toolbar=getToolbar();
toolbar.addView(logoWrap, new Toolbar.LayoutParams(Gravity.CENTER));
toolbar.addView(logoWrap, new Toolbar.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
toolbar.setContentInsetsRelative(V.dp(16), 0);
}
private void showNewPostsButton(){
@ -368,7 +521,7 @@ public class HomeTimelineFragment extends StatusListFragment{
toolbarShowNewPostsBtn.setVisibility(View.VISIBLE);
AnimatorSet set=new AnimatorSet();
set.playTogether(
ObjectAnimator.ofFloat(toolbarLogo, View.ALPHA, 0f),
ObjectAnimator.ofFloat(listsDropdown, View.ALPHA, 0f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.ALPHA, 1f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_X, 1f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_Y, 1f)
@ -378,7 +531,7 @@ public class HomeTimelineFragment extends StatusListFragment{
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
toolbarLogo.setVisibility(View.INVISIBLE);
listsDropdown.setVisibility(View.INVISIBLE);
currentNewPostsAnim=null;
}
});
@ -393,10 +546,10 @@ public class HomeTimelineFragment extends StatusListFragment{
if(currentNewPostsAnim!=null){
currentNewPostsAnim.cancel();
}
toolbarLogo.setVisibility(View.VISIBLE);
listsDropdown.setVisibility(View.VISIBLE);
AnimatorSet set=new AnimatorSet();
set.playTogether(
ObjectAnimator.ofFloat(toolbarLogo, View.ALPHA, 1f),
ObjectAnimator.ofFloat(listsDropdown, View.ALPHA, 1f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.ALPHA, 0f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_X, .8f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_Y, .8f)
@ -421,6 +574,20 @@ public class HomeTimelineFragment extends StatusListFragment{
}
}
private void onListsDropdownClick(View v){
listsDropdownArrow.animate().rotation(-180f).setDuration(150).setInterpolator(CubicBezierInterpolator.DEFAULT).start();
dropdownController.show(dropdownMainMenuController);
AccountSessionManager.get(accountID).getCacheController().reloadLists(new Callback<>(){
@Override
public void onSuccess(java.util.List<FollowList> result){
lists=result;
}
@Override
public void onError(ErrorResponse error){}
});
}
@Override
public void onDestroyView(){
super.onDestroyView();
@ -443,4 +610,47 @@ public class HomeTimelineFragment extends StatusListFragment{
protected boolean shouldRemoveAccountPostsWhenUnfollowing(){
return true;
}
@Override
public Toolbar getToolbar(){
return super.getToolbar();
}
@Override
public void onDropdownWillDismiss(){
listsDropdownArrow.animate().rotation(0f).setDuration(150).setInterpolator(CubicBezierInterpolator.DEFAULT).start();
}
@Override
public void onDropdownDismissed(){
}
@Override
public void reload(){
if(currentRequest!=null){
currentRequest.cancel();
currentRequest=null;
}
refreshing=true;
showProgress();
loadData();
listsDropdownText.setText(getCurrentListTitle());
invalidateOptionsMenu();
}
private String getCurrentListTitle(){
return switch(listMode){
case FOLLOWING -> getString(R.string.timeline_following);
case LOCAL -> getString(R.string.local_timeline);
case LIST -> currentList.title;
};
}
private enum ListMode{
FOLLOWING,
LOCAL,
LIST
}
}

View File

@ -0,0 +1,300 @@
package org.joinmastodon.android.fragments;
import android.os.Bundle;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.widget.ImageButton;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.api.requests.lists.AddAccountsToList;
import org.joinmastodon.android.api.requests.lists.GetListAccounts;
import org.joinmastodon.android.api.requests.lists.RemoveAccountsFromList;
import org.joinmastodon.android.events.AccountAddedToListEvent;
import org.joinmastodon.android.events.AccountRemovedFromListEvent;
import org.joinmastodon.android.fragments.account_list.AddListMembersFragment;
import org.joinmastodon.android.fragments.account_list.PaginatedAccountListFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.ActionModeHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.viewholders.AccountViewHolder;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.utils.V;
public class ListMembersFragment extends PaginatedAccountListFragment{
private static final int ADD_MEMBER_RESULT=600;
private ImageButton fab;
private FollowList followList;
private boolean inSelectionMode;
private Set<String> selectedAccounts=new HashSet<>();
private ActionMode actionMode;
private MenuItem deleteItem;
public ListMembersFragment(){
setListLayoutId(R.layout.recycler_fragment_with_fab);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
followList=Parcels.unwrap(getArguments().getParcelable("list"));
setTitle(R.string.list_members);
setHasOptionsMenu(true);
E.register(this);
}
@Override
public void onDestroy(){
super.onDestroy();
E.unregister(this);
}
@Override
public HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count){
return new GetListAccounts(followList.id, maxID, count);
}
@Override
protected boolean hasSubtitle(){
return false;
}
@Override
protected void onConfigureViewHolder(AccountViewHolder holder){
super.onConfigureViewHolder(holder);
holder.setStyle(inSelectionMode ? AccountViewHolder.AccessoryType.CHECKBOX : AccountViewHolder.AccessoryType.MENU, false);
holder.setOnClickListener(this::onItemClick);
holder.setOnLongClickListener(this::onItemLongClick);
holder.getContextMenu().getMenu().add(0, R.id.remove_from_list, 0, R.string.remove_from_list);
holder.setOnCustomMenuItemSelectedListener(item->onItemMenuItemSelected(holder, item));
}
@Override
protected void onBindViewHolder(AccountViewHolder holder){
super.onBindViewHolder(holder);
holder.setStyle(inSelectionMode ? AccountViewHolder.AccessoryType.CHECKBOX : AccountViewHolder.AccessoryType.MENU, false);
if(inSelectionMode){
holder.setChecked(selectedAccounts.contains(holder.getItem().account.id));
}
}
@Override
public boolean wantsLightStatusBar(){
if(actionMode!=null)
return UiUtils.isDarkTheme();
return super.wantsLightStatusBar();
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
inflater.inflate(R.menu.selectable_list, menu);
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
int id=item.getItemId();
if(id==R.id.select){
enterSelectionMode();
}else if(id==R.id.select_all){
for(AccountViewModel a:data){
selectedAccounts.add(a.account.id);
}
enterSelectionMode();
}
return true;
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
fab=view.findViewById(R.id.fab);
fab.setImageResource(R.drawable.ic_add_24px);
fab.setContentDescription(getString(R.string.add_list_member));
fab.setOnClickListener(v->onFabClick());
}
@Override
public void onFragmentResult(int reqCode, boolean success, Bundle result){
if(reqCode==ADD_MEMBER_RESULT && success){
Account acc=Objects.requireNonNull(Parcels.unwrap(result.getParcelable("selectedAccount")));
addAccounts(List.of(acc));
}
}
@Subscribe
public void onAccountRemovedFromList(AccountRemovedFromListEvent ev){
if(ev.accountID.equals(accountID) && ev.listID.equals(followList.id)){
removeAccountRows(Set.of(ev.targetAccountID));
}
}
@Subscribe
public void onAccountAddedToList(AccountAddedToListEvent ev){
if(ev.accountID.equals(accountID) && ev.listID.equals(followList.id)){
data.add(new AccountViewModel(ev.account, accountID));
list.getAdapter().notifyItemInserted(data.size()-1);
}
}
private void onFabClick(){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.goForResult(getActivity(), AddListMembersFragment.class, args, ADD_MEMBER_RESULT, this);
}
private void onItemClick(AccountViewHolder holder){
if(inSelectionMode){
String id=holder.getItem().account.id;
if(selectedAccounts.contains(id)){
selectedAccounts.remove(id);
holder.setChecked(false);
}else{
selectedAccounts.add(id);
holder.setChecked(true);
}
updateActionModeTitle();
deleteItem.setEnabled(!selectedAccounts.isEmpty());
return;
}
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("profileAccount", Parcels.wrap(holder.getItem().account));
Nav.go(getActivity(), ProfileFragment.class, args);
}
private boolean onItemLongClick(AccountViewHolder holder){
if(inSelectionMode)
return false;
selectedAccounts.add(holder.getItem().account.id);
enterSelectionMode();
return true;
}
private void onItemMenuItemSelected(AccountViewHolder holder, MenuItem item){
int id=item.getItemId();
if(id==R.id.remove_from_list){
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.confirm_remove_list_member)
.setPositiveButton(R.string.remove, (dlg, which)->removeAccounts(Set.of(holder.getItem().account.id)))
.setNegativeButton(R.string.cancel, null)
.show();
}
}
private void updateItemsForSelectionModeTransition(){
list.getAdapter().notifyItemRangeChanged(0, data.size());
}
private void enterSelectionMode(){
inSelectionMode=true;
updateItemsForSelectionModeTransition();
V.setVisibilityAnimated(fab, View.INVISIBLE);
actionMode=ActionModeHelper.startActionMode(this, ()->elevationOnScrollListener.getCurrentStatusBarColor(), new ActionMode.Callback(){
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu){
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu){
mode.getMenuInflater().inflate(R.menu.settings_filter_words_action_mode, menu);
deleteItem=menu.findItem(R.id.delete);
return true;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item){
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.confirm_remove_list_members)
.setPositiveButton(R.string.remove, (dlg, which)->removeAccounts(new HashSet<>(selectedAccounts)))
.setNegativeButton(R.string.cancel, null)
.show();
return true;
}
@Override
public void onDestroyActionMode(ActionMode mode){
actionMode=null;
inSelectionMode=false;
selectedAccounts.clear();
updateItemsForSelectionModeTransition();
V.setVisibilityAnimated(fab, View.VISIBLE);
}
});
updateActionModeTitle();
}
private void updateActionModeTitle(){
actionMode.setTitle(getResources().getQuantityString(R.plurals.x_items_selected, selectedAccounts.size(), selectedAccounts.size()));
}
private void removeAccounts(Set<String> ids){
new RemoveAccountsFromList(followList.id, ids)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Void result){
if(inSelectionMode)
actionMode.finish();
removeAccountRows(ids);
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.wrapProgress(getActivity(), R.string.loading, true)
.exec(accountID);
}
private void addAccounts(Collection<Account> accounts){
new AddAccountsToList(followList.id, accounts.stream().map(a->a.id).collect(Collectors.toSet()))
.setCallback(new Callback<>(){
@Override
public void onSuccess(Void result){
for(Account acc:accounts){
data.add(new AccountViewModel(acc, accountID));
}
list.getAdapter().notifyItemRangeInserted(data.size()-accounts.size(), accounts.size());
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.wrapProgress(getActivity(), R.string.loading, true)
.exec(accountID);
}
private void removeAccountRows(Set<String> ids){
for(int i=data.size()-1;i>=0;i--){
if(ids.contains(data.get(i).account.id)){
data.remove(i);
list.getAdapter().notifyItemRemoved(i);
}
}
}
}

View File

@ -0,0 +1,61 @@
package org.joinmastodon.android.fragments;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.timelines.GetListTimeline;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.Status;
import org.parceler.Parcels;
import java.util.List;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.SimpleCallback;
public class ListTimelineFragment extends StatusListFragment{
private FollowList followList;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
followList=Parcels.unwrap(getArguments().getParcelable("list"));
setTitle(followList.title);
setHasOptionsMenu(true);
loadData();
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetListTimeline(followList.id, offset>0 ? getMaxID() : null, null, count, null)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
onDataLoaded(result, !result.isEmpty());
}
})
.exec(accountID);
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
inflater.inflate(R.menu.standalone_list_timeline, menu);
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
int id=item.getItemId();
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("list", Parcels.wrap(followList));
if(id==R.id.members){
Nav.go(getActivity(), ListMembersFragment.class, args);
}else if(id==R.id.edit_list){
Nav.go(getActivity(), EditListFragment.class, args);
}
return true;
}
}

View File

@ -0,0 +1,95 @@
package org.joinmastodon.android.fragments;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.tags.GetFollowedTags;
import org.joinmastodon.android.api.requests.tags.SetTagFollowed;
import org.joinmastodon.android.fragments.settings.BaseSettingsFragment;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.viewmodel.ListItemWithOptionsMenu;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.stream.Collectors;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
public class ManageFollowedHashtagsFragment extends BaseSettingsFragment<Hashtag> implements ListItemWithOptionsMenu.OptionsMenuListener<Hashtag>{
private String maxID;
public ManageFollowedHashtagsFragment(){
super(100);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.manage_hashtags);
loadData();
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetFollowedTags(offset>0 ? maxID : null, count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(HeaderPaginationList<Hashtag> result){
maxID=null;
if(result.nextPageUri!=null)
maxID=result.nextPageUri.getQueryParameter("max_id");
onDataLoaded(result.stream().map(t->{
int posts=t.getWeekPosts();
return new ListItemWithOptionsMenu<>(t.name, getResources().getQuantityString(R.plurals.x_posts_recently, posts, posts), ManageFollowedHashtagsFragment.this,
R.drawable.ic_tag_24px, ManageFollowedHashtagsFragment.this::onItemClick, t, false);
}).collect(Collectors.toList()), maxID!=null);
}
})
.exec(accountID);
}
@Override
public void onConfigureListItemOptionsMenu(ListItemWithOptionsMenu<Hashtag> item, Menu menu){
menu.clear();
menu.add(getString(R.string.unfollow_user, "#"+item.parentObject.name));
}
@Override
public void onListItemOptionSelected(ListItemWithOptionsMenu<Hashtag> item, MenuItem menuItem){
new M3AlertDialogBuilder(getActivity())
.setTitle(getString(R.string.unfollow_confirmation, "#"+item.parentObject.name))
.setPositiveButton(R.string.unfollow, (dlg, which)->doUnfollow(item))
.setNegativeButton(R.string.cancel, null)
.show();
}
private void onItemClick(ListItemWithOptionsMenu<Hashtag> item){
UiUtils.openHashtagTimeline(getActivity(), accountID, item.parentObject);
}
private void doUnfollow(ListItemWithOptionsMenu<Hashtag> item){
new SetTagFollowed(item.parentObject.name, false)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Hashtag result){
int index=data.indexOf(item);
if(index==-1)
return;
data.remove(index);
list.getAdapter().notifyItemRemoved(index);
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.wrapProgress(getActivity(), R.string.loading, true)
.exec(accountID);
}
}

View File

@ -0,0 +1,152 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.lists.DeleteList;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.ListDeletedEvent;
import org.joinmastodon.android.events.ListUpdatedEvent;
import org.joinmastodon.android.fragments.settings.BaseSettingsFragment;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.model.viewmodel.ListItemWithOptionsMenu;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.parceler.Parcels;
import java.util.List;
import java.util.stream.Collectors;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
public class ManageListsFragment extends BaseSettingsFragment<FollowList> implements ListItemWithOptionsMenu.OptionsMenuListener<FollowList>{
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.manage_lists);
loadData();
setRefreshEnabled(true);
E.register(this);
}
@Override
public void onDestroy(){
super.onDestroy();
E.unregister(this);
}
@Override
protected void doLoadData(int offset, int count){
Callback<List<FollowList>> callback=new SimpleCallback<>(this){
@Override
public void onSuccess(List<FollowList> result){
onDataLoaded(result.stream().map(l->new ListItemWithOptionsMenu<>(l.title, null, ManageListsFragment.this, R.drawable.ic_list_alt_24px, ManageListsFragment.this::onListClick, l, false)).collect(Collectors.toList()), false);
}
};
if(refreshing){
AccountSessionManager.get(accountID)
.getCacheController()
.reloadLists(callback);
}else{
AccountSessionManager.get(accountID)
.getCacheController()
.getLists(callback);
}
}
private void onListClick(ListItemWithOptionsMenu<FollowList> item){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("list", Parcels.wrap(item.parentObject));
Nav.go(getActivity(), ListTimelineFragment.class, args);
}
@Override
public void onConfigureListItemOptionsMenu(ListItemWithOptionsMenu<FollowList> item, Menu menu){
menu.add(0, R.id.edit, 0, R.string.edit_list);
menu.add(0, R.id.delete, 1, R.string.delete_list);
}
@Override
public void onListItemOptionSelected(ListItemWithOptionsMenu<FollowList> item, MenuItem menuItem){
int id=menuItem.getItemId();
if(id==R.id.edit){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("list", Parcels.wrap(item.parentObject));
Nav.go(getActivity(), EditListFragment.class, args);
}else if(id==R.id.delete){
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.delete_list)
.setMessage(getString(R.string.delete_list_confirm, item.parentObject.title))
.setPositiveButton(R.string.delete, (dlg, which)->doDeleteList(item.parentObject))
.setNegativeButton(R.string.cancel, null)
.show();
}
}
private void doDeleteList(FollowList list){
new DeleteList(list.id)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Void result){
for(int i=0;i<data.size();i++){
if(data.get(i).parentObject==list){
data.remove(i);
itemsAdapter.notifyItemRemoved(i);
AccountSessionManager.get(accountID).getCacheController().reloadLists(null);
break;
}
}
}
@Override
public void onError(ErrorResponse error){
Activity activity=getActivity();
if(activity==null)
return;
error.showToast(activity);
}
})
.wrapProgress(getActivity(), R.string.loading, true)
.exec(accountID);
}
@Subscribe
public void onListUpdated(ListUpdatedEvent ev){
if(!ev.accountID.equals(accountID))
return;
for(ListItem<FollowList> item:data){
if(item.parentObject.id.equals(ev.list.id)){
item.parentObject=ev.list;
item.title=ev.list.title;
rebindItem(item);
break;
}
}
}
@Subscribe
public void onListDeleted(ListDeletedEvent ev){
if(!ev.accountID.equals(accountID))
return;
int i=0;
for(ListItem<FollowList> item:data){
if(item.parentObject.id.equals(ev.listID)){
data.remove(i);
itemsAdapter.notifyItemRemoved(i);
break;
}
i++;
}
}
}

View File

@ -5,6 +5,7 @@ import android.view.View;
import android.widget.Toolbar;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.utils.ElevationOnScrollListener;
@ -37,6 +38,7 @@ public abstract class MastodonRecyclerFragment<T> extends BaseRecyclerFragment<T
super.onViewCreated(view, savedInstanceState);
if(wantsElevationOnScrollEffect())
list.addOnScrollListener(elevationOnScrollListener=new ElevationOnScrollListener((FragmentRootLinearLayout) view, getViewsForElevationEffect()));
list.setItemAnimator(new BetterItemAnimator());
if(refreshLayout!=null){
int colorBackground=UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background);
int colorPrimary=UiUtils.getThemeColor(getActivity(), R.attr.colorM3Primary);

View File

@ -609,6 +609,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
menu.findItem(R.id.block_domain).setTitle(getString(relationship.domainBlocking ? R.string.unblock_domain : R.string.block_domain, account.getDomain()));
else
menu.findItem(R.id.block_domain).setVisible(false);
menu.findItem(R.id.add_to_list).setVisible(relationship.following);
}
@Override
@ -662,6 +663,11 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
}else if(id==R.id.save){
if(isInEditMode)
saveAndExitEditMode();
}else if(id==R.id.add_to_list){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("targetAccount", Parcels.wrap(account));
Nav.go(getActivity(), AddAccountToListsFragment.class, args);
}
return true;
}

View File

@ -5,7 +5,9 @@ import android.text.TextUtils;
import android.view.View;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.requests.search.GetSearchResults;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.SearchResults;
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
import org.joinmastodon.android.ui.SearchViewHelper;
@ -13,13 +15,14 @@ import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.viewholders.AccountViewHolder;
import org.parceler.Parcels;
import java.util.List;
import java.util.stream.Collectors;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.SimpleCallback;
public class ComposeAccountSearchFragment extends BaseAccountListFragment{
private String currentQuery;
public class AccountSearchFragment extends BaseAccountListFragment{
protected String currentQuery;
private boolean resultDelivered;
private SearchViewHelper searchViewHelper;
@ -33,7 +36,7 @@ public class ComposeAccountSearchFragment extends BaseAccountListFragment{
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
searchViewHelper=new SearchViewHelper(getActivity(), getToolbarContext(), getString(R.string.search_hint));
searchViewHelper=new SearchViewHelper(getActivity(), getToolbarContext(), getSearchViewPlaceholder());
searchViewHelper.setListeners(this::onQueryChanged, null);
searchViewHelper.addDivider(contentView);
super.onViewCreated(view, savedInstanceState);
@ -51,13 +54,21 @@ public class ComposeAccountSearchFragment extends BaseAccountListFragment{
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(SearchResults result){
setEmptyText(R.string.no_search_results);
onDataLoaded(result.accounts.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList()), false);
AccountSearchFragment.this.onSuccess(result.accounts);
}
})
.exec(accountID);
}
protected void onSuccess(List<Account> result){
setEmptyText(R.string.no_search_results);
onDataLoaded(result.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList()), false);
}
protected String getSearchViewPlaceholder(){
return getString(R.string.search_hint);
}
@Override
protected void onUpdateToolbar(){
super.onUpdateToolbar();

View File

@ -0,0 +1,29 @@
package org.joinmastodon.android.fragments.account_list;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.SearchAccounts;
import org.joinmastodon.android.model.Account;
import java.util.List;
import me.grishka.appkit.api.SimpleCallback;
public class AddListMembersFragment extends AccountSearchFragment{
@Override
protected void doLoadData(int offset, int count){
refreshing=true;
currentRequest=new SearchAccounts(currentQuery, 0, 0, false, true)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Account> result){
AddListMembersFragment.this.onSuccess(result);
}
})
.exec(accountID);
}
@Override
protected String getSearchViewPlaceholder(){
return getString(R.string.search_among_people_you_follow);
}
}

View File

@ -150,6 +150,7 @@ public abstract class BaseAccountListFragment extends MastodonRecyclerFragment<A
}
protected void onConfigureViewHolder(AccountViewHolder holder){}
protected void onBindViewHolder(AccountViewHolder holder){}
protected class AccountsAdapter extends UsableRecyclerView.Adapter<AccountViewHolder> implements ImageLoaderRecyclerAdapter{
public AccountsAdapter(){
@ -167,6 +168,7 @@ public abstract class BaseAccountListFragment extends MastodonRecyclerFragment<A
@Override
public void onBindViewHolder(AccountViewHolder holder, int position){
holder.bind(data.get(position));
BaseAccountListFragment.this.onBindViewHolder(holder);
super.onBindViewHolder(holder, position);
}

View File

@ -1,7 +1,6 @@
package org.joinmastodon.android.fragments.discover;
import android.os.Bundle;
import android.view.View;
import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager;
@ -29,7 +28,7 @@ public class LocalTimelineFragment extends StatusListFragment{
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetPublicTimeline(true, false, refreshing ? null : maxID, count)
currentRequest=new GetPublicTimeline(true, false, refreshing ? null : maxID, null, count, null)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){

View File

@ -112,7 +112,7 @@ public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultVi
onDataLoaded(results.stream().map(sr->{
SearchResultViewModel vm=new SearchResultViewModel(sr, accountID, true);
if(sr.type==SearchResult.Type.HASHTAG){
vm.hashtagItem.onClick=()->openHashtag(sr);
vm.hashtagItem.setOnClick(i->openHashtag(sr));
}
return vm;
}).collect(Collectors.toList()), false);
@ -129,7 +129,7 @@ public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultVi
.map(sr->{
SearchResultViewModel vm=new SearchResultViewModel(sr, accountID, false);
if(sr.type==SearchResult.Type.HASHTAG){
vm.hashtagItem.onClick=()->openHashtag(sr);
vm.hashtagItem.setOnClick(i->openHashtag(sr));
}
return vm;
})
@ -389,18 +389,18 @@ public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultVi
deliverResult(currentQuery, null);
}
private void onOpenURLClick(){
private void onOpenURLClick(ListItem<?> item_){
((MainActivity)getActivity()).handleURL(Uri.parse(searchViewHelper.getQuery()), accountID);
}
private void onGoToHashtagClick(){
private void onGoToHashtagClick(ListItem<?> item_){
String q=searchViewHelper.getQuery();
if(q.startsWith("#"))
q=q.substring(1);
UiUtils.openHashtagTimeline(getActivity(), accountID, q);
}
private void onGoToAccountClick(){
private void onGoToAccountClick(ListItem<?> item_){
String q=searchViewHelper.getQuery();
if(!q.startsWith("@")){
q="@"+q;
@ -411,11 +411,11 @@ public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultVi
((MainActivity)getActivity()).openSearchQuery(q, accountID, R.string.loading, true);
}
private void onGoToStatusSearchClick(){
private void onGoToStatusSearchClick(ListItem<?> item_){
deliverResult(searchViewHelper.getQuery(), SearchResult.Type.STATUS);
}
private void onGoToAccountSearchClick(){
private void onGoToAccountSearchClick(ListItem<?> item_){
deliverResult(searchViewHelper.getQuery(), SearchResult.Type.ACCOUNT);
}

View File

@ -12,12 +12,10 @@ import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.adapters.GenericListItemsAdapter;
import org.joinmastodon.android.ui.viewholders.CheckableListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.ListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.SimpleListItemViewHolder;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.utils.V;
public abstract class BaseSettingsFragment<T> extends MastodonRecyclerFragment<ListItem<T>>{
protected GenericListItemsAdapter<T> itemsAdapter;
@ -45,7 +43,7 @@ public abstract class BaseSettingsFragment<T> extends MastodonRecyclerFragment<L
@Override
protected RecyclerView.Adapter<?> getAdapter(){
return itemsAdapter=new GenericListItemsAdapter<T>(data);
return itemsAdapter=new GenericListItemsAdapter<T>(imgLoader, data);
}
@Override
@ -59,12 +57,13 @@ public abstract class BaseSettingsFragment<T> extends MastodonRecyclerFragment<L
return 0;
}
protected void toggleCheckableItem(CheckableListItem<T> item){
item.toggle();
protected void toggleCheckableItem(ListItem<?> item){
if(item instanceof CheckableListItem<?> checkable)
checkable.toggle();
rebindItem(item);
}
protected void rebindItem(ListItem<T> item){
protected void rebindItem(ListItem<?> item){
if(list==null)
return;
if(list.findViewHolderForAdapterPosition(indexOfItemsAdapter()+data.indexOf(item)) instanceof ListItemViewHolder<?> holder){

View File

@ -73,7 +73,7 @@ public class EditFilterFragment extends BaseSettingsFragment<Void> implements On
durationItem=new ListItem<>(R.string.settings_filter_duration, 0, this::onDurationClick),
wordsItem=new ListItem<>(R.string.settings_filter_muted_words, 0, this::onWordsClick),
contextItem=new ListItem<>(R.string.settings_filter_context, 0, this::onContextClick),
cwItem=new CheckableListItem<>(R.string.settings_filter_show_cw, R.string.settings_filter_show_cw_explanation, CheckableListItem.Style.SWITCH, filter==null || filter.filterAction==FilterAction.WARN, ()->toggleCheckableItem(cwItem))
cwItem=new CheckableListItem<>(R.string.settings_filter_show_cw, R.string.settings_filter_show_cw_explanation, CheckableListItem.Style.SWITCH, filter==null || filter.filterAction==FilterAction.WARN, this::toggleCheckableItem)
));
if(filter!=null){
@ -113,7 +113,7 @@ public class EditFilterFragment extends BaseSettingsFragment<Void> implements On
return 1;
}
private void onDurationClick(){
private void onDurationClick(ListItem<Void> item_){
int[] durationOptions={
1800,
3600,
@ -182,21 +182,21 @@ public class EditFilterFragment extends BaseSettingsFragment<Void> implements On
alert.setOnDismissListener(dialog->callback.accept(null));
}
private void onWordsClick(){
private void onWordsClick(ListItem<Void> item){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelableArrayList("words", (ArrayList<? extends Parcelable>) keywords.stream().map(Parcels::wrap).collect(Collectors.toCollection(ArrayList::new)));
Nav.goForResult(getActivity(), FilterWordsFragment.class, args, WORDS_RESULT, this);
}
private void onContextClick(){
private void onContextClick(ListItem<Void> item){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putSerializable("context", context);
Nav.goForResult(getActivity(), FilterContextFragment.class, args, CONTEXT_RESULT, this);
}
private void onDeleteClick(){
private void onDeleteClick(ListItem<Void> item_){
AlertDialog alert=new M3AlertDialogBuilder(getActivity())
.setTitle(getString(R.string.settings_delete_filter_title, filter.title))
.setMessage(R.string.settings_delete_filter_confirmation)

View File

@ -22,10 +22,9 @@ public class FilterContextFragment extends BaseSettingsFragment<FilterContext> i
setTitle(R.string.settings_filter_context);
context=(EnumSet<FilterContext>) getArguments().getSerializable("context");
onDataLoaded(Arrays.stream(FilterContext.values()).map(c->{
CheckableListItem<FilterContext> item=new CheckableListItem<>(c.getDisplayNameRes(), 0, CheckableListItem.Style.CHECKBOX, context.contains(c), null);
CheckableListItem<FilterContext> item=new CheckableListItem<>(c.getDisplayNameRes(), 0, CheckableListItem.Style.CHECKBOX, context.contains(c), this::toggleCheckableItem);
item.parentObject=c;
item.isEnabled=true;
item.onClick=()->toggleCheckableItem(item);
return item;
}).collect(Collectors.toList()));
}

View File

@ -1,11 +1,6 @@
package org.joinmastodon.android.fragments.settings;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.IntEvaluator;
import android.animation.ObjectAnimator;
import android.app.AlertDialog;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle;
import android.os.Parcelable;
@ -27,6 +22,7 @@ import org.joinmastodon.android.model.FilterKeyword;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.ActionModeHelper;
import org.joinmastodon.android.ui.utils.SimpleTextWatcher;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.FloatingHintEditTextLayout;
@ -37,7 +33,6 @@ import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import me.grishka.appkit.FragmentStackActivity;
import me.grishka.appkit.fragments.OnBackPressedListener;
import me.grishka.appkit.utils.V;
@ -60,7 +55,7 @@ public class FilterWordsFragment extends BaseSettingsFragment<FilterKeyword> imp
FilterKeyword word=Parcels.unwrap(p);
ListItem<FilterKeyword> item=new ListItem<>(word.keyword, null, null, word);
item.isEnabled=true;
item.onClick=()->onWordClick(item);
item.setOnClick(this::onWordClick);
return item;
}).collect(Collectors.toList()));
setHasOptionsMenu(true);
@ -114,7 +109,7 @@ public class FilterWordsFragment extends BaseSettingsFragment<FilterKeyword> imp
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
inflater.inflate(R.menu.settings_filter_words, menu);
inflater.inflate(R.menu.selectable_list, menu);
}
@Override
@ -174,7 +169,7 @@ public class FilterWordsFragment extends BaseSettingsFragment<FilterKeyword> imp
w.keyword=input;
ListItem<FilterKeyword> item=new ListItem<>(w.keyword, null, null, w);
item.isEnabled=true;
item.onClick=()->onWordClick(item);
item.setOnClick(this::onWordClick);
data.add(item);
itemsAdapter.notifyItemInserted(data.size()-1);
}else{
@ -228,29 +223,15 @@ public class FilterWordsFragment extends BaseSettingsFragment<FilterKeyword> imp
return;
V.setVisibilityAnimated(fab, View.GONE);
actionMode=getActivity().startActionMode(new ActionMode.Callback(){
actionMode=ActionModeHelper.startActionMode(this, ()->elevationOnScrollListener.getCurrentStatusBarColor(), new ActionMode.Callback(){
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu){
ObjectAnimator anim=ObjectAnimator.ofInt(getActivity().getWindow(), "statusBarColor", elevationOnScrollListener.getCurrentStatusBarColor(), UiUtils.getThemeColor(getActivity(), R.attr.colorM3Primary));
anim.setEvaluator(new IntEvaluator(){
@Override
public Integer evaluate(float fraction, Integer startValue, Integer endValue){
return UiUtils.alphaBlendColors(startValue, endValue, fraction);
}
});
anim.start();
((FragmentStackActivity) getActivity()).invalidateSystemBarColors(FilterWordsFragment.this);
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu){
mode.getMenuInflater().inflate(R.menu.settings_filter_words_action_mode, menu);
for(int i=0;i<menu.size();i++){
Drawable icon=menu.getItem(i).getIcon().mutate();
icon.setTint(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnPrimary));
menu.getItem(i).setIcon(icon);
}
deleteItem=menu.findItem(R.id.delete);
return true;
}
@ -266,21 +247,6 @@ public class FilterWordsFragment extends BaseSettingsFragment<FilterKeyword> imp
@Override
public void onDestroyActionMode(ActionMode mode){
leaveSelectionMode(true);
ObjectAnimator anim=ObjectAnimator.ofInt(getActivity().getWindow(), "statusBarColor", UiUtils.getThemeColor(getActivity(), R.attr.colorM3Primary), elevationOnScrollListener.getCurrentStatusBarColor());
anim.setEvaluator(new IntEvaluator(){
@Override
public Integer evaluate(float fraction, Integer startValue, Integer endValue){
return UiUtils.alphaBlendColors(startValue, endValue, fraction);
}
});
anim.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
getActivity().getWindow().setStatusBarColor(0);
}
});
anim.start();
((FragmentStackActivity) getActivity()).invalidateSystemBarColors(FilterWordsFragment.this);
}
});
@ -289,7 +255,7 @@ public class FilterWordsFragment extends BaseSettingsFragment<FilterKeyword> imp
ListItem<FilterKeyword> item=data.get(i);
CheckableListItem<FilterKeyword> newItem=new CheckableListItem<>(item.title, null, CheckableListItem.Style.CHECKBOX, selectAll, null);
newItem.isEnabled=true;
newItem.onClick=()->onSelectionModeWordClick(newItem);
newItem.setOnClick(this::onSelectionModeWordClick);
newItem.parentObject=item.parentObject;
if(selectAll)
selectedItems.add(newItem);
@ -313,7 +279,7 @@ public class FilterWordsFragment extends BaseSettingsFragment<FilterKeyword> imp
ListItem<FilterKeyword> item=data.get(i);
ListItem<FilterKeyword> newItem=new ListItem<>(item.title, null, null);
newItem.isEnabled=true;
newItem.onClick=()->onWordClick(newItem);
newItem.setOnClick(this::onWordClick);
newItem.parentObject=item.parentObject;
data.set(i, newItem);
}

View File

@ -32,10 +32,10 @@ public class SettingsAboutAppFragment extends BaseSettingsFragment<Void>{
setTitle(getString(R.string.about_app, getString(R.string.app_name)));
AccountSession s=AccountSessionManager.get(accountID);
onDataLoaded(List.of(
new ListItem<>(R.string.settings_even_more, 0, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+s.domain+"/auth/edit")),
new ListItem<>(R.string.settings_contribute, 0, ()->UiUtils.launchWebBrowser(getActivity(), getString(R.string.github_url))),
new ListItem<>(R.string.settings_tos, 0, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+s.domain+"/terms")),
new ListItem<>(R.string.settings_privacy_policy, 0, ()->UiUtils.launchWebBrowser(getActivity(), getString(R.string.privacy_policy_url)), 0, true),
new ListItem<>(R.string.settings_even_more, 0, i->UiUtils.launchWebBrowser(getActivity(), "https://"+s.domain+"/auth/edit")),
new ListItem<>(R.string.settings_contribute, 0, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.github_url))),
new ListItem<>(R.string.settings_tos, 0, i->UiUtils.launchWebBrowser(getActivity(), "https://"+s.domain+"/terms")),
new ListItem<>(R.string.settings_privacy_policy, 0, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.privacy_policy_url)), 0, true),
mediaCacheItem=new ListItem<>(R.string.settings_clear_cache, 0, this::onClearMediaCacheClick)
));
@ -62,7 +62,7 @@ public class SettingsAboutAppFragment extends BaseSettingsFragment<Void>{
return adapter;
}
private void onClearMediaCacheClick(){
private void onClearMediaCacheClick(ListItem<?> item){
MastodonAPIController.runInBackground(()->{
Activity activity=getActivity();
ImageCache.getInstance(getActivity()).clear();

View File

@ -33,19 +33,19 @@ public class SettingsBehaviorFragment extends BaseSettingsFragment<Void>{
onDataLoaded(List.of(
languageItem=new ListItem<>(getString(R.string.default_post_language), postLanguage!=null ? postLanguage.getDisplayName(Locale.getDefault()) : null, R.drawable.ic_language_24px, this::onDefaultLanguageClick),
altTextItem=new CheckableListItem<>(R.string.settings_alt_text_reminders, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.altTextReminders, R.drawable.ic_alt_24px, ()->toggleCheckableItem(altTextItem)),
playGifsItem=new CheckableListItem<>(R.string.settings_gif, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.playGifs, R.drawable.ic_animation_24px, ()->toggleCheckableItem(playGifsItem)),
customTabsItem=new CheckableListItem<>(R.string.settings_custom_tabs, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.useCustomTabs, R.drawable.ic_open_in_browser_24px, ()->toggleCheckableItem(customTabsItem)),
confirmUnfollowItem=new CheckableListItem<>(R.string.settings_confirm_unfollow, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmUnfollow, R.drawable.ic_person_remove_24px, ()->toggleCheckableItem(confirmUnfollowItem)),
confirmBoostItem=new CheckableListItem<>(R.string.settings_confirm_boost, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmBoost, R.drawable.ic_repeat_24px, ()->toggleCheckableItem(confirmBoostItem)),
confirmDeleteItem=new CheckableListItem<>(R.string.settings_confirm_delete_post, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmDeletePost, R.drawable.ic_delete_24px, ()->toggleCheckableItem(confirmDeleteItem))
altTextItem=new CheckableListItem<>(R.string.settings_alt_text_reminders, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.altTextReminders, R.drawable.ic_alt_24px, this::toggleCheckableItem),
playGifsItem=new CheckableListItem<>(R.string.settings_gif, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.playGifs, R.drawable.ic_animation_24px, this::toggleCheckableItem),
customTabsItem=new CheckableListItem<>(R.string.settings_custom_tabs, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.useCustomTabs, R.drawable.ic_open_in_browser_24px, this::toggleCheckableItem),
confirmUnfollowItem=new CheckableListItem<>(R.string.settings_confirm_unfollow, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmUnfollow, R.drawable.ic_person_remove_24px, this::toggleCheckableItem),
confirmBoostItem=new CheckableListItem<>(R.string.settings_confirm_boost, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmBoost, R.drawable.ic_repeat_24px, this::toggleCheckableItem),
confirmDeleteItem=new CheckableListItem<>(R.string.settings_confirm_delete_post, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmDeletePost, R.drawable.ic_delete_24px, this::toggleCheckableItem)
));
}
@Override
protected void doLoadData(int offset, int count){}
private void onDefaultLanguageClick(){
private void onDefaultLanguageClick(ListItem<?> item){
ComposeLanguageAlertViewController vc=new ComposeLanguageAlertViewController(getActivity(), null, new ComposeLanguageAlertViewController.SelectedOption(-1, postLanguage), null);
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.default_post_language)

View File

@ -39,7 +39,7 @@ public class SettingsDebugFragment extends BaseSettingsFragment<Void>{
@Override
protected void doLoadData(int offset, int count){}
private void onTestEmailConfirmClick(){
private void onTestEmailConfirmClick(ListItem<?> item){
AccountSession sess=AccountSessionManager.getInstance().getAccount(accountID);
sess.activated=false;
sess.activationInfo=new AccountActivationInfo("test@email", System.currentTimeMillis());
@ -49,18 +49,18 @@ public class SettingsDebugFragment extends BaseSettingsFragment<Void>{
Nav.goClearingStack(getActivity(), AccountActivationFragment.class, args);
}
private void onForceSelfUpdateClick(){
private void onForceSelfUpdateClick(ListItem<?> item){
GithubSelfUpdater.forceUpdate=true;
GithubSelfUpdater.getInstance().maybeCheckForUpdates();
restartUI();
}
private void onResetUpdaterClick(){
private void onResetUpdaterClick(ListItem<?> item){
GithubSelfUpdater.getInstance().reset();
restartUI();
}
private void onResetDiscoverBannersClick(){
private void onResetDiscoverBannersClick(ListItem<?> item){
DiscoverInfoBannerHelper.reset();
restartUI();
}

View File

@ -39,10 +39,10 @@ public class SettingsDisplayFragment extends BaseSettingsFragment<Void>{
AccountLocalPreferences lp=s.getLocalPreferences();
onDataLoaded(List.of(
themeItem=new ListItem<>(R.string.settings_theme, getAppearanceValue(), R.drawable.ic_dark_mode_24px, this::onAppearanceClick),
showCWsItem=new CheckableListItem<>(R.string.settings_show_cws, 0, CheckableListItem.Style.SWITCH, lp.showCWs, R.drawable.ic_warning_24px, ()->toggleCheckableItem(showCWsItem)),
hideSensitiveMediaItem=new CheckableListItem<>(R.string.settings_hide_sensitive_media, 0, CheckableListItem.Style.SWITCH, lp.hideSensitiveMedia, R.drawable.ic_no_adult_content_24px, ()->toggleCheckableItem(hideSensitiveMediaItem)),
interactionCountsItem=new CheckableListItem<>(R.string.settings_show_interaction_counts, 0, CheckableListItem.Style.SWITCH, lp.showInteractionCounts, R.drawable.ic_social_leaderboard_24px, ()->toggleCheckableItem(interactionCountsItem)),
emojiInNamesItem=new CheckableListItem<>(R.string.settings_show_emoji_in_names, 0, CheckableListItem.Style.SWITCH, lp.customEmojiInNames, R.drawable.ic_emoticon_24px, ()->toggleCheckableItem(emojiInNamesItem))
showCWsItem=new CheckableListItem<>(R.string.settings_show_cws, 0, CheckableListItem.Style.SWITCH, lp.showCWs, R.drawable.ic_warning_24px, this::toggleCheckableItem),
hideSensitiveMediaItem=new CheckableListItem<>(R.string.settings_hide_sensitive_media, 0, CheckableListItem.Style.SWITCH, lp.hideSensitiveMedia, R.drawable.ic_no_adult_content_24px, this::toggleCheckableItem),
interactionCountsItem=new CheckableListItem<>(R.string.settings_show_interaction_counts, 0, CheckableListItem.Style.SWITCH, lp.showInteractionCounts, R.drawable.ic_social_leaderboard_24px, this::toggleCheckableItem),
emojiInNamesItem=new CheckableListItem<>(R.string.settings_show_emoji_in_names, 0, CheckableListItem.Style.SWITCH, lp.customEmojiInNames, R.drawable.ic_emoticon_24px, this::toggleCheckableItem)
));
}
@ -80,7 +80,7 @@ public class SettingsDisplayFragment extends BaseSettingsFragment<Void>{
};
}
private void onAppearanceClick(){
private void onAppearanceClick(ListItem<?> item_){
int selected=switch(GlobalUserPreferences.theme){
case LIGHT -> 0;
case DARK -> 1;

View File

@ -67,16 +67,14 @@ public class SettingsFiltersFragment extends BaseSettingsFragment<Filter>{
Nav.go(getActivity(), EditFilterFragment.class, args);
}
private void onAddFilterClick(){
private void onAddFilterClick(ListItem<?> item){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), EditFilterFragment.class, args);
}
private ListItem<Filter> makeListItem(Filter f){
ListItem<Filter> item=new ListItem<>(f.title, getString(f.isActive() ? R.string.filter_active : R.string.filter_inactive), null, f);
item.onClick=()->onFilterClick(item);
item.isEnabled=true;
ListItem<Filter> item=new ListItem<>(f.title, getString(f.isActive() ? R.string.filter_active : R.string.filter_inactive), this::onFilterClick, f);
return item;
}

View File

@ -61,7 +61,7 @@ public class SettingsMainFragment extends BaseSettingsFragment<Void>{
));
if(BuildConfig.DEBUG || BuildConfig.BUILD_TYPE.equals("appcenterPrivateBeta")){
data.add(0, new ListItem<>("Debug settings", null, R.drawable.ic_settings_24px, ()->Nav.go(getActivity(), SettingsDebugFragment.class, makeFragmentArgs()), null, 0, true));
data.add(0, new ListItem<>("Debug settings", null, R.drawable.ic_settings_24px, i->Nav.go(getActivity(), SettingsDebugFragment.class, makeFragmentArgs()), null, 0, true));
}
AccountSession session=AccountSessionManager.get(accountID);
@ -122,35 +122,35 @@ public class SettingsMainFragment extends BaseSettingsFragment<Void>{
return args;
}
private void onBehaviorClick(){
private void onBehaviorClick(ListItem<?> item_){
Nav.go(getActivity(), SettingsBehaviorFragment.class, makeFragmentArgs());
}
private void onDisplayClick(){
private void onDisplayClick(ListItem<?> item_){
Nav.go(getActivity(), SettingsDisplayFragment.class, makeFragmentArgs());
}
private void onPrivacyClick(){
private void onPrivacyClick(ListItem<?> item_){
Nav.go(getActivity(), SettingsPrivacyFragment.class, makeFragmentArgs());
}
private void onFiltersClick(){
private void onFiltersClick(ListItem<?> item_){
Nav.go(getActivity(), SettingsFiltersFragment.class, makeFragmentArgs());
}
private void onNotificationsClick(){
private void onNotificationsClick(ListItem<?> item_){
Nav.go(getActivity(), SettingsNotificationsFragment.class, makeFragmentArgs());
}
private void onServerClick(){
private void onServerClick(ListItem<?> item_){
Nav.go(getActivity(), SettingsServerFragment.class, makeFragmentArgs());
}
private void onAboutClick(){
private void onAboutClick(ListItem<?> item_){
Nav.go(getActivity(), SettingsAboutAppFragment.class, makeFragmentArgs());
}
private void onLogOutClick(){
private void onLogOutClick(ListItem<?> item_){
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
new M3AlertDialogBuilder(getActivity())
.setMessage(getString(R.string.confirm_log_out, session.getFullUsername()))

View File

@ -55,14 +55,14 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment<Void>{
getPushSubscription();
onDataLoaded(List.of(
pauseItem=new CheckableListItem<>(getString(R.string.pause_all_notifications), getPauseItemSubtitle(), CheckableListItem.Style.SWITCH, false, R.drawable.ic_notifications_paused_24px, ()->onPauseNotificationsClick(false)),
pauseItem=new CheckableListItem<>(getString(R.string.pause_all_notifications), getPauseItemSubtitle(), CheckableListItem.Style.SWITCH, false, R.drawable.ic_notifications_paused_24px, i->onPauseNotificationsClick(false)),
policyItem=new ListItem<>(R.string.settings_notifications_policy, 0, R.drawable.ic_group_24px, this::onNotificationsPolicyClick),
mentionsItem=new CheckableListItem<>(R.string.notification_type_mentions_and_replies, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.mention, ()->toggleCheckableItem(mentionsItem)),
boostsItem=new CheckableListItem<>(R.string.notification_type_reblog, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.reblog, ()->toggleCheckableItem(boostsItem)),
favoritesItem=new CheckableListItem<>(R.string.notification_type_favorite, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.favourite, ()->toggleCheckableItem(favoritesItem)),
followersItem=new CheckableListItem<>(R.string.notification_type_follow, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.follow, ()->toggleCheckableItem(followersItem)),
pollsItem=new CheckableListItem<>(R.string.notification_type_poll, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.poll, ()->toggleCheckableItem(pollsItem))
mentionsItem=new CheckableListItem<>(R.string.notification_type_mentions_and_replies, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.mention, this::toggleCheckableItem),
boostsItem=new CheckableListItem<>(R.string.notification_type_reblog, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.reblog, this::toggleCheckableItem),
favoritesItem=new CheckableListItem<>(R.string.notification_type_favorite, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.favourite, this::toggleCheckableItem),
followersItem=new CheckableListItem<>(R.string.notification_type_follow, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.follow, this::toggleCheckableItem),
pollsItem=new CheckableListItem<>(R.string.notification_type_poll, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.poll, this::toggleCheckableItem)
));
typeItems=List.of(mentionsItem, boostsItem, favoritesItem, followersItem, pollsItem);
@ -209,7 +209,7 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment<Void>{
alert.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false);
}
private void onNotificationsPolicyClick(){
private void onNotificationsPolicyClick(ListItem<?> item_){
String[] items=Stream.of(
R.string.notifications_policy_anyone,
R.string.notifications_policy_followed,

View File

@ -18,8 +18,8 @@ public class SettingsPrivacyFragment extends BaseSettingsFragment<Void>{
setTitle(R.string.settings_privacy);
Account self=AccountSessionManager.get(accountID).self;
onDataLoaded(List.of(
discoverableItem=new CheckableListItem<>(R.string.settings_discoverable, 0, CheckableListItem.Style.SWITCH, self.discoverable, R.drawable.ic_thumbs_up_down_24px, ()->toggleCheckableItem(discoverableItem)),
indexableItem=new CheckableListItem<>(R.string.settings_indexable, 0, CheckableListItem.Style.SWITCH, self.source.indexable!=null ? self.source.indexable : true, R.drawable.ic_search_24px, ()->toggleCheckableItem(indexableItem))
discoverableItem=new CheckableListItem<>(R.string.settings_discoverable, 0, CheckableListItem.Style.SWITCH, self.discoverable, R.drawable.ic_thumbs_up_down_24px, this::toggleCheckableItem),
indexableItem=new CheckableListItem<>(R.string.settings_indexable, 0, CheckableListItem.Style.SWITCH, self.source.indexable!=null ? self.source.indexable : true, R.drawable.ic_search_24px, this::toggleCheckableItem)
));
if(self.source.indexable==null)
indexableItem.isEnabled=false;

View File

@ -139,7 +139,7 @@ public class SettingsServerAboutFragment extends LoaderFragment{
if(!TextUtils.isEmpty(instance.email)){
needDivider=true;
SimpleListItemViewHolder holder=new SimpleListItemViewHolder(getActivity(), scrollingLayout);
ListItem<Void> item=new ListItem<>(R.string.send_email_to_server_admin, 0, R.drawable.ic_mail_24px, ()->{});
ListItem<Void> item=new ListItem<>(R.string.send_email_to_server_admin, 0, R.drawable.ic_mail_24px, i->{});
holder.bind(item);
holder.itemView.setBackground(UiUtils.getThemeDrawable(getActivity(), android.R.attr.selectableItemBackground));
holder.itemView.setOnClickListener(v->openAdminEmail());

View File

@ -0,0 +1,43 @@
package org.joinmastodon.android.model;
import com.google.gson.annotations.SerializedName;
import org.joinmastodon.android.api.AllFieldsAreRequired;
import org.joinmastodon.android.api.ObjectValidationException;
import org.parceler.Parcel;
// Called like this to avoid conflict with java.util.List
@AllFieldsAreRequired
@Parcel
public class FollowList extends BaseModel{
public String id;
public String title;
public RepliesPolicy repliesPolicy=RepliesPolicy.LIST;
public boolean exclusive;
@Override
public String toString(){
return "FollowList{"+
"id='"+id+'\''+
", title='"+title+'\''+
", repliesPolicy="+repliesPolicy+
", exclusive="+exclusive+
'}';
}
@Override
public void postprocess() throws ObjectValidationException{
if(repliesPolicy==null)
repliesPolicy=RepliesPolicy.LIST;
super.postprocess();
}
public enum RepliesPolicy{
@SerializedName("followed")
FOLLOWED,
@SerializedName("list")
LIST,
@SerializedName("none")
NONE
}
}

View File

@ -45,4 +45,8 @@ public class Hashtag extends BaseModel implements DisplayItemsParent{
public int hashCode(){
return name.hashCode();
}
public int getWeekPosts(){
return history.stream().mapToInt(h->h.uses).sum();
}
}

View File

@ -0,0 +1,22 @@
package org.joinmastodon.android.model.viewmodel;
import org.joinmastodon.android.R;
import java.util.List;
import java.util.function.Consumer;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
public class AvatarPileListItem<T> extends ListItem<T>{
public List<ImageLoaderRequest> avatars;
public AvatarPileListItem(String title, String subtitle, List<ImageLoaderRequest> avatars, int iconRes, Consumer<AvatarPileListItem<T>> onClick, T parentObject, boolean dividerAfter){
super(title, subtitle, iconRes, (Consumer<ListItem<T>>)(Object)onClick, parentObject, 0, dividerAfter);
this.avatars=avatars;
}
@Override
public int getItemViewType(){
return R.id.list_item_avatar_pile;
}
}

View File

@ -9,42 +9,42 @@ public class CheckableListItem<T> extends ListItem<T>{
public boolean checked;
public Consumer<Boolean> checkedChangeListener;
public CheckableListItem(String title, String subtitle, Style style, boolean checked, int iconRes, Runnable onClick, T parentObject, boolean dividerAfter){
super(title, subtitle, iconRes, onClick, parentObject, 0, dividerAfter);
public CheckableListItem(String title, String subtitle, Style style, boolean checked, int iconRes, Consumer<CheckableListItem<T>> onClick, T parentObject, boolean dividerAfter){
super(title, subtitle, iconRes, (Consumer<ListItem<T>>)(Object)onClick, parentObject, 0, dividerAfter);
this.style=style;
this.checked=checked;
}
public CheckableListItem(String title, String subtitle, Style style, boolean checked, Runnable onClick){
public CheckableListItem(String title, String subtitle, Style style, boolean checked, Consumer<CheckableListItem<T>> onClick){
this(title, subtitle, style, checked, 0, onClick, null, false);
}
public CheckableListItem(String title, String subtitle, Style style, boolean checked, Runnable onClick, T parentObject){
public CheckableListItem(String title, String subtitle, Style style, boolean checked, Consumer<CheckableListItem<T>> onClick, T parentObject){
this(title, subtitle, style, checked, 0, onClick, parentObject, false);
}
public CheckableListItem(String title, String subtitle, Style style, boolean checked, int iconRes, Runnable onClick){
public CheckableListItem(String title, String subtitle, Style style, boolean checked, int iconRes, Consumer<CheckableListItem<T>> onClick){
this(title, subtitle, style, checked, iconRes, onClick, null, false);
}
public CheckableListItem(String title, String subtitle, Style style, boolean checked, int iconRes, Runnable onClick, T parentObject){
public CheckableListItem(String title, String subtitle, Style style, boolean checked, int iconRes, Consumer<CheckableListItem<T>> onClick, T parentObject){
this(title, subtitle, style, checked, iconRes, onClick, parentObject, false);
}
public CheckableListItem(int titleRes, int subtitleRes, Style style, boolean checked, Runnable onClick){
public CheckableListItem(int titleRes, int subtitleRes, Style style, boolean checked, Consumer<CheckableListItem<T>> onClick){
this(titleRes, subtitleRes, style, checked, 0, onClick, false);
}
public CheckableListItem(int titleRes, int subtitleRes, Style style, boolean checked, Runnable onClick, boolean dividerAfter){
public CheckableListItem(int titleRes, int subtitleRes, Style style, boolean checked, Consumer<CheckableListItem<T>> onClick, boolean dividerAfter){
this(titleRes, subtitleRes, style, checked, 0, onClick, dividerAfter);
}
public CheckableListItem(int titleRes, int subtitleRes, Style style, boolean checked, int iconRes, Runnable onClick){
public CheckableListItem(int titleRes, int subtitleRes, Style style, boolean checked, int iconRes, Consumer<CheckableListItem<T>> onClick){
this(titleRes, subtitleRes, style, checked, iconRes, onClick, false);
}
public CheckableListItem(int titleRes, int subtitleRes, Style style, boolean checked, int iconRes, Runnable onClick, boolean dividerAfter){
super(titleRes, subtitleRes, iconRes, onClick, 0, dividerAfter);
public CheckableListItem(int titleRes, int subtitleRes, Style style, boolean checked, int iconRes, Consumer<CheckableListItem<T>> onClick, boolean dividerAfter){
super(titleRes, subtitleRes, iconRes, (Consumer<ListItem<T>>)(Object)onClick, 0, dividerAfter);
this.style=style;
this.checked=checked;
}

View File

@ -2,6 +2,8 @@ package org.joinmastodon.android.model.viewmodel;
import org.joinmastodon.android.R;
import java.util.function.Consumer;
import androidx.annotation.DrawableRes;
import androidx.annotation.StringRes;
@ -16,11 +18,11 @@ public class ListItem<T>{
public int iconRes;
public int colorOverrideAttr;
public boolean dividerAfter;
public Runnable onClick;
private Consumer<ListItem<T>> onClick;
public boolean isEnabled=true;
public T parentObject;
public ListItem(String title, String subtitle, int iconRes, Runnable onClick, T parentObject, int colorOverrideAttr, boolean dividerAfter){
public ListItem(String title, String subtitle, int iconRes, Consumer<ListItem<T>> onClick, T parentObject, int colorOverrideAttr, boolean dividerAfter){
this.title=title;
this.subtitle=subtitle;
this.iconRes=iconRes;
@ -32,41 +34,41 @@ public class ListItem<T>{
isEnabled=false;
}
public ListItem(String title, String subtitle, Runnable onClick){
public ListItem(String title, String subtitle, Consumer<ListItem<T>> onClick){
this(title, subtitle, 0, onClick, null, 0, false);
}
public ListItem(String title, String subtitle, Runnable onClick, T parentObject){
public ListItem(String title, String subtitle, Consumer<ListItem<T>> onClick, T parentObject){
this(title, subtitle, 0, onClick, parentObject, 0, false);
}
public ListItem(String title, String subtitle, @DrawableRes int iconRes, Runnable onClick){
public ListItem(String title, String subtitle, @DrawableRes int iconRes, Consumer<ListItem<T>> onClick){
this(title, subtitle, iconRes, onClick, null, 0, false);
}
public ListItem(String title, String subtitle, @DrawableRes int iconRes, Runnable onClick, T parentObject){
public ListItem(String title, String subtitle, @DrawableRes int iconRes, Consumer<ListItem<T>> onClick, T parentObject){
this(title, subtitle, iconRes, onClick, parentObject, 0, false);
}
public ListItem(@StringRes int titleRes, @StringRes int subtitleRes, Runnable onClick){
public ListItem(@StringRes int titleRes, @StringRes int subtitleRes, Consumer<ListItem<T>> onClick){
this(null, null, 0, onClick, null, 0, false);
this.titleRes=titleRes;
this.subtitleRes=subtitleRes;
}
public ListItem(@StringRes int titleRes, @StringRes int subtitleRes, Runnable onClick, int colorOverrideAttr, boolean dividerAfter){
public ListItem(@StringRes int titleRes, @StringRes int subtitleRes, Consumer<ListItem<T>> onClick, int colorOverrideAttr, boolean dividerAfter){
this(null, null, 0, onClick, null, colorOverrideAttr, dividerAfter);
this.titleRes=titleRes;
this.subtitleRes=subtitleRes;
}
public ListItem(@StringRes int titleRes, @StringRes int subtitleRes, @DrawableRes int iconRes, Runnable onClick){
public ListItem(@StringRes int titleRes, @StringRes int subtitleRes, @DrawableRes int iconRes, Consumer<ListItem<T>> onClick){
this(null, null, iconRes, onClick, null, 0, false);
this.titleRes=titleRes;
this.subtitleRes=subtitleRes;
}
public ListItem(@StringRes int titleRes, @StringRes int subtitleRes, @DrawableRes int iconRes, Runnable onClick, int colorOverrideAttr, boolean dividerAfter){
public ListItem(@StringRes int titleRes, @StringRes int subtitleRes, @DrawableRes int iconRes, Consumer<ListItem<T>> onClick, int colorOverrideAttr, boolean dividerAfter){
this(null, null, iconRes, onClick, null, colorOverrideAttr, dividerAfter);
this.titleRes=titleRes;
this.subtitleRes=subtitleRes;
@ -75,4 +77,13 @@ public class ListItem<T>{
public int getItemViewType(){
return colorOverrideAttr==0 ? R.id.list_item_simple : R.id.list_item_simple_tinted;
}
public void performClick(){
if(onClick!=null)
onClick.accept(this);
}
public <I extends ListItem<T>> void setOnClick(Consumer<I> onClick){
this.onClick=(Consumer<ListItem<T>>) onClick;
}
}

View File

@ -0,0 +1,35 @@
package org.joinmastodon.android.model.viewmodel;
import android.view.Menu;
import android.view.MenuItem;
import org.joinmastodon.android.R;
import java.util.function.Consumer;
public class ListItemWithOptionsMenu<T> extends ListItem<T>{
public OptionsMenuListener<T> listener;
public ListItemWithOptionsMenu(String title, String subtitle, OptionsMenuListener<T> listener, int iconRes, Consumer<ListItemWithOptionsMenu<T>> onClick, T parentObject, boolean dividerAfter){
super(title, subtitle, iconRes, (Consumer<ListItem<T>>)(Object)onClick, parentObject, 0, dividerAfter);
this.listener=listener;
}
@Override
public int getItemViewType(){
return R.id.list_item_options;
}
public void performConfigureMenu(Menu menu){
listener.onConfigureListItemOptionsMenu(this, menu);
}
public void performItemSelected(MenuItem item){
listener.onListItemOptionSelected(this, item);
}
public interface OptionsMenuListener<T>{
void onConfigureListItemOptionsMenu(ListItemWithOptionsMenu<T> item, Menu menu);
void onListItemOptionSelected(ListItemWithOptionsMenu<T> item, MenuItem menuItem);
}
}

View File

@ -47,7 +47,7 @@ public class SearchViewHelper{
searchEdit.setBackground(null);
searchEdit.addTextChangedListener(new SimpleTextWatcher(e->{
searchEdit.removeCallbacks(debouncer);
searchEdit.postDelayed(debouncer, 300);
searchEdit.postDelayed(debouncer, 500);
boolean newIsEmpty=e.length()==0;
if(isEmpty!=newIsEmpty){
isEmpty=newIsEmpty;

View File

@ -3,9 +3,12 @@ package org.joinmastodon.android.ui.adapters;
import android.view.ViewGroup;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.viewmodel.AvatarPileListItem;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.viewholders.AvatarPileListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.CheckboxOrRadioListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.ListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.OptionsListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.SimpleListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.SwitchListItemViewHolder;
@ -13,11 +16,21 @@ import java.util.List;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.ListImageLoaderWrapper;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.views.UsableRecyclerView;
public class GenericListItemsAdapter<T> extends RecyclerView.Adapter<ListItemViewHolder<?>>{
public class GenericListItemsAdapter<T> extends UsableRecyclerView.Adapter<ListItemViewHolder<?>> implements ImageLoaderRecyclerAdapter{
private List<ListItem<T>> items;
public GenericListItemsAdapter(List<ListItem<T>> items){
super(null);
this.items=items;
}
public GenericListItemsAdapter(ListImageLoaderWrapper imgLoader, List<ListItem<T>> items){
super(imgLoader);
this.items=items;
}
@ -32,6 +45,10 @@ public class GenericListItemsAdapter<T> extends RecyclerView.Adapter<ListItemVie
return new CheckboxOrRadioListItemViewHolder(parent.getContext(), parent, false);
if(viewType==R.id.list_item_radio)
return new CheckboxOrRadioListItemViewHolder(parent.getContext(), parent, true);
if(viewType==R.id.list_item_options)
return new OptionsListItemViewHolder(parent.getContext(), parent);
if(viewType==R.id.list_item_avatar_pile)
return new AvatarPileListItemViewHolder(parent.getContext(), parent);
throw new IllegalArgumentException("Unexpected view type "+viewType);
}
@ -51,4 +68,20 @@ public class GenericListItemsAdapter<T> extends RecyclerView.Adapter<ListItemVie
public int getItemViewType(int position){
return items.get(position).getItemViewType();
}
@Override
public int getImageCountForItem(int position){
ListItem<?> item=items.get(position);
if(item instanceof AvatarPileListItem<?> avatarPileListItem)
return avatarPileListItem.avatars.size();
return 0;
}
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
ListItem<?> item=items.get(position);
if(item instanceof AvatarPileListItem<?> avatarPileListItem)
return avatarPileListItem.avatars.get(image);
return null;
}
}

View File

@ -24,6 +24,7 @@ import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
import org.joinmastodon.android.api.requests.statuses.GetStatusSourceText;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.AddAccountToListsFragment;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.ComposeFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
@ -198,6 +199,11 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
UiUtils.openSystemShareSheet(activity, item.status.url);
}else if(id==R.id.translate){
item.parentFragment.togglePostTranslation(item.status, item.parentID);
}else if(id==R.id.add_to_list){
Bundle args=new Bundle();
args.putString("account", item.parentFragment.getAccountID());
args.putParcelable("targetAccount", Parcels.wrap(account));
Nav.go(activity, AddAccountToListsFragment.class, args);
}
return true;
});
@ -326,6 +332,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
report.setTitle(item.parentFragment.getString(R.string.report_user, account.displayName));
follow.setTitle(item.parentFragment.getString(relationship!=null && relationship.following ? R.string.unfollow_user : R.string.follow_user, account.displayName));
}
menu.findItem(R.id.add_to_list).setVisible(relationship!=null && relationship.following);
}
}
}

View File

@ -0,0 +1,89 @@
package org.joinmastodon.android.ui.utils;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.IntEvaluator;
import android.animation.ObjectAnimator;
import android.graphics.drawable.Drawable;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import org.joinmastodon.android.R;
import java.util.function.IntSupplier;
import me.grishka.appkit.FragmentStackActivity;
import me.grishka.appkit.fragments.AppKitFragment;
public class ActionModeHelper{
public static ActionMode startActionMode(AppKitFragment fragment, IntSupplier statusBarColorSupplier, ActionMode.Callback callback){
FragmentStackActivity activity=(FragmentStackActivity) fragment.getActivity();
return activity.startActionMode(new ActionMode.Callback(){
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu){
if(!callback.onCreateActionMode(mode, menu))
return false;
ObjectAnimator anim=ObjectAnimator.ofInt(activity.getWindow(), "statusBarColor", statusBarColorSupplier.getAsInt(), UiUtils.getThemeColor(activity, R.attr.colorM3Primary));
anim.setEvaluator(new IntEvaluator(){
@Override
public Integer evaluate(float fraction, Integer startValue, Integer endValue){
return UiUtils.alphaBlendColors(startValue, endValue, fraction);
}
});
anim.start();
activity.invalidateSystemBarColors(fragment);
View fakeView=new View(activity);
// mode.setCustomView(fakeView);
// int buttonID=activity.getResources().getIdentifier("action_mode_close_button", "id", "android");
// View btn=activity.getWindow().getDecorView().findViewById(buttonID);
// if(btn!=null){
// ((ViewGroup.MarginLayoutParams)btn.getLayoutParams()).setMarginEnd(0);
// }
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu){
if(!callback.onPrepareActionMode(mode, menu))
return false;
for(int i=0;i<menu.size();i++){
Drawable icon=menu.getItem(i).getIcon();
if(icon!=null){
icon=icon.mutate();
icon.setTint(UiUtils.getThemeColor(activity, R.attr.colorM3OnPrimary));
menu.getItem(i).setIcon(icon);
}
}
return true;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item){
return callback.onActionItemClicked(mode, item);
}
@Override
public void onDestroyActionMode(ActionMode mode){
ObjectAnimator anim=ObjectAnimator.ofInt(activity.getWindow(), "statusBarColor", UiUtils.getThemeColor(activity, R.attr.colorM3Primary), statusBarColorSupplier.getAsInt());
anim.setEvaluator(new IntEvaluator(){
@Override
public Integer evaluate(float fraction, Integer startValue, Integer endValue){
return UiUtils.alphaBlendColors(startValue, endValue, fraction);
}
});
anim.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
activity.getWindow().setStatusBarColor(0);
}
});
anim.start();
activity.invalidateSystemBarColors(fragment);
callback.onDestroyActionMode(mode);
}
});
}
}

View File

@ -0,0 +1,177 @@
package org.joinmastodon.android.ui.viewcontrollers;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public abstract class DropdownSubmenuController{
protected List<Item<?>> items;
protected LinearLayout contentView;
protected UsableRecyclerView list;
protected TextView backItem;
protected final ToolbarDropdownMenuController dropdownController;
protected MergeRecyclerAdapter mergeAdapter;
protected ItemsAdapter itemsAdapter;
public DropdownSubmenuController(ToolbarDropdownMenuController dropdownController){
this.dropdownController=dropdownController;
}
protected abstract CharSequence getBackItemTitle();
public void onDismiss(){}
protected void createView(){
contentView=new LinearLayout(dropdownController.getActivity());
contentView.setOrientation(LinearLayout.VERTICAL);
CharSequence backTitle=getBackItemTitle();
if(!TextUtils.isEmpty(backTitle)){
backItem=(TextView) dropdownController.getActivity().getLayoutInflater().inflate(R.layout.item_dropdown_menu, contentView, false);
((LinearLayout.LayoutParams) backItem.getLayoutParams()).topMargin=V.dp(8);
backItem.setText(backTitle);
backItem.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_arrow_back, 0, 0, 0);
backItem.setBackground(UiUtils.getThemeDrawable(dropdownController.getActivity(), android.R.attr.selectableItemBackground));
backItem.setOnClickListener(v->dropdownController.popSubmenuController());
backItem.setAccessibilityDelegate(new View.AccessibilityDelegate(){
@Override
public void onInitializeAccessibilityNodeInfo(@NonNull View host, @NonNull AccessibilityNodeInfo info){
super.onInitializeAccessibilityNodeInfo(host, info);
info.setText(info.getText()+". "+host.getResources().getString(R.string.back));
}
});
contentView.addView(backItem);
}
list=new UsableRecyclerView(dropdownController.getActivity());
list.setLayoutManager(new LinearLayoutManager(dropdownController.getActivity()));
itemsAdapter=new ItemsAdapter();
mergeAdapter=new MergeRecyclerAdapter();
mergeAdapter.addAdapter(itemsAdapter);
list.setAdapter(mergeAdapter);
list.setPadding(0, backItem!=null ? 0 : V.dp(8), 0, V.dp(8));
list.setClipToPadding(false);
list.setItemAnimator(new BetterItemAnimator());
list.addItemDecoration(new RecyclerView.ItemDecoration(){
private final Paint paint=new Paint();
{
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(V.dp(1));
paint.setColor(UiUtils.getThemeColor(dropdownController.getActivity(), R.attr.colorM3OutlineVariant));
}
@Override
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
for(int i=0;i<parent.getChildCount();i++){
View view=parent.getChildAt(i);
if(parent.getChildViewHolder(view) instanceof ItemHolder ih && ih.getItem().dividerBefore){
paint.setAlpha(Math.round(view.getAlpha()*255));
float y=view.getTop()-V.dp(8)-paint.getStrokeWidth()/2f;
c.drawLine(0, y, parent.getWidth(), y, paint);
}
}
}
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
if(parent.getChildViewHolder(view) instanceof ItemHolder ih && ih.getItem().dividerBefore){
outRect.top=V.dp(17);
}
}
});
contentView.addView(list, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
}
public View getView(){
if(contentView==null)
createView();
return contentView;
}
protected final class Item<T>{
public final String title;
public final boolean hasSubmenu;
public final boolean dividerBefore;
public final T parentObject;
public final Consumer<Item<T>> onClick;
public Item(String title, boolean hasSubmenu, boolean dividerBefore, T parentObject, Consumer<Item<T>> onClick){
this.title=title;
this.hasSubmenu=hasSubmenu;
this.dividerBefore=dividerBefore;
this.parentObject=parentObject;
this.onClick=onClick;
}
public Item(String title, boolean hasSubmenu, boolean dividerBefore, Consumer<Item<T>> onClick){
this(title, hasSubmenu, dividerBefore, null, onClick);
}
public Item(@StringRes int titleRes, boolean hasSubmenu, boolean dividerBefore, Consumer<Item<T>> onClick){
this(dropdownController.getActivity().getString(titleRes), hasSubmenu, dividerBefore, null, onClick);
}
private void performClick(){
onClick.accept(this);
}
}
protected class ItemsAdapter extends RecyclerView.Adapter<ItemHolder>{
@NonNull
@Override
public ItemHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new ItemHolder();
}
@Override
public void onBindViewHolder(@NonNull ItemHolder holder, int position){
holder.bind(items.get(position));
}
@Override
public int getItemCount(){
return items.size();
}
}
private class ItemHolder extends BindableViewHolder<Item<?>> implements UsableRecyclerView.Clickable{
private final TextView text;
public ItemHolder(){
super(dropdownController.getActivity(), R.layout.item_dropdown_menu, list);
text=(TextView) itemView;
}
@Override
public void onBind(Item<?> item){
text.setText(item.title);
text.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, item.hasSubmenu ? R.drawable.ic_arrow_right_24px : 0, 0);
}
@Override
public void onClick(){
item.performClick();
}
}
}

View File

@ -0,0 +1,106 @@
package org.joinmastodon.android.ui.viewcontrollers;
import android.app.Activity;
import android.os.Bundle;
import android.view.Gravity;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ProgressBar;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.tags.GetFollowedTags;
import org.joinmastodon.android.fragments.ManageFollowedHashtagsFragment;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.APIRequest;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.utils.V;
public class HomeTimelineHashtagsMenuController extends DropdownSubmenuController{
private HideableSingleViewRecyclerAdapter largeProgressAdapter;
private APIRequest<?> currentRequest;
public HomeTimelineHashtagsMenuController(ToolbarDropdownMenuController dropdownController){
super(dropdownController);
items=new ArrayList<>();
loadHashtags();
}
@Override
protected void createView(){
super.createView();
FrameLayout largeProgressView=new FrameLayout(dropdownController.getActivity());
int pad=V.dp(32);
largeProgressView.setPadding(0, pad, 0, pad);
largeProgressView.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
ProgressBar progress=new ProgressBar(dropdownController.getActivity());
largeProgressView.addView(progress, new FrameLayout.LayoutParams(V.dp(48), V.dp(48), Gravity.CENTER));
largeProgressAdapter=new HideableSingleViewRecyclerAdapter(largeProgressView);
mergeAdapter.addAdapter(0, largeProgressAdapter);
}
@Override
protected CharSequence getBackItemTitle(){
return dropdownController.getActivity().getString(R.string.followed_hashtags);
}
@Override
public void onDismiss(){
if(currentRequest!=null){
currentRequest.cancel();
currentRequest=null;
}
}
private void onTagClick(Item<Hashtag> item){
dropdownController.dismiss();
UiUtils.openHashtagTimeline(dropdownController.getActivity(), dropdownController.getAccountID(), item.parentObject);
}
private void onManageTagsClick(){
dropdownController.dismiss();
Bundle args=new Bundle();
args.putString("account", dropdownController.getAccountID());
Nav.go(dropdownController.getActivity(), ManageFollowedHashtagsFragment.class, args);
}
private void loadHashtags(){
currentRequest=new GetFollowedTags(null, 200)
.setCallback(new Callback<>(){
@Override
public void onSuccess(HeaderPaginationList<Hashtag> result){
currentRequest=null;
dropdownController.resizeOnNextFrame();
largeProgressAdapter.setVisible(false);
((List<Hashtag>) result).sort(Comparator.comparing(tag->tag.name));
int prevSize=items.size();
for(Hashtag tag:result){
items.add(new Item<>("#"+tag.name, false, false, tag, HomeTimelineHashtagsMenuController.this::onTagClick));
}
items.add(new Item<Void>(R.string.manage_hashtags, false, true, i->onManageTagsClick()));
itemsAdapter.notifyItemRangeInserted(prevSize, result.size()+1);
}
@Override
public void onError(ErrorResponse error){
currentRequest=null;
Activity activity=dropdownController.getActivity();
if(activity!=null)
error.showToast(activity);
dropdownController.popSubmenuController();
}
})
.exec(dropdownController.getAccountID());
}
}

View File

@ -0,0 +1,43 @@
package org.joinmastodon.android.ui.viewcontrollers;
import android.os.Bundle;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.ManageListsFragment;
import org.joinmastodon.android.model.FollowList;
import java.util.ArrayList;
import java.util.List;
import me.grishka.appkit.Nav;
public class HomeTimelineListsMenuController extends DropdownSubmenuController{
private final List<FollowList> lists;
private final HomeTimelineMenuController.Callback callback;
public HomeTimelineListsMenuController(ToolbarDropdownMenuController dropdownController, HomeTimelineMenuController.Callback callback){
super(dropdownController);
this.lists=new ArrayList<>(callback.getLists());
this.callback=callback;
items=new ArrayList<>();
for(FollowList l:lists){
items.add(new Item<>(l.title, false, false, l, this::onListSelected));
}
items.add(new Item<Void>(dropdownController.getActivity().getString(R.string.manage_lists), false, true, i->{
dropdownController.dismiss();
Bundle args=new Bundle();
args.putString("account", dropdownController.getAccountID());
Nav.go(dropdownController.getActivity(), ManageListsFragment.class, args);
}));
}
@Override
protected CharSequence getBackItemTitle(){
return dropdownController.getActivity().getString(R.string.lists);
}
private void onListSelected(Item<FollowList> item){
callback.onListSelected(item.parentObject);
dropdownController.dismiss();
}
}

View File

@ -0,0 +1,39 @@
package org.joinmastodon.android.ui.viewcontrollers;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.FollowList;
import java.util.List;
public class HomeTimelineMenuController extends DropdownSubmenuController{
private Callback callback;
public HomeTimelineMenuController(ToolbarDropdownMenuController dropdownController, Callback callback){
super(dropdownController);
this.callback=callback;
items=List.of(
new Item<Void>(R.string.timeline_following, false, false, i->{
callback.onFollowingSelected();
dropdownController.dismiss();
}),
new Item<Void>(R.string.local_timeline, false, false, i->{
callback.onLocalSelected();
dropdownController.dismiss();
}),
new Item<Void>(R.string.lists, true, true, i->dropdownController.pushSubmenuController(new HomeTimelineListsMenuController(dropdownController, callback))),
new Item<Void>(R.string.followed_hashtags, true, false, i->dropdownController.pushSubmenuController(new HomeTimelineHashtagsMenuController(dropdownController)))
);
}
@Override
protected CharSequence getBackItemTitle(){
return null;
}
public interface Callback{
void onFollowingSelected();
void onLocalSelected();
List<FollowList> getLists();
void onListSelected(FollowList list);
}
}

View File

@ -0,0 +1,268 @@
package org.joinmastodon.android.ui.viewcontrollers;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.app.Activity;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.view.WindowManager;
import android.widget.FrameLayout;
import android.widget.Toolbar;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.OutlineProviders;
import java.util.ArrayList;
import java.util.List;
import androidx.annotation.NonNull;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.V;
public class ToolbarDropdownMenuController{
private final HostFragment fragment;
private FrameLayout windowView;
private FrameLayout menuContainer;
private boolean dismissing;
private List<DropdownSubmenuController> controllerStack=new ArrayList<>();
private Animator currentTransition;
public ToolbarDropdownMenuController(HostFragment fragment){
this.fragment=fragment;
}
public void show(DropdownSubmenuController initialSubmenu){
if(windowView!=null)
return;
menuContainer=new FrameLayout(fragment.getActivity());
menuContainer.setBackgroundResource(R.drawable.bg_m3_surface2);
menuContainer.setOutlineProvider(OutlineProviders.roundedRect(4));
menuContainer.setClipToOutline(true);
menuContainer.setElevation(V.dp(6));
View menuView=initialSubmenu.getView();
menuView.setVisibility(View.VISIBLE);
menuContainer.addView(menuView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
windowView=new WindowView(fragment.getActivity());
int pad=V.dp(16);
windowView.setPadding(pad, fragment.getToolbar().getHeight(), pad, pad);
windowView.setClipToPadding(false);
windowView.addView(menuContainer, new FrameLayout.LayoutParams(V.dp(200), ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP | Gravity.START));
WindowManager.LayoutParams wlp=new WindowManager.LayoutParams(WindowManager.LayoutParams.TYPE_APPLICATION_PANEL);
wlp.format=PixelFormat.TRANSLUCENT;
wlp.token=fragment.getActivity().getWindow().getDecorView().getWindowToken();
wlp.width=wlp.height=ViewGroup.LayoutParams.MATCH_PARENT;
wlp.flags=WindowManager.LayoutParams.FLAG_LAYOUT_ATTACHED_IN_DECOR;
wlp.setTitle(fragment.getActivity().getString(R.string.dropdown_menu));
fragment.getActivity().getWindowManager().addView(windowView, wlp);
menuContainer.setPivotX(V.dp(100));
menuContainer.setPivotY(0);
menuContainer.setScaleX(.8f);
menuContainer.setScaleY(.8f);
menuContainer.setAlpha(0f);
menuContainer.animate()
.scaleX(1f)
.scaleY(1f)
.alpha(1f)
.setInterpolator(CubicBezierInterpolator.DEFAULT)
.setDuration(150)
.withLayer()
.start();
controllerStack.add(initialSubmenu);
}
public void dismiss(){
if(windowView==null || dismissing)
return;
dismissing=true;
fragment.onDropdownWillDismiss();
menuContainer.animate()
.scaleX(.8f)
.scaleY(.8f)
.alpha(0f)
.setInterpolator(CubicBezierInterpolator.DEFAULT)
.setDuration(150)
.withLayer()
.withEndAction(()->{
controllerStack.clear();
fragment.getActivity().getWindowManager().removeView(windowView);
menuContainer.removeAllViews();
dismissing=false;
windowView=null;
menuContainer=null;
fragment.onDropdownDismissed();
})
.start();
}
public void pushSubmenuController(DropdownSubmenuController controller){
View prevView=menuContainer.getChildAt(menuContainer.getChildCount()-1);
View newView=controller.getView();
newView.setVisibility(View.VISIBLE);
menuContainer.addView(newView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
controllerStack.add(controller);
animateTransition(prevView, newView, true);
}
public void popSubmenuController(){
if(menuContainer.getChildCount()<=1)
throw new IllegalStateException();
DropdownSubmenuController controller=controllerStack.remove(controllerStack.size()-1);
controller.onDismiss();
View top=menuContainer.getChildAt(menuContainer.getChildCount()-1);
View prev=menuContainer.getChildAt(menuContainer.getChildCount()-2);
prev.setVisibility(View.VISIBLE);
animateTransition(prev, top, false);
}
private void animateTransition(View bottomView, View topView, boolean adding){
if(currentTransition!=null)
currentTransition.cancel();
int origBottom=menuContainer.getBottom();
menuContainer.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
private final Rect tmpRect=new Rect();
@Override
public boolean onPreDraw(){
menuContainer.getViewTreeObserver().removeOnPreDrawListener(this);
AnimatorSet set=new AnimatorSet();
ObjectAnimator slideIn;
set.playTogether(
ObjectAnimator.ofInt(menuContainer, "bottom", origBottom, menuContainer.getTop()+(adding ? topView : bottomView).getHeight()),
slideIn=ObjectAnimator.ofFloat(topView, View.TRANSLATION_X, adding ? menuContainer.getWidth() : 0, adding ? 0 : menuContainer.getWidth()),
ObjectAnimator.ofFloat(bottomView, View.TRANSLATION_X, adding ? 0 : -menuContainer.getWidth()/4f, adding ? -menuContainer.getWidth()/4f : 0),
ObjectAnimator.ofFloat(bottomView, View.ALPHA, adding ? 1f : 0f, adding ? 0f : 1f)
);
set.setDuration(300);
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
bottomView.setClipBounds(null);
bottomView.setTranslationX(0);
bottomView.setAlpha(1f);
topView.setTranslationX(0);
topView.setAlpha(1f);
if(adding){
bottomView.setVisibility(View.GONE);
}else{
menuContainer.removeView(topView);
}
currentTransition=null;
}
});
slideIn.addUpdateListener(animation->{
tmpRect.set(0, 0, Math.round(topView.getX()-bottomView.getX()), bottomView.getHeight());
bottomView.setClipBounds(tmpRect);
});
currentTransition=set;
set.start();
return true;
}
});
}
public void resizeOnNextFrame(){
if(currentTransition!=null)
currentTransition.cancel();
int origBottom=menuContainer.getBottom();
menuContainer.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
@Override
public boolean onPreDraw(){
menuContainer.getViewTreeObserver().removeOnPreDrawListener(this);
ObjectAnimator anim=ObjectAnimator.ofInt(menuContainer, "bottom", origBottom, menuContainer.getBottom());
anim.setDuration(300);
anim.setInterpolator(CubicBezierInterpolator.DEFAULT);
anim.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
currentTransition=null;
}
});
currentTransition=anim;
anim.start();
return true;
}
});
}
Activity getActivity(){
return fragment.getActivity();
}
String getAccountID(){
return fragment.getAccountID();
}
private class WindowView extends FrameLayout{
private final Rect tmpRect=new Rect();
public WindowView(@NonNull Context context){
super(context);
}
@Override
public boolean onTouchEvent(MotionEvent ev){
for(int i=0;i<getChildCount();i++){
View child=getChildAt(i);
child.getHitRect(tmpRect);
if(tmpRect.contains(Math.round(ev.getX()), Math.round(ev.getY())))
return super.onTouchEvent(ev);
}
if(ev.getAction()==MotionEvent.ACTION_DOWN){
dismiss();
}
return true;
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev){
if(currentTransition!=null)
return false;
return super.dispatchTouchEvent(ev);
}
@Override
public boolean dispatchKeyEvent(KeyEvent event){
if(event.getKeyCode()==KeyEvent.KEYCODE_BACK){
if(event.getAction()==KeyEvent.ACTION_DOWN){
if(controllerStack.size()>1)
popSubmenuController();
else
dismiss();
}
return true;
}
return super.dispatchKeyEvent(event);
}
}
public interface HostFragment{
// Fragment methods
Activity getActivity();
Resources getResources();
Toolbar getToolbar();
String getAccountID();
// Callbacks
void onDropdownWillDismiss();
void onDropdownDismissed();
}
}

View File

@ -17,6 +17,7 @@ import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.PopupMenu;
import android.widget.ProgressBar;
@ -26,6 +27,7 @@ import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.AddAccountToListsFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment;
import org.joinmastodon.android.model.Account;
@ -40,6 +42,7 @@ import org.parceler.Parcels;
import java.util.HashMap;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Predicate;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
@ -58,12 +61,15 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
private final CheckableRelativeLayout view;
private final View checkbox;
private final ProgressBar actionProgress;
private final ImageButton menuButton;
private final String accountID;
private final Fragment fragment;
private final HashMap<String, Relationship> relationships;
private Consumer<AccountViewHolder> onClick;
private Predicate<AccountViewHolder> onLongClick;
private Consumer<MenuItem> onCustomMenuItemSelected;
private AccessoryType accessoryType;
private boolean showBio;
private boolean checked;
@ -85,6 +91,7 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
bio=findViewById(R.id.bio);
checkbox=findViewById(R.id.checkbox);
actionProgress=findViewById(R.id.action_progress);
menuButton=findViewById(R.id.options_btn);
avatar.setOutlineProvider(OutlineProviders.roundedRect(10));
avatar.setClipToOutline(true);
@ -94,6 +101,7 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
contextMenu=new PopupMenu(fragment.getActivity(), menuAnchor);
contextMenu.inflate(R.menu.profile);
contextMenu.setOnMenuItemClickListener(this::onContextMenuItemSelected);
menuButton.setOnClickListener(v->showMenuFromButton());
setStyle(AccessoryType.BUTTON, false);
}
@ -181,37 +189,13 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
@Override
public boolean onLongClick(float x, float y){
if(relationships==null)
if(onLongClick!=null && onLongClick.test(this))
return true;
if(accessoryType==AccessoryType.MENU || !prepareMenu())
return false;
Relationship relationship=relationships.get(item.account.id);
if(relationship==null)
return false;
Menu menu=contextMenu.getMenu();
Account account=item.account;
menu.findItem(R.id.share).setTitle(fragment.getString(R.string.share_user, account.getDisplayUsername()));
menu.findItem(R.id.mute).setTitle(fragment.getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.getDisplayUsername()));
menu.findItem(R.id.block).setTitle(fragment.getString(relationship.blocking ? R.string.unblock_user : R.string.block_user, account.getDisplayUsername()));
menu.findItem(R.id.report).setTitle(fragment.getString(R.string.report_user, account.getDisplayUsername()));
MenuItem hideBoosts=menu.findItem(R.id.hide_boosts);
if(relationship.following){
hideBoosts.setTitle(fragment.getString(relationship.showingReblogs ? R.string.hide_boosts_from_user : R.string.show_boosts_from_user, account.getDisplayUsername()));
hideBoosts.setVisible(true);
}else{
hideBoosts.setVisible(false);
}
MenuItem blockDomain=menu.findItem(R.id.block_domain);
if(!account.isLocal()){
blockDomain.setTitle(fragment.getString(relationship.domainBlocking ? R.string.unblock_domain : R.string.block_domain, account.getDomain()));
blockDomain.setVisible(true);
}else{
blockDomain.setVisible(false);
}
menuAnchor.setTranslationX(x);
menuAnchor.setTranslationY(y);
contextMenu.show();
return true;
}
@ -279,6 +263,13 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
})
.wrapProgress(fragment.getActivity(), R.string.loading, false)
.exec(accountID);
}else if(id==R.id.add_to_list){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("targetAccount", Parcels.wrap(account));
Nav.go(fragment.getActivity(), AddAccountToListsFragment.class, args);
}else if(onCustomMenuItemSelected!=null){
onCustomMenuItemSelected.accept(item);
}
return true;
}
@ -292,6 +283,14 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
onClick=listener;
}
public void setOnLongClickListener(Predicate<AccountViewHolder> onLongClick){
this.onLongClick=onLongClick;
}
public void setOnCustomMenuItemSelectedListener(Consumer<MenuItem> onCustomMenuItemSelected){
this.onCustomMenuItemSelected=onCustomMenuItemSelected;
}
public void setStyle(AccessoryType accessoryType, boolean showBio){
if(accessoryType!=this.accessoryType){
this.accessoryType=accessoryType;
@ -299,20 +298,29 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
case NONE -> {
button.setVisibility(View.GONE);
checkbox.setVisibility(View.GONE);
menuButton.setVisibility(View.GONE);
}
case CHECKBOX -> {
button.setVisibility(View.GONE);
checkbox.setVisibility(View.VISIBLE);
menuButton.setVisibility(View.GONE);
checkbox.setBackground(new CheckBox(checkbox.getContext()).getButtonDrawable());
}
case RADIOBUTTON -> {
button.setVisibility(View.GONE);
checkbox.setVisibility(View.VISIBLE);
menuButton.setVisibility(View.GONE);
checkbox.setBackground(new RadioButton(checkbox.getContext()).getButtonDrawable());
}
case BUTTON -> {
button.setVisibility(View.VISIBLE);
checkbox.setVisibility(View.GONE);
menuButton.setVisibility(View.GONE);
}
case MENU -> {
button.setVisibility(View.GONE);
checkbox.setVisibility(View.GONE);
menuButton.setVisibility(View.VISIBLE);
}
}
view.setCheckable(accessoryType==AccessoryType.CHECKBOX || accessoryType==AccessoryType.RADIOBUTTON);
@ -321,15 +329,63 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
bio.setVisibility(showBio ? View.VISIBLE : View.GONE);
}
private boolean prepareMenu(){
if(relationships==null)
return false;
Relationship relationship=relationships.get(item.account.id);
if(relationship==null)
return false;
Menu menu=contextMenu.getMenu();
Account account=item.account;
menu.findItem(R.id.share).setTitle(fragment.getString(R.string.share_user, account.getDisplayUsername()));
menu.findItem(R.id.mute).setTitle(fragment.getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.getDisplayUsername()));
menu.findItem(R.id.block).setTitle(fragment.getString(relationship.blocking ? R.string.unblock_user : R.string.block_user, account.getDisplayUsername()));
menu.findItem(R.id.report).setTitle(fragment.getString(R.string.report_user, account.getDisplayUsername()));
MenuItem hideBoosts=menu.findItem(R.id.hide_boosts);
if(relationship.following){
hideBoosts.setTitle(fragment.getString(relationship.showingReblogs ? R.string.hide_boosts_from_user : R.string.show_boosts_from_user, account.getDisplayUsername()));
hideBoosts.setVisible(true);
}else{
hideBoosts.setVisible(false);
}
MenuItem blockDomain=menu.findItem(R.id.block_domain);
if(!account.isLocal()){
blockDomain.setTitle(fragment.getString(relationship.domainBlocking ? R.string.unblock_domain : R.string.block_domain, account.getDomain()));
blockDomain.setVisible(true);
}else{
blockDomain.setVisible(false);
}
menu.findItem(R.id.add_to_list).setVisible(relationship.following);
return true;
}
private void showMenuFromButton(){
if(!prepareMenu())
return;
int[] xy={0, 0};
itemView.getLocationInWindow(xy);
int x=xy[0], y=xy[1];
menuButton.getLocationInWindow(xy);
menuAnchor.setTranslationX(xy[0]-x+menuButton.getWidth()/2f);
menuAnchor.setTranslationY(xy[1]-y+menuButton.getHeight());
contextMenu.show();
}
public void setChecked(boolean checked){
this.checked=checked;
view.setChecked(checked);
}
public PopupMenu getContextMenu(){
return contextMenu;
}
public enum AccessoryType{
NONE,
BUTTON,
CHECKBOX,
RADIOBUTTON
RADIOBUTTON,
MENU
}
}

View File

@ -0,0 +1,42 @@
package org.joinmastodon.android.ui.viewholders;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.viewmodel.AvatarPileListItem;
import org.joinmastodon.android.ui.views.AvatarPileView;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.utils.V;
public class AvatarPileListItemViewHolder extends ListItemViewHolder<AvatarPileListItem<?>> implements ImageLoaderViewHolder{
private final AvatarPileView pile;
public AvatarPileListItemViewHolder(Context context, ViewGroup parent){
super(context, R.layout.item_generic_list, parent);
pile=new AvatarPileView(context);
LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT);
lp.topMargin=lp.bottomMargin=V.dp(-8);
view.addView(pile, lp);
view.setClipToPadding(false);
}
@Override
public void onBind(AvatarPileListItem<?> item){
super.onBind(item);
pile.setVisibleAvatarCount(item.avatars.size());
}
@Override
public void setImage(int index, Drawable image){
pile.avatars[index].setImageDrawable(image);
}
@Override
public void clearImage(int index){
pile.avatars[index].setImageResource(R.drawable.image_placeholder);
}
}

View File

@ -75,6 +75,6 @@ public abstract class ListItemViewHolder<T extends ListItem<?>> extends Bindable
@Override
public void onClick(){
item.onClick.run();
item.performClick();
}
}

View File

@ -0,0 +1,33 @@
package org.joinmastodon.android.ui.viewholders;
import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.PopupMenu;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.viewmodel.ListItemWithOptionsMenu;
public class OptionsListItemViewHolder extends ListItemViewHolder<ListItemWithOptionsMenu<?>>{
private final PopupMenu menu;
private final ImageButton menuBtn;
public OptionsListItemViewHolder(Context context, ViewGroup parent){
super(context, R.layout.item_generic_list_options, parent);
menuBtn=findViewById(R.id.options_btn);
menu=new PopupMenu(context, menuBtn);
menuBtn.setOnClickListener(this::onMenuBtnClick);
menu.setOnMenuItemClickListener(menuItem->{
item.performItemSelected(menuItem);
return true;
});
}
private void onMenuBtnClick(View v){
menu.getMenu().clear();
item.performConfigureMenu(menu.getMenu());
menu.show();
}
}

View File

@ -0,0 +1,81 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.RectF;
import android.util.AttributeSet;
import android.view.Gravity;
import android.view.View;
import android.widget.ImageView;
import android.widget.LinearLayout;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.OutlineProviders;
import androidx.annotation.Nullable;
import me.grishka.appkit.utils.CustomViewHelper;
public class AvatarPileView extends LinearLayout implements CustomViewHelper{
public final ImageView[] avatars=new ImageView[3];
private final Paint borderPaint=new Paint(Paint.ANTI_ALIAS_FLAG);
private final RectF tmpRect=new RectF();
public AvatarPileView(Context context){
super(context);
init();
}
public AvatarPileView(Context context, @Nullable AttributeSet attrs){
super(context, attrs);
init();
}
public AvatarPileView(Context context, @Nullable AttributeSet attrs, int defStyleAttr){
super(context, attrs, defStyleAttr);
init();
}
private void init(){
setLayerType(LAYER_TYPE_HARDWARE, null);
setPaddingRelative(dp(16), 0, 0, 0);
setClipToPadding(false);
for(int i=0;i<avatars.length;i++){
ImageView ava=new ImageView(getContext());
ava.setScaleType(ImageView.ScaleType.CENTER_CROP);
ava.setOutlineProvider(OutlineProviders.roundedRect(6));
ava.setClipToOutline(true);
ava.setImageResource(R.drawable.image_placeholder);
ava.setPivotX(dp(16));
ava.setPivotY(dp(32));
ava.setRotation((avatars.length-1-i)*(-2f));
LayoutParams lp=new LayoutParams(dp(32), dp(32));
lp.gravity=Gravity.CENTER_VERTICAL;
if(i<avatars.length-1)
lp.setMarginEnd(dp(-16));
addView(ava, lp);
avatars[avatars.length-1-i]=ava;
}
borderPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
}
public void setVisibleAvatarCount(int count){
for(int i=0;i<avatars.length;i++){
avatars[i].setVisibility(i<count ? VISIBLE : INVISIBLE);
}
}
@Override
protected boolean drawChild(Canvas canvas, View child, long drawingTime){
tmpRect.set(child.getLeft(), child.getTop(), child.getRight(), child.getBottom());
tmpRect.offset(child.getTranslationX(), child.getTranslationY());
tmpRect.inset(dp(-2), dp(-2));
canvas.save();
canvas.rotate(child.getRotation(), child.getLeft()+child.getPivotX(), child.getTop()+child.getPivotY());
canvas.drawRoundRect(tmpRect, dp(8), dp(8), borderPaint);
canvas.restore();
return super.drawChild(canvas, child, drawingTime);
}
}

View File

@ -6,6 +6,7 @@ import android.widget.ImageView;
public class FixedAspectRatioImageView extends ImageView{
private float aspectRatio=1;
private boolean useHeight;
public FixedAspectRatioImageView(Context context){
this(context, null);
@ -21,8 +22,13 @@ public class FixedAspectRatioImageView extends ImageView{
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
int width=MeasureSpec.getSize(widthMeasureSpec);
heightMeasureSpec=Math.round(width/aspectRatio) | MeasureSpec.EXACTLY;
if(useHeight){
int height=MeasureSpec.getSize(heightMeasureSpec);
widthMeasureSpec=Math.round(height*aspectRatio) | MeasureSpec.EXACTLY;
}else{
int width=MeasureSpec.getSize(widthMeasureSpec);
heightMeasureSpec=Math.round(width/aspectRatio) | MeasureSpec.EXACTLY;
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
@ -33,4 +39,12 @@ public class FixedAspectRatioImageView extends ImageView{
public void setAspectRatio(float aspectRatio){
this.aspectRatio=aspectRatio;
}
public boolean isUseHeight(){
return useHeight;
}
public void setUseHeight(boolean useHeight){
this.useHeight=useHeight;
}
}

View File

@ -34,11 +34,13 @@ import org.joinmastodon.android.ui.utils.UiUtils;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.CustomViewHelper;
public class FloatingHintEditTextLayout extends FrameLayout implements CustomViewHelper{
private EditText edit;
private View firstChild;
private TextView label;
private int labelTextSize;
private int offsetY;
@ -71,30 +73,37 @@ public class FloatingHintEditTextLayout extends FrameLayout implements CustomVie
@Override
protected void onFinishInflate(){
super.onFinishInflate();
if(getChildCount()>0 && getChildAt(0) instanceof EditText et){
edit=et;
if(getChildCount()>0){
firstChild=getChildAt(0);
if(firstChild instanceof EditText et)
edit=et;
}else{
throw new IllegalStateException("First child must be an EditText");
throw new IllegalStateException("Must contain at least one child view");
}
label=new TextView(getContext());
label.setTextSize(TypedValue.COMPLEX_UNIT_PX, labelTextSize);
// label.setTextColor(labelColors==null ? edit.getHintTextColors() : labelColors);
origHintColors=edit.getHintTextColors();
label.setText(edit.getHint());
if(edit!=null){
origHintColors=edit.getHintTextColors();
label.setText(edit.getHint());
}
label.setSingleLine();
label.setPivotX(0f);
label.setPivotY(0f);
label.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
LayoutParams lp=new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.START | Gravity.TOP);
lp.setMarginStart(edit.getPaddingStart()+((LayoutParams)edit.getLayoutParams()).getMarginStart());
lp.setMarginStart(firstChild.getPaddingStart()+((LayoutParams)firstChild.getLayoutParams()).getMarginStart());
addView(label, lp);
hintVisible=edit.getText().length()==0;
hintVisible=edit!=null && edit.getText().length()==0;
if(hintVisible)
label.setAlpha(0f);
else
animProgress=1;
edit.addTextChangedListener(new SimpleTextWatcher(this::onTextChanged));
if(edit!=null)
edit.addTextChangedListener(new SimpleTextWatcher(this::onTextChanged));
errorView=new LinkedTextView(getContext());
errorView.setTextAppearance(R.style.m3_body_small);
@ -110,6 +119,18 @@ public class FloatingHintEditTextLayout extends FrameLayout implements CustomVie
label.setText(edit.getHint());
}
public void setHint(CharSequence hint){
label.setText(hint);
}
public void setHint(@StringRes int hint){
label.setText(hint);
}
public TextView getLabel(){
return label;
}
private void onTextChanged(Editable text){
if(errorState){
errorView.setVisibility(View.GONE);
@ -244,7 +265,7 @@ public class FloatingHintEditTextLayout extends FrameLayout implements CustomVie
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
if(errorView.getVisibility()!=GONE){
int width=MeasureSpec.getSize(widthMeasureSpec)-getPaddingLeft()-getPaddingRight();
LayoutParams editLP=(LayoutParams) edit.getLayoutParams();
LayoutParams editLP=(LayoutParams) firstChild.getLayoutParams();
width-=editLP.leftMargin+editLP.rightMargin;
errorView.measure(width | MeasureSpec.EXACTLY, MeasureSpec.UNSPECIFIED);
LayoutParams lp=(LayoutParams) errorView.getLayoutParams();
@ -254,7 +275,7 @@ public class FloatingHintEditTextLayout extends FrameLayout implements CustomVie
lp.leftMargin=editLP.leftMargin;
editLP.bottomMargin=errorView.getMeasuredHeight();
}else{
LayoutParams editLP=(LayoutParams) edit.getLayoutParams();
LayoutParams editLP=(LayoutParams) firstChild.getLayoutParams();
editLP.bottomMargin=0;
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
@ -355,7 +376,7 @@ public class FloatingHintEditTextLayout extends FrameLayout implements CustomVie
protected void onBoundsChange(@NonNull Rect bounds){
super.onBoundsChange(bounds);
int offset=dp(12);
wrapped.setBounds(edit.getLeft()-offset, edit.getTop()-offset, edit.getRight()+offset, edit.getBottom()+offset);
wrapped.setBounds(firstChild.getLeft()-offset, firstChild.getTop()-offset, firstChild.getRight()+offset, firstChild.getBottom()+offset);
}
}
}

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:drawable="@drawable/ic_arrow_drop_down_24px"
android:gravity="end|center_vertical"
android:left="12dp"
android:right="12dp"/>
</layer-list>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,15 L7,10H17Z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M10,17V7L15,12Z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M8,17Q8.425,17 8.713,16.712Q9,16.425 9,16Q9,15.575 8.713,15.287Q8.425,15 8,15Q7.575,15 7.287,15.287Q7,15.575 7,16Q7,16.425 7.287,16.712Q7.575,17 8,17ZM8,13Q8.425,13 8.713,12.712Q9,12.425 9,12Q9,11.575 8.713,11.287Q8.425,11 8,11Q7.575,11 7.287,11.287Q7,11.575 7,12Q7,12.425 7.287,12.712Q7.575,13 8,13ZM8,9Q8.425,9 8.713,8.712Q9,8.425 9,8Q9,7.575 8.713,7.287Q8.425,7 8,7Q7.575,7 7.287,7.287Q7,7.575 7,8Q7,8.425 7.287,8.712Q7.575,9 8,9ZM11,17H17V15H11ZM11,13H17V11H11ZM11,9H17V7H11ZM5,21Q4.175,21 3.587,20.413Q3,19.825 3,19V5Q3,4.175 3.587,3.587Q4.175,3 5,3H19Q19.825,3 20.413,3.587Q21,4.175 21,5V19Q21,19.825 20.413,20.413Q19.825,21 19,21ZM5,19H19Q19,19 19,19Q19,19 19,19V5Q19,5 19,5Q19,5 19,5H5Q5,5 5,5Q5,5 5,5V19Q5,19 5,19Q5,19 5,19ZM5,5Q5,5 5,5Q5,5 5,5V19Q5,19 5,19Q5,19 5,19Q5,19 5,19Q5,19 5,19V5Q5,5 5,5Q5,5 5,5Z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,20Q11.175,20 10.588,19.413Q10,18.825 10,18Q10,17.175 10.588,16.587Q11.175,16 12,16Q12.825,16 13.413,16.587Q14,17.175 14,18Q14,18.825 13.413,19.413Q12.825,20 12,20ZM12,14Q11.175,14 10.588,13.412Q10,12.825 10,12Q10,11.175 10.588,10.587Q11.175,10 12,10Q12.825,10 13.413,10.587Q14,11.175 14,12Q14,12.825 13.413,13.412Q12.825,14 12,14ZM12,8Q11.175,8 10.588,7.412Q10,6.825 10,6Q10,5.175 10.588,4.588Q11.175,4 12,4Q12.825,4 13.413,4.588Q14,5.175 14,6Q14,6.825 13.413,7.412Q12.825,8 12,8Z"/>
</vector>

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<org.joinmastodon.android.ui.views.FloatingHintEditTextLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="4dp"
android:paddingBottom="12dp"
app:labelTextColor="@color/m3_outlined_text_field_label"
android:foreground="@drawable/bg_m3_outlined_text_field">
<Spinner
android:id="@+id/spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginTop="8dp"
android:elevation="0dp"/>
</org.joinmastodon.android.ui.views.FloatingHintEditTextLayout>

View File

@ -119,6 +119,17 @@
android:layout_marginTop="2dp"
android:duplicateParentState="true"
android:visibility="gone"/>
<ImageButton
android:id="@+id/options_btn"
android:layout_width="40dp"
android:layout_height="36dp"
android:layout_gravity="top"
android:background="?android:actionBarItemBackground"
android:tint="?colorM3OnSurfaceVariant"
android:contentDescription="@string/more_options"
android:src="@drawable/ic_more_vert_24px"
android:visibility="gone"/>
</FrameLayout>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="56dp"
android:paddingStart="16dp"
android:paddingEnd="24dp"
android:drawablePadding="16dp"
android:maxLines="2"
android:gravity="center_vertical"
android:textAppearance="@style/m3_body_large"
android:textColor="?colorM3OnSurface"
android:drawableTint="?colorM3OnSurface"
android:ellipsize="end"
tools:text="Menu item">
</TextView>

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="56dp"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:gravity="center_vertical">
<include layout="@layout/item_generic_list_content"/>
<ImageButton
android:id="@+id/options_btn"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="8dp"
android:layout_gravity="top"
android:contentDescription="@string/more_options"
android:src="@drawable/ic_more_vert_24px"
android:tint="?colorM3OnSurface"
android:background="?android:actionBarItemBackground"/>
</LinearLayout>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@android:id/text1"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
android:textAppearance="@style/m3_body_large"
android:textColor="?colorM3OnSurface"
android:singleLine="true"/>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/delete" android:showAsAction="always" android:icon="@drawable/ic_delete_24px" android:title="@string/delete"/>
</menu>

View File

@ -1,5 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/edit_list"
android:icon="@drawable/ic_edit_24px"
android:showAsAction="always"
android:title="@string/edit_list"/>
<item
android:id="@+id/settings"
android:icon="@drawable/ic_settings_24px"

View File

@ -9,7 +9,7 @@
<item android:id="@+id/delete" android:title="@string/delete"/>
</group>
<group android:id="@+id/menu_group2">
<!-- TODO add to list -->
<item android:id="@+id/add_to_list" android:title="@string/add_user_to_list"/>
<item android:id="@+id/follow" android:title="@string/follow_user"/>
<item android:id="@+id/mute" android:title="@string/mute_user"/>
<item android:id="@+id/block" android:title="@string/block_user"/>

View File

@ -1,11 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/share" android:title="@string/share_user"/>
<!-- TODO add to list -->
<item android:id="@+id/mute" android:title="@string/mute_user"/>
<item android:id="@+id/block" android:title="@string/block_user"/>
<item android:id="@+id/report" android:title="@string/report_user"/>
<item android:id="@+id/block_domain" android:title="@string/block_domain"/>
<item android:id="@+id/hide_boosts" android:title="@string/hide_boosts_from_user"/>
<item android:id="@+id/open_in_browser" android:title="@string/open_in_browser"/>
<group android:orderInCategory="1">
<item android:id="@+id/share" android:title="@string/share_user"/>
<item android:id="@+id/add_to_list" android:title="@string/add_user_to_list"/>
<item android:id="@+id/mute" android:title="@string/mute_user"/>
<item android:id="@+id/block" android:title="@string/block_user"/>
<item android:id="@+id/report" android:title="@string/report_user"/>
<item android:id="@+id/block_domain" android:title="@string/block_domain"/>
<item android:id="@+id/hide_boosts" android:title="@string/hide_boosts_from_user"/>
<item android:id="@+id/open_in_browser" android:title="@string/open_in_browser"/>
</group>
</menu>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/members"
android:icon="@drawable/ic_group_24px"
android:title="@string/list_members"
android:showAsAction="always"/>
<item
android:id="@+id/edit_list"
android:icon="@drawable/ic_edit_24px"
android:showAsAction="always"
android:title="@string/edit_list"/>
</menu>

View File

@ -16,6 +16,7 @@
<item name="notifications_mentions" type="id"/>
<item name="save" type="id"/>
<item name="remove_from_list" type="id"/>
<item name="list_item_simple" type="id"/>
<item name="list_item_simple_tinted" type="id"/>
@ -23,6 +24,8 @@
<item name="list_item_checkbox" type="id"/>
<item name="list_item_radio" type="id"/>
<item name="list_item_account" type="id"/>
<item name="list_item_options" type="id"/>
<item name="list_item_avatar_pile" type="id"/>
<item name="server_about" type="id"/>
<item name="server_rules" type="id"/>

View File

@ -610,4 +610,39 @@
</plurals>
<string name="error_playing_video">Error playing video</string>
<string name="timeline_following">Following</string>
<string name="lists">Lists</string>
<string name="followed_hashtags">Followed hashtags</string>
<string name="no_lists">You don\'t have any lists yet.</string>
<string name="no_followed_hashtags">You don\'t follow any hashtags.</string>
<string name="manage_lists">Manage lists</string>
<string name="manage_hashtags">Manage hashtags</string>
<!-- Screen reader description for the menu on the home timeline screen -->
<string name="dropdown_menu">Dropdown menu</string>
<string name="edit_list">Edit list</string>
<string name="list_members">List members</string>
<string name="delete_list">Delete list</string>
<!-- %s is the name of the list -->
<string name="delete_list_confirm">Delete “%s”?</string>
<string name="list_exclusive">Hide members in Following</string>
<string name="list_exclusive_subtitle">If someone is on this list, hide them in your Following timeline to avoid seeing their posts twice.</string>
<string name="list_name">List name</string>
<string name="list_show_replies_to">Show replies to</string>
<string name="list_replies_no_one">No one</string>
<string name="list_replies_members">Members of the list</string>
<string name="list_replies_anyone">Anyone I follow</string>
<string name="confirm_remove_list_members">Remove members?</string>
<string name="remove">Remove</string>
<string name="add_list_member">Add member</string>
<string name="search_among_people_you_follow">Search among people you follow</string>
<string name="add_user_to_list">Add to list…</string>
<string name="add_user_to_list_title">Add to list</string>
<!-- %s is a username -->
<string name="manage_user_lists">Manage the lists %s appears on</string>
<string name="remove_from_list">Remove from list</string>
<string name="confirm_remove_list_member">Remove member?</string>
<plurals name="x_posts_recently">
<item quantity="one">%,d post recently</item>
<item quantity="other">%,d posts recently</item>
</plurals>
</resources>

View File

@ -7,6 +7,8 @@
<item name="android:splitMotionEvents">false</item>
<item name="android:windowBackground">@color/m3_sys_light_surface</item>
<item name="android:editTextStyle">@style/Widget.Mastodon.EditText</item>
<item name="android:spinnerStyle">@style/Widget.Mastodon.Spinner</item>
<item name="android:actionModeCloseButtonStyle">@style/action_mode_close</item>
<item name="android:buttonStyle">@style/Widget.Mastodon.M3.Button.Filled</item>
<item name="android:actionBarTheme">@style/Theme.Mastodon.Toolbar</item>
@ -73,6 +75,8 @@
<item name="android:splitMotionEvents">false</item>
<item name="android:windowBackground">@color/m3_sys_dark_surface</item>
<item name="android:editTextStyle">@style/Widget.Mastodon.EditText</item>
<item name="android:spinnerStyle">@style/Widget.Mastodon.Spinner</item>
<item name="android:actionModeCloseButtonStyle">@style/action_mode_close</item>
<item name="android:buttonStyle">@style/Widget.Mastodon.M3.Button</item>
<item name="android:actionBarTheme">@style/Theme.Mastodon.Toolbar</item>
@ -165,7 +169,12 @@
<item name="android:titleTextStyle">@style/action_mode_title</item>
</style>
<style name="action_mode_close" parent="@android:style/Widget.Material.ActionButton.CloseMode">
<item name="android:layout_margin">0dp</item>
</style>
<style name="action_mode_title" parent="android:TextAppearance.Material.Widget.ActionMode.Title">
<item name="android:fontFamily">sans-serif</item>
<item name="android:textColor">?colorM3OnPrimary</item>
</style>
@ -192,6 +201,12 @@
<item name="android:textAppearance">@style/m3_body_large</item>
</style>
<style name="Widget.Mastodon.Spinner" parent="android:Widget.Material.Light.Spinner">
<item name="android:popupBackground">@drawable/bg_popup</item>
<item name="android:background">@drawable/bg_spinner</item>
<item name="android:backgroundTint">?colorM3OnSurface</item>
</style>
<style name="Theme.Mastodon.Dialog.Alert" parent="android:Theme.Material.Light.Dialog.Alert">
<item name="android:windowTitleStyle">@style/alert_title</item>
<item name="android:dialogPreferredPadding">24dp</item>