Username UX refresh.

This commit is contained in:
Alex Hart
2022-08-16 16:59:12 -03:00
committed by Cody Henthorne
parent 3252871ed5
commit 28310a88f5
24 changed files with 1254 additions and 589 deletions

View File

@@ -12,14 +12,12 @@ import android.view.View;
import android.view.ViewAnimationUtils;
import android.view.ViewGroup;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.appcompat.widget.Toolbar;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.ViewModelProviders;
import androidx.navigation.Navigation;
@@ -28,11 +26,13 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.signal.core.util.EditTextUtil;
import org.signal.core.util.StreamUtil;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.avatar.Avatars;
import org.thoughtcrime.securesms.avatar.picker.AvatarPickerFragment;
import org.thoughtcrime.securesms.databinding.ProfileCreateFragmentBinding;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.ParcelableGroupId;
import org.thoughtcrime.securesms.mediasend.Media;
@@ -41,11 +41,8 @@ import org.thoughtcrime.securesms.profiles.manage.EditProfileNameFragment;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.signal.core.util.concurrent.SimpleTask;
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
import org.thoughtcrime.securesms.util.views.CircularProgressMaterialButton;
import org.thoughtcrime.securesms.util.views.LearnMoreTextView;
import java.io.IOException;
import java.io.InputStream;
@@ -62,20 +59,10 @@ public class EditProfileFragment extends LoggingFragment {
private static final int MAX_DESCRIPTION_GLYPHS = 480;
private static final int MAX_DESCRIPTION_BYTES = 8192;
private Toolbar toolbar;
private View title;
private ImageView avatar;
private CircularProgressMaterialButton finishButton;
private EditText givenName;
private EditText familyName;
private View reveal;
private TextView preview;
private ImageView avatarPreviewBackground;
private ImageView avatarPreview;
private Intent nextIntent;
private EditProfileViewModel viewModel;
private EditProfileViewModel viewModel;
private ProfileCreateFragmentBinding binding;
private Controller controller;
@@ -92,7 +79,8 @@ public class EditProfileFragment extends LoggingFragment {
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.profile_create_fragment, container, false);
binding = ProfileCreateFragmentBinding.inflate(inflater, container, false);
return binding.getRoot();
}
@Override
@@ -108,7 +96,7 @@ public class EditProfileFragment extends LoggingFragment {
if (bundle.getBoolean(AvatarPickerFragment.SELECT_AVATAR_CLEAR)) {
viewModel.setAvatarMedia(null);
viewModel.setAvatar(null);
avatar.setImageDrawable(null);
binding.avatar.setImageDrawable(null);
} else {
Media media = bundle.getParcelable(AvatarPickerFragment.SELECT_AVATAR_MEDIA);
handleMediaFromResult(media);
@@ -116,6 +104,12 @@ public class EditProfileFragment extends LoggingFragment {
});
}
@Override
public void onDestroyView() {
super.onDestroyView();
binding = null;
}
private void handleMediaFromResult(@NonNull Media media) {
SimpleTask.run(() -> {
try {
@@ -136,7 +130,7 @@ public class EditProfileFragment extends LoggingFragment {
.skipMemoryCache(true)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.circleCrop()
.into(avatar);
.into(binding.avatar);
} else {
Toast.makeText(requireActivity(), R.string.CreateProfileActivity_error_setting_profile_photo, Toast.LENGTH_LONG).show();
}
@@ -162,111 +156,108 @@ public class EditProfileFragment extends LoggingFragment {
Bundle arguments = requireArguments();
boolean isEditingGroup = groupId != null;
this.toolbar = view.findViewById(R.id.toolbar);
this.title = view.findViewById(R.id.title);
this.avatar = view.findViewById(R.id.avatar);
this.givenName = view.findViewById(R.id.given_name);
this.familyName = view.findViewById(R.id.family_name);
this.finishButton = view.findViewById(R.id.finish_button);
this.reveal = view.findViewById(R.id.reveal);
this.preview = view.findViewById(R.id.name_preview);
this.avatarPreviewBackground = view.findViewById(R.id.avatar_background);
this.avatarPreview = view.findViewById(R.id.avatar_placeholder);
this.nextIntent = arguments.getParcelable(NEXT_INTENT);
this.nextIntent = arguments.getParcelable(NEXT_INTENT);
this.avatar.setOnClickListener(v -> startAvatarSelection());
view.findViewById(R.id.mms_group_hint)
.setVisibility(isEditingGroup && groupId.isMms() ? View.VISIBLE : View.GONE);
binding.avatar.setOnClickListener(v -> startAvatarSelection());
binding.mmsGroupHint.setVisibility(isEditingGroup && groupId.isMms() ? View.VISIBLE : View.GONE);
if (isEditingGroup) {
EditTextUtil.addGraphemeClusterLimitFilter(givenName, FeatureFlags.getMaxGroupNameGraphemeLength());
givenName.addTextChangedListener(new AfterTextChanged(s -> viewModel.setGivenName(s.toString())));
givenName.setHint(R.string.EditProfileFragment__group_name);
givenName.requestFocus();
toolbar.setTitle(R.string.EditProfileFragment__edit_group);
preview.setVisibility(View.GONE);
EditTextUtil.addGraphemeClusterLimitFilter(binding.givenName, FeatureFlags.getMaxGroupNameGraphemeLength());
binding.profileDescriptionText.setVisibility(View.GONE);
binding.whoCanFindMeContainer.setVisibility(View.GONE);
binding.givenName.addTextChangedListener(new AfterTextChanged(s -> viewModel.setGivenName(s.toString())));
binding.givenNameWrapper.setHint(R.string.EditProfileFragment__group_name);
binding.givenName.requestFocus();
binding.toolbar.setTitle(R.string.EditProfileFragment__edit_group);
binding.namePreview.setVisibility(View.GONE);
if (groupId.isV2()) {
EditTextUtil.addGraphemeClusterLimitFilter(familyName, MAX_DESCRIPTION_GLYPHS);
familyName.addTextChangedListener(new AfterTextChanged(s -> {
EditTextUtil.addGraphemeClusterLimitFilter(binding.familyName, MAX_DESCRIPTION_GLYPHS);
binding.familyName.addTextChangedListener(new AfterTextChanged(s -> {
EditProfileNameFragment.trimFieldToMaxByteLength(s, MAX_DESCRIPTION_BYTES);
viewModel.setFamilyName(s.toString());
}));
familyName.setHint(R.string.EditProfileFragment__group_description);
familyName.setSingleLine(false);
familyName.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES);
binding.familyNameWrapper.setHint(R.string.EditProfileFragment__group_description);
binding.familyName.setSingleLine(false);
binding.familyName.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES);
LearnMoreTextView descriptionText = view.findViewById(R.id.description_text);
descriptionText.setLearnMoreVisible(false);
descriptionText.setText(R.string.CreateProfileActivity_group_descriptions_will_be_visible_to_members_of_this_group_and_people_who_have_been_invited);
binding.groupDescriptionText.setLearnMoreVisible(false);
binding.groupDescriptionText.setText(R.string.CreateProfileActivity_group_descriptions_will_be_visible_to_members_of_this_group_and_people_who_have_been_invited);
} else {
familyName.setVisibility(View.GONE);
familyName.setEnabled(false);
view.findViewById(R.id.description_text).setVisibility(View.GONE);
binding.familyNameWrapper.setVisibility(View.GONE);
binding.familyName.setEnabled(false);
binding.groupDescriptionText.setVisibility(View.GONE);
}
view.<ImageView>findViewById(R.id.avatar_placeholder).setImageResource(R.drawable.ic_group_outline_40);
binding.avatarPlaceholder.setImageResource(R.drawable.ic_group_outline_40);
} else {
EditTextUtil.addGraphemeClusterLimitFilter(givenName, EditProfileNameFragment.NAME_MAX_GLYPHS);
EditTextUtil.addGraphemeClusterLimitFilter(familyName, EditProfileNameFragment.NAME_MAX_GLYPHS);
this.givenName.addTextChangedListener(new AfterTextChanged(s -> {
EditTextUtil.addGraphemeClusterLimitFilter(binding.givenName, EditProfileNameFragment.NAME_MAX_GLYPHS);
EditTextUtil.addGraphemeClusterLimitFilter(binding.familyName, EditProfileNameFragment.NAME_MAX_GLYPHS);
binding.givenName.addTextChangedListener(new AfterTextChanged(s -> {
EditProfileNameFragment.trimFieldToMaxByteLength(s);
viewModel.setGivenName(s.toString());
}));
this.familyName.addTextChangedListener(new AfterTextChanged(s -> {
binding.familyName.addTextChangedListener(new AfterTextChanged(s -> {
EditProfileNameFragment.trimFieldToMaxByteLength(s);
viewModel.setFamilyName(s.toString());
}));
LearnMoreTextView descriptionText = view.findViewById(R.id.description_text);
descriptionText.setLearnMoreVisible(true);
descriptionText.setOnLinkClickListener(v -> CommunicationActions.openBrowserLink(requireContext(), getString(R.string.EditProfileFragment__support_link)));
binding.groupDescriptionText.setVisibility(View.GONE);
binding.profileDescriptionText.setLearnMoreVisible(true);
binding.profileDescriptionText.setLinkColor(ContextCompat.getColor(requireContext(), R.color.signal_colorPrimary));
binding.profileDescriptionText.setOnLinkClickListener(v -> CommunicationActions.openBrowserLink(requireContext(), getString(R.string.EditProfileFragment__support_link)));
if (FeatureFlags.phoneNumberPrivacy()) {
binding.whoCanFindMeContainer.setVisibility(View.VISIBLE);
binding.whoCanFindMeContainer.setOnClickListener(v -> SafeNavigation.safeNavigate(Navigation.findNavController(v), EditProfileFragmentDirections.actionCreateProfileFragmentToPhoneNumberPrivacy()));
// TODO [alex] -- Where does this value come from?
binding.whoCanFindMeDescription.setText(R.string.PhoneNumberPrivacy_everyone);
}
}
this.finishButton.setOnClickListener(v -> {
this.finishButton.setSpinning();
binding.finishButton.setOnClickListener(v -> {
binding.finishButton.setSpinning();
handleUpload();
});
this.finishButton.setText(arguments.getInt(NEXT_BUTTON_TEXT, R.string.CreateProfileActivity_next));
binding.finishButton.setText(arguments.getInt(NEXT_BUTTON_TEXT, R.string.CreateProfileActivity_next));
if (arguments.getBoolean(SHOW_TOOLBAR, true)) {
this.toolbar.setVisibility(View.VISIBLE);
this.toolbar.setNavigationOnClickListener(v -> requireActivity().finish());
this.title.setVisibility(View.GONE);
binding.toolbar.setVisibility(View.VISIBLE);
binding.toolbar.setNavigationOnClickListener(v -> requireActivity().finish());
binding.title.setVisibility(View.GONE);
}
}
private void initializeProfileName() {
viewModel.isFormValid().observe(getViewLifecycleOwner(), isValid -> {
finishButton.setEnabled(isValid);
finishButton.setAlpha(isValid ? 1f : 0.5f);
binding.finishButton.setEnabled(isValid);
binding.finishButton.setAlpha(isValid ? 1f : 0.5f);
});
viewModel.givenName().observe(getViewLifecycleOwner(), givenName -> updateFieldIfNeeded(this.givenName, givenName));
viewModel.givenName().observe(getViewLifecycleOwner(), givenName -> updateFieldIfNeeded(binding.givenName, givenName));
viewModel.familyName().observe(getViewLifecycleOwner(), familyName -> updateFieldIfNeeded(this.familyName, familyName));
viewModel.familyName().observe(getViewLifecycleOwner(), familyName -> updateFieldIfNeeded(binding.familyName, familyName));
viewModel.profileName().observe(getViewLifecycleOwner(), profileName -> preview.setText(profileName.toString()));
viewModel.profileName().observe(getViewLifecycleOwner(), profileName -> binding.namePreview.setText(profileName.toString()));
}
private void initializeProfileAvatar() {
viewModel.avatar().observe(getViewLifecycleOwner(), bytes -> {
if (bytes == null) {
GlideApp.with(this).clear(avatar);
GlideApp.with(this).clear(binding.avatar);
return;
}
GlideApp.with(this)
.load(bytes)
.circleCrop()
.into(avatar);
.into(binding.avatar);
});
viewModel.avatarColor().observe(getViewLifecycleOwner(), avatarColor -> {
Avatars.ForegroundColor foregroundColor = Avatars.getForegroundColor(avatarColor);
avatarPreview.getDrawable().setColorFilter(new SimpleColorFilter(foregroundColor.getColorInt()));
avatarPreviewBackground.getDrawable().setColorFilter(new SimpleColorFilter(avatarColor.colorInt()));
binding.avatarPlaceholder.getDrawable().setColorFilter(new SimpleColorFilter(foregroundColor.getColorInt()));
binding.avatarBackground.getDrawable().setColorFilter(new SimpleColorFilter(avatarColor.colorInt()));
});
}
@@ -312,7 +303,7 @@ public class EditProfileFragment extends LoggingFragment {
}
private void handleFinishedLegacy() {
finishButton.cancelSpinning();
binding.finishButton.cancelSpinning();
if (nextIntent != null) startActivity(nextIntent);
controller.onProfileNameUploadCompleted();
@@ -323,16 +314,16 @@ public class EditProfileFragment extends LoggingFragment {
int[] finishButtonLocation = new int[2];
int[] revealLocation = new int[2];
finishButton.getLocationInWindow(finishButtonLocation);
reveal.getLocationInWindow(revealLocation);
binding.finishButton.getLocationInWindow(finishButtonLocation);
binding.reveal.getLocationInWindow(revealLocation);
int finishX = finishButtonLocation[0] - revealLocation[0];
int finishY = finishButtonLocation[1] - revealLocation[1];
finishX += finishButton.getWidth() / 2;
finishY += finishButton.getHeight() / 2;
finishX += binding.finishButton.getWidth() / 2;
finishY += binding.finishButton.getHeight() / 2;
Animator animation = ViewAnimationUtils.createCircularReveal(reveal, finishX, finishY, 0f, (float) Math.max(reveal.getWidth(), reveal.getHeight()));
Animator animation = ViewAnimationUtils.createCircularReveal(binding.reveal, finishX, finishY, 0f, (float) Math.max(binding.reveal.getWidth(), binding.reveal.getHeight()));
animation.setDuration(500);
animation.addListener(new Animator.AnimatorListener() {
@Override
@@ -340,7 +331,7 @@ public class EditProfileFragment extends LoggingFragment {
@Override
public void onAnimationEnd(Animator animation) {
finishButton.cancelSpinning();
binding.finishButton.cancelSpinning();
if (nextIntent != null && getActivity() != null) {
startActivity(nextIntent);
}
@@ -355,7 +346,7 @@ public class EditProfileFragment extends LoggingFragment {
public void onAnimationRepeat(Animator animation) {}
});
reveal.setVisibility(View.VISIBLE);
binding.reveal.setVisibility(View.VISIBLE);
animation.start();
}

View File

@@ -0,0 +1,46 @@
package org.thoughtcrime.securesms.profiles.edit.pnp
import androidx.fragment.app.viewModels
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.LifecycleDisposable
/**
* Allows the user to select who can see their phone number during registration.
*/
class WhoCanSeeMyPhoneNumberFragment : DSLSettingsFragment(titleId = R.string.WhoCanSeeMyPhoneNumberFragment__who_can_find_me_by_number) {
private val viewModel: WhoCanSeeMyPhoneNumberViewModel by viewModels()
private val lifecycleDisposable = LifecycleDisposable()
override fun bindAdapter(adapter: DSLSettingsAdapter) {
require(FeatureFlags.phoneNumberPrivacy())
lifecycleDisposable += viewModel.state.subscribe {
adapter.submitList(getConfiguration(it).toMappingModelList())
}
}
private fun getConfiguration(state: WhoCanSeeMyPhoneNumberState): DSLConfiguration {
return configure {
radioPref(
title = DSLSettingsText.from(R.string.PhoneNumberPrivacy_everyone),
summary = DSLSettingsText.from(R.string.WhoCanSeeMyPhoneNumberFragment__anyone_who_has),
isChecked = state == WhoCanSeeMyPhoneNumberState.EVERYONE,
onClick = { viewModel.onEveryoneCanSeeMyPhoneNumberSelected() }
)
radioPref(
title = DSLSettingsText.from(R.string.PhoneNumberPrivacy_nobody),
summary = DSLSettingsText.from(R.string.WhoCanSeeMyPhoneNumberFragment__nobody_on_signal),
isChecked = state == WhoCanSeeMyPhoneNumberState.NOBODY,
onClick = { viewModel.onNobodyCanSeeMyPhoneNumberSelected() }
)
}
}
}

View File

@@ -0,0 +1,6 @@
package org.thoughtcrime.securesms.profiles.edit.pnp
enum class WhoCanSeeMyPhoneNumberState {
EVERYONE,
NOBODY
}

View File

@@ -0,0 +1,27 @@
package org.thoughtcrime.securesms.profiles.edit.pnp
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import org.thoughtcrime.securesms.util.rx.RxStore
class WhoCanSeeMyPhoneNumberViewModel : ViewModel() {
private val store = RxStore(WhoCanSeeMyPhoneNumberState.EVERYONE)
private val disposables = CompositeDisposable()
val state: Flowable<WhoCanSeeMyPhoneNumberState> = store.stateFlowable.subscribeOn(AndroidSchedulers.mainThread())
fun onEveryoneCanSeeMyPhoneNumberSelected() {
store.update { WhoCanSeeMyPhoneNumberState.EVERYONE }
}
fun onNobodyCanSeeMyPhoneNumberSelected() {
store.update { WhoCanSeeMyPhoneNumberState.NOBODY }
}
override fun onCleared() {
disposables.clear()
}
}

View File

@@ -0,0 +1,32 @@
package org.thoughtcrime.securesms.profiles.manage
import org.thoughtcrime.securesms.databinding.CopyButtonBinding
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
/**
* Outlined button that allows the user to copy a piece of data.
*/
object CopyButton {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, BindingFactory(::ViewHolder, CopyButtonBinding::inflate))
}
class Model(
val text: CharSequence,
val onClick: (Model) -> Unit
) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = true
override fun areContentsTheSame(newItem: Model): Boolean = text == newItem.text
}
private class ViewHolder(binding: CopyButtonBinding) : BindingViewHolder<Model, CopyButtonBinding>(binding) {
override fun bind(model: Model) {
binding.root.text = model.text
binding.root.setOnClickListener { model.onClick(model) }
}
}
}

View File

@@ -7,33 +7,31 @@ import android.util.TypedValue;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.Toolbar;
import androidx.core.content.res.ResourcesCompat;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModelProviders;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.Navigation;
import com.airbnb.lottie.SimpleColorFilter;
import com.bumptech.glide.Glide;
import com.google.android.material.snackbar.Snackbar;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.AvatarPreviewActivity;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.avatar.Avatars;
import org.thoughtcrime.securesms.avatar.picker.AvatarPickerFragment;
import org.thoughtcrime.securesms.badges.BadgeImageView;
import org.thoughtcrime.securesms.badges.models.Badge;
import org.thoughtcrime.securesms.badges.self.none.BecomeASustainerFragment;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.components.emoji.EmojiUtil;
import org.thoughtcrime.securesms.databinding.ManageProfileFragmentBinding;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.profiles.ProfileName;
import org.thoughtcrime.securesms.profiles.manage.ManageProfileViewModel.AvatarState;
@@ -49,64 +47,42 @@ import java.util.Optional;
public class ManageProfileFragment extends LoggingFragment {
private static final String TAG = Log.tag(ManageProfileFragment.class);
private Toolbar toolbar;
private ImageView avatarView;
private ImageView avatarPlaceholderView;
private TextView profileNameView;
private View profileNameContainer;
private TextView usernameView;
private View usernameContainer;
private TextView aboutView;
private View aboutContainer;
private ImageView aboutEmojiView;
private AlertDialog avatarProgress;
private TextView avatarInitials;
private ImageView avatarBackground;
private View badgesContainer;
private BadgeImageView badgeView;
private ManageProfileViewModel viewModel;
private AlertDialog avatarProgress;
private ManageProfileViewModel viewModel;
private ManageProfileFragmentBinding binding;
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.manage_profile_fragment, container, false);
binding = ManageProfileFragmentBinding.inflate(inflater, container, false);
return binding.getRoot();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
this.toolbar = view.findViewById(R.id.toolbar);
this.avatarView = view.findViewById(R.id.manage_profile_avatar);
this.avatarPlaceholderView = view.findViewById(R.id.manage_profile_avatar_placeholder);
this.profileNameView = view.findViewById(R.id.manage_profile_name);
this.profileNameContainer = view.findViewById(R.id.manage_profile_name_container);
this.usernameView = view.findViewById(R.id.manage_profile_username);
this.usernameContainer = view.findViewById(R.id.manage_profile_username_container);
this.aboutView = view.findViewById(R.id.manage_profile_about);
this.aboutContainer = view.findViewById(R.id.manage_profile_about_container);
this.aboutEmojiView = view.findViewById(R.id.manage_profile_about_icon);
this.avatarInitials = view.findViewById(R.id.manage_profile_avatar_initials);
this.avatarBackground = view.findViewById(R.id.manage_profile_avatar_background);
this.badgesContainer = view.findViewById(R.id.manage_profile_badges_container);
this.badgeView = view.findViewById(R.id.manage_profile_badge);
new UsernameEditFragment.ResultContract().registerForResult(getParentFragmentManager(), getViewLifecycleOwner(), isUsernameCreated -> {
Snackbar.make(view, R.string.ManageProfileFragment__username_created, Snackbar.LENGTH_SHORT).show();
});
UsernameShareBottomSheet.ResultContract.INSTANCE.registerForResult(getParentFragmentManager(), getViewLifecycleOwner(), isCopiedToClipboard -> {
Snackbar.make(view, R.string.ManageProfileFragment__username_copied, Snackbar.LENGTH_SHORT).show();
});
initializeViewModel();
this.toolbar.setNavigationOnClickListener(v -> requireActivity().finish());
binding.toolbar.setNavigationOnClickListener(v -> requireActivity().finish());
View editAvatar = view.findViewById(R.id.manage_profile_edit_photo);
editAvatar.setOnClickListener(v -> onEditAvatarClicked());
binding.manageProfileEditPhoto.setOnClickListener(v -> onEditAvatarClicked());
this.profileNameContainer.setOnClickListener(v -> {
binding.manageProfileNameContainer.setOnClickListener(v -> {
SafeNavigation.safeNavigate(Navigation.findNavController(v), ManageProfileFragmentDirections.actionManageProfileName());
});
this.usernameContainer.setOnClickListener(v -> {
binding.manageProfileUsernameContainer.setOnClickListener(v -> {
SafeNavigation.safeNavigate(Navigation.findNavController(v), ManageProfileFragmentDirections.actionManageUsername());
});
this.aboutContainer.setOnClickListener(v -> {
binding.manageProfileAboutContainer.setOnClickListener(v -> {
SafeNavigation.safeNavigate(Navigation.findNavController(v), ManageProfileFragmentDirections.actionManageAbout());
});
@@ -119,6 +95,7 @@ public class ManageProfileFragment extends LoggingFragment {
}
});
EmojiTextView avatarInitials = binding.manageProfileAvatarInitials;
avatarInitials.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
if (avatarInitials.length() > 0) {
updateInitials(avatarInitials.getText().toString());
@@ -126,7 +103,7 @@ public class ManageProfileFragment extends LoggingFragment {
});
if (FeatureFlags.donorBadges()) {
badgesContainer.setOnClickListener(v -> {
binding.manageProfileBadgesContainer.setOnClickListener(v -> {
if (Recipient.self().getBadges().isEmpty()) {
BecomeASustainerFragment.show(getParentFragmentManager());
} else {
@@ -134,17 +111,27 @@ public class ManageProfileFragment extends LoggingFragment {
}
});
} else {
badgesContainer.setVisibility(View.GONE);
binding.manageProfileBadgesContainer.setVisibility(View.GONE);
}
avatarView.setOnClickListener(v -> {
binding.manageProfileAvatar.setOnClickListener(v -> {
startActivity(AvatarPreviewActivity.intentFromRecipientId(requireContext(), Recipient.self().getId()),
AvatarPreviewActivity.createTransitionBundle(requireActivity(), avatarView));
AvatarPreviewActivity.createTransitionBundle(requireActivity(), binding.manageProfileAvatar));
});
binding.manageProfileUsernameShare.setOnClickListener(v -> {
SafeNavigation.safeNavigate(Navigation.findNavController(v), ManageProfileFragmentDirections.actionManageProfileFragmentToShareUsernameDialog());
});
}
@Override
public void onDestroyView() {
super.onDestroyView();
binding = null;
}
private void initializeViewModel() {
viewModel = ViewModelProviders.of(this, new ManageProfileViewModel.Factory()).get(ManageProfileViewModel.class);
viewModel = new ViewModelProvider(this, new ManageProfileViewModel.Factory()).get(ManageProfileViewModel.class);
LiveData<Optional<byte[]>> avatarImage = Transformations.map(LiveDataUtil.distinctUntilChanged(viewModel.getAvatar(), (b1, b2) -> Arrays.equals(b1.getAvatar(), b2.getAvatar())),
b -> Optional.ofNullable(b.getAvatar()));
@@ -160,7 +147,7 @@ public class ManageProfileFragment extends LoggingFragment {
if (viewModel.shouldShowUsername()) {
viewModel.getUsername().observe(getViewLifecycleOwner(), this::presentUsername);
} else {
usernameContainer.setVisibility(View.GONE);
binding.manageProfileUsernameContainer.setVisibility(View.GONE);
}
}
@@ -169,9 +156,9 @@ public class ManageProfileFragment extends LoggingFragment {
Glide.with(this)
.load(avatarData.get())
.circleCrop()
.into(avatarView);
.into(binding.manageProfileAvatar);
} else {
Glide.with(this).load((Drawable) null).into(avatarView);
Glide.with(this).load((Drawable) null).into(binding.manageProfileAvatar);
}
}
@@ -180,21 +167,21 @@ public class ManageProfileFragment extends LoggingFragment {
CharSequence initials = NameUtil.getAbbreviation(avatarState.getSelf().getDisplayName(requireContext()));
Avatars.ForegroundColor foregroundColor = Avatars.getForegroundColor(avatarState.getSelf().getAvatarColor());
avatarBackground.setColorFilter(new SimpleColorFilter(avatarState.getSelf().getAvatarColor().colorInt()));
avatarPlaceholderView.setColorFilter(new SimpleColorFilter(foregroundColor.getColorInt()));
avatarInitials.setTextColor(foregroundColor.getColorInt());
binding.manageProfileAvatarBackground.setColorFilter(new SimpleColorFilter(avatarState.getSelf().getAvatarColor().colorInt()));
binding.manageProfileAvatarPlaceholder.setColorFilter(new SimpleColorFilter(foregroundColor.getColorInt()));
binding.manageProfileAvatarInitials.setTextColor(foregroundColor.getColorInt());
if (TextUtils.isEmpty(initials)) {
avatarPlaceholderView.setVisibility(View.VISIBLE);
avatarInitials.setVisibility(View.GONE);
binding.manageProfileAvatarPlaceholder.setVisibility(View.VISIBLE);
binding.manageProfileAvatarInitials.setVisibility(View.GONE);
} else {
updateInitials(initials.toString());
avatarPlaceholderView.setVisibility(View.GONE);
avatarInitials.setVisibility(View.VISIBLE);
binding.manageProfileAvatarPlaceholder.setVisibility(View.GONE);
binding.manageProfileAvatarInitials.setVisibility(View.VISIBLE);
}
} else {
avatarPlaceholderView.setVisibility(View.GONE);
avatarInitials.setVisibility(View.GONE);
binding.manageProfileAvatarPlaceholder.setVisibility(View.GONE);
binding.manageProfileAvatarInitials.setVisibility(View.GONE);
}
if (avatarProgress == null && avatarState.getLoadingState() == ManageProfileViewModel.LoadingState.LOADING) {
@@ -205,53 +192,59 @@ public class ManageProfileFragment extends LoggingFragment {
}
private void updateInitials(String initials) {
avatarInitials.setTextSize(TypedValue.COMPLEX_UNIT_PX, Avatars.getTextSizeForLength(requireContext(), initials, avatarInitials.getMeasuredWidth() * 0.8f, avatarInitials.getMeasuredWidth() * 0.45f));
avatarInitials.setText(initials);
binding.manageProfileAvatarInitials.setTextSize(TypedValue.COMPLEX_UNIT_PX,
Avatars.getTextSizeForLength(requireContext(),
initials,
binding.manageProfileAvatarInitials.getMeasuredWidth() * 0.8f,
binding.manageProfileAvatarInitials.getMeasuredWidth() * 0.45f));
binding.manageProfileAvatarInitials.setText(initials);
}
private void presentProfileName(@Nullable ProfileName profileName) {
if (profileName == null || profileName.isEmpty()) {
profileNameView.setText(R.string.ManageProfileFragment_profile_name);
binding.manageProfileName.setText(R.string.ManageProfileFragment_profile_name);
} else {
profileNameView.setText(profileName.toString());
binding.manageProfileName.setText(profileName.toString());
}
}
private void presentUsername(@Nullable String username) {
if (username == null || username.isEmpty()) {
usernameView.setText(R.string.ManageProfileFragment_username);
binding.manageProfileUsername.setText(R.string.ManageProfileFragment_username);
binding.manageProfileUsernameShare.setVisibility(View.GONE);
} else {
usernameView.setText(username);
binding.manageProfileUsername.setText(username);
binding.manageProfileUsernameShare.setVisibility(View.VISIBLE);
}
}
private void presentAbout(@Nullable String about) {
if (about == null || about.isEmpty()) {
aboutView.setText(R.string.ManageProfileFragment_about);
binding.manageProfileAbout.setText(R.string.ManageProfileFragment_about);
} else {
aboutView.setText(about);
binding.manageProfileAbout.setText(about);
}
}
private void presentAboutEmoji(@NonNull String aboutEmoji) {
if (aboutEmoji == null || aboutEmoji.isEmpty()) {
aboutEmojiView.setImageDrawable(ResourcesCompat.getDrawable(getResources(), R.drawable.ic_compose_24, null));
binding.manageProfileAboutIcon.setImageDrawable(ResourcesCompat.getDrawable(getResources(), R.drawable.ic_compose_24, null));
} else {
Drawable emoji = EmojiUtil.convertToDrawable(requireContext(), aboutEmoji);
if (emoji != null) {
aboutEmojiView.setImageDrawable(emoji);
binding.manageProfileAboutIcon.setImageDrawable(emoji);
} else {
aboutEmojiView.setImageDrawable(ResourcesCompat.getDrawable(getResources(), R.drawable.ic_compose_24, null));
binding.manageProfileAboutIcon.setImageDrawable(ResourcesCompat.getDrawable(getResources(), R.drawable.ic_compose_24, null));
}
}
}
private void presentBadge(@NonNull Optional<Badge> badge) {
if (badge.isPresent() && badge.get().getVisible() && !badge.get().isExpired()) {
badgeView.setBadge(badge.orElse(null));
binding.manageProfileBadge.setBadge(badge.orElse(null));
} else {
badgeView.setBadge(null);
binding.manageProfileBadge.setBadge(null);
}
}

View File

@@ -0,0 +1,28 @@
package org.thoughtcrime.securesms.profiles.manage
import org.thoughtcrime.securesms.databinding.ShareButtonBinding
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
object ShareButton {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, BindingFactory(::ViewHolder, ShareButtonBinding::inflate))
}
class Model(
val text: CharSequence,
val onClick: (Model) -> Unit
) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = true
override fun areContentsTheSame(newItem: Model): Boolean = text == newItem.text
}
private class ViewHolder(binding: ShareButtonBinding) : BindingViewHolder<Model, ShareButtonBinding>(binding) {
override fun bind(model: Model) {
binding.shareButton.setOnClickListener { model.onClick(model) }
}
}
}

View File

@@ -1,38 +1,56 @@
package org.thoughtcrime.securesms.profiles.manage;
import android.content.res.ColorStateList;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.EditorInfo;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.Toolbar;
import androidx.lifecycle.ViewModelProviders;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.Navigation;
import androidx.navigation.fragment.NavHostFragment;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.textfield.TextInputLayout;
import org.signal.core.util.DimensionUnit;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
import org.thoughtcrime.securesms.databinding.UsernameEditFragmentBinding;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.FragmentResultContract;
import org.thoughtcrime.securesms.util.LifecycleDisposable;
import org.thoughtcrime.securesms.util.UsernameUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.views.CircularProgressMaterialButton;
import org.thoughtcrime.securesms.util.views.LearnMoreTextView;
import java.util.Objects;
import java.util.function.Consumer;
public class UsernameEditFragment extends LoggingFragment {
private static final float DISABLED_ALPHA = 0.5f;
private UsernameEditViewModel viewModel;
private EditText usernameInput;
private TextView usernameSubtext;
private CircularProgressMaterialButton submitButton;
private CircularProgressMaterialButton deleteButton;
private UsernameEditViewModel viewModel;
private UsernameEditFragmentBinding binding;
private ImageView suffixProgress;
private LifecycleDisposable lifecycleDisposable;
public static UsernameEditFragment newInstance() {
return new UsernameEditFragment();
@@ -40,46 +58,97 @@ public class UsernameEditFragment extends LoggingFragment {
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.username_edit_fragment, container, false);
binding = UsernameEditFragmentBinding.inflate(inflater, container, false);
return binding.getRoot();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
usernameInput = view.findViewById(R.id.username_text);
usernameSubtext = view.findViewById(R.id.username_subtext);
submitButton = view.findViewById(R.id.username_submit_button);
deleteButton = view.findViewById(R.id.username_delete_button);
binding.toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(view).popBackStack());
view.<Toolbar>findViewById(R.id.toolbar)
.setNavigationOnClickListener(v -> Navigation.findNavController(view)
.popBackStack());
binding.usernameTextWrapper.setErrorIconDrawable(null);
viewModel = ViewModelProviders.of(this, new UsernameEditViewModel.Factory()).get(UsernameEditViewModel.class);
lifecycleDisposable = new LifecycleDisposable();
lifecycleDisposable.bindTo(getViewLifecycleOwner());
viewModel.getUiState().observe(getViewLifecycleOwner(), this::onUiStateChanged);
viewModel = new ViewModelProvider(this, new UsernameEditViewModel.Factory()).get(UsernameEditViewModel.class);
lifecycleDisposable.add(viewModel.getUiState().subscribe(this::onUiStateChanged));
viewModel.getEvents().observe(getViewLifecycleOwner(), this::onEvent);
submitButton.setOnClickListener(v -> viewModel.onUsernameSubmitted(usernameInput.getText().toString()));
deleteButton.setOnClickListener(v -> viewModel.onUsernameDeleted());
binding.usernameSubmitButton.setOnClickListener(v -> viewModel.onUsernameSubmitted(binding.usernameText.getText().toString()));
binding.usernameDeleteButton.setOnClickListener(v -> viewModel.onUsernameDeleted());
usernameInput.setText(Recipient.self().getUsername().orElse(null));
usernameInput.addTextChangedListener(new SimpleTextWatcher() {
binding.usernameText.setText(Recipient.self().getUsername().orElse(null));
binding.usernameText.addTextChangedListener(new SimpleTextWatcher() {
@Override
public void onTextChanged(String text) {
viewModel.onUsernameUpdated(text);
}
});
usernameInput.setOnEditorActionListener((v, actionId, event) -> {
binding.usernameText.setOnEditorActionListener((v, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_DONE) {
viewModel.onUsernameSubmitted(usernameInput.getText().toString());
viewModel.onUsernameSubmitted(binding.usernameText.getText().toString());
return true;
}
return false;
});
binding.usernameDescription.setLinkColor(ContextCompat.getColor(requireContext(), R.color.signal_colorPrimary));
binding.usernameDescription.setLearnMoreVisible(true);
binding.usernameDescription.setOnLinkClickListener(this::onLearnMore);
initializeSuffix();
ViewUtil.focusAndShowKeyboard(binding.usernameText);
}
private void initializeSuffix() {
TextView suffixTextView = binding.usernameTextWrapper.getSuffixTextView();
Drawable pipe = Objects.requireNonNull(ContextCompat.getDrawable(requireContext(), R.drawable.pipe_divider));
pipe.setBounds(0, 0, (int) DimensionUnit.DP.toPixels(1f), (int) DimensionUnit.DP.toPixels(20f));
suffixTextView.setCompoundDrawablesRelative(pipe, null, null, null);
LinearLayout suffixParent = (LinearLayout) suffixTextView.getParent();
LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT);
ViewUtil.setLeftMargin(suffixTextView, (int) DimensionUnit.DP.toPixels(16f));
binding.usernameTextWrapper.getSuffixTextView().setCompoundDrawablePadding((int) DimensionUnit.DP.toPixels(16f));
layoutParams.topMargin = suffixTextView.getPaddingTop();
layoutParams.bottomMargin = suffixTextView.getPaddingBottom();
suffixProgress = new ImageView(requireContext());
suffixProgress.setImageDrawable(UsernameSuffix.getInProgressDrawable(requireContext()));
suffixParent.addView(suffixProgress, 0, layoutParams);
suffixTextView.setOnClickListener(this::onLearnMore);
}
@Override
public void onDestroyView() {
super.onDestroyView();
binding = null;
suffixProgress = null;
}
private void onLearnMore(@Nullable View unused) {
new MaterialAlertDialogBuilder(requireContext())
.setTitle(new StringBuilder("#\n").append(getString(R.string.UsernameEditFragment__what_is_this_number)))
.setMessage(R.string.UsernameEditFragment__these_digits_help_keep)
.setPositiveButton(android.R.string.ok, (dialog, which) -> {})
.show();
}
private void onUiStateChanged(@NonNull UsernameEditViewModel.State state) {
EditText usernameInput = binding.usernameText;
CircularProgressMaterialButton submitButton = binding.usernameSubmitButton;
CircularProgressMaterialButton deleteButton = binding.usernameDeleteButton;
TextInputLayout usernameInputWrapper = binding.usernameTextWrapper;
usernameInput.setEnabled(true);
presentSuffix(state.getUsernameSuffix());
switch (state.getButtonState()) {
case SUBMIT:
@@ -128,39 +197,57 @@ public class UsernameEditFragment extends LoggingFragment {
switch (state.getUsernameStatus()) {
case NONE:
usernameSubtext.setText("");
usernameInputWrapper.setError(null);
break;
case TOO_SHORT:
case TOO_LONG:
usernameSubtext.setText(getResources().getString(R.string.UsernameEditFragment_usernames_must_be_between_a_and_b_characters, UsernameUtil.MIN_LENGTH, UsernameUtil.MAX_LENGTH));
usernameSubtext.setTextColor(getResources().getColor(R.color.core_red));
usernameInputWrapper.setError(getResources().getString(R.string.UsernameEditFragment_usernames_must_be_between_a_and_b_characters, UsernameUtil.MIN_LENGTH, UsernameUtil.MAX_LENGTH));
usernameInputWrapper.setErrorTextColor(ColorStateList.valueOf(getResources().getColor(R.color.signal_colorError)));
break;
case INVALID_CHARACTERS:
usernameSubtext.setText(R.string.UsernameEditFragment_usernames_can_only_include);
usernameSubtext.setTextColor(getResources().getColor(R.color.core_red));
usernameInputWrapper.setError(getResources().getString(R.string.UsernameEditFragment_usernames_can_only_include));
usernameInputWrapper.setErrorTextColor(ColorStateList.valueOf(getResources().getColor(R.color.signal_colorError)));
break;
case CANNOT_START_WITH_NUMBER:
usernameSubtext.setText(R.string.UsernameEditFragment_usernames_cannot_begin_with_a_number);
usernameSubtext.setTextColor(getResources().getColor(R.color.core_red));
usernameInputWrapper.setError(getResources().getString(R.string.UsernameEditFragment_usernames_cannot_begin_with_a_number));
usernameInputWrapper.setErrorTextColor(ColorStateList.valueOf(getResources().getColor(R.color.signal_colorError)));
break;
case INVALID_GENERIC:
usernameSubtext.setText(R.string.UsernameEditFragment_username_is_invalid);
usernameSubtext.setTextColor(getResources().getColor(R.color.core_red));
usernameInputWrapper.setError(getResources().getString(R.string.UsernameEditFragment_username_is_invalid));
usernameInputWrapper.setErrorTextColor(ColorStateList.valueOf(getResources().getColor(R.color.signal_colorError)));
break;
case TAKEN:
usernameSubtext.setText(R.string.UsernameEditFragment_this_username_is_taken);
usernameSubtext.setTextColor(getResources().getColor(R.color.core_red));
usernameInputWrapper.setError(getResources().getString(R.string.UsernameEditFragment_this_username_is_taken));
usernameInputWrapper.setErrorTextColor(ColorStateList.valueOf(getResources().getColor(R.color.signal_colorError)));
break;
case AVAILABLE:
usernameSubtext.setText(R.string.UsernameEditFragment_this_username_is_available);
usernameSubtext.setTextColor(getResources().getColor(R.color.core_green));
usernameInputWrapper.setError(getResources().getString(R.string.UsernameEditFragment_this_username_is_available));
usernameInputWrapper.setErrorTextColor(ColorStateList.valueOf(getResources().getColor(R.color.signal_accent_green)));
break;
}
}
private void presentSuffix(@NonNull UsernameSuffix usernameSuffix) {
binding.usernameTextWrapper.setSuffixText(usernameSuffix.getCharSequence());
boolean isInProgress = usernameSuffix.isInProgress();
if (isInProgress) {
suffixProgress.setVisibility(View.VISIBLE);
} else {
suffixProgress.setVisibility(View.GONE);
}
}
private void onEvent(@NonNull UsernameEditViewModel.Event event) {
switch (event) {
case SUBMIT_SUCCESS:
ResultContract.setUsernameCreated(getParentFragmentManager());
Toast.makeText(requireContext(), R.string.UsernameEditFragment_successfully_set_username, Toast.LENGTH_SHORT).show();
NavHostFragment.findNavController(this).popBackStack();
break;
@@ -179,4 +266,23 @@ public class UsernameEditFragment extends LoggingFragment {
break;
}
}
static class ResultContract extends FragmentResultContract<Boolean> {
private static final String REQUEST_KEY = "username_created";
protected ResultContract() {
super(REQUEST_KEY);
}
static void setUsernameCreated(@NonNull FragmentManager fragmentManager) {
Bundle bundle = new Bundle();
bundle.putBoolean(REQUEST_KEY, true);
fragmentManager.setFragmentResult(REQUEST_KEY, bundle);
}
@Override
protected Boolean getResult(@NonNull Bundle bundle) {
return bundle.getBoolean(REQUEST_KEY, false);
}
}
}

View File

@@ -5,7 +5,6 @@ import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
@@ -16,81 +15,80 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.thoughtcrime.securesms.util.UsernameUtil;
import org.thoughtcrime.securesms.util.UsernameUtil.InvalidReason;
import org.thoughtcrime.securesms.util.rx.RxStore;
import java.util.Optional;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.schedulers.Schedulers;
class UsernameEditViewModel extends ViewModel {
private static final String TAG = Log.tag(UsernameEditViewModel.class);
private final Application application;
private final MutableLiveData<State> uiState;
private final SingleLiveEvent<Event> events;
private final UsernameEditRepository repo;
private final RxStore<State> uiState;
private UsernameEditViewModel() {
this.application = ApplicationDependencies.getApplication();
this.repo = new UsernameEditRepository();
this.uiState = new MutableLiveData<>();
this.uiState = new RxStore<>(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, UsernameSuffix.NONE), Schedulers.computation());
this.events = new SingleLiveEvent<>();
uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE));
}
void onUsernameUpdated(@NonNull String username) {
if (TextUtils.isEmpty(username) && Recipient.self().getUsername().isPresent()) {
uiState.setValue(new State(ButtonState.DELETE, UsernameStatus.NONE));
return;
}
uiState.update(state -> {
if (TextUtils.isEmpty(username) && Recipient.self().getUsername().isPresent()) {
return new State(ButtonState.DELETE, UsernameStatus.NONE, state.usernameSuffix);
}
if (username.equals(Recipient.self().getUsername().orElse(null))) {
uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE));
return;
}
if (username.equals(Recipient.self().getUsername().orElse(null))) {
return new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, state.usernameSuffix);
}
Optional<InvalidReason> invalidReason = UsernameUtil.checkUsername(username);
Optional<InvalidReason> invalidReason = UsernameUtil.checkUsername(username);
if (invalidReason.isPresent()) {
uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, mapUsernameError(invalidReason.get())));
return;
}
uiState.setValue(new State(ButtonState.SUBMIT, UsernameStatus.NONE));
return invalidReason.map(reason -> new State(ButtonState.SUBMIT_DISABLED, mapUsernameError(reason), state.usernameSuffix))
.orElseGet(() -> new State(ButtonState.SUBMIT, UsernameStatus.NONE, state.usernameSuffix));
});
}
void onUsernameSubmitted(@NonNull String username) {
if (username.equals(Recipient.self().getUsername().orElse(null))) {
uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE));
uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, state.usernameSuffix));
return;
}
Optional<InvalidReason> invalidReason = UsernameUtil.checkUsername(username);
if (invalidReason.isPresent()) {
uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, mapUsernameError(invalidReason.get())));
uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, mapUsernameError(invalidReason.get()), state.usernameSuffix));
return;
}
uiState.setValue(new State(ButtonState.SUBMIT_LOADING, UsernameStatus.NONE));
uiState.update(state -> new State(ButtonState.SUBMIT_LOADING, UsernameStatus.NONE, state.usernameSuffix));
repo.setUsername(username, (result) -> {
ThreadUtil.runOnMain(() -> {
switch (result) {
case SUCCESS:
uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE));
uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, state.usernameSuffix));
events.postValue(Event.SUBMIT_SUCCESS);
break;
case USERNAME_INVALID:
uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.INVALID_GENERIC));
uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.INVALID_GENERIC, state.usernameSuffix));
events.postValue(Event.SUBMIT_FAIL_INVALID);
break;
case USERNAME_UNAVAILABLE:
uiState.setValue(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.TAKEN));
uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.TAKEN, state.usernameSuffix));
events.postValue(Event.SUBMIT_FAIL_TAKEN);
break;
case NETWORK_ERROR:
uiState.setValue(new State(ButtonState.SUBMIT, UsernameStatus.NONE));
uiState.update(state -> new State(ButtonState.SUBMIT, UsernameStatus.NONE, state.usernameSuffix));
events.postValue(Event.NETWORK_FAILURE);
break;
}
@@ -99,17 +97,17 @@ class UsernameEditViewModel extends ViewModel {
}
void onUsernameDeleted() {
uiState.setValue(new State(ButtonState.DELETE_LOADING, UsernameStatus.NONE));
uiState.update(state -> new State(ButtonState.DELETE_LOADING, UsernameStatus.NONE, state.usernameSuffix));
repo.deleteUsername((result) -> {
ThreadUtil.runOnMain(() -> {
switch (result) {
case SUCCESS:
uiState.postValue(new State(ButtonState.DELETE_DISABLED, UsernameStatus.NONE));
uiState.update(state -> new State(ButtonState.DELETE_DISABLED, UsernameStatus.NONE, state.usernameSuffix));
events.postValue(Event.DELETE_SUCCESS);
break;
case NETWORK_ERROR:
uiState.postValue(new State(ButtonState.DELETE, UsernameStatus.NONE));
uiState.update(state -> new State(ButtonState.DELETE, UsernameStatus.NONE, state.usernameSuffix));
events.postValue(Event.NETWORK_FAILURE);
break;
}
@@ -117,8 +115,8 @@ class UsernameEditViewModel extends ViewModel {
});
}
@NonNull LiveData<State> getUiState() {
return uiState;
@NonNull Flowable<State> getUiState() {
return uiState.getStateFlowable().observeOn(AndroidSchedulers.mainThread());
}
@NonNull LiveData<Event> getEvents() {
@@ -138,12 +136,15 @@ class UsernameEditViewModel extends ViewModel {
static class State {
private final ButtonState buttonState;
private final UsernameStatus usernameStatus;
private final UsernameSuffix usernameSuffix;
private State(@NonNull ButtonState buttonState,
@NonNull UsernameStatus usernameStatus)
@NonNull UsernameStatus usernameStatus,
@NonNull UsernameSuffix usernameSuffix)
{
this.buttonState = buttonState;
this.usernameStatus = usernameStatus;
this.usernameSuffix = usernameSuffix;
}
@NonNull ButtonState getButtonState() {
@@ -153,6 +154,10 @@ class UsernameEditViewModel extends ViewModel {
@NonNull UsernameStatus getUsernameStatus() {
return usernameStatus;
}
@NonNull UsernameSuffix getUsernameSuffix() {
return usernameSuffix;
}
}
enum UsernameStatus {

View File

@@ -0,0 +1,120 @@
package org.thoughtcrime.securesms.profiles.manage
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.core.app.ShareCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.setFragmentResult
import androidx.navigation.fragment.findNavController
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.FragmentResultContract
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.Util
/**
* Allows the user to either share their username directly or to copy it to their clipboard.
*/
class UsernameShareBottomSheet : DSLSettingsBottomSheetFragment() {
companion object {
private const val REQUEST_KEY = "copy_username"
}
private val lifecycleDisposable = LifecycleDisposable()
override fun bindAdapter(adapter: DSLSettingsAdapter) {
CopyButton.register(adapter)
ShareButton.register(adapter)
lifecycleDisposable += Recipient.observable(Recipient.self().id).subscribe {
adapter.submitList(getConfiguration(it).toMappingModelList())
}
}
private fun getConfiguration(recipient: Recipient): DSLConfiguration {
return configure {
noPadTextPref(
title = DSLSettingsText.from(
R.string.UsernameShareBottomSheet__copy_or_share_a_username_link,
DSLSettingsText.TextAppearanceModifier(R.style.Signal_Text_BodyMedium),
DSLSettingsText.CenterModifier,
DSLSettingsText.ColorModifier(
ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant),
)
)
)
space(DimensionUnit.DP.toPixels(32f).toInt())
val username = recipient.username.get()
customPref(
CopyButton.Model(
text = username,
onClick = {
copyToClipboard(it)
}
)
)
space(DimensionUnit.DP.toPixels(20f).toInt())
customPref(
CopyButton.Model(
text = getString(R.string.signal_me_url, username),
onClick = {
copyToClipboard(it)
}
)
)
space(DimensionUnit.DP.toPixels(24f).toInt())
customPref(
ShareButton.Model(
text = getString(R.string.signal_me_url, username),
onClick = {
openShareSheet(it.text)
}
)
)
space(DimensionUnit.DP.toPixels(18f).toInt())
}
}
private fun copyToClipboard(model: CopyButton.Model) {
Util.copyToClipboard(requireContext(), model.text)
setFragmentResult(REQUEST_KEY, Bundle().apply { putBoolean(REQUEST_KEY, true) })
findNavController().popBackStack()
}
private fun openShareSheet(charSequence: CharSequence) {
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setText(charSequence)
.setType(mimeType)
.createChooserIntent()
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
try {
startActivity(shareIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.MediaPreviewActivity_cant_find_an_app_able_to_share_this_media, Toast.LENGTH_LONG).show()
}
}
object ResultContract : FragmentResultContract<Boolean>(REQUEST_KEY) {
override fun getResult(bundle: Bundle): Boolean {
return bundle.getBoolean(REQUEST_KEY, false)
}
}
}

View File

@@ -0,0 +1,43 @@
package org.thoughtcrime.securesms.profiles.manage
import android.content.Context
import androidx.core.content.ContextCompat
import com.google.android.material.progressindicator.CircularProgressIndicatorSpec
import com.google.android.material.progressindicator.IndeterminateDrawable
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
/**
* Describes the state of the username suffix, which is a spanned CharSequence.
*/
data class UsernameSuffix(
val charSequence: CharSequence?
) {
val isInProgress = charSequence == null
companion object {
@JvmField
val LOADING = UsernameSuffix(null)
@JvmField
val NONE = UsernameSuffix("")
@JvmStatic
fun fromCode(code: Int) = UsernameSuffix("#$code")
@JvmStatic
fun getInProgressDrawable(context: Context): IndeterminateDrawable<CircularProgressIndicatorSpec> {
val progressIndicatorSpec = CircularProgressIndicatorSpec(context, null).apply {
indicatorInset = 0
indicatorSize = DimensionUnit.DP.toPixels(16f).toInt()
trackColor = ContextCompat.getColor(context, R.color.signal_colorOnSurfaceVariant)
trackThickness = DimensionUnit.DP.toPixels(1f).toInt()
}
return IndeterminateDrawable.createCircularDrawable(context, progressIndicatorSpec).apply {
setBounds(0, 0, DimensionUnit.DP.toPixels(16f).toInt(), DimensionUnit.DP.toPixels(16f).toInt())
}
}
}
}

View File

@@ -0,0 +1,23 @@
package org.thoughtcrime.securesms.util
import android.os.Bundle
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import java.util.function.Consumer
/**
* Generic Fragment result contract.
*/
abstract class FragmentResultContract<T> protected constructor(private val resultKey: String) {
protected abstract fun getResult(bundle: Bundle): T
fun registerForResult(fragmentManager: FragmentManager, lifecycleOwner: LifecycleOwner, consumer: Consumer<T>) {
fragmentManager.setFragmentResultListener(resultKey, lifecycleOwner) { key, bundle ->
if (key == resultKey) {
val result = getResult(bundle)
consumer.accept(result)
}
}
}
}

View File

@@ -0,0 +1,20 @@
package org.thoughtcrime.securesms.util.adapter.mapping
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.viewbinding.ViewBinding
/**
* Allows ViewHolders to be generated with a ViewBinding. Intended usage is as follows:
*
* BindingFactory(::MyBindingViewHolder, MyBinding::inflate)
*/
class BindingFactory<T : MappingModel<T>, B : ViewBinding>(
private val creator: (B) -> BindingViewHolder<T, B>,
private val inflater: (LayoutInflater, ViewGroup, Boolean) -> B
) : Factory<T> {
override fun createViewHolder(parent: ViewGroup): MappingViewHolder<T> {
val binding = inflater(LayoutInflater.from(parent.context), parent, false)
return creator(binding)
}
}

View File

@@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.util.adapter.mapping
import androidx.viewbinding.ViewBinding
/**
* A ViewHolder which is populated with a ViewBinding, used in conjunction with BindingFactory
*/
abstract class BindingViewHolder<T, B : ViewBinding>(protected val binding: B) : MappingViewHolder<T>(binding.root)

View File

@@ -13,7 +13,7 @@ import io.reactivex.rxjava3.subjects.PublishSubject
*/
class RxStore<T : Any>(
defaultValue: T,
private val scheduler: Scheduler = Schedulers.computation()
scheduler: Scheduler = Schedulers.computation()
) {
private val behaviorProcessor = BehaviorProcessor.createDefault(defaultValue)