diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2e75b87979..20f53edad8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/banner/BannerManager.kt b/app/src/main/java/org/thoughtcrime/securesms/banner/BannerManager.kt index 9725b5d78a..361b8d702b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/banner/BannerManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/banner/BannerManager.kt @@ -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?) + + banner?.let { nonNullBanner -> + val state by nonNullBanner.dataFlow.collectAsStateWithLifecycle(initialValue = null) + state?.let { model -> + nonNullBanner.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp)) + } + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/Emojifier.kt b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/Emojifier.kt new file mode 100644 index 0000000000..7b6e56b911 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/Emojifier.kt @@ -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) -> 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 = 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") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt index af380361cf..327c9d7255 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt @@ -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 - 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 + + val context = LocalContext.current + val bannerManager = remember { + BannerManager( + banners = listOf( + DeprecatedBuildBanner(), + UnauthorizedBanner(context) + ) + ) } + + AppSettingsContent( + self = self!!, + state = state!!, + bannerManager = bannerManager, + callbacks = this + ) } - private fun initializeBanners() { - this.bannerManager = 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 - } - ) + override fun onNavigationClick() { + requireActivity().finishAfterTransition() + } - this.bannerManager?.updateContent(bannerView.get()) + override fun navigate(actionId: Int) { + findNavController().safeNavigate(actionId) + } - viewModel.refreshDeprecatedOrUnregistered() + 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() { - override fun areItemsTheSame(newItem: SubscriptionPreference): Boolean { - return true - } +@Composable +private fun AppSettingsContent( + self: BioRecipientState, + state: AppSettingsState, + bannerManager: BannerManager, + callbacks: Callbacks +) { + val isRegisteredAndUpToDate by rememberUpdatedState(state.isRegisteredAndUpToDate()) - override fun areContentsTheSame(newItem: SubscriptionPreference): Boolean { - return super.areContentsTheSame(newItem) && isActive == newItem.isActive - } - } + 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() - private class SubscriptionPreferenceViewHolder(itemView: View) : PreferenceViewHolder(itemView) { - override fun bind(model: SubscriptionPreference) { - super.bind(model) - itemView.setOnClickListener { model.onClick(model.isActive) } - itemView.setOnLongClickListener { model.onLongClick() } - } - } + LazyColumn { + item { + BioRow( + self = self, + callbacks = callbacks + ) + } - private class BioPreference(val recipient: Recipient, val onRowClicked: () -> Unit, val onQrButtonClicked: () -> Unit) : PreferenceModel() { - override fun areContentsTheSame(newItem: BioPreference): Boolean { - return super.areContentsTheSame(newItem) && recipient.hasSameContent(newItem.recipient) - } + 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) + } + ) + } - override fun areItemsTheSame(newItem: BioPreference): Boolean { - return recipient == 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 + ) + } - private class BioPreferenceViewHolder(itemView: View) : PreferenceViewHolder(itemView) { + item { + val context = LocalContext.current + val donateUrl = stringResource(R.string.donate_url) - 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) + Rows.TextRow( + text = { + Text( + text = stringResource(R.string.preferences__donate_to_signal), + modifier = Modifier.weight(1f) + ) - init { - aboutView.setOverflowText(" ") - } + if (state.hasExpiredGiftBadge) { + Icon( + painter = painterResource(R.drawable.symbol_info_fill_24), + tint = colorResource(R.color.signal_accent_primary), + contentDescription = null + ) + } + }, + 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 { + CommunicationActions.openBrowserLink(context, donateUrl) + } + }, + onLongClick = { + callbacks.copyDonorBadgeSubscriberIdToClipboard() + } + ) + } - override fun bind(model: BioPreference) { - super.bind(model) + item { + Dividers.Default() + } - itemView.setOnClickListener { model.onRowClicked() } + 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) + } + ) + } - 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()) + 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 + ) + } - titleView.visibility = View.VISIBLE - summaryView.visibility = View.VISIBLE - avatarView.visibility = View.VISIBLE + 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 + ) + } - if (SignalStore.account.username.isNotNullOrBlank()) { - qrButton.visibility = View.VISIBLE - qrButton.isClickable = true - qrButton.setOnClickListener { model.onQrButtonClicked() } - } else { - qrButton.visibility = View.GONE - } + 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 + ) + } - if (model.recipient.combinedAboutAndEmoji != null) { - aboutView.text = model.recipient.combinedAboutAndEmoji - aboutView.visibility = View.VISIBLE - } else { - aboutView.visibility = View.GONE - } - } - } + 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 + ) + } - private class PaymentsPreference(val unreadCount: Int, val onClick: () -> Unit) : PreferenceModel() { - override fun areContentsTheSame(newItem: PaymentsPreference): Boolean { - return super.areContentsTheSame(newItem) && unreadCount == newItem.unreadCount - } + 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 + ) + } + } - override fun areItemsTheSame(newItem: PaymentsPreference): Boolean { - return true - } - } + 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) + } + ) + } - private class PaymentsPreferenceViewHolder(itemView: View) : MappingViewHolder(itemView) { + 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) + } + ) + } + } - private val unreadCountView: TextView = itemView.findViewById(R.id.unread_indicator) + if (state.showPayments) { + item { + Dividers.Default() + } - override fun bind(model: PaymentsPreference) { - unreadCountView.text = model.unreadCount.toString() - unreadCountView.visibility = if (model.unreadCount > 0) View.VISIBLE else View.GONE + item { + Rows.TextRow( + text = { + Text( + text = stringResource(R.string.preferences__payments), + modifier = Modifier.weight(1f) + ) - itemView.setOnClickListener { - model.onClick() + 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 { + 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) + ) + } + } + } + + 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) + ) + } + } + } +} + +@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 + ) + } +} + +@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 + ) + } +} + +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") +} + +private object EmptyCallbacks : Callbacks + +private class TestBanner : Banner() { + override val enabled: Boolean = true + override val dataFlow: Flow = 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 + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsState.kt index 59207d1fd6..40bb8429d7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsState.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt index 15e14cd3ea..ffcf4ebee5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt @@ -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.self().live().liveData private val disposables = CompositeDisposable() val state: LiveData = store.stateLiveData + val self: LiveData = 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 -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/BioRecipientState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/BioRecipientState.kt new file mode 100644 index 0000000000..f6b6cbd84a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/BioRecipientState.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/BadgeImage.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/BadgeImage.kt index c090ec401b..cd5d5d1d17 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/BadgeImage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/BadgeImage.kt @@ -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?, diff --git a/app/src/main/res/layout/bio_preference_item.xml b/app/src/main/res/layout/bio_preference_item.xml deleted file mode 100644 index 56a0a6c0d3..0000000000 --- a/app/src/main/res/layout/bio_preference_item.xml +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/dsl_payments_preference.xml b/app/src/main/res/layout/dsl_payments_preference.xml deleted file mode 100644 index aab4818949..0000000000 --- a/app/src/main/res/layout/dsl_payments_preference.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/core-ui/src/main/java/org/signal/core/ui/Rows.kt b/core-ui/src/main/java/org/signal/core/ui/Rows.kt index 942d409b95..b7e84eed45 100644 --- a/core-ui/src/main/java/org/signal/core/ui/Rows.kt +++ b/core-ui/src/main/java/org/signal/core/ui/Rows.kt @@ -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 ) { diff --git a/dependencies.gradle.kts b/dependencies.gradle.kts index 873f6b1bbe..3d17954329 100644 --- a/dependencies.gradle.kts +++ b/dependencies.gradle.kts @@ -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") diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index cbb733a340..91bfb2b936 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -4872,6 +4872,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + +