Complete the UI for the Folders screen (#783)

This commit is contained in:
Oleg Semenenko 2024-01-25 16:58:11 -06:00 committed by Álison Fernandes
parent 3de3c8f0ed
commit 317cc7396e
5 changed files with 305 additions and 33 deletions

View file

@ -1,13 +1,18 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.folders
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
@ -17,6 +22,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
@ -25,10 +31,16 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.bottomDivider
import com.x8bit.bitwarden.ui.platform.base.util.showNotYetImplementedToast
import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.feature.settings.folders.model.FolderDisplayItem
/**
* Displays the folders screen.
@ -40,10 +52,19 @@ fun FoldersScreen(
onNavigateBack: () -> Unit,
viewModel: FoldersViewModel = hiltViewModel(),
) {
val state = viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
EventsEffect(viewModel = viewModel) { event ->
when (event) {
FoldersEvent.NavigateBack -> onNavigateBack()
is FoldersEvent.NavigateBack -> onNavigateBack()
is FoldersEvent.NavigateToAddFolderScreen -> {
showNotYetImplementedToast(context = context)
}
is FoldersEvent.NavigateToEditFolderScreen -> {
showNotYetImplementedToast(context = context)
}
is FoldersEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
}
@ -82,27 +103,86 @@ fun FoldersScreen(
}
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Center,
) {
// TODO BIT-460 populate Folders screen
when (val viewState = state.value.viewState) {
is FoldersState.ViewState.Content -> {
FoldersContent(
foldersList = viewState.folderList,
onItemClick = remember(viewModel) {
{ viewModel.trySendAction(FoldersAction.OnFolderClick(it)) }
},
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
is FoldersState.ViewState.Error -> {
BitwardenErrorContent(
message = viewState.message(),
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
is FoldersState.ViewState.Loading -> {
BitwardenLoadingContent(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
}
}
}
@Composable
private fun FoldersContent(
foldersList: List<FolderDisplayItem>,
onItemClick: (folderId: String) -> Unit,
modifier: Modifier,
) {
if (foldersList.isEmpty()) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(id = R.string.no_folders_to_list),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.fillMaxSize()
.padding(
vertical = 4.dp,
horizontal = 16.dp,
),
)
}
} else {
LazyColumn(
modifier = modifier,
) {
items(foldersList) {
Row(
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(color = MaterialTheme.colorScheme.primary),
onClick = { onItemClick(it.id) },
)
.bottomDivider(paddingStart = 16.dp)
.defaultMinSize(minHeight = 56.dp)
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier
.padding(start = 16.dp)
.weight(1f),
text = it.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
}

View file

@ -1,30 +1,162 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.folders
import android.os.Parcelable
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.FolderView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.platform.feature.settings.folders.model.FolderDisplayItem
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
/**
* View model for the folders screen.
* Handles [FoldersAction],
* and launches [FoldersEvent] for the [FoldersScreen].
*/
@HiltViewModel
class FoldersViewModel @Inject constructor() :
BaseViewModel<Unit, FoldersEvent, FoldersAction>(
initialState = Unit,
) {
class FoldersViewModel @Inject constructor(
vaultRepository: VaultRepository,
) : BaseViewModel<FoldersState, FoldersEvent, FoldersAction>(
initialState = FoldersState(viewState = FoldersState.ViewState.Loading),
) {
init {
vaultRepository
.foldersStateFlow
.onEach { sendAction(FoldersAction.Internal.VaultDataReceive(it)) }
.launchIn(viewModelScope)
}
override fun handleAction(action: FoldersAction): Unit = when (action) {
FoldersAction.AddFolderButtonClick -> handleAddFolderButtonClicked()
FoldersAction.CloseButtonClick -> handleCloseButtonClicked()
is FoldersAction.AddFolderButtonClick -> handleAddFolderButtonClicked()
is FoldersAction.CloseButtonClick -> handleCloseButtonClicked()
is FoldersAction.Internal.VaultDataReceive -> handleVaultDataReceive(action)
is FoldersAction.OnFolderClick -> handleFolderClick(action)
}
private fun handleFolderClick(action: FoldersAction.OnFolderClick) {
sendEvent(FoldersEvent.NavigateToEditFolderScreen(action.folderId))
}
private fun handleAddFolderButtonClicked() {
// TODO BIT-458 implement add folders
sendEvent(FoldersEvent.ShowToast("Not yet implemented."))
sendEvent(FoldersEvent.NavigateToAddFolderScreen)
}
private fun handleCloseButtonClicked() {
sendEvent(FoldersEvent.NavigateBack)
}
@Suppress("LongMethod")
private fun handleVaultDataReceive(action: FoldersAction.Internal.VaultDataReceive) {
when (val vaultDataState = action.vaultDataState) {
is DataState.Error -> {
mutableStateFlow.update {
it.copy(
viewState = FoldersState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
is DataState.Loaded -> {
mutableStateFlow.update {
it.copy(
viewState = FoldersState.ViewState.Content(
folderList = vaultDataState
.data
?.map { folder ->
FolderDisplayItem(
id = folder.id.toString(),
name = folder.name,
)
}
.orEmpty(),
),
)
}
}
DataState.Loading -> {
mutableStateFlow.update {
it.copy(viewState = FoldersState.ViewState.Loading)
}
}
is DataState.NoNetwork -> {
mutableStateFlow.update {
it.copy(
viewState = FoldersState.ViewState.Error(
message = R.string.internet_connection_required_title
.asText()
.concat(R.string.internet_connection_required_message.asText()),
),
)
}
}
is DataState.Pending -> {
mutableStateFlow.update {
it.copy(
viewState = FoldersState.ViewState.Content(
folderList = vaultDataState
.data
?.map { folder ->
FolderDisplayItem(
id = folder.id.toString(),
name = folder.name,
)
}
.orEmpty(),
),
)
}
}
}
}
}
/**
* Represents the state for the folders screen.
*
* @property viewState indicates what view state the screen is in.
*/
@Parcelize
data class FoldersState(
val viewState: ViewState,
) : Parcelable {
/**
* Represents the specific view states for the [FoldersScreen].
*/
sealed class ViewState : Parcelable {
/**
* Represents an error state for the [FoldersScreen].
*/
@Parcelize
data class Error(val message: Text) : ViewState()
/**
* Loading state for the [FoldersScreen], signifying that the content is being
* processed.
*/
@Parcelize
data object Loading : ViewState()
/**
* Represents a loaded content state for the [FoldersScreen].
*/
@Parcelize
data class Content(val folderList: List<FolderDisplayItem>) : ViewState()
}
}
/**
@ -36,12 +168,20 @@ sealed class FoldersEvent {
*/
data object NavigateBack : FoldersEvent()
/**
* Navigates to the screen to add a folder.
*/
data object NavigateToAddFolderScreen : FoldersEvent()
/**
* Navigates to the screen to edit a folder.
*/
data class NavigateToEditFolderScreen(val folderId: String) : FoldersEvent()
/**
* Shows a toast with the given [message].
*/
data class ShowToast(
val message: String,
) : FoldersEvent()
data class ShowToast(val message: String) : FoldersEvent()
}
/**
@ -53,8 +193,26 @@ sealed class FoldersAction {
*/
data object AddFolderButtonClick : FoldersAction()
/**
* Indicates that the user clicked a folder.
*/
data class OnFolderClick(val folderId: String) : FoldersAction()
/**
* Indicates that the user clicked the close button.
*/
data object CloseButtonClick : FoldersAction()
/**
* Actions for internal use by the ViewModel.
*/
sealed class Internal : FoldersAction() {
/**
* Indicates that the vault folders data has been received.
*/
data class VaultDataReceive(
val vaultDataState: DataState<List<FolderView>?>,
) : Internal()
}
}

View file

@ -0,0 +1,16 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.folders.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* The data for the folder being displayed.
*
* @param id The id of the folder.
* @param name The name of the folder.
*/
@Parcelize
data class FolderDisplayItem(
val id: String,
val name: String,
) : Parcelable

View file

@ -15,8 +15,9 @@ import org.junit.Test
class FoldersScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<FoldersEvent>()
private val mutableStateFlow = MutableStateFlow(Unit)
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
val viewModel = mockk<FoldersViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
@ -52,3 +53,6 @@ class FoldersScreenTest : BaseComposeTest() {
assertTrue(onNavigateBackCalled)
}
}
private val DEFAULT_STATE =
FoldersState(viewState = FoldersState.ViewState.Loading)

View file

@ -1,13 +1,25 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.folders
import app.cash.turbine.test
import com.bitwarden.core.FolderView
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class FoldersViewModelTest : BaseViewModelTest() {
private val mutableFoldersStateFlow = MutableStateFlow(DataState.Loaded(listOf<FolderView>()))
private val vaultRepository: VaultRepository = mockk {
every { foldersStateFlow } returns mutableFoldersStateFlow
}
@Test
fun `BackClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
@ -18,16 +30,18 @@ class FoldersViewModelTest : BaseViewModelTest() {
}
@Test
fun `AddFolderButtonClick should emit ShowToast`() = runTest {
fun `AddFolderButtonClick should emit NavigateToAddFolderScreen`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(FoldersAction.AddFolderButtonClick)
assertEquals(
FoldersEvent.ShowToast("Not yet implemented."),
FoldersEvent.NavigateToAddFolderScreen,
awaitItem(),
)
}
}
private fun createViewModel(): FoldersViewModel = FoldersViewModel()
private fun createViewModel(): FoldersViewModel = FoldersViewModel(
vaultRepository = vaultRepository,
)
}