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.dnsjava)
implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.collections.immutable)
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
implementation(libs.accompanist.drawablepainter)
implementation(libs.kotlin.stdlib.jdk8) implementation(libs.kotlin.stdlib.jdk8)
implementation(libs.kotlin.reflect) implementation(libs.kotlin.reflect)
implementation(libs.kotlinx.coroutines.play.services) 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.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp 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.os.Bundle
import android.view.View import android.view.View
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.annotation.IdRes
import androidx.annotation.StringRes 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.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavDirections
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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.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.BannerManager
import org.thoughtcrime.securesms.banner.banners.DeprecatedBuildBanner import org.thoughtcrime.securesms.banner.banners.DeprecatedBuildBanner
import org.thoughtcrime.securesms.banner.banners.UnauthorizedBanner import org.thoughtcrime.securesms.banner.banners.UnauthorizedBanner
import org.thoughtcrime.securesms.components.AvatarImageView import org.thoughtcrime.securesms.banner.ui.compose.Action
import org.thoughtcrime.securesms.components.emoji.EmojiTextView import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
import org.thoughtcrime.securesms.components.settings.DSLConfiguration import org.thoughtcrime.securesms.banner.ui.compose.Importance
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment import org.thoughtcrime.securesms.components.emoji.Emojifier
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageMedium
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.components.settings.app.subscription.InAppPaymentsRepository 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.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.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.Environment import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.Util 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.navigation.safeNavigate
import org.thoughtcrime.securesms.util.views.Stub
import org.thoughtcrime.securesms.util.visible
class AppSettingsFragment : DSLSettingsFragment( class AppSettingsFragment : ComposeFragment(), Callbacks {
titleId = R.string.text_secure_normal__menu_settings,
layoutId = R.layout.dsl_settings_fragment_with_reminder
) {
private val viewModel: AppSettingsViewModel by viewModels() private val viewModel: AppSettingsViewModel by viewModels()
private var bannerManager: BannerManager? = null
private lateinit var bannerView: Stub<ComposeView>
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycle.addObserver(InAppPaymentsBottomSheetDelegate(childFragmentManager, viewLifecycleOwner)) viewLifecycleOwner.lifecycle.addObserver(InAppPaymentsBottomSheetDelegate(childFragmentManager, viewLifecycleOwner))
super.onViewCreated(view, savedInstanceState)
bannerView = ViewUtil.findStubById(view, R.id.banner_stub)
initializeBanners()
} }
override fun bindAdapter(adapter: MappingAdapter) { @Composable
adapter.registerFactory(BioPreference::class.java, LayoutFactory(::BioPreferenceViewHolder, R.layout.bio_preference_item)) override fun FragmentContent() {
adapter.registerFactory(PaymentsPreference::class.java, LayoutFactory(::PaymentsPreferenceViewHolder, R.layout.dsl_payments_preference)) val state by viewModel.state.observeAsState()
adapter.registerFactory(SubscriptionPreference::class.java, LayoutFactory(::SubscriptionPreferenceViewHolder, R.layout.dsl_preference_item)) val self by viewModel.self.observeAsState()
viewModel.state.observe(viewLifecycleOwner) { state -> if (state == null) return
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}
private fun initializeBanners() { val context = LocalContext.current
this.bannerManager = BannerManager( val bannerManager = remember {
BannerManager(
banners = listOf( banners = listOf(
DeprecatedBuildBanner(), DeprecatedBuildBanner(),
UnauthorizedBanner(requireContext()) UnauthorizedBanner(context)
),
onNewBannerShownListener = {
if (bannerView.resolved()) {
bannerView.get().addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ ->
recyclerView?.setPadding(0, bottom - top, 0, 0)
}
recyclerView?.clipToPadding = false
}
},
onNoBannerShownListener = {
recyclerView?.clipToPadding = true
}
) )
)
}
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() { override fun onResume() {
super.onResume() super.onResume()
viewModel.refreshExpiredGiftBadge() viewModel.refreshExpiredGiftBadge()
this.bannerManager?.updateContent(bannerView.get()) viewModel.refreshDeprecatedOrUnregistered()
} }
private fun getConfiguration(state: AppSettingsState): DSLConfiguration { override fun copyDonorBadgeSubscriberIdToClipboard() {
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 {
copySubscriberIdToClipboard( copySubscriberIdToClipboard(
subscriberType = InAppPaymentSubscriberRecord.Type.DONATION, subscriberType = InAppPaymentSubscriberRecord.Type.DONATION,
toastSuccessStringRes = R.string.AppSettingsFragment__copied_donor_subscriber_id_to_clipboard toastSuccessStringRes = R.string.AppSettingsFragment__copied_donor_subscriber_id_to_clipboard
) )
return true
} }
private fun copyRemoteBackupsSubscriberIdToClipboard(): Boolean { override fun copyRemoteBackupsSubscriberIdToClipboard() {
copySubscriberIdToClipboard( copySubscriberIdToClipboard(
subscriberType = InAppPaymentSubscriberRecord.Type.BACKUP, subscriberType = InAppPaymentSubscriberRecord.Type.BACKUP,
toastSuccessStringRes = R.string.AppSettingsFragment__copied_backups_subscriber_id_to_clipboard toastSuccessStringRes = R.string.AppSettingsFragment__copied_backups_subscriber_id_to_clipboard
) )
return true
} }
private fun copySubscriberIdToClipboard( 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 { @Composable
return super.areContentsTheSame(newItem) && isActive == newItem.isActive 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) { item {
override fun bind(model: SubscriptionPreference) { Rows.TextRow(
super.bind(model) text = stringResource(R.string.AccountSettingsFragment__account),
itemView.setOnClickListener { model.onClick(model.isActive) } icon = painterResource(R.drawable.symbol_person_circle_24),
itemView.setOnLongClickListener { model.onLongClick() } onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_accountSettingsFragment)
} }
)
} }
private class BioPreference(val recipient: Recipient, val onRowClicked: () -> Unit, val onQrButtonClicked: () -> Unit) : PreferenceModel<BioPreference>() { item {
override fun areContentsTheSame(newItem: BioPreference): Boolean { Rows.TextRow(
return super.areContentsTheSame(newItem) && recipient.hasSameContent(newItem.recipient) 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 { item {
return recipient == newItem.recipient 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
)
} }
} },
icon = {
private class BioPreferenceViewHolder(itemView: View) : PreferenceViewHolder<BioPreference>(itemView) { Icon(
painter = painterResource(R.drawable.symbol_heart_24),
private val avatarView: AvatarImageView = itemView.findViewById(R.id.icon) contentDescription = null,
private val aboutView: EmojiTextView = itemView.findViewById(R.id.about) tint = MaterialTheme.colorScheme.onSurface
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) onClick = {
if (state.allowUserToGoToDonationManagementScreen) {
init { callbacks.navigate(R.id.action_appSettingsFragment_to_manageDonationsFragment)
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() }
} else { } else {
qrButton.visibility = View.GONE CommunicationActions.openBrowserLink(context, donateUrl)
}
},
onLongClick = {
callbacks.copyDonorBadgeSubscriberIdToClipboard()
}
)
} }
if (model.recipient.combinedAboutAndEmoji != null) { item {
aboutView.text = model.recipient.combinedAboutAndEmoji Dividers.Default()
aboutView.visibility = View.VISIBLE }
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 { } 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>() { if (hasUsername) {
override fun areContentsTheSame(newItem: PaymentsPreference): Boolean { IconButtons.IconButton(
return super.areContentsTheSame(newItem) && unreadCount == newItem.unreadCount 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) { private interface Callbacks {
unreadCountView.text = model.unreadCount.toString() fun onNavigationClick(): Unit = error("Not implemented.")
unreadCountView.visibility = if (model.unreadCount > 0) View.VISIBLE else View.GONE 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 { private object EmptyCallbacks : Callbacks
model.onClick()
} 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 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( data class AppSettingsState(
val self: Recipient,
val unreadPaymentsCount: Int, val unreadPaymentsCount: Int,
val hasExpiredGiftBadge: Boolean, val hasExpiredGiftBadge: Boolean,
val allowUserToGoToDonationManagementScreen: Boolean, val allowUserToGoToDonationManagementScreen: Boolean,
val userUnregistered: 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 { fun isRegisteredAndUpToDate(): Boolean {
return !userUnregistered && !clientDeprecated return !userUnregistered && !clientDeprecated

View file

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy import io.reactivex.rxjava3.kotlin.subscribeBy
@ -19,7 +20,6 @@ class AppSettingsViewModel : ViewModel() {
private val store = Store( private val store = Store(
AppSettingsState( AppSettingsState(
Recipient.self(),
0, 0,
SignalStore.inAppPayments.getExpiredGiftBadge() != null, SignalStore.inAppPayments.getExpiredGiftBadge() != null,
SignalStore.inAppPayments.isLikelyASustainer() || InAppDonations.hasAtLeastOnePaymentMethodAvailable(), SignalStore.inAppPayments.isLikelyASustainer() || InAppDonations.hasAtLeastOnePaymentMethodAvailable(),
@ -29,14 +29,13 @@ class AppSettingsViewModel : ViewModel() {
) )
private val unreadPaymentsLiveData = UnreadPaymentsLiveData() private val unreadPaymentsLiveData = UnreadPaymentsLiveData()
private val selfLiveData: LiveData<Recipient> = Recipient.self().live().liveData
private val disposables = CompositeDisposable() private val disposables = CompositeDisposable()
val state: LiveData<AppSettingsState> = store.stateLiveData val state: LiveData<AppSettingsState> = store.stateLiveData
val self: LiveData<BioRecipientState> = Recipient.self().live().liveData.map { BioRecipientState(it) }
init { init {
store.update(unreadPaymentsLiveData) { payments, state -> state.copy(unreadPaymentsCount = payments.map { it.unreadCount }.orElse(0)) } 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( disposables += RecurringInAppPaymentRepository.getActiveSubscription(InAppPaymentSubscriberRecord.Type.DONATION).subscribeBy(
onSuccess = { activeSubscription -> 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) 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 @Composable
fun BadgeImage112( fun BadgeImage112(
badge: Badge?, 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 package org.signal.core.ui
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -132,6 +134,7 @@ object Rows {
icon: Painter? = null, icon: Painter? = null,
foregroundTint: Color = MaterialTheme.colorScheme.onSurface, foregroundTint: Color = MaterialTheme.colorScheme.onSurface,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true enabled: Boolean = true
) { ) {
TextRow( TextRow(
@ -157,6 +160,7 @@ object Rows {
}, },
modifier = modifier, modifier = modifier,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick,
enabled = enabled 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. * Customizable text row that allows [text] and [icon] to be provided as composable functions instead of primitives.
*/ */
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun TextRow( fun TextRow(
text: @Composable RowScope.() -> Unit, text: @Composable RowScope.() -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
icon: (@Composable RowScope.() -> Unit)? = null, icon: (@Composable RowScope.() -> Unit)? = null,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true enabled: Boolean = true
) { ) {
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(enabled = enabled && onClick != null, onClick = onClick ?: {}) .combinedClickable(
enabled = enabled && (onClick != null || onLongClick != null),
onClick = onClick ?: {},
onLongClick = onLongClick ?: {}
)
.padding(defaultPadding()), .padding(defaultPadding()),
verticalAlignment = CenterVertically verticalAlignment = CenterVertically
) { ) {

View file

@ -37,6 +37,7 @@ dependencyResolutionManagement {
// Accompanist // Accompanist
library("accompanist-permissions", "com.google.accompanist", "accompanist-permissions").versionRef("accompanist") library("accompanist-permissions", "com.google.accompanist", "accompanist-permissions").versionRef("accompanist")
library("accompanist-drawablepainter", "com.google.accompanist:accompanist-drawablepainter:0.36.0")
// Desugaring // Desugaring
library("android-tools-desugar", "com.android.tools:desugar_jdk_libs:1.1.6") 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"/> <sha256 value="2f6174e3049008f263fd832813390df645ac5c7cfa79f170ace58690810476f2" origin="Generated by Gradle"/>
</artifact> </artifact>
</component> </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"> <component group="com.google.accompanist" name="accompanist-permissions" version="0.28.0">
<artifact name="accompanist-permissions-0.28.0.aar"> <artifact name="accompanist-permissions-0.28.0.aar">
<sha256 value="8e0c961b18dfa0581adcc6314b00fe3b60a3aa58d2a455819fa2e82b19a806e5" origin="Generated by Gradle"/> <sha256 value="8e0c961b18dfa0581adcc6314b00fe3b60a3aa58d2a455819fa2e82b19a806e5" origin="Generated by Gradle"/>