Add compose bottom chrome.

This commit is contained in:
Alex Hart
2025-03-31 09:20:31 -03:00
committed by Greyson Parrelli
parent 80bc2bdc89
commit 7fe4816087
5 changed files with 471 additions and 2 deletions

View File

@@ -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
)
}
}
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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 = {}
)
}
}

View File

@@ -221,7 +221,7 @@ public class Megaphone {
}
}
enum Style {
public enum Style {
/**
* Specialized style for onboarding.
*/

View File

@@ -32,7 +32,7 @@ object Snackbars {
}
@Composable
internal fun Default(snackbarData: SnackbarData) {
fun Default(snackbarData: SnackbarData) {
val colors = LocalSnackbarColors.current
Snackbar(
snackbarData = snackbarData,