diff --git a/mastodon/build.gradle b/mastodon/build.gradle index 48be30f9..155ea706 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -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' diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java index 6bb5a04d..384c762e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java @@ -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>>> pendingNotificationsCallbacks=new ArrayList<>(); + private List lists; private static final int POST_FLAG_GAP_AFTER=1; @@ -300,6 +312,67 @@ public class CacheController{ }, 0); } + public void reloadLists(Callback> callback){ + new GetLists() + .setCallback(new Callback<>(){ + @Override + public void onSuccess(List 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 loadListsFromFile(){ + File file=getListsFile(); + if(!file.exists()) + return null; + try(InputStreamReader in=new InputStreamReader(new FileInputStream(file))){ + return MastodonAPIController.gson.fromJson(in, new TypeToken>(){}.getType()); + }catch(Exception x){ + Log.w(TAG, "failed to read lists from cache file", x); + return null; + } + } + + public void getLists(Callback> callback){ + if(lists!=null){ + if(callback!=null) + callback.onSuccess(lists); + return; + } + databaseThread.postRunnable(()->{ + List 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(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java index a7d1e77c..f308d872 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java @@ -154,6 +154,8 @@ public abstract class MastodonAPIRequest extends APIRequest{ } public RequestBody getRequestBody() throws IOException{ + if(requestBody instanceof RequestBody rb) + return rb; return requestBody==null ? null : new JsonObjectRequestBody(requestBody); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountLists.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountLists.java new file mode 100644 index 00000000..55e03fb3 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountLists.java @@ -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>{ + public GetAccountLists(String id){ + super(HttpMethod.GET, "/accounts/"+id+"/lists", new TypeToken<>(){}); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SearchAccounts.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SearchAccounts.java new file mode 100644 index 00000000..6bfb87e4 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SearchAccounts.java @@ -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>{ + 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"); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/KeywordAttribute.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/KeywordAttribute.java index d35a0f0f..b2ed7f18 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/KeywordAttribute.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/KeywordAttribute.java @@ -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") diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/AddAccountsToList.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/AddAccountsToList.java new file mode 100644 index 00000000..29c1aace --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/AddAccountsToList.java @@ -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 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()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/DeleteList.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/DeleteList.java new file mode 100644 index 00000000..716d9a5d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/DeleteList.java @@ -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); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetListAccounts.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetListAccounts.java new file mode 100644 index 00000000..1d54dc2d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetListAccounts.java @@ -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{ + 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)); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetLists.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetLists.java new file mode 100644 index 00000000..fb8aa663 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetLists.java @@ -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>{ + public GetLists(){ + super(HttpMethod.GET, "/lists", new TypeToken<>(){}); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/RemoveAccountsFromList.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/RemoveAccountsFromList.java new file mode 100644 index 00000000..20f20463 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/RemoveAccountsFromList.java @@ -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 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()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/UpdateList.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/UpdateList.java new file mode 100644 index 00000000..905ad0d2 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/UpdateList.java @@ -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{ + 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; + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/GetFollowedTags.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/GetFollowedTags.java new file mode 100644 index 00000000..5c88a471 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/GetFollowedTags.java @@ -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{ + 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+""); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetListTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetListTimeline.java new file mode 100644 index 00000000..899893cc --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetListTimeline.java @@ -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>{ + 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); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java index 6723c18b..4486f3f8 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java @@ -10,7 +10,7 @@ import org.joinmastodon.android.model.Status; import java.util.List; public class GetPublicTimeline extends MastodonAPIRequest>{ - 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>{ 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+""); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java index deb6ed43..0f5a773b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java @@ -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 lists; AccountSession(Token token, Account self, Application app, String domain, boolean activated, AccountActivationInfo activationInfo){ this.token=token; diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java index bff464b4..42e0808b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java @@ -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){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/AccountAddedToListEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/AccountAddedToListEvent.java new file mode 100644 index 00000000..03248538 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/AccountAddedToListEvent.java @@ -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; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/AccountRemovedFromListEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/AccountRemovedFromListEvent.java new file mode 100644 index 00000000..f7cce08e --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/AccountRemovedFromListEvent.java @@ -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; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/ListDeletedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/ListDeletedEvent.java new file mode 100644 index 00000000..b12eaa22 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/ListDeletedEvent.java @@ -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; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedEvent.java new file mode 100644 index 00000000..b27fe014 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/ListUpdatedEvent.java @@ -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; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/AddAccountToListsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/AddAccountToListsFragment.java new file mode 100644 index 00000000..deb59fce --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/AddAccountToListsFragment.java @@ -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{ + 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 allLists){ + if(getActivity()==null) + return; + loadAccountLists(allLists); + } + }); + } + + private void loadAccountLists(final List allLists){ + currentRequest=new GetAccountLists(account.id) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(List result){ + Set 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 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); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java index f3e214ab..7aaa337a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java @@ -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(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/EditListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditListFragment.java new file mode 100644 index 00000000..70afae3b --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditListFragment.java @@ -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{ + private FollowList followList; + private AvatarPileListItem membersItem; + private CheckableListItem 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 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 result){ + getMembersRequest=null; + membersItem.avatars=new ArrayList<>(); + for(int i=0;i 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 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 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> 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> 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 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 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 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 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 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 targetList=displayItems.subList(gapPos, gapPos+1); targetList.clear(); List 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> callback){ + MastodonAPIRequest> 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 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 + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListMembersFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListMembersFragment.java new file mode 100644 index 00000000..f8fe522a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListMembersFragment.java @@ -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 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 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 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 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 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); + } + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java new file mode 100644 index 00000000..550e8332 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java @@ -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 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; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ManageFollowedHashtagsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ManageFollowedHashtagsFragment.java new file mode 100644 index 00000000..f812b1ec --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ManageFollowedHashtagsFragment.java @@ -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 implements ListItemWithOptionsMenu.OptionsMenuListener{ + 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 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 item, Menu menu){ + menu.clear(); + menu.add(getString(R.string.unfollow_user, "#"+item.parentObject.name)); + } + + @Override + public void onListItemOptionSelected(ListItemWithOptionsMenu 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 item){ + UiUtils.openHashtagTimeline(getActivity(), accountID, item.parentObject); + } + + private void doUnfollow(ListItemWithOptionsMenu 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); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ManageListsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ManageListsFragment.java new file mode 100644 index 00000000..d9aef78f --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ManageListsFragment.java @@ -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 implements ListItemWithOptionsMenu.OptionsMenuListener{ + @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> callback=new SimpleCallback<>(this){ + @Override + public void onSuccess(List 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 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 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 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 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 item:data){ + if(item.parentObject.id.equals(ev.listID)){ + data.remove(i); + itemsAdapter.notifyItemRemoved(i); + break; + } + i++; + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonRecyclerFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonRecyclerFragment.java index f96cbb45..e95c5e16 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonRecyclerFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonRecyclerFragment.java @@ -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 extends BaseRecyclerFragment(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 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(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AddListMembersFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AddListMembersFragment.java new file mode 100644 index 00000000..ee061896 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AddListMembersFragment.java @@ -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 result){ + AddListMembersFragment.this.onSuccess(result); + } + }) + .exec(accountID); + } + + @Override + protected String getSearchViewPlaceholder(){ + return getString(R.string.search_among_people_you_follow); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BaseAccountListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BaseAccountListFragment.java index 46c5c49b..7d3c5f88 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BaseAccountListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BaseAccountListFragment.java @@ -150,6 +150,7 @@ public abstract class BaseAccountListFragment extends MastodonRecyclerFragment implements ImageLoaderRecyclerAdapter{ public AccountsAdapter(){ @@ -167,6 +168,7 @@ public abstract class BaseAccountListFragment extends MastodonRecyclerFragment(this){ @Override public void onSuccess(List result){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchQueryFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchQueryFragment.java index 226c4ad3..598e77e0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchQueryFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchQueryFragment.java @@ -112,7 +112,7 @@ public class SearchQueryFragment extends MastodonRecyclerFragment{ 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{ 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 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 item_){ deliverResult(searchViewHelper.getQuery(), SearchResult.Type.STATUS); } - private void onGoToAccountSearchClick(){ + private void onGoToAccountSearchClick(ListItem item_){ deliverResult(searchViewHelper.getQuery(), SearchResult.Type.ACCOUNT); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/BaseSettingsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/BaseSettingsFragment.java index b14b8c52..0207d4b7 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/BaseSettingsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/BaseSettingsFragment.java @@ -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 extends MastodonRecyclerFragment>{ protected GenericListItemsAdapter itemsAdapter; @@ -45,7 +43,7 @@ public abstract class BaseSettingsFragment extends MastodonRecyclerFragment getAdapter(){ - return itemsAdapter=new GenericListItemsAdapter(data); + return itemsAdapter=new GenericListItemsAdapter(imgLoader, data); } @Override @@ -59,12 +57,13 @@ public abstract class BaseSettingsFragment extends MastodonRecyclerFragment item){ - item.toggle(); + protected void toggleCheckableItem(ListItem item){ + if(item instanceof CheckableListItem checkable) + checkable.toggle(); rebindItem(item); } - protected void rebindItem(ListItem item){ + protected void rebindItem(ListItem item){ if(list==null) return; if(list.findViewHolderForAdapterPosition(indexOfItemsAdapter()+data.indexOf(item)) instanceof ListItemViewHolder holder){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/EditFilterFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/EditFilterFragment.java index 4655c416..bee767c1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/EditFilterFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/EditFilterFragment.java @@ -73,7 +73,7 @@ public class EditFilterFragment extends BaseSettingsFragment 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 implements On return 1; } - private void onDurationClick(){ + private void onDurationClick(ListItem item_){ int[] durationOptions={ 1800, 3600, @@ -182,21 +182,21 @@ public class EditFilterFragment extends BaseSettingsFragment implements On alert.setOnDismissListener(dialog->callback.accept(null)); } - private void onWordsClick(){ + private void onWordsClick(ListItem item){ Bundle args=new Bundle(); args.putString("account", accountID); args.putParcelableArrayList("words", (ArrayList) 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 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 item_){ AlertDialog alert=new M3AlertDialogBuilder(getActivity()) .setTitle(getString(R.string.settings_delete_filter_title, filter.title)) .setMessage(R.string.settings_delete_filter_confirmation) diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/FilterContextFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/FilterContextFragment.java index 233c00cb..37d91107 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/FilterContextFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/FilterContextFragment.java @@ -22,10 +22,9 @@ public class FilterContextFragment extends BaseSettingsFragment i setTitle(R.string.settings_filter_context); context=(EnumSet) getArguments().getSerializable("context"); onDataLoaded(Arrays.stream(FilterContext.values()).map(c->{ - CheckableListItem item=new CheckableListItem<>(c.getDisplayNameRes(), 0, CheckableListItem.Style.CHECKBOX, context.contains(c), null); + CheckableListItem 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())); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/FilterWordsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/FilterWordsFragment.java index 52c4f195..fb689ef6 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/FilterWordsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/FilterWordsFragment.java @@ -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 imp FilterKeyword word=Parcels.unwrap(p); ListItem 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 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 imp w.keyword=input; ListItem 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 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 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 imp ListItem item=data.get(i); CheckableListItem 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 imp ListItem item=data.get(i); ListItem 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); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java index f767e8d7..7174a340 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java @@ -32,10 +32,10 @@ public class SettingsAboutAppFragment extends BaseSettingsFragment{ 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{ return adapter; } - private void onClearMediaCacheClick(){ + private void onClearMediaCacheClick(ListItem item){ MastodonAPIController.runInBackground(()->{ Activity activity=getActivity(); ImageCache.getInstance(getActivity()).clear(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsBehaviorFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsBehaviorFragment.java index 28181860..0852f6e2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsBehaviorFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsBehaviorFragment.java @@ -33,19 +33,19 @@ public class SettingsBehaviorFragment extends BaseSettingsFragment{ 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) diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java index 0b666705..bc3b5949 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java @@ -39,7 +39,7 @@ public class SettingsDebugFragment extends BaseSettingsFragment{ @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{ 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(); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDisplayFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDisplayFragment.java index 27571a80..f50c9188 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDisplayFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDisplayFragment.java @@ -39,10 +39,10 @@ public class SettingsDisplayFragment extends BaseSettingsFragment{ 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{ }; } - private void onAppearanceClick(){ + private void onAppearanceClick(ListItem item_){ int selected=switch(GlobalUserPreferences.theme){ case LIGHT -> 0; case DARK -> 1; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsFiltersFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsFiltersFragment.java index 39082755..be9ba8a6 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsFiltersFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsFiltersFragment.java @@ -67,16 +67,14 @@ public class SettingsFiltersFragment extends BaseSettingsFragment{ 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 makeListItem(Filter f){ - ListItem 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 item=new ListItem<>(f.title, getString(f.isActive() ? R.string.filter_active : R.string.filter_inactive), this::onFilterClick, f); return item; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java index e7ea455f..f14c66b1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java @@ -61,7 +61,7 @@ public class SettingsMainFragment extends BaseSettingsFragment{ )); 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{ 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())) diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsNotificationsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsNotificationsFragment.java index 37f30745..deced530 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsNotificationsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsNotificationsFragment.java @@ -55,14 +55,14 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment{ 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{ 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, diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsPrivacyFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsPrivacyFragment.java index 743dd97a..27ce4f1d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsPrivacyFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsPrivacyFragment.java @@ -18,8 +18,8 @@ public class SettingsPrivacyFragment extends BaseSettingsFragment{ 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; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerAboutFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerAboutFragment.java index 7eb92ca2..cd464289 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerAboutFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerAboutFragment.java @@ -139,7 +139,7 @@ public class SettingsServerAboutFragment extends LoaderFragment{ if(!TextUtils.isEmpty(instance.email)){ needDivider=true; SimpleListItemViewHolder holder=new SimpleListItemViewHolder(getActivity(), scrollingLayout); - ListItem item=new ListItem<>(R.string.send_email_to_server_admin, 0, R.drawable.ic_mail_24px, ()->{}); + ListItem 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()); diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/FollowList.java b/mastodon/src/main/java/org/joinmastodon/android/model/FollowList.java new file mode 100644 index 00000000..82b5aa13 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/FollowList.java @@ -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 + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Hashtag.java b/mastodon/src/main/java/org/joinmastodon/android/model/Hashtag.java index d69ac319..66f91104 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Hashtag.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Hashtag.java @@ -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(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AvatarPileListItem.java b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AvatarPileListItem.java new file mode 100644 index 00000000..6839de1d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AvatarPileListItem.java @@ -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 extends ListItem{ + public List avatars; + + public AvatarPileListItem(String title, String subtitle, List avatars, int iconRes, Consumer> onClick, T parentObject, boolean dividerAfter){ + super(title, subtitle, iconRes, (Consumer>)(Object)onClick, parentObject, 0, dividerAfter); + this.avatars=avatars; + } + + @Override + public int getItemViewType(){ + return R.id.list_item_avatar_pile; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/CheckableListItem.java b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/CheckableListItem.java index cff521a9..0363f81f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/CheckableListItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/CheckableListItem.java @@ -9,42 +9,42 @@ public class CheckableListItem extends ListItem{ public boolean checked; public Consumer 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> onClick, T parentObject, boolean dividerAfter){ + super(title, subtitle, iconRes, (Consumer>)(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> 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> 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> 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> 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> 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> 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> 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> onClick, boolean dividerAfter){ + super(titleRes, subtitleRes, iconRes, (Consumer>)(Object)onClick, 0, dividerAfter); this.style=style; this.checked=checked; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/ListItem.java b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/ListItem.java index 8f4d9c73..97854314 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/ListItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/ListItem.java @@ -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{ public int iconRes; public int colorOverrideAttr; public boolean dividerAfter; - public Runnable onClick; + private Consumer> 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> onClick, T parentObject, int colorOverrideAttr, boolean dividerAfter){ this.title=title; this.subtitle=subtitle; this.iconRes=iconRes; @@ -32,41 +34,41 @@ public class ListItem{ isEnabled=false; } - public ListItem(String title, String subtitle, Runnable onClick){ + public ListItem(String title, String subtitle, Consumer> 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> 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> 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> 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> 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> 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> 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> 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{ 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 > void setOnClick(Consumer onClick){ + this.onClick=(Consumer>) onClick; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/ListItemWithOptionsMenu.java b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/ListItemWithOptionsMenu.java new file mode 100644 index 00000000..7f3f6080 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/ListItemWithOptionsMenu.java @@ -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 extends ListItem{ + public OptionsMenuListener listener; + + public ListItemWithOptionsMenu(String title, String subtitle, OptionsMenuListener listener, int iconRes, Consumer> onClick, T parentObject, boolean dividerAfter){ + super(title, subtitle, iconRes, (Consumer>)(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{ + void onConfigureListItemOptionsMenu(ListItemWithOptionsMenu item, Menu menu); + void onListItemOptionSelected(ListItemWithOptionsMenu item, MenuItem menuItem); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/SearchViewHelper.java b/mastodon/src/main/java/org/joinmastodon/android/ui/SearchViewHelper.java index d816b715..d64bb300 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/SearchViewHelper.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/SearchViewHelper.java @@ -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; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/adapters/GenericListItemsAdapter.java b/mastodon/src/main/java/org/joinmastodon/android/ui/adapters/GenericListItemsAdapter.java index d5b5655b..d8db4d9c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/adapters/GenericListItemsAdapter.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/adapters/GenericListItemsAdapter.java @@ -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 extends RecyclerView.Adapter>{ +public class GenericListItemsAdapter extends UsableRecyclerView.Adapter> implements ImageLoaderRecyclerAdapter{ private List> items; public GenericListItemsAdapter(List> items){ + super(null); + this.items=items; + } + + public GenericListItemsAdapter(ListImageLoaderWrapper imgLoader, List> items){ + super(imgLoader); this.items=items; } @@ -32,6 +45,10 @@ public class GenericListItemsAdapter extends RecyclerView.Adapter extends RecyclerView.Adapter 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; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java index b355b04f..fd8fad7b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java @@ -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); } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/ActionModeHelper.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/ActionModeHelper.java new file mode 100644 index 00000000..7e8bb310 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/ActionModeHelper.java @@ -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> 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{ + public final String title; + public final boolean hasSubmenu; + public final boolean dividerBefore; + public final T parentObject; + public final Consumer> onClick; + + public Item(String title, boolean hasSubmenu, boolean dividerBefore, T parentObject, Consumer> 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> onClick){ + this(title, hasSubmenu, dividerBefore, null, onClick); + } + + public Item(@StringRes int titleRes, boolean hasSubmenu, boolean dividerBefore, Consumer> onClick){ + this(dropdownController.getActivity().getString(titleRes), hasSubmenu, dividerBefore, null, onClick); + } + + private void performClick(){ + onClick.accept(this); + } + } + + protected class ItemsAdapter extends RecyclerView.Adapter{ + + @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> 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(); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineHashtagsMenuController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineHashtagsMenuController.java new file mode 100644 index 00000000..b905cf67 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineHashtagsMenuController.java @@ -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 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 result){ + currentRequest=null; + dropdownController.resizeOnNextFrame(); + largeProgressAdapter.setVisible(false); + ((List) 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(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()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineListsMenuController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineListsMenuController.java new file mode 100644 index 00000000..a17ac62a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineListsMenuController.java @@ -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 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(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 item){ + callback.onListSelected(item.parentObject); + dropdownController.dismiss(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineMenuController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineMenuController.java new file mode 100644 index 00000000..2391d5b7 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/HomeTimelineMenuController.java @@ -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(R.string.timeline_following, false, false, i->{ + callback.onFollowingSelected(); + dropdownController.dismiss(); + }), + new Item(R.string.local_timeline, false, false, i->{ + callback.onLocalSelected(); + dropdownController.dismiss(); + }), + new Item(R.string.lists, true, true, i->dropdownController.pushSubmenuController(new HomeTimelineListsMenuController(dropdownController, callback))), + new Item(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 getLists(); + void onListSelected(FollowList list); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ToolbarDropdownMenuController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ToolbarDropdownMenuController.java new file mode 100644 index 00000000..a40eca8c --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ToolbarDropdownMenuController.java @@ -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 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;i1) + 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(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AccountViewHolder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AccountViewHolder.java index f52b9199..2e6ccdab 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AccountViewHolder.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AccountViewHolder.java @@ -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 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 relationships; private Consumer onClick; + private Predicate onLongClick; + private Consumer onCustomMenuItemSelected; private AccessoryType accessoryType; private boolean showBio; private boolean checked; @@ -85,6 +91,7 @@ public class AccountViewHolder extends BindableViewHolder 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 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 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 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 impl onClick=listener; } + public void setOnLongClickListener(Predicate onLongClick){ + this.onLongClick=onLongClick; + } + + public void setOnCustomMenuItemSelectedListener(Consumer 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 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 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 } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AvatarPileListItemViewHolder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AvatarPileListItemViewHolder.java new file mode 100644 index 00000000..1590f7df --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AvatarPileListItemViewHolder.java @@ -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> 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); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/ListItemViewHolder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/ListItemViewHolder.java index 7b138c48..f703b2f9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/ListItemViewHolder.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/ListItemViewHolder.java @@ -75,6 +75,6 @@ public abstract class ListItemViewHolder> extends Bindable @Override public void onClick(){ - item.onClick.run(); + item.performClick(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/OptionsListItemViewHolder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/OptionsListItemViewHolder.java new file mode 100644 index 00000000..30a32614 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/OptionsListItemViewHolder.java @@ -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>{ + 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(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/AvatarPileView.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/AvatarPileView.java new file mode 100644 index 00000000..bdb050f0 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/AvatarPileView.java @@ -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;i0 && 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); } } } diff --git a/mastodon/src/main/res/drawable/bg_spinner.xml b/mastodon/src/main/res/drawable/bg_spinner.xml new file mode 100644 index 00000000..0cfe9635 --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_spinner.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_arrow_drop_down_24px.xml b/mastodon/src/main/res/drawable/ic_arrow_drop_down_24px.xml new file mode 100644 index 00000000..56d1b3b0 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_arrow_drop_down_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_arrow_right_24px.xml b/mastodon/src/main/res/drawable/ic_arrow_right_24px.xml new file mode 100644 index 00000000..6062a6d4 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_arrow_right_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_list_alt_24px.xml b/mastodon/src/main/res/drawable/ic_list_alt_24px.xml new file mode 100644 index 00000000..5341250c --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_list_alt_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_more_vert_24px.xml b/mastodon/src/main/res/drawable/ic_more_vert_24px.xml new file mode 100644 index 00000000..bb501448 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_more_vert_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/layout/floating_hint_spinner.xml b/mastodon/src/main/res/layout/floating_hint_spinner.xml new file mode 100644 index 00000000..44b15818 --- /dev/null +++ b/mastodon/src/main/res/layout/floating_hint_spinner.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/mastodon/src/main/res/layout/item_account_list.xml b/mastodon/src/main/res/layout/item_account_list.xml index 3f5528c3..99b57d3f 100644 --- a/mastodon/src/main/res/layout/item_account_list.xml +++ b/mastodon/src/main/res/layout/item_account_list.xml @@ -119,6 +119,17 @@ android:layout_marginTop="2dp" android:duplicateParentState="true" android:visibility="gone"/> + + diff --git a/mastodon/src/main/res/layout/item_dropdown_menu.xml b/mastodon/src/main/res/layout/item_dropdown_menu.xml new file mode 100644 index 00000000..46483fcb --- /dev/null +++ b/mastodon/src/main/res/layout/item_dropdown_menu.xml @@ -0,0 +1,17 @@ + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/item_generic_list_options.xml b/mastodon/src/main/res/layout/item_generic_list_options.xml new file mode 100644 index 00000000..4ca3c0e9 --- /dev/null +++ b/mastodon/src/main/res/layout/item_generic_list_options.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/item_spinner.xml b/mastodon/src/main/res/layout/item_spinner.xml new file mode 100644 index 00000000..fcf964b9 --- /dev/null +++ b/mastodon/src/main/res/layout/item_spinner.xml @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/mastodon/src/main/res/menu/edit_list_action_mode.xml b/mastodon/src/main/res/menu/edit_list_action_mode.xml new file mode 100644 index 00000000..87e8ad39 --- /dev/null +++ b/mastodon/src/main/res/menu/edit_list_action_mode.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/menu/home.xml b/mastodon/src/main/res/menu/home.xml index ba9978f4..f3d4169d 100644 --- a/mastodon/src/main/res/menu/home.xml +++ b/mastodon/src/main/res/menu/home.xml @@ -1,5 +1,10 @@ + - + diff --git a/mastodon/src/main/res/menu/profile.xml b/mastodon/src/main/res/menu/profile.xml index 5e6aef95..d35e329f 100644 --- a/mastodon/src/main/res/menu/profile.xml +++ b/mastodon/src/main/res/menu/profile.xml @@ -1,11 +1,13 @@ - - - - - - - - + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/menu/settings_filter_words.xml b/mastodon/src/main/res/menu/selectable_list.xml similarity index 100% rename from mastodon/src/main/res/menu/settings_filter_words.xml rename to mastodon/src/main/res/menu/selectable_list.xml diff --git a/mastodon/src/main/res/menu/standalone_list_timeline.xml b/mastodon/src/main/res/menu/standalone_list_timeline.xml new file mode 100644 index 00000000..dcbf3cce --- /dev/null +++ b/mastodon/src/main/res/menu/standalone_list_timeline.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/values/ids.xml b/mastodon/src/main/res/values/ids.xml index dc2ec96d..777ddfe1 100644 --- a/mastodon/src/main/res/values/ids.xml +++ b/mastodon/src/main/res/values/ids.xml @@ -16,6 +16,7 @@ + @@ -23,6 +24,8 @@ + + diff --git a/mastodon/src/main/res/values/strings.xml b/mastodon/src/main/res/values/strings.xml index 9ae6db2c..7d5104ca 100644 --- a/mastodon/src/main/res/values/strings.xml +++ b/mastodon/src/main/res/values/strings.xml @@ -610,4 +610,39 @@ Error playing video + Following + Lists + Followed hashtags + You don\'t have any lists yet. + You don\'t follow any hashtags. + Manage lists + Manage hashtags + + Dropdown menu + Edit list + List members + Delete list + + Delete “%s”? + Hide members in Following + If someone is on this list, hide them in your Following timeline to avoid seeing their posts twice. + List name + Show replies to + No one + Members of the list + Anyone I follow + Remove members? + Remove + Add member + Search among people you follow + Add to list… + Add to list + + Manage the lists %s appears on + Remove from list + Remove member? + + %,d post recently + %,d posts recently + \ No newline at end of file diff --git a/mastodon/src/main/res/values/styles.xml b/mastodon/src/main/res/values/styles.xml index 9918183b..b48628e0 100644 --- a/mastodon/src/main/res/values/styles.xml +++ b/mastodon/src/main/res/values/styles.xml @@ -7,6 +7,8 @@ false @color/m3_sys_light_surface @style/Widget.Mastodon.EditText + @style/Widget.Mastodon.Spinner + @style/action_mode_close @style/Widget.Mastodon.M3.Button.Filled @style/Theme.Mastodon.Toolbar @@ -73,6 +75,8 @@ false @color/m3_sys_dark_surface @style/Widget.Mastodon.EditText + @style/Widget.Mastodon.Spinner + @style/action_mode_close @style/Widget.Mastodon.M3.Button @style/Theme.Mastodon.Toolbar @@ -165,7 +169,12 @@ @style/action_mode_title + + @@ -192,6 +201,12 @@ @style/m3_body_large + +