diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index d9e8b21fa2..edcc8c8ebc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/BasicMegaphoneView.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/BasicMegaphoneView.java deleted file mode 100644 index 8f4ba89e79..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/BasicMegaphoneView.java +++ /dev/null @@ -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); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneComponent.kt b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneComponent.kt new file mode 100644 index 0000000000..ec8d7512b4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneComponent.kt @@ -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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneViewBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneViewBuilder.java index 8fd5b77ae3..050f50444f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneViewBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/MegaphoneViewBuilder.java @@ -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; - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphone.kt b/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphone.kt new file mode 100644 index 0000000000..0a1d322e72 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphone.kt @@ -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 + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphoneView.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphoneView.java deleted file mode 100644 index 1621f79432..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/OnboardingMegaphoneView.java +++ /dev/null @@ -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 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 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 buildData() { - List 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); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/PopupMegaphoneView.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/PopupMegaphoneView.java deleted file mode 100644 index 2dcb11dd61..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/PopupMegaphoneView.java +++ /dev/null @@ -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())); - } - - } -} diff --git a/app/src/main/res/layout/basic_megaphone_view.xml b/app/src/main/res/layout/basic_megaphone_view.xml deleted file mode 100644 index c28af6902c..0000000000 --- a/app/src/main/res/layout/basic_megaphone_view.xml +++ /dev/null @@ -1,123 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/onboarding_megaphone.xml b/app/src/main/res/layout/onboarding_megaphone.xml deleted file mode 100644 index 30fb87c5ce..0000000000 --- a/app/src/main/res/layout/onboarding_megaphone.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/onboarding_megaphone_card.xml b/app/src/main/res/layout/onboarding_megaphone_card.xml deleted file mode 100644 index 8947c64566..0000000000 --- a/app/src/main/res/layout/onboarding_megaphone_card.xml +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/popup_megaphone_view.xml b/app/src/main/res/layout/popup_megaphone_view.xml deleted file mode 100644 index bbeb014e75..0000000000 --- a/app/src/main/res/layout/popup_megaphone_view.xml +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file