mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 13:08:46 +00:00
Add compose bottom chrome.
This commit is contained in:
committed by
Greyson Parrelli
parent
80bc2bdc89
commit
7fe4816087
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -221,7 +221,7 @@ public class Megaphone {
|
||||
}
|
||||
}
|
||||
|
||||
enum Style {
|
||||
public enum Style {
|
||||
/**
|
||||
* Specialized style for onboarding.
|
||||
*/
|
||||
|
||||
@@ -32,7 +32,7 @@ object Snackbars {
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun Default(snackbarData: SnackbarData) {
|
||||
fun Default(snackbarData: SnackbarData) {
|
||||
val colors = LocalSnackbarColors.current
|
||||
Snackbar(
|
||||
snackbarData = snackbarData,
|
||||
|
||||
Reference in New Issue
Block a user