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 package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import android.os.Bundle import androidx.compose.foundation.Image
import android.view.View import androidx.compose.foundation.background
import androidx.appcompat.widget.Toolbar 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.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController 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.R
import org.thoughtcrime.securesms.components.emoji.EmojiUtil import org.thoughtcrime.securesms.components.emoji.EmojiStrings
import org.thoughtcrime.securesms.components.settings.DSLConfiguration import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfileRow
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon import org.thoughtcrime.securesms.conversation.colors.AvatarColor
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.notifications.profiles.NotificationProfile import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
import org.thoughtcrime.securesms.util.navigation.safeNavigate 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 * Primary entry point for Notification Profiles. When user has no profiles, shows empty state, otherwise shows
* all current profiles. * all current profiles.
*/ */
class NotificationProfilesFragment : DSLSettingsFragment() { class NotificationProfilesFragment : ComposeFragment() {
private val viewModel: NotificationProfilesViewModel by viewModels( private val viewModel: NotificationProfilesViewModel by viewModels(
factoryProducer = { NotificationProfilesViewModel.Factory() } factoryProducer = { NotificationProfilesViewModel.Factory() }
) )
private val lifecycleDisposable = LifecycleDisposable() @Composable
private var toolbar: Toolbar? = null override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle(initialValue = NotificationProfilesState(profiles = emptyList()))
val callback = remember { DefaultNotificationProfilesScreenCallback() }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { NotificationProfilesScreen(
super.onViewCreated(view, savedInstanceState) state = state,
callbacks = callback
toolbar = view.findViewById(R.id.toolbar)
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
}
override fun onDestroyView() {
super.onDestroyView()
toolbar = null
}
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)
}
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) }
) )
}
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)
}
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 { } else {
sectionHeaderPref(R.string.NotificationProfilesFragment__profiles) LazyColumn(
modifier = Modifier.padding(paddingValues)
customPref( ) {
LargeIconClickPreference.Model( item {
title = DSLSettingsText.from(R.string.NotificationProfilesFragment__new_profile), Texts.SectionHeader(
icon = DSLSettingsIcon.from(R.drawable.add_to_a_group, NO_TINT), text = stringResource(R.string.NotificationProfilesFragment__profiles)
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
)
}
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)
) )
val activeProfile: NotificationProfile? = NotificationProfiles.getActiveProfile(profiles) Spacer(modifier = Modifier.height(12.dp))
profiles.sortedDescending().forEach { profile ->
customPref( Text(
NotificationProfilePreference.Model( text = stringResource(R.string.NotificationProfilesFragment__create_a_profile_to_receive_notifications_and_calls_only_from_the_people_and_groups_you_want_to_hear_from),
title = DSLSettingsText.from(profile.name), style = MaterialTheme.typography.bodyLarge,
summary = if (profile == activeProfile) DSLSettingsText.from(NotificationProfiles.getActiveProfileDescription(requireContext(), profile)) else null, textAlign = TextAlign.Center,
icon = if (profile.emoji.isNotEmpty()) EmojiUtil.convertToDrawable(requireContext(), profile.emoji)?.let { DSLSettingsIcon.from(it) } else DSLSettingsIcon.from(R.drawable.ic_moon_24, NO_TINT), modifier = Modifier
color = profile.color, .fillMaxWidth()
onClick = { .padding(horizontal = 24.dp)
findNavController().safeNavigate(NotificationProfilesFragmentDirections.actionNotificationProfilesFragmentToNotificationProfileDetailsFragment(profile.id))
}
) )
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.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import kotlinx.coroutines.flow.Flow
import io.reactivex.rxjava3.core.Flowable import kotlinx.coroutines.flow.map
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile import kotlinx.coroutines.reactive.asFlow
import kotlinx.coroutines.rx3.asFlow
class NotificationProfilesViewModel(private val repository: NotificationProfilesRepository) : ViewModel() { class NotificationProfilesViewModel(private val repository: NotificationProfilesRepository) : ViewModel() {
fun getProfiles(): Flowable<List<NotificationProfile>> { val state: Flow<NotificationProfilesState> = repository.getProfiles()
return repository.getProfiles() .asFlow()
.observeOn(AndroidSchedulers.mainThread()) .map { profiles -> NotificationProfilesState(profiles = profiles) }
}
class Factory() : ViewModelProvider.Factory { class Factory() : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { 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 package org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models
import android.view.View 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.airbnb.lottie.SimpleColorFilter
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import com.google.android.material.materialswitch.MaterialSwitch 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.R
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
import org.thoughtcrime.securesms.conversation.colors.AvatarColor 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.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.visible import org.thoughtcrime.securesms.util.visible
import java.util.UUID
/** /**
* DSL custom preference for showing Notification Profile rows. * 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>