diff --git a/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopup.kt b/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopup.kt new file mode 100644 index 0000000000..d2fae90525 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopup.kt @@ -0,0 +1,123 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.compose.cfr + +import android.view.View +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import java.lang.ref.WeakReference + +/** + * Properties used to customize the behavior of a [CFRPopup]. + * + * @property popupWidth Width of the popup. Defaults to [CFRPopup.DEFAULT_WIDTH]. + * @property indicatorDirection The direction the indicator arrow is pointing. + * @property dismissOnBackPress Whether the popup can be dismissed by pressing the back button. + * If true, pressing the back button will also call onDismiss(). + * @property dismissOnClickOutside Whether the popup can be dismissed by clicking outside the + * popup's bounds. If true, clicking outside the popup will call onDismiss(). + * @property overlapAnchor How the popup's indicator will be shown in relation to the anchor: + * - true - indicator will be shown exactly in the middle horizontally and vertically + * - false - indicator will be shown horizontally in the middle of the anchor but immediately below or above it + * @property indicatorArrowStartOffset Maximum distance between the popup start and the indicator arrow. + * If there isn't enough space this could automatically be overridden up to 0 such that + * the indicator arrow will be pointing to the middle of the anchor. + */ +data class CFRPopupProperties( + val popupWidth: Dp = CFRPopup.DEFAULT_WIDTH.dp, + val indicatorDirection: CFRPopup.IndicatorDirection = CFRPopup.IndicatorDirection.UP, + val dismissOnBackPress: Boolean = true, + val dismissOnClickOutside: Boolean = true, + val overlapAnchor: Boolean = false, + val indicatorArrowStartOffset: Dp = CFRPopup.DEFAULT_INDICATOR_START_OFFSET.dp, +) + +/** + * CFR - Contextual Feature Recommendation popup. + * + * @param text [String] shown as the popup content. + * @param anchor [View] that will serve as the anchor of the popup and serve as lifecycle owner + * for this popup also. + * @param properties [CFRPopupProperties] allowing to customize the popup behavior. + * @param onDismiss Callback for when the popup is dismissed indicating also if the dismissal + * was explicit - by tapping the "X" button or not. + * @param action Optional other composable to show just below the popup text. + */ +class CFRPopup( + private val text: String, + private val anchor: View, + private val properties: CFRPopupProperties = CFRPopupProperties(), + private val onDismiss: (Boolean) -> Unit = {}, + private val action: @Composable (() -> Unit) = {} +) { + // This is just a facade for the CFRPopupFullScreenLayout composable offering a cleaner API. + + @VisibleForTesting + internal var popup: WeakReference? = null + + /** + * Construct and display a styled CFR popup shown at the coordinates of [anchor]. + * This popup will be dismissed when the user clicks on the "x" button or based on other user actions + * with such behavior set in [CFRPopupProperties]. + */ + fun show() { + anchor.post { + CFRPopupFullScreenLayout(text, anchor, properties, onDismiss, action).apply { + this.show() + popup = WeakReference(this) + } + } + } + + /** + * Immediately dismiss this CFR popup. + * The [onDismiss] callback won't be fired. + */ + fun dismiss() { + popup?.get()?.dismiss() + } + + /** + * Possible direction for the arrow indicator of a CFR popup. + * The direction is expressed in relation with the popup body containing the text. + */ + enum class IndicatorDirection { + UP, + DOWN + } + + companion object { + /** + * Default width for all CFRs. + */ + internal const val DEFAULT_WIDTH = 335 + + /** + * Fixed horizontal padding. + * Allows the close button to extend with 10dp more to the end and intercept touches to + * a bit outside of the popup to ensure it respects a11y recommendations of 48dp size while + * also offer a bit more space to the text. + */ + internal const val DEFAULT_HORIZONTAL_PADDING = 10 + + /** + * How tall the indicator arrow should be. + * This will also affect how wide the base of the indicator arrow will be. + */ + internal const val DEFAULT_INDICATOR_HEIGHT = 15 + + /** + * Maximum distance between the popup start and the indicator. + */ + internal const val DEFAULT_INDICATOR_START_OFFSET = 30 + + /** + * Corner radius for the popup body. + */ + internal const val DEFAULT_CORNER_RADIUS = 12 + } +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopupContent.kt b/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopupContent.kt new file mode 100644 index 0000000000..dfd7e28899 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopupContent.kt @@ -0,0 +1,171 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.compose.cfr + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons.Filled +import androidx.compose.material.icons.filled.Close +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.mozilla.fenix.R +import org.mozilla.fenix.theme.FirefoxTheme + +/** + * Complete content of the popup. + * [CFRPopupShape] with a gradient background containing [text] and a dismiss ("X") button. + * + * @param text String message in the popup. + * @param indicatorDirection The direction the indicator arrow is pointing to. + * @param indicatorArrowStartOffset Maximum distance between the popup start and the indicator arrow. + * If there isn't enough space this could automatically be overridden up to 0. + * @param onDismiss Callback for when the popup is dismissed indicating also if the dismissal + * was explicit - by tapping the "X" button or not. + * @param action Optional other composable to show just below the popup text. + */ +@Composable +@Suppress("LongParameterList", "LongMethod") +fun CFRPopupContent( + text: String, + indicatorDirection: CFRPopup.IndicatorDirection, + indicatorArrowStartOffset: Dp, + onDismiss: (Boolean) -> Unit, + popupWidth: Dp = CFRPopup.DEFAULT_WIDTH.dp, + action: @Composable (() -> Unit) = {} +) { + val popupShape = CFRPopupShape( + indicatorDirection, + indicatorArrowStartOffset, + CFRPopup.DEFAULT_INDICATOR_HEIGHT.dp, + CFRPopup.DEFAULT_CORNER_RADIUS.dp, + ) + + Box(modifier = Modifier.width(popupWidth + CFRPopup.DEFAULT_HORIZONTAL_PADDING.dp * 2)) { + Surface( + color = Color.Transparent, + // Need to override the default RectangleShape to avoid casting shadows for that shape. + shape = popupShape, + modifier = Modifier + .align(Alignment.Center) + .background( + shape = popupShape, + brush = Brush.linearGradient( + colors = listOf( + FirefoxTheme.colors.gradientEnd, + FirefoxTheme.colors.gradientStart + ), + end = Offset(0f, Float.POSITIVE_INFINITY), + start = Offset(Float.POSITIVE_INFINITY, 0f) + ) + ) + .wrapContentHeight() + .width(popupWidth) + ) { + + Column( + modifier = Modifier + .padding( + start = 16.dp, + top = 16.dp + if (indicatorDirection == CFRPopup.IndicatorDirection.UP) { + CFRPopup.DEFAULT_INDICATOR_HEIGHT.dp + } else { + 0.dp + }, + end = 16.dp, + bottom = 16.dp + + if (indicatorDirection == CFRPopup.IndicatorDirection.DOWN) { + CFRPopup.DEFAULT_INDICATOR_HEIGHT.dp + } else { + 0.dp + } + ) + ) { + Text( + text = text, + modifier = Modifier.padding( + end = 24.dp, // 8.dp extra padding to the "X" icon + ), + color = FirefoxTheme.colors.textOnColorPrimary, + style = FirefoxTheme.typography.body2 + ) + + action() + } + } + + IconButton( + onClick = { onDismiss(true) }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding( + top = if (indicatorDirection == CFRPopup.IndicatorDirection.UP) 14.dp else 0.dp, + end = 6.dp + ) + .size(48.dp) + ) { + Icon( + imageVector = Filled.Close, + contentDescription = stringResource(R.string.cfr_dismiss_button_default_content_description), + modifier = Modifier + // Following alignment and padding are necessary to visually align the middle + // of the "X" button with the top of the text. + .align(Alignment.TopCenter) + .padding(top = 10.dp) + .size(24.dp), + tint = FirefoxTheme.colors.iconOnColor + ) + } + } +} + +@Composable +@Preview(locale = "en", name = "LTR") +@Preview(locale = "ar", name = "RTL") +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark theme") +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Light theme") +private fun CFRPopupAbovePreview() { + FirefoxTheme { + CFRPopupContent( + text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt", + indicatorDirection = CFRPopup.IndicatorDirection.DOWN, + indicatorArrowStartOffset = CFRPopup.DEFAULT_INDICATOR_START_OFFSET.dp, + onDismiss = { } + ) + } +} + +@Composable +@Preview(locale = "en", name = "LTR") +@Preview(locale = "ar", name = "RTL") +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark theme") +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Light theme") +private fun CFRPopupBelowPreview() { + FirefoxTheme { + CFRPopupContent( + text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt", + indicatorDirection = CFRPopup.IndicatorDirection.UP, + indicatorArrowStartOffset = CFRPopup.DEFAULT_INDICATOR_START_OFFSET.dp, + onDismiss = { } + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopupFullscreenLayout.kt b/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopupFullscreenLayout.kt new file mode 100644 index 0000000000..3cfd163ceb --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopupFullscreenLayout.kt @@ -0,0 +1,361 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.compose.cfr + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.PixelFormat +import android.view.View +import android.view.WindowManager +import androidx.annotation.Px +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.AbstractComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.ViewRootForInspector +import androidx.compose.ui.unit.Dp +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.LayoutDirection.Ltr +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.ViewTreeLifecycleOwner +import androidx.savedstate.ViewTreeSavedStateRegistryOwner +import mozilla.components.support.ktx.android.util.dpToPx +import org.mozilla.fenix.compose.cfr.CFRPopup.IndicatorDirection.DOWN +import org.mozilla.fenix.compose.cfr.CFRPopup.IndicatorDirection.UP +import org.mozilla.fenix.theme.FirefoxTheme +import org.mozilla.gecko.GeckoScreenOrientation +import kotlin.math.absoluteValue +import kotlin.math.roundToInt + +/** + * Value class allowing to easily reason about what an `Int` represents. + * This is compiled to the underlying `Int` type so incurs no performance penalty. + */ +@JvmInline +private value class Pixels(val value: Int) + +/** + * Simple wrapper over the absolute x-coordinates of the popup. Includes any paddings. + */ +private data class PopupHorizontalBounds( + val startCoord: Pixels, + val endCoord: Pixels +) + +/** + * [AbstractComposeView] that can be added or removed dynamically in the current window to display + * a [Composable] based popup anywhere on the screen. + * + * @param text [String] shown as the popup content. + * @param anchor [View] that will serve as the anchor of the popup and serve as lifecycle owner + * for this popup also. + * @param properties [CFRPopupProperties] allowing to customize the popup behavior. + * @param onDismiss Callback for when the popup is dismissed indicating also if the dismissal + * was explicit - by tapping the "X" button or not. + * @param action Optional other composable to show just below the popup text. + */ +@SuppressLint("ViewConstructor") // Intended to be used only in code, don't need a View constructor +internal class CFRPopupFullScreenLayout( + private val text: String, + private val anchor: View, + private val properties: CFRPopupProperties, + private val onDismiss: (Boolean) -> Unit, + private val action: @Composable (() -> Unit) = {} +) : AbstractComposeView(anchor.context), ViewRootForInspector { + private val windowManager = anchor.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + + /** + * Listener for when the anchor is removed from the screen. + * Useful in the following situations: + * - lack of purpose - if there is no anchor the context/action to which this popup refers to disappeared + * - leak from WindowManager - if removing the app from task manager while the popup is shown. + * + * Will not inform client about this since the user did not expressly dismissed this popup. + */ + private val anchorDetachedListener = OnViewDetachedListener { + dismiss() + } + + /** + * When the screen is rotated the popup may get improperly anchored + * because of the async nature of insets and screen rotation. + * To avoid any improper anchorage the popups are automatically dismissed. + * + * Will not inform client about this since the user did not expressly dismissed this popup. + */ + private val orientationChangeListener = GeckoScreenOrientation.OrientationChangeListener { + dismiss() + } + + override var shouldCreateCompositionOnAttachedToWindow: Boolean = false + private set + + init { + ViewTreeLifecycleOwner.set(this, ViewTreeLifecycleOwner.get(anchor)) + ViewTreeSavedStateRegistryOwner.set(this, ViewTreeSavedStateRegistryOwner.get(anchor)) + GeckoScreenOrientation.getInstance().addListener(orientationChangeListener) + anchor.addOnAttachStateChangeListener(anchorDetachedListener) + } + + /** + * Add a new CFR popup to the current window overlaying everything already displayed. + * This popup will be dismissed when the user clicks on the "x" button or based on other user actions + * with such behavior set in [CFRPopupProperties]. + */ + fun show() { + windowManager.addView(this, createLayoutParams()) + } + + @Composable + override fun Content() { + val anchorLocation = IntArray(2).apply { + anchor.getLocationOnScreen(this) + } + + val anchorXCoordMiddle = Pixels(anchorLocation.first() + anchor.width / 2) + val indicatorArrowHeight = Pixels( + CFRPopup.DEFAULT_INDICATOR_HEIGHT.dp.toPx() + ) + + val popupBounds = computePopupHorizontalBounds( + anchorMiddleXCoord = anchorXCoordMiddle, + arrowIndicatorWidth = Pixels(CFRPopupShape.getIndicatorBaseWidthForHeight(indicatorArrowHeight.value)), + ) + val indicatorOffset = computeIndicatorArrowStartCoord( + anchorMiddleXCoord = anchorXCoordMiddle, + popupStartCoord = popupBounds.startCoord, + arrowIndicatorWidth = Pixels( + CFRPopupShape.getIndicatorBaseWidthForHeight(indicatorArrowHeight.value) + ) + ) + + FirefoxTheme { + Popup( + popupPositionProvider = getPopupPositionProvider( + anchorLocation = anchorLocation, + popupBounds = popupBounds, + ), + properties = PopupProperties( + focusable = properties.dismissOnBackPress, + dismissOnBackPress = properties.dismissOnBackPress, + dismissOnClickOutside = properties.dismissOnClickOutside, + ), + onDismissRequest = { + // For when tapping outside the popup. + dismiss() + onDismiss(false) + } + ) { + CFRPopupContent( + text = text, + indicatorDirection = properties.indicatorDirection, + indicatorArrowStartOffset = with(LocalDensity.current) { + indicatorOffset.value.toDp() + }, + onDismiss = { + // For when tapping the "X" button. + dismiss() + onDismiss(true) + }, + action = action, + ) + } + } + } + + @Composable + private fun getPopupPositionProvider( + anchorLocation: IntArray, + popupBounds: PopupHorizontalBounds, + ): PopupPositionProvider { + return object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset { + // Popup will be anchored such that the indicator arrow will point to the middle of the anchor View + // but the popup is allowed some space as start padding in which it can be displayed such that the + // indicator arrow is exactly at the top-start/bottom-start corner but slightly translated to end. + // Values are in pixels. + return IntOffset( + when (layoutDirection) { + Ltr -> popupBounds.startCoord.value + else -> popupBounds.endCoord.value + }, + when (properties.indicatorDirection) { + UP -> { + when (properties.overlapAnchor) { + true -> anchorLocation.last() + anchor.height / 2 + else -> anchorLocation.last() + anchor.height + } + } + DOWN -> { + when (properties.overlapAnchor) { + true -> anchorLocation.last() - popupContentSize.height + anchor.height / 2 + else -> anchorLocation.last() - popupContentSize.height + } + } + } + ) + } + } + } + + /** + * Compute the x-coordinates for the absolute start and end position of the popup, including any padding. + * This assumes anchoring is indicated with an arrow to the horizontal middle of the anchor with the popup's + * body potentially extending to the `start` of the arrow indicator. + * + * @param anchorMiddleXCoord x-coordinate for the middle of the anchor. + * @param arrowIndicatorWidth x-distance the arrow indicator occupies. + */ + @Composable + private fun computePopupHorizontalBounds( + anchorMiddleXCoord: Pixels, + arrowIndicatorWidth: Pixels + ): PopupHorizontalBounds { + val arrowIndicatorHalfWidth = arrowIndicatorWidth.value / 2 + + return if (LocalConfiguration.current.layoutDirection == View.LAYOUT_DIRECTION_LTR) { + // Push the popup as far to the start as needed including any needed paddings. + val startCoord = Pixels( + (anchorMiddleXCoord.value - arrowIndicatorHalfWidth) + .minus(properties.indicatorArrowStartOffset.toPx()) + .minus(CFRPopup.DEFAULT_HORIZONTAL_PADDING.dp.toPx()) + .coerceAtLeast(getLeftInsets()) + ) + + PopupHorizontalBounds( + startCoord = startCoord, + endCoord = Pixels( + startCoord.value + .plus(properties.popupWidth.toPx()) + .plus(CFRPopup.DEFAULT_HORIZONTAL_PADDING.dp.toPx() * 2) + ) + ) + } else { + val startCoord = Pixels( + // Push the popup as far to the start (in RTL) as possible. + anchorMiddleXCoord.value + .plus(arrowIndicatorHalfWidth) + .plus(properties.indicatorArrowStartOffset.toPx()) + .plus(CFRPopup.DEFAULT_HORIZONTAL_PADDING.dp.toPx()) + .coerceAtMost( + LocalDensity.current.run { + LocalConfiguration.current.screenWidthDp.dp.toPx() + } + .roundToInt() + .plus(getLeftInsets()) + ) + ) + PopupHorizontalBounds( + startCoord = startCoord, + endCoord = Pixels( + startCoord.value + .minus(properties.popupWidth.toPx()) + .minus(CFRPopup.DEFAULT_HORIZONTAL_PADDING.dp.toPx() * 2) + ) + ) + } + } + + /** + * Compute the x-coordinate for where the popup's indicator arrow should start + * relative to the available distance between it and the popup's starting x-coordinate. + * + * @param anchorMiddleXCoord x-coordinate for the middle of the anchor. + * @param popupStartCoord x-coordinate for the popup start + * @param arrowIndicatorWidth Width of the arrow indicator. + */ + @Composable + private fun computeIndicatorArrowStartCoord( + anchorMiddleXCoord: Pixels, + popupStartCoord: Pixels, + arrowIndicatorWidth: Pixels + ): Pixels { + val arrowIndicatorHalfWidth = arrowIndicatorWidth.value / 2 + + return if (LocalConfiguration.current.layoutDirection == View.LAYOUT_DIRECTION_LTR) { + val visiblePopupStartCoord = popupStartCoord.value + CFRPopup.DEFAULT_HORIZONTAL_PADDING.dp.toPx() + val arrowIndicatorStartCoord = anchorMiddleXCoord.value - arrowIndicatorHalfWidth + + Pixels((visiblePopupStartCoord - arrowIndicatorStartCoord).absoluteValue) + } else { + val indicatorStartCoord = popupStartCoord.value - CFRPopup.DEFAULT_HORIZONTAL_PADDING.dp.toPx() - + anchorMiddleXCoord.value - arrowIndicatorHalfWidth + + Pixels(indicatorStartCoord.absoluteValue) + } + } + + /** + * Cleanup and remove the current popup from the screen. + * Clients are not automatically informed about this. Use a separate call to [onDismiss] if needed. + */ + internal fun dismiss() { + anchor.removeOnAttachStateChangeListener(anchorDetachedListener) + GeckoScreenOrientation.getInstance().removeListener(orientationChangeListener) + disposeComposition() + ViewTreeLifecycleOwner.set(this, null) + ViewTreeSavedStateRegistryOwner.set(this, null) + windowManager.removeViewImmediate(this) + } + + /** + * Create fullscreen translucent layout params. + * This will allow placing the visible popup anywhere on the screen. + */ + @VisibleForTesting + internal fun createLayoutParams(): WindowManager.LayoutParams = + WindowManager.LayoutParams().apply { + type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL + token = anchor.applicationWindowToken + width = WindowManager.LayoutParams.MATCH_PARENT + height = WindowManager.LayoutParams.MATCH_PARENT + format = PixelFormat.TRANSLUCENT + flags = WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or + WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED + } + + /** + * Intended to allow querying the insets of the navigation bar. + * Value will be `0` except for when the screen is rotated by 90 degrees. + */ + private fun getLeftInsets() = ViewCompat.getRootWindowInsets(anchor) + ?.getInsets(WindowInsetsCompat.Type.systemBars())?.left + ?: 0.coerceAtLeast(0) + + @Px + internal fun Dp.toPx(): Int { + return this.value + .dpToPx(anchor.resources.displayMetrics) + .roundToInt() + } +} + +/** + * Simpler [View.OnAttachStateChangeListener] only informing about + * [View.OnAttachStateChangeListener.onViewDetachedFromWindow]. + */ +private class OnViewDetachedListener(val onDismiss: () -> Unit) : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View?) { + // no-op + } + + override fun onViewDetachedFromWindow(v: View?) { + onDismiss() + } +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopupShape.kt b/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopupShape.kt new file mode 100644 index 0000000000..0924f0b136 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/cfr/CFRPopupShape.kt @@ -0,0 +1,252 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.compose.cfr + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import org.mozilla.fenix.theme.FirefoxTheme +import kotlin.math.roundToInt + +private const val INDICATOR_BASE_TO_HEIGHT_RATIO = 1f + +/** + * A [Shape] describing a popup with an indicator triangle shown above or below the popup. + * + * @param indicatorDirection The direction the indicator arrow is pointing to. + * @param indicatorArrowStartOffset Distance between the popup start and the indicator arrow start + * @param indicatorArrowHeight Height of the indicator triangle. This influences the base length. + * @param cornerRadius The radius of the popup's corners. + * If [indicatorArrowStartOffset] is `0` then the top-start corner will not be rounded. + */ +class CFRPopupShape( + private val indicatorDirection: CFRPopup.IndicatorDirection, + private val indicatorArrowStartOffset: Dp, + private val indicatorArrowHeight: Dp, + private val cornerRadius: Dp +) : Shape { + @Suppress("LongMethod") + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val indicatorArrowStartOffsetPx = indicatorArrowStartOffset.value * density.density + val indicatorArrowHeightPx = indicatorArrowHeight.value * density.density + val indicatorArrowBasePx = + getIndicatorBaseWidthForHeight((indicatorArrowHeight.value * density.density).roundToInt()) + val cornerRadiusPx = cornerRadius.value * density.density + val indicatorCornerRadiusPx = cornerRadiusPx.coerceAtMost(indicatorArrowStartOffsetPx) + + // All outlines are drawn in a LTR space but with accounting for the LTR direction. + return when (indicatorDirection) { + CFRPopup.IndicatorDirection.UP -> { + Outline.Generic( + path = Path().apply { + reset() + + lineTo(0f, size.height - cornerRadiusPx) + quadraticBezierTo( + 0f, size.height, + cornerRadiusPx, size.height + ) + + lineTo(size.width - cornerRadiusPx, size.height) + quadraticBezierTo( + size.width, size.height, + size.width, size.height - cornerRadiusPx + ) + + if (layoutDirection == LayoutDirection.Ltr) { + lineTo(size.width, cornerRadiusPx + indicatorArrowHeightPx) + quadraticBezierTo( + size.width, indicatorArrowHeightPx, + size.width - cornerRadiusPx, indicatorArrowHeightPx + ) + + lineTo(indicatorArrowStartOffsetPx + indicatorArrowBasePx, indicatorArrowHeightPx) + lineTo(indicatorArrowStartOffsetPx + indicatorArrowBasePx / 2, 0f) + lineTo(indicatorArrowStartOffsetPx, indicatorArrowHeightPx) + + lineTo(indicatorCornerRadiusPx, indicatorArrowHeightPx) + quadraticBezierTo( + 0f, indicatorArrowHeightPx, + 0f, indicatorArrowHeightPx + indicatorCornerRadiusPx + ) + } else { + lineTo(size.width, indicatorCornerRadiusPx + indicatorArrowHeightPx) + quadraticBezierTo( + size.width, indicatorArrowHeightPx, + size.width - indicatorCornerRadiusPx, indicatorArrowHeightPx + ) + + val indicatorEnd = size.width - indicatorArrowStartOffsetPx + lineTo(indicatorEnd, indicatorArrowHeightPx) + lineTo(indicatorEnd - indicatorArrowBasePx / 2, 0f) + lineTo(indicatorEnd - indicatorArrowBasePx, indicatorArrowHeightPx) + + lineTo(cornerRadiusPx, indicatorArrowHeightPx) + quadraticBezierTo( + 0f, indicatorArrowHeightPx, + 0f, indicatorArrowHeightPx + cornerRadiusPx + ) + } + + close() + } + ) + } + CFRPopup.IndicatorDirection.DOWN -> { + val messageBodyHeightPx = size.height - indicatorArrowHeightPx + + Outline.Generic( + path = Path().apply { + reset() + + if (layoutDirection == LayoutDirection.Ltr) { + lineTo(0f, messageBodyHeightPx - indicatorCornerRadiusPx) + quadraticBezierTo( + 0f, size.height - indicatorArrowHeightPx, + indicatorCornerRadiusPx, size.height - indicatorArrowHeightPx + ) + + lineTo(indicatorArrowStartOffsetPx, messageBodyHeightPx) + lineTo(indicatorArrowStartOffsetPx + indicatorArrowBasePx / 2, size.height) + lineTo(indicatorArrowStartOffsetPx + indicatorArrowBasePx, messageBodyHeightPx) + + lineTo(size.width - cornerRadiusPx, messageBodyHeightPx) + quadraticBezierTo( + size.width, messageBodyHeightPx, + size.width, messageBodyHeightPx - cornerRadiusPx + ) + } else { + lineTo(0f, messageBodyHeightPx - cornerRadiusPx) + quadraticBezierTo( + 0f, messageBodyHeightPx, + cornerRadiusPx, messageBodyHeightPx + ) + + val indicatorStartPx = size.width - indicatorArrowStartOffsetPx - indicatorArrowBasePx + lineTo(indicatorStartPx, messageBodyHeightPx) + lineTo(indicatorStartPx + indicatorArrowBasePx / 2, size.height) + lineTo(indicatorStartPx + indicatorArrowBasePx, messageBodyHeightPx) + + lineTo(size.width - indicatorCornerRadiusPx, messageBodyHeightPx) + quadraticBezierTo( + size.width, messageBodyHeightPx, + size.width, messageBodyHeightPx - indicatorCornerRadiusPx + ) + } + + lineTo(size.width, cornerRadiusPx) + quadraticBezierTo( + size.width, 0f, + size.width - cornerRadiusPx, 0f + ) + + lineTo(cornerRadiusPx, 0f) + quadraticBezierTo( + 0f, 0f, + 0f, cornerRadiusPx + ) + + close() + } + ) + } + } + } + + companion object { + /** + * This [Shape]'s arrow indicator will have an automatic width depending on the set height. + * This method allows knowing what the base width will be before instantiating the class. + */ + fun getIndicatorBaseWidthForHeight(height: Int): Int { + return (height * INDICATOR_BASE_TO_HEIGHT_RATIO).roundToInt() + } + } +} + +@Composable +@Preview(locale = "en", name = "LTR") +@Preview(locale = "ar", name = "RTL") +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark theme") +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Light theme") +private fun CFRPopupBelowShapePreview() { + FirefoxTheme { + Box( + modifier = Modifier + .height(100.dp) + .width(200.dp) + .background( + shape = CFRPopupShape(CFRPopup.IndicatorDirection.UP, 10.dp, 10.dp, 10.dp), + brush = Brush.linearGradient( + colors = listOf( + FirefoxTheme.colors.gradientStart, + FirefoxTheme.colors.gradientEnd + ), + end = Offset(0f, Float.POSITIVE_INFINITY), + start = Offset(Float.POSITIVE_INFINITY, 0f) + ) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = "This is just a test", + color = FirefoxTheme.colors.textOnColorPrimary + ) + } + } +} + +@Composable +@Preview(locale = "en", name = "LTR") +@Preview(locale = "ar", name = "RTL") +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark theme") +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Light theme") +private fun CFRPopupAboveShapePreview() { + FirefoxTheme { + Box( + modifier = Modifier + .height(100.dp) + .width(200.dp) + .background( + shape = CFRPopupShape(CFRPopup.IndicatorDirection.DOWN, 10.dp, 10.dp, 10.dp), + brush = Brush.linearGradient( + colors = listOf( + FirefoxTheme.colors.gradientStart, + FirefoxTheme.colors.gradientEnd + ), + end = Offset(0f, Float.POSITIVE_INFINITY), + start = Offset(Float.POSITIVE_INFINITY, 0f) + ) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = "This is just a test", + color = FirefoxTheme.colors.textOnColorPrimary + ) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0cc85f4fb0..327e0cc6f5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -73,6 +73,9 @@ Dismiss + + Dismiss + Camera access needed. Go to Android settings, tap permissions, and tap allow. diff --git a/app/src/test/java/org/mozilla/fenix/compose/cfr/CFRPopupFullScreenLayoutTest.kt b/app/src/test/java/org/mozilla/fenix/compose/cfr/CFRPopupFullScreenLayoutTest.kt new file mode 100644 index 0000000000..4c3cec58be --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/compose/cfr/CFRPopupFullScreenLayoutTest.kt @@ -0,0 +1,114 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.compose.cfr + +import android.content.Context +import android.graphics.PixelFormat +import android.view.View +import android.view.ViewManager +import android.view.WindowManager +import android.view.WindowManager.LayoutParams +import androidx.lifecycle.ViewTreeLifecycleOwner +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.savedstate.ViewTreeSavedStateRegistryOwner +import androidx.savedstate.findViewTreeSavedStateRegistryOwner +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.spyk +import io.mockk.verify +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.fenix.helpers.FenixRobolectricTestRunner + +@RunWith(FenixRobolectricTestRunner::class) +class CFRPopupFullScreenLayoutTest { + @Test + fun `WHEN the popup is constructed THEN setup lifecycle owners`() { + val anchor = View(testContext).apply { + ViewTreeLifecycleOwner.set(this, mockk()) + ViewTreeSavedStateRegistryOwner.set(this, mockk()) + } + + val popupView = spyk(CFRPopupFullScreenLayout("", anchor, mockk(), mockk()) {}) + + assertNotNull(popupView.findViewTreeLifecycleOwner()) + assertEquals( + anchor.findViewTreeLifecycleOwner(), + popupView.findViewTreeLifecycleOwner() + ) + assertNotNull(popupView.findViewTreeSavedStateRegistryOwner()) + assertEquals( + assertNotNull(anchor.findViewTreeSavedStateRegistryOwner()), + assertNotNull(popupView.findViewTreeSavedStateRegistryOwner()) + ) + } + + @Test + fun `WHEN the popup is dismissed THEN cleanup lifecycle owners and detach from window`() { + val context = spyk(testContext) + val anchor = View(context).apply { + ViewTreeLifecycleOwner.set(this, mockk()) + ViewTreeSavedStateRegistryOwner.set(this, mockk()) + } + val windowManager = spyk(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager) + every { context.getSystemService(Context.WINDOW_SERVICE) } returns windowManager + val popupView = CFRPopupFullScreenLayout("", anchor, mockk(), mockk()) {} + popupView.show() + assertNotNull(popupView.findViewTreeLifecycleOwner()) + assertNotNull(popupView.findViewTreeSavedStateRegistryOwner()) + + popupView.dismiss() + + assertNull(popupView.findViewTreeLifecycleOwner()) + assertNull(popupView.findViewTreeSavedStateRegistryOwner()) + verify { windowManager.removeViewImmediate(popupView) } + } + + @Test + fun `GIVEN a popup WHEN adding it to window THEN use translucent layout params`() { + val context = spyk(testContext) + val anchor = View(context) + val windowManager = spyk(context.getSystemService(Context.WINDOW_SERVICE)) + every { context.getSystemService(Context.WINDOW_SERVICE) } returns windowManager + val popupView = CFRPopupFullScreenLayout("", anchor, mockk(), mockk()) {} + val layoutParamsCaptor = slot() + + popupView.show() + + verify { (windowManager as ViewManager).addView(eq(popupView), capture(layoutParamsCaptor)) } + assertEquals(LayoutParams.TYPE_APPLICATION_PANEL, layoutParamsCaptor.captured.type) + assertEquals(anchor.applicationWindowToken, layoutParamsCaptor.captured.token) + assertEquals(LayoutParams.MATCH_PARENT, layoutParamsCaptor.captured.width) + assertEquals(LayoutParams.MATCH_PARENT, layoutParamsCaptor.captured.height) + assertEquals(PixelFormat.TRANSLUCENT, layoutParamsCaptor.captured.format) + assertEquals( + LayoutParams.FLAG_LAYOUT_IN_SCREEN or LayoutParams.FLAG_HARDWARE_ACCELERATED, + layoutParamsCaptor.captured.flags + ) + } + + @Test + fun `WHEN creating layout params THEN get fullscreen translucent layout params`() { + val anchor = View(testContext) + val popupView = CFRPopupFullScreenLayout("", anchor, mockk(), mockk()) {} + + val result = popupView.createLayoutParams() + + assertEquals(LayoutParams.TYPE_APPLICATION_PANEL, result.type) + assertEquals(anchor.applicationWindowToken, result.token) + assertEquals(LayoutParams.MATCH_PARENT, result.width) + assertEquals(LayoutParams.MATCH_PARENT, result.height) + assertEquals(PixelFormat.TRANSLUCENT, result.format) + assertEquals( + LayoutParams.FLAG_LAYOUT_IN_SCREEN or LayoutParams.FLAG_HARDWARE_ACCELERATED, + result.flags + ) + } +}