diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java index 9ad3532cc..8f607e629 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java @@ -1,6 +1,5 @@ package org.joinmastodon.android.fragments; -import android.content.res.Configuration; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; @@ -10,7 +9,8 @@ import android.view.WindowInsets; import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.R; -import org.joinmastodon.android.fragments.onboarding.InstanceCatalogFragment; +import org.joinmastodon.android.fragments.onboarding.InstanceCatalogSignupFragment; +import org.joinmastodon.android.fragments.onboarding.InstanceChooserLoginFragment; import org.joinmastodon.android.ui.InterpolatingMotionEffect; import org.joinmastodon.android.ui.views.SizeListenerFrameLayout; @@ -66,8 +66,9 @@ public class SplashFragment extends AppKitFragment{ private void onButtonClick(View v){ Bundle extras=new Bundle(); - extras.putBoolean("signup", v.getId()==R.id.btn_get_started); - Nav.go(getActivity(), InstanceCatalogFragment.class, extras); + boolean isSignup=v.getId()==R.id.btn_get_started; + extras.putBoolean("signup", isSignup); + Nav.go(getActivity(), isSignup ? InstanceCatalogSignupFragment.class : InstanceChooserLoginFragment.class, extras); } private void updateArtSize(int w, int h){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogFragment.java index d23b8339e..a188711b4 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogFragment.java @@ -2,46 +2,30 @@ package org.joinmastodon.android.fragments.onboarding; import android.app.Activity; import android.app.ProgressDialog; -import android.content.Context; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.LocaleList; -import android.text.Editable; import android.text.TextUtils; -import android.text.TextWatcher; -import android.util.Log; import android.view.KeyEvent; import android.view.View; import android.view.ViewGroup; import android.view.WindowInsets; -import android.view.inputmethod.InputMethodManager; import android.widget.Button; import android.widget.EditText; -import android.widget.ImageView; import android.widget.RadioButton; import android.widget.TextView; import org.joinmastodon.android.R; import org.joinmastodon.android.api.MastodonAPIController; -import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.api.MastodonErrorResponse; import org.joinmastodon.android.api.requests.instance.GetInstance; -import org.joinmastodon.android.api.requests.catalog.GetCatalogCategories; -import org.joinmastodon.android.api.requests.catalog.GetCatalogInstances; -import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Instance; -import org.joinmastodon.android.model.catalog.CatalogCategory; import org.joinmastodon.android.model.catalog.CatalogInstance; -import org.joinmastodon.android.ui.BetterItemAnimator; -import org.joinmastodon.android.ui.DividerItemDecoration; import org.joinmastodon.android.ui.M3AlertDialogBuilder; -import org.joinmastodon.android.ui.tabs.TabLayout; import org.joinmastodon.android.ui.utils.UiUtils; -import org.parceler.Parcels; import org.w3c.dom.Document; import org.w3c.dom.Element; -import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.xml.sax.InputSource; @@ -59,49 +43,42 @@ import java.util.stream.Collectors; import javax.xml.parsers.DocumentBuilderFactory; import androidx.annotation.NonNull; -import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.RecyclerView; -import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.utils.MergeRecyclerAdapter; -import me.grishka.appkit.utils.SingleViewRecyclerAdapter; import me.grishka.appkit.utils.V; import me.grishka.appkit.views.UsableRecyclerView; import okhttp3.Call; import okhttp3.Request; import okhttp3.Response; -public class InstanceCatalogFragment extends BaseRecyclerFragment{ - private InstancesAdapter adapter; - private MergeRecyclerAdapter mergeAdapter; - private View headerView; - private CatalogInstance chosenInstance; - private List filteredData=new ArrayList<>(); - private Button nextButton; - private MastodonAPIRequest getCategoriesRequest; - private EditText searchEdit; - private TabLayout categoriesList; - private Runnable searchDebouncer=this::onSearchChangedDebounced; - private String currentSearchQuery; - private String currentCategory="all"; - private List categories=new ArrayList<>(); - private String loadingInstanceDomain; - private GetInstance loadingInstanceRequest; - private Call loadingInstanceRedirectRequest; - private HashMap instancesCache=new HashMap<>(); - private ProgressDialog instanceProgressDialog; - private View buttonBar; - private HashMap redirects=new HashMap<>(), redirectsInverse=new HashMap<>(); - - private boolean isSignup; +abstract class InstanceCatalogFragment extends BaseRecyclerFragment{ + protected RecyclerView.Adapter adapter; + protected MergeRecyclerAdapter mergeAdapter; + protected CatalogInstance chosenInstance; + protected Button nextButton; + protected EditText searchEdit; + protected Runnable searchDebouncer=this::onSearchChangedDebounced; + protected String currentSearchQuery; + protected String loadingInstanceDomain; + protected HashMap instancesCache=new HashMap<>(); + protected View buttonBar; + protected List filteredData=new ArrayList<>(); + protected GetInstance loadingInstanceRequest; + protected Call loadingInstanceRedirectRequest; + protected ProgressDialog instanceProgressDialog; + protected HashMap redirects=new HashMap<>(); + protected HashMap redirectsInverse=new HashMap<>(); + protected boolean isSignup; + protected CatalogInstance fakeInstance=new CatalogInstance(); private static final double DUNBAR=Math.log(800); - public InstanceCatalogFragment(){ - super(R.layout.fragment_onboarding_common, 10); + public InstanceCatalogFragment(int layout, int perPage){ + super(layout, perPage); } @Override @@ -110,266 +87,9 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment(){ - @Override - public void onSuccess(List result){ - if(getActivity()==null) - return; - Map> byLang=result.stream().collect(Collectors.groupingBy(ci->ci.language)); - for(List group:byLang.values()){ - Collections.sort(group, (a, b)->{ - double aa=Math.abs(DUNBAR-Math.log(a.lastWeekUsers)); - double bb=Math.abs(DUNBAR-Math.log(b.lastWeekUsers)); - return Double.compare(aa, bb); - }); - } - // get the list of user-configured system languages - List userLangs; - if(Build.VERSION.SDK_INT<24){ - userLangs=Collections.singletonList(getResources().getConfiguration().locale.getLanguage()); - }else{ - LocaleList ll=getResources().getConfiguration().getLocales(); - userLangs=new ArrayList<>(ll.size()); - for(int i=0;i sortedList=new ArrayList<>(); - for(String lang:userLangs){ - List langInstances=byLang.remove(lang); - if(langInstances!=null){ - sortedList.addAll(langInstances); - } - } - // sort the remaining language groups by aggregate lastWeekUsers - class InstanceGroup{ - public int activeUsers; - public List instances; - } - byLang.values().stream().map(il->{ - InstanceGroup group=new InstanceGroup(); - group.instances=il; - for(CatalogInstance instance:il){ - group.activeUsers+=instance.lastWeekUsers; - } - return group; - }).sorted(Comparator.comparingInt((InstanceGroup g)->g.activeUsers).reversed()).forEachOrdered(ig->sortedList.addAll(ig.instances)); - onDataLoaded(sortedList, false); - updateFilteredList(); - } - - @Override - public void onError(ErrorResponse error){ - error.showToast(getActivity()); - onDataLoaded(Collections.emptyList(), false); - } - }) - .execNoAuth(""); - getCategoriesRequest=new GetCatalogCategories(null) - .setCallback(new Callback<>(){ - @Override - public void onSuccess(List result){ - getCategoriesRequest=null; - CatalogCategory all=new CatalogCategory(); - all.category="all"; - categories.add(all); - result.stream().sorted(Comparator.comparingInt((CatalogCategory cc)->cc.serversCount).reversed()).forEach(categories::add); - updateCategories(); - } - - @Override - public void onError(ErrorResponse error){ - getCategoriesRequest=null; - error.showToast(getActivity()); - CatalogCategory all=new CatalogCategory(); - all.category="all"; - categories.add(all); - updateCategories(); - } - }) - .execNoAuth(""); - } - - private void updateCategories(){ - categoriesList.removeAllTabs(); - for(CatalogCategory cat:categories){ - int titleRes=getTitleForCategory(cat.category); - TabLayout.Tab tab=categoriesList.newTab().setText(titleRes!=0 ? getString(titleRes) : cat.category).setCustomView(R.layout.item_instance_category); - ImageView emoji=tab.getCustomView().findViewById(R.id.emoji); - emoji.setImageResource(getEmojiForCategory(cat.category)); - categoriesList.addTab(tab); - } - } - - @Override - public void onDestroy(){ - super.onDestroy(); - if(getCategoriesRequest!=null) - getCategoriesRequest.cancel(); - } - - @Override - protected RecyclerView.Adapter getAdapter(){ - headerView=getActivity().getLayoutInflater().inflate(R.layout.header_onboarding_instance_catalog, list, false); - searchEdit=headerView.findViewById(R.id.search_edit); - categoriesList=headerView.findViewById(R.id.categories_list); - categoriesList.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener(){ - @Override - public void onTabSelected(TabLayout.Tab tab){ - CatalogCategory category=categories.get(tab.getPosition()); - currentCategory=category.category; - updateFilteredList(); - } - - @Override - public void onTabUnselected(TabLayout.Tab tab){ - - } - - @Override - public void onTabReselected(TabLayout.Tab tab){ - - } - }); - searchEdit.setOnEditorActionListener(this::onSearchEnterPressed); - searchEdit.addTextChangedListener(new TextWatcher(){ - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after){ - - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count){ - searchEdit.removeCallbacks(searchDebouncer); - searchEdit.postDelayed(searchDebouncer, 300); - } - - @Override - public void afterTextChanged(Editable s){ - } - }); - - mergeAdapter=new MergeRecyclerAdapter(); - mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(headerView)); - mergeAdapter.addAdapter(adapter=new InstancesAdapter()); - return mergeAdapter; - } - - @Override - public void onViewCreated(View view, Bundle savedInstanceState){ - super.onViewCreated(view, savedInstanceState); - nextButton=view.findViewById(R.id.btn_next); - nextButton.setOnClickListener(this::onNextClick); - nextButton.setEnabled(chosenInstance!=null); - view.findViewById(R.id.btn_back).setOnClickListener(v->Nav.finish(this)); - list.setItemAnimator(new BetterItemAnimator()); - list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 1, 16, 16, DividerItemDecoration.NOT_FIRST)); - view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorBackgroundLight)); - buttonBar=view.findViewById(R.id.button_bar); - setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorBackgroundLight)); - } - - private void onNextClick(View v){ - String domain=chosenInstance.domain; - Instance instance=instancesCache.get(domain); - if(instance!=null){ - proceedWithAuthOrSignup(instance); - }else{ - showProgressDialog(); - if(!domain.equals(loadingInstanceDomain)){ - loadInstanceInfo(domain, false); - } - } - } - - private void proceedWithAuthOrSignup(Instance instance){ - getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(contentView.getWindowToken(), 0); - if(isSignup){ - if(!instance.registrations){ - new M3AlertDialogBuilder(getActivity()) - .setTitle(R.string.error) - .setMessage(R.string.instance_signup_closed) - .setPositiveButton(R.string.ok, null) - .show(); - return; - } - Bundle args=new Bundle(); - args.putParcelable("instance", Parcels.wrap(instance)); - Nav.go(getActivity(), InstanceRulesFragment.class, args); - }else{ - AccountSessionManager.getInstance().authenticate(getActivity(), instance); - } - } - -// private String getEmojiForCategory(String category){ -// return switch(category){ -// case "all" -> "๐Ÿ’ฌ"; -// case "academia" -> "๐Ÿ“š"; -// case "activism" -> "โœŠ"; -// case "food" -> "๐Ÿ•"; -// case "furry" -> "๐Ÿฆ"; -// case "games" -> "๐Ÿ•น"; -// case "general" -> "๐Ÿ˜"; -// case "journalism" -> "๐Ÿ“ฐ"; -// case "lgbt" -> "๐Ÿณ๏ธโ€๐ŸŒˆ"; -// case "regional" -> "๐Ÿ“"; -// case "art" -> "๐ŸŽจ"; -// case "music" -> "๐ŸŽผ"; -// case "tech" -> "๐Ÿ“ฑ"; -// default -> "โ“"; -// }; -// } - - private int getEmojiForCategory(String category){ - return switch(category){ - case "all" -> R.drawable.ic_category_all; - case "academia" -> R.drawable.ic_category_academia; - case "activism" -> R.drawable.ic_category_activism; - case "food" -> R.drawable.ic_category_food; - case "furry" -> R.drawable.ic_category_furry; - case "games" -> R.drawable.ic_category_games; - case "general" -> R.drawable.ic_category_general; - case "journalism" -> R.drawable.ic_category_journalism; - case "lgbt" -> R.drawable.ic_category_lgbt; - case "regional" -> R.drawable.ic_category_regional; - case "art" -> R.drawable.ic_category_art; - case "music" -> R.drawable.ic_category_music; - case "tech" -> R.drawable.ic_category_tech; - default -> R.drawable.ic_category_unknown; - }; - } - - private int getTitleForCategory(String category){ - return switch(category){ - case "all" -> R.string.category_all; - case "academia" -> R.string.category_academia; - case "activism" -> R.string.category_activism; - case "food" -> R.string.category_food; - case "furry" -> R.string.category_furry; - case "games" -> R.string.category_games; - case "general" -> R.string.category_general; - case "journalism" -> R.string.category_journalism; - case "lgbt" -> R.string.category_lgbt; - case "regional" -> R.string.category_regional; - case "art" -> R.string.category_art; - case "music" -> R.string.category_music; - case "tech" -> R.string.category_tech; - default -> 0; - }; - } - - private boolean onSearchEnterPressed(TextView v, int actionId, KeyEvent event){ + protected boolean onSearchEnterPressed(TextView v, int actionId, KeyEvent event){ if(event!=null && event.getAction()!=KeyEvent.ACTION_DOWN) return true; currentSearchQuery=searchEdit.getText().toString().toLowerCase(); @@ -385,60 +105,73 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment prevData=new ArrayList<>(filteredData); - filteredData.clear(); - for(CatalogInstance instance:data){ - if(currentCategory.equals("all") || instance.categories.contains(currentCategory)){ - if(TextUtils.isEmpty(currentSearchQuery) || instance.domain.contains(currentSearchQuery)){ - if(instance.domain.equals(currentSearchQuery) || !isSignup || !instance.approvalRequired) - filteredData.add(instance); - } + protected List sortInstances(List result){ + Map> byLang=result.stream().collect(Collectors.groupingBy(ci->ci.language)); + for(List group:byLang.values()){ + Collections.sort(group, (a, b)->{ + double aa=Math.abs(DUNBAR-Math.log(a.lastWeekUsers)); + double bb=Math.abs(DUNBAR-Math.log(b.lastWeekUsers)); + return Double.compare(aa, bb); + }); + } + // get the list of user-configured system languages + List userLangs; + if(Build.VERSION.SDK_INT<24){ + userLangs=Collections.singletonList(getResources().getConfiguration().locale.getLanguage()); + }else{ + LocaleList ll=getResources().getConfiguration().getLocales(); + userLangs=new ArrayList<>(ll.size()); + for(int i=0;i sortedList=new ArrayList<>(); + for(String lang:userLangs){ + List langInstances=byLang.remove(lang); + if(langInstances!=null){ + sortedList.addAll(langInstances); } - - @Override - public int getNewListSize(){ - return filteredData.size(); + } + // sort the remaining language groups by aggregate lastWeekUsers + class InstanceGroup{ + public int activeUsers; + public List instances; + } + byLang.values().stream().map(il->{ + InstanceGroup group=new InstanceGroup(); + group.instances=il; + for(CatalogInstance instance:il){ + group.activeUsers+=instance.lastWeekUsers; } - - @Override - public boolean areItemsTheSame(int oldItemPosition, int newItemPosition){ - return prevData.get(oldItemPosition)==filteredData.get(newItemPosition); - } - - @Override - public boolean areContentsTheSame(int oldItemPosition, int newItemPosition){ - return prevData.get(oldItemPosition)==filteredData.get(newItemPosition); - } - }).dispatchUpdatesTo(adapter); + return group; + }).sorted(Comparator.comparingInt((InstanceGroup g)->g.activeUsers).reversed()).forEachOrdered(ig->sortedList.addAll(ig.instances)); + return sortedList; } - private void showProgressDialog(){ + protected abstract void updateFilteredList(); + + protected void showProgressDialog(){ instanceProgressDialog=new ProgressDialog(getActivity()); instanceProgressDialog.setMessage(getString(R.string.loading_instance)); instanceProgressDialog.setOnCancelListener(dialog->cancelLoadingInstanceInfo()); instanceProgressDialog.show(); } - private String normalizeInstanceDomain(String _domain){ + protected String normalizeInstanceDomain(String _domain){ if(TextUtils.isEmpty(_domain)) return null; if(_domain.contains(":")){ try{ _domain=Uri.parse(_domain).getAuthority(); - }catch(Exception ignore){} + }catch(Exception ignore){ + } if(TextUtils.isEmpty(_domain)) return null; } @@ -453,12 +186,12 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment(){ - @Override - public void onSuccess(Instance result){ - loadingInstanceRequest=null; - loadingInstanceDomain=null; - result.uri=domain; // needed for instances that use domain redirection - instancesCache.put(domain, result); - if(instanceProgressDialog!=null){ - instanceProgressDialog.dismiss(); - instanceProgressDialog=null; - proceedWithAuthOrSignup(result); - } - if(Objects.equals(domain, currentSearchQuery) || Objects.equals(currentSearchQuery, redirects.get(domain)) || Objects.equals(currentSearchQuery, redirectsInverse.get(domain))){ - boolean found=false; - for(CatalogInstance ci:filteredData){ - if(ci.domain.equals(domain)){ - found=true; - break; - } - } - if(!found){ - CatalogInstance ci=result.toCatalogInstance(); - filteredData.add(0, ci); - adapter.notifyItemInserted(0); - } + @Override + public void onSuccess(Instance result){ + loadingInstanceRequest=null; + loadingInstanceDomain=null; + result.uri=domain; // needed for instances that use domain redirection + instancesCache.put(domain, result); + if(instanceProgressDialog!=null){ + instanceProgressDialog.dismiss(); + instanceProgressDialog=null; + proceedWithAuthOrSignup(result); + } + if(Objects.equals(domain, currentSearchQuery) || Objects.equals(currentSearchQuery, redirects.get(domain)) || Objects.equals(currentSearchQuery, redirectsInverse.get(domain))){ + boolean found=false; + for(CatalogInstance ci : filteredData){ + if(ci.domain.equals(domain) && ci!=fakeInstance){ + found=true; + break; } } + if(!found){ + CatalogInstance ci=result.toCatalogInstance(); + if(filteredData.size()==1 && filteredData.get(0)==fakeInstance){ + filteredData.set(0, ci); + adapter.notifyItemChanged(0); + }else{ + filteredData.add(0, ci); + adapter.notifyItemInserted(0); + } + } + } + } - @Override - public void onError(ErrorResponse error){ - loadingInstanceRequest=null; - if(!isFromRedirect && error instanceof MastodonErrorResponse me && me.httpStatus==404){ - fetchDomainFromHostMetaAndMaybeRetry(domain, error); - return; + @Override + public void onError(ErrorResponse error){ + loadingInstanceRequest=null; + if(!isFromRedirect && error instanceof MastodonErrorResponse me && me.httpStatus==404){ + fetchDomainFromHostMetaAndMaybeRetry(domain, error); + return; + } + loadingInstanceDomain=null; + showInstanceInfoLoadError(domain, error); + if(fakeInstance!=null){ + fakeInstance.description=getString(R.string.error); + if(filteredData.size()>0 && filteredData.get(0)==fakeInstance){ + if(list.findViewHolderForAdapterPosition(1) instanceof BindableViewHolder ivh){ + ivh.rebind(); } - loadingInstanceDomain=null; - showInstanceInfoLoadError(domain, error); } - }).execNoAuth(domain); + } + } + }).execNoAuth(domain); } private void cancelLoadingInstanceInfo(){ @@ -584,7 +330,7 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment{ - public InstancesAdapter(){ - super(imgLoader); - } - - @NonNull - @Override - public InstanceViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ - return new InstanceViewHolder(); - } - - @Override - public void onBindViewHolder(InstanceViewHolder holder, int position){ - holder.bind(filteredData.get(position)); - super.onBindViewHolder(holder, position); - } - - @Override - public int getItemCount(){ - return filteredData.size(); - } - - @Override - public int getItemViewType(int position){ - return -1; - } + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + nextButton=view.findViewById(R.id.btn_next); + nextButton.setOnClickListener(this::onNextClick); + nextButton.setEnabled(chosenInstance!=null); + buttonBar=view.findViewById(R.id.button_bar); + setRefreshEnabled(false); } - private class InstanceViewHolder extends BindableViewHolder implements UsableRecyclerView.Clickable{ - private final TextView title, description, userCount, lang; - private final RadioButton radioButton; - - public InstanceViewHolder(){ - super(getActivity(), R.layout.item_instance_catalog, list); - title=findViewById(R.id.title); - description=findViewById(R.id.description); - userCount=findViewById(R.id.user_count); - lang=findViewById(R.id.lang); - radioButton=findViewById(R.id.radiobtn); - if(Build.VERSION.SDK_INT getCategoriesRequest; + private TabLayout categoriesList; + private String currentCategory="all"; + private List categories=new ArrayList<>(); + + + public InstanceCatalogSignupFragment(){ + super(R.layout.fragment_onboarding_common, 10); + } + + @Override + public void onAttach(Context context){ + super.onAttach(context); + setRefreshEnabled(false); + loadData(); + } + + @Override + protected void doLoadData(int offset, int count){ + currentRequest=new GetCatalogInstances(null, null) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + if(getActivity()==null) + return; + onDataLoaded(sortInstances(result), false); + updateFilteredList(); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + onDataLoaded(Collections.emptyList(), false); + } + }) + .execNoAuth(""); + getCategoriesRequest=new GetCatalogCategories(null) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + getCategoriesRequest=null; + CatalogCategory all=new CatalogCategory(); + all.category="all"; + categories.add(all); + result.stream().sorted(Comparator.comparingInt((CatalogCategory cc)->cc.serversCount).reversed()).forEach(categories::add); + updateCategories(); + } + + @Override + public void onError(ErrorResponse error){ + getCategoriesRequest=null; + error.showToast(getActivity()); + CatalogCategory all=new CatalogCategory(); + all.category="all"; + categories.add(all); + updateCategories(); + } + }) + .execNoAuth(""); + } + + private void updateCategories(){ + categoriesList.removeAllTabs(); + for(CatalogCategory cat:categories){ + int titleRes=getTitleForCategory(cat.category); + TabLayout.Tab tab=categoriesList.newTab().setText(titleRes!=0 ? getString(titleRes) : cat.category).setCustomView(R.layout.item_instance_category); + ImageView emoji=tab.getCustomView().findViewById(R.id.emoji); + emoji.setImageResource(getEmojiForCategory(cat.category)); + categoriesList.addTab(tab); + } + } + + @Override + public void onDestroy(){ + super.onDestroy(); + if(getCategoriesRequest!=null) + getCategoriesRequest.cancel(); + } + + @Override + protected RecyclerView.Adapter getAdapter(){ + headerView=getActivity().getLayoutInflater().inflate(R.layout.header_onboarding_instance_catalog, list, false); + searchEdit=headerView.findViewById(R.id.search_edit); + categoriesList=headerView.findViewById(R.id.categories_list); + categoriesList.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener(){ + @Override + public void onTabSelected(TabLayout.Tab tab){ + CatalogCategory category=categories.get(tab.getPosition()); + currentCategory=category.category; + updateFilteredList(); + } + + @Override + public void onTabUnselected(TabLayout.Tab tab){ + + } + + @Override + public void onTabReselected(TabLayout.Tab tab){ + + } + }); + searchEdit.setOnEditorActionListener(this::onSearchEnterPressed); + searchEdit.addTextChangedListener(new TextWatcher(){ + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after){ + + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count){ + searchEdit.removeCallbacks(searchDebouncer); + searchEdit.postDelayed(searchDebouncer, 300); + } + + @Override + public void afterTextChanged(Editable s){ + } + }); + + mergeAdapter=new MergeRecyclerAdapter(); + mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(headerView)); + mergeAdapter.addAdapter(adapter=new InstancesAdapter()); + return mergeAdapter; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + view.findViewById(R.id.btn_back).setOnClickListener(v->Nav.finish(this)); + list.setItemAnimator(new BetterItemAnimator()); + list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 1, 16, 16, DividerItemDecoration.NOT_FIRST)); + view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorBackgroundLight)); + setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorBackgroundLight)); + } + + @Override + protected void proceedWithAuthOrSignup(Instance instance){ + getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(contentView.getWindowToken(), 0); + if(isSignup){ + if(!instance.registrations){ + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.error) + .setMessage(R.string.instance_signup_closed) + .setPositiveButton(R.string.ok, null) + .show(); + return; + } + Bundle args=new Bundle(); + args.putParcelable("instance", Parcels.wrap(instance)); + Nav.go(getActivity(), InstanceRulesFragment.class, args); + }else{ + } + } + +// private String getEmojiForCategory(String category){ +// return switch(category){ +// case "all" -> "๐Ÿ’ฌ"; +// case "academia" -> "๐Ÿ“š"; +// case "activism" -> "โœŠ"; +// case "food" -> "๐Ÿ•"; +// case "furry" -> "๐Ÿฆ"; +// case "games" -> "๐Ÿ•น"; +// case "general" -> "๐Ÿ˜"; +// case "journalism" -> "๐Ÿ“ฐ"; +// case "lgbt" -> "๐Ÿณ๏ธโ€๐ŸŒˆ"; +// case "regional" -> "๐Ÿ“"; +// case "art" -> "๐ŸŽจ"; +// case "music" -> "๐ŸŽผ"; +// case "tech" -> "๐Ÿ“ฑ"; +// default -> "โ“"; +// }; +// } + + private int getEmojiForCategory(String category){ + return switch(category){ + case "all" -> R.drawable.ic_category_all; + case "academia" -> R.drawable.ic_category_academia; + case "activism" -> R.drawable.ic_category_activism; + case "food" -> R.drawable.ic_category_food; + case "furry" -> R.drawable.ic_category_furry; + case "games" -> R.drawable.ic_category_games; + case "general" -> R.drawable.ic_category_general; + case "journalism" -> R.drawable.ic_category_journalism; + case "lgbt" -> R.drawable.ic_category_lgbt; + case "regional" -> R.drawable.ic_category_regional; + case "art" -> R.drawable.ic_category_art; + case "music" -> R.drawable.ic_category_music; + case "tech" -> R.drawable.ic_category_tech; + default -> R.drawable.ic_category_unknown; + }; + } + + private int getTitleForCategory(String category){ + return switch(category){ + case "all" -> R.string.category_all; + case "academia" -> R.string.category_academia; + case "activism" -> R.string.category_activism; + case "food" -> R.string.category_food; + case "furry" -> R.string.category_furry; + case "games" -> R.string.category_games; + case "general" -> R.string.category_general; + case "journalism" -> R.string.category_journalism; + case "lgbt" -> R.string.category_lgbt; + case "regional" -> R.string.category_regional; + case "art" -> R.string.category_art; + case "music" -> R.string.category_music; + case "tech" -> R.string.category_tech; + default -> 0; + }; + } + + @Override + protected void updateFilteredList(){ + ArrayList prevData=new ArrayList<>(filteredData); + filteredData.clear(); + for(CatalogInstance instance:data){ + if(currentCategory.equals("all") || instance.categories.contains(currentCategory)){ + if(TextUtils.isEmpty(currentSearchQuery) || instance.domain.contains(currentSearchQuery)){ + if(instance.domain.equals(currentSearchQuery) || !isSignup || !instance.approvalRequired) + filteredData.add(instance); + } + } + } + DiffUtil.calculateDiff(new DiffUtil.Callback(){ + @Override + public int getOldListSize(){ + return prevData.size(); + } + + @Override + public int getNewListSize(){ + return filteredData.size(); + } + + @Override + public boolean areItemsTheSame(int oldItemPosition, int newItemPosition){ + return prevData.get(oldItemPosition)==filteredData.get(newItemPosition); + } + + @Override + public boolean areContentsTheSame(int oldItemPosition, int newItemPosition){ + return prevData.get(oldItemPosition)==filteredData.get(newItemPosition); + } + }).dispatchUpdatesTo(adapter); + } + + + private class InstancesAdapter extends UsableRecyclerView.Adapter{ + public InstancesAdapter(){ + super(imgLoader); + } + + @NonNull + @Override + public InstanceCatalogSignupFragment.InstanceViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return new InstanceCatalogSignupFragment.InstanceViewHolder(); + } + + @Override + public void onBindViewHolder(InstanceCatalogSignupFragment.InstanceViewHolder holder, int position){ + holder.bind(filteredData.get(position)); + super.onBindViewHolder(holder, position); + } + + @Override + public int getItemCount(){ + return filteredData.size(); + } + + @Override + public int getItemViewType(int position){ + return -1; + } + } + + private class InstanceViewHolder extends BindableViewHolder implements UsableRecyclerView.Clickable{ + private final TextView title, description, userCount, lang; + private final RadioButton radioButton; + + public InstanceViewHolder(){ + super(getActivity(), R.layout.item_instance_catalog, list); + title=findViewById(R.id.title); + description=findViewById(R.id.description); + userCount=findViewById(R.id.user_count); + lang=findViewById(R.id.lang); + radioButton=findViewById(R.id.radiobtn); + if(Build.VERSION.SDK_INT prevData=new ArrayList<>(filteredData); + filteredData.clear(); + if(currentSearchQuery.length()>0){ + boolean foundExactMatch=false; + for(CatalogInstance inst:data){ + if(inst.normalizedDomain.contains(currentSearchQuery)){ + filteredData.add(inst); + if(inst.normalizedDomain.equals(currentSearchQuery)) + foundExactMatch=true; + } + } + if(!foundExactMatch) + filteredData.add(0, fakeInstance); + } + UiUtils.updateList(prevData, filteredData, list, adapter, Objects::equals); + for(int i=0;i(){ + @Override + public void onSuccess(List result){ + data.clear(); + data.addAll(sortInstances(result)); + } + + @Override + public void onError(ErrorResponse error){ + + } + }) + .execNoAuth(""); + } + + @Override + protected void onUpdateToolbar(){ + super.onUpdateToolbar(); + Toolbar toolbar=getToolbar(); + toolbar.setElevation(0); + toolbar.setBackground(null); + } + + @Override + protected RecyclerView.Adapter getAdapter(){ + headerView=getActivity().getLayoutInflater().inflate(R.layout.header_onboarding_login, list, false); + clearBtn=headerView.findViewById(R.id.search_clear); + searchEdit=headerView.findViewById(R.id.search_edit); + searchEdit.setOnEditorActionListener(this::onSearchEnterPressed); + searchEdit.addTextChangedListener(new TextWatcher(){ + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after){ + + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count){ + searchEdit.removeCallbacks(searchDebouncer); + searchEdit.postDelayed(searchDebouncer, 300); + + if(s.length()>0){ + fakeInstance.domain=fakeInstance.normalizedDomain=s.toString(); + fakeInstance.description=getString(R.string.loading_instance); + if(filteredData.size()>0 && filteredData.get(0)==fakeInstance){ + if(list.findViewHolderForAdapterPosition(1) instanceof InstanceViewHolder ivh){ + ivh.rebind(); + } + } + if(filteredData.isEmpty()){ + filteredData.add(fakeInstance); + adapter.notifyItemInserted(0); + } + clearBtn.setVisibility(View.VISIBLE); + }else{ + clearBtn.setVisibility(View.GONE); + } + } + + @Override + public void afterTextChanged(Editable s){ + } + }); + clearBtn.setOnClickListener(v->searchEdit.setText("")); + + mergeAdapter=new MergeRecyclerAdapter(); + mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(headerView)); + mergeAdapter.addAdapter(adapter=new InstancesAdapter()); + return mergeAdapter; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background)); + + list.addItemDecoration(new RecyclerView.ItemDecoration(){ + @Override + public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){ + if(parent.getChildViewHolder(view) instanceof InstanceViewHolder){ + outRect.left=outRect.right=V.dp(16); + } + } + }); + ((UsableRecyclerView)list).setDrawSelectorOnTop(true); + } + + private class InstancesAdapter extends UsableRecyclerView.Adapter{ + public InstancesAdapter(){ + super(imgLoader); + } + + @NonNull + @Override + public InstanceViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return new InstanceViewHolder(); + } + + @Override + public void onBindViewHolder(InstanceViewHolder holder, int position){ + holder.bind(filteredData.get(position)); + super.onBindViewHolder(holder, position); + } + + @Override + public int getItemCount(){ + return filteredData.size(); + } + + @Override + public int getItemViewType(int position){ + return -1; + } + } + + private class InstanceViewHolder extends BindableViewHolder implements UsableRecyclerView.Clickable{ + private final TextView title, description; + private final RadioButton radioButton; + + public InstanceViewHolder(){ + super(getActivity(), R.layout.item_instance_login, list); + title=findViewById(R.id.title); + description=findViewById(R.id.description); + radioButton=findViewById(R.id.radiobtn); + radioButton.setMinWidth(0); + radioButton.setMinHeight(0); + + itemView.setOutlineProvider(new ViewOutlineProvider(){ + @Override + public void getOutline(View view, Outline outline){ + outline.setRoundRect(0, getAbsoluteAdapterPosition()==1 ? 0 : V.dp(-4), view.getWidth(), view.getHeight()+(getAbsoluteAdapterPosition()==filteredData.size() ? 0 : V.dp(4)), V.dp(4)); + } + }); + itemView.setClipToOutline(true); + } + + @Override + public void onBind(CatalogInstance item){ + title.setText(item.normalizedDomain); + description.setText(item.description); + radioButton.setChecked(chosenInstance==item); + } + + @Override + public void onClick(){ + if(chosenInstance==item) + return; + if(chosenInstance!=null){ + int idx=filteredData.indexOf(chosenInstance); + if(idx!=-1){ + RecyclerView.ViewHolder holder=list.findViewHolderForAdapterPosition(mergeAdapter.getPositionForAdapter(adapter)+idx); + if(holder instanceof InstanceViewHolder ivh){ + ivh.radioButton.setChecked(false); + } + } + } + radioButton.setChecked(true); + if(chosenInstance==null) + nextButton.setEnabled(true); + chosenInstance=item; + loadInstanceInfo(chosenInstance.domain, false); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/FloatingHintEditTextLayout.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/FloatingHintEditTextLayout.java new file mode 100644 index 000000000..6e59ead59 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/FloatingHintEditTextLayout.java @@ -0,0 +1,124 @@ +package org.joinmastodon.android.ui.views; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.content.res.TypedArray; +import android.text.Editable; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.TextView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.ui.utils.SimpleTextWatcher; + +import me.grishka.appkit.utils.CubicBezierInterpolator; +import me.grishka.appkit.utils.V; + +public class FloatingHintEditTextLayout extends FrameLayout{ + private EditText edit; + private TextView label; + private int labelTextSize; + private int offsetY; + private boolean hintVisible; + private Animator currentAnim; + + public FloatingHintEditTextLayout(Context context){ + this(context, null); + } + + public FloatingHintEditTextLayout(Context context, AttributeSet attrs){ + this(context, attrs, 0); + } + + public FloatingHintEditTextLayout(Context context, AttributeSet attrs, int defStyle){ + super(context, attrs, defStyle); + if(isInEditMode()) + V.setApplicationContext(context); + TypedArray ta=context.obtainStyledAttributes(attrs, R.styleable.FloatingHintEditTextLayout); + labelTextSize=ta.getDimensionPixelSize(R.styleable.FloatingHintEditTextLayout_android_labelTextSize, V.dp(12)); + offsetY=ta.getDimensionPixelOffset(R.styleable.FloatingHintEditTextLayout_editTextOffsetY, 0); + ta.recycle(); + } + + @Override + protected void onFinishInflate(){ + super.onFinishInflate(); + if(getChildCount()>0 && getChildAt(0) instanceof EditText et){ + edit=et; + }else{ + throw new IllegalStateException("First child must be an EditText"); + } + + label=new TextView(getContext()); + label.setTextSize(TypedValue.COMPLEX_UNIT_PX, labelTextSize); + label.setTextColor(edit.getHintTextColors()); + label.setText(edit.getHint()); + label.setSingleLine(); + label.setPivotX(0f); + label.setPivotY(0f); + LayoutParams lp=new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.START | Gravity.TOP); + lp.setMarginStart(edit.getPaddingStart()); + addView(label, lp); + + hintVisible=edit.getText().length()==0; + if(hintVisible) + label.setAlpha(0f); + + edit.addTextChangedListener(new SimpleTextWatcher(this::onTextChanged)); + } + + private void onTextChanged(Editable text){ + boolean newHintVisible=text.length()==0; + if(newHintVisible==hintVisible) + return; + if(currentAnim!=null) + currentAnim.cancel(); + hintVisible=newHintVisible; + + label.setAlpha(1); + float scale=edit.getLineHeight()/(float)label.getLineHeight(); + float transY=edit.getHeight()/2f-edit.getLineHeight()/2f+(edit.getTop()-label.getTop())-(label.getHeight()/2f-label.getLineHeight()/2f); + + AnimatorSet anim=new AnimatorSet(); + if(hintVisible){ + anim.playTogether( + ObjectAnimator.ofFloat(edit, TRANSLATION_Y, 0), + ObjectAnimator.ofFloat(label, SCALE_X, scale), + ObjectAnimator.ofFloat(label, SCALE_Y, scale), + ObjectAnimator.ofFloat(label, TRANSLATION_Y, transY) + ); + edit.setHintTextColor(0); + }else{ + label.setScaleX(scale); + label.setScaleY(scale); + label.setTranslationY(transY); + anim.playTogether( + ObjectAnimator.ofFloat(edit, TRANSLATION_Y, offsetY), + ObjectAnimator.ofFloat(label, SCALE_X, 1f), + ObjectAnimator.ofFloat(label, SCALE_Y, 1f), + ObjectAnimator.ofFloat(label, TRANSLATION_Y, 0f) + ); + } + anim.setDuration(150); + anim.setInterpolator(CubicBezierInterpolator.DEFAULT); + anim.start(); + anim.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + currentAnim=null; + if(hintVisible){ + label.setAlpha(0); + edit.setHintTextColor(label.getTextColors()); + } + } + }); + currentAnim=anim; + } +} diff --git a/mastodon/src/main/res/color/button_text_m3_filled.xml b/mastodon/src/main/res/color/button_text_m3_filled.xml new file mode 100644 index 000000000..84416b4f9 --- /dev/null +++ b/mastodon/src/main/res/color/button_text_m3_filled.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/color/m3_pressed_overlay.xml b/mastodon/src/main/res/color/m3_pressed_overlay.xml new file mode 100644 index 000000000..824b4b289 --- /dev/null +++ b/mastodon/src/main/res/color/m3_pressed_overlay.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/color/m3_radiobutton_tint.xml b/mastodon/src/main/res/color/m3_radiobutton_tint.xml new file mode 100644 index 000000000..029457ae2 --- /dev/null +++ b/mastodon/src/main/res/color/m3_radiobutton_tint.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_button_m3_filled.xml b/mastodon/src/main/res/drawable/bg_button_m3_filled.xml new file mode 100644 index 000000000..8ba6277c9 --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_button_m3_filled.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_m3_cancel.xml b/mastodon/src/main/res/drawable/ic_m3_cancel.xml new file mode 100644 index 000000000..258e402fb --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_m3_cancel.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_m3_search.xml b/mastodon/src/main/res/drawable/ic_m3_search.xml new file mode 100644 index 000000000..1b2a144a7 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_m3_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/rect_4dp.xml b/mastodon/src/main/res/drawable/rect_4dp.xml new file mode 100644 index 000000000..c44581d40 --- /dev/null +++ b/mastodon/src/main/res/drawable/rect_4dp.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/fragment_login.xml b/mastodon/src/main/res/layout/fragment_login.xml new file mode 100644 index 000000000..0abb69439 --- /dev/null +++ b/mastodon/src/main/res/layout/fragment_login.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + +