Add more polish to Badges.

* Better network error handling
* Marking user cancellations so we don't annoy them
* Manage Profile screen treatment.
This commit is contained in:
Alex Hart 2021-10-29 14:05:22 -03:00 committed by Greyson Parrelli
parent 17517cfc88
commit 1af15842cc
19 changed files with 207 additions and 70 deletions

View file

@ -51,9 +51,14 @@ class BadgesOverviewViewModel(
} else {
Optional.absent()
}
}.subscribeBy { badgeId ->
store.update { it.copy(fadedBadgeId = badgeId.orNull()) }
}
}.subscribeBy(
onSuccess = { badgeId ->
store.update { it.copy(fadedBadgeId = badgeId.orNull()) }
},
onError = { throwable ->
Log.w(TAG, "Could not retrieve data from server", throwable)
}
)
}
fun setDisplayBadgesOnProfile(displayBadgesOnProfile: Boolean) {
@ -82,4 +87,8 @@ class BadgesOverviewViewModel(
return requireNotNull(modelClass.cast(BadgesOverviewViewModel(badgeRepository, subscriptionsRepository)))
}
}
companion object {
private val TAG = Log.tag(BadgesOverviewViewModel::class.java)
}
}

View file

@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData
import org.thoughtcrime.securesms.keyvalue.SignalStore
@ -37,6 +38,9 @@ class AppSettingsViewModel(private val subscriptionsRepository: SubscriptionsRep
subscriptionsRepository.getActiveSubscription().subscribeBy(
onSuccess = { subscription -> store.update { it.copy(hasActiveSubscription = subscription.isActive) } },
onError = { throwable ->
Log.w(TAG, "Could not load active subscription", throwable)
}
)
}
@ -45,4 +49,8 @@ class AppSettingsViewModel(private val subscriptionsRepository: SubscriptionsRep
return modelClass.cast(AppSettingsViewModel(subscriptionsRepository)) as T
}
}
companion object {
private val TAG = Log.tag(AppSettingsViewModel::class.java)
}
}

View file

@ -141,6 +141,7 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
subscriber.currencyCode,
levelUpdateOperation.idempotencyKey.serialize()
).flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement().andThen {
SignalStore.donationsValues().clearUserManuallyCancelled()
SignalStore.donationsValues().clearLevelOperation(levelUpdateOperation)
it.onComplete()
}.andThen {

View file

@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.subscription.Subscription
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels
import org.whispersystems.signalservice.internal.ServiceResponse
import java.util.Currency
@ -26,8 +27,9 @@ class SubscriptionsRepository(private val donationsService: DonationsService) {
}
}
fun getSubscriptions(currency: Currency): Single<List<Subscription>> = donationsService.subscriptionLevels.map { response ->
response.result.transform { subscriptionLevels ->
fun getSubscriptions(currency: Currency): Single<List<Subscription>> = donationsService.subscriptionLevels
.flatMap(ServiceResponse<SubscriptionLevels>::flattenResult)
.map { subscriptionLevels ->
subscriptionLevels.levels.map { (code, level) ->
Subscription(
id = code,
@ -38,6 +40,5 @@ class SubscriptionsRepository(private val donationsService: DonationsService) {
}.sortedBy {
it.level
}
}.or(emptyList())
}
}
}

View file

@ -58,6 +58,8 @@ class BoostFragment : DSLSettingsBottomSheetFragment(
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
viewModel.refresh()
CurrencySelection.register(adapter)
BadgePreview.register(adapter)
Boost.register(adapter)

View file

@ -21,5 +21,6 @@ data class BoostState(
READY,
TOKEN_REQUEST,
PAYMENT_PIPELINE,
FAILURE
}
}

View file

@ -11,6 +11,7 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.donations.GooglePayApi
import org.thoughtcrime.securesms.badges.models.Badge
@ -40,24 +41,33 @@ class BoostViewModel(
disposables.clear()
}
init {
fun refresh() {
disposables.clear()
val currencyObservable = SignalStore.donationsValues().observableBoostCurrency
val boosts = currencyObservable.flatMapSingle { boostRepository.getBoosts(it) }
val boostBadge = boostRepository.getBoostBadge()
disposables += Observable.combineLatest(boosts, boostBadge.toObservable()) {
boostList, badge ->
disposables += Observable.combineLatest(boosts, boostBadge.toObservable()) { boostList, badge ->
BoostInfo(boostList, boostList[2], badge)
}.subscribe { info ->
store.update {
it.copy(
boosts = info.boosts,
selectedBoost = if (it.selectedBoost in info.boosts) it.selectedBoost else info.defaultBoost,
boostBadge = it.boostBadge ?: info.boostBadge,
stage = if (it.stage == BoostState.Stage.INIT) BoostState.Stage.READY else it.stage
)
}.subscribeBy(
onNext = { info ->
store.update {
it.copy(
boosts = info.boosts,
selectedBoost = if (it.selectedBoost in info.boosts) it.selectedBoost else info.defaultBoost,
boostBadge = it.boostBadge ?: info.boostBadge,
stage = if (it.stage == BoostState.Stage.INIT || it.stage == BoostState.Stage.FAILURE) BoostState.Stage.READY else it.stage
)
}
},
onError = { throwable ->
Log.w(TAG, "Could not load boost information", throwable)
store.update {
it.copy(stage = BoostState.Stage.FAILURE)
}
}
}
)
disposables += donationPaymentRepository.isGooglePayAvailable().subscribeBy(
onComplete = { store.update { it.copy(isGooglePayAvailable = true) } },
@ -170,4 +180,8 @@ class BoostViewModel(
return modelClass.cast(BoostViewModel(boostRepository, donationPaymentRepository, fetchTokenRequestCode))!!
}
}
companion object {
private val TAG = Log.tag(BoostViewModel::class.java)
}
}

View file

@ -10,6 +10,7 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
@ -66,9 +67,14 @@ class ManageDonationsViewModel(
}
)
disposables += subscriptionsRepository.getSubscriptions(SignalStore.donationsValues().getSubscriptionCurrency()).subscribeBy { subs ->
store.update { it.copy(availableSubscriptions = subs) }
}
disposables += subscriptionsRepository.getSubscriptions(SignalStore.donationsValues().getSubscriptionCurrency()).subscribeBy(
onSuccess = { subs ->
store.update { it.copy(availableSubscriptions = subs) }
},
onError = {
Log.w(TAG, "Error retrieving subscriptions data", it)
}
)
}
class Factory(
@ -78,4 +84,8 @@ class ManageDonationsViewModel(
return modelClass.cast(ManageDonationsViewModel(subscriptionsRepository))!!
}
}
companion object {
private val TAG = Log.tag(ManageDonationsViewModel::class.java)
}
}

View file

@ -39,9 +39,10 @@ data class CurrencySelection(
override fun bind(model: Model) {
spinner.text = model.currencySelection.selectedCurrencyCode
if (model.isEnabled) {
itemView.setOnClickListener { model.onClick() }
}
itemView.setOnClickListener { model.onClick() }
itemView.isEnabled = model.isEnabled
itemView.isClickable = model.isEnabled
}
}
}

View file

@ -60,6 +60,8 @@ class SubscribeFragment : DSLSettingsFragment(
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
viewModel.refresh()
BadgePreview.register(adapter)
CurrencySelection.register(adapter)
Subscription.register(adapter)

View file

@ -18,6 +18,7 @@ data class SubscribeState(
READY,
TOKEN_REQUEST,
PAYMENT_PIPELINE,
CANCELLING
CANCELLING,
FAILURE
}
}

View file

@ -12,6 +12,7 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.logging.Log
import org.signal.donations.GooglePayApi
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
@ -43,7 +44,9 @@ class SubscribeViewModel(
disposables.clear()
}
init {
fun refresh() {
disposables.clear()
val currency: Observable<Currency> = SignalStore.donationsValues().observableSubscriptionCurrency
val allSubscriptions: Observable<List<Subscription>> = currency.switchMapSingle { subscriptionsRepository.getSubscriptions(it) }
refreshActiveSubscription()
@ -56,16 +59,19 @@ class SubscribeViewModel(
}
}
disposables += Observable.combineLatest(allSubscriptions, activeSubscriptionSubject, ::Pair).subscribe { (subs, active) ->
store.update {
it.copy(
subscriptions = subs,
selectedSubscription = it.selectedSubscription ?: resolveSelectedSubscription(active, subs),
activeSubscription = active,
stage = if (it.stage == SubscribeState.Stage.INIT) SubscribeState.Stage.READY else it.stage,
)
}
}
disposables += Observable.combineLatest(allSubscriptions, activeSubscriptionSubject, ::Pair).subscribeBy(
onNext = { (subs, active) ->
store.update {
it.copy(
subscriptions = subs,
selectedSubscription = it.selectedSubscription ?: resolveSelectedSubscription(active, subs),
activeSubscription = active,
stage = if (it.stage == SubscribeState.Stage.INIT || it.stage == SubscribeState.Stage.FAILURE) SubscribeState.Stage.READY else it.stage,
)
}
},
onError = this::handleSubscriptionDataLoadFailure
)
disposables += donationPaymentRepository.isGooglePayAvailable().subscribeBy(
onComplete = { store.update { it.copy(isGooglePayAvailable = true) } },
@ -77,10 +83,20 @@ class SubscribeViewModel(
}
}
private fun handleSubscriptionDataLoadFailure(throwable: Throwable) {
Log.w(TAG, "Could not load subscription data", throwable)
store.update {
it.copy(stage = SubscribeState.Stage.FAILURE)
}
}
fun refreshActiveSubscription() {
subscriptionsRepository
.getActiveSubscription()
.subscribeBy { activeSubscriptionSubject.onNext(it) }
.subscribeBy(
onSuccess = { activeSubscriptionSubject.onNext(it) },
onError = { activeSubscriptionSubject.onNext(ActiveSubscription(null)) }
)
}
private fun resolveSelectedSubscription(activeSubscription: ActiveSubscription, subscriptions: List<Subscription>): Subscription? {
@ -97,6 +113,7 @@ class SubscribeViewModel(
onComplete = {
eventPublisher.onNext(DonationEvent.SubscriptionCancelled)
SignalStore.donationsValues().setLastEndOfPeriod(0L)
SignalStore.donationsValues().markUserManuallyCancelled()
refreshActiveSubscription()
store.update { it.copy(stage = SubscribeState.Stage.READY) }
},
@ -196,4 +213,8 @@ class SubscribeViewModel(
return modelClass.cast(SubscribeViewModel(subscriptionsRepository, donationPaymentRepository, fetchTokenRequestCode))!!
}
}
companion object {
private val TAG = Log.tag(SubscribeViewModel::class.java)
}
}

View file

@ -14,6 +14,8 @@ import com.airbnb.lottie.LottieAnimationView
import com.airbnb.lottie.LottieDrawable
import com.google.android.material.button.MaterialButton
import com.google.android.material.switchmaterial.SwitchMaterial
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.animation.AnimationCompleteListener
import org.thoughtcrime.securesms.badges.BadgeImageView
@ -131,9 +133,17 @@ class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSh
val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
if (controlState == ControlState.DISPLAY) {
badgeRepository.setVisibilityForAllBadges(controlChecked).subscribe()
badgeRepository.setVisibilityForAllBadges(controlChecked).subscribeBy(
onError = {
Log.w(TAG, "Failure while updating badge visibility", it)
}
)
} else if (controlChecked) {
badgeRepository.setFeaturedBadge(args.badge).subscribe()
badgeRepository.setFeaturedBadge(args.badge).subscribeBy(
onError = {
Log.w(TAG, "Failure while updating featured badge", it)
}
)
}
if (args.isBoost) {
@ -151,4 +161,8 @@ class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSh
private fun presentSubscriptionCopy() {
heading.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__thanks_for_your_support)
}
companion object {
private val TAG = Log.tag(ThanksForYourSupportBottomSheetDialogFragment::class.java)
}
}

View file

@ -328,7 +328,10 @@ public class ConversationListFragment extends MainFragment implements ActionMode
Badge expiredBadge = SignalStore.donationsValues().getExpiredBadge();
if (expiredBadge != null) {
SignalStore.donationsValues().setExpiredBadge(null);
ExpiredBadgeBottomSheetDialogFragment.show(expiredBadge, getParentFragmentManager());
if (expiredBadge.isBoost() || !SignalStore.donationsValues().isUserManuallyCancelled()) {
ExpiredBadgeBottomSheetDialogFragment.show(expiredBadge, getParentFragmentManager());
}
}
}

View file

@ -29,6 +29,7 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
private const val KEY_LAST_KEEP_ALIVE_LAUNCH = "donation.last.successful.ping"
private const val KEY_LAST_END_OF_PERIOD = "donation.last.end.of.period"
private const val EXPIRED_BADGE = "donation.expired.badge"
private const val USER_MANUALLY_CANCELLED = "donation.user.manually.cancelled"
}
override fun onFirstEverAppLaunch() = Unit
@ -188,6 +189,18 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
putLong(KEY_LAST_END_OF_PERIOD, timestamp)
}
fun isUserManuallyCancelled(): Boolean {
return getBoolean(USER_MANUALLY_CANCELLED, false)
}
fun markUserManuallyCancelled() {
putBoolean(USER_MANUALLY_CANCELLED, true)
}
fun clearUserManuallyCancelled() {
remove(USER_MANUALLY_CANCELLED)
}
private fun dispatchLevelOperation() {
levelUpdateOperationPublisher.onNext(Optional.fromNullable(getLevelOperation()))
}

View file

@ -27,6 +27,8 @@ 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.components.emoji.EmojiUtil;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.profiles.ProfileName;
@ -34,6 +36,7 @@ import org.thoughtcrime.securesms.profiles.manage.ManageProfileViewModel.AvatarS
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.NameUtil;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import org.whispersystems.libsignal.util.guava.Optional;
public class ManageProfileFragment extends LoggingFragment {
@ -53,6 +56,7 @@ public class ManageProfileFragment extends LoggingFragment {
private TextView avatarInitials;
private ImageView avatarBackground;
private View badgesContainer;
private BadgeImageView badgeView;
private ManageProfileViewModel viewModel;
@ -76,11 +80,14 @@ public class ManageProfileFragment extends LoggingFragment {
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);
initializeViewModel();
this.toolbar.setNavigationOnClickListener(v -> requireActivity().finish());
this.avatarView.setOnClickListener(v -> onAvatarClicked());
View editAvatar = view.findViewById(R.id.manage_profile_edit_photo);
editAvatar.setOnClickListener(v -> onEditAvatarClicked());
this.profileNameContainer.setOnClickListener(v -> {
Navigation.findNavController(v).navigate(ManageProfileFragmentDirections.actionManageProfileName());
@ -126,6 +133,7 @@ public class ManageProfileFragment extends LoggingFragment {
viewModel.getEvents().observe(getViewLifecycleOwner(), this::presentEvent);
viewModel.getAbout().observe(getViewLifecycleOwner(), this::presentAbout);
viewModel.getAboutEmoji().observe(getViewLifecycleOwner(), this::presentAboutEmoji);
viewModel.getBadge().observe(getViewLifecycleOwner(), this::presentBadge);
if (viewModel.shouldShowUsername()) {
viewModel.getUsername().observe(getViewLifecycleOwner(), this::presentUsername);
@ -217,6 +225,10 @@ public class ManageProfileFragment extends LoggingFragment {
}
}
private void presentBadge(@NonNull Optional<Badge> badge) {
badgeView.setBadge(badge.orNull());
}
private void presentEvent(@NonNull ManageProfileViewModel.Event event) {
switch (event) {
case AVATAR_DISK_FAILURE:
@ -228,7 +240,7 @@ public class ManageProfileFragment extends LoggingFragment {
}
}
private void onAvatarClicked() {
private void onEditAvatarClicked() {
Navigation.findNavController(requireView()).navigate(ManageProfileFragmentDirections.actionManageProfileFragmentToAvatarPicker(null, null));
}
}

View file

@ -12,6 +12,7 @@ import androidx.lifecycle.ViewModelProvider;
import org.signal.core.util.StreamUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.badges.models.Badge;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
import org.thoughtcrime.securesms.mediasend.Media;
@ -20,9 +21,11 @@ import org.thoughtcrime.securesms.profiles.ProfileName;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.util.StreamDetails;
import java.io.IOException;
@ -42,6 +45,7 @@ class ManageProfileViewModel extends ViewModel {
private final SingleLiveEvent<Event> events;
private final RecipientForeverObserver observer;
private final ManageProfileRepository repository;
private final MutableLiveData<Optional<Badge>> badge;
private byte[] previousAvatar;
@ -53,6 +57,7 @@ class ManageProfileViewModel extends ViewModel {
this.aboutEmoji = new MutableLiveData<>();
this.events = new SingleLiveEvent<>();
this.repository = new ManageProfileRepository();
this.badge = new DefaultValueLiveData<>(Optional.absent());
this.observer = this::onRecipientChanged;
this.avatarState = LiveDataUtil.combineLatest(Recipient.self().live().getLiveData(), internalAvatarState, (self, state) -> new AvatarState(state, self));
@ -97,6 +102,10 @@ class ManageProfileViewModel extends ViewModel {
return aboutEmoji;
}
public @NonNull LiveData<Optional<Badge>> getBadge() {
return badge;
}
public @NonNull LiveData<Event> getEvents() {
return events;
}
@ -159,6 +168,7 @@ class ManageProfileViewModel extends ViewModel {
username.postValue(recipient.getUsername().orNull());
about.postValue(recipient.getAbout());
aboutEmoji.postValue(recipient.getAboutEmoji());
badge.postValue(Optional.fromNullable(recipient.getFeaturedBadge()));
}
@Override

View file

@ -23,8 +23,8 @@
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/manage_profile_avatar_background"
android:layout_width="96dp"
android:layout_height="96dp"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_marginTop="16dp"
android:src="@drawable/circle_tintable"
android:tint="@color/core_grey_05"
@ -71,32 +71,45 @@
app:layout_constraintStart_toStartOf="@+id/manage_profile_avatar_background"
app:layout_constraintTop_toTopOf="@+id/manage_profile_avatar_background" />
<ImageView
android:id="@+id/manage_profile_camera_icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="56dp"
android:layout_marginTop="56dp"
android:background="@drawable/circle_tintable_padded"
android:cropToPadding="false"
android:elevation="4dp"
android:padding="14dp"
app:backgroundTint="@color/camera_icon_background_tint"
app:layout_constraintStart_toStartOf="@+id/manage_profile_avatar_background"
app:layout_constraintTop_toTopOf="@+id/manage_profile_avatar_background"
app:srcCompat="@drawable/ic_camera_24" />
<org.thoughtcrime.securesms.badges.BadgeImageView
android:id="@+id/manage_profile_badge"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginStart="44dp"
android:layout_marginTop="52dp"
app:badge_size="large"
app:layout_constraintStart_toStartOf="@+id/manage_profile_avatar_background"
app:layout_constraintTop_toTopOf="@+id/manage_profile_avatar_background" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/manage_profile_name_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:paddingStart="@dimen/dsl_settings_gutter"
android:paddingEnd="@dimen/dsl_settings_gutter"
android:paddingTop="16dp"
android:paddingBottom="16dp"
android:background="?selectableItemBackground"
app:layout_constraintTop_toBottomOf="@id/manage_profile_avatar">
<TextView
android:id="@+id/manage_profile_edit_photo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="14dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:background="@drawable/rounded_rectangle_tertiary"
android:paddingStart="21dp"
android:paddingTop="7dp"
android:paddingEnd="21dp"
android:paddingBottom="7dp"
android:text="@string/ManageProfileFragment__edit_photo"
android:textAppearance="@style/TextAppearance.Signal.Body2.Bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/manage_profile_badge" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/manage_profile_name_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:background="?selectableItemBackground"
android:paddingStart="@dimen/dsl_settings_gutter"
android:paddingTop="16dp"
android:paddingEnd="@dimen/dsl_settings_gutter"
android:paddingBottom="16dp"
app:layout_constraintTop_toBottomOf="@id/manage_profile_edit_photo">
<ImageView
android:id="@+id/manage_profile_name_icon"

View file

@ -815,6 +815,7 @@
<string name="ManageProfileFragment_your_username">Your username</string>
<string name="ManageProfileFragment_failed_to_set_avatar">Failed to set avatar</string>
<string name="ManageProfileFragment_badges">Badges</string>
<string name="ManageProfileFragment__edit_photo">Edit photo</string>
<!-- ManageRecipientActivity -->
<string name="ManageRecipientActivity_no_groups_in_common">No groups in common</string>