diff options
6 files changed, 862 insertions, 0 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/user/ui/binder/UserSwitcherViewBinder.kt b/packages/SystemUI/src/com/android/systemui/user/ui/binder/UserSwitcherViewBinder.kt new file mode 100644 index 000000000000..83a3d0d0457a --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/user/ui/binder/UserSwitcherViewBinder.kt @@ -0,0 +1,220 @@ +/* + * 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() + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/user/ui/binder/UserViewBinder.kt b/packages/SystemUI/src/com/android/systemui/user/ui/binder/UserViewBinder.kt new file mode 100644 index 000000000000..e78807e675b3 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/user/ui/binder/UserViewBinder.kt @@ -0,0 +1,77 @@ +/* + * 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 + } +} diff --git a/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserActionViewModel.kt b/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserActionViewModel.kt new file mode 100644 index 000000000000..149b1ffdaff0 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserActionViewModel.kt @@ -0,0 +1,33 @@ +/* + * 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, +) diff --git a/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModel.kt b/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModel.kt new file mode 100644 index 000000000000..66ce01b7a86e --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModel.kt @@ -0,0 +1,199 @@ +/* + * 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 + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserViewModel.kt b/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserViewModel.kt new file mode 100644 index 000000000000..d57bba0fa86a --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/user/ui/viewmodel/UserViewModel.kt @@ -0,0 +1,36 @@ +/* + * 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)?, +) diff --git a/packages/SystemUI/tests/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModelTest.kt new file mode 100644 index 000000000000..ef4500df3600 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/user/ui/viewmodel/UserSwitcherViewModelTest.kt @@ -0,0 +1,297 @@ +/* + * 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 androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.common.shared.model.Text +import com.android.systemui.keyguard.data.repository.FakeKeyguardRepository +import com.android.systemui.keyguard.domain.interactor.KeyguardInteractor +import com.android.systemui.plugins.ActivityStarter +import com.android.systemui.power.data.repository.FakePowerRepository +import com.android.systemui.power.domain.interactor.PowerInteractor +import com.android.systemui.statusbar.policy.UserSwitcherController +import com.android.systemui.user.data.repository.FakeUserRepository +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 com.android.systemui.util.mockito.mock +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.Mock +import org.mockito.MockitoAnnotations + +@SmallTest +@RunWith(JUnit4::class) +class UserSwitcherViewModelTest : SysuiTestCase() { + + @Mock private lateinit var controller: UserSwitcherController + @Mock private lateinit var activityStarter: ActivityStarter + + private lateinit var underTest: UserSwitcherViewModel + + private lateinit var userRepository: FakeUserRepository + private lateinit var keyguardRepository: FakeKeyguardRepository + private lateinit var powerRepository: FakePowerRepository + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + + userRepository = FakeUserRepository() + keyguardRepository = FakeKeyguardRepository() + powerRepository = FakePowerRepository() + underTest = + UserSwitcherViewModel.Factory( + userInteractor = + UserInteractor( + repository = userRepository, + controller = controller, + activityStarter = activityStarter, + keyguardInteractor = + KeyguardInteractor( + repository = keyguardRepository, + ) + ), + powerInteractor = + PowerInteractor( + repository = powerRepository, + ), + ) + .create(UserSwitcherViewModel::class.java) + } + + @Test + fun users() = + runBlocking(IMMEDIATE) { + userRepository.setUsers( + listOf( + UserModel( + id = 0, + name = Text.Loaded("zero"), + image = USER_IMAGE, + isSelected = true, + isSelectable = true, + ), + UserModel( + id = 1, + name = Text.Loaded("one"), + image = USER_IMAGE, + isSelected = false, + isSelectable = true, + ), + UserModel( + id = 2, + name = Text.Loaded("two"), + image = USER_IMAGE, + isSelected = false, + isSelectable = false, + ), + ) + ) + + var userViewModels: List<UserViewModel>? = null + val job = underTest.users.onEach { userViewModels = it }.launchIn(this) + + assertThat(userViewModels).hasSize(3) + assertUserViewModel( + viewModel = userViewModels?.get(0), + viewKey = 0, + name = "zero", + isSelectionMarkerVisible = true, + alpha = LegacyUserUiHelper.USER_SWITCHER_USER_VIEW_SELECTABLE_ALPHA, + isClickable = true, + ) + assertUserViewModel( + viewModel = userViewModels?.get(1), + viewKey = 1, + name = "one", + isSelectionMarkerVisible = false, + alpha = LegacyUserUiHelper.USER_SWITCHER_USER_VIEW_SELECTABLE_ALPHA, + isClickable = true, + ) + assertUserViewModel( + viewModel = userViewModels?.get(2), + viewKey = 2, + name = "two", + isSelectionMarkerVisible = false, + alpha = LegacyUserUiHelper.USER_SWITCHER_USER_VIEW_NOT_SELECTABLE_ALPHA, + isClickable = false, + ) + job.cancel() + } + + @Test + fun `maximumUserColumns - few users`() = + runBlocking(IMMEDIATE) { + setUsers(count = 2) + var value: Int? = null + val job = underTest.maximumUserColumns.onEach { value = it }.launchIn(this) + + assertThat(value).isEqualTo(4) + job.cancel() + } + + @Test + fun `maximumUserColumns - many users`() = + runBlocking(IMMEDIATE) { + setUsers(count = 5) + var value: Int? = null + val job = underTest.maximumUserColumns.onEach { value = it }.launchIn(this) + + assertThat(value).isEqualTo(3) + job.cancel() + } + + @Test + fun `isOpenMenuButtonVisible - has actions - true`() = + runBlocking(IMMEDIATE) { + userRepository.setActions(UserActionModel.values().toList()) + + var isVisible: Boolean? = null + val job = underTest.isOpenMenuButtonVisible.onEach { isVisible = it }.launchIn(this) + + assertThat(isVisible).isTrue() + job.cancel() + } + + @Test + fun `isOpenMenuButtonVisible - no actions - false`() = + runBlocking(IMMEDIATE) { + userRepository.setActions(emptyList()) + + var isVisible: Boolean? = null + val job = underTest.isOpenMenuButtonVisible.onEach { isVisible = it }.launchIn(this) + + assertThat(isVisible).isFalse() + job.cancel() + } + + @Test + fun menu() = + runBlocking(IMMEDIATE) { + userRepository.setActions(UserActionModel.values().toList()) + var isMenuVisible: Boolean? = null + val job = underTest.isMenuVisible.onEach { isMenuVisible = it }.launchIn(this) + assertThat(isMenuVisible).isFalse() + + underTest.onOpenMenuButtonClicked() + assertThat(isMenuVisible).isTrue() + + underTest.onMenuClosed() + assertThat(isMenuVisible).isFalse() + + job.cancel() + } + + @Test + fun `isFinishRequested - finishes when user is switched`() = + runBlocking(IMMEDIATE) { + setUsers(count = 2) + var isFinishRequested: Boolean? = null + val job = underTest.isFinishRequested.onEach { isFinishRequested = it }.launchIn(this) + assertThat(isFinishRequested).isFalse() + + userRepository.setSelectedUser(1) + yield() + assertThat(isFinishRequested).isTrue() + + job.cancel() + } + + @Test + fun `isFinishRequested - finishes when the screen turns off`() = + runBlocking(IMMEDIATE) { + setUsers(count = 2) + powerRepository.setInteractive(true) + var isFinishRequested: Boolean? = null + val job = underTest.isFinishRequested.onEach { isFinishRequested = it }.launchIn(this) + assertThat(isFinishRequested).isFalse() + + powerRepository.setInteractive(false) + yield() + assertThat(isFinishRequested).isTrue() + + job.cancel() + } + + @Test + fun `isFinishRequested - finishes when cancel button is clicked`() = + runBlocking(IMMEDIATE) { + setUsers(count = 2) + powerRepository.setInteractive(true) + var isFinishRequested: Boolean? = null + val job = underTest.isFinishRequested.onEach { isFinishRequested = it }.launchIn(this) + assertThat(isFinishRequested).isFalse() + + underTest.onCancelButtonClicked() + yield() + assertThat(isFinishRequested).isTrue() + + underTest.onFinished() + yield() + assertThat(isFinishRequested).isFalse() + + job.cancel() + } + + private fun setUsers(count: Int) { + userRepository.setUsers( + (0 until count).map { index -> + UserModel( + id = index, + name = Text.Loaded("$index"), + image = USER_IMAGE, + isSelected = index == 0, + isSelectable = true, + ) + } + ) + } + + private fun assertUserViewModel( + viewModel: UserViewModel?, + viewKey: Int, + name: String, + isSelectionMarkerVisible: Boolean, + alpha: Float, + isClickable: Boolean, + ) { + checkNotNull(viewModel) + assertThat(viewModel.viewKey).isEqualTo(viewKey) + assertThat(viewModel.name).isEqualTo(Text.Loaded(name)) + assertThat(viewModel.isSelectionMarkerVisible).isEqualTo(isSelectionMarkerVisible) + assertThat(viewModel.alpha).isEqualTo(alpha) + assertThat(viewModel.onClicked != null).isEqualTo(isClickable) + } + + companion object { + private val IMMEDIATE = Dispatchers.Main.immediate + private val USER_IMAGE = mock<Drawable>() + } +} |