Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 29556424 authored by Wes Okuhara's avatar Wes Okuhara
Browse files

Screen Capture: Setup ViewModel and State

Basic setup for the ScreenCaptureViewModel which holds the state for the
pre-capture UI. The state for the toolbar radio buttons is propagated
down to the composables. Also, the RadioButtonGroup API was updated to
make it easier to configure the click action for each button.

Bug: 422855266
Test: atest ScreenCaptureViewModelTest
Flag: com.android.systemui.desktop_screen_capture
Change-Id: I2ec944f243e5e2eb45c6738a6a69a2e12e8f55ab
parent 92b59bc5
Loading
Loading
Loading
Loading
+31 −13
Original line number Diff line number Diff line
@@ -29,11 +29,18 @@ import androidx.compose.ui.unit.dp
import com.android.systemui.common.shared.model.Icon as IconModel
import com.android.systemui.common.ui.compose.Icon
import com.android.systemui.res.R
import com.android.systemui.screencapture.ui.viewmodel.ScreenCaptureRegion
import com.android.systemui.screencapture.ui.viewmodel.ScreenCaptureType
import com.android.systemui.screencapture.ui.viewmodel.ScreenCaptureViewModel

/** TODO(b/422855266): Inject ViewModel */
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
fun PreCaptureToolbar(expanded: Boolean, onCloseClick: () -> Unit, modifier: Modifier = Modifier) {
@Composable
fun PreCaptureToolbar(
    viewModel: ScreenCaptureViewModel,
    expanded: Boolean,
    onCloseClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    // TODO(b/422855266): Preload icons in the view model to avoid loading icons in UI thread and
    // improve performance
    val screenshotIcon =
@@ -51,18 +58,34 @@ fun PreCaptureToolbar(expanded: Boolean, onCloseClick: () -> Unit, modifier: Mod

    val captureRegionButtonItems =
        listOf(
            RadioButtonGroupItem(icon = screenshotWindowIcon),
            RadioButtonGroupItem(icon = screenshotRegionIcon),
            RadioButtonGroupItem(icon = screenshotFullscreenIcon),
            RadioButtonGroupItem(
                isSelected = (viewModel.captureRegion == ScreenCaptureRegion.APP_WINDOW),
                onClick = { viewModel.updateCaptureRegion(ScreenCaptureRegion.APP_WINDOW) },
                icon = screenshotWindowIcon,
            ),
            RadioButtonGroupItem(
                isSelected = (viewModel.captureRegion == ScreenCaptureRegion.PARTIAL),
                onClick = { viewModel.updateCaptureRegion(ScreenCaptureRegion.PARTIAL) },
                icon = screenshotRegionIcon,
            ),
            RadioButtonGroupItem(
                isSelected = (viewModel.captureRegion == ScreenCaptureRegion.FULLSCREEN),
                onClick = { viewModel.updateCaptureRegion(ScreenCaptureRegion.FULLSCREEN) },
                icon = screenshotFullscreenIcon,
            ),
        )

    val captureTypeButtonItems =
        listOf(
            RadioButtonGroupItem(
                isSelected = (viewModel.captureType == ScreenCaptureType.SCREEN_RECORD),
                onClick = { viewModel.updateCaptureType(ScreenCaptureType.SCREEN_RECORD) },
                icon = screenRecordIcon,
                label = stringResource(id = R.string.screen_capture_toolbar_record_button),
            ),
            RadioButtonGroupItem(
                isSelected = (viewModel.captureType == ScreenCaptureType.SCREENSHOT),
                onClick = { viewModel.updateCaptureType(ScreenCaptureType.SCREENSHOT) },
                icon = screenshotIcon,
                label = stringResource(id = R.string.screen_capture_toolbar_capture_button),
            ),
@@ -80,16 +103,11 @@ fun PreCaptureToolbar(expanded: Boolean, onCloseClick: () -> Unit, modifier: Mod

            Spacer(Modifier.size(8.dp))

            // TODO(b/422855266): Use state from ViewModel for selected index
            RadioButtonGroup(
                items = captureRegionButtonItems,
                selectedIndex = 0,
                onSelect = { _ -> },
            )
            RadioButtonGroup(items = captureRegionButtonItems)

            Spacer(Modifier.size(16.dp))

            RadioButtonGroup(items = captureTypeButtonItems, selectedIndex = 0, onSelect = { _ -> })
            RadioButtonGroup(items = captureTypeButtonItems)
        }
    }
}
+32 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.screencapture.ui.compose

import androidx.compose.runtime.Composable
import com.android.systemui.lifecycle.rememberViewModel
import com.android.systemui.screencapture.ui.viewmodel.ScreenCaptureViewModel

/** Main component for the pre-capture UI. */
@Composable
fun PreCaptureUI(viewModelFactory: ScreenCaptureViewModel.Factory) {
    val viewModel: ScreenCaptureViewModel =
        rememberViewModel("ScreenCaptureViewModel") { viewModelFactory.create() }

    PreCaptureToolbar(viewModel = viewModel, expanded = true, onCloseClick = {})

    // TODO: Add region box here
}
+9 −6
Original line number Diff line number Diff line
@@ -42,7 +42,12 @@ private val ICON_SIZE = 20.dp
 * Data class to represent a single radio button item. The item must have an [icon] or a [label] (or
 * both).
 */
data class RadioButtonGroupItem(val icon: IconModel? = null, val label: String? = null) {
data class RadioButtonGroupItem(
    val isSelected: Boolean,
    val onClick: () -> Unit,
    val icon: IconModel? = null,
    val label: String? = null,
) {
    init {
        require(icon != null || label != null) {
            "A ButtonItem must have at least an icon or a label (or both)."
@@ -54,12 +59,10 @@ data class RadioButtonGroupItem(val icon: IconModel? = null, val label: String?
@Composable
fun RadioButtonGroup(
    items: List<RadioButtonGroupItem>,
    selectedIndex: Int,
    onSelect: (index: Int) -> Unit,
    modifier: Modifier = Modifier,
    colors: ToggleButtonColors = defaultColors(),
) {
    require(selectedIndex in 0..items.size) { "selectedIndex is out of range of items." }
    require(items.count { it.isSelected } == 1) { "Only one button item must be selected." }

    Row(
        modifier = modifier,
@@ -74,8 +77,8 @@ fun RadioButtonGroup(
                        items.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
                        else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
                    },
                checked = (index == selectedIndex),
                onCheckedChange = { onSelect(index) },
                checked = item.isSelected,
                onCheckedChange = { item.onClick() },
            ) {
                if (item.icon != null && item.label != null) {
                    Icon(icon = item.icon, modifier = Modifier.size(ICON_SIZE))
+76 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.screencapture.ui.viewmodel

import androidx.compose.runtime.getValue
import com.android.systemui.lifecycle.ExclusiveActivatable
import com.android.systemui.lifecycle.Hydrator
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.MutableStateFlow

enum class ScreenCaptureType {
    SCREENSHOT,
    SCREEN_RECORD,
}

enum class ScreenCaptureRegion {
    FULLSCREEN,
    PARTIAL,
    APP_WINDOW,
}

/** Models state for the Screen Capture UI */
class ScreenCaptureViewModel @AssistedInject constructor() : ExclusiveActivatable() {
    private val hydrator = Hydrator("ScreenCaptureViewModel.hydrator")

    private val captureTypeSource = MutableStateFlow(ScreenCaptureType.SCREENSHOT)
    private val captureRegionSource = MutableStateFlow(ScreenCaptureRegion.FULLSCREEN)

    // TODO(b/423697394) Init default value to be user's previously selected option
    val captureType by
        hydrator.hydratedStateOf(
            traceName = "captureType",
            initialValue = ScreenCaptureType.SCREENSHOT,
            source = captureTypeSource,
        )

    // TODO(b/423697394) Init default value to be user's previously selected option
    val captureRegion by
        hydrator.hydratedStateOf(
            traceName = "captureRegion",
            initialValue = ScreenCaptureRegion.FULLSCREEN,
            source = captureRegionSource,
        )

    fun updateCaptureType(selectedType: ScreenCaptureType) {
        captureTypeSource.value = selectedType
    }

    fun updateCaptureRegion(selectedRegion: ScreenCaptureRegion) {
        captureRegionSource.value = selectedRegion
    }

    override suspend fun onActivated(): Nothing {
        hydrator.activate()
    }

    @AssistedFactory
    interface Factory {
        fun create(): ScreenCaptureViewModel
    }
}
+66 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.screencapture.ui.viewmodel

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.kosmos.testScope
import com.android.systemui.kosmos.useUnconfinedTestDispatcher
import com.android.systemui.lifecycle.activateIn
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@SmallTest
@RunWith(AndroidJUnit4::class)
class ScreenCaptureViewModelTest : SysuiTestCase() {
    private val kosmos = testKosmos().useUnconfinedTestDispatcher()
    private val testScope = kosmos.testScope

    private val viewModel: ScreenCaptureViewModel by lazy { kosmos.screenCaptureViewModel }

    @Before
    fun setUp() {
        viewModel.activateIn(testScope)
    }

    @Test
    fun initialState() =
        testScope.runTest {
            // Assert that the initial values are as expected upon creation and activation.
            assertThat(viewModel.captureType).isEqualTo(ScreenCaptureType.SCREENSHOT)
            assertThat(viewModel.captureRegion).isEqualTo(ScreenCaptureRegion.FULLSCREEN)
        }

    @Test
    fun updateCaptureType_updatesState() =
        testScope.runTest {
            viewModel.updateCaptureType(ScreenCaptureType.SCREEN_RECORD)
            assertThat(viewModel.captureType).isEqualTo(ScreenCaptureType.SCREEN_RECORD)
        }

    @Test
    fun updateCaptureRegion_updatesState() =
        testScope.runTest {
            viewModel.updateCaptureRegion(ScreenCaptureRegion.PARTIAL)
            assertThat(viewModel.captureRegion).isEqualTo(ScreenCaptureRegion.PARTIAL)
        }
}
Loading