diff --git a/packages/SystemUI/res/values/config.xml b/packages/SystemUI/res/values/config.xml index 311990c45d43c..e5cd0c55027bd 100644 --- a/packages/SystemUI/res/values/config.xml +++ b/packages/SystemUI/res/values/config.xml @@ -844,4 +844,44 @@ true + + + + true + + 0.67 + + 436dp + + 436dp + + #D9D9D9 + + 0.1 + + 0.5 + + 0.5 + 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 0000000000000..c48028c31cf0b --- /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 0000000000000..86f0c0d15b55b --- /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( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val proxiedInput: Flow = _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 0000000000000..1172030127575 --- /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 = 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 = + 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 = _forceCollapseAll.asStateFlow() + + private val _shadeInteraction = MutableStateFlow(null) + /** The current shade interaction or `null` if no shade is interacted with currently. */ + val shadeInteraction: StateFlow = _shadeInteraction.asStateFlow() + + private val stateByShade = mutableMapOf>() + + /** The model for the shade with the given ID. */ + fun getShade( + shadeId: ShadeId, + ): StateFlow { + 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 { + 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/shared/model/ProxiedInputModel.kt b/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ProxiedInputModel.kt new file mode 100644 index 0000000000000..ee1dd65b867f6 --- /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 0000000000000..a4cd35c8a11ab --- /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, + + /** + * 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 0000000000000..9e026576e8427 --- /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 0000000000000..49ac64c58cb83 --- /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/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 0000000000000..ceacaf9557ca4 --- /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, + ) + } + } +}