summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/SystemUI/compose/core/src/com/android/compose/swipeable/Swipeable.kt849
-rw-r--r--packages/SystemUI/compose/core/src/com/android/compose/ui/util/MathHelpers.kt38
-rw-r--r--packages/SystemUI/compose/facade/disabled/src/com/android/systemui/compose/ComposeFacade.kt10
-rw-r--r--packages/SystemUI/compose/facade/enabled/src/com/android/systemui/compose/ComposeFacade.kt20
-rw-r--r--packages/SystemUI/compose/features/src/com/android/systemui/multishade/ui/composable/MultiShade.kt144
-rw-r--r--packages/SystemUI/compose/features/src/com/android/systemui/multishade/ui/composable/Shade.kt317
-rw-r--r--packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt43
-rw-r--r--packages/SystemUI/compose/features/src/com/android/systemui/qs/footer/ui/compose/QuickSettings.kt43
-rw-r--r--packages/SystemUI/compose/features/src/com/android/systemui/statusbar/ui/composable/StatusBar.kt43
-rw-r--r--packages/SystemUI/res/values/config.xml40
-rw-r--r--packages/SystemUI/src/com/android/systemui/compose/BaseComposeFacade.kt9
-rw-r--r--packages/SystemUI/src/com/android/systemui/multishade/data/model/MultiShadeInteractionModel.kt28
-rw-r--r--packages/SystemUI/src/com/android/systemui/multishade/data/remoteproxy/MultiShadeInputProxy.kt47
-rw-r--r--packages/SystemUI/src/com/android/systemui/multishade/data/repository/MultiShadeRepository.kt157
-rw-r--r--packages/SystemUI/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractor.kt322
-rw-r--r--packages/SystemUI/src/com/android/systemui/multishade/shared/model/ProxiedInputModel.kt50
-rw-r--r--packages/SystemUI/src/com/android/systemui/multishade/shared/model/ShadeConfig.kt79
-rw-r--r--packages/SystemUI/src/com/android/systemui/multishade/shared/model/ShadeId.kt28
-rw-r--r--packages/SystemUI/src/com/android/systemui/multishade/shared/model/ShadeModel.kt26
-rw-r--r--packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModel.kt112
-rw-r--r--packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/ShadeViewModel.kt150
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/multishade/data/repository/MultiShadeRepositoryTest.kt191
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractorTest.kt301
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModelTest.kt127
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/multishade/ui/viewmodel/ShadeViewModelTest.kt226
25 files changed, 3400 insertions, 0 deletions
diff --git a/packages/SystemUI/compose/core/src/com/android/compose/swipeable/Swipeable.kt b/packages/SystemUI/compose/core/src/com/android/compose/swipeable/Swipeable.kt
new file mode 100644
index 000000000000..946e77959b1c
--- /dev/null
+++ b/packages/SystemUI/compose/core/src/com/android/compose/swipeable/Swipeable.kt
@@ -0,0 +1,849 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.compose.swipeable
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.animation.core.SpringSpec
+import androidx.compose.foundation.gestures.DraggableState
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.draggable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.debugInspectorInfo
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.Velocity
+import androidx.compose.ui.unit.dp
+import com.android.compose.swipeable.SwipeableDefaults.AnimationSpec
+import com.android.compose.swipeable.SwipeableDefaults.StandardResistanceFactor
+import com.android.compose.swipeable.SwipeableDefaults.VelocityThreshold
+import com.android.compose.swipeable.SwipeableDefaults.resistanceConfig
+import com.android.compose.ui.util.lerp
+import kotlin.math.PI
+import kotlin.math.abs
+import kotlin.math.sign
+import kotlin.math.sin
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.launch
+
+/**
+ * State of the [swipeable] modifier.
+ *
+ * This contains necessary information about any ongoing swipe or animation and provides methods to
+ * change the state either immediately or by starting an animation. To create and remember a
+ * [SwipeableState] with the default animation clock, use [rememberSwipeableState].
+ *
+ * @param initialValue The initial value of the state.
+ * @param animationSpec The default animation that will be used to animate to a new state.
+ * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
+ *
+ * TODO(b/272311106): this is a fork from material. Unfork it when Swipeable.kt reaches material3.
+ */
+@Stable
+open class SwipeableState<T>(
+ initialValue: T,
+ internal val animationSpec: AnimationSpec<Float> = AnimationSpec,
+ internal val confirmStateChange: (newValue: T) -> Boolean = { true }
+) {
+ /**
+ * The current value of the state.
+ *
+ * If no swipe or animation is in progress, this corresponds to the anchor at which the
+ * [swipeable] is currently settled. If a swipe or animation is in progress, this corresponds
+ * the last anchor at which the [swipeable] was settled before the swipe or animation started.
+ */
+ var currentValue: T by mutableStateOf(initialValue)
+ private set
+
+ /** Whether the state is currently animating. */
+ var isAnimationRunning: Boolean by mutableStateOf(false)
+ private set
+
+ /**
+ * The current position (in pixels) of the [swipeable].
+ *
+ * You should use this state to offset your content accordingly. The recommended way is to use
+ * `Modifier.offsetPx`. This includes the resistance by default, if resistance is enabled.
+ */
+ val offset: State<Float>
+ get() = offsetState
+
+ /** The amount by which the [swipeable] has been swiped past its bounds. */
+ val overflow: State<Float>
+ get() = overflowState
+
+ // Use `Float.NaN` as a placeholder while the state is uninitialised.
+ private val offsetState = mutableStateOf(0f)
+ private val overflowState = mutableStateOf(0f)
+
+ // the source of truth for the "real"(non ui) position
+ // basically position in bounds + overflow
+ private val absoluteOffset = mutableStateOf(0f)
+
+ // current animation target, if animating, otherwise null
+ private val animationTarget = mutableStateOf<Float?>(null)
+
+ internal var anchors by mutableStateOf(emptyMap<Float, T>())
+
+ private val latestNonEmptyAnchorsFlow: Flow<Map<Float, T>> =
+ snapshotFlow { anchors }.filter { it.isNotEmpty() }.take(1)
+
+ internal var minBound = Float.NEGATIVE_INFINITY
+ internal var maxBound = Float.POSITIVE_INFINITY
+
+ internal fun ensureInit(newAnchors: Map<Float, T>) {
+ if (anchors.isEmpty()) {
+ // need to do initial synchronization synchronously :(
+ val initialOffset = newAnchors.getOffset(currentValue)
+ requireNotNull(initialOffset) { "The initial value must have an associated anchor." }
+ offsetState.value = initialOffset
+ absoluteOffset.value = initialOffset
+ }
+ }
+
+ internal suspend fun processNewAnchors(oldAnchors: Map<Float, T>, newAnchors: Map<Float, T>) {
+ if (oldAnchors.isEmpty()) {
+ // If this is the first time that we receive anchors, then we need to initialise
+ // the state so we snap to the offset associated to the initial value.
+ minBound = newAnchors.keys.minOrNull()!!
+ maxBound = newAnchors.keys.maxOrNull()!!
+ val initialOffset = newAnchors.getOffset(currentValue)
+ requireNotNull(initialOffset) { "The initial value must have an associated anchor." }
+ snapInternalToOffset(initialOffset)
+ } else if (newAnchors != oldAnchors) {
+ // If we have received new anchors, then the offset of the current value might
+ // have changed, so we need to animate to the new offset. If the current value
+ // has been removed from the anchors then we animate to the closest anchor
+ // instead. Note that this stops any ongoing animation.
+ minBound = Float.NEGATIVE_INFINITY
+ maxBound = Float.POSITIVE_INFINITY
+ val animationTargetValue = animationTarget.value
+ // if we're in the animation already, let's find it a new home
+ val targetOffset =
+ if (animationTargetValue != null) {
+ // first, try to map old state to the new state
+ val oldState = oldAnchors[animationTargetValue]
+ val newState = newAnchors.getOffset(oldState)
+ // return new state if exists, or find the closes one among new anchors
+ newState ?: newAnchors.keys.minByOrNull { abs(it - animationTargetValue) }!!
+ } else {
+ // we're not animating, proceed by finding the new anchors for an old value
+ val actualOldValue = oldAnchors[offset.value]
+ val value = if (actualOldValue == currentValue) currentValue else actualOldValue
+ newAnchors.getOffset(value)
+ ?: newAnchors.keys.minByOrNull { abs(it - offset.value) }!!
+ }
+ try {
+ animateInternalToOffset(targetOffset, animationSpec)
+ } catch (c: CancellationException) {
+ // If the animation was interrupted for any reason, snap as a last resort.
+ snapInternalToOffset(targetOffset)
+ } finally {
+ currentValue = newAnchors.getValue(targetOffset)
+ minBound = newAnchors.keys.minOrNull()!!
+ maxBound = newAnchors.keys.maxOrNull()!!
+ }
+ }
+ }
+
+ internal var thresholds: (Float, Float) -> Float by mutableStateOf({ _, _ -> 0f })
+
+ internal var velocityThreshold by mutableStateOf(0f)
+
+ internal var resistance: ResistanceConfig? by mutableStateOf(null)
+
+ internal val draggableState = DraggableState {
+ val newAbsolute = absoluteOffset.value + it
+ val clamped = newAbsolute.coerceIn(minBound, maxBound)
+ val overflow = newAbsolute - clamped
+ val resistanceDelta = resistance?.computeResistance(overflow) ?: 0f
+ offsetState.value = clamped + resistanceDelta
+ overflowState.value = overflow
+ absoluteOffset.value = newAbsolute
+ }
+
+ private suspend fun snapInternalToOffset(target: Float) {
+ draggableState.drag { dragBy(target - absoluteOffset.value) }
+ }
+
+ private suspend fun animateInternalToOffset(target: Float, spec: AnimationSpec<Float>) {
+ draggableState.drag {
+ var prevValue = absoluteOffset.value
+ animationTarget.value = target
+ isAnimationRunning = true
+ try {
+ Animatable(prevValue).animateTo(target, spec) {
+ dragBy(this.value - prevValue)
+ prevValue = this.value
+ }
+ } finally {
+ animationTarget.value = null
+ isAnimationRunning = false
+ }
+ }
+ }
+
+ /**
+ * The target value of the state.
+ *
+ * If a swipe is in progress, this is the value that the [swipeable] would animate to if the
+ * swipe finished. If an animation is running, this is the target value of that animation.
+ * Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
+ */
+ val targetValue: T
+ get() {
+ // TODO(calintat): Track current velocity (b/149549482) and use that here.
+ val target =
+ animationTarget.value
+ ?: computeTarget(
+ offset = offset.value,
+ lastValue = anchors.getOffset(currentValue) ?: offset.value,
+ anchors = anchors.keys,
+ thresholds = thresholds,
+ velocity = 0f,
+ velocityThreshold = Float.POSITIVE_INFINITY
+ )
+ return anchors[target] ?: currentValue
+ }
+
+ /**
+ * Information about the ongoing swipe or animation, if any. See [SwipeProgress] for details.
+ *
+ * If no swipe or animation is in progress, this returns `SwipeProgress(value, value, 1f)`.
+ */
+ val progress: SwipeProgress<T>
+ get() {
+ val bounds = findBounds(offset.value, anchors.keys)
+ val from: T
+ val to: T
+ val fraction: Float
+ when (bounds.size) {
+ 0 -> {
+ from = currentValue
+ to = currentValue
+ fraction = 1f
+ }
+ 1 -> {
+ from = anchors.getValue(bounds[0])
+ to = anchors.getValue(bounds[0])
+ fraction = 1f
+ }
+ else -> {
+ val (a, b) =
+ if (direction > 0f) {
+ bounds[0] to bounds[1]
+ } else {
+ bounds[1] to bounds[0]
+ }
+ from = anchors.getValue(a)
+ to = anchors.getValue(b)
+ fraction = (offset.value - a) / (b - a)
+ }
+ }
+ return SwipeProgress(from, to, fraction)
+ }
+
+ /**
+ * The direction in which the [swipeable] is moving, relative to the current [currentValue].
+ *
+ * This will be either 1f if it is is moving from left to right or top to bottom, -1f if it is
+ * moving from right to left or bottom to top, or 0f if no swipe or animation is in progress.
+ */
+ val direction: Float
+ get() = anchors.getOffset(currentValue)?.let { sign(offset.value - it) } ?: 0f
+
+ /**
+ * Set the state without any animation and suspend until it's set
+ *
+ * @param targetValue The new target value to set [currentValue] to.
+ */
+ suspend fun snapTo(targetValue: T) {
+ latestNonEmptyAnchorsFlow.collect { anchors ->
+ val targetOffset = anchors.getOffset(targetValue)
+ requireNotNull(targetOffset) { "The target value must have an associated anchor." }
+ snapInternalToOffset(targetOffset)
+ currentValue = targetValue
+ }
+ }
+
+ /**
+ * Set the state to the target value by starting an animation.
+ *
+ * @param targetValue The new value to animate to.
+ * @param anim The animation that will be used to animate to the new value.
+ */
+ suspend fun animateTo(targetValue: T, anim: AnimationSpec<Float> = animationSpec) {
+ latestNonEmptyAnchorsFlow.collect { anchors ->
+ try {
+ val targetOffset = anchors.getOffset(targetValue)
+ requireNotNull(targetOffset) { "The target value must have an associated anchor." }
+ animateInternalToOffset(targetOffset, anim)
+ } finally {
+ val endOffset = absoluteOffset.value
+ val endValue =
+ anchors
+ // fighting rounding error once again, anchor should be as close as 0.5
+ // pixels
+ .filterKeys { anchorOffset -> abs(anchorOffset - endOffset) < 0.5f }
+ .values
+ .firstOrNull()
+ ?: currentValue
+ currentValue = endValue
+ }
+ }
+ }
+
+ /**
+ * Perform fling with settling to one of the anchors which is determined by the given
+ * [velocity]. Fling with settling [swipeable] will always consume all the velocity provided
+ * since it will settle at the anchor.
+ *
+ * In general cases, [swipeable] flings by itself when being swiped. This method is to be used
+ * for nested scroll logic that wraps the [swipeable]. In nested scroll developer may want to
+ * trigger settling fling when the child scroll container reaches the bound.
+ *
+ * @param velocity velocity to fling and settle with
+ * @return the reason fling ended
+ */
+ suspend fun performFling(velocity: Float) {
+ latestNonEmptyAnchorsFlow.collect { anchors ->
+ val lastAnchor = anchors.getOffset(currentValue)!!
+ val targetValue =
+ computeTarget(
+ offset = offset.value,
+ lastValue = lastAnchor,
+ anchors = anchors.keys,
+ thresholds = thresholds,
+ velocity = velocity,
+ velocityThreshold = velocityThreshold
+ )
+ val targetState = anchors[targetValue]
+ if (targetState != null && confirmStateChange(targetState)) animateTo(targetState)
+ // If the user vetoed the state change, rollback to the previous state.
+ else animateInternalToOffset(lastAnchor, animationSpec)
+ }
+ }
+
+ /**
+ * Force [swipeable] to consume drag delta provided from outside of the regular [swipeable]
+ * gesture flow.
+ *
+ * Note: This method performs generic drag and it won't settle to any particular anchor, *
+ * leaving swipeable in between anchors. When done dragging, [performFling] must be called as
+ * well to ensure swipeable will settle at the anchor.
+ *
+ * In general cases, [swipeable] drags by itself when being swiped. This method is to be used
+ * for nested scroll logic that wraps the [swipeable]. In nested scroll developer may want to
+ * force drag when the child scroll container reaches the bound.
+ *
+ * @param delta delta in pixels to drag by
+ * @return the amount of [delta] consumed
+ */
+ fun performDrag(delta: Float): Float {
+ val potentiallyConsumed = absoluteOffset.value + delta
+ val clamped = potentiallyConsumed.coerceIn(minBound, maxBound)
+ val deltaToConsume = clamped - absoluteOffset.value
+ if (abs(deltaToConsume) > 0) {
+ draggableState.dispatchRawDelta(deltaToConsume)
+ }
+ return deltaToConsume
+ }
+
+ companion object {
+ /** The default [Saver] implementation for [SwipeableState]. */
+ fun <T : Any> Saver(
+ animationSpec: AnimationSpec<Float>,
+ confirmStateChange: (T) -> Boolean
+ ) =
+ Saver<SwipeableState<T>, T>(
+ save = { it.currentValue },
+ restore = { SwipeableState(it, animationSpec, confirmStateChange) }
+ )
+ }
+}
+
+/**
+ * Collects information about the ongoing swipe or animation in [swipeable].
+ *
+ * To access this information, use [SwipeableState.progress].
+ *
+ * @param from The state corresponding to the anchor we are moving away from.
+ * @param to The state corresponding to the anchor we are moving towards.
+ * @param fraction The fraction that the current position represents between [from] and [to]. Must
+ * be between `0` and `1`.
+ */
+@Immutable
+class SwipeProgress<T>(
+ val from: T,
+ val to: T,
+ /*@FloatRange(from = 0.0, to = 1.0)*/
+ val fraction: Float
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is SwipeProgress<*>) return false
+
+ if (from != other.from) return false
+ if (to != other.to) return false
+ if (fraction != other.fraction) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = from?.hashCode() ?: 0
+ result = 31 * result + (to?.hashCode() ?: 0)
+ result = 31 * result + fraction.hashCode()
+ return result
+ }
+
+ override fun toString(): String {
+ return "SwipeProgress(from=$from, to=$to, fraction=$fraction)"
+ }
+}
+
+/**
+ * Create and [remember] a [SwipeableState] with the default animation clock.
+ *
+ * @param initialValue The initial value of the state.
+ * @param animationSpec The default animation that will be used to animate to a new state.
+ * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
+ */
+@Composable
+fun <T : Any> rememberSwipeableState(
+ initialValue: T,
+ animationSpec: AnimationSpec<Float> = AnimationSpec,
+ confirmStateChange: (newValue: T) -> Boolean = { true }
+): SwipeableState<T> {
+ return rememberSaveable(
+ saver =
+ SwipeableState.Saver(
+ animationSpec = animationSpec,
+ confirmStateChange = confirmStateChange
+ )
+ ) {
+ SwipeableState(
+ initialValue = initialValue,
+ animationSpec = animationSpec,
+ confirmStateChange = confirmStateChange
+ )
+ }
+}
+
+/**
+ * Create and [remember] a [SwipeableState] which is kept in sync with another state, i.e.:
+ * 1. Whenever the [value] changes, the [SwipeableState] will be animated to that new value.
+ * 2. Whenever the value of the [SwipeableState] changes (e.g. after a swipe), the owner of the
+ * [value] will be notified to update their state to the new value of the [SwipeableState] by
+ * invoking [onValueChange]. If the owner does not update their state to the provided value for
+ * some reason, then the [SwipeableState] will perform a rollback to the previous, correct value.
+ */
+@Composable
+internal fun <T : Any> rememberSwipeableStateFor(
+ value: T,
+ onValueChange: (T) -> Unit,
+ animationSpec: AnimationSpec<Float> = AnimationSpec
+): SwipeableState<T> {
+ val swipeableState = remember {
+ SwipeableState(
+ initialValue = value,
+ animationSpec = animationSpec,
+ confirmStateChange = { true }
+ )
+ }
+ val forceAnimationCheck = remember { mutableStateOf(false) }
+ LaunchedEffect(value, forceAnimationCheck.value) {
+ if (value != swipeableState.currentValue) {
+ swipeableState.animateTo(value)
+ }
+ }
+ DisposableEffect(swipeableState.currentValue) {
+ if (value != swipeableState.currentValue) {
+ onValueChange(swipeableState.currentValue)
+ forceAnimationCheck.value = !forceAnimationCheck.value
+ }
+ onDispose {}
+ }
+ return swipeableState
+}
+
+/**
+ * Enable swipe gestures between a set of predefined states.
+ *
+ * To use this, you must provide a map of anchors (in pixels) to states (of type [T]). Note that
+ * this map cannot be empty and cannot have two anchors mapped to the same state.
+ *
+ * When a swipe is detected, the offset of the [SwipeableState] will be updated with the swipe
+ * delta. You should use this offset to move your content accordingly (see `Modifier.offsetPx`).
+ * When the swipe ends, the offset will be animated to one of the anchors and when that anchor is
+ * reached, the value of the [SwipeableState] will also be updated to the state corresponding to the
+ * new anchor. The target anchor is calculated based on the provided positional [thresholds].
+ *
+ * Swiping is constrained between the minimum and maximum anchors. If the user attempts to swipe
+ * past these bounds, a resistance effect will be applied by default. The amount of resistance at
+ * each edge is specified by the [resistance] config. To disable all resistance, set it to `null`.
+ *
+ * For an example of a [swipeable] with three states, see:
+ *
+ * @param T The type of the state.
+ * @param state The state of the [swipeable].
+ * @param anchors Pairs of anchors and states, used to map anchors to states and vice versa.
+ * @param thresholds Specifies where the thresholds between the states are. The thresholds will be
+ * used to determine which state to animate to when swiping stops. This is represented as a lambda
+ * that takes two states and returns the threshold between them in the form of a
+ * [ThresholdConfig]. Note that the order of the states corresponds to the swipe direction.
+ * @param orientation The orientation in which the [swipeable] can be swiped.
+ * @param enabled Whether this [swipeable] is enabled and should react to the user's input.
+ * @param reverseDirection Whether to reverse the direction of the swipe, so a top to bottom swipe
+ * will behave like bottom to top, and a left to right swipe will behave like right to left.
+ * @param interactionSource Optional [MutableInteractionSource] that will passed on to the internal
+ * [Modifier.draggable].
+ * @param resistance Controls how much resistance will be applied when swiping past the bounds.
+ * @param velocityThreshold The threshold (in dp per second) that the end velocity has to exceed in
+ * order to animate to the next state, even if the positional [thresholds] have not been reached.
+ * @sample androidx.compose.material.samples.SwipeableSample
+ */
+fun <T> Modifier.swipeable(
+ state: SwipeableState<T>,
+ anchors: Map<Float, T>,
+ orientation: Orientation,
+ enabled: Boolean = true,
+ reverseDirection: Boolean = false,
+ interactionSource: MutableInteractionSource? = null,
+ thresholds: (from: T, to: T) -> ThresholdConfig = { _, _ -> FixedThreshold(56.dp) },
+ resistance: ResistanceConfig? = resistanceConfig(anchors.keys),
+ velocityThreshold: Dp = VelocityThreshold
+) =
+ composed(
+ inspectorInfo =
+ debugInspectorInfo {
+ name = "swipeable"
+ properties["state"] = state
+ properties["anchors"] = anchors
+ properties["orientation"] = orientation
+ properties["enabled"] = enabled
+ properties["reverseDirection"] = reverseDirection
+ properties["interactionSource"] = interactionSource
+ properties["thresholds"] = thresholds
+ properties["resistance"] = resistance
+ properties["velocityThreshold"] = velocityThreshold
+ }
+ ) {
+ require(anchors.isNotEmpty()) { "You must have at least one anchor." }
+ require(anchors.values.distinct().count() == anchors.size) {
+ "You cannot have two anchors mapped to the same state."
+ }
+ val density = LocalDensity.current
+ state.ensureInit(anchors)
+ LaunchedEffect(anchors, state) {
+ val oldAnchors = state.anchors
+ state.anchors = anchors
+ state.resistance = resistance
+ state.thresholds = { a, b ->
+ val from = anchors.getValue(a)
+ val to = anchors.getValue(b)
+ with(thresholds(from, to)) { density.computeThreshold(a, b) }
+ }
+ with(density) { state.velocityThreshold = velocityThreshold.toPx() }
+ state.processNewAnchors(oldAnchors, anchors)
+ }
+
+ Modifier.draggable(
+ orientation = orientation,
+ enabled = enabled,
+ reverseDirection = reverseDirection,
+ interactionSource = interactionSource,
+ startDragImmediately = state.isAnimationRunning,
+ onDragStopped = { velocity -> launch { state.performFling(velocity) } },
+ state = state.draggableState
+ )
+ }
+
+/**
+ * Interface to compute a threshold between two anchors/states in a [swipeable].
+ *
+ * To define a [ThresholdConfig], consider using [FixedThreshold] and [FractionalThreshold].
+ */
+@Stable
+interface ThresholdConfig {
+ /** Compute the value of the threshold (in pixels), once the values of the anchors are known. */
+ fun Density.computeThreshold(fromValue: Float, toValue: Float): Float
+}
+
+/**
+ * A fixed threshold will be at an [offset] away from the first anchor.
+ *
+ * @param offset The offset (in dp) that the threshold will be at.
+ */
+@Immutable
+data class FixedThreshold(private val offset: Dp) : ThresholdConfig {
+ override fun Density.computeThreshold(fromValue: Float, toValue: Float): Float {
+ return fromValue + offset.toPx() * sign(toValue - fromValue)
+ }
+}
+
+/**
+ * A fractional threshold will be at a [fraction] of the way between the two anchors.
+ *
+ * @param fraction The fraction (between 0 and 1) that the threshold will be at.
+ */
+@Immutable
+data class FractionalThreshold(
+ /*@FloatRange(from = 0.0, to = 1.0)*/
+ private val fraction: Float
+) : ThresholdConfig {
+ override fun Density.computeThreshold(fromValue: Float, toValue: Float): Float {
+ return lerp(fromValue, toValue, fraction)
+ }
+}
+
+/**
+ * Specifies how resistance is calculated in [swipeable].
+ *
+ * There are two things needed to calculate resistance: the resistance basis determines how much
+ * overflow will be consumed to achieve maximum resistance, and the resistance factor determines the
+ * amount of resistance (the larger the resistance factor, the stronger the resistance).
+ *
+ * The resistance basis is usually either the size of the component which [swipeable] is applied to,
+ * or the distance between the minimum and maximum anchors. For a constructor in which the
+ * resistance basis defaults to the latter, consider using [resistanceConfig].
+ *
+ * You may specify different resistance factors for each bound. Consider using one of the default
+ * resistance factors in [SwipeableDefaults]: `StandardResistanceFactor` to convey that the user has
+ * run out of things to see, and `StiffResistanceFactor` to convey that the user cannot swipe this
+ * right now. Also, you can set either factor to 0 to disable resistance at that bound.
+ *
+ * @param basis Specifies the maximum amount of overflow that will be consumed. Must be positive.
+ * @param factorAtMin The factor by which to scale the resistance at the minimum bound. Must not be
+ * negative.
+ * @param factorAtMax The factor by which to scale the resistance at the maximum bound. Must not be
+ * negative.
+ */
+@Immutable
+class ResistanceConfig(
+ /*@FloatRange(from = 0.0, fromInclusive = false)*/
+ val basis: Float,
+ /*@FloatRange(from = 0.0)*/
+ val factorAtMin: Float = StandardResistanceFactor,
+ /*@FloatRange(from = 0.0)*/
+ val factorAtMax: Float = StandardResistanceFactor
+) {
+ fun computeResistance(overflow: Float): Float {
+ val factor = if (overflow < 0) factorAtMin else factorAtMax
+ if (factor == 0f) return 0f
+ val progress = (overflow / basis).coerceIn(-1f, 1f)
+ return basis / factor * sin(progress * PI.toFloat() / 2)
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is ResistanceConfig) return false
+
+ if (basis != other.basis) return false
+ if (factorAtMin != other.factorAtMin) return false
+ if (factorAtMax != other.factorAtMax) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = basis.hashCode()
+ result = 31 * result + factorAtMin.hashCode()
+ result = 31 * result + factorAtMax.hashCode()
+ return result
+ }
+
+ override fun toString(): String {
+ return "ResistanceConfig(basis=$basis, factorAtMin=$factorAtMin, factorAtMax=$factorAtMax)"
+ }
+}
+
+/**
+ * Given an offset x and a set of anchors, return a list of anchors:
+ * 1. [ ] if the set of anchors is empty,
+ * 2. [ x' ] if x is equal to one of the anchors, accounting for a small rounding error, where x' is
+ * x rounded to the exact value of the matching anchor,
+ * 3. [ min ] if min is the minimum anchor and x < min,
+ * 4. [ max ] if max is the maximum anchor and x > max, or
+ * 5. [ a , b ] if a and b are anchors such that a < x < b and b - a is minimal.
+ */
+private fun findBounds(offset: Float, anchors: Set<Float>): List<Float> {
+ // Find the anchors the target lies between with a little bit of rounding error.
+ val a = anchors.filter { it <= offset + 0.001 }.maxOrNull()
+ val b = anchors.filter { it >= offset - 0.001 }.minOrNull()
+
+ return when {
+ a == null ->
+ // case 1 or 3
+ listOfNotNull(b)
+ b == null ->
+ // case 4
+ listOf(a)
+ a == b ->
+ // case 2
+ // Can't return offset itself here since it might not be exactly equal
+ // to the anchor, despite being considered an exact match.
+ listOf(a)
+ else ->
+ // case 5
+ listOf(a, b)
+ }
+}
+
+private fun computeTarget(
+ offset: Float,
+ lastValue: Float,
+ anchors: Set<Float>,
+ thresholds: (Float, Float) -> Float,
+ velocity: Float,
+ velocityThreshold: Float
+): Float {
+ val bounds = findBounds(offset, anchors)
+ return when (bounds.size) {
+ 0 -> lastValue
+ 1 -> bounds[0]
+ else -> {
+ val lower = bounds[0]
+ val upper = bounds[1]
+ if (lastValue <= offset) {
+ // Swiping from lower to upper (positive).
+ if (velocity >= velocityThreshold) {
+ return upper
+ } else {
+ val threshold = thresholds(lower, upper)
+ if (offset < threshold) lower else upper
+ }
+ } else {
+ // Swiping from upper to lower (negative).
+ if (velocity <= -velocityThreshold) {
+ return lower
+ } else {
+ val threshold = thresholds(upper, lower)
+ if (offset > threshold) upper else lower
+ }
+ }
+ }
+ }
+}
+
+private fun <T> Map<Float, T>.getOffset(state: T): Float? {
+ return entries.firstOrNull { it.value == state }?.key
+}
+
+/** Contains useful defaults for [swipeable] and [SwipeableState]. */
+object SwipeableDefaults {
+ /** The default animation used by [SwipeableState]. */
+ val AnimationSpec = SpringSpec<Float>()
+
+ /** The default velocity threshold (1.8 dp per millisecond) used by [swipeable]. */
+ val VelocityThreshold = 125.dp
+
+ /** A stiff resistance factor which indicates that swiping isn't available right now. */
+ const val StiffResistanceFactor = 20f
+
+ /** A standard resistance factor which indicates that the user has run out of things to see. */
+ const val StandardResistanceFactor = 10f
+
+ /**
+ * The default resistance config used by [swipeable].
+ *
+ * This returns `null` if there is one anchor. If there are at least two anchors, it returns a
+ * [ResistanceConfig] with the resistance basis equal to the distance between the two bounds.
+ */
+ fun resistanceConfig(
+ anchors: Set<Float>,
+ factorAtMin: Float = StandardResistanceFactor,
+ factorAtMax: Float = StandardResistanceFactor
+ ): ResistanceConfig? {
+ return if (anchors.size <= 1) {
+ null
+ } else {
+ val basis = anchors.maxOrNull()!! - anchors.minOrNull()!!
+ ResistanceConfig(basis, factorAtMin, factorAtMax)
+ }
+ }
+}
+
+// temp default nested scroll connection for swipeables which desire as an opt in
+// revisit in b/174756744 as all types will have their own specific connection probably
+internal val <T> SwipeableState<T>.PreUpPostDownNestedScrollConnection: NestedScrollConnection
+ get() =
+ object : NestedScrollConnection {
+ override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
+ val delta = available.toFloat()
+ return if (delta < 0 && source == NestedScrollSource.Drag) {
+ performDrag(delta).toOffset()
+ } else {
+ Offset.Zero
+ }
+ }
+
+ override fun onPostScroll(
+ consumed: Offset,
+ available: Offset,
+ source: NestedScrollSource
+ ): Offset {
+ return if (source == NestedScrollSource.Drag) {
+ performDrag(available.toFloat()).toOffset()
+ } else {
+ Offset.Zero
+ }
+ }
+
+ override suspend fun onPreFling(available: Velocity): Velocity {
+ val toFling = Offset(available.x, available.y).toFloat()
+ return if (toFling < 0 && offset.value > minBound) {
+ performFling(velocity = toFling)
+ // since we go to the anchor with tween settling, consume all for the best UX
+ available
+ } else {
+ Velocity.Zero
+ }
+ }
+
+ override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
+ performFling(velocity = Offset(available.x, available.y).toFloat())
+ return available
+ }
+
+ private fun Float.toOffset(): Offset = Offset(0f, this)
+
+ private fun Offset.toFloat(): Float = this.y
+ }
diff --git a/packages/SystemUI/compose/core/src/com/android/compose/ui/util/MathHelpers.kt b/packages/SystemUI/compose/core/src/com/android/compose/ui/util/MathHelpers.kt
new file mode 100644
index 000000000000..c1defb722077
--- /dev/null
+++ b/packages/SystemUI/compose/core/src/com/android/compose/ui/util/MathHelpers.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.compose.ui.util
+
+import kotlin.math.roundToInt
+import kotlin.math.roundToLong
+
+// TODO(b/272311106): this is a fork from material. Unfork it when MathHelpers.kt reaches material3.
+
+/** Linearly interpolate between [start] and [stop] with [fraction] fraction between them. */
+fun lerp(start: Float, stop: Float, fraction: Float): Float {
+ return (1 - fraction) * start + fraction * stop
+}
+
+/** Linearly interpolate between [start] and [stop] with [fraction] fraction between them. */
+fun lerp(start: Int, stop: Int, fraction: Float): Int {
+ return start + ((stop - start) * fraction.toDouble()).roundToInt()
+}
+
+/** Linearly interpolate between [start] and [stop] with [fraction] fraction between them. */
+fun lerp(start: Long, stop: Long, fraction: Float): Long {
+ return start + ((stop - start) * fraction.toDouble()).roundToLong()
+}
diff --git a/packages/SystemUI/compose/facade/disabled/src/com/android/systemui/compose/ComposeFacade.kt b/packages/SystemUI/compose/facade/disabled/src/com/android/systemui/compose/ComposeFacade.kt
index e253fb925ceb..cc337459a83c 100644
--- a/packages/SystemUI/compose/facade/disabled/src/com/android/systemui/compose/ComposeFacade.kt
+++ b/packages/SystemUI/compose/facade/disabled/src/com/android/systemui/compose/ComposeFacade.kt
@@ -21,8 +21,10 @@ import android.content.Context
import android.view.View
import androidx.activity.ComponentActivity
import androidx.lifecycle.LifecycleOwner
+import com.android.systemui.multishade.ui.viewmodel.MultiShadeViewModel
import com.android.systemui.people.ui.viewmodel.PeopleViewModel
import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsViewModel
+import com.android.systemui.util.time.SystemClock
/** The Compose facade, when Compose is *not* available. */
object ComposeFacade : BaseComposeFacade {
@@ -48,6 +50,14 @@ object ComposeFacade : BaseComposeFacade {
throwComposeUnavailableError()
}
+ override fun createMultiShadeView(
+ context: Context,
+ viewModel: MultiShadeViewModel,
+ clock: SystemClock,
+ ): View {
+ throwComposeUnavailableError()
+ }
+
private fun throwComposeUnavailableError(): Nothing {
error(
"Compose is not available. Make sure to check isComposeAvailable() before calling any" +
diff --git a/packages/SystemUI/compose/facade/enabled/src/com/android/systemui/compose/ComposeFacade.kt b/packages/SystemUI/compose/facade/enabled/src/com/android/systemui/compose/ComposeFacade.kt
index 1ea18fec4abe..0e79b18b1c24 100644
--- a/packages/SystemUI/compose/facade/enabled/src/com/android/systemui/compose/ComposeFacade.kt
+++ b/packages/SystemUI/compose/facade/enabled/src/com/android/systemui/compose/ComposeFacade.kt
@@ -23,10 +23,13 @@ import androidx.activity.compose.setContent
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.LifecycleOwner
import com.android.compose.theme.PlatformTheme
+import com.android.systemui.multishade.ui.composable.MultiShade
+import com.android.systemui.multishade.ui.viewmodel.MultiShadeViewModel
import com.android.systemui.people.ui.compose.PeopleScreen
import com.android.systemui.people.ui.viewmodel.PeopleViewModel
import com.android.systemui.qs.footer.ui.compose.FooterActions
import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsViewModel
+import com.android.systemui.util.time.SystemClock
/** The Compose facade, when Compose is available. */
object ComposeFacade : BaseComposeFacade {
@@ -51,4 +54,21 @@ object ComposeFacade : BaseComposeFacade {
setContent { PlatformTheme { FooterActions(viewModel, qsVisibilityLifecycleOwner) } }
}
}
+
+ override fun createMultiShadeView(
+ context: Context,
+ viewModel: MultiShadeViewModel,
+ clock: SystemClock,
+ ): View {
+ return ComposeView(context).apply {
+ setContent {
+ PlatformTheme {
+ MultiShade(
+ viewModel = viewModel,
+ clock = clock,
+ )
+ }
+ }
+ }
+ }
}
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/multishade/ui/composable/MultiShade.kt b/packages/SystemUI/compose/features/src/com/android/systemui/multishade/ui/composable/MultiShade.kt
new file mode 100644
index 000000000000..b9e38cf3cc60
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/multishade/ui/composable/MultiShade.kt
@@ -0,0 +1,144 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.systemui.multishade.ui.composable
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.gestures.detectVerticalDragGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+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.graphics.graphicsLayer
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.unit.IntSize
+import com.android.systemui.R
+import com.android.systemui.multishade.shared.model.ProxiedInputModel
+import com.android.systemui.multishade.ui.viewmodel.MultiShadeViewModel
+import com.android.systemui.notifications.ui.composable.Notifications
+import com.android.systemui.qs.footer.ui.compose.QuickSettings
+import com.android.systemui.statusbar.ui.composable.StatusBar
+import com.android.systemui.util.time.SystemClock
+
+@Composable
+fun MultiShade(
+ viewModel: MultiShadeViewModel,
+ clock: SystemClock,
+ modifier: Modifier = Modifier,
+) {
+ val isScrimEnabled: Boolean by viewModel.isScrimEnabled.collectAsState()
+
+ // TODO(b/273298030): find a different way to get the height constraint from its parent.
+ BoxWithConstraints(modifier = modifier) {
+ val maxHeightPx = with(LocalDensity.current) { maxHeight.toPx() }
+
+ Scrim(
+ modifier = Modifier.fillMaxSize(),
+ remoteTouch = viewModel::onScrimTouched,
+ alpha = { viewModel.scrimAlpha.value },
+ isScrimEnabled = isScrimEnabled,
+ )
+ Shade(
+ viewModel = viewModel.leftShade,
+ currentTimeMillis = clock::elapsedRealtime,
+ containerHeightPx = maxHeightPx,
+ modifier = Modifier.align(Alignment.TopStart),
+ ) {
+ Column {
+ StatusBar()
+ Notifications()
+ }
+ }
+ Shade(
+ viewModel = viewModel.rightShade,
+ currentTimeMillis = clock::elapsedRealtime,
+ containerHeightPx = maxHeightPx,
+ modifier = Modifier.align(Alignment.TopEnd),
+ ) {
+ Column {
+ StatusBar()
+ QuickSettings()
+ }
+ }
+ Shade(
+ viewModel = viewModel.singleShade,
+ currentTimeMillis = clock::elapsedRealtime,
+ containerHeightPx = maxHeightPx,
+ modifier = Modifier,
+ ) {
+ Column {
+ StatusBar()
+ Notifications()
+ QuickSettings()
+ }
+ }
+ }
+}
+
+@Composable
+private fun Scrim(
+ remoteTouch: (ProxiedInputModel) -> Unit,
+ alpha: () -> Float,
+ isScrimEnabled: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ var size by remember { mutableStateOf(IntSize.Zero) }
+
+ Box(
+ modifier =
+ modifier
+ .graphicsLayer { this.alpha = alpha() }
+ .background(colorResource(R.color.opaque_scrim))
+ .fillMaxSize()
+ .onSizeChanged { size = it }
+ .then(
+ if (isScrimEnabled) {
+ Modifier.pointerInput(Unit) {
+ detectTapGestures(onTap = { remoteTouch(ProxiedInputModel.OnTap) })
+ }
+ .pointerInput(Unit) {
+ detectVerticalDragGestures(
+ onVerticalDrag = { change, dragAmount ->
+ remoteTouch(
+ ProxiedInputModel.OnDrag(
+ xFraction = change.position.x / size.width,
+ yDragAmountPx = dragAmount,
+ )
+ )
+ },
+ onDragEnd = { remoteTouch(ProxiedInputModel.OnDragEnd) },
+ onDragCancel = { remoteTouch(ProxiedInputModel.OnDragCancel) }
+ )
+ }
+ } else {
+ Modifier
+ }
+ )
+ )
+}
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/multishade/ui/composable/Shade.kt b/packages/SystemUI/compose/features/src/com/android/systemui/multishade/ui/composable/Shade.kt
new file mode 100644
index 000000000000..98ef57f94783
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/multishade/ui/composable/Shade.kt
@@ -0,0 +1,317 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.systemui.multishade.ui.composable
+
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.interaction.DragInteraction
+import androidx.compose.foundation.interaction.InteractionSource
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.pointer.util.VelocityTracker
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.android.compose.modifiers.height
+import com.android.compose.swipeable.FixedThreshold
+import com.android.compose.swipeable.SwipeableState
+import com.android.compose.swipeable.ThresholdConfig
+import com.android.compose.swipeable.rememberSwipeableState
+import com.android.compose.swipeable.swipeable
+import com.android.systemui.multishade.shared.model.ProxiedInputModel
+import com.android.systemui.multishade.ui.viewmodel.ShadeViewModel
+import kotlin.math.roundToInt
+import kotlinx.coroutines.launch
+
+/**
+ * Renders a shade (container and content).
+ *
+ * This should be allowed to grow to fill the width and height of its container.
+ *
+ * @param viewModel The view-model for this shade.
+ * @param currentTimeMillis A provider for the current time, in milliseconds.
+ * @param containerHeightPx The height of the container that this shade is being shown in, in
+ * pixels.
+ * @param modifier The Modifier.
+ * @param content The content of the shade.
+ */
+@Composable
+fun Shade(
+ viewModel: ShadeViewModel,
+ currentTimeMillis: () -> Long,
+ containerHeightPx: Float,
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit = {},
+) {
+ val isVisible: Boolean by viewModel.isVisible.collectAsState()
+ if (!isVisible) {
+ return
+ }
+
+ val interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
+ ReportNonProxiedInput(viewModel, interactionSource)
+
+ val swipeableState = rememberSwipeableState(initialValue = ShadeState.FullyCollapsed)
+ HandleForcedCollapse(viewModel, swipeableState)
+ HandleProxiedInput(viewModel, swipeableState, currentTimeMillis)
+ ReportShadeExpansion(viewModel, swipeableState, containerHeightPx)
+
+ val isSwipingEnabled: Boolean by viewModel.isSwipingEnabled.collectAsState()
+ val collapseThreshold: Float by viewModel.swipeCollapseThreshold.collectAsState()
+ val expandThreshold: Float by viewModel.swipeExpandThreshold.collectAsState()
+
+ val width: ShadeViewModel.Size by viewModel.width.collectAsState()
+ val density = LocalDensity.current
+
+ val anchors: Map<Float, ShadeState> =
+ remember(containerHeightPx) { swipeableAnchors(containerHeightPx) }
+
+ ShadeContent(
+ shadeHeightPx = { swipeableState.offset.value },
+ overstretch = { swipeableState.overflow.value / containerHeightPx },
+ isSwipingEnabled = isSwipingEnabled,
+ swipeableState = swipeableState,
+ interactionSource = interactionSource,
+ anchors = anchors,
+ thresholds = { _, to ->
+ swipeableThresholds(
+ to = to,
+ swipeCollapseThreshold = collapseThreshold.fractionToDp(density, containerHeightPx),
+ swipeExpandThreshold = expandThreshold.fractionToDp(density, containerHeightPx),
+ )
+ },
+ modifier = modifier.shadeWidth(width, density),
+ content = content,
+ )
+}
+
+/**
+ * Draws the content of the shade.
+ *
+ * @param shadeHeightPx Provider for the current expansion of the shade, in pixels, where `0` is
+ * fully collapsed.
+ * @param overstretch Provider for the current amount of vertical "overstretch" that the shade
+ * should be rendered with. This is `0` or a positive number that is a percentage of the total
+ * height of the shade when fully expanded. A value of `0` means that the shade is not stretched
+ * at all.
+ * @param isSwipingEnabled Whether swiping inside the shade is enabled or not.
+ * @param swipeableState The state to use for the [swipeable] modifier, allowing external control in
+ * addition to direct control (proxied user input in addition to non-proxied/direct user input).
+ * @param anchors A map of [ShadeState] keyed by the vertical position, in pixels, where that state
+ * occurs; this is used to configure the [swipeable] modifier.
+ * @param thresholds Function that returns the [ThresholdConfig] for going from one [ShadeState] to
+ * another. This controls how the [swipeable] decides which [ShadeState] to animate to once the
+ * user lets go of the shade; e.g. does it animate to fully collapsed or fully expanded.
+ * @param content The content to render inside the shade.
+ * @param modifier The [Modifier].
+ */
+@Composable
+private fun ShadeContent(
+ shadeHeightPx: () -> Float,
+ overstretch: () -> Float,
+ isSwipingEnabled: Boolean,
+ swipeableState: SwipeableState<ShadeState>,
+ interactionSource: MutableInteractionSource,
+ anchors: Map<Float, ShadeState>,
+ thresholds: (from: ShadeState, to: ShadeState) -> ThresholdConfig,
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit = {},
+) {
+ Surface(
+ shape = RoundedCornerShape(32.dp),
+ modifier =
+ modifier
+ .padding(12.dp)
+ .fillMaxWidth()
+ .height { shadeHeightPx().roundToInt() }
+ .graphicsLayer {
+ // Applies the vertical over-stretching of the shade content that may happen if
+ // the user keep dragging down when the shade is already fully-expanded.
+ transformOrigin = transformOrigin.copy(pivotFractionY = 0f)
+ this.scaleY = 1 + overstretch().coerceAtLeast(0f)
+ }
+ .swipeable(
+ enabled = isSwipingEnabled,
+ state = swipeableState,
+ interactionSource = interactionSource,
+ anchors = anchors,
+ thresholds = thresholds,
+ orientation = Orientation.Vertical,
+ ),
+ content = content,
+ )
+}
+
+/** Funnels current shade expansion values into the view-model. */
+@Composable
+private fun ReportShadeExpansion(
+ viewModel: ShadeViewModel,
+ swipeableState: SwipeableState<ShadeState>,
+ containerHeightPx: Float,
+) {
+ LaunchedEffect(swipeableState.offset, containerHeightPx) {
+ snapshotFlow { swipeableState.offset.value / containerHeightPx }
+ .collect { expansion -> viewModel.onExpansionChanged(expansion) }
+ }
+}
+
+/** Funnels drag gesture start and end events into the view-model. */
+@Composable
+private fun ReportNonProxiedInput(
+ viewModel: ShadeViewModel,
+ interactionSource: InteractionSource,
+) {
+ LaunchedEffect(interactionSource) {
+ interactionSource.interactions.collect {
+ when (it) {
+ is DragInteraction.Start -> {
+ viewModel.onDragStarted()
+ }
+ is DragInteraction.Stop -> {
+ viewModel.onDragEnded()
+ }
+ }
+ }
+ }
+}
+
+/** When told to force collapse, collapses the shade. */
+@Composable
+private fun HandleForcedCollapse(
+ viewModel: ShadeViewModel,
+ swipeableState: SwipeableState<ShadeState>,
+) {
+ LaunchedEffect(viewModel) {
+ viewModel.isForceCollapsed.collect {
+ launch { swipeableState.animateTo(ShadeState.FullyCollapsed) }
+ }
+ }
+}
+
+/**
+ * Handles proxied input (input originating outside of the UI of the shade) by driving the
+ * [SwipeableState] accordingly.
+ */
+@Composable
+private fun HandleProxiedInput(
+ viewModel: ShadeViewModel,
+ swipeableState: SwipeableState<ShadeState>,
+ currentTimeMillis: () -> Long,
+) {
+ val velocityTracker: VelocityTracker = remember { VelocityTracker() }
+ LaunchedEffect(viewModel) {
+ viewModel.proxiedInput.collect {
+ when (it) {
+ is ProxiedInputModel.OnDrag -> {
+ velocityTracker.addPosition(
+ timeMillis = currentTimeMillis.invoke(),
+ position = Offset(0f, it.yDragAmountPx),
+ )
+ swipeableState.performDrag(it.yDragAmountPx)
+ }
+ is ProxiedInputModel.OnDragEnd -> {
+ launch {
+ val velocity = velocityTracker.calculateVelocity().y
+ velocityTracker.resetTracking()
+ // We use a VelocityTracker to keep a record of how fast the pointer was
+ // moving such that we know how far to fling the shade when the gesture
+ // ends. Flinging the SwipeableState using performFling is required after
+ // one or more calls to performDrag such that the swipeable settles into one
+ // of the states. Without doing that, the shade would remain unmoving in an
+ // in-between state on the screen.
+ swipeableState.performFling(velocity)
+ }
+ }
+ is ProxiedInputModel.OnDragCancel -> {
+ launch {
+ velocityTracker.resetTracking()
+ swipeableState.animateTo(swipeableState.progress.from)
+ }
+ }
+ else -> Unit
+ }
+ }
+ }
+}
+
+/**
+ * Converts the [Float] (which is assumed to be a fraction between `0` and `1`) to a value in dp.
+ *
+ * @param density The [Density] of the display.
+ * @param wholePx The whole amount that the given [Float] is a fraction of.
+ * @return The dp size that's a fraction of the whole amount.
+ */
+private fun Float.fractionToDp(density: Density, wholePx: Float): Dp {
+ return with(density) { (this@fractionToDp * wholePx).toDp() }
+}
+
+private fun Modifier.shadeWidth(
+ size: ShadeViewModel.Size,
+ density: Density,
+): Modifier {
+ return then(
+ when (size) {
+ is ShadeViewModel.Size.Fraction -> Modifier.fillMaxWidth(size.fraction)
+ is ShadeViewModel.Size.Pixels -> Modifier.width(with(density) { size.pixels.toDp() })
+ }
+ )
+}
+
+/** Returns the pixel positions for each of the supported shade states. */
+private fun swipeableAnchors(containerHeightPx: Float): Map<Float, ShadeState> {
+ return mapOf(
+ 0f to ShadeState.FullyCollapsed,
+ containerHeightPx to ShadeState.FullyExpanded,
+ )
+}
+
+/**
+ * Returns the [ThresholdConfig] for how far the shade should be expanded or collapsed such that it
+ * actually completes the expansion or collapse after the user lifts their pointer.
+ */
+private fun swipeableThresholds(
+ to: ShadeState,
+ swipeExpandThreshold: Dp,
+ swipeCollapseThreshold: Dp,
+): ThresholdConfig {
+ return FixedThreshold(
+ when (to) {
+ ShadeState.FullyExpanded -> swipeExpandThreshold
+ ShadeState.FullyCollapsed -> swipeCollapseThreshold
+ }
+ )
+}
+
+/** Enumerates the shade UI states for [SwipeableState]. */
+private enum class ShadeState {
+ FullyCollapsed,
+ FullyExpanded,
+}
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt
new file mode 100644
index 000000000000..ca91b8a21a81
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.systemui.notifications.ui.composable
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun Notifications(
+ modifier: Modifier = Modifier,
+) {
+ // TODO(b/272779828): implement.
+ Column(
+ modifier = modifier.fillMaxWidth().defaultMinSize(minHeight = 300.dp).padding(4.dp),
+ ) {
+ Text("Notifications", modifier = Modifier.align(Alignment.CenterHorizontally))
+ Spacer(modifier = Modifier.weight(1f))
+ Text("Shelf", modifier = Modifier.align(Alignment.CenterHorizontally))
+ }
+}
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/qs/footer/ui/compose/QuickSettings.kt b/packages/SystemUI/compose/features/src/com/android/systemui/qs/footer/ui/compose/QuickSettings.kt
new file mode 100644
index 000000000000..665d6dd0cfa2
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/qs/footer/ui/compose/QuickSettings.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.systemui.qs.footer.ui.compose
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun QuickSettings(
+ modifier: Modifier = Modifier,
+) {
+ // TODO(b/272780058): implement.
+ Column(
+ modifier = modifier.fillMaxWidth().defaultMinSize(minHeight = 300.dp).padding(4.dp),
+ ) {
+ Text("Quick settings", modifier = Modifier.align(Alignment.CenterHorizontally))
+ Spacer(modifier = Modifier.weight(1f))
+ Text("QS footer actions", modifier = Modifier.align(Alignment.CenterHorizontally))
+ }
+}
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/statusbar/ui/composable/StatusBar.kt b/packages/SystemUI/compose/features/src/com/android/systemui/statusbar/ui/composable/StatusBar.kt
new file mode 100644
index 000000000000..f514ab4a710b
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/statusbar/ui/composable/StatusBar.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.systemui.statusbar.ui.composable
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun StatusBar(
+ modifier: Modifier = Modifier,
+) {
+ // TODO(b/272780101): implement.
+ Row(
+ modifier = modifier.fillMaxWidth().defaultMinSize(minHeight = 48.dp).padding(4.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text("Status bar")
+ }
+}
diff --git a/packages/SystemUI/res/values/config.xml b/packages/SystemUI/res/values/config.xml
index 311990c45d43..e5cd0c55027b 100644
--- a/packages/SystemUI/res/values/config.xml
+++ b/packages/SystemUI/res/values/config.xml
@@ -844,4 +844,44 @@
<!-- Configuration to set Learn more in device logs as URL link -->
<bool name="log_access_confirmation_learn_more_as_link">true</bool>
+
+ <!-- [START] MULTI SHADE -->
+ <!-- Whether the device should use dual shade. If false, the device uses single shade. -->
+ <bool name="dual_shade_enabled">true</bool>
+ <!--
+ When in dual shade, where should the horizontal split be on the screen to help determine whether
+ the user is pulling down the left shade or the right shade. Must be between 0.0 and 1.0,
+ inclusive. In other words: how much of the left-hand side of the screen, when pulled down on,
+ would reveal the left-hand side shade.
+
+ More concretely:
+ A value of 0.67 means that the left two-thirds of the screen are dedicated to the left-hand side
+ shade and the remaining one-third of the screen on the right is dedicated to the right-hand side
+ shade.
+ -->
+ <dimen name="dual_shade_split_fraction">0.67</dimen>
+ <!-- Width of the left-hand side shade. -->
+ <dimen name="left_shade_width">436dp</dimen>
+ <!-- Width of the right-hand side shade. -->
+ <dimen name="right_shade_width">436dp</dimen>
+ <!--
+ Opaque version of the scrim that shows up behind dual shades. The alpha channel is driven
+ programmatically.
+ -->
+ <color name="opaque_scrim">#D9D9D9</color>
+ <!-- Maximum opacity when the scrim that shows up behind the dual shades is fully visible. -->
+ <dimen name="dual_shade_scrim_alpha">0.1</dimen>
+ <!--
+ The amount that the user must swipe down when the shade is fully collapsed to automatically
+ expand once the user lets go of the shade. If the user swipes less than this amount, the shade
+ will automatically revert back to fully collapsed once the user stops swiping.
+ -->
+ <dimen name="shade_swipe_expand_threshold">0.5</dimen>
+ <!--
+ The amount that the user must swipe up when the shade is fully expanded to automatically
+ collapse once the user lets go of the shade. If the user swipes less than this amount, the shade
+ will automatically revert back to fully expanded once the user stops swiping.
+ -->
+ <dimen name="shade_swipe_collapse_threshold">0.5</dimen>
+ <!-- [END] MULTI SHADE -->
</resources>
diff --git a/packages/SystemUI/src/com/android/systemui/compose/BaseComposeFacade.kt b/packages/SystemUI/src/com/android/systemui/compose/BaseComposeFacade.kt
index c0f854958c41..4173bdc3c261 100644
--- a/packages/SystemUI/src/com/android/systemui/compose/BaseComposeFacade.kt
+++ b/packages/SystemUI/src/com/android/systemui/compose/BaseComposeFacade.kt
@@ -21,8 +21,10 @@ import android.content.Context
import android.view.View
import androidx.activity.ComponentActivity
import androidx.lifecycle.LifecycleOwner
+import com.android.systemui.multishade.ui.viewmodel.MultiShadeViewModel
import com.android.systemui.people.ui.viewmodel.PeopleViewModel
import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsViewModel
+import com.android.systemui.util.time.SystemClock
/**
* A facade to interact with Compose, when it is available.
@@ -57,4 +59,11 @@ interface BaseComposeFacade {
viewModel: FooterActionsViewModel,
qsVisibilityLifecycleOwner: LifecycleOwner,
): View
+
+ /** Create a [View] to represent [viewModel] on screen. */
+ fun createMultiShadeView(
+ context: Context,
+ viewModel: MultiShadeViewModel,
+ clock: SystemClock,
+ ): View
}
diff --git a/packages/SystemUI/src/com/android/systemui/multishade/data/model/MultiShadeInteractionModel.kt b/packages/SystemUI/src/com/android/systemui/multishade/data/model/MultiShadeInteractionModel.kt
new file mode 100644
index 000000000000..c48028c31cf0
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/multishade/data/model/MultiShadeInteractionModel.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.systemui.multishade.data.model
+
+import com.android.systemui.multishade.shared.model.ShadeId
+
+/** Models the current interaction with one of the shades. */
+data class MultiShadeInteractionModel(
+ /** The ID of the shade that the user is currently interacting with. */
+ val shadeId: ShadeId,
+ /** Whether the interaction is proxied (as in: coming from an external app or different UI). */
+ val isProxied: Boolean,
+)
diff --git a/packages/SystemUI/src/com/android/systemui/multishade/data/remoteproxy/MultiShadeInputProxy.kt b/packages/SystemUI/src/com/android/systemui/multishade/data/remoteproxy/MultiShadeInputProxy.kt
new file mode 100644
index 000000000000..86f0c0d15b55
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/multishade/data/remoteproxy/MultiShadeInputProxy.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.systemui.multishade.data.remoteproxy
+
+import com.android.systemui.multishade.shared.model.ProxiedInputModel
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+
+/**
+ * Acts as a hub for routing proxied user input into the multi shade system.
+ *
+ * "Proxied" user input is coming through a proxy; typically from an external app or different UI.
+ * In other words: it's not user input that's occurring directly on the shade UI itself. This class
+ * is that proxy.
+ */
+@Singleton
+class MultiShadeInputProxy @Inject constructor() {
+ private val _proxiedTouch =
+ MutableSharedFlow<ProxiedInputModel>(
+ replay = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+ val proxiedInput: Flow<ProxiedInputModel> = _proxiedTouch.asSharedFlow()
+
+ fun onProxiedInput(proxiedInput: ProxiedInputModel) {
+ _proxiedTouch.tryEmit(proxiedInput)
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/multishade/data/repository/MultiShadeRepository.kt b/packages/SystemUI/src/com/android/systemui/multishade/data/repository/MultiShadeRepository.kt
new file mode 100644
index 000000000000..117203012757
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/multishade/data/repository/MultiShadeRepository.kt
@@ -0,0 +1,157 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.systemui.multishade.data.repository
+
+import android.content.Context
+import androidx.annotation.FloatRange
+import com.android.systemui.R
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.multishade.data.model.MultiShadeInteractionModel
+import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy
+import com.android.systemui.multishade.shared.model.ProxiedInputModel
+import com.android.systemui.multishade.shared.model.ShadeConfig
+import com.android.systemui.multishade.shared.model.ShadeId
+import com.android.systemui.multishade.shared.model.ShadeModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/** Encapsulates application state for all shades. */
+@SysUISingleton
+class MultiShadeRepository
+@Inject
+constructor(
+ @Application private val applicationContext: Context,
+ inputProxy: MultiShadeInputProxy,
+) {
+ /**
+ * Remote input coming from sources outside of system UI (for example, swiping down on the
+ * Launcher or from the status bar).
+ */
+ val proxiedInput: Flow<ProxiedInputModel> = inputProxy.proxiedInput
+
+ /** Width of the left-hand side shade, in pixels. */
+ private val leftShadeWidthPx =
+ applicationContext.resources.getDimensionPixelSize(R.dimen.left_shade_width)
+
+ /** Width of the right-hand side shade, in pixels. */
+ private val rightShadeWidthPx =
+ applicationContext.resources.getDimensionPixelSize(R.dimen.right_shade_width)
+
+ /**
+ * The amount that the user must swipe up when the shade is fully expanded to automatically
+ * collapse once the user lets go of the shade. If the user swipes less than this amount, the
+ * shade will automatically revert back to fully expanded once the user stops swiping.
+ *
+ * This is a fraction between `0` and `1`.
+ */
+ private val swipeCollapseThreshold =
+ checkInBounds(applicationContext.resources.getFloat(R.dimen.shade_swipe_collapse_threshold))
+
+ /**
+ * The amount that the user must swipe down when the shade is fully collapsed to automatically
+ * expand once the user lets go of the shade. If the user swipes less than this amount, the
+ * shade will automatically revert back to fully collapsed once the user stops swiping.
+ *
+ * This is a fraction between `0` and `1`.
+ */
+ private val swipeExpandThreshold =
+ checkInBounds(applicationContext.resources.getFloat(R.dimen.shade_swipe_expand_threshold))
+
+ /**
+ * Maximum opacity when the scrim that shows up behind the dual shades is fully visible.
+ *
+ * This is a fraction between `0` and `1`.
+ */
+ private val dualShadeScrimAlpha =
+ checkInBounds(applicationContext.resources.getFloat(R.dimen.dual_shade_scrim_alpha))
+
+ /** The current configuration of the shade system. */
+ val shadeConfig: StateFlow<ShadeConfig> =
+ MutableStateFlow(
+ if (applicationContext.resources.getBoolean(R.bool.dual_shade_enabled)) {
+ ShadeConfig.DualShadeConfig(
+ leftShadeWidthPx = leftShadeWidthPx,
+ rightShadeWidthPx = rightShadeWidthPx,
+ swipeCollapseThreshold = swipeCollapseThreshold,
+ swipeExpandThreshold = swipeExpandThreshold,
+ splitFraction =
+ applicationContext.resources.getFloat(
+ R.dimen.dual_shade_split_fraction
+ ),
+ scrimAlpha = dualShadeScrimAlpha,
+ )
+ } else {
+ ShadeConfig.SingleShadeConfig(
+ swipeCollapseThreshold = swipeCollapseThreshold,
+ swipeExpandThreshold = swipeExpandThreshold,
+ )
+ }
+ )
+ .asStateFlow()
+
+ private val _forceCollapseAll = MutableStateFlow(false)
+ /** Whether all shades should be collapsed. */
+ val forceCollapseAll: StateFlow<Boolean> = _forceCollapseAll.asStateFlow()
+
+ private val _shadeInteraction = MutableStateFlow<MultiShadeInteractionModel?>(null)
+ /** The current shade interaction or `null` if no shade is interacted with currently. */
+ val shadeInteraction: StateFlow<MultiShadeInteractionModel?> = _shadeInteraction.asStateFlow()
+
+ private val stateByShade = mutableMapOf<ShadeId, MutableStateFlow<ShadeModel>>()
+
+ /** The model for the shade with the given ID. */
+ fun getShade(
+ shadeId: ShadeId,
+ ): StateFlow<ShadeModel> {
+ return getMutableShade(shadeId).asStateFlow()
+ }
+
+ /** Sets the expansion amount for the shade with the given ID. */
+ fun setExpansion(
+ shadeId: ShadeId,
+ @FloatRange(from = 0.0, to = 1.0) expansion: Float,
+ ) {
+ getMutableShade(shadeId).let { mutableState ->
+ mutableState.value = mutableState.value.copy(expansion = expansion)
+ }
+ }
+
+ /** Sets whether all shades should be immediately forced to collapse. */
+ fun setForceCollapseAll(isForced: Boolean) {
+ _forceCollapseAll.value = isForced
+ }
+
+ /** Sets the current shade interaction; use `null` if no shade is interacted with currently. */
+ fun setShadeInteraction(shadeInteraction: MultiShadeInteractionModel?) {
+ _shadeInteraction.value = shadeInteraction
+ }
+
+ private fun getMutableShade(id: ShadeId): MutableStateFlow<ShadeModel> {
+ return stateByShade.getOrPut(id) { MutableStateFlow(ShadeModel(id)) }
+ }
+
+ /** Asserts that the given [Float] is in the range of `0` and `1`, inclusive. */
+ private fun checkInBounds(float: Float): Float {
+ check(float in 0f..1f) { "$float isn't between 0 and 1." }
+ return float
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractor.kt b/packages/SystemUI/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractor.kt
new file mode 100644
index 000000000000..b9f6d83d8406
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractor.kt
@@ -0,0 +1,322 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.systemui.multishade.domain.interactor
+
+import androidx.annotation.FloatRange
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.multishade.data.model.MultiShadeInteractionModel
+import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy
+import com.android.systemui.multishade.data.repository.MultiShadeRepository
+import com.android.systemui.multishade.shared.model.ProxiedInputModel
+import com.android.systemui.multishade.shared.model.ShadeConfig
+import com.android.systemui.multishade.shared.model.ShadeId
+import com.android.systemui.multishade.shared.model.ShadeModel
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.yield
+
+/** Encapsulates business logic related to interactions with the multi-shade system. */
+@OptIn(ExperimentalCoroutinesApi::class)
+@SysUISingleton
+class MultiShadeInteractor
+@Inject
+constructor(
+ @Application private val applicationScope: CoroutineScope,
+ private val repository: MultiShadeRepository,
+ private val inputProxy: MultiShadeInputProxy,
+) {
+ /** The current configuration of the shade system. */
+ val shadeConfig: StateFlow<ShadeConfig> = repository.shadeConfig
+
+ /** The expansion of the shade that's most expanded. */
+ val maxShadeExpansion: Flow<Float> =
+ repository.shadeConfig.flatMapLatest { shadeConfig ->
+ combine(allShades(shadeConfig)) { shadeModels ->
+ shadeModels.maxOfOrNull { it.expansion } ?: 0f
+ }
+ }
+
+ /**
+ * A _processed_ version of the proxied input flow.
+ *
+ * All internal dependencies on the proxied input flow *must* use this one for two reasons:
+ * 1. It's a [SharedFlow] so we only do the upstream work once, no matter how many usages we
+ * actually have.
+ * 2. It actually does some preprocessing as the proxied input events stream through, handling
+ * common things like recording the current state of the system based on incoming input
+ * events.
+ */
+ private val processedProxiedInput: SharedFlow<ProxiedInputModel> =
+ combine(
+ repository.shadeConfig,
+ repository.proxiedInput.distinctUntilChanged(),
+ ::Pair,
+ )
+ .map { (shadeConfig, proxiedInput) ->
+ if (proxiedInput !is ProxiedInputModel.OnTap) {
+ // If the user is interacting with any other gesture type (for instance,
+ // dragging),
+ // we no longer want to force collapse all shades.
+ repository.setForceCollapseAll(false)
+ }
+
+ when (proxiedInput) {
+ is ProxiedInputModel.OnDrag -> {
+ val affectedShadeId = affectedShadeId(shadeConfig, proxiedInput.xFraction)
+ // This might be the start of a new drag gesture, let's update our
+ // application
+ // state to record that fact.
+ onUserInteractionStarted(
+ shadeId = affectedShadeId,
+ isProxied = true,
+ )
+ }
+ is ProxiedInputModel.OnTap -> {
+ // Tapping outside any shade collapses all shades. This code path is not hit
+ // for
+ // taps that happen _inside_ a shade as that input event is directly applied
+ // through the UI and is, hence, not a proxied input.
+ collapseAll()
+ }
+ else -> Unit
+ }
+
+ proxiedInput
+ }
+ .shareIn(
+ scope = applicationScope,
+ started = SharingStarted.Eagerly,
+ replay = 1,
+ )
+
+ /** Whether the shade with the given ID should be visible. */
+ fun isVisible(shadeId: ShadeId): Flow<Boolean> {
+ return repository.shadeConfig.map { shadeConfig -> shadeConfig.shadeIds.contains(shadeId) }
+ }
+
+ /** Whether direct user input is allowed on the shade with the given ID. */
+ fun isNonProxiedInputAllowed(shadeId: ShadeId): Flow<Boolean> {
+ return combine(
+ isForceCollapsed(shadeId),
+ repository.shadeInteraction,
+ ::Pair,
+ )
+ .map { (isForceCollapsed, shadeInteraction) ->
+ !isForceCollapsed && shadeInteraction?.isProxied != true
+ }
+ }
+
+ /** Whether the shade with the given ID is forced to collapse. */
+ fun isForceCollapsed(shadeId: ShadeId): Flow<Boolean> {
+ return combine(
+ repository.forceCollapseAll,
+ repository.shadeInteraction.map { it?.shadeId },
+ ::Pair,
+ )
+ .map { (collapseAll, userInteractedShadeIdOrNull) ->
+ val counterpartShadeIdOrNull =
+ when (shadeId) {
+ ShadeId.SINGLE -> null
+ ShadeId.LEFT -> ShadeId.RIGHT
+ ShadeId.RIGHT -> ShadeId.LEFT
+ }
+
+ when {
+ // If all shades have been told to collapse (by a tap outside, for example),
+ // then this shade is collapsed.
+ collapseAll -> true
+ // A shade that doesn't have a counterpart shade cannot be force-collapsed by
+ // interactions on the counterpart shade.
+ counterpartShadeIdOrNull == null -> false
+ // If the current user interaction is on the counterpart shade, then this shade
+ // should be force-collapsed.
+ else -> userInteractedShadeIdOrNull == counterpartShadeIdOrNull
+ }
+ }
+ }
+
+ /**
+ * Proxied input affecting the shade with the given ID. This is input coming from sources
+ * outside of system UI (for example, swiping down on the Launcher or from the status bar) or
+ * outside the UI of any shade (for example, the scrim that's shown behind the shades).
+ */
+ fun proxiedInput(shadeId: ShadeId): Flow<ProxiedInputModel?> {
+ return combine(
+ processedProxiedInput,
+ isForceCollapsed(shadeId).distinctUntilChanged(),
+ repository.shadeInteraction,
+ ::Triple,
+ )
+ .map { (proxiedInput, isForceCollapsed, shadeInteraction) ->
+ when {
+ // If the shade is force-collapsed, we ignored proxied input on it.
+ isForceCollapsed -> null
+ // If the proxied input does not belong to this shade, ignore it.
+ shadeInteraction?.shadeId != shadeId -> null
+ // If there is ongoing non-proxied user input on any shade, ignore the
+ // proxied input.
+ !shadeInteraction.isProxied -> null
+ // Otherwise, send the proxied input downstream.
+ else -> proxiedInput
+ }
+ }
+ .onEach { proxiedInput ->
+ // We use yield() to make sure that the following block of code happens _after_
+ // downstream collectors had a chance to process the proxied input. Otherwise, we
+ // might change our state to clear the current UserInteraction _before_ those
+ // downstream collectors get a chance to process the proxied input, which will make
+ // them ignore it (since they ignore proxied input when the current user interaction
+ // doesn't match their shade).
+ yield()
+
+ if (
+ proxiedInput is ProxiedInputModel.OnDragEnd ||
+ proxiedInput is ProxiedInputModel.OnDragCancel
+ ) {
+ onUserInteractionEnded(shadeId = shadeId, isProxied = true)
+ }
+ }
+ }
+
+ /** Sets the expansion amount for the shade with the given ID. */
+ fun setExpansion(
+ shadeId: ShadeId,
+ @FloatRange(from = 0.0, to = 1.0) expansion: Float,
+ ) {
+ repository.setExpansion(shadeId, expansion)
+ }
+
+ /** Collapses all shades. */
+ fun collapseAll() {
+ repository.setForceCollapseAll(true)
+ }
+
+ /**
+ * Notifies that a new non-proxied interaction may have started. Safe to call multiple times for
+ * the same interaction as it won't overwrite an existing interaction.
+ *
+ * Existing interactions can be cleared by calling [onUserInteractionEnded].
+ */
+ fun onUserInteractionStarted(shadeId: ShadeId) {
+ onUserInteractionStarted(
+ shadeId = shadeId,
+ isProxied = false,
+ )
+ }
+
+ /**
+ * Notifies that the current non-proxied interaction has ended.
+ *
+ * Safe to call multiple times, even if there's no current interaction or even if the current
+ * interaction doesn't belong to the given shade or is proxied as the code is a no-op unless
+ * there's a match between the parameters and the current interaction.
+ */
+ fun onUserInteractionEnded(
+ shadeId: ShadeId,
+ ) {
+ onUserInteractionEnded(
+ shadeId = shadeId,
+ isProxied = false,
+ )
+ }
+
+ fun sendProxiedInput(proxiedInput: ProxiedInputModel) {
+ inputProxy.onProxiedInput(proxiedInput)
+ }
+
+ /**
+ * Notifies that a new interaction may have started. Safe to call multiple times for the same
+ * interaction as it won't overwrite an existing interaction.
+ *
+ * Existing interactions can be cleared by calling [onUserInteractionEnded].
+ */
+ private fun onUserInteractionStarted(
+ shadeId: ShadeId,
+ isProxied: Boolean,
+ ) {
+ if (repository.shadeInteraction.value != null) {
+ return
+ }
+
+ repository.setShadeInteraction(
+ MultiShadeInteractionModel(
+ shadeId = shadeId,
+ isProxied = isProxied,
+ )
+ )
+ }
+
+ /**
+ * Notifies that the current interaction has ended.
+ *
+ * Safe to call multiple times, even if there's no current interaction or even if the current
+ * interaction doesn't belong to the given shade or [isProxied] value as the code is a no-op
+ * unless there's a match between the parameters and the current interaction.
+ */
+ private fun onUserInteractionEnded(
+ shadeId: ShadeId,
+ isProxied: Boolean,
+ ) {
+ repository.shadeInteraction.value?.let { (interactionShadeId, isInteractionProxied) ->
+ if (shadeId == interactionShadeId && isProxied == isInteractionProxied) {
+ repository.setShadeInteraction(null)
+ }
+ }
+ }
+
+ /**
+ * Returns the ID of the shade that's affected by user input at a given coordinate.
+ *
+ * @param config The shade configuration being used.
+ * @param xFraction The horizontal position of the user input as a fraction along the width of
+ * its container where `0` is all the way to the left and `1` is all the way to the right.
+ */
+ private fun affectedShadeId(
+ config: ShadeConfig,
+ @FloatRange(from = 0.0, to = 1.0) xFraction: Float,
+ ): ShadeId {
+ return if (config is ShadeConfig.DualShadeConfig) {
+ if (xFraction <= config.splitFraction) {
+ ShadeId.LEFT
+ } else {
+ ShadeId.RIGHT
+ }
+ } else {
+ ShadeId.SINGLE
+ }
+ }
+
+ /** Returns the list of flows of all the shades in the given configuration. */
+ private fun allShades(
+ config: ShadeConfig,
+ ): List<Flow<ShadeModel>> {
+ return config.shadeIds.map { shadeId -> repository.getShade(shadeId) }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ProxiedInputModel.kt b/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ProxiedInputModel.kt
new file mode 100644
index 000000000000..ee1dd65b867f
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ProxiedInputModel.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.systemui.multishade.shared.model
+
+import androidx.annotation.FloatRange
+
+/**
+ * Models a part of an ongoing proxied user input gesture.
+ *
+ * "Proxied" user input is coming through a proxy; typically from an external app or different UI.
+ * In other words: it's not user input that's occurring directly on the shade UI itself.
+ */
+sealed class ProxiedInputModel {
+ /** The user is dragging their pointer. */
+ data class OnDrag(
+ /**
+ * The relative position of the pointer as a fraction of its container width where `0` is
+ * all the way to the left and `1` is all the way to the right.
+ */
+ @FloatRange(from = 0.0, to = 1.0) val xFraction: Float,
+ /** The amount that the pointer was dragged, in pixels. */
+ val yDragAmountPx: Float,
+ ) : ProxiedInputModel()
+
+ /** The user finished dragging by lifting up their pointer. */
+ object OnDragEnd : ProxiedInputModel()
+
+ /**
+ * The drag gesture has been canceled. Usually because the pointer exited the draggable area.
+ */
+ object OnDragCancel : ProxiedInputModel()
+
+ /** The user has tapped (clicked). */
+ object OnTap : ProxiedInputModel()
+}
diff --git a/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ShadeConfig.kt b/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ShadeConfig.kt
new file mode 100644
index 000000000000..a4cd35c8a11a
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ShadeConfig.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.systemui.multishade.shared.model
+
+import androidx.annotation.FloatRange
+
+/** Enumerates the various possible configurations of the shade system. */
+sealed class ShadeConfig(
+
+ /** IDs of the shade(s) in this configuration. */
+ open val shadeIds: List<ShadeId>,
+
+ /**
+ * The amount that the user must swipe up when the shade is fully expanded to automatically
+ * collapse once the user lets go of the shade. If the user swipes less than this amount, the
+ * shade will automatically revert back to fully expanded once the user stops swiping.
+ */
+ @FloatRange(from = 0.0, to = 1.0) open val swipeCollapseThreshold: Float,
+
+ /**
+ * The amount that the user must swipe down when the shade is fully collapsed to automatically
+ * expand once the user lets go of the shade. If the user swipes less than this amount, the
+ * shade will automatically revert back to fully collapsed once the user stops swiping.
+ */
+ @FloatRange(from = 0.0, to = 1.0) open val swipeExpandThreshold: Float,
+) {
+
+ /** There is a single shade. */
+ data class SingleShadeConfig(
+ @FloatRange(from = 0.0, to = 1.0) override val swipeCollapseThreshold: Float,
+ @FloatRange(from = 0.0, to = 1.0) override val swipeExpandThreshold: Float,
+ ) :
+ ShadeConfig(
+ shadeIds = listOf(ShadeId.SINGLE),
+ swipeCollapseThreshold = swipeCollapseThreshold,
+ swipeExpandThreshold = swipeExpandThreshold,
+ )
+
+ /** There are two shades arranged side-by-side. */
+ data class DualShadeConfig(
+ /** Width of the left-hand side shade. */
+ val leftShadeWidthPx: Int,
+ /** Width of the right-hand side shade. */
+ val rightShadeWidthPx: Int,
+ @FloatRange(from = 0.0, to = 1.0) override val swipeCollapseThreshold: Float,
+ @FloatRange(from = 0.0, to = 1.0) override val swipeExpandThreshold: Float,
+ /**
+ * The position of the "split" between interaction areas for each of the shades, as a
+ * fraction of the width of the container.
+ *
+ * Interactions that occur on the start-side (left-hand side in left-to-right languages like
+ * English) affect the start-side shade. Interactions that occur on the end-side (right-hand
+ * side in left-to-right languages like English) affect the end-side shade.
+ */
+ @FloatRange(from = 0.0, to = 1.0) val splitFraction: Float,
+ /** Maximum opacity when the scrim that shows up behind the dual shades is fully visible. */
+ @FloatRange(from = 0.0, to = 1.0) val scrimAlpha: Float,
+ ) :
+ ShadeConfig(
+ shadeIds = listOf(ShadeId.LEFT, ShadeId.RIGHT),
+ swipeCollapseThreshold = swipeCollapseThreshold,
+ swipeExpandThreshold = swipeExpandThreshold,
+ )
+}
diff --git a/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ShadeId.kt b/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ShadeId.kt
new file mode 100644
index 000000000000..9e026576e842
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ShadeId.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.systemui.multishade.shared.model
+
+/** Enumerates all known shade IDs. */
+enum class ShadeId {
+ /** ID of the shade on the left in dual shade configurations. */
+ LEFT,
+ /** ID of the shade on the right in dual shade configurations. */
+ RIGHT,
+ /** ID of the single shade in single shade configurations. */
+ SINGLE,
+}
diff --git a/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ShadeModel.kt b/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ShadeModel.kt
new file mode 100644
index 000000000000..49ac64c58cb8
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ShadeModel.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.systemui.multishade.shared.model
+
+import androidx.annotation.FloatRange
+
+/** Models the current state of a shade. */
+data class ShadeModel(
+ val id: ShadeId,
+ @FloatRange(from = 0.0, to = 1.0) val expansion: Float = 0f,
+)
diff --git a/packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModel.kt b/packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModel.kt
new file mode 100644
index 000000000000..ce6ab977dea2
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModel.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.systemui.multishade.ui.viewmodel
+
+import com.android.systemui.multishade.domain.interactor.MultiShadeInteractor
+import com.android.systemui.multishade.shared.model.ProxiedInputModel
+import com.android.systemui.multishade.shared.model.ShadeConfig
+import com.android.systemui.multishade.shared.model.ShadeId
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+
+/** Models UI state for UI that supports multi (or single) shade. */
+@OptIn(ExperimentalCoroutinesApi::class)
+class MultiShadeViewModel(
+ viewModelScope: CoroutineScope,
+ private val interactor: MultiShadeInteractor,
+) {
+ /** Models UI state for the single shade. */
+ val singleShade =
+ ShadeViewModel(
+ viewModelScope,
+ ShadeId.SINGLE,
+ interactor,
+ )
+
+ /** Models UI state for the shade on the left-hand side. */
+ val leftShade =
+ ShadeViewModel(
+ viewModelScope,
+ ShadeId.LEFT,
+ interactor,
+ )
+
+ /** Models UI state for the shade on the right-hand side. */
+ val rightShade =
+ ShadeViewModel(
+ viewModelScope,
+ ShadeId.RIGHT,
+ interactor,
+ )
+
+ /** The amount of alpha that the scrim should have. This is a value between `0` and `1`. */
+ val scrimAlpha: StateFlow<Float> =
+ combine(
+ interactor.maxShadeExpansion,
+ interactor.shadeConfig
+ .map { it as? ShadeConfig.DualShadeConfig }
+ .map { dualShadeConfigOrNull -> dualShadeConfigOrNull?.scrimAlpha ?: 0f },
+ ::Pair,
+ )
+ .map { (anyShadeExpansion, scrimAlpha) ->
+ (anyShadeExpansion * scrimAlpha).coerceIn(0f, 1f)
+ }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = 0f,
+ )
+
+ /** Whether the scrim should accept touch events. */
+ val isScrimEnabled: StateFlow<Boolean> =
+ interactor.shadeConfig
+ .flatMapLatest { shadeConfig ->
+ when (shadeConfig) {
+ // In the dual shade configuration, the scrim is enabled when the expansion is
+ // greater than zero on any one of the shades.
+ is ShadeConfig.DualShadeConfig ->
+ interactor.maxShadeExpansion
+ .map { expansion -> expansion > 0 }
+ .distinctUntilChanged()
+ // No scrim in the single shade configuration.
+ is ShadeConfig.SingleShadeConfig -> flowOf(false)
+ }
+ }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = false,
+ )
+
+ /** Notifies that the scrim has been touched. */
+ fun onScrimTouched(proxiedInput: ProxiedInputModel) {
+ if (!isScrimEnabled.value) {
+ return
+ }
+
+ interactor.sendProxiedInput(proxiedInput)
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/ShadeViewModel.kt b/packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/ShadeViewModel.kt
new file mode 100644
index 000000000000..e828dbdc6c62
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/ShadeViewModel.kt
@@ -0,0 +1,150 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.systemui.multishade.ui.viewmodel
+
+import androidx.annotation.FloatRange
+import com.android.systemui.multishade.domain.interactor.MultiShadeInteractor
+import com.android.systemui.multishade.shared.model.ProxiedInputModel
+import com.android.systemui.multishade.shared.model.ShadeConfig
+import com.android.systemui.multishade.shared.model.ShadeId
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+
+/** Models UI state for a single shade. */
+class ShadeViewModel(
+ viewModelScope: CoroutineScope,
+ private val shadeId: ShadeId,
+ private val interactor: MultiShadeInteractor,
+) {
+ /** Whether the shade is visible. */
+ val isVisible: StateFlow<Boolean> =
+ interactor
+ .isVisible(shadeId)
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = false,
+ )
+
+ /** Whether swiping on the shade UI is currently enabled. */
+ val isSwipingEnabled: StateFlow<Boolean> =
+ interactor
+ .isNonProxiedInputAllowed(shadeId)
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = false,
+ )
+
+ /** Whether the shade must be collapsed immediately. */
+ val isForceCollapsed: Flow<Boolean> =
+ interactor.isForceCollapsed(shadeId).distinctUntilChanged()
+
+ /** The width of the shade. */
+ val width: StateFlow<Size> =
+ interactor.shadeConfig
+ .map { shadeWidth(it) }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = shadeWidth(interactor.shadeConfig.value),
+ )
+
+ /**
+ * The amount that the user must swipe up when the shade is fully expanded to automatically
+ * collapse once the user lets go of the shade. If the user swipes less than this amount, the
+ * shade will automatically revert back to fully expanded once the user stops swiping.
+ */
+ val swipeCollapseThreshold: StateFlow<Float> =
+ interactor.shadeConfig
+ .map { it.swipeCollapseThreshold }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = interactor.shadeConfig.value.swipeCollapseThreshold,
+ )
+
+ /**
+ * The amount that the user must swipe down when the shade is fully collapsed to automatically
+ * expand once the user lets go of the shade. If the user swipes less than this amount, the
+ * shade will automatically revert back to fully collapsed once the user stops swiping.
+ */
+ val swipeExpandThreshold: StateFlow<Float> =
+ interactor.shadeConfig
+ .map { it.swipeExpandThreshold }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = interactor.shadeConfig.value.swipeExpandThreshold,
+ )
+
+ /**
+ * Proxied input affecting the shade. This is input coming from sources outside of system UI
+ * (for example, swiping down on the Launcher or from the status bar) or outside the UI of any
+ * shade (for example, the scrim that's shown behind the shades).
+ */
+ val proxiedInput: Flow<ProxiedInputModel?> =
+ interactor
+ .proxiedInput(shadeId)
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = null,
+ )
+
+ /** Notifies that the expansion amount for the shade has changed. */
+ fun onExpansionChanged(
+ expansion: Float,
+ ) {
+ interactor.setExpansion(shadeId, expansion.coerceIn(0f, 1f))
+ }
+
+ /** Notifies that a drag gesture has started. */
+ fun onDragStarted() {
+ interactor.onUserInteractionStarted(shadeId)
+ }
+
+ /** Notifies that a drag gesture has ended. */
+ fun onDragEnded() {
+ interactor.onUserInteractionEnded(shadeId = shadeId)
+ }
+
+ private fun shadeWidth(shadeConfig: ShadeConfig): Size {
+ return when (shadeId) {
+ ShadeId.LEFT ->
+ Size.Pixels((shadeConfig as? ShadeConfig.DualShadeConfig)?.leftShadeWidthPx ?: 0)
+ ShadeId.RIGHT ->
+ Size.Pixels((shadeConfig as? ShadeConfig.DualShadeConfig)?.rightShadeWidthPx ?: 0)
+ ShadeId.SINGLE -> Size.Fraction(1f)
+ }
+ }
+
+ sealed class Size {
+ data class Fraction(
+ @FloatRange(from = 0.0, to = 1.0) val fraction: Float,
+ ) : Size()
+ data class Pixels(
+ val pixels: Int,
+ ) : Size()
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/multishade/data/repository/MultiShadeRepositoryTest.kt b/packages/SystemUI/tests/src/com/android/systemui/multishade/data/repository/MultiShadeRepositoryTest.kt
new file mode 100644
index 000000000000..ceacaf9557ca
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/multishade/data/repository/MultiShadeRepositoryTest.kt
@@ -0,0 +1,191 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.systemui.multishade.data.repository
+
+import android.content.Context
+import androidx.test.filters.SmallTest
+import com.android.systemui.R
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.multishade.data.model.MultiShadeInteractionModel
+import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy
+import com.android.systemui.multishade.shared.model.ProxiedInputModel
+import com.android.systemui.multishade.shared.model.ShadeConfig
+import com.android.systemui.multishade.shared.model.ShadeId
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@SmallTest
+@RunWith(JUnit4::class)
+class MultiShadeRepositoryTest : SysuiTestCase() {
+
+ private lateinit var inputProxy: MultiShadeInputProxy
+
+ @Before
+ fun setUp() {
+ inputProxy = MultiShadeInputProxy()
+ }
+
+ @Test
+ fun proxiedInput() = runTest {
+ val underTest = create()
+ val latest: ProxiedInputModel? by collectLastValue(underTest.proxiedInput)
+
+ assertWithMessage("proxiedInput should start with null").that(latest).isNull()
+
+ inputProxy.onProxiedInput(ProxiedInputModel.OnTap)
+ assertThat(latest).isEqualTo(ProxiedInputModel.OnTap)
+
+ inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0f, 100f))
+ assertThat(latest).isEqualTo(ProxiedInputModel.OnDrag(0f, 100f))
+
+ inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0f, 120f))
+ assertThat(latest).isEqualTo(ProxiedInputModel.OnDrag(0f, 120f))
+
+ inputProxy.onProxiedInput(ProxiedInputModel.OnDragEnd)
+ assertThat(latest).isEqualTo(ProxiedInputModel.OnDragEnd)
+ }
+
+ @Test
+ fun shadeConfig_dualShadeEnabled() = runTest {
+ overrideResource(R.bool.dual_shade_enabled, true)
+ val underTest = create()
+ val shadeConfig: ShadeConfig? by collectLastValue(underTest.shadeConfig)
+
+ assertThat(shadeConfig).isInstanceOf(ShadeConfig.DualShadeConfig::class.java)
+ }
+
+ @Test
+ fun shadeConfig_dualShadeNotEnabled() = runTest {
+ overrideResource(R.bool.dual_shade_enabled, false)
+ val underTest = create()
+ val shadeConfig: ShadeConfig? by collectLastValue(underTest.shadeConfig)
+
+ assertThat(shadeConfig).isInstanceOf(ShadeConfig.SingleShadeConfig::class.java)
+ }
+
+ @Test
+ fun forceCollapseAll() = runTest {
+ val underTest = create()
+ val forceCollapseAll: Boolean? by collectLastValue(underTest.forceCollapseAll)
+
+ assertWithMessage("forceCollapseAll should start as false!")
+ .that(forceCollapseAll)
+ .isFalse()
+
+ underTest.setForceCollapseAll(true)
+ assertThat(forceCollapseAll).isTrue()
+
+ underTest.setForceCollapseAll(false)
+ assertThat(forceCollapseAll).isFalse()
+ }
+
+ @Test
+ fun shadeInteraction() = runTest {
+ val underTest = create()
+ val shadeInteraction: MultiShadeInteractionModel? by
+ collectLastValue(underTest.shadeInteraction)
+
+ assertWithMessage("shadeInteraction should start as null!").that(shadeInteraction).isNull()
+
+ underTest.setShadeInteraction(
+ MultiShadeInteractionModel(shadeId = ShadeId.LEFT, isProxied = false)
+ )
+ assertThat(shadeInteraction)
+ .isEqualTo(MultiShadeInteractionModel(shadeId = ShadeId.LEFT, isProxied = false))
+
+ underTest.setShadeInteraction(
+ MultiShadeInteractionModel(shadeId = ShadeId.RIGHT, isProxied = true)
+ )
+ assertThat(shadeInteraction)
+ .isEqualTo(MultiShadeInteractionModel(shadeId = ShadeId.RIGHT, isProxied = true))
+
+ underTest.setShadeInteraction(null)
+ assertThat(shadeInteraction).isNull()
+ }
+
+ @Test
+ fun expansion() = runTest {
+ val underTest = create()
+ val leftExpansion: Float? by
+ collectLastValue(underTest.getShade(ShadeId.LEFT).map { it.expansion })
+ val rightExpansion: Float? by
+ collectLastValue(underTest.getShade(ShadeId.RIGHT).map { it.expansion })
+ val singleExpansion: Float? by
+ collectLastValue(underTest.getShade(ShadeId.SINGLE).map { it.expansion })
+
+ assertWithMessage("expansion should start as 0!").that(leftExpansion).isZero()
+ assertWithMessage("expansion should start as 0!").that(rightExpansion).isZero()
+ assertWithMessage("expansion should start as 0!").that(singleExpansion).isZero()
+
+ underTest.setExpansion(
+ shadeId = ShadeId.LEFT,
+ 0.4f,
+ )
+ assertThat(leftExpansion).isEqualTo(0.4f)
+ assertThat(rightExpansion).isEqualTo(0f)
+ assertThat(singleExpansion).isEqualTo(0f)
+
+ underTest.setExpansion(
+ shadeId = ShadeId.RIGHT,
+ 0.73f,
+ )
+ assertThat(leftExpansion).isEqualTo(0.4f)
+ assertThat(rightExpansion).isEqualTo(0.73f)
+ assertThat(singleExpansion).isEqualTo(0f)
+
+ underTest.setExpansion(
+ shadeId = ShadeId.LEFT,
+ 0.1f,
+ )
+ underTest.setExpansion(
+ shadeId = ShadeId.SINGLE,
+ 0.88f,
+ )
+ assertThat(leftExpansion).isEqualTo(0.1f)
+ assertThat(rightExpansion).isEqualTo(0.73f)
+ assertThat(singleExpansion).isEqualTo(0.88f)
+ }
+
+ private fun create(): MultiShadeRepository {
+ return create(
+ context = context,
+ inputProxy = inputProxy,
+ )
+ }
+
+ companion object {
+ fun create(
+ context: Context,
+ inputProxy: MultiShadeInputProxy,
+ ): MultiShadeRepository {
+ return MultiShadeRepository(
+ applicationContext = context,
+ inputProxy = inputProxy,
+ )
+ }
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractorTest.kt
new file mode 100644
index 000000000000..415e68f6013d
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractorTest.kt
@@ -0,0 +1,301 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.systemui.multishade.domain.interactor
+
+import android.content.Context
+import androidx.test.filters.SmallTest
+import com.android.systemui.R
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy
+import com.android.systemui.multishade.data.repository.MultiShadeRepositoryTest
+import com.android.systemui.multishade.shared.model.ProxiedInputModel
+import com.android.systemui.multishade.shared.model.ShadeId
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@SmallTest
+@RunWith(JUnit4::class)
+class MultiShadeInteractorTest : SysuiTestCase() {
+
+ private lateinit var testScope: TestScope
+ private lateinit var inputProxy: MultiShadeInputProxy
+
+ @Before
+ fun setUp() {
+ testScope = TestScope()
+ inputProxy = MultiShadeInputProxy()
+ }
+
+ @Test
+ fun maxShadeExpansion() =
+ testScope.runTest {
+ val underTest = create()
+ val maxShadeExpansion: Float? by collectLastValue(underTest.maxShadeExpansion)
+ assertWithMessage("maxShadeExpansion must start with 0.0!")
+ .that(maxShadeExpansion)
+ .isEqualTo(0f)
+
+ underTest.setExpansion(shadeId = ShadeId.LEFT, expansion = 0.441f)
+ assertThat(maxShadeExpansion).isEqualTo(0.441f)
+
+ underTest.setExpansion(shadeId = ShadeId.RIGHT, expansion = 0.442f)
+ assertThat(maxShadeExpansion).isEqualTo(0.442f)
+
+ underTest.setExpansion(shadeId = ShadeId.RIGHT, expansion = 0f)
+ assertThat(maxShadeExpansion).isEqualTo(0.441f)
+
+ underTest.setExpansion(shadeId = ShadeId.LEFT, expansion = 0f)
+ assertThat(maxShadeExpansion).isEqualTo(0f)
+ }
+
+ @Test
+ fun isVisible_dualShadeConfig() =
+ testScope.runTest {
+ overrideResource(R.bool.dual_shade_enabled, true)
+ val underTest = create()
+ val isLeftShadeVisible: Boolean? by collectLastValue(underTest.isVisible(ShadeId.LEFT))
+ val isRightShadeVisible: Boolean? by
+ collectLastValue(underTest.isVisible(ShadeId.RIGHT))
+ val isSingleShadeVisible: Boolean? by
+ collectLastValue(underTest.isVisible(ShadeId.SINGLE))
+
+ assertThat(isLeftShadeVisible).isTrue()
+ assertThat(isRightShadeVisible).isTrue()
+ assertThat(isSingleShadeVisible).isFalse()
+ }
+
+ @Test
+ fun isVisible_singleShadeConfig() =
+ testScope.runTest {
+ overrideResource(R.bool.dual_shade_enabled, false)
+ val underTest = create()
+ val isLeftShadeVisible: Boolean? by collectLastValue(underTest.isVisible(ShadeId.LEFT))
+ val isRightShadeVisible: Boolean? by
+ collectLastValue(underTest.isVisible(ShadeId.RIGHT))
+ val isSingleShadeVisible: Boolean? by
+ collectLastValue(underTest.isVisible(ShadeId.SINGLE))
+
+ assertThat(isLeftShadeVisible).isFalse()
+ assertThat(isRightShadeVisible).isFalse()
+ assertThat(isSingleShadeVisible).isTrue()
+ }
+
+ @Test
+ fun isNonProxiedInputAllowed() =
+ testScope.runTest {
+ val underTest = create()
+ val isLeftShadeNonProxiedInputAllowed: Boolean? by
+ collectLastValue(underTest.isNonProxiedInputAllowed(ShadeId.LEFT))
+ assertWithMessage("isNonProxiedInputAllowed should start as true!")
+ .that(isLeftShadeNonProxiedInputAllowed)
+ .isTrue()
+
+ // Need to collect proxied input so the flows become hot as the gesture cancelation code
+ // logic sits in side the proxiedInput flow for each shade.
+ collectLastValue(underTest.proxiedInput(ShadeId.LEFT))
+ collectLastValue(underTest.proxiedInput(ShadeId.RIGHT))
+
+ // Starting a proxied interaction on the LEFT shade disallows non-proxied interaction on
+ // the
+ // same shade.
+ inputProxy.onProxiedInput(
+ ProxiedInputModel.OnDrag(xFraction = 0f, yDragAmountPx = 123f)
+ )
+ assertThat(isLeftShadeNonProxiedInputAllowed).isFalse()
+
+ // Registering the end of the proxied interaction re-allows it.
+ inputProxy.onProxiedInput(ProxiedInputModel.OnDragEnd)
+ assertThat(isLeftShadeNonProxiedInputAllowed).isTrue()
+
+ // Starting a proxied interaction on the RIGHT shade force-collapses the LEFT shade,
+ // disallowing non-proxied input on the LEFT shade.
+ inputProxy.onProxiedInput(
+ ProxiedInputModel.OnDrag(xFraction = 1f, yDragAmountPx = 123f)
+ )
+ assertThat(isLeftShadeNonProxiedInputAllowed).isFalse()
+
+ // Registering the end of the interaction on the RIGHT shade re-allows it.
+ inputProxy.onProxiedInput(ProxiedInputModel.OnDragEnd)
+ assertThat(isLeftShadeNonProxiedInputAllowed).isTrue()
+ }
+
+ @Test
+ fun isForceCollapsed_whenOtherShadeInteractionUnderway() =
+ testScope.runTest {
+ val underTest = create()
+ val isLeftShadeForceCollapsed: Boolean? by
+ collectLastValue(underTest.isForceCollapsed(ShadeId.LEFT))
+ val isRightShadeForceCollapsed: Boolean? by
+ collectLastValue(underTest.isForceCollapsed(ShadeId.RIGHT))
+ val isSingleShadeForceCollapsed: Boolean? by
+ collectLastValue(underTest.isForceCollapsed(ShadeId.SINGLE))
+
+ assertWithMessage("isForceCollapsed should start as false!")
+ .that(isLeftShadeForceCollapsed)
+ .isFalse()
+ assertWithMessage("isForceCollapsed should start as false!")
+ .that(isRightShadeForceCollapsed)
+ .isFalse()
+ assertWithMessage("isForceCollapsed should start as false!")
+ .that(isSingleShadeForceCollapsed)
+ .isFalse()
+
+ // Registering the start of an interaction on the RIGHT shade force-collapses the LEFT
+ // shade.
+ underTest.onUserInteractionStarted(ShadeId.RIGHT)
+ assertThat(isLeftShadeForceCollapsed).isTrue()
+ assertThat(isRightShadeForceCollapsed).isFalse()
+ assertThat(isSingleShadeForceCollapsed).isFalse()
+
+ // Registering the end of the interaction on the RIGHT shade re-allows it.
+ underTest.onUserInteractionEnded(ShadeId.RIGHT)
+ assertThat(isLeftShadeForceCollapsed).isFalse()
+ assertThat(isRightShadeForceCollapsed).isFalse()
+ assertThat(isSingleShadeForceCollapsed).isFalse()
+
+ // Registering the start of an interaction on the LEFT shade force-collapses the RIGHT
+ // shade.
+ underTest.onUserInteractionStarted(ShadeId.LEFT)
+ assertThat(isLeftShadeForceCollapsed).isFalse()
+ assertThat(isRightShadeForceCollapsed).isTrue()
+ assertThat(isSingleShadeForceCollapsed).isFalse()
+
+ // Registering the end of the interaction on the LEFT shade re-allows it.
+ underTest.onUserInteractionEnded(ShadeId.LEFT)
+ assertThat(isLeftShadeForceCollapsed).isFalse()
+ assertThat(isRightShadeForceCollapsed).isFalse()
+ assertThat(isSingleShadeForceCollapsed).isFalse()
+ }
+
+ @Test
+ fun collapseAll() =
+ testScope.runTest {
+ val underTest = create()
+ val isLeftShadeForceCollapsed: Boolean? by
+ collectLastValue(underTest.isForceCollapsed(ShadeId.LEFT))
+ val isRightShadeForceCollapsed: Boolean? by
+ collectLastValue(underTest.isForceCollapsed(ShadeId.RIGHT))
+ val isSingleShadeForceCollapsed: Boolean? by
+ collectLastValue(underTest.isForceCollapsed(ShadeId.SINGLE))
+
+ assertWithMessage("isForceCollapsed should start as false!")
+ .that(isLeftShadeForceCollapsed)
+ .isFalse()
+ assertWithMessage("isForceCollapsed should start as false!")
+ .that(isRightShadeForceCollapsed)
+ .isFalse()
+ assertWithMessage("isForceCollapsed should start as false!")
+ .that(isSingleShadeForceCollapsed)
+ .isFalse()
+
+ underTest.collapseAll()
+ assertThat(isLeftShadeForceCollapsed).isTrue()
+ assertThat(isRightShadeForceCollapsed).isTrue()
+ assertThat(isSingleShadeForceCollapsed).isTrue()
+
+ // Receiving proxied input on that's not a tap gesture, on the left-hand side resets the
+ // "collapse all". Note that now the RIGHT shade is force-collapsed because we're
+ // interacting with the LEFT shade.
+ inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0f, 0f))
+ assertThat(isLeftShadeForceCollapsed).isFalse()
+ assertThat(isRightShadeForceCollapsed).isTrue()
+ assertThat(isSingleShadeForceCollapsed).isFalse()
+ }
+
+ @Test
+ fun onTapOutside_collapsesAll() =
+ testScope.runTest {
+ val underTest = create()
+ val isLeftShadeForceCollapsed: Boolean? by
+ collectLastValue(underTest.isForceCollapsed(ShadeId.LEFT))
+ val isRightShadeForceCollapsed: Boolean? by
+ collectLastValue(underTest.isForceCollapsed(ShadeId.RIGHT))
+ val isSingleShadeForceCollapsed: Boolean? by
+ collectLastValue(underTest.isForceCollapsed(ShadeId.SINGLE))
+
+ assertWithMessage("isForceCollapsed should start as false!")
+ .that(isLeftShadeForceCollapsed)
+ .isFalse()
+ assertWithMessage("isForceCollapsed should start as false!")
+ .that(isRightShadeForceCollapsed)
+ .isFalse()
+ assertWithMessage("isForceCollapsed should start as false!")
+ .that(isSingleShadeForceCollapsed)
+ .isFalse()
+
+ inputProxy.onProxiedInput(ProxiedInputModel.OnTap)
+ assertThat(isLeftShadeForceCollapsed).isTrue()
+ assertThat(isRightShadeForceCollapsed).isTrue()
+ assertThat(isSingleShadeForceCollapsed).isTrue()
+ }
+
+ @Test
+ fun proxiedInput_ignoredWhileNonProxiedGestureUnderway() =
+ testScope.runTest {
+ val underTest = create()
+ val proxiedInput: ProxiedInputModel? by
+ collectLastValue(underTest.proxiedInput(ShadeId.RIGHT))
+ underTest.onUserInteractionStarted(shadeId = ShadeId.RIGHT)
+
+ inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0.9f, 100f))
+ assertThat(proxiedInput).isNull()
+
+ inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0.8f, 110f))
+ assertThat(proxiedInput).isNull()
+
+ underTest.onUserInteractionEnded(shadeId = ShadeId.RIGHT)
+
+ inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0.9f, 100f))
+ assertThat(proxiedInput).isNotNull()
+ }
+
+ private fun create(): MultiShadeInteractor {
+ return create(
+ testScope = testScope,
+ context = context,
+ inputProxy = inputProxy,
+ )
+ }
+
+ companion object {
+ fun create(
+ testScope: TestScope,
+ context: Context,
+ inputProxy: MultiShadeInputProxy,
+ ): MultiShadeInteractor {
+ return MultiShadeInteractor(
+ applicationScope = testScope.backgroundScope,
+ repository =
+ MultiShadeRepositoryTest.create(
+ context = context,
+ inputProxy = inputProxy,
+ ),
+ inputProxy = inputProxy,
+ )
+ }
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModelTest.kt
new file mode 100644
index 000000000000..0484515e38bd
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModelTest.kt
@@ -0,0 +1,127 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.systemui.multishade.ui.viewmodel
+
+import androidx.test.filters.SmallTest
+import com.android.systemui.R
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy
+import com.android.systemui.multishade.domain.interactor.MultiShadeInteractorTest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@SmallTest
+@RunWith(JUnit4::class)
+class MultiShadeViewModelTest : SysuiTestCase() {
+
+ private lateinit var testScope: TestScope
+ private lateinit var inputProxy: MultiShadeInputProxy
+
+ @Before
+ fun setUp() {
+ testScope = TestScope()
+ inputProxy = MultiShadeInputProxy()
+ }
+
+ @Test
+ fun scrim_whenDualShadeCollapsed() =
+ testScope.runTest {
+ val alpha = 0.5f
+ overrideResource(R.dimen.dual_shade_scrim_alpha, alpha)
+ overrideResource(R.bool.dual_shade_enabled, true)
+
+ val underTest = create()
+ val scrimAlpha: Float? by collectLastValue(underTest.scrimAlpha)
+ val isScrimEnabled: Boolean? by collectLastValue(underTest.isScrimEnabled)
+
+ assertThat(scrimAlpha).isZero()
+ assertThat(isScrimEnabled).isFalse()
+ }
+
+ @Test
+ fun scrim_whenDualShadeExpanded() =
+ testScope.runTest {
+ val alpha = 0.5f
+ overrideResource(R.dimen.dual_shade_scrim_alpha, alpha)
+ overrideResource(R.bool.dual_shade_enabled, true)
+ val underTest = create()
+ val scrimAlpha: Float? by collectLastValue(underTest.scrimAlpha)
+ val isScrimEnabled: Boolean? by collectLastValue(underTest.isScrimEnabled)
+ assertThat(scrimAlpha).isZero()
+ assertThat(isScrimEnabled).isFalse()
+
+ underTest.leftShade.onExpansionChanged(0.5f)
+ assertThat(scrimAlpha).isEqualTo(alpha * 0.5f)
+ assertThat(isScrimEnabled).isTrue()
+
+ underTest.rightShade.onExpansionChanged(1f)
+ assertThat(scrimAlpha).isEqualTo(alpha * 1f)
+ assertThat(isScrimEnabled).isTrue()
+ }
+
+ @Test
+ fun scrim_whenSingleShadeCollapsed() =
+ testScope.runTest {
+ val alpha = 0.5f
+ overrideResource(R.dimen.dual_shade_scrim_alpha, alpha)
+ overrideResource(R.bool.dual_shade_enabled, false)
+
+ val underTest = create()
+ val scrimAlpha: Float? by collectLastValue(underTest.scrimAlpha)
+ val isScrimEnabled: Boolean? by collectLastValue(underTest.isScrimEnabled)
+
+ assertThat(scrimAlpha).isZero()
+ assertThat(isScrimEnabled).isFalse()
+ }
+
+ @Test
+ fun scrim_whenSingleShadeExpanded() =
+ testScope.runTest {
+ val alpha = 0.5f
+ overrideResource(R.dimen.dual_shade_scrim_alpha, alpha)
+ overrideResource(R.bool.dual_shade_enabled, false)
+ val underTest = create()
+ val scrimAlpha: Float? by collectLastValue(underTest.scrimAlpha)
+ val isScrimEnabled: Boolean? by collectLastValue(underTest.isScrimEnabled)
+
+ underTest.singleShade.onExpansionChanged(0.95f)
+
+ assertThat(scrimAlpha).isZero()
+ assertThat(isScrimEnabled).isFalse()
+ }
+
+ private fun create(): MultiShadeViewModel {
+ return MultiShadeViewModel(
+ viewModelScope = testScope.backgroundScope,
+ interactor =
+ MultiShadeInteractorTest.create(
+ testScope = testScope,
+ context = context,
+ inputProxy = inputProxy,
+ ),
+ )
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/multishade/ui/viewmodel/ShadeViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/multishade/ui/viewmodel/ShadeViewModelTest.kt
new file mode 100644
index 000000000000..e32aac596e5b
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/multishade/ui/viewmodel/ShadeViewModelTest.kt
@@ -0,0 +1,226 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.systemui.multishade.ui.viewmodel
+
+import androidx.test.filters.SmallTest
+import com.android.systemui.R
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy
+import com.android.systemui.multishade.domain.interactor.MultiShadeInteractor
+import com.android.systemui.multishade.domain.interactor.MultiShadeInteractorTest
+import com.android.systemui.multishade.shared.model.ProxiedInputModel
+import com.android.systemui.multishade.shared.model.ShadeId
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@SmallTest
+@RunWith(JUnit4::class)
+class ShadeViewModelTest : SysuiTestCase() {
+
+ private lateinit var testScope: TestScope
+ private lateinit var inputProxy: MultiShadeInputProxy
+ private var interactor: MultiShadeInteractor? = null
+
+ @Before
+ fun setUp() {
+ testScope = TestScope()
+ inputProxy = MultiShadeInputProxy()
+ }
+
+ @Test
+ fun isVisible_dualShadeConfig() =
+ testScope.runTest {
+ overrideResource(R.bool.dual_shade_enabled, true)
+ val isLeftShadeVisible: Boolean? by collectLastValue(create(ShadeId.LEFT).isVisible)
+ val isRightShadeVisible: Boolean? by collectLastValue(create(ShadeId.RIGHT).isVisible)
+ val isSingleShadeVisible: Boolean? by collectLastValue(create(ShadeId.SINGLE).isVisible)
+
+ assertThat(isLeftShadeVisible).isTrue()
+ assertThat(isRightShadeVisible).isTrue()
+ assertThat(isSingleShadeVisible).isFalse()
+ }
+
+ @Test
+ fun isVisible_singleShadeConfig() =
+ testScope.runTest {
+ overrideResource(R.bool.dual_shade_enabled, false)
+ val isLeftShadeVisible: Boolean? by collectLastValue(create(ShadeId.LEFT).isVisible)
+ val isRightShadeVisible: Boolean? by collectLastValue(create(ShadeId.RIGHT).isVisible)
+ val isSingleShadeVisible: Boolean? by collectLastValue(create(ShadeId.SINGLE).isVisible)
+
+ assertThat(isLeftShadeVisible).isFalse()
+ assertThat(isRightShadeVisible).isFalse()
+ assertThat(isSingleShadeVisible).isTrue()
+ }
+
+ @Test
+ fun isSwipingEnabled() =
+ testScope.runTest {
+ val underTest = create(ShadeId.LEFT)
+ val isSwipingEnabled: Boolean? by collectLastValue(underTest.isSwipingEnabled)
+ assertWithMessage("isSwipingEnabled should start as true!")
+ .that(isSwipingEnabled)
+ .isTrue()
+
+ // Need to collect proxied input so the flows become hot as the gesture cancelation code
+ // logic sits in side the proxiedInput flow for each shade.
+ collectLastValue(underTest.proxiedInput)
+ collectLastValue(create(ShadeId.RIGHT).proxiedInput)
+
+ // Starting a proxied interaction on the LEFT shade disallows non-proxied interaction on
+ // the
+ // same shade.
+ inputProxy.onProxiedInput(
+ ProxiedInputModel.OnDrag(xFraction = 0f, yDragAmountPx = 123f)
+ )
+ assertThat(isSwipingEnabled).isFalse()
+
+ // Registering the end of the proxied interaction re-allows it.
+ inputProxy.onProxiedInput(ProxiedInputModel.OnDragEnd)
+ assertThat(isSwipingEnabled).isTrue()
+
+ // Starting a proxied interaction on the RIGHT shade force-collapses the LEFT shade,
+ // disallowing non-proxied input on the LEFT shade.
+ inputProxy.onProxiedInput(
+ ProxiedInputModel.OnDrag(xFraction = 1f, yDragAmountPx = 123f)
+ )
+ assertThat(isSwipingEnabled).isFalse()
+
+ // Registering the end of the interaction on the RIGHT shade re-allows it.
+ inputProxy.onProxiedInput(ProxiedInputModel.OnDragEnd)
+ assertThat(isSwipingEnabled).isTrue()
+ }
+
+ @Test
+ fun isForceCollapsed_whenOtherShadeInteractionUnderway() =
+ testScope.runTest {
+ val leftShade = create(ShadeId.LEFT)
+ val rightShade = create(ShadeId.RIGHT)
+ val isLeftShadeForceCollapsed: Boolean? by collectLastValue(leftShade.isForceCollapsed)
+ val isRightShadeForceCollapsed: Boolean? by
+ collectLastValue(rightShade.isForceCollapsed)
+ val isSingleShadeForceCollapsed: Boolean? by
+ collectLastValue(create(ShadeId.SINGLE).isForceCollapsed)
+
+ assertWithMessage("isForceCollapsed should start as false!")
+ .that(isLeftShadeForceCollapsed)
+ .isFalse()
+ assertWithMessage("isForceCollapsed should start as false!")
+ .that(isRightShadeForceCollapsed)
+ .isFalse()
+ assertWithMessage("isForceCollapsed should start as false!")
+ .that(isSingleShadeForceCollapsed)
+ .isFalse()
+
+ // Registering the start of an interaction on the RIGHT shade force-collapses the LEFT
+ // shade.
+ rightShade.onDragStarted()
+ assertThat(isLeftShadeForceCollapsed).isTrue()
+ assertThat(isRightShadeForceCollapsed).isFalse()
+ assertThat(isSingleShadeForceCollapsed).isFalse()
+
+ // Registering the end of the interaction on the RIGHT shade re-allows it.
+ rightShade.onDragEnded()
+ assertThat(isLeftShadeForceCollapsed).isFalse()
+ assertThat(isRightShadeForceCollapsed).isFalse()
+ assertThat(isSingleShadeForceCollapsed).isFalse()
+
+ // Registering the start of an interaction on the LEFT shade force-collapses the RIGHT
+ // shade.
+ leftShade.onDragStarted()
+ assertThat(isLeftShadeForceCollapsed).isFalse()
+ assertThat(isRightShadeForceCollapsed).isTrue()
+ assertThat(isSingleShadeForceCollapsed).isFalse()
+
+ // Registering the end of the interaction on the LEFT shade re-allows it.
+ leftShade.onDragEnded()
+ assertThat(isLeftShadeForceCollapsed).isFalse()
+ assertThat(isRightShadeForceCollapsed).isFalse()
+ assertThat(isSingleShadeForceCollapsed).isFalse()
+ }
+
+ @Test
+ fun onTapOutside_collapsesAll() =
+ testScope.runTest {
+ val isLeftShadeForceCollapsed: Boolean? by
+ collectLastValue(create(ShadeId.LEFT).isForceCollapsed)
+ val isRightShadeForceCollapsed: Boolean? by
+ collectLastValue(create(ShadeId.RIGHT).isForceCollapsed)
+ val isSingleShadeForceCollapsed: Boolean? by
+ collectLastValue(create(ShadeId.SINGLE).isForceCollapsed)
+
+ assertWithMessage("isForceCollapsed should start as false!")
+ .that(isLeftShadeForceCollapsed)
+ .isFalse()
+ assertWithMessage("isForceCollapsed should start as false!")
+ .that(isRightShadeForceCollapsed)
+ .isFalse()
+ assertWithMessage("isForceCollapsed should start as false!")
+ .that(isSingleShadeForceCollapsed)
+ .isFalse()
+
+ inputProxy.onProxiedInput(ProxiedInputModel.OnTap)
+ assertThat(isLeftShadeForceCollapsed).isTrue()
+ assertThat(isRightShadeForceCollapsed).isTrue()
+ assertThat(isSingleShadeForceCollapsed).isTrue()
+ }
+
+ @Test
+ fun proxiedInput_ignoredWhileNonProxiedGestureUnderway() =
+ testScope.runTest {
+ val underTest = create(ShadeId.RIGHT)
+ val proxiedInput: ProxiedInputModel? by collectLastValue(underTest.proxiedInput)
+ underTest.onDragStarted()
+
+ inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0.9f, 100f))
+ assertThat(proxiedInput).isNull()
+
+ inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0.8f, 110f))
+ assertThat(proxiedInput).isNull()
+
+ underTest.onDragEnded()
+
+ inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0.9f, 100f))
+ assertThat(proxiedInput).isNotNull()
+ }
+
+ private fun create(
+ shadeId: ShadeId,
+ ): ShadeViewModel {
+ return ShadeViewModel(
+ viewModelScope = testScope.backgroundScope,
+ shadeId = shadeId,
+ interactor = interactor
+ ?: MultiShadeInteractorTest.create(
+ testScope = testScope,
+ context = context,
+ inputProxy = inputProxy,
+ )
+ .also { interactor = it },
+ )
+ }
+}