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

View file

@ -970,6 +970,11 @@
android:windowSoftInputMode="stateVisible|adjustResize"
android:exported="false"/>
<activity android:name=".nicknames.NicknameActivity"
android:theme="@style/TextSecure.LightTheme"
android:windowSoftInputMode="stateVisible|adjustResize"
android:exported="false"/>
<activity
android:name=".payments.preferences.PaymentsActivity"
android:theme="@style/TextSecure.LightRegistrationTheme"

View file

@ -14,6 +14,7 @@ import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
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.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository
import org.thoughtcrime.securesms.nicknames.NicknameActivity
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
import org.thoughtcrime.securesms.recipients.Recipient
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.DateUtils
import org.thoughtcrime.securesms.util.ExpirationUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@ -149,6 +152,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
private lateinit var toolbarTitle: TextView
private lateinit var toolbarBackground: View
private lateinit var addToGroupStoryDelegate: AddToGroupStoryDelegate
private lateinit var nicknameLauncher: ActivityResultLauncher<NicknameActivity.Args>
private val navController get() = Navigation.findNavController(requireView())
private val lifecycleDisposable = LifecycleDisposable()
@ -216,6 +220,10 @@ class ConversationSettingsFragment : DSLSettingsFragment(
}
override fun bindAdapter(adapter: MappingAdapter) {
nicknameLauncher = registerForActivityResult(NicknameActivity.Contract()) {
// Intentionally left blank
}
val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments())
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) {
clickPref(
title = DSLSettingsText.from(R.string.preferences__chat_color_and_wallpaper),

View file

@ -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) {
val contentValues = ContentValues(1).apply {
put(PROFILE_GIVEN_NAME, profileName.givenName.nullIfBlank())

View file

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

View file

@ -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
}
}

View file

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

View file

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

View file

@ -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()
}
}

View file

@ -505,6 +505,10 @@ public class Recipient {
return contactUri;
}
public @Nullable String getNote() {
return note;
}
public @Nullable String getGroupName(@NonNull Context context) {
if (groupId != null && Util.isEmpty(this.groupName)) {
RecipientId selfId = ApplicationDependencies.getRecipientCache().getSelfId();

View file

@ -10,6 +10,7 @@ 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.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
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.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@ -27,9 +29,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
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.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.nicknames.ViewNoteSheet
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@ -101,10 +106,12 @@ class AboutSheet : ComposeBottomSheetDialogFragment() {
},
groupsInCommon = groupsInCommonCount,
profileSharing = recipient.get().isProfileSharing,
systemContact = recipient.get().isSystemContact
systemContact = recipient.get().isSystemContact,
note = recipient.get().note ?: ""
),
onClickSignalConnections = this::openSignalConnectionsSheet,
onAvatarClicked = this::openProfilePhotoViewer
onAvatarClicked = this::openProfilePhotoViewer,
onNoteClicked = this::openNoteSheet
)
}
}
@ -117,6 +124,11 @@ class AboutSheet : ComposeBottomSheetDialogFragment() {
private fun openProfilePhotoViewer() {
startActivity(AvatarPreviewActivity.intentFromRecipientId(requireContext(), recipientId))
}
private fun openNoteSheet() {
dismiss()
ViewNoteSheet.create(recipientId).show(parentFragmentManager, null)
}
}
private data class AboutModel(
@ -130,14 +142,16 @@ private data class AboutModel(
val formattedE164: String?,
val profileSharing: Boolean,
val systemContact: Boolean,
val groupsInCommon: Int
val groupsInCommon: Int,
val note: String
)
@Composable
private fun Content(
model: AboutModel,
onClickSignalConnections: () -> Unit,
onAvatarClicked: () -> Unit
onAvatarClicked: () -> Unit,
onNoteClicked: () -> Unit
) {
Box(
contentAlignment = Alignment.Center,
@ -180,12 +194,15 @@ private fun Content(
)
if (model.about.isNotNullOrBlank()) {
val textColor = LocalContentColor.current
AboutRow(
startIcon = painterResource(R.drawable.symbol_edit_24),
text = {
Row {
AndroidView(factory = ::EmojiTextView) {
it.text = model.about
it.setTextColor(textColor.toArgb())
TextViewCompat.setTextAppearance(it, R.style.Signal_Text_BodyLarge)
}
@ -255,6 +272,16 @@ private fun Content(
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))
}
}
@ -272,7 +299,10 @@ private fun AboutRow(
text = {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, false)
)
},
modifier = modifier,
@ -284,7 +314,7 @@ private fun AboutRow(
@Composable
private fun AboutRow(
startIcon: Painter,
text: @Composable () -> Unit,
text: @Composable RowScope.() -> Unit,
modifier: Modifier = Modifier,
endIcon: Painter? = null,
onClick: (() -> Unit)? = null
@ -346,10 +376,12 @@ private fun ContentPreviewDefault() {
formattedE164 = "(123) 456-7890",
profileSharing = true,
systemContact = true,
groupsInCommon = 0
groupsInCommon = 0,
note = "GET ME SPIDERMAN BEFORE I BLOW A DANG GASKET"
),
onClickSignalConnections = {},
onAvatarClicked = {}
onAvatarClicked = {},
onNoteClicked = {}
)
}
}
@ -373,10 +405,12 @@ private fun ContentPreviewInContactsNotProfileSharing() {
formattedE164 = null,
profileSharing = false,
systemContact = true,
groupsInCommon = 3
groupsInCommon = 3,
note = "GET ME SPIDER MAN"
),
onClickSignalConnections = {},
onAvatarClicked = {}
onAvatarClicked = {},
onNoteClicked = {}
)
}
}
@ -400,10 +434,12 @@ private fun ContentPreviewGroupsInCommonNoE164() {
formattedE164 = null,
profileSharing = true,
systemContact = false,
groupsInCommon = 3
groupsInCommon = 3,
note = "GET ME SPIDERMAN"
),
onClickSignalConnections = {},
onAvatarClicked = {}
onAvatarClicked = {},
onNoteClicked = {}
)
}
}
@ -427,10 +463,12 @@ private fun ContentPreviewNotAConnection() {
formattedE164 = null,
profileSharing = false,
systemContact = false,
groupsInCommon = 3
groupsInCommon = 3,
note = "GET ME SPIDERMAN"
),
onClickSignalConnections = {},
onAvatarClicked = {}
onAvatarClicked = {},
onNoteClicked = {}
)
}
}

View file

@ -17,6 +17,7 @@ import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
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.groups.GroupId;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.nicknames.NicknameActivity;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientExporter;
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.util.BottomSheetUtil;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
@ -74,6 +77,7 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF
private AvatarView avatar;
private TextView fullName;
private TextView about;
private TextView nickname;
private TextView blockButton;
private TextView unblockButton;
private TextView addContactButton;
@ -92,6 +96,8 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF
private ButtonStripPreference.ViewHolder buttonStripViewHolder;
private ActivityResultLauncher<NicknameActivity.Args> nicknameLauncher;
public static void show(FragmentManager fragmentManager, @NonNull RecipientId recipientId, @Nullable GroupId groupId) {
Recipient recipient = Recipient.resolved(recipientId);
if (recipient.isSelf()) {
@ -127,6 +133,7 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF
avatar = view.findViewById(R.id.rbs_recipient_avatar);
fullName = view.findViewById(R.id.rbs_full_name);
about = view.findViewById(R.id.rbs_about);
nickname = view.findViewById(R.id.rbs_nickname_button);
blockButton = view.findViewById(R.id.rbs_block_button);
unblockButton = view.findViewById(R.id.rbs_unblock_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) {
super.onViewCreated(fragmentView, savedInstanceState);
nicknameLauncher = registerForActivityResult(new NicknameActivity.Contract(), (b) -> {});
Bundle arguments = requireArguments();
RecipientId recipientId = RecipientId.from(Objects.requireNonNull(arguments.getString(ARGS_RECIPIENT_ID)));
GroupId groupId = GroupId.parseNullableOrThrow(arguments.getString(ARGS_GROUP_ID));
@ -214,6 +223,16 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF
dismiss();
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();

View file

@ -127,6 +127,7 @@ public final class FeatureFlags {
private static final String RX_MESSAGE_SEND = "android.rxMessageSend";
private static final String LINKED_DEVICE_LIFESPAN_SECONDS = "android.linkedDeviceLifespanSeconds";
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
@ -205,7 +206,8 @@ public final class FeatureFlags {
PREKEY_FORCE_REFRESH_INTERVAL,
CDSI_LIBSIGNAL_NET,
RX_MESSAGE_SEND,
LINKED_DEVICE_LIFESPAN_SECONDS
LINKED_DEVICE_LIFESPAN_SECONDS,
NICKNAMES
);
@VisibleForTesting
@ -740,6 +742,11 @@ public final class FeatureFlags {
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. */
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);

View file

@ -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>

View file

@ -119,6 +119,23 @@
<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
android:id="@+id/rbs_block_button"
android:layout_width="match_parent"

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. -->
<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 -->
</resources>

View file

@ -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
}
}

View file

@ -2,8 +2,11 @@ package org.signal.core.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
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.Icon
import androidx.compose.material3.IconButton
@ -40,6 +43,7 @@ object Scaffolds {
Text(text = title, style = MaterialTheme.typography.titleLarge)
},
snackbarHost: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
content: @Composable (PaddingValues) -> Unit
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
@ -53,7 +57,8 @@ object Scaffolds {
scrollBehavior,
onNavigationClick,
navigationIconPainter,
navigationContentDescription
navigationContentDescription,
actions
)
},
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@ -68,7 +73,8 @@ object Scaffolds {
scrollBehavior: TopAppBarScrollBehavior,
onNavigationClick: () -> Unit,
navigationIconPainter: Painter,
navigationContentDescription: String?
navigationContentDescription: String?,
actions: @Composable RowScope.() -> Unit
) {
TopAppBar(
title = {
@ -88,7 +94,8 @@ object Scaffolds {
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = SignalTheme.colors.colorSurface2
)
),
actions = actions
)
}
}
@ -100,7 +107,12 @@ private fun SettingsScaffoldPreview() {
Scaffolds.Settings(
"Settings Scaffold",
onNavigationClick = {},
navigationIconPainter = ColorPainter(Color.Black)
navigationIconPainter = ColorPainter(Color.Black),
actions = {
IconButton(onClick = {}) {
Icon(Icons.Default.Settings, contentDescription = null)
}
}
) { paddingValues ->
Box(
contentAlignment = Alignment.Center,

View file

@ -5,9 +5,12 @@
package org.signal.core.ui
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.PaddingValues
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.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
@ -21,22 +24,34 @@ import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
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.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
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.
*/
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun TextField(
value: String,
@ -76,15 +91,57 @@ object TextFields {
val mergedTextStyle = textStyle.merge(TextStyle(color = 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))) {
BasicTextField(
value = value,
value = textFieldValue,
modifier = modifier
.onFocusChanged { }
.bringIntoViewRequester(bringIntoViewRequester)
.onFocusChanged { focusState -> hasFocus = focusState.hasFocus }
.defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
minHeight = TextFieldDefaults.MinHeight
),
onValueChange = onValueChange,
onValueChange = { newTextFieldValueState ->
textFieldValueState = newTextFieldValueState
val stringChangedSinceLastInvocation = lastTextValue != newTextFieldValueState.text
lastTextValue = newTextFieldValueState.text
if (stringChangedSinceLastInvocation) {
onValueChange(newTextFieldValueState.text)
}
},
enabled = enabled,
readOnly = readOnly,
textStyle = mergedTextStyle,
@ -96,6 +153,15 @@ object TextFields {
singleLine = singleLine,
maxLines = maxLines,
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 ->
// places leading icon, text field with label and placeholder, trailing icon
TextFieldDefaults.DecorationBox(
@ -121,4 +187,11 @@ object TextFields {
)
}
}
private fun String.createSelection(): TextRange {
return when {
isEmpty() -> TextRange.Zero
else -> TextRange(length, length)
}
}
}

View file

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

View file

@ -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