Implement the majority of the new nicknames and notes feature.
This commit is contained in:
parent
7a24554b68
commit
303929090b
20 changed files with 1504 additions and 23 deletions
|
@ -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"
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
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();
|
||||
|
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
9
app/src/main/res/drawable/symbol_x_circle_fill_24.xml
Normal file
9
app/src/main/res/drawable/symbol_x_circle_fill_24.xml
Normal 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>
|
|
@ -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"
|
||||
|
|
|
@ -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 & 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>
|
||||
|
|
94
core-ui/src/main/java/org/signal/core/ui/DropdownMenus.kt
Normal file
94
core-ui/src/main/java/org/signal/core/ui/DropdownMenus.kt
Normal 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
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
Loading…
Add table
Reference in a new issue