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

Commit b6508925 authored by Alejandro Nijamkin's avatar Alejandro Nijamkin
Browse files

User UI layer.

UserSwitcherActivity refactor: CL 6/7

Includes view-models and view-binders for the user and user-switcher
experiences.

Bug: 243844359
Test: Integration tests included for the top-most view-model. Also
manually tested with the next CL.

Change-Id: Ia05c2f267a37475c409ffed9b9784cdc435a9d76
parent 2ee73627
Loading
Loading
Loading
Loading
+220 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2022 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.user.ui.binder

import android.content.Context
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.helper.widget.Flow as FlowWidget
import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.android.systemui.Gefingerpoken
import com.android.systemui.R
import com.android.systemui.classifier.FalsingCollector
import com.android.systemui.user.UserSwitcherPopupMenu
import com.android.systemui.user.UserSwitcherRootView
import com.android.systemui.user.ui.viewmodel.UserActionViewModel
import com.android.systemui.user.ui.viewmodel.UserSwitcherViewModel
import com.android.systemui.util.children
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch

/** Binds a user switcher to its view-model. */
object UserSwitcherViewBinder {

    private const val USER_VIEW_TAG = "user_view"

    /** Binds the given view to the given view-model. */
    fun bind(
        view: ViewGroup,
        viewModel: UserSwitcherViewModel,
        lifecycleOwner: LifecycleOwner,
        layoutInflater: LayoutInflater,
        falsingCollector: FalsingCollector,
        onFinish: () -> Unit,
    ) {
        val rootView: UserSwitcherRootView = view.requireViewById(R.id.user_switcher_root)
        val flowWidget: FlowWidget = view.requireViewById(R.id.flow)
        val addButton: View = view.requireViewById(R.id.add)
        val cancelButton: View = view.requireViewById(R.id.cancel)
        val popupMenuAdapter = MenuAdapter(layoutInflater)
        var popupMenu: UserSwitcherPopupMenu? = null

        rootView.touchHandler =
            object : Gefingerpoken {
                override fun onTouchEvent(ev: MotionEvent?): Boolean {
                    falsingCollector.onTouchEvent(ev)
                    return false
                }
            }
        addButton.setOnClickListener { viewModel.onOpenMenuButtonClicked() }
        cancelButton.setOnClickListener { viewModel.onCancelButtonClicked() }

        lifecycleOwner.lifecycleScope.launch {
            lifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
                launch {
                    viewModel.isFinishRequested
                        .filter { it }
                        .collect {
                            onFinish()
                            viewModel.onFinished()
                        }
                }
            }
        }

        lifecycleOwner.lifecycleScope.launch {
            lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                launch { viewModel.isOpenMenuButtonVisible.collect { addButton.isVisible = it } }

                launch {
                    viewModel.isMenuVisible.collect { isVisible ->
                        if (isVisible && popupMenu?.isShowing != true) {
                            popupMenu?.dismiss()
                            // Use post to make sure we show the popup menu *after* the activity is
                            // ready to show one to avoid a WindowManager$BadTokenException.
                            view.post {
                                popupMenu =
                                    createAndShowPopupMenu(
                                        context = view.context,
                                        anchorView = addButton,
                                        adapter = popupMenuAdapter,
                                        onDismissed = viewModel::onMenuClosed,
                                    )
                            }
                        } else if (!isVisible && popupMenu?.isShowing == true) {
                            popupMenu?.dismiss()
                            popupMenu = null
                        }
                    }
                }

                launch {
                    viewModel.menu.collect { menuViewModels ->
                        popupMenuAdapter.setItems(menuViewModels)
                    }
                }

                launch {
                    viewModel.maximumUserColumns.collect { maximumColumns ->
                        flowWidget.setMaxElementsWrap(maximumColumns)
                    }
                }

                launch {
                    viewModel.users.collect { users ->
                        val viewPool =
                            view.children.filter { it.tag == USER_VIEW_TAG }.toMutableList()
                        viewPool.forEach { view.removeView(it) }
                        users.forEach { userViewModel ->
                            val userView =
                                if (viewPool.isNotEmpty()) {
                                    viewPool.removeAt(0)
                                } else {
                                    val inflatedView =
                                        layoutInflater.inflate(
                                            R.layout.user_switcher_fullscreen_item,
                                            view,
                                            false,
                                        )
                                    inflatedView.tag = USER_VIEW_TAG
                                    inflatedView
                                }
                            userView.id = View.generateViewId()
                            view.addView(userView)
                            flowWidget.addView(userView)
                            UserViewBinder.bind(
                                view = userView,
                                viewModel = userViewModel,
                            )
                        }
                    }
                }
            }
        }
    }

    private fun createAndShowPopupMenu(
        context: Context,
        anchorView: View,
        adapter: MenuAdapter,
        onDismissed: () -> Unit,
    ): UserSwitcherPopupMenu {
        return UserSwitcherPopupMenu(context).apply {
            this.anchorView = anchorView
            setAdapter(adapter)
            setOnDismissListener { onDismissed() }
            setOnItemClickListener { _, _, position, _ ->
                val itemPositionExcludingHeader = position - 1
                adapter.getItem(itemPositionExcludingHeader).onClicked()
            }

            show()
        }
    }

    /** Adapter for the menu that can be opened. */
    private class MenuAdapter(
        private val layoutInflater: LayoutInflater,
    ) : BaseAdapter() {

        private val items = mutableListOf<UserActionViewModel>()

        override fun getCount(): Int {
            return items.size
        }

        override fun getItem(position: Int): UserActionViewModel {
            return items[position]
        }

        override fun getItemId(position: Int): Long {
            return getItem(position).viewKey
        }

        override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
            val view =
                convertView
                    ?: layoutInflater.inflate(
                        R.layout.user_switcher_fullscreen_popup_item,
                        parent,
                        false
                    )
            val viewModel = getItem(position)
            view.requireViewById<ImageView>(R.id.icon).setImageResource(viewModel.iconResourceId)
            view.requireViewById<TextView>(R.id.text).text =
                view.resources.getString(viewModel.textResourceId)
            return view
        }

        fun setItems(items: List<UserActionViewModel>) {
            this.items.clear()
            this.items.addAll(items)
            notifyDataSetChanged()
        }
    }
}
+77 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2022 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.user.ui.binder

import android.content.Context
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.graphics.drawable.LayerDrawable
import android.view.View
import android.widget.ImageView
import androidx.core.content.res.ResourcesCompat
import com.android.settingslib.Utils
import com.android.systemui.R
import com.android.systemui.common.ui.binder.TextViewBinder
import com.android.systemui.user.ui.viewmodel.UserViewModel

/** Binds a user view to its view-model. */
object UserViewBinder {
    /** Binds the given view to the given view-model. */
    fun bind(view: View, viewModel: UserViewModel) {
        TextViewBinder.bind(view.requireViewById(R.id.user_switcher_text), viewModel.name)
        view
            .requireViewById<ImageView>(R.id.user_switcher_icon)
            .setImageDrawable(getSelectableDrawable(view.context, viewModel))
        view.alpha = viewModel.alpha
        if (viewModel.onClicked != null) {
            view.setOnClickListener { viewModel.onClicked.invoke() }
        } else {
            view.setOnClickListener(null)
        }
    }

    private fun getSelectableDrawable(context: Context, viewModel: UserViewModel): Drawable {
        val layerDrawable =
            checkNotNull(
                    ResourcesCompat.getDrawable(
                        context.resources,
                        R.drawable.user_switcher_icon_large,
                        context.theme,
                    )
                )
                .mutate() as LayerDrawable
        if (viewModel.isSelectionMarkerVisible) {
            (layerDrawable.findDrawableByLayerId(R.id.ring) as GradientDrawable).apply {
                val stroke =
                    context.resources.getDimensionPixelSize(
                        R.dimen.user_switcher_icon_selected_width
                    )
                val color =
                    Utils.getColorAttrDefaultColor(
                        context,
                        com.android.internal.R.attr.colorAccentPrimary
                    )

                setStroke(stroke, color)
            }
        }

        layerDrawable.setDrawableByLayerId(R.id.user_avatar, viewModel.image)
        return layerDrawable
    }
}
+33 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2022 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.user.ui.viewmodel

import androidx.annotation.DrawableRes
import androidx.annotation.StringRes

/** Models UI state for an action that can be performed on a user. */
data class UserActionViewModel(
    /**
     * Key to use with the view or compose system to keep track of the view/composable across
     * changes to the collection of [UserActionViewModel] instances.
     */
    val viewKey: Long,
    @DrawableRes val iconResourceId: Int,
    @StringRes val textResourceId: Int,
    val onClicked: () -> Unit,
)
+199 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2022 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.user.ui.viewmodel

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.android.systemui.R
import com.android.systemui.power.domain.interactor.PowerInteractor
import com.android.systemui.user.domain.interactor.UserInteractor
import com.android.systemui.user.legacyhelper.ui.LegacyUserUiHelper
import com.android.systemui.user.shared.model.UserActionModel
import com.android.systemui.user.shared.model.UserModel
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map

/** Models UI state for the user switcher feature. */
class UserSwitcherViewModel
private constructor(
    private val userInteractor: UserInteractor,
    private val powerInteractor: PowerInteractor,
) : ViewModel() {

    /** On-device users. */
    val users: Flow<List<UserViewModel>> =
        userInteractor.users.map { models -> models.map { user -> toViewModel(user) } }

    /** The maximum number of columns that the user selection grid should use. */
    val maximumUserColumns: Flow<Int> =
        users.map { LegacyUserUiHelper.getMaxUserSwitcherItemColumns(it.size) }

    /** Whether the button to open the user action menu is visible. */
    val isOpenMenuButtonVisible: Flow<Boolean> = userInteractor.actions.map { it.isNotEmpty() }

    private val _isMenuVisible = MutableStateFlow(false)
    /**
     * Whether the user action menu should be shown. Once the action menu is dismissed/closed, the
     * consumer must invoke [onMenuClosed].
     */
    val isMenuVisible: Flow<Boolean> = _isMenuVisible
    /** The user action menu. */
    val menu: Flow<List<UserActionViewModel>> =
        userInteractor.actions.map { actions -> actions.map { action -> toViewModel(action) } }

    private val hasCancelButtonBeenClicked = MutableStateFlow(false)

    /**
     * Whether the observer should finish the experience. Once consumed, [onFinished] must be called
     * by the consumer.
     */
    val isFinishRequested: Flow<Boolean> = createFinishRequestedFlow()

    /** Notifies that the user has clicked the cancel button. */
    fun onCancelButtonClicked() {
        hasCancelButtonBeenClicked.value = true
    }

    /**
     * Notifies that the user experience is finished.
     *
     * Call this after consuming [isFinishRequested] with a `true` value in order to mark it as
     * consumed such that the next consumer doesn't immediately finish itself.
     */
    fun onFinished() {
        hasCancelButtonBeenClicked.value = false
    }

    /** Notifies that the user has clicked the "open menu" button. */
    fun onOpenMenuButtonClicked() {
        _isMenuVisible.value = true
    }

    /**
     * Notifies that the user has dismissed or closed the user action menu.
     *
     * Call this after consuming [isMenuVisible] with a `true` value in order to reset it to `false`
     * such that the next consumer doesn't immediately show the menu again.
     */
    fun onMenuClosed() {
        _isMenuVisible.value = false
    }

    private fun createFinishRequestedFlow(): Flow<Boolean> {
        var mostRecentSelectedUserId: Int? = null
        var mostRecentIsInteractive: Boolean? = null

        return combine(
            // When the user is switched, we should finish.
            userInteractor.selectedUser
                .map { it.id }
                .map {
                    val selectedUserChanged =
                        mostRecentSelectedUserId != null && mostRecentSelectedUserId != it
                    mostRecentSelectedUserId = it
                    selectedUserChanged
                },
            // When the screen turns off, we should finish.
            powerInteractor.isInteractive.map {
                val screenTurnedOff = mostRecentIsInteractive == true && !it
                mostRecentIsInteractive = it
                screenTurnedOff
            },
            // When the cancel button is clicked, we should finish.
            hasCancelButtonBeenClicked,
        ) { selectedUserChanged, screenTurnedOff, cancelButtonClicked ->
            selectedUserChanged || screenTurnedOff || cancelButtonClicked
        }
    }

    private fun toViewModel(
        model: UserModel,
    ): UserViewModel {
        return UserViewModel(
            viewKey = model.id,
            name = model.name,
            image = model.image,
            isSelectionMarkerVisible = model.isSelected,
            alpha =
                if (model.isSelectable) {
                    LegacyUserUiHelper.USER_SWITCHER_USER_VIEW_SELECTABLE_ALPHA
                } else {
                    LegacyUserUiHelper.USER_SWITCHER_USER_VIEW_NOT_SELECTABLE_ALPHA
                },
            onClicked = createOnSelectedCallback(model),
        )
    }

    private fun toViewModel(
        model: UserActionModel,
    ): UserActionViewModel {
        return UserActionViewModel(
            viewKey = model.ordinal.toLong(),
            iconResourceId =
                if (model == UserActionModel.NAVIGATE_TO_USER_MANAGEMENT) {
                    R.drawable.ic_manage_users
                } else {
                    LegacyUserUiHelper.getUserSwitcherActionIconResourceId(
                        isAddSupervisedUser = model == UserActionModel.ADD_SUPERVISED_USER,
                        isAddUser = model == UserActionModel.ADD_USER,
                        isGuest = model == UserActionModel.ENTER_GUEST_MODE,
                    )
                },
            textResourceId =
                if (model == UserActionModel.NAVIGATE_TO_USER_MANAGEMENT) {
                    R.string.manage_users
                } else {
                    LegacyUserUiHelper.getUserSwitcherActionTextResourceId(
                        isGuest = model == UserActionModel.ENTER_GUEST_MODE,
                        isGuestUserAutoCreated = userInteractor.isGuestUserAutoCreated,
                        isGuestUserResetting = userInteractor.isGuestUserResetting,
                        isAddSupervisedUser = model == UserActionModel.ADD_SUPERVISED_USER,
                        isAddUser = model == UserActionModel.ADD_USER,
                    )
                },
            onClicked = { userInteractor.executeAction(action = model) },
        )
    }

    private fun createOnSelectedCallback(model: UserModel): (() -> Unit)? {
        return if (!model.isSelectable) {
            null
        } else {
            { userInteractor.selectUser(model.id) }
        }
    }

    class Factory
    @Inject
    constructor(
        private val userInteractor: UserInteractor,
        private val powerInteractor: PowerInteractor,
    ) : ViewModelProvider.Factory {
        override fun <T : ViewModel> create(modelClass: Class<T>): T {
            @Suppress("UNCHECKED_CAST")
            return UserSwitcherViewModel(
                userInteractor = userInteractor,
                powerInteractor = powerInteractor,
            )
                as T
        }
    }
}
+36 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2022 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.user.ui.viewmodel

import android.graphics.drawable.Drawable
import com.android.systemui.common.shared.model.Text

/** Models UI state for representing a single user. */
data class UserViewModel(
    /**
     * Key to use with the view or compose system to keep track of the view/composable across
     * changes to the collection of [UserViewModel] instances.
     */
    val viewKey: Int,
    val name: Text,
    val image: Drawable,
    /** Whether a marker should be shown to highlight that this user is the selected one. */
    val isSelectionMarkerVisible: Boolean,
    val alpha: Float,
    val onClicked: (() -> Unit)?,
)
Loading