From 47ce28a721ab6ec0760aef1cabc45756a5252468 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 20 Feb 2025 14:15:56 -0400 Subject: [PATCH] Reimplement the call overflow menu in compose. --- .../webrtc/v2/AdditionalActionsPopup.kt | 202 ++++++++++++++++++ .../components/webrtc/v2/CallControls.kt | 15 +- .../components/webrtc/v2/CallScreen.kt | 25 ++- .../webrtc/v2/CallScreenControlsListener.kt | 2 + .../components/webrtc/v2/CallScreenState.kt | 11 +- .../webrtc/v2/ComposeCallScreenMediator.kt | 40 +++- .../webrtc/v2/WebRtcCallActivity.kt | 4 + .../org/signal/core/ui/TriggerAlignedPopup.kt | 118 ++++++++++ 8 files changed, 407 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/AdditionalActionsPopup.kt create mode 100644 core-ui/src/main/java/org/signal/core/ui/TriggerAlignedPopup.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/AdditionalActionsPopup.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/AdditionalActionsPopup.kt new file mode 100644 index 0000000000..418617a4ed --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/AdditionalActionsPopup.kt @@ -0,0 +1,202 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement.spacedBy +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.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import org.signal.core.ui.DarkPreview +import org.signal.core.ui.Previews +import org.signal.core.ui.TriggerAlignedPopup +import org.signal.core.ui.TriggerAlignedPopupState +import org.signal.core.ui.theme.SignalTheme +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.emoji.Emojifier + +data class AdditionalActionsState( + val triggerAlignedPopupState: TriggerAlignedPopupState, + val isShown: Boolean = false, + val reactions: PersistentList = persistentListOf(), + val isSelfHandRaised: Boolean = false, + @Stable val listener: AdditionalActionsListener = AdditionalActionsListener.Empty +) + +interface AdditionalActionsListener { + fun onReactClick(reaction: String) + fun onReactWithAnyClick() + fun onRaiseHandClick(raised: Boolean) + + object Empty : AdditionalActionsListener { + override fun onReactClick(reaction: String) = Unit + override fun onReactWithAnyClick() = Unit + override fun onRaiseHandClick(raised: Boolean) = Unit + } +} + +@Composable +fun AdditionalActionsPopup( + onDismissRequest: () -> Unit, + state: AdditionalActionsState +) { + TriggerAlignedPopup( + onDismissRequest = onDismissRequest, + state = state.triggerAlignedPopupState + ) { + Column( + verticalArrangement = spacedBy(12.dp), + modifier = Modifier + .width(320.dp) + .padding(12.dp) + ) { + CallReactionScrubber( + reactions = state.reactions, + listener = state.listener + ) + + CallScreenMenu( + onRaiseHandClick = state.listener::onRaiseHandClick, + isSelfHandRaised = state.isSelfHandRaised + ) + } + } +} + +@Composable +private fun CallReactionScrubber( + reactions: PersistentList, + listener: AdditionalActionsListener +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .background(SignalTheme.colors.colorSurface2, RoundedCornerShape(percent = 50)) + .padding(start = 6.dp, top = 12.dp, bottom = 12.dp, end = 12.dp) + ) { + reactions.forEach { + Emojifier(it) { annotatedText, inlineContent -> + Text( + text = annotatedText, + inlineContent = inlineContent, + style = MaterialTheme.typography.headlineLarge, + textAlign = TextAlign.Center, + modifier = Modifier + .width(44.dp) + .clickable(onClick = { + listener.onReactClick(it) + }) + ) + } + } + + Spacer(modifier = Modifier.width(6.dp)) + + IconButton( + onClick = listener::onReactWithAnyClick, + modifier = Modifier.size(32.dp) + ) { + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_any_emoji_32), + contentDescription = null + ) + } + } +} + +@Composable +private fun CallScreenMenu( + isSelfHandRaised: Boolean, + onRaiseHandClick: (Boolean) -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(SignalTheme.colors.colorSurface2, RoundedCornerShape(18.dp)) + ) { + CallScreenMenuOption( + imageVector = ImageVector.vectorResource(R.drawable.symbol_raise_hand_24), + title = if (isSelfHandRaised) stringResource(R.string.CallOverflowPopupWindow__lower_hand) else stringResource(R.string.CallOverflowPopupWindow__raise_hand), + onClick = { onRaiseHandClick(!isSelfHandRaised) } + ) + } +} + +@Composable +private fun CallScreenMenuOption( + imageVector: ImageVector, + title: String, + onClick: () -> Unit +) { + Row( + horizontalArrangement = spacedBy(16.dp), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Icon( + imageVector = imageVector, + contentDescription = title, + tint = MaterialTheme.colorScheme.onSurface + ) + + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + +@DarkPreview +@Composable +private fun CallScreenAdditionalActionsPopupPreview() { + Previews.Preview { + AdditionalActionsPopup( + onDismissRequest = {}, + state = AdditionalActionsState( + isShown = false, + reactions = persistentListOf( + "\u2764\ufe0f", + "\ud83d\udc4d", + "\ud83d\udc4e", + "\ud83d\ude02", + "\ud83d\ude2e", + "\ud83d\ude22" + ), + isSelfHandRaised = false, + listener = AdditionalActionsListener.Empty, + triggerAlignedPopupState = TriggerAlignedPopupState.rememberTriggerAlignedPopupState() + ) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt index 9745cd4688..c251646702 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt @@ -31,6 +31,8 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import org.signal.core.ui.DarkPreview import org.signal.core.ui.Previews +import org.signal.core.ui.TriggerAlignedPopupState.Companion.popupTrigger +import org.signal.core.ui.TriggerAlignedPopupState.Companion.rememberTriggerAlignedPopupState import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState import org.thoughtcrime.securesms.components.webrtc.ToggleButtonOutputState @@ -50,6 +52,7 @@ fun CallControls( callControlsState: CallControlsState, callScreenControlsListener: CallScreenControlsListener, callScreenSheetDisplayListener: CallScreenSheetDisplayListener, + additionalActionsState: AdditionalActionsState, modifier: Modifier = Modifier ) { val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT @@ -138,7 +141,10 @@ fun CallControls( } if (callControlsState.displayAdditionalActions) { - AdditionalActionsButton(onClick = callScreenControlsListener::onOverflowClicked) + AdditionalActionsButton( + onClick = callScreenControlsListener::onOverflowClicked, + modifier = Modifier.popupTrigger(additionalActionsState.triggerAlignedPopupState) + ) } if (callControlsState.displayEndCallButton) { @@ -187,7 +193,10 @@ fun CallControlsPreview() { ), displayVideoTooltip = false, callScreenControlsListener = CallScreenControlsListener.Empty, - callScreenSheetDisplayListener = CallScreenSheetDisplayListener.Empty + callScreenSheetDisplayListener = CallScreenSheetDisplayListener.Empty, + additionalActionsState = AdditionalActionsState( + triggerAlignedPopupState = rememberTriggerAlignedPopupState() + ) ) } } @@ -197,10 +206,12 @@ fun CallControlsPreview() { */ interface CallScreenSheetDisplayListener { fun onAudioDeviceSheetDisplayChanged(displayed: Boolean) + fun onOverflowDisplayChanged(displayed: Boolean) fun onVideoTooltipDismissed() object Empty : CallScreenSheetDisplayListener { override fun onAudioDeviceSheetDisplayChanged(displayed: Boolean) = Unit + override fun onOverflowDisplayChanged(displayed: Boolean) = Unit override fun onVideoTooltipDismissed() = Unit } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt index 61556716cc..40b5542227 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt @@ -56,6 +56,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.signal.core.ui.BottomSheets import org.signal.core.ui.Previews +import org.signal.core.ui.TriggerAlignedPopupState import org.signal.core.util.DimensionUnit import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState import org.thoughtcrime.securesms.events.CallParticipant @@ -86,6 +87,7 @@ fun CallScreen( ), callScreenControlsListener: CallScreenControlsListener = CallScreenControlsListener.Empty, callScreenSheetDisplayListener: CallScreenSheetDisplayListener = CallScreenSheetDisplayListener.Empty, + additionalActionsListener: AdditionalActionsListener = AdditionalActionsListener.Empty, callParticipantsPagerState: CallParticipantsPagerState, pendingParticipantsListener: PendingParticipantsListener = PendingParticipantsListener.Empty, overflowParticipants: List, @@ -117,6 +119,21 @@ fun CallScreen( val scope = rememberCoroutineScope() val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT + val additionalActionsPopupState = TriggerAlignedPopupState.rememberTriggerAlignedPopupState() + val additionalActionsState = remember( + callScreenState.reactions, + localParticipant.isHandRaised + ) { + AdditionalActionsState( + reactions = callScreenState.reactions, + isSelfHandRaised = localParticipant.isHandRaised, + listener = additionalActionsListener, + triggerAlignedPopupState = additionalActionsPopupState + ) + } + + additionalActionsPopupState.display = callScreenState.displayAdditionalActionsDialog + BoxWithConstraints { val maxHeight = constraints.maxHeight val maxSheetHeight = round(constraints.maxHeight * 0.66f) @@ -133,6 +150,11 @@ fun CallScreen( sheetContent = { BottomSheets.Handle(modifier = Modifier.align(Alignment.CenterHorizontally)) + AdditionalActionsPopup( + onDismissRequest = callScreenControlsListener::onDismissOverflow, + state = additionalActionsState + ) + Box( modifier = Modifier .fillMaxWidth() @@ -159,6 +181,7 @@ fun CallScreen( callScreenControlsListener = callScreenControlsListener, callScreenSheetDisplayListener = callScreenSheetDisplayListener, displayVideoTooltip = callScreenState.displayVideoTooltip, + additionalActionsState = additionalActionsState, modifier = Modifier .fillMaxWidth() .alpha(callControlsAlpha) @@ -304,7 +327,7 @@ private fun BoxScope.Viewport( val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT val scope = rememberCoroutineScope() - val hideSheet by rememberUpdatedState(newValue = scaffoldState.bottomSheetState.currentValue == SheetValue.PartiallyExpanded && !callControlsState.skipHiddenState && !callScreenState.isDisplayingAudioToggleSheet) + val hideSheet by rememberUpdatedState(newValue = scaffoldState.bottomSheetState.currentValue == SheetValue.PartiallyExpanded && !callControlsState.skipHiddenState && !callScreenState.isDisplayingControlMenu()) LaunchedEffect(callScreenController.restartTimerRequests, hideSheet) { if (hideSheet) { delay(5.seconds) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenControlsListener.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenControlsListener.kt index 2179799782..1dfae30395 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenControlsListener.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenControlsListener.kt @@ -23,6 +23,7 @@ interface CallScreenControlsListener { fun onVideoChanged(isVideoEnabled: Boolean) fun onMicChanged(isMicEnabled: Boolean) fun onOverflowClicked() + fun onDismissOverflow() fun onCameraDirectionChanged() fun onEndCallPressed() fun onDenyCallPressed() @@ -44,6 +45,7 @@ interface CallScreenControlsListener { override fun onVideoChanged(isVideoEnabled: Boolean) = Unit override fun onMicChanged(isMicEnabled: Boolean) = Unit override fun onOverflowClicked() = Unit + override fun onDismissOverflow() = Unit override fun onCameraDirectionChanged() = Unit override fun onEndCallPressed() = Unit override fun onDenyCallPressed() = Unit diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt index 2562519cad..88c73b8ae5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt @@ -5,6 +5,8 @@ package org.thoughtcrime.securesms.components.webrtc.v2 +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf import org.thoughtcrime.securesms.recipients.RecipientId /** @@ -25,9 +27,12 @@ data class CallScreenState( val displayVideoTooltip: Boolean = false, val displaySwipeToSpeakerHint: Boolean = false, val displayWifiToCellularPopup: Boolean = false, - val displayAdditionalActionsPopup: Boolean = false, + val displayAdditionalActionsDialog: Boolean = false, val displayMissingPermissionsNotice: Boolean = false, val pendingParticipantsState: PendingParticipantsState? = null, val isParticipantUpdatePopupEnabled: Boolean = false, - val isCallStateUpdatePopupEnabled: Boolean = false -) + val isCallStateUpdatePopupEnabled: Boolean = false, + val reactions: PersistentList = persistentListOf() +) { + fun isDisplayingControlMenu(): Boolean = isDisplayingAudioToggleSheet || displayAdditionalActionsDialog +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt index 344f93c079..078b7fbf80 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt @@ -15,6 +15,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope +import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay @@ -27,11 +28,15 @@ import org.signal.core.ui.theme.SignalTheme import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.components.webrtc.CallParticipantListUpdate import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState +import org.thoughtcrime.securesms.components.webrtc.CallReactionScrubber.Companion.CUSTOM_REACTION_BOTTOM_SHEET_TAG import org.thoughtcrime.securesms.components.webrtc.WebRtcControls import org.thoughtcrime.securesms.components.webrtc.controls.CallInfoView import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel import org.thoughtcrime.securesms.components.webrtc.controls.RaiseHandSnackbar +import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.events.WebRtcViewModel +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState @@ -40,7 +45,7 @@ import kotlin.time.Duration.Companion.seconds /** * Compose call screen wrapper */ -class ComposeCallScreenMediator(activity: WebRtcCallActivity, viewModel: WebRtcCallViewModel) : CallScreenMediator { +class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewModel: WebRtcCallViewModel) : CallScreenMediator, AdditionalActionsListener { companion object { private val TAG = Log.tag(ComposeCallScreenMediator::class) @@ -78,6 +83,10 @@ class ComposeCallScreenMediator(activity: WebRtcCallActivity, viewModel: WebRtcC callScreenViewModel.callScreenState.update { it.copy(isDisplayingAudioToggleSheet = displayed) } } + override fun onOverflowDisplayChanged(displayed: Boolean) { + callScreenViewModel.callScreenState.update { it.copy(displayAdditionalActionsDialog = displayed) } + } + override fun onVideoTooltipDismissed() { callScreenViewModel.callScreenState.update { it.copy(displayVideoTooltip = false) } } @@ -117,6 +126,7 @@ class ComposeCallScreenMediator(activity: WebRtcCallActivity, viewModel: WebRtcC callControlsState = callControlsState, callScreenController = callScreenController, callScreenControlsListener = callScreenControlsListener, + additionalActionsListener = this, callScreenSheetDisplayListener = callScreenSheetDisplayListener, callParticipantsPagerState = callParticipantsPagerState, pendingParticipantsListener = pendingParticipantsListener, @@ -163,7 +173,7 @@ class ComposeCallScreenMediator(activity: WebRtcCallActivity, viewModel: WebRtcC override fun toggleOverflowPopup() { callScreenViewModel.callScreenState.update { - it.copy(displayAdditionalActionsPopup = !it.displayAdditionalActionsPopup) + it.copy(displayAdditionalActionsDialog = !it.displayAdditionalActionsDialog) } } @@ -262,7 +272,7 @@ class ComposeCallScreenMediator(activity: WebRtcCallActivity, viewModel: WebRtcC } override fun dismissCallOverflowPopup() { - callScreenViewModel.callScreenState.update { it.copy(displayAdditionalActionsPopup = false) } + callScreenViewModel.callScreenState.update { it.copy(displayAdditionalActionsDialog = false) } } override fun onParticipantListUpdate(callParticipantListUpdate: CallParticipantListUpdate) { @@ -285,13 +295,35 @@ class ComposeCallScreenMediator(activity: WebRtcCallActivity, viewModel: WebRtcC callScreenViewModel.callScreenState.update { it.copy(displayMissingPermissionsNotice = false) } } + override fun onReactWithAnyClick() { + val bottomSheet = ReactWithAnyEmojiBottomSheetDialogFragment.createForCallingReactions() + bottomSheet.show(activity.supportFragmentManager, CUSTOM_REACTION_BOTTOM_SHEET_TAG) + callScreenViewModel.callScreenState.update { it.copy(displayAdditionalActionsDialog = false) } + } + + override fun onReactClick(reaction: String) { + AppDependencies.signalCallManager.react(reaction) + callScreenViewModel.callScreenState.update { it.copy(displayAdditionalActionsDialog = false) } + } + + override fun onRaiseHandClick(raised: Boolean) { + AppDependencies.signalCallManager.raiseHand(raised) + callScreenViewModel.callScreenState.update { it.copy(displayAdditionalActionsDialog = false) } + } + /** * State holder for compose call screen */ class CallScreenViewModel : ViewModel() { val callScreenControllerEvents = MutableSharedFlow() val callState = MutableStateFlow(WebRtcViewModel.State.IDLE) - val callScreenState = MutableStateFlow(CallScreenState()) + val callScreenState = MutableStateFlow( + CallScreenState( + reactions = SignalStore.emoji.reactions.map { + SignalStore.emoji.getPreferredVariation(it) + }.toPersistentList() + ) + ) val dialog = MutableStateFlow(CallScreenDialogType.NONE) val callParticipantsViewState = MutableStateFlow( CallParticipantsViewState( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt index 5a3229342c..d824e31a15 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt @@ -1139,6 +1139,10 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re callScreen.toggleOverflowPopup() } + override fun onDismissOverflow() { + callScreen.dismissCallOverflowPopup() + } + override fun onCameraDirectionChanged() { handleFlipCamera() } diff --git a/core-ui/src/main/java/org/signal/core/ui/TriggerAlignedPopup.kt b/core-ui/src/main/java/org/signal/core/ui/TriggerAlignedPopup.kt new file mode 100644 index 0000000000..29771e955d --- /dev/null +++ b/core-ui/src/main/java/org/signal/core/ui/TriggerAlignedPopup.kt @@ -0,0 +1,118 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui + +import android.graphics.drawable.ColorDrawable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.roundToIntRect +import androidx.compose.ui.window.DialogWindowProvider +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import kotlin.math.max +import kotlin.math.min + +/** + * Stores information related to the positional and display state of a + * [TriggerAlignedPopup]. + */ +@Stable +class TriggerAlignedPopupState private constructor( + initialDisplay: Boolean = false, + initialTriggerBounds: IntRect = IntRect.Zero +) { + + var display by mutableStateOf(initialDisplay) + + private var triggerBounds by mutableStateOf(initialTriggerBounds) + + val popupPositionProvider = derivedStateOf { + object : PopupPositionProvider { + override fun calculatePosition(anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize): IntOffset { + val desiredXOffset = triggerBounds.left + triggerBounds.width / 2 - popupContentSize.width / 2 + val maxXOffset = windowSize.width - popupContentSize.width + + return IntOffset(max(0, min(desiredXOffset, maxXOffset)), anchorBounds.top - popupContentSize.height) + } + } + } + + companion object { + + @Composable + fun rememberTriggerAlignedPopupState(): TriggerAlignedPopupState { + return rememberSaveable( + saver = Saver( + save = { + it.display to it.triggerBounds + }, + restore = { + TriggerAlignedPopupState( + it.first, + it.second + ) + } + ) + ) { + TriggerAlignedPopupState() + } + } + + /** + * Sets the given composable as the popup trigger. This does NOT + * display the popup. Rather, it just sets positional information + * in the state. It is still up to the caller to call `state.displayed = true` + * in order to display the popup itself. + */ + fun Modifier.popupTrigger(state: TriggerAlignedPopupState): Modifier { + return this.onPlaced { + state.triggerBounds = it.boundsInWindow().roundToIntRect() + } + } + } +} + +/** + * Focusable popup window that aligns itself with its trigger, if provided. + * + * See [TriggerAlignedPopupState.Companion.popupTrigger] for more information. + */ +@Composable +fun TriggerAlignedPopup( + state: TriggerAlignedPopupState, + onDismissRequest: () -> Unit = { state.display = false }, + content: @Composable () -> Unit +) { + if (state.display) { + val positionProvider by state.popupPositionProvider + Popup( + properties = PopupProperties(focusable = true), + onDismissRequest = onDismissRequest, + popupPositionProvider = positionProvider + ) { + (LocalView.current.parent as? DialogWindowProvider)?.apply { + this.window.setBackgroundDrawable(ColorDrawable(0)) + } + + content() + } + } +}