diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersScreen.kt index 526e6c2934..a8e181ea6f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersScreen.kt @@ -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, + 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, + ) + } + } + } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersViewModel.kt index 9fd0f7bad5..1e8e86dbe8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersViewModel.kt @@ -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( - initialState = Unit, - ) { +class FoldersViewModel @Inject constructor( + vaultRepository: VaultRepository, +) : BaseViewModel( + 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) : 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?>, + ) : Internal() + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/model/FolderDisplayItem.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/model/FolderDisplayItem.kt new file mode 100644 index 0000000000..d6407b34c9 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/model/FolderDisplayItem.kt @@ -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 diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersScreenTest.kt index af7034087f..b32083f367 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersScreenTest.kt @@ -15,8 +15,9 @@ import org.junit.Test class FoldersScreenTest : BaseComposeTest() { private var onNavigateBackCalled = false + private val mutableEventFlow = bufferedMutableSharedFlow() - private val mutableStateFlow = MutableStateFlow(Unit) + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) val viewModel = mockk(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) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersViewModelTest.kt index 3cdacc920f..401a258a27 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/FoldersViewModelTest.kt @@ -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())) + + 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, + ) }