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