diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt new file mode 100644 index 0000000000..6dae66403f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt @@ -0,0 +1,162 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.main + +import androidx.annotation.ColorRes +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.SignalPreview +import org.signal.core.ui.compose.Snackbars +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.megaphone.Megaphone +import org.thoughtcrime.securesms.megaphone.MegaphoneActionController +import org.thoughtcrime.securesms.megaphone.Megaphones + +data class SnackbarState( + val message: String, + val actionState: ActionState?, + val showProgress: Boolean = false, + val duration: SnackbarDuration = SnackbarDuration.Long +) { + data class ActionState( + val action: String, + @ColorRes val color: Int = R.color.core_white, + val onActionClick: () -> Unit + ) +} + +interface MainBottomChromeCallback { + fun onNewChatClick() + fun onNewCallClick() + fun onCameraClick(destination: MainNavigationDestination) + fun onMegaphoneVisible(megaphone: Megaphone) + fun onSnackbarDismissed() + + object Empty : MainBottomChromeCallback { + override fun onNewChatClick() = Unit + override fun onNewCallClick() = Unit + override fun onCameraClick(destination: MainNavigationDestination) = Unit + override fun onMegaphoneVisible(megaphone: Megaphone) = Unit + override fun onSnackbarDismissed() = Unit + } +} + +data class MainBottomChromeState( + val destination: MainNavigationDestination = MainNavigationDestination.CHATS, + val megaphoneState: MainMegaphoneState = MainMegaphoneState(), + val snackbarState: SnackbarState? = null +) + +/** + * Stack of bottom chrome components: + * - The Floating Action buttons + * - The megaphone view + * - The snackbar + */ +@Composable +fun MainBottomChrome( + state: MainBottomChromeState, + callback: MainBottomChromeCallback, + megaphoneActionController: MegaphoneActionController +) { + Column( + modifier = Modifier + .fillMaxWidth() + .animateContentSize() + ) { + Box( + contentAlignment = Alignment.CenterEnd, + modifier = Modifier.fillMaxWidth() + ) { + MainFloatingActionButtons( + destination = state.destination, + onCameraClick = callback::onCameraClick, + onNewCallClick = callback::onNewCallClick, + onNewChatClick = callback::onNewChatClick + ) + } + + MainMegaphoneContainer( + state = state.megaphoneState, + controller = megaphoneActionController, + onMegaphoneVisible = callback::onMegaphoneVisible + ) + + MainSnackbar( + snackbarState = state.snackbarState, + onDismissed = callback::onSnackbarDismissed + ) + } +} + +@Composable +private fun MainSnackbar( + snackbarState: SnackbarState?, + onDismissed: () -> Unit +) { + val hostState = remember { SnackbarHostState() } + + Snackbars.Host(hostState) + + LaunchedEffect(snackbarState) { + if (snackbarState != null) { + val result = hostState.showSnackbar( + message = snackbarState.message + ) + + when (result) { + SnackbarResult.Dismissed -> Unit + SnackbarResult.ActionPerformed -> snackbarState.actionState + } + + onDismissed() + } + } +} + +@SignalPreview +@Composable +fun MainBottomChromePreview() { + Previews.Preview { + val megaphone = remember { + Megaphone.Builder(Megaphones.Event.ONBOARDING, Megaphone.Style.ONBOARDING).build() + } + + Box( + contentAlignment = Alignment.BottomCenter, + modifier = Modifier.fillMaxSize() + ) { + MainBottomChrome( + state = MainBottomChromeState( + megaphoneState = MainMegaphoneState( + megaphone = megaphone + ), + snackbarState = SnackbarState( + message = "Test Message", + actionState = SnackbarState.ActionState( + action = "Ok", + onActionClick = {} + ) + ) + ), + callback = MainBottomChromeCallback.Empty, + megaphoneActionController = EmptyMegaphoneActionController + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainFloatingActionButtons.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainFloatingActionButtons.kt new file mode 100644 index 0000000000..dfd1386fc2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainFloatingActionButtons.kt @@ -0,0 +1,196 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.main + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonColors +import androidx.compose.material3.IconButtonDefaults +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.draw.shadow +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +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 kotlin.math.roundToInt + +private val ACTION_BUTTON_SIZE = 56.dp +private val ACTION_BUTTON_SPACING = 16.dp + +@Composable +fun MainFloatingActionButtons( + destination: MainNavigationDestination, + onNewChatClick: () -> Unit = {}, + onCameraClick: (MainNavigationDestination) -> Unit = {}, + onNewCallClick: () -> Unit = {} +) { + val boxHeightDp = (ACTION_BUTTON_SIZE * 2 + ACTION_BUTTON_SPACING) + val boxHeightPx = with(LocalDensity.current) { + boxHeightDp.toPx().roundToInt() + } + + Box( + modifier = Modifier + .padding(ACTION_BUTTON_SPACING) + .height(boxHeightDp) + ) { + AnimatedVisibility( + visible = destination == MainNavigationDestination.CHATS, + modifier = Modifier.align(Alignment.TopCenter), + enter = slideInVertically(initialOffsetY = { boxHeightPx - it }), + exit = slideOutVertically(targetOffsetY = { boxHeightPx - it }) + ) { + CameraButton( + colors = IconButtonDefaults.filledTonalIconButtonColors().copy( + containerColor = SignalTheme.colors.colorSurface1 + ), + onClick = { + onCameraClick(MainNavigationDestination.CHATS) + } + ) + } + + AnimatedContent( + targetState = destination, + modifier = Modifier.align(Alignment.BottomCenter), + transitionSpec = { EnterTransition.None togetherWith ExitTransition.None } + ) { targetState -> + when (targetState) { + MainNavigationDestination.CHATS -> NewChatButton(onNewChatClick) + MainNavigationDestination.CALLS -> NewCallButton(onNewCallClick) + MainNavigationDestination.STORIES -> CameraButton(onClick = { onCameraClick(MainNavigationDestination.STORIES) }) + } + } + } +} + +@Composable +private fun NewChatButton( + onClick: () -> Unit +) { + MainFloatingActionButton( + onClick = onClick, + contentDescription = "", + icon = ImageVector.vectorResource(R.drawable.symbol_edit_24) + ) +} + +@Composable +private fun CameraButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + colors: IconButtonColors = IconButtonDefaults.filledTonalIconButtonColors() +) { + MainFloatingActionButton( + onClick = onClick, + contentDescription = "", + icon = ImageVector.vectorResource(R.drawable.symbol_camera_24), + colors = colors, + modifier = modifier + ) +} + +@Composable +private fun NewCallButton( + onClick: () -> Unit +) { + MainFloatingActionButton( + onClick = onClick, + contentDescription = "", + icon = ImageVector.vectorResource(R.drawable.symbol_phone_plus_24) + ) +} + +@Composable +private fun MainFloatingActionButton( + onClick: () -> Unit, + icon: ImageVector, + contentDescription: String, + modifier: Modifier = Modifier, + colors: IconButtonColors = IconButtonDefaults.filledTonalIconButtonColors() +) { + FilledTonalIconButton( + onClick = onClick, + shape = RoundedCornerShape(18.dp), + modifier = modifier + .size(ACTION_BUTTON_SIZE) + .shadow(4.dp, RoundedCornerShape(18.dp)), + enabled = true, + colors = colors + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription + ) + } +} + +@SignalPreview +@Composable +private fun MainFloatingActionButtonsPreview() { + var destination by remember { mutableStateOf(MainNavigationDestination.CHATS) } + + Previews.Preview { + MainFloatingActionButtons( + destination = destination, + onCameraClick = { destination = MainNavigationDestination.CALLS }, + onNewChatClick = { destination = MainNavigationDestination.STORIES }, + onNewCallClick = { destination = MainNavigationDestination.CHATS } + ) + } +} + +@SignalPreview +@Composable +private fun NewChatButtonPreview() { + Previews.Preview { + NewChatButton( + onClick = {} + ) + } +} + +@SignalPreview +@Composable +private fun CameraButtonPreview() { + Previews.Preview { + CameraButton( + onClick = {} + ) + } +} + +@SignalPreview +@Composable +private fun NewCallButtonPreview() { + Previews.Preview { + NewCallButton( + onClick = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainMegaphoneContainer.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainMegaphoneContainer.kt new file mode 100644 index 0000000000..a71207595a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainMegaphoneContainer.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.main + +import android.app.Activity +import android.content.Intent +import android.content.res.Configuration +import android.view.LayoutInflater +import android.widget.FrameLayout +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.fragment.app.DialogFragment +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.SignalPreview +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.megaphone.Megaphone +import org.thoughtcrime.securesms.megaphone.MegaphoneActionController +import org.thoughtcrime.securesms.megaphone.MegaphoneViewBuilder +import org.thoughtcrime.securesms.megaphone.Megaphones +import org.thoughtcrime.securesms.util.visible + +data class MainMegaphoneState( + val megaphone: Megaphone = Megaphone.NONE, + val isDisplayingArchivedChats: Boolean = false, + val isSearchOpen: Boolean = false, + val isInActionMode: Boolean = false +) + +object EmptyMegaphoneActionController : MegaphoneActionController { + override fun onMegaphoneNavigationRequested(intent: Intent) = Unit + override fun onMegaphoneNavigationRequested(intent: Intent, requestCode: Int) = Unit + override fun onMegaphoneToastRequested(string: String) = Unit + override fun getMegaphoneActivity(): Activity = error("Empty controller") + override fun onMegaphoneSnooze(event: Megaphones.Event) = Unit + override fun onMegaphoneCompleted(event: Megaphones.Event) = Unit + override fun onMegaphoneDialogFragmentRequested(dialogFragment: DialogFragment) = Unit +} + +/** + * Composable wrapper for Megaphones + */ +@Composable +fun MainMegaphoneContainer( + state: MainMegaphoneState, + controller: MegaphoneActionController, + onMegaphoneVisible: (Megaphone) -> Unit +) { + val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val visible = remember(isLandscape, state.isDisplayingArchivedChats, state.isSearchOpen, state.isInActionMode, state.megaphone) { + !(state.megaphone == Megaphone.NONE || state.megaphone.style == Megaphone.Style.FULLSCREEN || state.isDisplayingArchivedChats || isLandscape || state.isSearchOpen || state.isInActionMode) + } + + AnimatedVisibility(visible = visible) { + if (LocalInspectionMode.current) { + Box( + modifier = Modifier + .background(color = Color.Red) + .fillMaxWidth() + .height(80.dp) + ) + } else { + AndroidView(factory = { context -> + LayoutInflater.from(context).inflate(R.layout.conversation_list_megaphone_container, null, false) as FrameLayout + }) { megaphoneContainer -> + val view = requireNotNull(MegaphoneViewBuilder.build(megaphoneContainer.context, state.megaphone, controller)) + megaphoneContainer.removeAllViews() + megaphoneContainer.addView(view) + megaphoneContainer.visible = true + } + } + } + + LaunchedEffect(state.megaphone, state.isDisplayingArchivedChats, isLandscape) { + if (state.megaphone == Megaphone.NONE || state.isDisplayingArchivedChats || isLandscape) { + return@LaunchedEffect + } + + if (state.megaphone.style == Megaphone.Style.FULLSCREEN) { + state.megaphone.onVisibleListener?.onEvent(state.megaphone, controller) + } + + onMegaphoneVisible(state.megaphone) + } +} + +@SignalPreview +@Composable +private fun MainMegaphoneContainerPreview() { + Previews.Preview { + MainMegaphoneContainer( + state = MainMegaphoneState(), + controller = EmptyMegaphoneActionController, + onMegaphoneVisible = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java index ca50b7b097..79159028da 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphone.java @@ -221,7 +221,7 @@ public class Megaphone { } } - enum Style { + public enum Style { /** * Specialized style for onboarding. */ diff --git a/core-ui/src/main/java/org/signal/core/ui/compose/Snackbars.kt b/core-ui/src/main/java/org/signal/core/ui/compose/Snackbars.kt index 2cbc3c05fa..db66034596 100644 --- a/core-ui/src/main/java/org/signal/core/ui/compose/Snackbars.kt +++ b/core-ui/src/main/java/org/signal/core/ui/compose/Snackbars.kt @@ -32,7 +32,7 @@ object Snackbars { } @Composable - internal fun Default(snackbarData: SnackbarData) { + fun Default(snackbarData: SnackbarData) { val colors = LocalSnackbarColors.current Snackbar( snackbarData = snackbarData,