Reimplement megaphone UI in compose.

This commit is contained in:
Alex Hart
2025-04-08 12:25:08 -03:00
committed by Michelle Tang
parent aa7b61ecb1
commit 855b315067
11 changed files with 716 additions and 884 deletions

View File

@@ -1038,10 +1038,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
} else {
megaphoneContainer.get().setVisibility(View.GONE);
if (megaphone.getOnVisibleListener() != null) {
megaphone.getOnVisibleListener().onEvent(megaphone, this);
}
}
viewModel.onMegaphoneVisible(megaphone);

View File

@@ -1,123 +0,0 @@
package org.thoughtcrime.securesms.megaphone;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.airbnb.lottie.LottieAnimationView;
import org.thoughtcrime.securesms.R;
public class BasicMegaphoneView extends FrameLayout {
private LottieAnimationView image;
private TextView titleText;
private TextView bodyText;
private Button actionButton;
private Button secondaryButton;
private Megaphone megaphone;
private MegaphoneActionController megaphoneListener;
public BasicMegaphoneView(@NonNull Context context) {
super(context);
init(context);
}
public BasicMegaphoneView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(context);
}
private void init(@NonNull Context context) {
inflate(context, R.layout.basic_megaphone_view, this);
this.image = findViewById(R.id.basic_megaphone_image);
this.titleText = findViewById(R.id.basic_megaphone_title);
this.bodyText = findViewById(R.id.basic_megaphone_body);
this.actionButton = findViewById(R.id.basic_megaphone_action);
this.secondaryButton = findViewById(R.id.basic_megaphone_secondary);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
if (megaphone != null && megaphoneListener != null && megaphone.getOnVisibleListener() != null) {
megaphone.getOnVisibleListener().onEvent(megaphone, megaphoneListener);
}
}
public void present(@NonNull Megaphone megaphone, @NonNull MegaphoneActionController megaphoneListener) {
this.megaphone = megaphone;
this.megaphoneListener = megaphoneListener;
if (megaphone.getImageRes() != 0) {
image.setVisibility(VISIBLE);
image.setImageResource(megaphone.getImageRes());
} else if (megaphone.getImageRequestBuilder() != null) {
image.setVisibility(VISIBLE);
megaphone.getImageRequestBuilder().into(image);
} else if (megaphone.getLottieRes() != 0) {
image.setVisibility(VISIBLE);
image.setAnimation(megaphone.getLottieRes());
image.playAnimation();
} else {
image.setVisibility(GONE);
}
if (megaphone.getTitle().hasText()) {
titleText.setVisibility(VISIBLE);
titleText.setText(megaphone.getTitle().resolve(getContext()));
} else {
titleText.setVisibility(GONE);
}
if (megaphone.getBody().hasText()) {
bodyText.setVisibility(VISIBLE);
bodyText.setText(megaphone.getBody().resolve(getContext()));
} else {
bodyText.setVisibility(GONE);
}
if (megaphone.hasButton()) {
actionButton.setVisibility(VISIBLE);
actionButton.setText(megaphone.getButtonText().resolve(getContext()));
actionButton.setOnClickListener(v -> {
if (megaphone.getButtonClickListener() != null) {
megaphone.getButtonClickListener().onEvent(megaphone, megaphoneListener);
}
});
} else {
actionButton.setVisibility(GONE);
}
if (megaphone.canSnooze() || megaphone.hasSecondaryButton()) {
secondaryButton.setVisibility(VISIBLE);
if (megaphone.canSnooze()) {
secondaryButton.setOnClickListener(v -> {
megaphoneListener.onMegaphoneSnooze(megaphone.getEvent());
if (megaphone.getSnoozeListener() != null) {
megaphone.getSnoozeListener().onEvent(megaphone, megaphoneListener);
}
});
} else {
secondaryButton.setText(megaphone.getSecondaryButtonText().resolve(getContext()));
secondaryButton.setOnClickListener(v -> {
if (megaphone.getSecondaryButtonClickListener() != null) {
megaphone.getSecondaryButtonClickListener().onEvent(megaphone, megaphoneListener);
}
});
}
} else {
secondaryButton.setVisibility(GONE);
}
}
}

View File

@@ -0,0 +1,355 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.megaphone
import android.graphics.drawable.Drawable
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.rememberLottieComposition
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.signal.core.ui.compose.IconButtons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.Emojifier
import org.thoughtcrime.securesms.main.EmptyMegaphoneActionController
import org.thoughtcrime.securesms.megaphone.Megaphones.Event
import org.thoughtcrime.securesms.util.DynamicTheme
import kotlin.math.roundToInt
/**
* Allows us to utilize our composeView from Java code.
*/
fun setContent(composeView: ComposeView, megaphone: Megaphone, megaphoneActionController: MegaphoneActionController) {
composeView.setContent {
SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(composeView.context)) {
MegaphoneComponent(
megaphone,
megaphoneActionController
)
}
}
}
/**
* Composable which replaces the whole builder pattern for megaphone views.
*/
@Composable
fun MegaphoneComponent(
megaphone: Megaphone,
megaphoneActionController: MegaphoneActionController,
modifier: Modifier = Modifier
) {
if (megaphone == Megaphone.NONE) {
return
}
when (megaphone.style) {
Megaphone.Style.ONBOARDING -> OnboardingMegaphone(
megaphoneActionController = megaphoneActionController,
modifier = modifier.fillMaxWidth()
)
Megaphone.Style.BASIC -> BasicMegaphone(
megaphone = megaphone,
megaphoneActionController = megaphoneActionController,
modifier = modifier.fillMaxWidth()
)
Megaphone.Style.FULLSCREEN -> Unit
Megaphone.Style.POPUP -> PopupMegaphone(
megaphone = megaphone,
megaphoneActionController = megaphoneActionController,
modifier = modifier.fillMaxWidth()
)
}
LaunchedEffect(megaphone) {
megaphone.onVisibleListener?.onEvent(megaphone, megaphoneActionController)
}
}
/**
* Basic megaphone with up to two actions, no elevation, and an outline.
*/
@Composable
private fun BasicMegaphone(
megaphone: Megaphone,
megaphoneActionController: MegaphoneActionController,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
Card(
elevation = CardDefaults.outlinedCardElevation(),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.38f)),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.background
),
modifier = Modifier
.padding(8.dp)
.then(modifier)
) {
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.padding(top = 16.dp, bottom = 8.dp)
) {
MegaphoneCardContent(megaphone = megaphone)
Row {
Spacer(modifier = Modifier.weight(1f))
if (megaphone.hasSecondaryButton()) {
TextButton(
onClick = { megaphone.secondaryButtonClickListener?.onEvent(megaphone, megaphoneActionController) }
) {
Text(
text = megaphone.secondaryButtonText?.resolve(context)!!
)
}
}
if (megaphone.canSnooze()) {
TextButton(
onClick = {
megaphoneActionController.onMegaphoneSnooze(megaphone.event)
megaphone.snoozeListener?.onEvent(megaphone, megaphoneActionController)
}
) {
Text(
text = megaphone.buttonText?.resolve(context)!!
)
}
} else if (megaphone.hasButton()) {
TextButton(
onClick = { megaphone.buttonClickListener?.onEvent(megaphone, megaphoneActionController) }
) {
Text(
text = megaphone.buttonText?.resolve(context)!!
)
}
}
}
}
}
}
/**
* Elevated megaphone with no actions but does have a close button.
*/
@Composable
private fun PopupMegaphone(
megaphone: Megaphone,
megaphoneActionController: MegaphoneActionController,
modifier: Modifier = Modifier
) {
Card(
elevation = CardDefaults.cardElevation(6.dp),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = colorResource(R.color.megaphone_background_color)),
modifier = Modifier
.padding(8.dp)
.then(modifier)
) {
Box {
MegaphoneCardContent(
megaphone = megaphone,
modifier = Modifier
.padding(horizontal = 8.dp)
.padding(top = 16.dp, bottom = 8.dp)
)
IconButtons.IconButton(
onClick = {
if (megaphone.hasButton()) {
megaphone.buttonClickListener?.onEvent(megaphone, megaphoneActionController)
} else {
megaphoneActionController.onMegaphoneCompleted(megaphone.event)
}
},
size = 48.dp,
modifier = Modifier.align(Alignment.TopEnd)
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_x_20),
contentDescription = stringResource(R.string.Material3SearchToolbar__close)
)
}
}
}
}
/**
* Shared card content including the image, title, and body.
*/
@Composable
private fun MegaphoneCardContent(
megaphone: Megaphone,
modifier: Modifier = Modifier
) {
Row(modifier = modifier) {
MegaphoneImage(
megaphone = megaphone,
modifier = Modifier.padding(start = 8.dp)
)
Column(
modifier = Modifier.padding(start = 12.dp, end = 8.dp)
) {
if (megaphone.title.hasText) {
Emojifier(
text = megaphone.title.resolve(LocalContext.current)!!
) { annotatedText, inlineContent ->
Text(
text = annotatedText,
inlineContent = inlineContent,
style = MaterialTheme.typography.bodyLarge
)
}
}
if (megaphone.body.hasText) {
Emojifier(
text = megaphone.body.resolve(LocalContext.current)!!
) { annotatedText, inlineContent ->
Text(
text = annotatedText,
inlineContent = inlineContent,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
/**
* An image, which is either backed by Lottie, a glide request or just a plain vector.
*/
@Composable
private fun MegaphoneImage(
megaphone: Megaphone,
modifier: Modifier = Modifier
) {
val sharedModifier = modifier.size(64.dp)
if (megaphone.imageRes != 0) {
Image(
imageVector = ImageVector.vectorResource(megaphone.imageRes),
contentDescription = null,
contentScale = ContentScale.Inside,
modifier = sharedModifier
)
} else if (megaphone.imageRequestBuilder != null) {
var drawable: Drawable? by remember {
mutableStateOf(null)
}
val painter = rememberDrawablePainter(drawable)
val size = with(LocalDensity.current) {
64.dp.toPx().roundToInt()
}
LaunchedEffect(megaphone.imageRequestBuilder) {
drawable = withContext(Dispatchers.IO) {
megaphone.imageRequestBuilder?.submit(size, size)?.get()
}
}
Image(
painter = painter,
contentDescription = null,
contentScale = ContentScale.Inside,
modifier = sharedModifier
)
} else if (megaphone.lottieRes != 0) {
val lottieComposition by rememberLottieComposition(spec = LottieCompositionSpec.RawRes(megaphone.lottieRes))
LottieAnimation(
composition = lottieComposition,
modifier = sharedModifier
)
}
}
@SignalPreview
@Composable
private fun BasicMegaphonePreview() {
Previews.Preview {
MegaphoneComponent(
megaphone = rememberTestMegaphone(Event.PINS_FOR_ALL, Megaphone.Style.BASIC),
megaphoneActionController = EmptyMegaphoneActionController
)
}
}
@SignalPreview
@Composable
private fun PopupMegaphonePreview() {
Previews.Preview {
MegaphoneComponent(
megaphone = rememberTestMegaphone(Event.PIN_REMINDER, Megaphone.Style.POPUP),
megaphoneActionController = EmptyMegaphoneActionController
)
}
}
/**
* Testing only.
*/
@Composable
private fun rememberTestMegaphone(
event: Event,
style: Megaphone.Style
): Megaphone {
return remember {
Megaphone.Builder(event, style)
.setImage(R.drawable.illustration_toggle_switch)
.setTitle("Avengers HQ Destroyed!")
.setBody("Where was the 'hero' Spider-Man during the battle?")
.setActionButton("*sigh*") { _, _ -> }
.setSecondaryButton("Remind me later") { _, _ -> }
.build()
}
}

View File

@@ -5,6 +5,7 @@ import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.compose.ui.platform.ComposeView;
public class MegaphoneViewBuilder {
@@ -14,42 +15,15 @@ public class MegaphoneViewBuilder {
{
switch (megaphone.getStyle()) {
case BASIC:
return buildBasicMegaphone(context, megaphone, listener);
case ONBOARDING:
case POPUP:
ComposeView composeView = new ComposeView(context);
MegaphoneComponentKt.setContent(composeView, megaphone, listener);
return composeView;
case FULLSCREEN:
return null;
case ONBOARDING:
return buildOnboardingMegaphone(context, megaphone, listener);
case POPUP:
return buildPopupMegaphone(context, megaphone, listener);
default:
throw new IllegalArgumentException("No view implemented for style!");
}
}
private static @NonNull View buildBasicMegaphone(@NonNull Context context,
@NonNull Megaphone megaphone,
@NonNull MegaphoneActionController listener)
{
BasicMegaphoneView view = new BasicMegaphoneView(context);
view.present(megaphone, listener);
return view;
}
private static @NonNull View buildOnboardingMegaphone(@NonNull Context context,
@NonNull Megaphone megaphone,
@NonNull MegaphoneActionController listener)
{
OnboardingMegaphoneView view = new OnboardingMegaphoneView(context);
view.present(megaphone, listener);
return view;
}
private static @NonNull View buildPopupMegaphone(@NonNull Context context,
@NonNull Megaphone megaphone,
@NonNull MegaphoneActionController listener)
{
PopupMegaphoneView view = new PopupMegaphoneView(context);
view.present(megaphone, listener);
return view;
}
}

View File

@@ -0,0 +1,355 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.megaphone
import android.content.Intent
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.IconButtons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.InviteActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.main.EmptyMegaphoneActionController
import org.thoughtcrime.securesms.profiles.manage.EditProfileActivity
import org.thoughtcrime.securesms.wallpaper.ChatWallpaperActivity
/**
* The onboarding megaphone (list of cards)
*/
@Composable
fun OnboardingMegaphone(
megaphoneActionController: MegaphoneActionController,
modifier: Modifier = Modifier,
onboardingState: OnboardingState = OnboardingState.rememberOnboardingState(megaphoneActionController)
) {
Column(
modifier = modifier
.padding(bottom = 22.dp)
) {
Box(
modifier = Modifier
.height(24.dp)
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
MaterialTheme.colorScheme.background
)
)
)
)
Text(
text = stringResource(R.string.Megaphones_get_started),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
val onboardingItems = remember(onboardingState.displayState) {
OnboardingListItem.entries.filter(onboardingState.displayState::shouldDisplayListItem)
}
LazyRow(
modifier = Modifier.padding(top = 10.dp)
) {
itemsIndexed(items = onboardingItems) { idx, item ->
OnboardingMegaphoneListItem(
onboardingListItem = item,
onActionClick = {
onboardingState.onItemActionClick(item)
},
onCloseClick = {
onboardingState.onItemCloseClick(item)
},
modifier = if (idx == 0) Modifier.padding(start = 16.dp) else Modifier
)
}
}
}
}
/**
* Single megaphone list item, such as "Invite Friends"
*/
@Composable
private fun OnboardingMegaphoneListItem(
onboardingListItem: OnboardingListItem,
onActionClick: (OnboardingListItem) -> Unit,
onCloseClick: (OnboardingListItem) -> Unit,
modifier: Modifier = Modifier
) {
Card(
shape = RoundedCornerShape(28.dp),
elevation = CardDefaults.cardElevation(0.dp),
colors = CardDefaults.cardColors(
containerColor = colorResource(onboardingListItem.cardColor)
),
modifier = modifier
.padding(end = 12.dp)
.width(152.dp)
.clickable(onClick = { onActionClick(onboardingListItem) })
) {
Box(
modifier = Modifier.fillMaxWidth()
) {
IconButtons.IconButton(
onClick = { onCloseClick(onboardingListItem) },
size = 48.dp,
modifier = Modifier.align(Alignment.TopEnd)
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_x_24),
tint = colorResource(R.color.signal_light_colorOutline),
contentDescription = stringResource(R.string.Material3SearchToolbar__close)
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 84.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = ImageVector.vectorResource(onboardingListItem.icon),
contentDescription = null,
tint = colorResource(R.color.signal_light_colorOnSurface),
modifier = Modifier.size(24.dp)
)
Text(
text = stringResource(onboardingListItem.title),
style = MaterialTheme.typography.labelMedium,
textAlign = TextAlign.Center,
maxLines = 2,
color = colorResource(R.color.signal_light_colorOnSurface),
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 8.dp)
)
}
}
}
}
@SignalPreview
@Composable
private fun OnboardingMegaphonePreview() {
Previews.Preview {
OnboardingMegaphone(
megaphoneActionController = EmptyMegaphoneActionController,
onboardingState = OnboardingState.rememberOnboardingState()
)
}
}
@SignalPreview
@Composable
private fun OnboardingMegaphoneListItemPreview() {
Previews.Preview {
OnboardingMegaphoneListItem(
onboardingListItem = OnboardingListItem.INVITE,
onActionClick = {},
onCloseClick = {}
)
}
}
/**
* Represents a card that can be displayed to the user when showing onboarding content.
*/
enum class OnboardingListItem(
@StringRes val title: Int,
@DrawableRes val icon: Int,
@ColorRes val cardColor: Int
) {
GROUP(
title = R.string.Megaphones_new_group,
icon = R.drawable.symbol_group_24,
cardColor = R.color.onboarding_background_1
),
INVITE(
title = R.string.Megaphones_invite_friends,
icon = R.drawable.symbol_invite_24,
cardColor = R.color.onboarding_background_2
),
ADD_PHOTO(
title = R.string.Megaphones_add_a_profile_photo,
icon = R.drawable.symbol_person_circle_24,
cardColor = R.color.onboarding_background_4
),
APPEARANCE(
title = R.string.Megaphones_chat_colors,
icon = R.drawable.ic_color_24,
cardColor = R.color.onboarding_background_3
)
}
/**
* Maintains the list of displayable cards and drives actions performed by the user.
*/
abstract class OnboardingState private constructor(
initialState: DisplayState = DisplayState(),
val megaphoneActionController: MegaphoneActionController
) {
companion object {
/**
* Grabs an [OnboardingState], keyed to the given [MegaphoneActionController]
*/
@Composable
fun rememberOnboardingState(megaphoneActionController: MegaphoneActionController = EmptyMegaphoneActionController): OnboardingState {
return if (LocalInspectionMode.current) {
Preview
} else {
remember(megaphoneActionController) { Real(megaphoneActionController = megaphoneActionController) }
}
}
}
/**
* The latest display state for the list of onboarding items. An empty list means we can
* mark this megaphone as complete.
*/
var displayState: DisplayState by mutableStateOf(initialState)
/**
* When a list item is clicked.
*/
abstract fun onItemActionClick(onboardingListItem: OnboardingListItem)
/**
* When a list item close button is clicked.
*/
abstract fun onItemCloseClick(onboardingListItem: OnboardingListItem)
/**
* Preview implementation, used automatically when rendering previews.
*/
private object Preview : OnboardingState(
initialState = DisplayState(
shouldShowNewGroup = true,
shouldShowInviteFriends = true,
shouldShowAddPhoto = true,
shouldShowAppearance = true
),
megaphoneActionController = EmptyMegaphoneActionController
) {
override fun onItemCloseClick(onboardingListItem: OnboardingListItem) {
displayState = when (onboardingListItem) {
OnboardingListItem.GROUP -> displayState.copy(shouldShowNewGroup = false)
OnboardingListItem.INVITE -> displayState.copy(shouldShowInviteFriends = false)
OnboardingListItem.ADD_PHOTO -> displayState.copy(shouldShowAddPhoto = false)
OnboardingListItem.APPEARANCE -> displayState.copy(shouldShowAppearance = false)
}
}
override fun onItemActionClick(onboardingListItem: OnboardingListItem) = Unit
}
/**
* Real implementation, used automatically on-device. Backed by SignalStore.
*/
private class Real(megaphoneActionController: MegaphoneActionController) : OnboardingState(megaphoneActionController = megaphoneActionController) {
override fun onItemCloseClick(onboardingListItem: OnboardingListItem) {
when (onboardingListItem) {
OnboardingListItem.GROUP -> SignalStore.onboarding.setShowNewGroup(false)
OnboardingListItem.INVITE -> SignalStore.onboarding.setShowInviteFriends(false)
OnboardingListItem.ADD_PHOTO -> SignalStore.onboarding.setShowAddPhoto(false)
OnboardingListItem.APPEARANCE -> SignalStore.onboarding.setShowAppearance(false)
}
displayState = DisplayState()
if (displayState.hasNoVisibleContent()) {
megaphoneActionController.onMegaphoneCompleted(Megaphones.Event.ONBOARDING)
}
}
override fun onItemActionClick(onboardingListItem: OnboardingListItem) {
when (onboardingListItem) {
OnboardingListItem.GROUP -> megaphoneActionController.onMegaphoneNavigationRequested(CreateGroupActivity.newIntent(megaphoneActionController.megaphoneActivity))
OnboardingListItem.INVITE -> megaphoneActionController.onMegaphoneNavigationRequested(Intent(megaphoneActionController.megaphoneActivity, InviteActivity::class.java))
OnboardingListItem.ADD_PHOTO -> {
megaphoneActionController.onMegaphoneNavigationRequested(EditProfileActivity.getIntentForAvatarEdit(megaphoneActionController.megaphoneActivity))
SignalStore.onboarding.setShowAddPhoto(false)
}
OnboardingListItem.APPEARANCE -> {
megaphoneActionController.onMegaphoneNavigationRequested(ChatWallpaperActivity.createIntent(megaphoneActionController.megaphoneActivity))
SignalStore.onboarding.setShowAppearance(false)
}
}
displayState = DisplayState()
if (displayState.hasNoVisibleContent()) {
megaphoneActionController.onMegaphoneCompleted(Megaphones.Event.ONBOARDING)
}
}
}
/**
* Simple display state, driven by [SignalStore] by default.
*/
data class DisplayState(
private val shouldShowNewGroup: Boolean = SignalStore.onboarding.shouldShowNewGroup(),
private val shouldShowInviteFriends: Boolean = SignalStore.onboarding.shouldShowInviteFriends(),
private val shouldShowAddPhoto: Boolean = SignalStore.onboarding.shouldShowAddPhoto() && !SignalStore.misc.hasEverHadAnAvatar,
private val shouldShowAppearance: Boolean = SignalStore.onboarding.shouldShowAppearance()
) {
fun hasNoVisibleContent(): Boolean = !(shouldShowNewGroup || shouldShowInviteFriends || shouldShowAddPhoto || shouldShowAppearance)
fun shouldDisplayListItem(onboardingListItem: OnboardingListItem): Boolean {
return when (onboardingListItem) {
OnboardingListItem.GROUP -> shouldShowNewGroup
OnboardingListItem.INVITE -> shouldShowInviteFriends
OnboardingListItem.ADD_PHOTO -> shouldShowAddPhoto
OnboardingListItem.APPEARANCE -> shouldShowAppearance
}
}
}
}

View File

@@ -1,312 +0,0 @@
package org.thoughtcrime.securesms.megaphone;
import android.content.Context;
import android.content.Intent;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import androidx.annotation.ColorRes;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.InviteActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.databinding.OnboardingMegaphoneCardBinding;
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.profiles.manage.EditProfileActivity;
import org.thoughtcrime.securesms.wallpaper.ChatWallpaperActivity;
import java.util.ArrayList;
import java.util.List;
/**
* Shows the a fun rail of cards that educate the user about some actions they can take right after
* they install the app.
*/
public class OnboardingMegaphoneView extends FrameLayout {
private static final String TAG = Log.tag(OnboardingMegaphoneView.class);
private RecyclerView cardList;
public OnboardingMegaphoneView(Context context) {
super(context);
initialize(context);
}
public OnboardingMegaphoneView(Context context, AttributeSet attrs) {
super(context, attrs);
initialize(context);
}
private void initialize(@NonNull Context context) {
inflate(context, R.layout.onboarding_megaphone, this);
this.cardList = findViewById(R.id.onboarding_megaphone_list);
}
public void present(@NonNull Megaphone megaphone, @NonNull MegaphoneActionController listener) {
this.cardList.setAdapter(new CardAdapter(getContext(), listener));
}
private static class CardAdapter extends RecyclerView.Adapter<CardViewHolder> implements ActionClickListener {
private static final int TYPE_GROUP = 0;
private static final int TYPE_INVITE = 1;
private static final int TYPE_APPEARANCE = 2;
private static final int TYPE_ADD_PHOTO = 3;
private final Context context;
private final MegaphoneActionController controller;
private final List<Integer> data;
CardAdapter(@NonNull Context context, @NonNull MegaphoneActionController controller) {
this.context = context;
this.controller = controller;
this.data = buildData();
if (data.isEmpty()) {
Log.i(TAG, "Nothing to show (constructor)! Considering megaphone completed.");
controller.onMegaphoneCompleted(Megaphones.Event.ONBOARDING);
}
setHasStableIds(true);
}
@Override
public int getItemViewType(int position) {
return data.get(position);
}
@Override
public long getItemId(int position) {
return data.get(position);
}
@Override
public @NonNull CardViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.onboarding_megaphone_card, parent, false);
switch (viewType) {
case TYPE_GROUP: return new GroupCardViewHolder(view);
case TYPE_INVITE: return new InviteCardViewHolder(view);
case TYPE_APPEARANCE: return new AppearanceCardViewHolder(view);
case TYPE_ADD_PHOTO: return new AddPhotoCardViewHolder(view);
default: throw new IllegalStateException("Invalid viewType! " + viewType);
}
}
@Override
public void onBindViewHolder(@NonNull CardViewHolder holder, int position) {
holder.bind(this, controller);
}
@Override
public int getItemCount() {
return data.size();
}
@Override
public void onClick() {
data.clear();
data.addAll(buildData());
if (data.isEmpty()) {
Log.i(TAG, "Nothing to show! Considering megaphone completed.");
controller.onMegaphoneCompleted(Megaphones.Event.ONBOARDING);
}
notifyDataSetChanged();
}
private static List<Integer> buildData() {
List<Integer> data = new ArrayList<>();
if (SignalStore.onboarding().shouldShowNewGroup()) {
data.add(TYPE_GROUP);
}
if (SignalStore.onboarding().shouldShowInviteFriends()) {
data.add(TYPE_INVITE);
}
if (SignalStore.onboarding().shouldShowAddPhoto() && !SignalStore.misc().getHasEverHadAnAvatar()) {
data.add(TYPE_ADD_PHOTO);
}
if (SignalStore.onboarding().shouldShowAppearance()) {
data.add(TYPE_APPEARANCE);
}
return data;
}
}
private interface ActionClickListener {
void onClick();
}
private static abstract class CardViewHolder extends RecyclerView.ViewHolder {
private final OnboardingMegaphoneCardBinding binding;
public CardViewHolder(@NonNull View itemView) {
super(itemView);
binding = OnboardingMegaphoneCardBinding.bind(itemView);
}
public void bind(@NonNull ActionClickListener listener, @NonNull MegaphoneActionController controller) {
binding.getRoot().setCardBackgroundColor(ContextCompat.getColor(binding.getRoot().getContext(), getBackgroundColor()));
binding.icon.setImageResource(getImageRes());
binding.text.setText(getButtonStringRes());
binding.getRoot().setOnClickListener(v -> {
onActionClicked(controller);
listener.onClick();
});
binding.close.setOnClickListener(v -> {
onCloseClicked();
listener.onClick();
});
}
abstract @StringRes int getButtonStringRes();
abstract @DrawableRes int getImageRes();
abstract @ColorRes int getBackgroundColor();
abstract void onActionClicked(@NonNull MegaphoneActionController controller);
abstract void onCloseClicked();
}
private static class GroupCardViewHolder extends CardViewHolder {
public GroupCardViewHolder(@NonNull View itemView) {
super(itemView);
}
@Override
int getButtonStringRes() {
return R.string.Megaphones_new_group;
}
@Override
int getImageRes() {
return R.drawable.symbol_group_24;
}
@Override
int getBackgroundColor() {
return R.color.onboarding_background_1;
}
@Override
void onActionClicked(@NonNull MegaphoneActionController controller) {
controller.onMegaphoneNavigationRequested(CreateGroupActivity.newIntent(controller.getMegaphoneActivity()));
}
@Override
void onCloseClicked() {
SignalStore.onboarding().setShowNewGroup(false);
}
}
private static class InviteCardViewHolder extends CardViewHolder {
public InviteCardViewHolder(@NonNull View itemView) {
super(itemView);
}
@Override
int getButtonStringRes() {
return R.string.Megaphones_invite_friends;
}
@Override
int getImageRes() {
return R.drawable.symbol_invite_24;
}
@Override
int getBackgroundColor() {
return R.color.onboarding_background_2;
}
@Override
void onActionClicked(@NonNull MegaphoneActionController controller) {
controller.onMegaphoneNavigationRequested(new Intent(controller.getMegaphoneActivity(), InviteActivity.class));
}
@Override
void onCloseClicked() {
SignalStore.onboarding().setShowInviteFriends(false);
}
}
private static class AppearanceCardViewHolder extends CardViewHolder {
public AppearanceCardViewHolder(@NonNull View itemView) {
super(itemView);
}
@Override
int getButtonStringRes() {
return R.string.Megaphones_chat_colors;
}
@Override
int getImageRes() {
return R.drawable.ic_color_24;
}
@Override
int getBackgroundColor() {
return R.color.onboarding_background_3;
}
@Override
void onActionClicked(@NonNull MegaphoneActionController controller) {
controller.onMegaphoneNavigationRequested(ChatWallpaperActivity.createIntent(controller.getMegaphoneActivity()));
SignalStore.onboarding().setShowAppearance(false);
}
@Override
void onCloseClicked() {
SignalStore.onboarding().setShowAppearance(false);
}
}
private static class AddPhotoCardViewHolder extends CardViewHolder {
public AddPhotoCardViewHolder(@NonNull View itemView) {
super(itemView);
}
@Override
int getButtonStringRes() {
return R.string.Megaphones_add_a_profile_photo;
}
@Override
int getImageRes() {
return R.drawable.symbol_person_circle_24;
}
@Override
int getBackgroundColor() {
return R.color.onboarding_background_4;
}
@Override
void onActionClicked(@NonNull MegaphoneActionController controller) {
controller.onMegaphoneNavigationRequested(EditProfileActivity.getIntentForAvatarEdit(controller.getMegaphoneActivity()));
SignalStore.onboarding().setShowAddPhoto(false);
}
@Override
void onCloseClicked() {
SignalStore.onboarding().setShowAddPhoto(false);
}
}
}

View File

@@ -1,90 +0,0 @@
package org.thoughtcrime.securesms.megaphone;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.airbnb.lottie.LottieAnimationView;
import org.thoughtcrime.securesms.R;
public class PopupMegaphoneView extends FrameLayout {
private LottieAnimationView image;
private TextView titleText;
private TextView bodyText;
private View xButton;
private Megaphone megaphone;
private MegaphoneActionController megaphoneListener;
public PopupMegaphoneView(@NonNull Context context) {
super(context);
init(context);
}
public PopupMegaphoneView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(context);
}
private void init(@NonNull Context context) {
inflate(context, R.layout.popup_megaphone_view, this);
this.image = findViewById(R.id.popup_megaphone_image);
this.titleText = findViewById(R.id.popup_megaphone_title);
this.bodyText = findViewById(R.id.popup_megaphone_body);
this.xButton = findViewById(R.id.popup_x);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
if (megaphone != null && megaphoneListener != null && megaphone.getOnVisibleListener() != null) {
megaphone.getOnVisibleListener().onEvent(megaphone, megaphoneListener);
}
}
public void present(@NonNull Megaphone megaphone, @NonNull MegaphoneActionController megaphoneListener) {
this.megaphone = megaphone;
this.megaphoneListener = megaphoneListener;
if (megaphone.getImageRequestBuilder() != null) {
image.setVisibility(VISIBLE);
megaphone.getImageRequestBuilder().into(image);
} else if (megaphone.getLottieRes() != 0) {
image.setVisibility(VISIBLE);
image.setAnimation(megaphone.getLottieRes());
image.playAnimation();
} else {
image.setVisibility(GONE);
}
if (megaphone.getTitle().hasText()) {
titleText.setVisibility(VISIBLE);
titleText.setText(megaphone.getTitle().resolve(getContext()));
} else {
titleText.setVisibility(GONE);
}
if (megaphone.getBody().hasText()) {
bodyText.setVisibility(VISIBLE);
bodyText.setText(megaphone.getBody().resolve(getContext()));
} else {
bodyText.setVisibility(GONE);
}
if (megaphone.hasButton()) {
xButton.setOnClickListener(v -> megaphone.getButtonClickListener().onEvent(megaphone, megaphoneListener));
} else {
xButton.setOnClickListener(v -> megaphoneListener.onMegaphoneCompleted(megaphone.getEvent()));
}
}
}

View File

@@ -1,123 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<merge
xmlns:android="http://schemas.android.com/apk/res/android"
tools:viewBindingIgnore="true"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:clipChildren="false"
android:clipToPadding="false"
app:strokeColor="@color/signal_colorOutline_38"
app:strokeWidth="1dp"
app:cardBackgroundColor="@color/signal_colorBackground"
app:cardCornerRadius="12dp"
app:cardElevation="0dp"
app:cardPreventCornerOverlap="false"
app:cardUseCompatPadding="true"
app:contentPadding="0dp">
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
android:paddingStart="8dp"
android:paddingTop="16dp"
android:paddingEnd="8dp"
android:paddingBottom="8dp">
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/basic_megaphone_image"
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_marginStart="8dp"
android:scaleType="centerInside"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@tools:sample/avatars" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/basic_megaphone_title"
style="@style/Signal.Text.BodyLarge"
android:textColor="@color/signal_colorOnSurface"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="8dp"
android:fontFamily="sans-serif-medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/basic_megaphone_image"
app:layout_constraintTop_toTopOf="@id/basic_megaphone_image"
tools:text="Avengers HQ Destroyed!" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/basic_megaphone_body"
style="@style/Signal.Text.BodyMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:textColor="@color/megaphone_body_text_color"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/basic_megaphone_title"
app:layout_constraintTop_toBottomOf="@id/basic_megaphone_title"
tools:text="Where was the 'hero' Spider-Man during the battle?" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/basic_megaphone_content_barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="basic_megaphone_image,basic_megaphone_body,basic_megaphone_title" />
<com.google.android.material.button.MaterialButton
android:id="@+id/basic_megaphone_action"
style="@style/Signal.Widget.Button.Large.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAllCaps="false"
android:textColor="@color/signal_colorPrimary"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/basic_megaphone_secondary"
app:layout_constraintTop_toBottomOf="@id/basic_megaphone_content_barrier"
app:layout_constraintWidth_max="wrap"
app:layout_constraintWidth_percent="0.5"
tools:text="*sigh*"
tools:visibility="visible" />
<com.google.android.material.button.MaterialButton
android:id="@+id/basic_megaphone_secondary"
style="@style/Signal.Widget.Button.Large.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/Megaphones_remind_me_later"
android:textAllCaps="false"
android:textColor="@color/signal_colorPrimary"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toStartOf="@id/basic_megaphone_action"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/basic_megaphone_content_barrier"
app:layout_constraintWidth_max="wrap"
app:layout_constraintWidth_percent="0.5"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</com.google.android.material.card.MaterialCardView>
</merge>

View File

@@ -1,60 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:paddingBottom="10dp"
tools:parentTag="android.widget.FrameLayout"
tools:viewBindingIgnore="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical">
<ImageView
android:layout_width="match_parent"
android:layout_height="24dp"
android:src="@drawable/megaphone_onboarding_gradient" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/signal_background_primary"
android:clipChildren="false"
android:clipToPadding="false"
android:paddingBottom="12dp">
<TextView
android:id="@+id/onboarding_megaphone_title"
style="@style/TextAppearance.Signal.Title2.Bold"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:text="@string/Megaphones_get_started"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/onboarding_megaphone_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="10dp"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="horizontal"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintTop_toBottomOf="@id/onboarding_megaphone_title"
tools:listitem="@layout/onboarding_megaphone_card" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
</merge>

View File

@@ -1,61 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="152dp"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
app:cardCornerRadius="28dp"
app:cardElevation="0dp"
tools:cardBackgroundColor="@color/onboarding_background_1">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:minHeight="84dp">
<ImageView
android:id="@+id/close"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/Material3SearchToolbar__close"
android:scaleType="centerInside"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/symbol_x_24"
app:tint="@color/signal_light_colorOutline" />
<ImageView
android:id="@+id/icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:importantForAccessibility="no"
android:scaleType="centerInside"
app:layout_constraintBottom_toTopOf="@id/text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
app:tint="@color/signal_light_colorOnSurface"
tools:srcCompat="@drawable/symbol_group_24" />
<TextView
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:paddingHorizontal="8dp"
android:textAlignment="center"
android:textAppearance="@style/Signal.Text.LabelMedium"
android:textColor="@color/signal_light_colorOnSurface"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/icon"
tools:text="Set some profile picture so folks know who you are." />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -1,79 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<merge
xmlns:android="http://schemas.android.com/apk/res/android"
tools:viewBindingIgnore="true"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:clipChildren="false"
android:clipToPadding="false"
app:cardBackgroundColor="@color/megaphone_background_color"
app:cardCornerRadius="8dp"
app:cardElevation="6dp"
app:cardPreventCornerOverlap="false"
app:cardUseCompatPadding="true"
app:contentPadding="0dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/signal_background_secondary"
android:clickable="true"
android:paddingBottom="16dp">
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/popup_megaphone_image"
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:scaleType="centerInside"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@tools:sample/avatars" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/popup_megaphone_title"
style="@style/Signal.Text.Body"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:fontFamily="sans-serif-medium"
app:layout_constraintEnd_toStartOf="@id/popup_x"
app:layout_constraintStart_toEndOf="@id/popup_megaphone_image"
app:layout_constraintTop_toTopOf="@id/popup_megaphone_image"
tools:text="Avengers HQ Destroyed!" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/popup_megaphone_body"
style="@style/Signal.Text.Preview"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textColor="@color/megaphone_body_text_color"
app:layout_constraintEnd_toStartOf="@id/popup_x"
app:layout_constraintStart_toStartOf="@id/popup_megaphone_title"
app:layout_constraintTop_toBottomOf="@id/popup_megaphone_title"
tools:text="Where was the 'hero' Spider-Man during the battle?" />
<ImageView
android:id="@+id/popup_x"
android:layout_width="48dp"
android:layout_height="48dp"
android:paddingStart="12.5dp"
android:paddingTop="14dp"
android:paddingEnd="15.5dp"
android:paddingBottom="14dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_x_20" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
</merge>