Implement new about sheet.

This commit is contained in:
Alex Hart 2023-12-18 14:35:15 -04:00 committed by Clark Chen
parent 490d3549e2
commit 9924e293c9
11 changed files with 477 additions and 8 deletions

View file

@ -0,0 +1,37 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.avatar
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.viewinterop.AndroidView
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.recipients.Recipient
@Composable
fun AvatarImage(
recipient: Recipient,
modifier: Modifier = Modifier
) {
if (LocalInspectionMode.current) {
Spacer(
modifier = modifier
.background(color = Color.Red, shape = CircleShape)
)
} else {
AndroidView(
factory = ::AvatarImageView,
modifier = modifier
) {
it.setAvatarUsingProfile(recipient)
}
}
}

View file

@ -78,6 +78,7 @@ import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientExporter
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.ui.about.AboutSheet
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.stories.StoryViewerArgs
@ -321,7 +322,11 @@ class ConversationSettingsFragment : DSLSettingsFragment(
)
state.withRecipientSettingsState {
customPref(BioTextPreference.RecipientModel(recipient = state.recipient))
customPref(
BioTextPreference.RecipientModel(recipient = state.recipient, onHeadlineClickListener = {
AboutSheet.create(state.recipient).show(parentFragmentManager, null)
})
)
}
state.withGroupSettingsState { groupState ->

View file

@ -31,10 +31,13 @@ object BioTextPreference {
abstract fun getHeadlineText(context: Context): CharSequence
abstract fun getSubhead1Text(context: Context): String?
abstract fun getSubhead2Text(): String?
open val onHeadlineClickListener: () -> Unit = {}
}
class RecipientModel(
private val recipient: Recipient
private val recipient: Recipient,
override val onHeadlineClickListener: () -> Unit
) : BioTextPreferenceModel<RecipientModel>() {
override fun getHeadlineText(context: Context): CharSequence {
@ -44,12 +47,18 @@ object BioTextPreference {
recipient.getDisplayNameOrUsername(context)
}
return if (recipient.showVerified()) {
SpannableStringBuilder(name).apply {
if (!recipient.showVerified() && !recipient.isIndividual) {
return name
}
return SpannableStringBuilder(name).apply {
if (recipient.showVerified()) {
SpanUtil.appendCenteredImageSpan(this, ContextUtil.requireDrawable(context, R.drawable.ic_official_28), 28, 28)
}
} else {
name
if (recipient.isIndividual) {
SpanUtil.appendCenteredImageSpan(this, ContextUtil.requireDrawable(context, R.drawable.symbol_chevron_right_24_color_on_secondary_container), 24, 24)
}
}
}
@ -101,6 +110,7 @@ object BioTextPreference {
override fun bind(model: T) {
headline.text = model.getHeadlineText(context)
headline.setOnClickListener { model.onHeadlineClickListener() }
model.getSubhead1Text(context).let {
subhead1.text = it

View file

@ -0,0 +1,294 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.recipients.ui.about
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
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.widget.TextViewCompat
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.getParcelableCompat
import org.thoughtcrime.securesms.AvatarPreviewActivity
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.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.settings.my.SignalConnectionsBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.viewModel
/**
* Displays all relevant context you know for a given user on the sheet.
*/
class AboutSheet : ComposeBottomSheetDialogFragment() {
companion object {
private const val RECIPIENT_ID = "recipient_id"
@JvmStatic
fun create(recipient: Recipient): AboutSheet {
return AboutSheet().apply {
arguments = bundleOf(
RECIPIENT_ID to recipient.id
)
}
}
}
override val peekHeightPercentage: Float = 1f
private val recipientId: RecipientId
get() = requireArguments().getParcelableCompat(RECIPIENT_ID, RecipientId::class.java)!!
private val viewModel by viewModel {
AboutSheetViewModel(recipientId)
}
@Composable
override fun SheetContent() {
val recipient by viewModel.recipient
val groupsInCommonCount by viewModel.groupsInCommonCount
if (recipient.isPresent) {
AboutSheetContent(
recipient = recipient.get(),
groupsInCommonCount = groupsInCommonCount,
onClickSignalConnections = this::openSignalConnectionsSheet,
onAvatarClicked = this::openProfilePhotoViewer
)
}
}
private fun openSignalConnectionsSheet() {
dismiss()
SignalConnectionsBottomSheetDialogFragment().show(parentFragmentManager, null)
}
private fun openProfilePhotoViewer() {
startActivity(AvatarPreviewActivity.intentFromRecipientId(requireContext(), recipientId))
}
}
@Preview
@Composable
private fun AboutSheetContentPreview() {
SignalTheme {
Surface {
AboutSheetContent(
recipient = Recipient.UNKNOWN,
groupsInCommonCount = 0,
onClickSignalConnections = {},
onAvatarClicked = {}
)
}
}
}
@Composable
private fun AboutSheetContent(
recipient: Recipient,
groupsInCommonCount: Int,
onClickSignalConnections: () -> Unit,
onAvatarClicked: () -> Unit
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth()
) {
BottomSheets.Handle(modifier = Modifier.padding(top = 6.dp))
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
AvatarImage(
recipient = recipient,
modifier = Modifier
.padding(top = 56.dp)
.size(240.dp)
.clickable(onClick = onAvatarClicked)
)
Text(
text = "About",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.padding(top = 20.dp, bottom = 8.dp)
)
val context = LocalContext.current
val displayName = remember(recipient) { recipient.getDisplayName(context) }
AboutRow(
startIcon = painterResource(R.drawable.symbol_person_24),
text = displayName,
modifier = Modifier.fillMaxWidth()
)
if (recipient.about != null) {
AboutRow(
startIcon = painterResource(R.drawable.symbol_edit_24),
text = {
Row {
AndroidView(factory = ::EmojiTextView) {
it.text = recipient.combinedAboutAndEmoji
TextViewCompat.setTextAppearance(it, R.style.Signal_Text_BodyLarge)
}
}
},
modifier = Modifier.fillMaxWidth()
)
}
if (recipient.isProfileSharing) {
AboutRow(
startIcon = painterResource(id = R.drawable.symbol_connections_24),
text = stringResource(id = R.string.AboutSheet__signal_connection),
endIcon = painterResource(id = R.drawable.symbol_chevron_right_compact_bold_16),
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClickSignalConnections)
)
}
val shortName = remember(recipient) { recipient.getShortDisplayName(context) }
if (recipient.isSystemContact) {
AboutRow(
startIcon = painterResource(id = R.drawable.symbol_person_circle_24),
text = stringResource(id = R.string.AboutSheet__s_is_in_your_system_contacts, shortName),
modifier = Modifier.fillMaxWidth()
)
}
if (recipient.e164.isPresent) {
val e164 = remember(recipient.e164.get()) {
PhoneNumberFormatter.get(context).prettyPrintFormat(recipient.e164.get())
}
AboutRow(
startIcon = painterResource(R.drawable.symbol_phone_24),
text = e164,
modifier = Modifier.fillMaxWidth()
)
}
val groupsInCommonText = if (recipient.hasGroupsInCommon()) {
stringResource(id = R.string.AboutSheet__d_groups_in_common, groupsInCommonCount)
} else {
stringResource(id = R.string.AboutSheet__you_have_no_groups_in_common)
}
AboutRow(
startIcon = painterResource(R.drawable.symbol_group_24),
text = groupsInCommonText,
modifier = Modifier.fillMaxWidth()
)
if (!recipient.isProfileSharing) {
AboutRow(
startIcon = painterResource(R.drawable.symbol_error_circle_24),
text = stringResource(id = R.string.AboutSheet__review_requests_carefully),
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.size(32.dp))
}
}
@Preview
@Composable
private fun AboutRowPreview() {
SignalTheme {
Surface {
AboutRow(
startIcon = painterResource(R.drawable.symbol_person_24),
text = "Maya Johnson",
endIcon = painterResource(id = R.drawable.symbol_chevron_right_compact_bold_16)
)
}
}
}
@Composable
private fun AboutRow(
startIcon: Painter,
text: String,
modifier: Modifier = Modifier,
endIcon: Painter? = null
) {
AboutRow(
startIcon = startIcon,
text = {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge
)
},
modifier = modifier,
endIcon = endIcon
)
}
@Composable
private fun AboutRow(
startIcon: Painter,
text: @Composable () -> Unit,
modifier: Modifier = Modifier,
endIcon: Painter? = null
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.padding(horizontal = 32.dp)
.padding(top = 12.dp)
) {
Icon(
painter = startIcon,
contentDescription = null,
modifier = Modifier
.padding(end = 16.dp)
.size(20.dp)
)
text()
if (endIcon != null) {
Icon(
painter = endIcon,
contentDescription = null,
tint = MaterialTheme.colorScheme.outline
)
}
}
}

View file

@ -0,0 +1,19 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.recipients.ui.about
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.recipients.RecipientId
class AboutSheetRepository {
fun getGroupsInCommonCount(recipientId: RecipientId): Single<Int> {
return Single.fromCallable {
SignalDatabase.groups.getPushGroupsContainingMember(recipientId).size
}.subscribeOn(Schedulers.io())
}
}

View file

@ -0,0 +1,51 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.recipients.ui.about
import androidx.compose.runtime.IntState
import androidx.compose.runtime.MutableIntState
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import java.util.Optional
class AboutSheetViewModel(
recipientId: RecipientId,
repository: AboutSheetRepository = AboutSheetRepository()
) : ViewModel() {
private val _recipient: MutableState<Optional<Recipient>> = mutableStateOf(Optional.empty())
val recipient: State<Optional<Recipient>> = _recipient
private val _groupsInCommonCount: MutableIntState = mutableIntStateOf(0)
val groupsInCommonCount: IntState = _groupsInCommonCount
private val recipientDisposable: Disposable = Recipient
.observable(recipientId)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy {
_recipient.value = Optional.of(it)
}
private val groupsInCommonDisposable: Disposable = repository
.getGroupsInCommonCount(recipientId)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy {
_groupsInCommonCount.intValue = it
}
override fun onCleared() {
recipientDisposable.dispose()
groupsInCommonDisposable.dispose()
}
}

View file

@ -40,6 +40,7 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientExporter;
import org.thoughtcrime.securesms.recipients.RecipientId;
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.DrawableUtil;
@ -192,7 +193,13 @@ public final class RecipientBottomSheetDialogFragment extends BottomSheetDialogF
} else if (recipient.showVerified()) {
SpanUtil.appendCenteredImageSpan(nameBuilder, ContextUtil.requireDrawable(requireContext(), R.drawable.ic_official_28), 28, 28);
}
SpanUtil.appendCenteredImageSpan(nameBuilder, ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_chevron_right_24_color_on_secondary_container), 24, 24);
fullName.setText(nameBuilder);
fullName.setOnClickListener(v -> {
dismiss();
AboutSheet.create(recipient).show(getParentFragmentManager(), null);
});
String aboutText = recipient.getCombinedAboutAndEmoji();
if (recipient.isReleaseNotes()) {

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/signal_colorOnSecondaryContainer"
android:pathData="M8.881,4.381a0.875,0.875 0,0 1,1.238 0l7,7a0.875,0.875 0,0 1,0 1.238l-7,7A0.875,0.875 0,0 1,8.88 18.38L15.263,12 8.88,5.619a0.875,0.875 0,0 1,0 -1.238Z" />
</vector>

View file

@ -0,0 +1,24 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:fillColor="#FF000000"
android:pathData="M13.5 2.5c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6Zm-4 6c0-2.2 1.8-4 4-4s4 1.8 4 4-1.8 4-4 4-4-1.8-4-4Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M13.5 25.5c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6Zm-4 6c0-2.2 1.8-4 4-4s4 1.8 4 4-1.8 4-4 4-4-1.8-4-4Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M20.5 8.5c0-3.31 2.69-6 6-6s6 2.69 6 6-2.69 6-6 6-6-2.69-6-6Zm6-4c-2.2 0-4 1.8-4 4s1.8 4 4 4 4-1.8 4-4-1.8-4-4-4Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M26.5 25.5c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6Zm-4 6c0-2.2 1.8-4 4-4s4 1.8 4 4-1.8 4-4 4-4-1.8-4-4Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M27 20c0-3.31 2.69-6 6-6s6 2.69 6 6-2.69 6-6 6-6-2.69-6-6Zm6-4c-2.2 0-4 1.8-4 4s1.8 4 4 4 4-1.8 4-4-1.8-4-4-4Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M7 14c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6Zm-4 6c0-2.2 1.8-4 4-4s4 1.8 4 4-1.8 4-4 4-4-1.8-4-4Z"/>
</vector>

View file

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
tools:viewBindingIgnore="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"

View file

@ -1879,6 +1879,18 @@
<!-- An accessibility label for screen readers on a view that can be expanded -->
<string name="CallOverflowPopupWindow__expand_snackbar_accessibility_label">Expand raised hand view</string>
<!-- AboutSheet -->
<!-- Displayed in a sheet row and allows user to open signal connection explanation on tap -->
<string name="AboutSheet__signal_connection">Signal connection</string>
<!-- Explains that the given user (placeholder is short name) is in the users system contact -->
<string name="AboutSheet__s_is_in_your_system_contacts">%1$s is in your system contacts</string>
<!-- Notice in a row when user has no groups in common -->
<string name="AboutSheet__you_have_no_groups_in_common">You have no groups in common</string>
<!-- Notice when a user is not a connection to review requests carefully -->
<string name="AboutSheet__review_requests_carefully">Review requests carefully</string>
<!-- Text used when user has groups in common. Placeholder is the count -->
<string name="AboutSheet__d_groups_in_common">%1$d groups in common</string>
<!-- CallParticipantsListDialog -->
<plurals name="CallParticipantsListDialog_in_this_call">
<item quantity="one">In this call (%1$d)</item>