Rewrite App Settings in compose.

This commit is contained in:
Alex Hart 2024-10-25 16:26:46 -03:00 committed by Greyson Parrelli
parent 7f3ceea9fe
commit b979be0cb9
13 changed files with 710 additions and 486 deletions

View file

@ -568,6 +568,7 @@ dependencies {
implementation(libs.dnsjava)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.accompanist.permissions)
implementation(libs.accompanist.drawablepainter)
implementation(libs.kotlin.stdlib.jdk8)
implementation(libs.kotlin.reflect)
implementation(libs.kotlinx.coroutines.play.services)

View file

@ -7,8 +7,10 @@ package org.thoughtcrime.securesms.banner
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp
@ -60,4 +62,19 @@ class BannerManager @JvmOverloads constructor(
}
}
}
/**
* Displays the current banner.
*/
@Composable
fun Banner() {
val banner by rememberUpdatedState(banners.firstOrNull { it.enabled } as Banner<Any>?)
banner?.let { nonNullBanner ->
val state by nonNullBanner.dataFlow.collectAsStateWithLifecycle(initialValue = null)
state?.let { model ->
nonNullBanner.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
}
}
}
}

View file

@ -0,0 +1,79 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.emoji
import androidx.compose.foundation.Image
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.sp
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
/**
* Applies Signal or System emoji to the given content based off user settings.
*
* Text is transformed and passed to content as an annotated string and inline content map.
*/
@Composable
fun Emojifier(
text: String,
content: @Composable (AnnotatedString, Map<String, InlineTextContent>) -> Unit = { annotatedText, inlineContent ->
Text(
text = annotatedText,
inlineContent = inlineContent
)
}
) {
if (LocalInspectionMode.current) {
content(buildAnnotatedString { append(text) }, emptyMap())
return
}
val context = LocalContext.current
val candidates = remember(text) { EmojiProvider.getCandidates(text) }
val candidateMap: Map<String, InlineTextContent> = remember(text) {
candidates?.associate { candidate ->
candidate.drawInfo.emoji to InlineTextContent(placeholder = Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)) {
Image(
painter = rememberDrawablePainter(EmojiProvider.getEmojiDrawable(context, candidate.drawInfo.emoji)),
contentDescription = null
)
}
} ?: emptyMap()
}
val annotatedString = buildAnnotatedString {
append(text)
candidates?.forEach {
addStringAnnotation(
tag = "EMOJI",
annotation = it.drawInfo.emoji,
start = it.startIndex,
end = it.endIndex
)
}
}
content(annotatedString, candidateMap)
}
@Composable
@SignalPreview
private fun EmojifierPreview() {
Previews.Preview {
Emojifier(text = "This message has an emoji ❤\uFE0F")
}
}

View file

@ -2,292 +2,139 @@ package org.thoughtcrime.securesms.components.settings.app
import android.os.Bundle
import android.view.View
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.IdRes
import androidx.annotation.StringRes
import androidx.compose.ui.platform.ComposeView
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavDirections
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.ui.Dividers
import org.signal.core.ui.IconButtons
import org.signal.core.ui.Previews
import org.signal.core.ui.Rows
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.horizontalGutters
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.avatar.AvatarImage
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.BannerManager
import org.thoughtcrime.securesms.banner.banners.DeprecatedBuildBanner
import org.thoughtcrime.securesms.banner.banners.UnauthorizedBanner
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
import org.thoughtcrime.securesms.banner.ui.compose.Action
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
import org.thoughtcrime.securesms.banner.ui.compose.Importance
import org.thoughtcrime.securesms.components.emoji.Emojifier
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageMedium
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.views.Stub
import org.thoughtcrime.securesms.util.visible
class AppSettingsFragment : DSLSettingsFragment(
titleId = R.string.text_secure_normal__menu_settings,
layoutId = R.layout.dsl_settings_fragment_with_reminder
) {
class AppSettingsFragment : ComposeFragment(), Callbacks {
private val viewModel: AppSettingsViewModel by viewModels()
private var bannerManager: BannerManager? = null
private lateinit var bannerView: Stub<ComposeView>
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycle.addObserver(InAppPaymentsBottomSheetDelegate(childFragmentManager, viewLifecycleOwner))
super.onViewCreated(view, savedInstanceState)
bannerView = ViewUtil.findStubById(view, R.id.banner_stub)
initializeBanners()
}
override fun bindAdapter(adapter: MappingAdapter) {
adapter.registerFactory(BioPreference::class.java, LayoutFactory(::BioPreferenceViewHolder, R.layout.bio_preference_item))
adapter.registerFactory(PaymentsPreference::class.java, LayoutFactory(::PaymentsPreferenceViewHolder, R.layout.dsl_payments_preference))
adapter.registerFactory(SubscriptionPreference::class.java, LayoutFactory(::SubscriptionPreferenceViewHolder, R.layout.dsl_preference_item))
@Composable
override fun FragmentContent() {
val state by viewModel.state.observeAsState()
val self by viewModel.self.observeAsState()
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}
if (state == null) return
private fun initializeBanners() {
this.bannerManager = BannerManager(
val context = LocalContext.current
val bannerManager = remember {
BannerManager(
banners = listOf(
DeprecatedBuildBanner(),
UnauthorizedBanner(requireContext())
),
onNewBannerShownListener = {
if (bannerView.resolved()) {
bannerView.get().addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ ->
recyclerView?.setPadding(0, bottom - top, 0, 0)
}
recyclerView?.clipToPadding = false
}
},
onNoBannerShownListener = {
recyclerView?.clipToPadding = true
}
UnauthorizedBanner(context)
)
)
}
this.bannerManager?.updateContent(bannerView.get())
AppSettingsContent(
self = self!!,
state = state!!,
bannerManager = bannerManager,
callbacks = this
)
}
viewModel.refreshDeprecatedOrUnregistered()
override fun onNavigationClick() {
requireActivity().finishAfterTransition()
}
override fun navigate(actionId: Int) {
findNavController().safeNavigate(actionId)
}
override fun navigate(directions: NavDirections) {
findNavController().safeNavigate(directions)
}
override fun onResume() {
super.onResume()
viewModel.refreshExpiredGiftBadge()
this.bannerManager?.updateContent(bannerView.get())
viewModel.refreshDeprecatedOrUnregistered()
}
private fun getConfiguration(state: AppSettingsState): DSLConfiguration {
return configure {
customPref(
BioPreference(
recipient = state.self,
onRowClicked = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
},
onQrButtonClicked = {
if (SignalStore.account.username != null) {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameLinkSettingsFragment)
} else {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameEducationFragment)
}
}
)
)
clickPref(
title = DSLSettingsText.from(R.string.AccountSettingsFragment__account),
icon = DSLSettingsIcon.from(R.drawable.symbol_person_circle_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_accountSettingsFragment)
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__linked_devices),
icon = DSLSettingsIcon.from(R.drawable.symbol_devices_24),
onClick = { findNavController().safeNavigate(R.id.action_appSettingsFragment_to_linkDeviceFragment) },
isEnabled = state.isRegisteredAndUpToDate()
)
if (state.allowUserToGoToDonationManagementScreen) {
clickPref(
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
icon = DSLSettingsIcon.from(R.drawable.symbol_heart_24),
iconEnd = if (state.hasExpiredGiftBadge) DSLSettingsIcon.from(R.drawable.symbol_info_fill_24, R.color.signal_accent_primary) else null,
onClick = {
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToManageDonationsFragment())
},
onLongClick = this@AppSettingsFragment::copyDonorBadgeSubscriberIdToClipboard
)
} else {
externalLinkPref(
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
icon = DSLSettingsIcon.from(R.drawable.symbol_heart_24),
linkId = R.string.donate_url
)
}
dividerPref()
clickPref(
title = DSLSettingsText.from(R.string.preferences__appearance),
icon = DSLSettingsIcon.from(R.drawable.symbol_appearance_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_appearanceSettingsFragment)
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences_chats__chats),
icon = DSLSettingsIcon.from(R.drawable.symbol_chat_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_chatsSettingsFragment)
},
onLongClick = this@AppSettingsFragment::copyRemoteBackupsSubscriberIdToClipboard,
isEnabled = state.isRegisteredAndUpToDate()
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__stories),
icon = DSLSettingsIcon.from(R.drawable.symbol_stories_24),
onClick = {
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToStoryPrivacySettings(R.string.preferences__stories))
},
isEnabled = state.isRegisteredAndUpToDate()
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__notifications),
icon = DSLSettingsIcon.from(R.drawable.symbol_bell_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_notificationsSettingsFragment)
},
isEnabled = state.isRegisteredAndUpToDate()
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__privacy),
icon = DSLSettingsIcon.from(R.drawable.symbol_lock_white_48),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_privacySettingsFragment)
},
isEnabled = state.isRegisteredAndUpToDate()
)
if (RemoteConfig.messageBackups) {
clickPref(
title = DSLSettingsText.from(R.string.preferences_chats__backups),
icon = DSLSettingsIcon.from(R.drawable.symbol_backup_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_backupsSettingsFragment)
},
isEnabled = state.isRegisteredAndUpToDate()
)
}
clickPref(
title = DSLSettingsText.from(R.string.preferences__data_and_storage),
icon = DSLSettingsIcon.from(R.drawable.symbol_data_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_dataAndStorageSettingsFragment)
}
)
if (Environment.IS_NIGHTLY) {
clickPref(
title = DSLSettingsText.from("App updates"),
icon = DSLSettingsIcon.from(R.drawable.symbol_calendar_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_appUpdatesSettingsFragment)
}
)
}
dividerPref()
if (SignalStore.payments.paymentsAvailability.showPaymentsMenu()) {
customPref(
PaymentsPreference(
unreadCount = state.unreadPaymentsCount
) {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_paymentsActivity)
}
)
dividerPref()
}
clickPref(
title = DSLSettingsText.from(R.string.preferences__help),
icon = DSLSettingsIcon.from(R.drawable.symbol_help_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_helpSettingsFragment)
}
)
clickPref(
title = DSLSettingsText.from(R.string.AppSettingsFragment__invite_your_friends),
icon = DSLSettingsIcon.from(R.drawable.symbol_invite_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_inviteActivity)
}
)
if (RemoteConfig.internalUser) {
dividerPref()
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_preferences),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_internalSettingsFragment)
}
)
}
}
}
private fun copyDonorBadgeSubscriberIdToClipboard(): Boolean {
override fun copyDonorBadgeSubscriberIdToClipboard() {
copySubscriberIdToClipboard(
subscriberType = InAppPaymentSubscriberRecord.Type.DONATION,
toastSuccessStringRes = R.string.AppSettingsFragment__copied_donor_subscriber_id_to_clipboard
)
return true
}
private fun copyRemoteBackupsSubscriberIdToClipboard(): Boolean {
override fun copyRemoteBackupsSubscriberIdToClipboard() {
copySubscriberIdToClipboard(
subscriberType = InAppPaymentSubscriberRecord.Type.BACKUP,
toastSuccessStringRes = R.string.AppSettingsFragment__copied_backups_subscriber_id_to_clipboard
)
return true
}
private fun copySubscriberIdToClipboard(
@ -307,109 +154,463 @@ class AppSettingsFragment : DSLSettingsFragment(
}
}
}
private class SubscriptionPreference(
override val title: DSLSettingsText,
override val summary: DSLSettingsText? = null,
override val icon: DSLSettingsIcon? = null,
override val isEnabled: Boolean = true,
val isActive: Boolean = false,
val onClick: (Boolean) -> Unit,
val onLongClick: () -> Boolean
) : PreferenceModel<SubscriptionPreference>() {
override fun areItemsTheSame(newItem: SubscriptionPreference): Boolean {
return true
}
override fun areContentsTheSame(newItem: SubscriptionPreference): Boolean {
return super.areContentsTheSame(newItem) && isActive == newItem.isActive
}
@Composable
private fun AppSettingsContent(
self: BioRecipientState,
state: AppSettingsState,
bannerManager: BannerManager,
callbacks: Callbacks
) {
val isRegisteredAndUpToDate by rememberUpdatedState(state.isRegisteredAndUpToDate())
Scaffolds.Settings(
title = stringResource(R.string.text_secure_normal__menu_settings),
navigationContentDescription = stringResource(R.string.CallScreenTopBar__go_back),
navigationIconPainter = painterResource(R.drawable.symbol_arrow_left_24),
onNavigationClick = callbacks::onNavigationClick
) { contentPadding ->
Column(
modifier = Modifier.padding(contentPadding)
) {
bannerManager.Banner()
LazyColumn {
item {
BioRow(
self = self,
callbacks = callbacks
)
}
private class SubscriptionPreferenceViewHolder(itemView: View) : PreferenceViewHolder<SubscriptionPreference>(itemView) {
override fun bind(model: SubscriptionPreference) {
super.bind(model)
itemView.setOnClickListener { model.onClick(model.isActive) }
itemView.setOnLongClickListener { model.onLongClick() }
item {
Rows.TextRow(
text = stringResource(R.string.AccountSettingsFragment__account),
icon = painterResource(R.drawable.symbol_person_circle_24),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_accountSettingsFragment)
}
)
}
private class BioPreference(val recipient: Recipient, val onRowClicked: () -> Unit, val onQrButtonClicked: () -> Unit) : PreferenceModel<BioPreference>() {
override fun areContentsTheSame(newItem: BioPreference): Boolean {
return super.areContentsTheSame(newItem) && recipient.hasSameContent(newItem.recipient)
item {
Rows.TextRow(
text = stringResource(R.string.preferences__linked_devices),
icon = painterResource(R.drawable.symbol_devices_24),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_linkDeviceFragment)
},
enabled = isRegisteredAndUpToDate
)
}
override fun areItemsTheSame(newItem: BioPreference): Boolean {
return recipient == newItem.recipient
item {
val context = LocalContext.current
val donateUrl = stringResource(R.string.donate_url)
Rows.TextRow(
text = {
Text(
text = stringResource(R.string.preferences__donate_to_signal),
modifier = Modifier.weight(1f)
)
if (state.hasExpiredGiftBadge) {
Icon(
painter = painterResource(R.drawable.symbol_info_fill_24),
tint = colorResource(R.color.signal_accent_primary),
contentDescription = null
)
}
}
private class BioPreferenceViewHolder(itemView: View) : PreferenceViewHolder<BioPreference>(itemView) {
private val avatarView: AvatarImageView = itemView.findViewById(R.id.icon)
private val aboutView: EmojiTextView = itemView.findViewById(R.id.about)
private val badgeView: BadgeImageView = itemView.findViewById(R.id.badge)
private val qrButton: View = itemView.findViewById(R.id.qr_button)
private val usernameView: TextView = itemView.findViewById(R.id.username)
init {
aboutView.setOverflowText(" ")
}
override fun bind(model: BioPreference) {
super.bind(model)
itemView.setOnClickListener { model.onRowClicked() }
titleView.text = model.recipient.profileName.toString()
summaryView.text = PhoneNumberFormatter.prettyPrint(model.recipient.requireE164())
usernameView.text = model.recipient.username.orElse("")
usernameView.visible = model.recipient.username.isPresent
avatarView.setRecipient(Recipient.self())
badgeView.setBadgeFromRecipient(Recipient.self())
titleView.visibility = View.VISIBLE
summaryView.visibility = View.VISIBLE
avatarView.visibility = View.VISIBLE
if (SignalStore.account.username.isNotNullOrBlank()) {
qrButton.visibility = View.VISIBLE
qrButton.isClickable = true
qrButton.setOnClickListener { model.onQrButtonClicked() }
},
icon = {
Icon(
painter = painterResource(R.drawable.symbol_heart_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
},
onClick = {
if (state.allowUserToGoToDonationManagementScreen) {
callbacks.navigate(R.id.action_appSettingsFragment_to_manageDonationsFragment)
} else {
qrButton.visibility = View.GONE
CommunicationActions.openBrowserLink(context, donateUrl)
}
},
onLongClick = {
callbacks.copyDonorBadgeSubscriberIdToClipboard()
}
)
}
if (model.recipient.combinedAboutAndEmoji != null) {
aboutView.text = model.recipient.combinedAboutAndEmoji
aboutView.visibility = View.VISIBLE
item {
Dividers.Default()
}
item {
Rows.TextRow(
text = stringResource(R.string.preferences__appearance),
icon = painterResource(R.drawable.symbol_appearance_24),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_appearanceSettingsFragment)
}
)
}
item {
Rows.TextRow(
text = stringResource(R.string.preferences_chats__chats),
icon = painterResource(R.drawable.symbol_chat_24),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_chatsSettingsFragment)
},
enabled = isRegisteredAndUpToDate
)
}
item {
Rows.TextRow(
text = stringResource(R.string.preferences__stories),
icon = painterResource(R.drawable.symbol_stories_24),
onClick = {
callbacks.navigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToStoryPrivacySettings(R.string.preferences__stories))
},
enabled = isRegisteredAndUpToDate
)
}
item {
Rows.TextRow(
text = stringResource(R.string.preferences__notifications),
icon = painterResource(R.drawable.symbol_bell_24),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_notificationsSettingsFragment)
},
enabled = isRegisteredAndUpToDate
)
}
item {
Rows.TextRow(
text = stringResource(R.string.preferences__privacy),
icon = painterResource(R.drawable.symbol_lock_24),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_privacySettingsFragment)
},
enabled = isRegisteredAndUpToDate
)
}
if (state.showBackups) {
item {
Rows.TextRow(
text = stringResource(R.string.preferences_chats__backups),
icon = painterResource(R.drawable.symbol_backup_24),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_backupsSettingsFragment)
},
onLongClick = {
callbacks.copyRemoteBackupsSubscriberIdToClipboard()
},
enabled = isRegisteredAndUpToDate
)
}
}
item {
Rows.TextRow(
text = stringResource(R.string.preferences__data_and_storage),
icon = painterResource(R.drawable.symbol_data_24),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_dataAndStorageSettingsFragment)
}
)
}
if (state.showAppUpdates) {
item {
Rows.TextRow(
text = "App updates",
icon = painterResource(R.drawable.symbol_calendar_24),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_appUpdatesSettingsFragment)
}
)
}
}
if (state.showPayments) {
item {
Dividers.Default()
}
item {
Rows.TextRow(
text = {
Text(
text = stringResource(R.string.preferences__payments),
modifier = Modifier.weight(1f)
)
if (state.unreadPaymentsCount > 0) {
Text(
text = state.unreadPaymentsCount.toString(),
color = MaterialTheme.colorScheme.inverseOnSurface,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier
.background(
color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(50)
)
.defaultMinSize(minWidth = 30.dp)
.padding(4.dp)
)
}
},
icon = {
Icon(
painter = painterResource(R.drawable.symbol_payment_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
},
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_paymentsActivity)
}
)
}
}
item {
Dividers.Default()
}
item {
Rows.TextRow(
text = stringResource(R.string.preferences__help),
icon = painterResource(R.drawable.symbol_help_24),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_helpSettingsFragment)
}
)
}
item {
Rows.TextRow(
text = stringResource(R.string.AppSettingsFragment__invite_your_friends),
icon = painterResource(R.drawable.symbol_invite_24),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_inviteActivity)
}
)
}
if (state.showInternalPreferences) {
item {
Dividers.Default()
}
item {
Rows.TextRow(
text = stringResource(R.string.preferences__internal_preferences),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_internalSettingsFragment)
}
)
}
}
}
}
}
}
@Composable
private fun BioRow(
self: BioRecipientState,
callbacks: Callbacks
) {
val hasUsername by rememberUpdatedState(self.username.isNotBlank())
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
}
)
.horizontalGutters()
) {
Box {
AvatarImage(
recipient = self.recipient,
modifier = Modifier
.padding(vertical = 24.dp)
.size(80.dp)
)
if (self.featuredBadge != null) {
BadgeImageMedium(
badge = self.featuredBadge,
modifier = Modifier
.padding(bottom = 24.dp)
.size(24.dp)
.align(Alignment.BottomEnd)
)
}
}
Column(
modifier = Modifier
.weight(1f)
.padding(start = 24.dp, end = 12.dp)
) {
Emojifier(text = self.profileName.toString()) { annotatedString, inlineTextContentMap ->
Text(
text = annotatedString,
inlineContent = inlineTextContentMap,
style = MaterialTheme.typography.titleLarge
)
}
val prettyPhoneNumber = if (LocalInspectionMode.current) {
self.e164
} else {
aboutView.visibility = View.GONE
remember(self.e164) {
PhoneNumberFormatter.prettyPrint(self.e164)
}
}
Text(
text = prettyPhoneNumber,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (hasUsername) {
Text(
text = self.username,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (self.combinedAboutAndEmoji != null) {
Emojifier(
text = self.combinedAboutAndEmoji
) { annotatedString, inlineTextContentMap ->
Text(
text = annotatedString,
color = MaterialTheme.colorScheme.outline,
inlineContent = inlineTextContentMap,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
private class PaymentsPreference(val unreadCount: Int, val onClick: () -> Unit) : PreferenceModel<PaymentsPreference>() {
override fun areContentsTheSame(newItem: PaymentsPreference): Boolean {
return super.areContentsTheSame(newItem) && unreadCount == newItem.unreadCount
if (hasUsername) {
IconButtons.IconButton(
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_usernameLinkSettingsFragment)
},
size = 36.dp,
colors = IconButtons.iconButtonColors(
containerColor = SignalTheme.colors.colorSurface4
)
) {
Icon(
painter = painterResource(R.drawable.symbol_qrcode_24),
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
}
override fun areItemsTheSame(newItem: PaymentsPreference): Boolean {
return true
}
}
private class PaymentsPreferenceViewHolder(itemView: View) : MappingViewHolder<PaymentsPreference>(itemView) {
@SignalPreview
@Composable
private fun AppSettingsContentPreview() {
Previews.Preview {
AppSettingsContent(
self = BioRecipientState(
Recipient(
systemContactName = "Miles Morales",
profileName = ProfileName.fromParts("Miles", "Morales ❤\uFE0F"),
isSelf = true,
e164Value = "+15555555555",
usernameValue = "miles.98",
aboutEmoji = "\uFE0F",
about = "About",
isResolving = false
)
),
state = AppSettingsState(
unreadPaymentsCount = 5,
hasExpiredGiftBadge = true,
allowUserToGoToDonationManagementScreen = true,
userUnregistered = false,
clientDeprecated = false,
showInternalPreferences = true,
showPayments = true,
showAppUpdates = true,
showBackups = true
),
bannerManager = BannerManager(
banners = listOf(TestBanner())
),
callbacks = EmptyCallbacks
)
}
}
private val unreadCountView: TextView = itemView.findViewById(R.id.unread_indicator)
@SignalPreview
@Composable
private fun BioRowPreview() {
Previews.Preview {
BioRow(
self = BioRecipientState(
Recipient(
systemContactName = "Miles Morales",
profileName = ProfileName.fromParts("Miles", "Morales ❤\uFE0F"),
isSelf = true,
e164Value = "+15555555555",
usernameValue = "miles.98",
aboutEmoji = "\uFE0F",
about = "About",
isResolving = false
)
),
callbacks = EmptyCallbacks
)
}
}
override fun bind(model: PaymentsPreference) {
unreadCountView.text = model.unreadCount.toString()
unreadCountView.visibility = if (model.unreadCount > 0) View.VISIBLE else View.GONE
private interface Callbacks {
fun onNavigationClick(): Unit = error("Not implemented.")
fun navigate(@IdRes actionId: Int): Unit = error("Not implemented")
fun navigate(directions: NavDirections): Unit = error("Not implemented")
fun copyDonorBadgeSubscriberIdToClipboard(): Unit = error("Not implemented")
fun copyRemoteBackupsSubscriberIdToClipboard(): Unit = error("Not implemented")
}
itemView.setOnClickListener {
model.onClick()
}
}
private object EmptyCallbacks : Callbacks
private class TestBanner : Banner<Unit>() {
override val enabled: Boolean = true
override val dataFlow: Flow<Unit> = flowOf(Unit)
@Composable
override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) {
DefaultBanner(
title = "Test Title",
body = "This is a test body",
importance = Importance.ERROR,
actions = listOf(
Action(android.R.string.ok) {}
),
paddingValues = contentPadding
)
}
}

View file

@ -1,14 +1,21 @@
package org.thoughtcrime.securesms.components.settings.app
import org.thoughtcrime.securesms.recipients.Recipient
import androidx.compose.runtime.Immutable
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.RemoteConfig
@Immutable
data class AppSettingsState(
val self: Recipient,
val unreadPaymentsCount: Int,
val hasExpiredGiftBadge: Boolean,
val allowUserToGoToDonationManagementScreen: Boolean,
val userUnregistered: Boolean,
val clientDeprecated: Boolean
val clientDeprecated: Boolean,
val showInternalPreferences: Boolean = RemoteConfig.internalUser,
val showPayments: Boolean = SignalStore.payments.paymentsAvailability.showPaymentsMenu(),
val showAppUpdates: Boolean = Environment.IS_NIGHTLY,
val showBackups: Boolean = RemoteConfig.messageBackups
) {
fun isRegisteredAndUpToDate(): Boolean {
return !userUnregistered && !clientDeprecated

View file

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
@ -19,7 +20,6 @@ class AppSettingsViewModel : ViewModel() {
private val store = Store(
AppSettingsState(
Recipient.self(),
0,
SignalStore.inAppPayments.getExpiredGiftBadge() != null,
SignalStore.inAppPayments.isLikelyASustainer() || InAppDonations.hasAtLeastOnePaymentMethodAvailable(),
@ -29,14 +29,13 @@ class AppSettingsViewModel : ViewModel() {
)
private val unreadPaymentsLiveData = UnreadPaymentsLiveData()
private val selfLiveData: LiveData<Recipient> = Recipient.self().live().liveData
private val disposables = CompositeDisposable()
val state: LiveData<AppSettingsState> = store.stateLiveData
val self: LiveData<BioRecipientState> = Recipient.self().live().liveData.map { BioRecipientState(it) }
init {
store.update(unreadPaymentsLiveData) { payments, state -> state.copy(unreadPaymentsCount = payments.map { it.unreadCount }.orElse(0)) }
store.update(selfLiveData) { self, state -> state.copy(self = self) }
disposables += RecurringInAppPaymentRepository.getActiveSubscription(InAppPaymentSubscriberRecord.Type.DONATION).subscribeBy(
onSuccess = { activeSubscription ->

View file

@ -0,0 +1,42 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app
import androidx.compose.runtime.Immutable
import com.google.common.base.Objects
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
/**
* Derived state class of recipient for BioRow
*/
@Immutable
class BioRecipientState(
val recipient: Recipient
) {
val username: String = recipient.username.orElse("")
val featuredBadge: Badge? = recipient.featuredBadge
val profileName: ProfileName = recipient.profileName
val e164: String = recipient.requireE164()
val combinedAboutAndEmoji: String? = recipient.combinedAboutAndEmoji
override fun equals(other: Any?): Boolean {
if (other !is Recipient) return false
return recipient.hasSameContent(other)
}
override fun hashCode(): Int {
return Objects.hashCode(
recipient,
username,
featuredBadge,
profileName,
e164,
combinedAboutAndEmoji
)
}
}

View file

@ -25,6 +25,26 @@ enum class BadgeImageSize(val sizeCode: Int) {
BADGE_112(5)
}
@Composable
fun BadgeImageMedium(
badge: Badge?,
modifier: Modifier
) {
if (LocalInspectionMode.current) {
Box(modifier = modifier.background(color = Color.Black, shape = CircleShape))
} else {
AndroidView(
factory = {
BadgeImageView(it, BadgeImageSize.MEDIUM)
},
update = {
it.setBadge(badge)
},
modifier = modifier
)
}
}
@Composable
fun BadgeImage112(
badge: Badge?,

View file

@ -1,104 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/dsl_preference_item_background"
android:minHeight="56dp"
tools:viewBindingIgnore="true">
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/icon"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_marginStart="24dp"
android:layout_marginTop="24dp"
android:layout_marginBottom="24dp"
android:importantForAccessibility="no"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<org.thoughtcrime.securesms.badges.BadgeImageView
android:id="@+id/badge"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="56dp"
android:layout_marginTop="56dp"
android:contentDescription="@string/ImageView__badge"
app:badge_size="medium"
app:layout_constraintStart_toStartOf="@id/icon"
app:layout_constraintTop_toTopOf="@id/icon" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="14dp"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/qr_button"
app:layout_constraintStart_toEndOf="@id/icon"
app:layout_constraintTop_toTopOf="parent"
app:layout_goneMarginEnd="24dp">
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="viewStart"
android:textAppearance="@style/Signal.Text.TitleLarge"
tools:text="Peter Parker" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/summary"
style="@style/Signal.Text.BodyMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="viewStart"
android:textColor="@color/signal_colorOnSurfaceVariant"
tools:text="+1 (999) 555-1234" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/username"
style="@style/Signal.Text.BodyMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="viewStart"
android:textColor="@color/signal_colorOnSurfaceVariant"
android:visibility="gone"
tools:text="miles.07" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/about"
style="@style/Signal.Text.BodySmall"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textAlignment="viewStart"
android:textColor="@color/signal_colorOnSurfaceVariant"
tools:text="Crusin' the web" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/qr_button"
style="@style/Widget.Signal.Button.Icon"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginEnd="24dp"
app:backgroundTint="@color/signal_colorSurface4"
app:icon="@drawable/symbol_qrcode_24"
app:iconSize="20dp"
app:iconTint="@color/signal_colorOnSurface"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/dsl_preference_item_background"
android:minHeight="56dp"
tools:viewBindingIgnore="true">
<ImageView
android:id="@id/icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:importantForAccessibility="no"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/symbol_payment_24"
app:tint="@color/signal_icon_tint_primary" />
<TextView
android:id="@id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginEnd="4dp"
android:text="@string/preferences__payments"
android:textAppearance="@style/Signal.Text.BodyLarge"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/unread_indicator"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@id/icon"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/unread_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:background="@drawable/unread_count_background"
android:gravity="center"
android:minWidth="30sp"
android:padding="4sp"
android:textAppearance="@style/Signal.Text.BodyMedium"
android:textColor="@color/core_white"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1"
app:layout_constraintTop_toTopOf="parent"
tools:text="1"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,6 +1,8 @@
package org.signal.core.ui
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@ -132,6 +134,7 @@ object Rows {
icon: Painter? = null,
foregroundTint: Color = MaterialTheme.colorScheme.onSurface,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true
) {
TextRow(
@ -157,6 +160,7 @@ object Rows {
},
modifier = modifier,
onClick = onClick,
onLongClick = onLongClick,
enabled = enabled
)
}
@ -164,18 +168,24 @@ object Rows {
/**
* Customizable text row that allows [text] and [icon] to be provided as composable functions instead of primitives.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TextRow(
text: @Composable RowScope.() -> Unit,
modifier: Modifier = Modifier,
icon: (@Composable RowScope.() -> Unit)? = null,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(enabled = enabled && onClick != null, onClick = onClick ?: {})
.combinedClickable(
enabled = enabled && (onClick != null || onLongClick != null),
onClick = onClick ?: {},
onLongClick = onLongClick ?: {}
)
.padding(defaultPadding()),
verticalAlignment = CenterVertically
) {

View file

@ -37,6 +37,7 @@ dependencyResolutionManagement {
// Accompanist
library("accompanist-permissions", "com.google.accompanist", "accompanist-permissions").versionRef("accompanist")
library("accompanist-drawablepainter", "com.google.accompanist:accompanist-drawablepainter:0.36.0")
// Desugaring
library("android-tools-desugar", "com.android.tools:desugar_jdk_libs:1.1.6")

View file

@ -4872,6 +4872,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="2f6174e3049008f263fd832813390df645ac5c7cfa79f170ace58690810476f2" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.accompanist" name="accompanist-drawablepainter" version="0.36.0">
<artifact name="accompanist-drawablepainter-0.36.0.aar">
<sha256 value="599137c53f921c901ee40df056a7f940a6acdb0dd8f05763deec8cf495a40848" origin="Generated by Gradle"/>
</artifact>
<artifact name="accompanist-drawablepainter-0.36.0.module">
<sha256 value="2d1da93fb4f071b277c47b8b0f8dfd21ee77bfb054b16a56793ff18d3841d80f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.accompanist" name="accompanist-permissions" version="0.28.0">
<artifact name="accompanist-permissions-0.28.0.aar">
<sha256 value="8e0c961b18dfa0581adcc6314b00fe3b60a3aa58d2a455819fa2e82b19a806e5" origin="Generated by Gradle"/>