Implement the majority of the new nicknames and notes feature.

This commit is contained in:
Alex Hart
2024-03-22 16:03:04 -03:00
committed by Nicholas Tinsley
parent 7a24554b68
commit 303929090b
20 changed files with 1504 additions and 23 deletions
+5
View File
@@ -970,6 +970,11 @@
android:windowSoftInputMode="stateVisible|adjustResize" android:windowSoftInputMode="stateVisible|adjustResize"
android:exported="false"/> android:exported="false"/>
<activity android:name=".nicknames.NicknameActivity"
android:theme="@style/TextSecure.LightTheme"
android:windowSoftInputMode="stateVisible|adjustResize"
android:exported="false"/>
<activity <activity
android:name=".payments.preferences.PaymentsActivity" android:name=".payments.preferences.PaymentsActivity"
android:theme="@style/TextSecure.LightRegistrationTheme" android:theme="@style/TextSecure.LightRegistrationTheme"
@@ -14,6 +14,7 @@ import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.doOnPreDraw import androidx.core.view.doOnPreDraw
@@ -77,6 +78,7 @@ import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreB
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository
import org.thoughtcrime.securesms.nicknames.NicknameActivity
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientExporter import org.thoughtcrime.securesms.recipients.RecipientExporter
@@ -92,6 +94,7 @@ import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.ContextUtil import org.thoughtcrime.securesms.util.ContextUtil
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.ExpirationUtil import org.thoughtcrime.securesms.util.ExpirationUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.Material3OnScrollHelper import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@@ -149,6 +152,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
private lateinit var toolbarTitle: TextView private lateinit var toolbarTitle: TextView
private lateinit var toolbarBackground: View private lateinit var toolbarBackground: View
private lateinit var addToGroupStoryDelegate: AddToGroupStoryDelegate private lateinit var addToGroupStoryDelegate: AddToGroupStoryDelegate
private lateinit var nicknameLauncher: ActivityResultLauncher<NicknameActivity.Args>
private val navController get() = Navigation.findNavController(requireView()) private val navController get() = Navigation.findNavController(requireView())
private val lifecycleDisposable = LifecycleDisposable() private val lifecycleDisposable = LifecycleDisposable()
@@ -216,6 +220,10 @@ class ConversationSettingsFragment : DSLSettingsFragment(
} }
override fun bindAdapter(adapter: MappingAdapter) { override fun bindAdapter(adapter: MappingAdapter) {
nicknameLauncher = registerForActivityResult(NicknameActivity.Contract()) {
// Intentionally left blank
}
val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments()) val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments())
BioTextPreference.register(adapter) BioTextPreference.register(adapter)
@@ -495,6 +503,21 @@ class ConversationSettingsFragment : DSLSettingsFragment(
) )
} }
if (FeatureFlags.nicknames() && state.recipient.isIndividual) {
clickPref(
title = DSLSettingsText.from(R.string.NicknameActivity__nickname),
icon = DSLSettingsIcon.from(R.drawable.symbol_edit_24),
onClick = {
nicknameLauncher.launch(
NicknameActivity.Args(
state.recipient.id,
false
)
)
}
)
}
if (!state.recipient.isReleaseNotes) { if (!state.recipient.isReleaseNotes) {
clickPref( clickPref(
title = DSLSettingsText.from(R.string.preferences__chat_color_and_wallpaper), title = DSLSettingsText.from(R.string.preferences__chat_color_and_wallpaper),
@@ -1739,6 +1739,20 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
} }
} }
fun setNicknameAndNote(id: RecipientId, nickname: ProfileName, note: String) {
val contentValues = contentValuesOf(
NICKNAME_GIVEN_NAME to nickname.givenName.nullIfBlank(),
NICKNAME_FAMILY_NAME to nickname.familyName.nullIfBlank(),
NICKNAME_JOINED_NAME to nickname.toString().nullIfBlank(),
NOTE to note.nullIfBlank()
)
if (update(id, contentValues)) {
rotateStorageId(id)
ApplicationDependencies.getDatabaseObserver().notifyRecipientChanged(id)
StorageSyncHelper.scheduleSyncForDataChange()
}
}
fun setProfileName(id: RecipientId, profileName: ProfileName) { fun setProfileName(id: RecipientId, profileName: ProfileName) {
val contentValues = ContentValues(1).apply { val contentValues = ContentValues(1).apply {
put(PROFILE_GIVEN_NAME, profileName.givenName.nullIfBlank()) put(PROFILE_GIVEN_NAME, profileName.givenName.nullIfBlank())
@@ -0,0 +1,500 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.nicknames
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs
import org.signal.core.ui.DropdownMenus
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.TextFields
import org.signal.core.util.getParcelableCompat
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarImage
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.viewModel
/**
* Fragment allowing a user to set a custom nickname for the given recipient.
*/
class NicknameActivity : PassphraseRequiredActivity(), NicknameContentCallback {
private val theme = DynamicNoActionBarTheme()
private val args: Args by lazy {
Args.fromBundle(intent.extras!!)
}
private val viewModel: NicknameViewModel by viewModel {
NicknameViewModel(args.recipientId)
}
override fun onPreCreate() {
theme.onCreate(this)
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
setContent {
val state by viewModel.state
LaunchedEffect(state.formState) {
if (state.formState == NicknameState.FormState.SAVED) {
supportFinishAfterTransition()
}
}
NicknameContent(
callback = remember { this },
state = state,
focusNoteFirst = args.focusNoteFirst
)
}
}
override fun onResume() {
super.onResume()
theme.onResume(this)
}
override fun onNavigationClick() {
supportFinishAfterTransition()
}
override fun onSaveClick() {
viewModel.save()
}
override fun onDeleteClick() {
viewModel.delete()
}
override fun onFirstNameChanged(value: String) {
viewModel.onFirstNameChanged(value)
}
override fun onLastNameChanged(value: String) {
viewModel.onLastNameChanged(value)
}
override fun onNoteChanged(value: String) {
viewModel.onNoteChanged(value)
}
/**
* @param recipientId The recipient to edit the nickname and note for
* @param focusNoteFirst Whether default focus should be on the edit note field
*/
data class Args(
val recipientId: RecipientId,
val focusNoteFirst: Boolean
) {
fun toBundle(): Bundle {
return bundleOf(
RECIPIENT_ID to recipientId,
FOCUS_NOTE_FIRST to focusNoteFirst
)
}
companion object {
private const val RECIPIENT_ID = "recipient_id"
private const val FOCUS_NOTE_FIRST = "focus_note_first"
fun fromBundle(bundle: Bundle): Args {
return Args(
recipientId = bundle.getParcelableCompat(RECIPIENT_ID, RecipientId::class.java)!!,
focusNoteFirst = bundle.getBoolean(FOCUS_NOTE_FIRST)
)
}
}
}
/**
* Launches the nickname activity with the proper arguments.
* Doesn't return a response, but is a helpful signal to know when to refresh UI.
*/
class Contract : ActivityResultContract<Args, Unit>() {
override fun createIntent(context: Context, input: Args): Intent {
return Intent(context, NicknameActivity::class.java).putExtras(input.toBundle())
}
override fun parseResult(resultCode: Int, intent: Intent?) = Unit
}
}
private interface NicknameContentCallback {
fun onNavigationClick()
fun onSaveClick()
fun onDeleteClick()
fun onFirstNameChanged(value: String)
fun onLastNameChanged(value: String)
fun onNoteChanged(value: String)
}
@Preview(name = "Light Theme", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun NicknameContentPreview() {
Previews.Preview {
val callback = remember {
object : NicknameContentCallback {
override fun onNavigationClick() = Unit
override fun onSaveClick() = Unit
override fun onDeleteClick() = Unit
override fun onFirstNameChanged(value: String) = Unit
override fun onLastNameChanged(value: String) = Unit
override fun onNoteChanged(value: String) = Unit
}
}
NicknameContent(
callback = callback,
state = NicknameState(
isEditing = true,
note = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod temor incididunt ut labore et dolore magna aliqua. Ut enim ad minimu"
),
focusNoteFirst = false
)
}
}
@Composable
private fun NicknameContent(
callback: NicknameContentCallback,
state: NicknameState,
focusNoteFirst: Boolean
) {
var displayDeletionDialog by remember { mutableStateOf(false) }
Scaffolds.Settings(
title = stringResource(id = R.string.NicknameActivity__nickname),
onNavigationClick = callback::onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24),
actions = {
if (state.isEditing) {
val menuController = remember {
DropdownMenus.MenuController()
}
IconButton(onClick = { menuController.toggle() }) {
Icon(
painter = painterResource(id = R.drawable.symbol_more_vertical_24),
contentDescription = null
)
}
DropdownMenus.Menu(
controller = menuController
) {
DropdownMenus.Item(
text = {
Text(text = stringResource(id = R.string.delete))
},
onClick = {
displayDeletionDialog = true
}
)
}
}
}
) { paddingValues ->
val firstNameFocusRequester = remember { FocusRequester() }
val noteFocusRequester = remember { FocusRequester() }
Column(
modifier = Modifier
.padding(paddingValues)
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) {
LazyColumn(modifier = Modifier.weight(1f)) {
item {
Text(
text = stringResource(id = R.string.NicknameActivity__nicknames_amp_notes),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
}
item {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp)
) {
if (state.recipient != null) {
AvatarImage(recipient = state.recipient, modifier = Modifier.size(80.dp))
} else {
Spacer(modifier = Modifier.size(80.dp))
}
}
}
item {
ClearableTextField(
value = state.firstName,
hint = stringResource(id = R.string.NicknameActivity__first_name),
clearContentDescription = stringResource(id = R.string.NicknameActivity__clear_first_name),
enabled = true,
singleLine = true,
onValueChange = callback::onFirstNameChanged,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier
.focusRequester(firstNameFocusRequester)
.fillMaxWidth()
.padding(bottom = 20.dp)
)
}
item {
ClearableTextField(
value = state.lastName,
hint = stringResource(id = R.string.NicknameActivity__last_name),
clearContentDescription = stringResource(id = R.string.NicknameActivity__clear_last_name),
enabled = state.firstName.isNotBlank(),
singleLine = true,
onValueChange = callback::onLastNameChanged,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 20.dp)
)
}
item {
ClearableTextField(
value = state.note,
hint = stringResource(id = R.string.NicknameActivity__note),
clearContentDescription = "",
clearable = false,
enabled = true,
onValueChange = callback::onNoteChanged,
keyboardActions = KeyboardActions(onDone = {
callback.onSaveClick()
}),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
charactersRemaining = state.noteCharactersRemaining,
modifier = Modifier
.focusRequester(noteFocusRequester)
.fillMaxWidth()
.padding(bottom = 20.dp)
)
}
}
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 20.dp)
) {
Buttons.LargeTonal(
onClick = callback::onSaveClick,
enabled = state.canSave
) {
Text(
text = stringResource(id = R.string.NicknameActivity__save)
)
}
}
}
if (displayDeletionDialog) {
Dialogs.SimpleAlertDialog(
title = stringResource(id = R.string.NicknameActivity__delete_nickname),
body = stringResource(id = R.string.NicknameActivity__this_will_permanently_delete_this_nickname_and_note),
confirm = stringResource(id = R.string.delete),
dismiss = stringResource(id = android.R.string.cancel),
onConfirm = {
callback.onDeleteClick()
},
onDismiss = { displayDeletionDialog = false }
)
}
LaunchedEffect(state.hasBecomeReady) {
if (state.hasBecomeReady) {
if (focusNoteFirst) {
noteFocusRequester.requestFocus()
} else {
firstNameFocusRequester.requestFocus()
}
}
}
}
}
@Preview(name = "Light Theme", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ClearableTextFieldPreview() {
Previews.Preview {
val focusRequester = remember { FocusRequester() }
Column(modifier = Modifier.padding(16.dp)) {
ClearableTextField(
value = "",
hint = "Without content",
enabled = true,
onValueChange = {},
clearContentDescription = ""
)
Spacer(modifier = Modifier.size(16.dp))
ClearableTextField(
value = "Test",
hint = "With Content",
enabled = true,
onValueChange = {},
clearContentDescription = ""
)
Spacer(modifier = Modifier.size(16.dp))
ClearableTextField(
value = "",
hint = "Disabled",
enabled = false,
onValueChange = {},
clearContentDescription = ""
)
Spacer(modifier = Modifier.size(16.dp))
ClearableTextField(
value = "",
hint = "Focused",
enabled = true,
onValueChange = {},
modifier = Modifier.focusRequester(focusRequester),
clearContentDescription = ""
)
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ClearableTextField(
value: String,
hint: String,
clearContentDescription: String,
enabled: Boolean,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
singleLine: Boolean = false,
clearable: Boolean = true,
charactersRemaining: Int = Int.MAX_VALUE,
keyboardActions: KeyboardActions = KeyboardActions.Default,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default
) {
var focused by remember { mutableStateOf(false) }
val displayCountdown = charactersRemaining <= 100
val clearButton: @Composable () -> Unit = {
ClearButton(
visible = focused,
onClick = { onValueChange("") },
contentDescription = clearContentDescription
)
}
Box(modifier = modifier) {
TextFields.TextField(
value = value,
onValueChange = onValueChange,
label = {
Text(text = hint)
},
enabled = enabled,
singleLine = singleLine,
keyboardActions = keyboardActions,
keyboardOptions = keyboardOptions,
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focused = it.hasFocus && clearable },
colors = TextFieldDefaults.colors(
unfocusedLabelColor = MaterialTheme.colorScheme.outline,
unfocusedIndicatorColor = MaterialTheme.colorScheme.outline
),
trailingIcon = if (clearable) clearButton else null,
contentPadding = TextFieldDefaults.contentPaddingWithLabel(end = if (displayCountdown) 48.dp else 16.dp)
)
AnimatedVisibility(
visible = displayCountdown,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(bottom = 10.dp, end = 12.dp)
) {
Text(
text = "$charactersRemaining",
style = MaterialTheme.typography.bodySmall,
color = if (charactersRemaining <= 5) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline
)
}
}
}
@Composable
private fun ClearButton(
visible: Boolean,
onClick: () -> Unit,
contentDescription: String
) {
AnimatedVisibility(visible = visible) {
IconButton(
onClick = onClick
) {
Icon(
painter = painterResource(id = R.drawable.symbol_x_circle_fill_24),
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.outline
)
}
}
}
@@ -0,0 +1,33 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.nicknames
import org.thoughtcrime.securesms.recipients.Recipient
data class NicknameState(
val recipient: Recipient? = null,
val firstName: String = "",
val lastName: String = "",
val note: String = "",
val noteCharactersRemaining: Int = 0,
val formState: FormState = FormState.LOADING,
val hasBecomeReady: Boolean = false,
val isEditing: Boolean = false
) {
private val isFormBlank: Boolean = firstName.isBlank() && lastName.isBlank() && note.isBlank()
private val hasFirstNameOrNote: Boolean = firstName.isNotBlank() || note.isNotBlank()
private val isFormReady: Boolean = formState == FormState.READY
private val isBlankFormDuringEdit: Boolean = isFormBlank && isEditing
val canSave: Boolean = isFormReady && (hasFirstNameOrNote || isBlankFormDuringEdit)
enum class FormState {
LOADING,
READY,
SAVING,
SAVED
}
}
@@ -0,0 +1,127 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.nicknames
import androidx.annotation.MainThread
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.BreakIteratorCompat
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
class NicknameViewModel(
private val recipientId: RecipientId
) : ViewModel() {
companion object {
private const val NAME_MAX_LENGTH = 26
private const val NOTE_MAX_LENGTH = 240
}
private val internalState = mutableStateOf(NicknameState())
private val iteratorCompat = BreakIteratorCompat.getInstance()
val state: MutableState<NicknameState> = internalState
private val recipientDisposable = Recipient.observable(recipientId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { recipient ->
internalState.value = if (state.value.formState == NicknameState.FormState.LOADING) {
val noteLength = iteratorCompat.run {
setText(recipient.note ?: "")
countBreaks()
}
NicknameState(
recipient = recipient,
firstName = recipient.nickname.givenName,
lastName = recipient.nickname.familyName,
note = recipient.note ?: "",
noteCharactersRemaining = NOTE_MAX_LENGTH - noteLength,
formState = NicknameState.FormState.READY,
hasBecomeReady = true,
isEditing = !recipient.nickname.isEmpty
)
} else {
state.value.copy(recipient = recipient)
}
}
override fun onCleared() {
recipientDisposable.dispose()
}
@MainThread
fun onFirstNameChanged(value: String) {
iteratorCompat.setText(value)
internalState.value = state.value.copy(firstName = iteratorCompat.take(NAME_MAX_LENGTH).toString())
}
@MainThread
fun onLastNameChanged(value: String) {
iteratorCompat.setText(value)
internalState.value = state.value.copy(lastName = iteratorCompat.take(NAME_MAX_LENGTH).toString())
}
@MainThread
fun onNoteChanged(value: String) {
iteratorCompat.setText(value)
val trimmed = iteratorCompat.take(NOTE_MAX_LENGTH)
val count = iteratorCompat.run {
setText(trimmed)
countBreaks()
}
internalState.value = state.value.copy(
note = iteratorCompat.take(NOTE_MAX_LENGTH).toString(),
noteCharactersRemaining = NOTE_MAX_LENGTH - count
)
}
@MainThread
fun delete() {
viewModelScope.launch {
internalState.value = state.value.copy(formState = NicknameState.FormState.SAVING)
withContext(Dispatchers.IO) {
SignalDatabase.recipients.setNicknameAndNote(
recipientId,
ProfileName.EMPTY,
""
)
}
internalState.value = state.value.copy(formState = NicknameState.FormState.SAVED)
}
}
@MainThread
fun save() {
viewModelScope.launch {
val stateSnapshot = state.value.copy(formState = NicknameState.FormState.SAVING)
internalState.value = stateSnapshot
withContext(Dispatchers.IO) {
SignalDatabase.recipients.setNicknameAndNote(
recipientId,
ProfileName.fromParts(stateSnapshot.firstName, stateSnapshot.lastName),
stateSnapshot.note
)
}
internalState.value = state.value.copy(formState = NicknameState.FormState.SAVED)
}
}
}
@@ -0,0 +1,169 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.nicknames
import android.os.Bundle
import android.text.util.Linkify
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
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.platform.LocalInspectionMode
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.os.bundleOf
import androidx.core.text.method.LinkMovementMethodCompat
import androidx.core.text.util.LinkifyCompat
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Previews
import org.signal.core.util.getParcelableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.viewModel
/**
* Allows user to view the full note for a given recipient.
*/
class ViewNoteSheet : ComposeBottomSheetDialogFragment() {
companion object {
private const val RECIPIENT_ID = "recipient_id"
@JvmStatic
fun create(recipientId: RecipientId): ViewNoteSheet {
return ViewNoteSheet().apply {
arguments = bundleOf(
RECIPIENT_ID to recipientId
)
}
}
}
private val recipientId: RecipientId by lazy {
requireArguments().getParcelableCompat(RECIPIENT_ID, RecipientId::class.java)!!
}
private val viewModel: ViewNoteSheetViewModel by viewModel {
ViewNoteSheetViewModel(recipientId)
}
private lateinit var editNoteLauncher: ActivityResultLauncher<NicknameActivity.Args>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
editNoteLauncher = registerForActivityResult(NicknameActivity.Contract()) {}
}
@Composable
override fun SheetContent() {
val note by remember { viewModel.note }
ViewNoteBottomSheetContent(
onEditNoteClick = this::onEditNoteClick,
note = note
)
}
private fun onEditNoteClick() {
editNoteLauncher.launch(
NicknameActivity.Args(
recipientId = recipientId,
focusNoteFirst = true
)
)
dismissAllowingStateLoss()
}
}
@Preview
@Composable
private fun ViewNoteBottomSheetContentPreview() {
Previews.Preview {
ViewNoteBottomSheetContent(
onEditNoteClick = {},
note = "Lorem ipsum dolor sit amet\n\nWebsite: https://example.com"
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ViewNoteBottomSheetContent(
onEditNoteClick: () -> Unit,
note: String
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) {
BottomSheets.Handle()
CenterAlignedTopAppBar(
title = {
Text(
text = stringResource(id = R.string.ViewNoteSheet__note)
)
},
actions = {
IconButton(onClick = onEditNoteClick) {
Icon(
painter = painterResource(id = R.drawable.symbol_edit_24),
contentDescription = stringResource(id = R.string.ViewNoteSheet__edit_note)
)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent
)
)
val mask = if (LocalInspectionMode.current) {
Linkify.WEB_URLS
} else {
Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS
}
AndroidView(
factory = { context ->
val view = EmojiTextView(context)
@Suppress("DEPRECATION")
view.setTextAppearance(context, R.style.Signal_Text_BodyLarge)
view.movementMethod = LinkMovementMethodCompat.getInstance()
view
},
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 48.dp)
) {
it.text = note
LinkifyCompat.addLinks(it, mask)
}
}
}
@@ -0,0 +1,32 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.nicknames
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
class ViewNoteSheetViewModel(
recipientId: RecipientId
) : ViewModel() {
private val internalNote = mutableStateOf("")
val note: State<String> = internalNote
private val recipientDisposable = Recipient.observable(recipientId)
.map { it.note ?: "" }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy { internalNote.value = it }
override fun onCleared() {
recipientDisposable.dispose()
}
}
@@ -505,6 +505,10 @@ public class Recipient {
return contactUri; return contactUri;
} }
public @Nullable String getNote() {
return note;
}
public @Nullable String getGroupName(@NonNull Context context) { public @Nullable String getGroupName(@NonNull Context context) {
if (groupId != null && Util.isEmpty(this.groupName)) { if (groupId != null && Util.isEmpty(this.groupName)) {
RecipientId selfId = ApplicationDependencies.getRecipientCache().getSelfId(); RecipientId selfId = ApplicationDependencies.getRecipientCache().getSelfId();
@@ -10,6 +10,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -27,9 +29,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
@@ -44,6 +48,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarImage import org.thoughtcrime.securesms.avatar.AvatarImage
import org.thoughtcrime.securesms.components.emoji.EmojiTextView import org.thoughtcrime.securesms.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.nicknames.ViewNoteSheet
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
@@ -101,10 +106,12 @@ class AboutSheet : ComposeBottomSheetDialogFragment() {
}, },
groupsInCommon = groupsInCommonCount, groupsInCommon = groupsInCommonCount,
profileSharing = recipient.get().isProfileSharing, profileSharing = recipient.get().isProfileSharing,
systemContact = recipient.get().isSystemContact systemContact = recipient.get().isSystemContact,
note = recipient.get().note ?: ""
), ),
onClickSignalConnections = this::openSignalConnectionsSheet, onClickSignalConnections = this::openSignalConnectionsSheet,
onAvatarClicked = this::openProfilePhotoViewer onAvatarClicked = this::openProfilePhotoViewer,
onNoteClicked = this::openNoteSheet
) )
} }
} }
@@ -117,6 +124,11 @@ class AboutSheet : ComposeBottomSheetDialogFragment() {
private fun openProfilePhotoViewer() { private fun openProfilePhotoViewer() {
startActivity(AvatarPreviewActivity.intentFromRecipientId(requireContext(), recipientId)) startActivity(AvatarPreviewActivity.intentFromRecipientId(requireContext(), recipientId))
} }
private fun openNoteSheet() {
dismiss()
ViewNoteSheet.create(recipientId).show(parentFragmentManager, null)
}
} }
private data class AboutModel( private data class AboutModel(
@@ -130,14 +142,16 @@ private data class AboutModel(
val formattedE164: String?, val formattedE164: String?,
val profileSharing: Boolean, val profileSharing: Boolean,
val systemContact: Boolean, val systemContact: Boolean,
val groupsInCommon: Int val groupsInCommon: Int,
val note: String
) )
@Composable @Composable
private fun Content( private fun Content(
model: AboutModel, model: AboutModel,
onClickSignalConnections: () -> Unit, onClickSignalConnections: () -> Unit,
onAvatarClicked: () -> Unit onAvatarClicked: () -> Unit,
onNoteClicked: () -> Unit
) { ) {
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -180,12 +194,15 @@ private fun Content(
) )
if (model.about.isNotNullOrBlank()) { if (model.about.isNotNullOrBlank()) {
val textColor = LocalContentColor.current
AboutRow( AboutRow(
startIcon = painterResource(R.drawable.symbol_edit_24), startIcon = painterResource(R.drawable.symbol_edit_24),
text = { text = {
Row { Row {
AndroidView(factory = ::EmojiTextView) { AndroidView(factory = ::EmojiTextView) {
it.text = model.about it.text = model.about
it.setTextColor(textColor.toArgb())
TextViewCompat.setTextAppearance(it, R.style.Signal_Text_BodyLarge) TextViewCompat.setTextAppearance(it, R.style.Signal_Text_BodyLarge)
} }
@@ -255,6 +272,16 @@ private fun Content(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
if (model.note.isNotBlank()) {
AboutRow(
startIcon = painterResource(id = R.drawable.symbol_note_light_24),
text = model.note,
modifier = Modifier.fillMaxWidth(),
endIcon = painterResource(id = R.drawable.symbol_chevron_right_compact_bold_16),
onClick = onNoteClicked
)
}
Spacer(modifier = Modifier.size(26.dp)) Spacer(modifier = Modifier.size(26.dp))
} }
} }
@@ -272,7 +299,10 @@ private fun AboutRow(
text = { text = {
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, false)
) )
}, },
modifier = modifier, modifier = modifier,
@@ -284,7 +314,7 @@ private fun AboutRow(
@Composable @Composable
private fun AboutRow( private fun AboutRow(
startIcon: Painter, startIcon: Painter,
text: @Composable () -> Unit, text: @Composable RowScope.() -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
endIcon: Painter? = null, endIcon: Painter? = null,
onClick: (() -> Unit)? = null onClick: (() -> Unit)? = null
@@ -346,10 +376,12 @@ private fun ContentPreviewDefault() {
formattedE164 = "(123) 456-7890", formattedE164 = "(123) 456-7890",
profileSharing = true, profileSharing = true,
systemContact = true, systemContact = true,
groupsInCommon = 0 groupsInCommon = 0,
note = "GET ME SPIDERMAN BEFORE I BLOW A DANG GASKET"
), ),
onClickSignalConnections = {}, onClickSignalConnections = {},
onAvatarClicked = {} onAvatarClicked = {},
onNoteClicked = {}
) )
} }
} }
@@ -373,10 +405,12 @@ private fun ContentPreviewInContactsNotProfileSharing() {
formattedE164 = null, formattedE164 = null,
profileSharing = false, profileSharing = false,
systemContact = true, systemContact = true,
groupsInCommon = 3 groupsInCommon = 3,
note = "GET ME SPIDER MAN"
), ),
onClickSignalConnections = {}, onClickSignalConnections = {},
onAvatarClicked = {} onAvatarClicked = {},
onNoteClicked = {}
) )
} }
} }
@@ -400,10 +434,12 @@ private fun ContentPreviewGroupsInCommonNoE164() {
formattedE164 = null, formattedE164 = null,
profileSharing = true, profileSharing = true,
systemContact = false, systemContact = false,
groupsInCommon = 3 groupsInCommon = 3,
note = "GET ME SPIDERMAN"
), ),
onClickSignalConnections = {}, onClickSignalConnections = {},
onAvatarClicked = {} onAvatarClicked = {},
onNoteClicked = {}
) )
} }
} }
@@ -427,10 +463,12 @@ private fun ContentPreviewNotAConnection() {
formattedE164 = null, formattedE164 = null,
profileSharing = false, profileSharing = false,
systemContact = false, systemContact = false,
groupsInCommon = 3 groupsInCommon = 3,
note = "GET ME SPIDERMAN"
), ),
onClickSignalConnections = {}, onClickSignalConnections = {},
onAvatarClicked = {} onAvatarClicked = {},
onNoteClicked = {}
) )
} }
} }
@@ -17,6 +17,7 @@ import android.widget.ProgressBar;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
@@ -39,6 +40,7 @@ import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto80dp;
import org.thoughtcrime.securesms.fonts.SignalSymbols; import org.thoughtcrime.securesms.fonts.SignalSymbols;
import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.nicknames.NicknameActivity;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientExporter; import org.thoughtcrime.securesms.recipients.RecipientExporter;
import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -46,6 +48,7 @@ import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.recipients.ui.about.AboutSheet; import org.thoughtcrime.securesms.recipients.ui.about.AboutSheet;
import org.thoughtcrime.securesms.util.BottomSheetUtil; import org.thoughtcrime.securesms.util.BottomSheetUtil;
import org.thoughtcrime.securesms.util.ContextUtil; import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.SpanUtil; import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.ThemeUtil; import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.Util;
@@ -74,6 +77,7 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF
private AvatarView avatar; private AvatarView avatar;
private TextView fullName; private TextView fullName;
private TextView about; private TextView about;
private TextView nickname;
private TextView blockButton; private TextView blockButton;
private TextView unblockButton; private TextView unblockButton;
private TextView addContactButton; private TextView addContactButton;
@@ -92,6 +96,8 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF
private ButtonStripPreference.ViewHolder buttonStripViewHolder; private ButtonStripPreference.ViewHolder buttonStripViewHolder;
private ActivityResultLauncher<NicknameActivity.Args> nicknameLauncher;
public static void show(FragmentManager fragmentManager, @NonNull RecipientId recipientId, @Nullable GroupId groupId) { public static void show(FragmentManager fragmentManager, @NonNull RecipientId recipientId, @Nullable GroupId groupId) {
Recipient recipient = Recipient.resolved(recipientId); Recipient recipient = Recipient.resolved(recipientId);
if (recipient.isSelf()) { if (recipient.isSelf()) {
@@ -127,6 +133,7 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF
avatar = view.findViewById(R.id.rbs_recipient_avatar); avatar = view.findViewById(R.id.rbs_recipient_avatar);
fullName = view.findViewById(R.id.rbs_full_name); fullName = view.findViewById(R.id.rbs_full_name);
about = view.findViewById(R.id.rbs_about); about = view.findViewById(R.id.rbs_about);
nickname = view.findViewById(R.id.rbs_nickname_button);
blockButton = view.findViewById(R.id.rbs_block_button); blockButton = view.findViewById(R.id.rbs_block_button);
unblockButton = view.findViewById(R.id.rbs_unblock_button); unblockButton = view.findViewById(R.id.rbs_unblock_button);
addContactButton = view.findViewById(R.id.rbs_add_contact_button); addContactButton = view.findViewById(R.id.rbs_add_contact_button);
@@ -150,6 +157,8 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF
public void onViewCreated(@NonNull View fragmentView, @Nullable Bundle savedInstanceState) { public void onViewCreated(@NonNull View fragmentView, @Nullable Bundle savedInstanceState) {
super.onViewCreated(fragmentView, savedInstanceState); super.onViewCreated(fragmentView, savedInstanceState);
nicknameLauncher = registerForActivityResult(new NicknameActivity.Contract(), (b) -> {});
Bundle arguments = requireArguments(); Bundle arguments = requireArguments();
RecipientId recipientId = RecipientId.from(Objects.requireNonNull(arguments.getString(ARGS_RECIPIENT_ID))); RecipientId recipientId = RecipientId.from(Objects.requireNonNull(arguments.getString(ARGS_RECIPIENT_ID)));
GroupId groupId = GroupId.parseNullableOrThrow(arguments.getString(ARGS_GROUP_ID)); GroupId groupId = GroupId.parseNullableOrThrow(arguments.getString(ARGS_GROUP_ID));
@@ -214,6 +223,16 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF
dismiss(); dismiss();
AboutSheet.create(recipient).show(getParentFragmentManager(), null); AboutSheet.create(recipient).show(getParentFragmentManager(), null);
}); });
if (FeatureFlags.nicknames() && groupId != null) {
nickname.setVisibility(View.VISIBLE);
nickname.setOnClickListener(v -> {
nicknameLauncher.launch(new NicknameActivity.Args(
recipientId,
false
));
});
}
} }
String aboutText = recipient.getCombinedAboutAndEmoji(); String aboutText = recipient.getCombinedAboutAndEmoji();
@@ -127,6 +127,7 @@ public final class FeatureFlags {
private static final String RX_MESSAGE_SEND = "android.rxMessageSend"; private static final String RX_MESSAGE_SEND = "android.rxMessageSend";
private static final String LINKED_DEVICE_LIFESPAN_SECONDS = "android.linkedDeviceLifespanSeconds"; private static final String LINKED_DEVICE_LIFESPAN_SECONDS = "android.linkedDeviceLifespanSeconds";
private static final String MESSAGE_BACKUPS = "android.messageBackups"; private static final String MESSAGE_BACKUPS = "android.messageBackups";
private static final String NICKNAMES = "android.nicknames";
/** /**
* We will only store remote values for flags in this set. If you want a flag to be controllable * We will only store remote values for flags in this set. If you want a flag to be controllable
@@ -205,7 +206,8 @@ public final class FeatureFlags {
PREKEY_FORCE_REFRESH_INTERVAL, PREKEY_FORCE_REFRESH_INTERVAL,
CDSI_LIBSIGNAL_NET, CDSI_LIBSIGNAL_NET,
RX_MESSAGE_SEND, RX_MESSAGE_SEND,
LINKED_DEVICE_LIFESPAN_SECONDS LINKED_DEVICE_LIFESPAN_SECONDS,
NICKNAMES
); );
@VisibleForTesting @VisibleForTesting
@@ -740,6 +742,11 @@ public final class FeatureFlags {
return getBoolean(MESSAGE_BACKUPS, false); return getBoolean(MESSAGE_BACKUPS, false);
} }
/** Whether or not the nicknames feature is available */
public static boolean nicknames() {
return getBoolean(NICKNAMES, false);
}
/** Only for rendering debug info. */ /** Only for rendering debug info. */
public static synchronized @NonNull Map<String, Object> getMemoryValues() { public static synchronized @NonNull Map<String, Object> getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES); return new TreeMap<>(REMOTE_VALUES);
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M12 1.5C6.2 1.5 1.5 6.2 1.5 12S6.2 22.5 12 22.5 22.5 17.8 22.5 12 17.8 1.5 12 1.5ZM9.12 7.88L12 10.76l2.88-2.88c0.34-0.34 0.9-0.34 1.24 0 0.34 0.34 0.34 0.9 0 1.24L13.24 12l2.88 2.88c0.34 0.34 0.34 0.9 0 1.24-0.34 0.34-0.9 0.34-1.24 0L12 13.24l-2.88 2.88c-0.34 0.34-0.9 0.34-1.24 0-0.34-0.34-0.34-0.9 0-1.24L10.76 12 7.88 9.12c-0.34-0.34-0.34-0.9 0-1.24 0.34-0.34 0.9-0.34 1.24 0Z"/>
</vector>
@@ -119,6 +119,23 @@
<include layout="@layout/dsl_divider_item" /> <include layout="@layout/dsl_divider_item" />
<TextView
android:id="@+id/rbs_nickname_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground"
android:drawablePadding="16dp"
android:gravity="center_vertical"
android:minHeight="56dp"
android:paddingStart="@dimen/dsl_settings_gutter"
android:paddingEnd="@dimen/dsl_settings_gutter"
android:text="@string/NicknameActivity__nickname"
android:textAppearance="@style/Signal.Text.BodyLarge"
android:visibility="gone"
app:drawableStartCompat="@drawable/symbol_edit_24"
app:drawableTint="@color/icon_tint_color_primary_enabled_selector"
tools:visibility="visible" />
<TextView <TextView
android:id="@+id/rbs_block_button" android:id="@+id/rbs_block_button"
android:layout_width="match_parent" android:layout_width="match_parent"
+28
View File
@@ -6678,5 +6678,33 @@
<!-- Button label for an alert letting someone know that one of their linked devices is inactive. When clicked, the alert will be dismissed. --> <!-- Button label for an alert letting someone know that one of their linked devices is inactive. When clicked, the alert will be dismissed. -->
<string name="LinkedDeviceInactiveMegaphone_got_it_button_label">Got it</string> <string name="LinkedDeviceInactiveMegaphone_got_it_button_label">Got it</string>
<!-- NicknameFragment -->
<!-- Title displayed at the top of the screen -->
<string name="NicknameActivity__nickname">Nickname</string>
<!-- Subtitle displayed under title -->
<string name="NicknameActivity__nicknames_amp_notes">Nicknames &amp; notes are stored using Signal\'s end-to-end encrypted storage service. They are only visible to you.</string>
<!-- Field label for given name -->
<string name="NicknameActivity__first_name">First name</string>
<!-- Content description for first name clear button -->
<string name="NicknameActivity__clear_first_name">Clear first name</string>
<!-- Field label for family name -->
<string name="NicknameActivity__last_name">Last name</string>
<!-- Content description for last name clear button -->
<string name="NicknameActivity__clear_last_name">Clear last name</string>
<!-- Field label for note -->
<string name="NicknameActivity__note">Note</string>
<!-- Button label to save -->
<string name="NicknameActivity__save">Save</string>
<!-- Dialog title for note and name deletion -->
<string name="NicknameActivity__delete_nickname">Delete nickname?</string>
<!-- Dialog message for note and name deletion -->
<string name="NicknameActivity__this_will_permanently_delete_this_nickname_and_note">This will permanently delete this nickname and note.</string>
<!-- ViewNoteBottomSheetDialogFragment -->
<!-- Sheet title -->
<string name="ViewNoteSheet__note">Note</string>
<!-- Content description for opening the note editor -->
<string name="ViewNoteSheet__edit_note">Edit note</string>
<!-- EOF --> <!-- EOF -->
</resources> </resources>
@@ -0,0 +1,94 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import org.signal.core.ui.copied.androidx.compose.material3.DropdownMenu
/**
* Properly styled dropdown menus and items.
*/
object DropdownMenus {
/**
* Properly styled dropdown menu
*/
@Composable
fun Menu(
controller: MenuController = remember { MenuController() },
modifier: Modifier = Modifier,
content: @Composable ColumnScope.(MenuController) -> Unit
) {
MaterialTheme(shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(18.dp))) {
DropdownMenu(
expanded = controller.isShown(),
onDismissRequest = controller::hide,
offset = DpOffset(
x = dimensionResource(id = R.dimen.core_ui__gutter),
y = 0.dp
),
content = { content(controller) },
modifier = modifier
)
}
}
/**
* Properly styled dropdown menu item
*/
@Composable
fun Item(
contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp),
text: @Composable () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
DropdownMenuItem(
contentPadding = contentPadding,
text = text,
onClick = onClick,
modifier = modifier
)
}
/**
* Menu controller to hold menu display state and allow other components
* to show and hide it.
*/
class MenuController {
private var isMenuShown by mutableStateOf(false)
fun show() {
isMenuShown = true
}
fun hide() {
isMenuShown = false
}
fun toggle() {
if (isShown()) {
hide()
} else {
show()
}
}
fun isShown() = isMenuShown
}
}
@@ -2,8 +2,11 @@ package org.signal.core.ui
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -40,6 +43,7 @@ object Scaffolds {
Text(text = title, style = MaterialTheme.typography.titleLarge) Text(text = title, style = MaterialTheme.typography.titleLarge)
}, },
snackbarHost: @Composable () -> Unit = {}, snackbarHost: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
content: @Composable (PaddingValues) -> Unit content: @Composable (PaddingValues) -> Unit
) { ) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
@@ -53,7 +57,8 @@ object Scaffolds {
scrollBehavior, scrollBehavior,
onNavigationClick, onNavigationClick,
navigationIconPainter, navigationIconPainter,
navigationContentDescription navigationContentDescription,
actions
) )
}, },
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -68,7 +73,8 @@ object Scaffolds {
scrollBehavior: TopAppBarScrollBehavior, scrollBehavior: TopAppBarScrollBehavior,
onNavigationClick: () -> Unit, onNavigationClick: () -> Unit,
navigationIconPainter: Painter, navigationIconPainter: Painter,
navigationContentDescription: String? navigationContentDescription: String?,
actions: @Composable RowScope.() -> Unit
) { ) {
TopAppBar( TopAppBar(
title = { title = {
@@ -88,7 +94,8 @@ object Scaffolds {
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = SignalTheme.colors.colorSurface2 scrolledContainerColor = SignalTheme.colors.colorSurface2
) ),
actions = actions
) )
} }
} }
@@ -100,7 +107,12 @@ private fun SettingsScaffoldPreview() {
Scaffolds.Settings( Scaffolds.Settings(
"Settings Scaffold", "Settings Scaffold",
onNavigationClick = {}, onNavigationClick = {},
navigationIconPainter = ColorPainter(Color.Black) navigationIconPainter = ColorPainter(Color.Black),
actions = {
IconButton(onClick = {}) {
Icon(Icons.Default.Settings, contentDescription = null)
}
}
) { paddingValues -> ) { paddingValues ->
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -5,9 +5,12 @@
package org.signal.core.ui package org.signal.core.ui
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
@@ -21,22 +24,34 @@ import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
object TextFields { object TextFields {
/** /**
* This is intended to replicate what TextField exposes but allows us to set our own content padding. * This is intended to replicate what TextField exposes but allows us to set our own content padding as
* well as resolving the auto-scroll to cursor position issue.
*
* Prefer the base TextField where possible. * Prefer the base TextField where possible.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun TextField( fun TextField(
value: String, value: String,
@@ -76,15 +91,57 @@ object TextFields {
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
val cursorColor = rememberUpdatedState(newValue = if (isError) MaterialTheme.colorScheme.error else textColor) val cursorColor = rememberUpdatedState(newValue = if (isError) MaterialTheme.colorScheme.error else textColor)
// Borrowed from BasicTextField, all this helps reduce recompositions.
var lastTextValue by remember(value) { mutableStateOf(value) }
var textFieldValueState by remember {
mutableStateOf(
TextFieldValue(
text = value,
selection = value.createSelection()
)
)
}
val textFieldValue = textFieldValueState.copy(
text = value,
selection = if (textFieldValueState.text.isBlank()) value.createSelection() else textFieldValueState.selection
)
SideEffect {
if (textFieldValue.selection != textFieldValueState.selection ||
textFieldValue.composition != textFieldValueState.composition
) {
textFieldValueState = textFieldValue
}
}
var hasFocus by remember { mutableStateOf(false) }
// BasicTextField has a bug where it won't scroll down to keep the cursor in view.
val bringIntoViewRequester = BringIntoViewRequester()
val coroutineScope = rememberCoroutineScope()
CompositionLocalProvider(LocalTextSelectionColors provides TextSelectionColors(handleColor = LocalContentColor.current, LocalContentColor.current.copy(alpha = 0.4f))) { CompositionLocalProvider(LocalTextSelectionColors provides TextSelectionColors(handleColor = LocalContentColor.current, LocalContentColor.current.copy(alpha = 0.4f))) {
BasicTextField( BasicTextField(
value = value, value = textFieldValue,
modifier = modifier modifier = modifier
.onFocusChanged { }
.bringIntoViewRequester(bringIntoViewRequester)
.onFocusChanged { focusState -> hasFocus = focusState.hasFocus }
.defaultMinSize( .defaultMinSize(
minWidth = TextFieldDefaults.MinWidth, minWidth = TextFieldDefaults.MinWidth,
minHeight = TextFieldDefaults.MinHeight minHeight = TextFieldDefaults.MinHeight
), ),
onValueChange = onValueChange, onValueChange = { newTextFieldValueState ->
textFieldValueState = newTextFieldValueState
val stringChangedSinceLastInvocation = lastTextValue != newTextFieldValueState.text
lastTextValue = newTextFieldValueState.text
if (stringChangedSinceLastInvocation) {
onValueChange(newTextFieldValueState.text)
}
},
enabled = enabled, enabled = enabled,
readOnly = readOnly, readOnly = readOnly,
textStyle = mergedTextStyle, textStyle = mergedTextStyle,
@@ -96,6 +153,15 @@ object TextFields {
singleLine = singleLine, singleLine = singleLine,
maxLines = maxLines, maxLines = maxLines,
minLines = minLines, minLines = minLines,
onTextLayout = { result ->
if (hasFocus && textFieldValue.selection.collapsed) {
val rect = result.getCursorRect(textFieldValue.selection.start)
coroutineScope.launch {
bringIntoViewRequester.bringIntoView(rect.translate(translateX = 0f, translateY = 72.dp.value))
}
}
},
decorationBox = @Composable { innerTextField -> decorationBox = @Composable { innerTextField ->
// places leading icon, text field with label and placeholder, trailing icon // places leading icon, text field with label and placeholder, trailing icon
TextFieldDefaults.DecorationBox( TextFieldDefaults.DecorationBox(
@@ -121,4 +187,11 @@ object TextFields {
) )
} }
} }
private fun String.createSelection(): TextRange {
return when {
isEmpty() -> TextRange.Zero
else -> TextRange(length, length)
}
}
} }
@@ -0,0 +1,62 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.copied.androidx.compose.material3
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
/**
* Lifted straight from Compose-Material3
*
* This eliminates the content padding on the dropdown menu.
*/
@Suppress("ModifierParameter")
@Composable
internal fun DropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
offset: DpOffset = DpOffset(0.dp, 0.dp),
properties: PopupProperties = PopupProperties(focusable = true),
content: @Composable ColumnScope.() -> Unit
) {
val expandedStates = remember { MutableTransitionState(false) }
expandedStates.targetState = expanded
if (expandedStates.currentState || expandedStates.targetState) {
val transformOriginState = remember { mutableStateOf(TransformOrigin.Center) }
val density = LocalDensity.current
val popupPositionProvider = DropdownMenuPositionProvider(
offset,
density
) { parentBounds, menuBounds ->
transformOriginState.value = calculateTransformOrigin(parentBounds, menuBounds)
}
Popup(
onDismissRequest = onDismissRequest,
popupPositionProvider = popupPositionProvider,
properties = properties
) {
DropdownMenuContent(
expandedStates = expandedStates,
transformOriginState = transformOriginState,
modifier = modifier,
content = content
)
}
}
}
@@ -0,0 +1,215 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.copied.androidx.compose.material3
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupPositionProvider
import kotlin.math.max
import kotlin.math.min
@Suppress("ModifierParameter", "TransitionPropertiesLabel")
@Composable
internal fun DropdownMenuContent(
expandedStates: MutableTransitionState<Boolean>,
transformOriginState: MutableState<TransformOrigin>,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
// Menu open/close animation.
val transition = updateTransition(expandedStates, "DropDownMenu")
val scale by transition.animateFloat(
transitionSpec = {
if (false isTransitioningTo true) {
// Dismissed to expanded
tween(
durationMillis = InTransitionDuration,
easing = LinearOutSlowInEasing
)
} else {
// Expanded to dismissed.
tween(
durationMillis = 1,
delayMillis = OutTransitionDuration - 1
)
}
}
) {
if (it) {
// Menu is expanded.
1f
} else {
// Menu is dismissed.
0.8f
}
}
val alpha by transition.animateFloat(
transitionSpec = {
if (false isTransitioningTo true) {
// Dismissed to expanded
tween(durationMillis = 30)
} else {
// Expanded to dismissed.
tween(durationMillis = OutTransitionDuration)
}
}
) {
if (it) {
// Menu is expanded.
1f
} else {
// Menu is dismissed.
0f
}
}
Surface(
modifier = Modifier.graphicsLayer {
scaleX = scale
scaleY = scale
this.alpha = alpha
transformOrigin = transformOriginState.value
},
shape = MaterialTheme.shapes.extraSmall,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 3.dp,
shadowElevation = 3.dp
) {
Column(
modifier = modifier
.width(IntrinsicSize.Max)
.verticalScroll(rememberScrollState()),
content = content
)
}
}
internal fun calculateTransformOrigin(
parentBounds: IntRect,
menuBounds: IntRect
): TransformOrigin {
val pivotX = when {
menuBounds.left >= parentBounds.right -> 0f
menuBounds.right <= parentBounds.left -> 1f
menuBounds.width == 0 -> 0f
else -> {
val intersectionCenter =
(
max(parentBounds.left, menuBounds.left) +
min(parentBounds.right, menuBounds.right)
) / 2
(intersectionCenter - menuBounds.left).toFloat() / menuBounds.width
}
}
val pivotY = when {
menuBounds.top >= parentBounds.bottom -> 0f
menuBounds.bottom <= parentBounds.top -> 1f
menuBounds.height == 0 -> 0f
else -> {
val intersectionCenter =
(
max(parentBounds.top, menuBounds.top) +
min(parentBounds.bottom, menuBounds.bottom)
) / 2
(intersectionCenter - menuBounds.top).toFloat() / menuBounds.height
}
}
return TransformOrigin(pivotX, pivotY)
}
@Immutable
internal data class DropdownMenuPositionProvider(
val contentOffset: DpOffset,
val density: Density,
val onPositionCalculated: (IntRect, IntRect) -> Unit = { _, _ -> }
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize
): IntOffset {
// The min margin above and below the menu, relative to the screen.
val verticalMargin = with(density) { MenuVerticalMargin.roundToPx() }
// The content offset specified using the dropdown offset parameter.
val contentOffsetX = with(density) { contentOffset.x.roundToPx() }
val contentOffsetY = with(density) { contentOffset.y.roundToPx() }
// Compute horizontal position.
val toRight = anchorBounds.left + contentOffsetX
val toLeft = anchorBounds.right - contentOffsetX - popupContentSize.width
val toDisplayRight = windowSize.width - popupContentSize.width
val toDisplayLeft = 0
val x = if (layoutDirection == LayoutDirection.Ltr) {
sequenceOf(
toRight,
toLeft,
// If the anchor gets outside of the window on the left, we want to position
// toDisplayLeft for proximity to the anchor. Otherwise, toDisplayRight.
if (anchorBounds.left >= 0) toDisplayRight else toDisplayLeft
)
} else {
sequenceOf(
toLeft,
toRight,
// If the anchor gets outside of the window on the right, we want to position
// toDisplayRight for proximity to the anchor. Otherwise, toDisplayLeft.
if (anchorBounds.right <= windowSize.width) toDisplayLeft else toDisplayRight
)
}.firstOrNull {
it >= 0 && it + popupContentSize.width <= windowSize.width
} ?: toLeft
// Compute vertical position.
val toBottom = maxOf(anchorBounds.bottom + contentOffsetY, verticalMargin)
val toTop = anchorBounds.top - contentOffsetY - popupContentSize.height
val toCenter = anchorBounds.top - popupContentSize.height / 2
val toDisplayBottom = windowSize.height - popupContentSize.height - verticalMargin
val y = sequenceOf(toBottom, toTop, toCenter, toDisplayBottom).firstOrNull {
it >= verticalMargin &&
it + popupContentSize.height <= windowSize.height - verticalMargin
} ?: toTop
onPositionCalculated(
anchorBounds,
IntRect(x, y, x + popupContentSize.width, y + popupContentSize.height)
)
return IntOffset(x, y)
}
}
// Size defaults.
internal val MenuVerticalMargin = 48.dp
// Menu open/close animation.
internal const val InTransitionDuration = 120
internal const val OutTransitionDuration = 75