mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 18:58:59 +03:00
Complete the UI for the Folders screen (#783)
This commit is contained in:
parent
3de3c8f0ed
commit
317cc7396e
5 changed files with 305 additions and 33 deletions
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue