From 07f33d22bffe4a2e2989a409f37d0a21c94bc694 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 31 Oct 2025 14:25:16 -0300 Subject: [PATCH] Convert NotificationProfilesSettingsFragment to compose. --- .../profiles/NotificationProfilesFragment.kt | 303 ++++++++++++++---- .../profiles/NotificationProfilesState.kt | 9 + .../profiles/NotificationProfilesViewModel.kt | 14 +- .../profiles/models/NoNotificationProfiles.kt | 36 --- .../models/NotificationProfilePreference.kt | 153 +++++++++ .../layout/notification_profiles_empty.xml | 64 ---- 6 files changed, 401 insertions(+), 178 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/NotificationProfilesState.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/models/NoNotificationProfiles.kt delete mode 100644 app/src/main/res/layout/notification_profiles_empty.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/NotificationProfilesFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/NotificationProfilesFragment.kt index c0408b333a..0a849a2922 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/NotificationProfilesFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/NotificationProfilesFragment.kt @@ -1,103 +1,264 @@ package org.thoughtcrime.securesms.components.settings.app.notifications.profiles -import android.os.Bundle -import android.view.View -import androidx.appcompat.widget.Toolbar +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +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.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.fragment.findNavController -import org.signal.core.util.concurrent.LifecycleDisposable +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Rows +import org.signal.core.ui.compose.Scaffolds +import org.signal.core.ui.compose.Texts +import org.signal.core.ui.compose.horizontalGutters import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.emoji.EmojiUtil -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.NO_TINT -import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NoNotificationProfiles -import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfilePreference -import org.thoughtcrime.securesms.components.settings.configure -import org.thoughtcrime.securesms.components.settings.conversation.preferences.LargeIconClickPreference +import org.thoughtcrime.securesms.components.emoji.EmojiStrings +import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfileRow +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.conversation.colors.AvatarColor import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile -import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter +import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId +import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule import org.thoughtcrime.securesms.util.navigation.safeNavigate +import java.util.UUID /** * Primary entry point for Notification Profiles. When user has no profiles, shows empty state, otherwise shows * all current profiles. */ -class NotificationProfilesFragment : DSLSettingsFragment() { +class NotificationProfilesFragment : ComposeFragment() { private val viewModel: NotificationProfilesViewModel by viewModels( factoryProducer = { NotificationProfilesViewModel.Factory() } ) - private val lifecycleDisposable = LifecycleDisposable() - private var toolbar: Toolbar? = null + @Composable + override fun FragmentContent() { + val state by viewModel.state.collectAsStateWithLifecycle(initialValue = NotificationProfilesState(profiles = emptyList())) + val callback = remember { DefaultNotificationProfilesScreenCallback() } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - toolbar = view.findViewById(R.id.toolbar) - - lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle) + NotificationProfilesScreen( + state = state, + callbacks = callback + ) } - override fun onDestroyView() { - super.onDestroyView() - toolbar = null + inner class DefaultNotificationProfilesScreenCallback : NotificationProfilesScreenCallback { + override fun onNavigationClick() { + requireActivity().onBackPressedDispatcher.onBackPressed() + } + + override fun onCreateNewProfile() { + findNavController().safeNavigate(R.id.action_notificationProfilesFragment_to_editNotificationProfileFragment) + } + + override fun onProfileClick(profileId: Long) { + findNavController().safeNavigate(NotificationProfilesFragmentDirections.actionNotificationProfilesFragmentToNotificationProfileDetailsFragment(profileId)) + } + } +} + +@Composable +fun NotificationProfilesScreen( + state: NotificationProfilesState, + callbacks: NotificationProfilesScreenCallback +) { + val title = if (state.profiles.isEmpty()) { + "" + } else { + stringResource(R.string.NotificationsSettingsFragment__notification_profiles) } - override fun bindAdapter(adapter: MappingAdapter) { - NoNotificationProfiles.register(adapter) - LargeIconClickPreference.register(adapter) - NotificationProfilePreference.register(adapter) - - lifecycleDisposable += viewModel.getProfiles() - .subscribe { profiles -> - if (profiles.isEmpty()) { - toolbar?.title = "" - } else { - toolbar?.setTitle(R.string.NotificationsSettingsFragment__notification_profiles) + Scaffolds.Settings( + title = title, + onNavigationClick = callbacks::onNavigationClick, + navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24), + navigationContentDescription = stringResource(R.string.Material3SearchToolbar__close) + ) { paddingValues -> + if (state.profiles.isEmpty()) { + NoNotificationProfilesEmpty( + onCreateProfileClick = callbacks::onCreateNewProfile, + modifier = Modifier.padding(paddingValues) + ) + } else { + LazyColumn( + modifier = Modifier.padding(paddingValues) + ) { + item { + Texts.SectionHeader( + text = stringResource(R.string.NotificationProfilesFragment__profiles) + ) } - adapter.submitList(getConfiguration(profiles).toMappingModelList()) - } - } - private fun getConfiguration(profiles: List): DSLConfiguration { - return configure { - if (profiles.isEmpty()) { - customPref( - NoNotificationProfiles.Model( - onClick = { findNavController().safeNavigate(R.id.action_notificationProfilesFragment_to_editNotificationProfileFragment) } + item { + Rows.TextRow( + text = { + Text(text = stringResource(R.string.NotificationProfilesFragment__new_profile)) + }, + icon = { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.symbol_plus_24), + contentDescription = null, + modifier = Modifier + .size(40.dp) + .background(color = MaterialTheme.colorScheme.secondaryContainer, shape = CircleShape) + .padding(8.dp) + ) + }, + onClick = callbacks::onCreateNewProfile ) - ) - } else { - sectionHeaderPref(R.string.NotificationProfilesFragment__profiles) + } - customPref( - LargeIconClickPreference.Model( - title = DSLSettingsText.from(R.string.NotificationProfilesFragment__new_profile), - icon = DSLSettingsIcon.from(R.drawable.add_to_a_group, NO_TINT), - onClick = { findNavController().safeNavigate(R.id.action_notificationProfilesFragment_to_editNotificationProfileFragment) } - ) - ) - - val activeProfile: NotificationProfile? = NotificationProfiles.getActiveProfile(profiles) - profiles.sortedDescending().forEach { profile -> - customPref( - NotificationProfilePreference.Model( - title = DSLSettingsText.from(profile.name), - summary = if (profile == activeProfile) DSLSettingsText.from(NotificationProfiles.getActiveProfileDescription(requireContext(), profile)) else null, - icon = if (profile.emoji.isNotEmpty()) EmojiUtil.convertToDrawable(requireContext(), profile.emoji)?.let { DSLSettingsIcon.from(it) } else DSLSettingsIcon.from(R.drawable.ic_moon_24, NO_TINT), - color = profile.color, - onClick = { - findNavController().safeNavigate(NotificationProfilesFragmentDirections.actionNotificationProfilesFragmentToNotificationProfileDetailsFragment(profile.id)) - } + state.profiles.sortedDescending().forEach { profile -> + item { + NotificationProfileRow( + profile = profile, + isActiveProfile = profile == state.activeProfile, + onClick = callbacks::onProfileClick ) - ) + } } } } } } + +@Composable +private fun NoNotificationProfilesEmpty( + onCreateProfileClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .horizontalGutters(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(96.dp)) + + Box( + modifier = Modifier + .size(88.dp) + .background( + color = Color(AvatarColor.A100.colorInt()), + shape = CircleShape + ) + .padding(20.dp), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.drawable.ic_sleeping_face), + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = stringResource(R.string.NotificationProfilesFragment__notification_profiles), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = stringResource(R.string.NotificationProfilesFragment__create_a_profile_to_receive_notifications_and_calls_only_from_the_people_and_groups_you_want_to_hear_from), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + + Spacer(modifier = Modifier.weight(1f)) + + Button( + onClick = onCreateProfileClick, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.NotificationProfilesFragment__create_profile)) + } + } +} + +@DayNightPreviews +@Composable +fun NotificationProfilesScreenPreview() { + Previews.Preview { + val profile = remember { + NotificationProfile( + id = 1L, + name = "Test Profile", + emoji = EmojiStrings.AUDIO, + createdAt = System.currentTimeMillis(), + schedule = NotificationProfileSchedule( + id = 1 + ), + notificationProfileId = NotificationProfileId(UUID.randomUUID()) + ) + } + + NotificationProfilesScreen( + state = NotificationProfilesState( + profiles = listOf( + profile + ), + activeProfile = null + ), + callbacks = NotificationProfilesScreenCallback.Empty + ) + } +} + +@DayNightPreviews +@Composable +private fun NoNotificationProfilesEmptyPreview() { + Previews.Preview { + NoNotificationProfilesEmpty( + onCreateProfileClick = {} + ) + } +} + +interface NotificationProfilesScreenCallback { + fun onNavigationClick() + fun onCreateNewProfile() + fun onProfileClick(profileId: Long) + + object Empty : NotificationProfilesScreenCallback { + override fun onNavigationClick() = Unit + override fun onCreateNewProfile() = Unit + override fun onProfileClick(profileId: Long) = Unit + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/NotificationProfilesState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/NotificationProfilesState.kt new file mode 100644 index 0000000000..17c616d140 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/NotificationProfilesState.kt @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.components.settings.app.notifications.profiles + +import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile +import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles + +data class NotificationProfilesState( + val profiles: List, + val activeProfile: NotificationProfile? = NotificationProfiles.getActiveProfile(profiles) +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/NotificationProfilesViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/NotificationProfilesViewModel.kt index 785719cb32..2ec7060d85 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/NotificationProfilesViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/NotificationProfilesViewModel.kt @@ -2,16 +2,16 @@ package org.thoughtcrime.securesms.components.settings.app.notifications.profile import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Flowable -import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.rx3.asFlow class NotificationProfilesViewModel(private val repository: NotificationProfilesRepository) : ViewModel() { - fun getProfiles(): Flowable> { - return repository.getProfiles() - .observeOn(AndroidSchedulers.mainThread()) - } + val state: Flow = repository.getProfiles() + .asFlow() + .map { profiles -> NotificationProfilesState(profiles = profiles) } class Factory() : ViewModelProvider.Factory { override fun create(modelClass: Class): T { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/models/NoNotificationProfiles.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/models/NoNotificationProfiles.kt deleted file mode 100644 index 01ae5d3d87..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/models/NoNotificationProfiles.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models - -import android.view.View -import android.widget.ImageView -import com.airbnb.lottie.SimpleColorFilter -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.settings.PreferenceModel -import org.thoughtcrime.securesms.conversation.colors.AvatarColor -import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter -import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder - -/** - * DSL custom preference for showing no profiles/empty state. - */ -object NoNotificationProfiles { - - fun register(adapter: MappingAdapter) { - adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.notification_profiles_empty)) - } - - class Model(val onClick: () -> Unit) : PreferenceModel() { - override fun areItemsTheSame(newItem: Model): Boolean = true - } - - class ViewHolder(itemView: View) : MappingViewHolder(itemView) { - - private val icon: ImageView = findViewById(R.id.notification_profiles_empty_icon) - private val button: View = findViewById(R.id.notification_profiles_empty_create_profile) - - override fun bind(model: Model) { - icon.background.colorFilter = SimpleColorFilter(AvatarColor.A100.colorInt()) - button.setOnClickListener { model.onClick() } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/models/NotificationProfilePreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/models/NotificationProfilePreference.kt index b2ab0c75fb..f177909ebf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/models/NotificationProfilePreference.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/models/NotificationProfilePreference.kt @@ -1,17 +1,51 @@ package org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models import android.view.View +import androidx.compose.foundation.Image +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp import com.airbnb.lottie.SimpleColorFilter +import com.google.accompanist.drawablepainter.rememberDrawablePainter import com.google.android.material.materialswitch.MaterialSwitch +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.horizontalGutters import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.emoji.EmojiUtil 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.conversation.colors.AvatarColor +import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile +import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId +import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule +import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.visible +import java.util.UUID /** * DSL custom preference for showing Notification Profile rows. @@ -48,3 +82,122 @@ object NotificationProfilePreference { } } } + +@Composable +fun NotificationProfileRow( + profile: NotificationProfile, + isActiveProfile: Boolean = false, + showSwitch: Boolean = false, + enabled: Boolean = true, + onClick: (Long) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = { onClick(profile.id) }) + .horizontalGutters() + .padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = Color(profile.color.colorInt()), + shape = CircleShape + ) + .padding(8.dp), + contentAlignment = Alignment.Center + ) { + if (profile.emoji.isNotEmpty()) { + val emojiDrawable = remember(profile.emoji) { EmojiUtil.convertToDrawable(context, profile.emoji) } + + Image( + painter = rememberDrawablePainter(drawable = emojiDrawable), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } else { + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_moon_24), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = profile.name, + style = MaterialTheme.typography.bodyLarge + ) + + val summary = remember(isActiveProfile) { + if (isActiveProfile) { + NotificationProfiles.getActiveProfileDescription(context, profile) + } else { + null + } + } + + if (summary != null) { + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + if (showSwitch) { + Switch( + checked = isActiveProfile, + onCheckedChange = { onClick(profile.id) }, + enabled = enabled + ) + } + } +} + +@DayNightPreviews +@Composable +private fun NotificationProfileRowPreview() { + Previews.Preview { + Column { + NotificationProfileRow( + profile = NotificationProfile( + id = 1L, + name = "Work", + createdAt = 0L, + schedule = NotificationProfileSchedule( + id = 1L + ), + emoji = "", + notificationProfileId = NotificationProfileId(UUID.randomUUID()) + ), + onClick = {} + ) + + NotificationProfileRow( + profile = NotificationProfile( + id = 1L, + name = "Sleep", + createdAt = 0L, + schedule = NotificationProfileSchedule( + id = 1L + ), + emoji = "", + notificationProfileId = NotificationProfileId(UUID.randomUUID()) + ), + onClick = {} + ) + } + } +} diff --git a/app/src/main/res/layout/notification_profiles_empty.xml b/app/src/main/res/layout/notification_profiles_empty.xml deleted file mode 100644 index d1d3ac0ab3..0000000000 --- a/app/src/main/res/layout/notification_profiles_empty.xml +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file