Settings M3 redesign wip

This commit is contained in:
Grishka
2023-06-04 02:04:55 +03:00
parent 7c6ec2e3d7
commit 31c8665653
139 changed files with 4520 additions and 1145 deletions

View File

@@ -111,21 +111,12 @@ public class AccountSwitcherSheet extends BottomSheet{
}
private void logOut(String accountID){
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
new RevokeOauthToken(session.app.clientId, session.app.clientSecret, session.token.accessToken)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Object result){
onLoggedOut(accountID);
}
@Override
public void onError(ErrorResponse error){
onLoggedOut(accountID);
}
})
.wrapProgress(activity, R.string.loading, false)
.exec(accountID);
AccountSessionManager.get(accountID).logOut(activity, ()->{
dismiss();
activity.finish();
Intent intent=new Intent(activity, MainActivity.class);
activity.startActivity(intent);
});
}
private void logOutAll(){
@@ -163,11 +154,6 @@ public class AccountSwitcherSheet extends BottomSheet{
}
}
private void onLoggedOut(String accountID){
AccountSessionManager.getInstance().removeAccount(accountID);
dismiss();
}
@Override
protected void onWindowInsetsUpdated(WindowInsets insets){
if(Build.VERSION.SDK_INT>=29){

View File

@@ -2,12 +2,21 @@ package org.joinmastodon.android.ui;
import android.app.AlertDialog;
import android.content.Context;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import org.joinmastodon.android.R;
import androidx.annotation.StringRes;
import me.grishka.appkit.utils.V;
public class M3AlertDialogBuilder extends AlertDialog.Builder{
private CharSequence supportingText, title, helpText;
private AlertDialog alert;
public M3AlertDialogBuilder(Context context){
super(context);
}
@@ -18,12 +27,36 @@ public class M3AlertDialogBuilder extends AlertDialog.Builder{
@Override
public AlertDialog create(){
AlertDialog alert=super.create();
if(!TextUtils.isEmpty(helpText) && !TextUtils.isEmpty(supportingText))
throw new IllegalStateException("You can't have both help text and supporting text in the same alert");
if(!TextUtils.isEmpty(supportingText)){
View titleLayout=getContext().getSystemService(LayoutInflater.class).inflate(R.layout.alert_title_with_supporting_text, null);
TextView title=titleLayout.findViewById(R.id.title);
TextView subtitle=titleLayout.findViewById(R.id.subtitle);
title.setText(this.title);
subtitle.setText(supportingText);
setCustomTitle(titleLayout);
}else if(!TextUtils.isEmpty(helpText)){
View titleLayout=getContext().getSystemService(LayoutInflater.class).inflate(R.layout.alert_title_with_help, null);
TextView title=titleLayout.findViewById(R.id.title);
TextView helpText=titleLayout.findViewById(R.id.help_text);
View helpButton=titleLayout.findViewById(R.id.help);
title.setText(this.title);
helpText.setText(this.helpText);
helpButton.setOnClickListener(v->{
helpText.setVisibility(helpText.getVisibility()==View.VISIBLE ? View.GONE : View.VISIBLE);
helpButton.setSelected(helpText.getVisibility()==View.VISIBLE);
});
setCustomTitle(titleLayout);
}
alert=super.create();
alert.create();
Button btn=alert.getButton(AlertDialog.BUTTON_POSITIVE);
if(btn!=null){
View buttonBar=(View) btn.getParent();
buttonBar.setPadding(V.dp(16), 0, V.dp(16), V.dp(24));
buttonBar.setPadding(V.dp(16), V.dp(16), V.dp(16), V.dp(16));
((View)buttonBar.getParent()).setPadding(0, 0, 0, 0);
}
// hacc
@@ -49,13 +82,40 @@ public class M3AlertDialogBuilder extends AlertDialog.Builder{
scrollView.setPadding(0, 0, 0, 0);
}
}
int messageID=getContext().getResources().getIdentifier("message", "id", "android");
if(messageID!=0){
View message=alert.findViewById(messageID);
if(message!=null){
message.setPadding(message.getPaddingLeft(), message.getPaddingTop(), message.getPaddingRight(), V.dp(24));
}
}
return alert;
}
public M3AlertDialogBuilder setSupportingText(CharSequence text){
supportingText=text;
return this;
}
public M3AlertDialogBuilder setSupportingText(@StringRes int text){
supportingText=getContext().getString(text);
return this;
}
@Override
public M3AlertDialogBuilder setTitle(CharSequence title){
super.setTitle(title);
this.title=title;
return this;
}
@Override
public M3AlertDialogBuilder setTitle(@StringRes int title){
super.setTitle(title);
this.title=getContext().getString(title);
return this;
}
public M3AlertDialogBuilder setHelpText(CharSequence text){
helpText=text;
return this;
}
public M3AlertDialogBuilder setHelpText(@StringRes int text){
helpText=getContext().getString(text);
return this;
}
}

View File

@@ -10,6 +10,7 @@ import me.grishka.appkit.utils.V;
public class OutlineProviders{
private static final SparseArray<ViewOutlineProvider> roundedRects=new SparseArray<>();
private static final SparseArray<ViewOutlineProvider> topRoundedRects=new SparseArray<>();
private static final SparseArray<ViewOutlineProvider> bottomRoundedRects=new SparseArray<>();
private static final SparseArray<ViewOutlineProvider> endRoundedRects=new SparseArray<>();
public static final int RADIUS_XSMALL=4;
@@ -54,6 +55,15 @@ public class OutlineProviders{
return provider;
}
public static ViewOutlineProvider bottomRoundedRect(int dp){
ViewOutlineProvider provider=bottomRoundedRects.get(dp);
if(provider!=null)
return provider;
provider=new BottomRoundRectOutlineProvider(V.dp(dp));
bottomRoundedRects.put(dp, provider);
return provider;
}
public static ViewOutlineProvider endRoundedRect(int dp){
ViewOutlineProvider provider=endRoundedRects.get(dp);
if(provider!=null)
@@ -89,6 +99,19 @@ public class OutlineProviders{
}
}
private static class BottomRoundRectOutlineProvider extends ViewOutlineProvider{
private final int radius;
private BottomRoundRectOutlineProvider(int radius){
this.radius=radius;
}
@Override
public void getOutline(View view, Outline outline){
outline.setRoundRect(0, -radius, view.getWidth(), view.getHeight(), radius);
}
}
private static class EndRoundRectOutlineProvider extends ViewOutlineProvider{
private final int radius;

View File

@@ -0,0 +1,54 @@
package org.joinmastodon.android.ui.adapters;
import android.view.ViewGroup;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.viewholders.CheckboxOrRadioListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.ListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.SimpleListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.SwitchListItemViewHolder;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
public class GenericListItemsAdapter<T> extends RecyclerView.Adapter<ListItemViewHolder<?>>{
private List<ListItem<T>> items;
public GenericListItemsAdapter(List<ListItem<T>> items){
this.items=items;
}
@NonNull
@Override
public ListItemViewHolder<?> onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
if(viewType==R.id.list_item_simple || viewType==R.id.list_item_simple_tinted)
return new SimpleListItemViewHolder(parent.getContext(), parent);
if(viewType==R.id.list_item_switch)
return new SwitchListItemViewHolder(parent.getContext(), parent);
if(viewType==R.id.list_item_checkbox)
return new CheckboxOrRadioListItemViewHolder(parent.getContext(), parent, false);
if(viewType==R.id.list_item_radio)
return new CheckboxOrRadioListItemViewHolder(parent.getContext(), parent, true);
throw new IllegalArgumentException("Unexpected view type "+viewType);
}
@SuppressWarnings("unchecked")
@Override
public void onBindViewHolder(@NonNull ListItemViewHolder<?> holder, int position){
((ListItemViewHolder<ListItem<T>>)holder).bind(items.get(position));
}
@Override
public int getItemCount(){
return items.size();
}
@Override
public int getItemViewType(int position){
return items.get(position).getItemViewType();
}
}

View File

@@ -0,0 +1,36 @@
package org.joinmastodon.android.ui.adapters;
import android.view.ViewGroup;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.ui.viewholders.InstanceRuleViewHolder;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
public class InstanceRulesAdapter extends RecyclerView.Adapter<InstanceRuleViewHolder>{
private final List<Instance.Rule> rules;
public InstanceRulesAdapter(List<Instance.Rule> rules){
this.rules=rules;
}
@NonNull
@Override
public InstanceRuleViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new InstanceRuleViewHolder(parent);
}
@Override
public void onBindViewHolder(@NonNull InstanceRuleViewHolder holder, int position){
holder.setPosition(position);
holder.bind(rules.get(position));
}
@Override
public int getItemCount(){
return rules.size();
}
}

View File

@@ -9,6 +9,7 @@ import android.widget.ImageView;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.ui.OutlineProviders;
@@ -29,7 +30,10 @@ public class AccountStatusDisplayItem extends StatusDisplayItem{
public AccountStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Account account){
super(parentID, parentFragment);
this.account=account;
parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis);
if(AccountSessionManager.get(parentFragment.getAccountID()).getLocalPreferences().customEmojiInNames)
parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis);
else
parsedName=account.displayName;
emojiHelper.setText(parsedName);
if(!TextUtils.isEmpty(account.avatar))
avaRequest=new UrlImageLoaderRequest(account.avatar, V.dp(50), V.dp(50));

View File

@@ -103,7 +103,7 @@ public class AudioStatusDisplayItem extends StatusDisplayItem{
@Override
public void onBind(AudioStatusDisplayItem item){
int seconds=(int)item.attachment.getDuration();
String duration=UiUtils.formatDuration(seconds);
String duration=UiUtils.formatMediaDuration(seconds);
AudioPlayerService service=AudioPlayerService.getInstance();
if(service!=null && service.getAttachmentID().equals(item.attachment.id)){
forwardBtn.setVisibility(View.VISIBLE);
@@ -168,7 +168,7 @@ public class AudioStatusDisplayItem extends StatusDisplayItem{
setPlayButtonPlaying(false, true);
forwardBtn.setVisibility(View.INVISIBLE);
rewindBtn.setVisibility(View.INVISIBLE);
time.setText(UiUtils.formatDuration((int)item.attachment.getDuration()));
time.setText(UiUtils.formatMediaDuration((int)item.attachment.getDuration()));
}
}
@@ -187,7 +187,7 @@ public class AudioStatusDisplayItem extends StatusDisplayItem{
int posSeconds=(int)pos;
if(posSeconds!=lastPosSeconds){
lastPosSeconds=posSeconds;
time.setText(UiUtils.formatDuration(posSeconds)+"/"+UiUtils.formatDuration((int)item.attachment.getDuration()));
time.setText(UiUtils.formatMediaDuration(posSeconds)+"/"+UiUtils.formatMediaDuration((int)item.attachment.getDuration()));
}
}

View File

@@ -68,7 +68,7 @@ public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{
reblogs.setText(itemView.getResources().getQuantityString(R.plurals.x_reblogs, (int)item.status.reblogsCount, item.status.reblogsCount));
if(s.editedAt!=null){
editHistory.setVisibility(View.VISIBLE);
editHistory.setText(item.parentFragment.getString(R.string.last_edit_at_x, UiUtils.formatRelativeTimestampAsMinutesAgo(itemView.getContext(), s.editedAt)));
editHistory.setText(item.parentFragment.getString(R.string.last_edit_at_x, UiUtils.formatRelativeTimestampAsMinutesAgo(itemView.getContext(), s.editedAt, false)));
}else{
editHistory.setVisibility(View.GONE);
}

View File

@@ -6,13 +6,16 @@ import android.content.res.ColorStateList;
import android.graphics.Color;
import android.os.Build;
import android.os.Bundle;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.PopupMenu;
import android.widget.TextView;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
@@ -133,6 +136,20 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
}
private void onBoostClick(View v){
if(GlobalUserPreferences.confirmBoost){
PopupMenu menu=new PopupMenu(itemView.getContext(), boost);
menu.getMenu().add(R.string.button_reblog);
menu.setOnMenuItemClickListener(item->{
doBoost();
return true;
});
menu.show();
}else{
doBoost();
}
}
private void doBoost(){
AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setReblogged(item.status, !item.status.reblogged);
boost.setSelected(item.status.reblogged);
bindButton(boost, item.status.reblogsCount);

View File

@@ -71,7 +71,8 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
this.accountID=accountID;
parsedName=new SpannableStringBuilder(user.displayName);
this.status=status;
HtmlParser.parseCustomEmoji(parsedName, user.emojis);
if(AccountSessionManager.get(accountID).getLocalPreferences().customEmojiInNames)
HtmlParser.parseCustomEmoji(parsedName, user.emojis);
emojiHelper.setText(parsedName);
if(status!=null){
hasVisibilityToggle=status.sensitive || !TextUtils.isEmpty(status.spoilerText);

View File

@@ -6,7 +6,9 @@ import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.ThreadFragment;
import org.joinmastodon.android.model.Account;
@@ -90,6 +92,7 @@ public abstract class StatusDisplayItem{
ArrayList<StatusDisplayItem> items=new ArrayList<>();
Status statusForContent=status.getContentStatus();
HeaderStatusDisplayItem header=null;
boolean hideCounts=!AccountSessionManager.get(accountID).getLocalPreferences().showInteractionCounts;
if((flags & FLAG_NO_HEADER)==0){
if(status.reblog!=null){
items.add(new ReblogOrReplyLineStatusDisplayItem(parentID, fragment, fragment.getString(R.string.user_boosted, status.account.displayName), status.account.emojis, R.drawable.ic_repeat_20px));
@@ -104,7 +107,7 @@ public abstract class StatusDisplayItem{
}
ArrayList<StatusDisplayItem> contentItems;
if(!TextUtils.isEmpty(statusForContent.spoilerText)){
if(!TextUtils.isEmpty(statusForContent.spoilerText) && AccountSessionManager.get(accountID).getLocalPreferences().showCWs){
SpoilerStatusDisplayItem spoilerItem=new SpoilerStatusDisplayItem(parentID, fragment, statusForContent);
items.add(spoilerItem);
contentItems=spoilerItem.contentItems;
@@ -126,6 +129,8 @@ public abstract class StatusDisplayItem{
MediaGridStatusDisplayItem mediaGrid=new MediaGridStatusDisplayItem(parentID, fragment, layout, imageAttachments, statusForContent);
if((flags & FLAG_MEDIA_FORCE_HIDDEN)!=0)
mediaGrid.sensitiveTitle=fragment.getString(R.string.media_hidden);
else if(statusForContent.sensitive && !AccountSessionManager.get(accountID).getLocalPreferences().hideSensitiveMedia)
mediaGrid.sensitiveRevealed=true;
contentItems.add(mediaGrid);
}
for(Attachment att:statusForContent.mediaAttachments){
@@ -140,7 +145,9 @@ public abstract class StatusDisplayItem{
contentItems.add(new LinkCardStatusDisplayItem(parentID, fragment, statusForContent));
}
if((flags & FLAG_NO_FOOTER)==0){
items.add(new FooterStatusDisplayItem(parentID, fragment, statusForContent, accountID));
FooterStatusDisplayItem footer=new FooterStatusDisplayItem(parentID, fragment, statusForContent, accountID);
footer.hideCounts=hideCounts;
items.add(footer);
if(status.hasGapAfter && !(fragment instanceof ThreadFragment))
items.add(new GapStatusDisplayItem(parentID, fragment));
}

View File

@@ -60,7 +60,7 @@ public class MediaAttachmentViewController{
altButton.setVisibility(TextUtils.isEmpty(attachment.description) ? View.GONE : View.VISIBLE);
}
if(type==MediaGridStatusDisplayItem.GridItemType.VIDEO){
duration.setText(UiUtils.formatDuration((int)attachment.getDuration()));
duration.setText(UiUtils.formatMediaDuration((int)attachment.getDuration()));
}
didClear=false;
}

View File

@@ -71,6 +71,7 @@ import org.parceler.Parcels;
import java.io.File;
import java.lang.reflect.Method;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
@@ -99,6 +100,7 @@ import okhttp3.MediaType;
public class UiUtils{
private static Handler mainHandler=new Handler(Looper.getMainLooper());
private static final DateTimeFormatter DATE_FORMATTER_SHORT_WITH_YEAR=DateTimeFormatter.ofPattern("d MMM uuuu"), DATE_FORMATTER_SHORT=DateTimeFormatter.ofPattern("d MMM");
private static final DateTimeFormatter TIME_FORMATTER=DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT);
public static final DateTimeFormatter DATE_TIME_FORMATTER=DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT);
private UiUtils(){}
@@ -144,21 +146,52 @@ public class UiUtils{
}
}
public static String formatRelativeTimestampAsMinutesAgo(Context context, Instant instant){
public static String formatRelativeTimestampAsMinutesAgo(Context context, Instant instant, boolean relativeHours){
long t=instant.toEpochMilli();
long now=System.currentTimeMillis();
long diff=now-t;
if(diff<1000L){
long diff=System.currentTimeMillis()-t;
if(diff<1000L && diff>-1000L){
return context.getString(R.string.time_just_now);
}else if(diff<60_000L){
int secs=(int)(diff/1000L);
return context.getResources().getQuantityString(R.plurals.x_seconds_ago, secs, secs);
}else if(diff<3600_000L){
int mins=(int)(diff/60_000L);
return context.getResources().getQuantityString(R.plurals.x_minutes_ago, mins, mins);
}else if(diff>0){
if(diff<60_000L){
int secs=(int)(diff/1000L);
return context.getResources().getQuantityString(R.plurals.x_seconds_ago, secs, secs);
}else if(diff<3600_000L){
int mins=(int)(diff/60_000L);
return context.getResources().getQuantityString(R.plurals.x_minutes_ago, mins, mins);
}else if(relativeHours && diff<24*3600_000L){
int hours=(int)(diff/3600_000L);
return context.getResources().getQuantityString(R.plurals.x_hours_ago, hours, hours);
}
}else{
return DATE_TIME_FORMATTER.format(instant.atZone(ZoneId.systemDefault()));
if(diff>-60_000L){
int secs=-(int)(diff/1000L);
return context.getResources().getQuantityString(R.plurals.in_x_seconds, secs, secs);
}else if(diff>-3600_000L){
int mins=-(int)(diff/60_000L);
return context.getResources().getQuantityString(R.plurals.in_x_minutes, mins, mins);
}else if(relativeHours && diff>-24*3600_000L){
int hours=-(int)(diff/3600_000L);
return context.getResources().getQuantityString(R.plurals.in_x_hours, hours, hours);
}
}
ZonedDateTime dt=instant.atZone(ZoneId.systemDefault());
ZonedDateTime now=ZonedDateTime.now();
String formattedTime=TIME_FORMATTER.format(dt);
String formattedDate;
LocalDate today=now.toLocalDate();
LocalDate date=dt.toLocalDate();
if(date.equals(today)){
formattedDate=context.getString(R.string.today);
}else if(date.equals(today.minusDays(1))){
formattedDate=context.getString(R.string.yesterday);
}else if(date.equals(today.plusDays(1))){
formattedDate=context.getString(R.string.tomorrow);
}else if(date.getYear()==today.getYear()){
formattedDate=DATE_FORMATTER_SHORT.format(dt);
}else{
formattedDate=DATE_FORMATTER_SHORT_WITH_YEAR.format(dt);
}
return context.getString(R.string.date_at_time, formattedDate, formattedTime);
}
public static String formatTimeLeft(Context context, Instant instant){
@@ -317,7 +350,7 @@ public class UiUtils{
}
public static void showConfirmationAlert(Context context, @StringRes int title, @StringRes int message, @StringRes int confirmButton, Runnable onConfirmed){
showConfirmationAlert(context, context.getString(title), context.getString(message), context.getString(confirmButton), onConfirmed);
showConfirmationAlert(context, context.getString(title), message==0 ? null : context.getString(message), context.getString(confirmButton), onConfirmed);
}
public static void showConfirmationAlert(Context context, CharSequence title, CharSequence message, CharSequence confirmButton, Runnable onConfirmed){
@@ -399,24 +432,26 @@ public class UiUtils{
}
public static void confirmDeletePost(Activity activity, String accountID, Status status, Consumer<Status> resultCallback){
showConfirmationAlert(activity, R.string.confirm_delete_title, R.string.confirm_delete, R.string.delete, ()->{
new DeleteStatus(status.id)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Status result){
resultCallback.accept(result);
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().deleteStatus(status.id);
E.post(new StatusDeletedEvent(status.id, accountID));
}
Runnable delete=()->new DeleteStatus(status.id)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Status result){
resultCallback.accept(result);
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().deleteStatus(status.id);
E.post(new StatusDeletedEvent(status.id, accountID));
}
@Override
public void onError(ErrorResponse error){
error.showToast(activity);
}
})
.wrapProgress(activity, R.string.deleting, false)
.exec(accountID);
});
@Override
public void onError(ErrorResponse error){
error.showToast(activity);
}
})
.wrapProgress(activity, R.string.deleting, false)
.exec(accountID);
if(GlobalUserPreferences.confirmDeletePost)
showConfirmationAlert(activity, R.string.confirm_delete_title, R.string.confirm_delete, R.string.delete, delete);
else
delete.run();
}
public static void setRelationshipToActionButton(Relationship relationship, Button button){
@@ -488,25 +523,32 @@ public class UiUtils{
}else if(relationship.muting){
confirmToggleMuteUser(activity, accountID, account, true, resultCallback);
}else{
progressCallback.accept(true);
new SetAccountFollowed(account.id, !relationship.following && !relationship.requested, true)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Relationship result){
resultCallback.accept(result);
progressCallback.accept(false);
if(!result.following && !result.requested){
E.post(new RemoveAccountPostsEvent(accountID, account.id, true));
Runnable action=()->{
progressCallback.accept(true);
new SetAccountFollowed(account.id, !relationship.following && !relationship.requested, true)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Relationship result){
resultCallback.accept(result);
progressCallback.accept(false);
if(!result.following && !result.requested){
E.post(new RemoveAccountPostsEvent(accountID, account.id, true));
}
}
}
@Override
public void onError(ErrorResponse error){
error.showToast(activity);
progressCallback.accept(false);
}
})
.exec(accountID);
@Override
public void onError(ErrorResponse error){
error.showToast(activity);
progressCallback.accept(false);
}
})
.exec(accountID);
};
if(relationship.following && GlobalUserPreferences.confirmUnfollow){
showConfirmationAlert(activity, null, activity.getString(R.string.unfollow_confirmation, account.getDisplayUsername()), activity.getString(R.string.unfollow), action);
}else{
action.run();
}
}
}
@@ -586,9 +628,9 @@ public class UiUtils{
public static void setUserPreferredTheme(Context context){
context.setTheme(switch(GlobalUserPreferences.theme){
case AUTO -> GlobalUserPreferences.trueBlackTheme ? R.style.Theme_Mastodon_AutoLightDark_TrueBlack : R.style.Theme_Mastodon_AutoLightDark;
case AUTO -> R.style.Theme_Mastodon_AutoLightDark;
case LIGHT -> R.style.Theme_Mastodon_Light;
case DARK -> GlobalUserPreferences.trueBlackTheme ? R.style.Theme_Mastodon_Dark_TrueBlack : R.style.Theme_Mastodon_Dark;
case DARK -> R.style.Theme_Mastodon_Dark;
});
}
@@ -718,7 +760,7 @@ public class UiUtils{
}
@SuppressLint("DefaultLocale")
public static String formatDuration(int seconds){
public static String formatMediaDuration(int seconds){
if(seconds>=3600)
return String.format("%d:%02d:%02d", seconds/3600, seconds%3600/60, seconds%60);
else
@@ -750,4 +792,20 @@ public class UiUtils{
}
return insets;
}
public static String formatDuration(Context context, int seconds){
if(seconds<3600){
int minutes=seconds/60;
return context.getResources().getQuantityString(R.plurals.x_minutes, minutes, minutes);
}else if(seconds<24*3600){
int hours=seconds/3600;
return context.getResources().getQuantityString(R.plurals.x_hours, hours, hours);
}else if(seconds>=7*24*3600 && seconds%(7*24*3600)<24*3600){
int weeks=seconds/(7*24*3600);
return context.getResources().getQuantityString(R.plurals.x_weeks, weeks, weeks);
}else{
int days=seconds/(24*3600);
return context.getResources().getQuantityString(R.plurals.x_days, days, days);
}
}
}

View File

@@ -19,6 +19,7 @@ import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.SearchResults;
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.text.HtmlParser;
@@ -58,7 +59,7 @@ public class ComposeAutocompleteViewController{
private FrameLayout contentView;
private UsableRecyclerView list;
private ListImageLoaderWrapper imgLoader;
private List<WrappedAccount> users=Collections.emptyList();
private List<AccountViewModel> users=Collections.emptyList();
private List<Hashtag> hashtags=Collections.emptyList();
private List<WrappedEmoji> emojis=Collections.emptyList();
private Mode mode;
@@ -226,8 +227,8 @@ public class ComposeAutocompleteViewController{
@Override
public void onSuccess(SearchResults result){
currentRequest=null;
List<WrappedAccount> oldList=users;
users=result.accounts.stream().map(WrappedAccount::new).collect(Collectors.toList());
List<AccountViewModel> oldList=users;
users=result.accounts.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList());
if(isLoading){
isLoading=false;
if(users.size()>=LOADING_FAKE_USER_COUNT){
@@ -313,7 +314,7 @@ public class ComposeAutocompleteViewController{
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
WrappedAccount a=users.get(position);
AccountViewModel a=users.get(position);
if(image==0)
return a.avaRequest;
return a.emojiHelper.getImageRequest(image-1);
@@ -325,7 +326,7 @@ public class ComposeAutocompleteViewController{
}
}
private class UserViewHolder extends BindableViewHolder<WrappedAccount> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{
private class UserViewHolder extends BindableViewHolder<AccountViewModel> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{
protected final ImageView ava;
protected final TextView username;
@@ -338,7 +339,7 @@ public class ComposeAutocompleteViewController{
}
@Override
public void onBind(WrappedAccount item){
public void onBind(AccountViewModel item){
username.setText("@"+item.account.acct);
}
@@ -483,21 +484,6 @@ public class ComposeAutocompleteViewController{
}
}
private static class WrappedAccount{
private Account account;
private CharSequence parsedName;
private CustomEmojiHelper emojiHelper;
private ImageLoaderRequest avaRequest;
public WrappedAccount(Account account){
this.account=account;
parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis);
emojiHelper=new CustomEmojiHelper();
emojiHelper.setText(parsedName);
avaRequest=new UrlImageLoaderRequest(account.avatar, V.dp(50), V.dp(50));
}
}
private static class WrappedEmoji{
private Emoji emoji;
private ImageLoaderRequest request;

View File

@@ -89,10 +89,32 @@ public class ComposeLanguageAlertViewController{
}
if(previouslySelected!=null){
if((previouslySelected.index<specialLocales.size() && Objects.equals(previouslySelected.locale, specialLocales.get(previouslySelected.index).locale)) ||
(previouslySelected.index<specialLocales.size()+allLocales.size() && Objects.equals(previouslySelected.locale, allLocales.get(previouslySelected.index-specialLocales.size()).locale))){
if(previouslySelected.index!=-1 && ((previouslySelected.index<specialLocales.size() && Objects.equals(previouslySelected.locale, specialLocales.get(previouslySelected.index).locale)) ||
(previouslySelected.index<specialLocales.size()+allLocales.size() && Objects.equals(previouslySelected.locale, allLocales.get(previouslySelected.index-specialLocales.size()).locale)))){
selectedIndex=previouslySelected.index;
selectedLocale=previouslySelected.locale;
}else{
int i=0;
boolean found=false;
for(SpecialLocaleInfo li:specialLocales){
if(li.locale.equals(previouslySelected.locale)){
selectedLocale=li.locale;
selectedIndex=i;
found=true;
break;
}
i++;
}
if(!found){
for(LocaleInfo li:allLocales){
if(li.locale.equals(previouslySelected.locale)){
selectedLocale=li.locale;
selectedIndex=i;
break;
}
i++;
}
}
}
}else{
selectedLocale=specialLocales.get(0).locale;

View File

@@ -543,6 +543,23 @@ public class ComposeMediaViewController{
return attachments.size()<MAX_ATTACHMENTS;
}
public int getMissingAltTextAttachmentCount(){
int count=0;
for(DraftMediaAttachment att:attachments){
if(TextUtils.isEmpty(att.description))
count++;
}
return count;
}
public boolean areAllAttachmentsImages(){
for(DraftMediaAttachment att:attachments){
if(!att.mimeType.startsWith("image/"))
return false;
}
return true;
}
public int getMaxAttachments(){
return MAX_ATTACHMENTS;
}

View File

@@ -108,7 +108,7 @@ public class ComposePollViewController{
updatePollOptionHints();
pollDuration=savedInstanceState.getInt("pollDuration");
pollIsMultipleChoice=savedInstanceState.getBoolean("pollMultiple");
pollDurationValue.setText(formatPollDuration(pollDuration));
pollDurationValue.setText(UiUtils.formatDuration(fragment.getContext(), pollDuration));
pollStyleValue.setText(pollIsMultipleChoice ? R.string.compose_poll_multiple_choice : R.string.compose_poll_single_choice);
}else if(savedInstanceState!=null && !pollOptions.isEmpty()){ // Fragment was recreated but instance was retained
pollWrap.setVisibility(View.VISIBLE);
@@ -119,7 +119,7 @@ public class ComposePollViewController{
opt.edit.setText(oldOpt.edit.getText());
}
updatePollOptionHints();
pollDurationValue.setText(formatPollDuration(pollDuration));
pollDurationValue.setText(UiUtils.formatDuration(fragment.getContext(), pollDuration));
pollStyleValue.setText(pollIsMultipleChoice ? R.string.compose_poll_multiple_choice : R.string.compose_poll_single_choice);
}else if(savedInstanceState==null && fragment.editingStatus!=null && fragment.editingStatus.poll!=null){
pollWrap.setVisibility(View.VISIBLE);
@@ -129,11 +129,11 @@ public class ComposePollViewController{
}
pollDuration=(int)fragment.editingStatus.poll.expiresAt.minus(fragment.editingStatus.createdAt.toEpochMilli(), ChronoUnit.MILLIS).getEpochSecond();
updatePollOptionHints();
pollDurationValue.setText(formatPollDuration(pollDuration));
pollDurationValue.setText(UiUtils.formatDuration(fragment.getContext(), pollDuration));
pollIsMultipleChoice=fragment.editingStatus.poll.multiple;
pollStyleValue.setText(pollIsMultipleChoice ? R.string.compose_poll_multiple_choice : R.string.compose_poll_single_choice);
}else{
pollDurationValue.setText(formatPollDuration(24*3600));
pollDurationValue.setText(UiUtils.formatDuration(fragment.getContext(), 24*3600));
pollStyleValue.setText(R.string.compose_poll_single_choice);
}
}
@@ -186,7 +186,7 @@ public class ComposePollViewController{
int selectedOption=-1;
for(int i=0;i<POLL_LENGTH_OPTIONS.length;i++){
int l=POLL_LENGTH_OPTIONS[i];
options[i]=formatPollDuration(l);
options[i]=UiUtils.formatDuration(fragment.getContext(), l);
if(l==pollDuration)
selectedOption=i;
}
@@ -196,25 +196,12 @@ public class ComposePollViewController{
.setTitle(R.string.poll_length)
.setPositiveButton(R.string.ok, (dialog, which)->{
pollDuration=POLL_LENGTH_OPTIONS[chosenOption[0]];
pollDurationValue.setText(formatPollDuration(pollDuration));
pollDurationValue.setText(UiUtils.formatDuration(fragment.getContext(), pollDuration));
})
.setNegativeButton(R.string.cancel, null)
.show();
}
private String formatPollDuration(int seconds){
if(seconds<3600){
int minutes=seconds/60;
return fragment.getResources().getQuantityString(R.plurals.x_minutes, minutes, minutes);
}else if(seconds<24*3600){
int hours=seconds/3600;
return fragment.getResources().getQuantityString(R.plurals.x_hours, hours, hours);
}else{
int days=seconds/(24*3600);
return fragment.getResources().getQuantityString(R.plurals.x_days, days, days);
}
}
private void showPollStyleAlert(){
final int[] option={pollIsMultipleChoice ? R.id.multiple_choice : R.id.single_choice};
AlertDialog alert=new M3AlertDialogBuilder(fragment.getActivity())

View File

@@ -110,6 +110,8 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
}
public void bindRelationship(){
if(relationships==null)
return;
Relationship rel=relationships.get(item.account.id);
if(rel==null || AccountSessionManager.getInstance().isSelf(accountID, item.account)){
button.setVisibility(View.GONE);
@@ -193,6 +195,8 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
}
private void onButtonClick(View v){
if(relationships==null)
return;
ProgressDialog progress=new ProgressDialog(fragment.getActivity());
progress.setMessage(fragment.getString(R.string.loading));
progress.setCancelable(false);

View File

@@ -0,0 +1,23 @@
package org.joinmastodon.android.ui.viewholders;
import android.content.Context;
import android.view.ViewGroup;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.ui.views.CheckableLinearLayout;
public abstract class CheckableListItemViewHolder extends ListItemViewHolder<CheckableListItem<?>>{
protected final CheckableLinearLayout checkableLayout;
public CheckableListItemViewHolder(Context context, ViewGroup parent){
super(context, R.layout.item_generic_list_checkable, parent);
checkableLayout=(CheckableLinearLayout) itemView;
}
@Override
public void onBind(CheckableListItem<?> item){
super.onBind(item);
checkableLayout.setChecked(item.checked);
}
}

View File

@@ -0,0 +1,25 @@
package org.joinmastodon.android.ui.viewholders;
import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.CompoundButton;
import android.widget.LinearLayout;
import android.widget.RadioButton;
import me.grishka.appkit.utils.V;
public class CheckboxOrRadioListItemViewHolder extends CheckableListItemViewHolder{
public CheckboxOrRadioListItemViewHolder(Context context, ViewGroup parent, boolean radio){
super(context, parent);
View iconView=new View(context);
iconView.setDuplicateParentStateEnabled(true);
CompoundButton terribleHack=radio ? new RadioButton(context) : new CheckBox(context);
iconView.setBackground(terribleHack.getButtonDrawable());
LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(V.dp(32), V.dp(32));
lp.setMarginStart(V.dp(12));
lp.setMarginEnd(V.dp(4));
checkableLayout.addView(iconView, lp);
}
}

View File

@@ -0,0 +1,36 @@
package org.joinmastodon.android.ui.viewholders;
import android.annotation.SuppressLint;
import android.view.ViewGroup;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.ui.text.HtmlParser;
import me.grishka.appkit.utils.BindableViewHolder;
public class InstanceRuleViewHolder extends BindableViewHolder<Instance.Rule>{
private final TextView text, number;
private int position;
public InstanceRuleViewHolder(ViewGroup parent){
super(parent.getContext(), R.layout.item_server_rule, parent);
text=findViewById(R.id.text);
number=findViewById(R.id.number);
}
public void setPosition(int position){
this.position=position;
}
@SuppressLint("DefaultLocale")
@Override
public void onBind(Instance.Rule item){
if(item.parsedText==null){
item.parsedText=HtmlParser.parseLinks(item.text);
}
text.setText(item.parsedText);
number.setText(String.format("%d", position+1));
}
}

View File

@@ -0,0 +1,80 @@
package org.joinmastodon.android.ui.viewholders;
import android.content.Context;
import android.content.res.ColorStateList;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.utils.UiUtils;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public abstract class ListItemViewHolder<T extends ListItem<?>> extends BindableViewHolder<T> implements UsableRecyclerView.DisableableClickable{
protected final TextView title;
protected final TextView subtitle;
protected final ImageView icon;
protected final LinearLayout view;
public ListItemViewHolder(Context context, int layout, ViewGroup parent){
super(context, layout, parent);
title=findViewById(R.id.title);
subtitle=findViewById(R.id.subtitle);
icon=findViewById(R.id.icon);
view=(LinearLayout) itemView;
}
@Override
public void onBind(T item){
if(TextUtils.isEmpty(item.title))
title.setText(item.titleRes);
else
title.setText(item.title);
if(TextUtils.isEmpty(item.subtitle) && item.subtitleRes==0){
subtitle.setVisibility(View.GONE);
title.setMaxLines(2);
view.setMinimumHeight(V.dp(56));
}else{
subtitle.setVisibility(View.VISIBLE);
title.setMaxLines(1);
view.setMinimumHeight(V.dp(72));
if(TextUtils.isEmpty(item.subtitle))
subtitle.setText(item.subtitleRes);
else
subtitle.setText(item.subtitle);
}
if(item.iconRes!=0){
icon.setVisibility(View.VISIBLE);
icon.setImageResource(item.iconRes);
}else{
icon.setVisibility(View.GONE);
}
if(item.colorOverrideAttr!=0){
int color=UiUtils.getThemeColor(view.getContext(), item.colorOverrideAttr);
title.setTextColor(color);
icon.setImageTintList(ColorStateList.valueOf(color));
}
view.setAlpha(item.isEnabled ? 1 : .4f);
}
@Override
public boolean isEnabled(){
return item.isEnabled;
}
@Override
public void onClick(){
item.onClick.run();
}
}

View File

@@ -0,0 +1,13 @@
package org.joinmastodon.android.ui.viewholders;
import android.content.Context;
import android.view.ViewGroup;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.viewmodel.ListItem;
public class SimpleListItemViewHolder extends ListItemViewHolder<ListItem<?>>{
public SimpleListItemViewHolder(Context context, ViewGroup parent){
super(context, R.layout.item_generic_list, parent);
}
}

View File

@@ -0,0 +1,43 @@
package org.joinmastodon.android.ui.viewholders;
import android.content.Context;
import android.view.Gravity;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.ui.views.M3Switch;
import me.grishka.appkit.utils.V;
public class SwitchListItemViewHolder extends CheckableListItemViewHolder{
private final M3Switch sw;
private boolean ignoreListener;
public SwitchListItemViewHolder(Context context, ViewGroup parent){
super(context, parent);
sw=new M3Switch(context);
LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(V.dp(52), V.dp(32));
lp.gravity=Gravity.TOP;
lp.setMarginStart(V.dp(16));
checkableLayout.addView(sw, lp);
sw.setOnCheckedChangeListener((buttonView, isChecked)->{
if(ignoreListener)
return;
if(item.checkedChangeListener!=null)
item.checkedChangeListener.accept(isChecked);
else
item.checked=isChecked;
});
sw.setClickable(true);
}
@Override
public void onBind(CheckableListItem<?> item){
super.onBind(item);
ignoreListener=true;
sw.setChecked(item.checked);
sw.setEnabled(item.isEnabled);
ignoreListener=false;
}
}

View File

@@ -98,7 +98,7 @@ public class FloatingHintEditTextLayout extends FrameLayout implements CustomVie
errorView=new LinkedTextView(getContext());
errorView.setTextAppearance(R.style.m3_body_small);
errorView.setTextColor(UiUtils.getThemeColor(getContext(), R.attr.colorM3OnSurfaceVariant));
errorView.setTextColor(UiUtils.getThemeColor(getContext(), R.attr.colorM3Error));
errorView.setLinkTextColor(UiUtils.getThemeColor(getContext(), R.attr.colorM3Primary));
errorView.setLayoutParams(new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
errorView.setPadding(dp(16), dp(4), dp(16), 0);
@@ -106,6 +106,10 @@ public class FloatingHintEditTextLayout extends FrameLayout implements CustomVie
addView(errorView);
}
public void updateHint(){
label.setText(edit.getHint());
}
private void onTextChanged(Editable text){
if(errorState){
errorView.setVisibility(View.GONE);

View File

@@ -1,8 +1,13 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.view.View;
import android.webkit.WebView;
import android.widget.ScrollView;
import org.joinmastodon.android.R;
import java.util.function.Supplier;
@@ -10,19 +15,22 @@ import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
public class NestedRecyclerScrollView extends CustomScrollView{
private Supplier<RecyclerView> scrollableChildSupplier;
private Supplier<View> scrollableChildSupplier;
private boolean takePriorityOverChildViews;
public NestedRecyclerScrollView(Context context){
super(context);
this(context, null);
}
public NestedRecyclerScrollView(Context context, AttributeSet attrs){
super(context, attrs);
this(context, attrs, 0);
}
public NestedRecyclerScrollView(Context context, AttributeSet attrs, int defStyleAttr){
super(context, attrs, defStyleAttr);
public NestedRecyclerScrollView(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
TypedArray ta=context.obtainStyledAttributes(attrs, R.styleable.NestedRecyclerScrollView);
takePriorityOverChildViews=ta.getBoolean(R.styleable.NestedRecyclerScrollView_takePriorityOverChildViews, false);
ta.recycle();
}
@Override
@@ -33,7 +41,7 @@ public class NestedRecyclerScrollView extends CustomScrollView{
consumed[1]=dy;
return;
}
}else if((dy<0 && target instanceof RecyclerView rv && isScrolledToTop(rv)) || (dy>0 && !isScrolledToBottom())){
}else if((dy<0 && isScrolledToTop(target)) || (dy>0 && !isScrolledToBottom())){
scrollBy(0, dy);
consumed[1]=dy;
return;
@@ -48,7 +56,7 @@ public class NestedRecyclerScrollView extends CustomScrollView{
fling((int)velY);
return true;
}
}else if((velY<0 && target instanceof RecyclerView rv && isScrolledToTop(rv)) || (velY>0 && !isScrolledToBottom())){
}else if((velY<0 && isScrolledToTop(target)) || (velY>0 && !isScrolledToBottom())){
fling((int) velY);
return true;
}
@@ -59,22 +67,40 @@ public class NestedRecyclerScrollView extends CustomScrollView{
return !canScrollVertically(1);
}
private boolean isScrolledToTop(RecyclerView rv){
final LinearLayoutManager lm=(LinearLayoutManager) rv.getLayoutManager();
return lm.findFirstVisibleItemPosition()==0
&& lm.findViewByPosition(0).getTop()==rv.getPaddingTop();
private boolean isScrolledToTop(View view){
if(view instanceof RecyclerView rv){
final LinearLayoutManager lm=(LinearLayoutManager) rv.getLayoutManager();
return lm.findFirstVisibleItemPosition()==0
&& lm.findViewByPosition(0).getTop()==rv.getPaddingTop();
}
return !view.canScrollVertically(-1);
}
public void setScrollableChildSupplier(Supplier<RecyclerView> scrollableChildSupplier){
public void setScrollableChildSupplier(Supplier<View> scrollableChildSupplier){
this.scrollableChildSupplier=scrollableChildSupplier;
}
@Override
protected boolean onScrollingHitEdge(float velocity){
if(velocity>0 || takePriorityOverChildViews){
RecyclerView view=scrollableChildSupplier.get();
if(view!=null){
return view.fling(0, (int) velocity);
View view=scrollableChildSupplier==null ? null : scrollableChildSupplier.get();
if(view instanceof RecyclerView rv){
return rv.fling(0, (int) velocity);
}else if(view instanceof ScrollView sv){
if(sv.canScrollVertically((int)velocity)){
sv.fling((int)velocity);
return true;
}
}else if(view instanceof CustomScrollView sv){
if(sv.canScrollVertically((int)velocity)){
sv.fling((int)velocity);
return true;
}
}else if(view instanceof WebView wv){
if(wv.canScrollVertically((int)velocity)){
wv.flingScroll(0, (int)velocity);
return true;
}
}
}
return false;