Convert NotificationProfilesSettingsFragment to compose.

This commit is contained in:
Alex Hart
2025-10-31 14:25:16 -03:00
committed by Michelle Tang
parent 19192437ad
commit 07f33d22bf
6 changed files with 401 additions and 178 deletions

View File

@@ -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<NotificationProfile>): 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
}
}

View File

@@ -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<NotificationProfile>,
val activeProfile: NotificationProfile? = NotificationProfiles.getActiveProfile(profiles)
)

View File

@@ -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<List<NotificationProfile>> {
return repository.getProfiles()
.observeOn(AndroidSchedulers.mainThread())
}
val state: Flow<NotificationProfilesState> = repository.getProfiles()
.asFlow()
.map { profiles -> NotificationProfilesState(profiles = profiles) }
class Factory() : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {

View File

@@ -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<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean = true
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(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() }
}
}
}

View File

@@ -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 = {}
)
}
}
}

View File

@@ -1,64 +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"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="24dp">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/notification_profiles_empty_icon"
android:layout_width="88dp"
android:layout_height="88dp"
android:layout_marginTop="96dp"
android:background="@drawable/tinted_circle"
android:padding="20dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_sleeping_face"
tools:backgroundTint="#E3E3FE" />
<TextView
android:id="@+id/notification_profiles_empty_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="20dp"
android:layout_marginEnd="24dp"
android:gravity="center"
android:text="@string/NotificationProfilesFragment__notification_profiles"
android:textAppearance="@style/TextAppearance.Signal.Title1"
android:hyphenationFrequency="normal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/notification_profiles_empty_icon" />
<TextView
android:id="@+id/notification_profiles_empty_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="24dp"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Signal.Body1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/notification_profiles_empty_title"
android:text="@string/NotificationProfilesFragment__create_a_profile_to_receive_notifications_and_calls_only_from_the_people_and_groups_you_want_to_hear_from" />
<com.google.android.material.button.MaterialButton
android:id="@+id/notification_profiles_empty_create_profile"
style="@style/Signal.Widget.Button.Large.Primary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/NotificationProfilesFragment__create_profile"
app:layout_constraintVertical_bias="1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/notification_profiles_empty_description" />
</androidx.constraintlayout.widget.ConstraintLayout>