From 28310a88f573ca1096b3e31e7060af6a5c36d7fc Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 16 Aug 2022 16:59:12 -0300 Subject: [PATCH] Username UX refresh. --- .../profiles/edit/EditProfileFragment.java | 159 +++-- .../pnp/WhoCanSeeMyPhoneNumberFragment.kt | 46 ++ .../edit/pnp/WhoCanSeeMyPhoneNumberState.kt | 6 + .../pnp/WhoCanSeeMyPhoneNumberViewModel.kt | 27 + .../securesms/profiles/manage/CopyButton.kt | 32 + .../manage/ManageProfileFragment.java | 145 +++-- .../securesms/profiles/manage/ShareButton.kt | 28 + .../profiles/manage/UsernameEditFragment.java | 178 ++++-- .../manage/UsernameEditViewModel.java | 71 ++- .../manage/UsernameShareBottomSheet.kt | 120 ++++ .../profiles/manage/UsernameSuffix.kt | 43 ++ .../securesms/util/FragmentResultContract.kt | 23 + .../util/adapter/mapping/BindingFactory.kt | 20 + .../util/adapter/mapping/BindingViewHolder.kt | 8 + .../thoughtcrime/securesms/util/rx/RxStore.kt | 2 +- app/src/main/res/drawable/pipe_divider.xml | 4 + app/src/main/res/layout/copy_button.xml | 22 + .../res/layout/manage_profile_fragment.xml | 577 +++++++++--------- .../res/layout/profile_create_fragment.xml | 158 +++-- app/src/main/res/layout/share_button.xml | 16 + .../res/layout/username_edit_fragment.xml | 104 ++-- app/src/main/res/navigation/edit_profile.xml | 14 + .../main/res/navigation/manage_profile.xml | 8 + app/src/main/res/values/strings.xml | 32 +- 24 files changed, 1254 insertions(+), 589 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/edit/pnp/WhoCanSeeMyPhoneNumberFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/edit/pnp/WhoCanSeeMyPhoneNumberState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/edit/pnp/WhoCanSeeMyPhoneNumberViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/manage/CopyButton.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ShareButton.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameShareBottomSheet.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameSuffix.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/FragmentResultContract.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/BindingFactory.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/BindingViewHolder.kt create mode 100644 app/src/main/res/drawable/pipe_divider.xml create mode 100644 app/src/main/res/layout/copy_button.xml create mode 100644 app/src/main/res/layout/share_button.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java index e949f1e92..046812292 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java @@ -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.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(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/pnp/WhoCanSeeMyPhoneNumberFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/pnp/WhoCanSeeMyPhoneNumberFragment.kt new file mode 100644 index 000000000..5c1425636 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/pnp/WhoCanSeeMyPhoneNumberFragment.kt @@ -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() } + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/pnp/WhoCanSeeMyPhoneNumberState.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/pnp/WhoCanSeeMyPhoneNumberState.kt new file mode 100644 index 000000000..f8e935b11 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/pnp/WhoCanSeeMyPhoneNumberState.kt @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.profiles.edit.pnp + +enum class WhoCanSeeMyPhoneNumberState { + EVERYONE, + NOBODY +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/pnp/WhoCanSeeMyPhoneNumberViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/pnp/WhoCanSeeMyPhoneNumberViewModel.kt new file mode 100644 index 000000000..a518d4e61 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/pnp/WhoCanSeeMyPhoneNumberViewModel.kt @@ -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 = store.stateFlowable.subscribeOn(AndroidSchedulers.mainThread()) + + fun onEveryoneCanSeeMyPhoneNumberSelected() { + store.update { WhoCanSeeMyPhoneNumberState.EVERYONE } + } + + fun onNobodyCanSeeMyPhoneNumberSelected() { + store.update { WhoCanSeeMyPhoneNumberState.NOBODY } + } + + override fun onCleared() { + disposables.clear() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/CopyButton.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/CopyButton.kt new file mode 100644 index 000000000..fe06d4229 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/CopyButton.kt @@ -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 { + override fun areItemsTheSame(newItem: Model): Boolean = true + + override fun areContentsTheSame(newItem: Model): Boolean = text == newItem.text + } + + private class ViewHolder(binding: CopyButtonBinding) : BindingViewHolder(binding) { + override fun bind(model: Model) { + binding.root.text = model.text + binding.root.setOnClickListener { model.onClick(model) } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java index 8086b2eac..3b646d2b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ManageProfileFragment.java @@ -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> 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) { 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); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ShareButton.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ShareButton.kt new file mode 100644 index 000000000..7ec4b6a13 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/ShareButton.kt @@ -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 { + override fun areItemsTheSame(newItem: Model): Boolean = true + + override fun areContentsTheSame(newItem: Model): Boolean = text == newItem.text + } + + private class ViewHolder(binding: ShareButtonBinding) : BindingViewHolder(binding) { + override fun bind(model: Model) { + binding.shareButton.setOnClickListener { model.onClick(model) } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragment.java index 11a577d20..68a4010b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragment.java @@ -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.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 { + 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); + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java index f74fe6465..69e2aebc0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java @@ -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 uiState; private final SingleLiveEvent events; private final UsernameEditRepository repo; + private final RxStore 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 = UsernameUtil.checkUsername(username); + Optional 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 = 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 getUiState() { - return uiState; + @NonNull Flowable getUiState() { + return uiState.getStateFlowable().observeOn(AndroidSchedulers.mainThread()); } @NonNull LiveData 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameShareBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameShareBottomSheet.kt new file mode 100644 index 000000000..8ae579b1b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameShareBottomSheet.kt @@ -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(REQUEST_KEY) { + override fun getResult(bundle: Bundle): Boolean { + return bundle.getBoolean(REQUEST_KEY, false) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameSuffix.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameSuffix.kt new file mode 100644 index 000000000..d53a6b457 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameSuffix.kt @@ -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 { + 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()) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FragmentResultContract.kt b/app/src/main/java/org/thoughtcrime/securesms/util/FragmentResultContract.kt new file mode 100644 index 000000000..b9ad31e49 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FragmentResultContract.kt @@ -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 protected constructor(private val resultKey: String) { + + protected abstract fun getResult(bundle: Bundle): T + + fun registerForResult(fragmentManager: FragmentManager, lifecycleOwner: LifecycleOwner, consumer: Consumer) { + fragmentManager.setFragmentResultListener(resultKey, lifecycleOwner) { key, bundle -> + if (key == resultKey) { + val result = getResult(bundle) + consumer.accept(result) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/BindingFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/BindingFactory.kt new file mode 100644 index 000000000..6d138e795 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/BindingFactory.kt @@ -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, B : ViewBinding>( + private val creator: (B) -> BindingViewHolder, + private val inflater: (LayoutInflater, ViewGroup, Boolean) -> B +) : Factory { + override fun createViewHolder(parent: ViewGroup): MappingViewHolder { + val binding = inflater(LayoutInflater.from(parent.context), parent, false) + return creator(binding) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/BindingViewHolder.kt b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/BindingViewHolder.kt new file mode 100644 index 000000000..a28118fd6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/BindingViewHolder.kt @@ -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(protected val binding: B) : MappingViewHolder(binding.root) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/rx/RxStore.kt b/app/src/main/java/org/thoughtcrime/securesms/util/rx/RxStore.kt index 6429158b2..a36ced2e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/rx/RxStore.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/rx/RxStore.kt @@ -13,7 +13,7 @@ import io.reactivex.rxjava3.subjects.PublishSubject */ class RxStore( defaultValue: T, - private val scheduler: Scheduler = Schedulers.computation() + scheduler: Scheduler = Schedulers.computation() ) { private val behaviorProcessor = BehaviorProcessor.createDefault(defaultValue) diff --git a/app/src/main/res/drawable/pipe_divider.xml b/app/src/main/res/drawable/pipe_divider.xml new file mode 100644 index 000000000..d117e7065 --- /dev/null +++ b/app/src/main/res/drawable/pipe_divider.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/copy_button.xml b/app/src/main/res/layout/copy_button.xml new file mode 100644 index 000000000..8cd6b5bdc --- /dev/null +++ b/app/src/main/res/layout/copy_button.xml @@ -0,0 +1,22 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/manage_profile_fragment.xml b/app/src/main/res/layout/manage_profile_fragment.xml index ef1eed9d8..2cd26c24b 100644 --- a/app/src/main/res/layout/manage_profile_fragment.xml +++ b/app/src/main/res/layout/manage_profile_fragment.xml @@ -1,297 +1,310 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="wrap_content"> - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/profile_create_fragment.xml b/app/src/main/res/layout/profile_create_fragment.xml index 18aa3b0ab..6385cc0f5 100644 --- a/app/src/main/res/layout/profile_create_fragment.xml +++ b/app/src/main/res/layout/profile_create_fragment.xml @@ -2,7 +2,6 @@ + + - + android:hint="@string/CreateProfileActivity_first_name_required"> + + + + - + android:hint="@string/CreateProfileActivity_last_name_optional"> + + + + + + + + + + + + + @@ -196,8 +272,8 @@ android:layout_marginEnd="32dp" android:layout_marginBottom="16dp" android:enabled="false" - app:materialThemeOverlay="@style/ThemeOverlay.Signal.CircularProgressIndicator.Primary" - app:circularProgressMaterialButton__label="@string/CreateProfileActivity_next" /> + app:circularProgressMaterialButton__label="@string/CreateProfileActivity_next" + app:materialThemeOverlay="@style/ThemeOverlay.Signal.CircularProgressIndicator.Primary" /> diff --git a/app/src/main/res/layout/share_button.xml b/app/src/main/res/layout/share_button.xml new file mode 100644 index 000000000..cf4659ddf --- /dev/null +++ b/app/src/main/res/layout/share_button.xml @@ -0,0 +1,16 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/username_edit_fragment.xml b/app/src/main/res/layout/username_edit_fragment.xml index cd4bee503..65d89d3b0 100644 --- a/app/src/main/res/layout/username_edit_fragment.xml +++ b/app/src/main/res/layout/username_edit_fragment.xml @@ -1,7 +1,6 @@ + app:navigationIcon="@drawable/ic_x_24" + app:title="@string/UsernameEditFragment_username" + app:titleTextAppearance="@style/Signal.Text.TitleLarge" /> - + app:layout_constraintTop_toBottomOf="@id/toolbar" + app:srcCompat="@drawable/ic_at_24" /> - + app:layout_constraintTop_toBottomOf="@id/summary" + app:suffixTextColor="@color/signal_colorOnSurface" + tools:suffixText="| #1234"> + + + + + + + + + + app:layout_constraintTop_toBottomOf="@id/username_text_wrapper" + app:layout_constraintVertical_bias="0" /> + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/manage_profile.xml b/app/src/main/res/navigation/manage_profile.xml index 294da435e..39d2390a1 100644 --- a/app/src/main/res/navigation/manage_profile.xml +++ b/app/src/main/res/navigation/manage_profile.xml @@ -62,6 +62,9 @@ app:exitAnim="@anim/nav_default_exit_anim" app:popEnterAnim="@anim/nav_default_pop_enter_anim" app:popExitAnim="@anim/nav_default_pop_exit_anim" /> + @@ -87,4 +90,9 @@ + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c11e8eeb..dd756d4c3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,6 +11,7 @@ https://support.signal.org/hc/articles/4408365318426 https://pay.google.com https://support.signal.org/hc/articles/4408365318426#errors + https://signal.me/%1$s Yes No @@ -503,6 +504,20 @@ Your profile is end-to-end encrypted. Your profile and changes to it will be visible to your contacts, when you initiate or accept new conversations, and when you join new groups. Set avatar + + + Profiles are only visible to people you message. + + Who can find me by number? + + + + Who can find me by number? + + Anyone who has your phone number in their contacts will see you as a contact on Signal. Others will be able to find you with your number in search. + + Nobody on Signal will be able to find you with your phone number. + Restore from backup? Restore your messages and media from a local backup. If you don\'t restore now, you won\'t be able to restore later. @@ -856,6 +871,11 @@ Failed to set avatar Badges Edit photo + + Username created + + Username copied + No groups in common @@ -1851,6 +1871,8 @@ Send + + Choose your username Username Delete Successfully set username. @@ -1862,13 +1884,21 @@ Usernames cannot begin with a number. Username is invalid. Usernames must be between %1$d and %2$d characters. - Usernames on Signal are optional. If you choose to create a username, other Signal users will be able to find you by this username and contact you without knowing your phone number. + + Usernames let others message you without needing your phone number. They are paired with a set of digits to help keep your address private. + + What is this number? + These digits help keep your username private so you can avoid unwanted messages. Share your username with only the people and groups you\'d like to chat with. If you change usernames you\'ll get a new set of digits. %d contact is on Signal! %d contacts are on Signal! + + + Copy or share a username link + Your contact is running an old version of Signal. Please ask them to update before verifying your safety number. Your contact is running a newer version of Signal with an incompatible QR code format. Please update to compare.