diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt index 356266070ce..44d4b95b2e0 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt @@ -24,10 +24,7 @@ import com.wire.android.ui.home.conversations.model.messagetypes.multipart.CellA import com.wire.kalium.cells.CellsScope import com.wire.kalium.cells.domain.CellUploadManager import com.wire.kalium.cells.domain.usecase.AddAttachmentDraftUseCase -import com.wire.kalium.cells.domain.usecase.CreateFolderUseCase import com.wire.kalium.cells.domain.usecase.DeleteCellAssetUseCase -import com.wire.kalium.cells.domain.usecase.DownloadCellFileUseCase -import com.wire.kalium.cells.domain.usecase.DownloadCellVersionUseCase import com.wire.kalium.cells.domain.usecase.GetAllTagsUseCase import com.wire.kalium.cells.domain.usecase.GetCellFileUseCase import com.wire.kalium.cells.domain.usecase.GetEditorUrlUseCase @@ -47,6 +44,12 @@ import com.wire.kalium.cells.domain.usecase.RenameNodeUseCase import com.wire.kalium.cells.domain.usecase.RestoreNodeFromRecycleBinUseCase import com.wire.kalium.cells.domain.usecase.RetryAttachmentUploadUseCase import com.wire.kalium.cells.domain.usecase.UpdateNodeTagsUseCase +import com.wire.kalium.cells.domain.usecase.create.CreateDocumentFileUseCase +import com.wire.kalium.cells.domain.usecase.create.CreateFolderUseCase +import com.wire.kalium.cells.domain.usecase.create.CreatePresentationFileUseCase +import com.wire.kalium.cells.domain.usecase.create.CreateSpreadsheetFileUseCase +import com.wire.kalium.cells.domain.usecase.download.DownloadCellFileUseCase +import com.wire.kalium.cells.domain.usecase.download.DownloadCellVersionUseCase import com.wire.kalium.cells.domain.usecase.publiclink.CreatePublicLinkPasswordUseCase import com.wire.kalium.cells.domain.usecase.publiclink.CreatePublicLinkUseCase import com.wire.kalium.cells.domain.usecase.publiclink.DeletePublicLinkUseCase @@ -142,6 +145,18 @@ class CellsModule { @Provides fun provideCreateFolderUseCase(cellsScope: CellsScope): CreateFolderUseCase = cellsScope.createFolderUseCase + @ViewModelScoped + @Provides + fun provideCreateSpreadsheetFileUseCase(cellsScope: CellsScope): CreateSpreadsheetFileUseCase = cellsScope.createSpreadsheetFileUseCase + + @ViewModelScoped + @Provides + fun provideCreateDocumentFileUseCase(cellsScope: CellsScope): CreateDocumentFileUseCase = cellsScope.createDocumentFileUseCase + + @ViewModelScoped + @Provides + fun provideCreatePresentationFileUseCase(cellsScope: CellsScope): CreatePresentationFileUseCase = cellsScope.createPresentationFileUseCase + @ViewModelScoped @Provides fun provideMoveNodeUseCase(cellsScope: CellsScope): MoveNodeUseCase = cellsScope.moveNodeUseCase diff --git a/app/src/main/kotlin/com/wire/android/navigation/WireMainNavGraph.kt b/app/src/main/kotlin/com/wire/android/navigation/WireMainNavGraph.kt index 783477b35c3..3b63b8c18e8 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/WireMainNavGraph.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/WireMainNavGraph.kt @@ -23,6 +23,7 @@ import com.ramcosta.composedestinations.spec.NavGraphSpec import com.wire.android.feature.cells.ui.destinations.AddRemoveTagsScreenDestination import com.wire.android.feature.cells.ui.destinations.ConversationFilesScreenDestination import com.wire.android.feature.cells.ui.destinations.ConversationFilesWithSlideInTransitionScreenDestination +import com.wire.android.feature.cells.ui.destinations.CreateFileScreenDestination import com.wire.android.feature.cells.ui.destinations.CreateFolderScreenDestination import com.wire.android.feature.cells.ui.destinations.MoveToFolderScreenDestination import com.wire.android.feature.cells.ui.destinations.PublicLinkExpirationScreenDestination @@ -43,6 +44,7 @@ object WireMainNavGraph : NavGraphSpec { .plus(ConversationFilesScreenDestination) .plus(ConversationFilesWithSlideInTransitionScreenDestination) .plus(CreateFolderScreenDestination) + .plus(CreateFileScreenDestination) .plus(MoveToFolderScreenDestination) .plus(RecycleBinScreenDestination) .plus(RenameNodeScreenDestination) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt index fbc075b71af..936389ef7de 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt @@ -29,7 +29,7 @@ import com.wire.android.feature.cells.ui.edit.OnlineEditor import com.wire.android.ui.common.multipart.MultipartAttachmentUi import com.wire.android.ui.common.multipart.toUiModel import com.wire.android.util.FileManager -import com.wire.kalium.cells.domain.usecase.DownloadCellFileUseCase +import com.wire.kalium.cells.domain.usecase.download.DownloadCellFileUseCase import com.wire.kalium.cells.domain.usecase.GetEditorUrlUseCase import com.wire.kalium.common.functional.onSuccess import com.wire.kalium.logic.data.asset.AssetTransferStatus diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModelTest.kt index ab814499f7a..3045f06b856 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModelTest.kt @@ -23,7 +23,7 @@ import com.wire.android.framework.FakeKaliumFileSystem import com.wire.android.ui.common.multipart.AssetSource import com.wire.android.ui.common.multipart.MultipartAttachmentUi import com.wire.android.util.FileManager -import com.wire.kalium.cells.domain.usecase.DownloadCellFileUseCase +import com.wire.kalium.cells.domain.usecase.download.DownloadCellFileUseCase import com.wire.kalium.cells.domain.usecase.GetEditorUrlUseCase import com.wire.kalium.common.functional.right import com.wire.kalium.logic.data.asset.AssetTransferStatus diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt index 346eb61eaa1..49c01e59a02 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt @@ -39,7 +39,7 @@ import com.wire.android.ui.common.ActionsViewModel import com.wire.android.ui.common.DEFAULT_SEARCH_QUERY_DEBOUNCE import com.wire.kalium.cells.domain.model.Node import com.wire.kalium.cells.domain.usecase.DeleteCellAssetUseCase -import com.wire.kalium.cells.domain.usecase.DownloadCellFileUseCase +import com.wire.kalium.cells.domain.usecase.download.DownloadCellFileUseCase import com.wire.kalium.cells.domain.usecase.GetAllTagsUseCase import com.wire.kalium.cells.domain.usecase.GetEditorUrlUseCase import com.wire.kalium.cells.domain.usecase.GetPaginatedFilesFlowUseCase diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt index fd3f18835b6..65773c0e31a 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt @@ -52,8 +52,11 @@ import androidx.paging.compose.collectAsLazyPagingItems import com.wire.android.feature.cells.R import com.wire.android.feature.cells.domain.model.AttachmentFileType import com.wire.android.feature.cells.ui.common.Breadcrumbs +import com.wire.android.feature.cells.ui.create.FileTypeBottomSheetDialog +import com.wire.android.feature.cells.ui.create.file.CreateFileScreenNavArgs import com.wire.android.feature.cells.ui.destinations.AddRemoveTagsScreenDestination import com.wire.android.feature.cells.ui.destinations.ConversationFilesWithSlideInTransitionScreenDestination +import com.wire.android.feature.cells.ui.destinations.CreateFileScreenDestination import com.wire.android.feature.cells.ui.destinations.CreateFolderScreenDestination import com.wire.android.feature.cells.ui.destinations.MoveToFolderScreenDestination import com.wire.android.feature.cells.ui.destinations.PublicLinkScreenDestination @@ -159,6 +162,7 @@ fun ConversationFilesScreenContent( breadcrumbs: Array? = emptyArray(), ) { val newActionBottomSheetState = rememberWireModalSheetState() + val fileTypeBottomSheetState = rememberWireModalSheetState() val optionsBottomSheetState = rememberWireModalSheetState() val isFabVisible = when { @@ -176,6 +180,10 @@ fun ConversationFilesScreenContent( onCreateFolder = { newActionBottomSheetState.hide() navigator.navigate(NavigationCommand(CreateFolderScreenDestination(currentNodeUuid))) + }, + onCreateFile = { + newActionBottomSheetState.hide() + fileTypeBottomSheetState.show() } ) @@ -198,6 +206,19 @@ fun ConversationFilesScreenContent( } ) + FileTypeBottomSheetDialog( + sheetState = fileTypeBottomSheetState, + onDismiss = { + fileTypeBottomSheetState.hide() + }, + onItemSelected = { + currentNodeUuid?.let { uuid -> + navigator.navigate(NavigationCommand(CreateFileScreenDestination(CreateFileScreenNavArgs(uuid, it)))) + } + fileTypeBottomSheetState.hide() + }, + ) + CollapsingTopBarScaffold( modifier = modifier, topBarHeader = { diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/FileTypeBottomSheetDialog.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/FileTypeBottomSheetDialog.kt new file mode 100644 index 00000000000..c5370ebdbd6 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/FileTypeBottomSheetDialog.kt @@ -0,0 +1,107 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.cells.ui.create + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.wire.android.feature.cells.R +import com.wire.android.feature.cells.ui.create.file.FileType +import com.wire.android.ui.common.bottomsheet.MenuBottomSheetItem +import com.wire.android.ui.common.bottomsheet.WireMenuModalSheetContent +import com.wire.android.ui.common.bottomsheet.WireModalSheetLayout +import com.wire.android.ui.common.bottomsheet.WireModalSheetState +import com.wire.android.ui.common.bottomsheet.WireSheetValue +import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState +import com.wire.android.ui.common.preview.MultipleThemePreviews +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireDimensions + +@Composable +fun FileTypeBottomSheetDialog( + sheetState: WireModalSheetState, + onDismiss: () -> Unit, + onItemSelected: (FileType) -> Unit, +) { + WireModalSheetLayout( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + WireMenuModalSheetContent( + menuItems = buildList { + add { + BottomSheetItem( + title = stringResource(R.string.file_type_bottom_sheet_document), + icon = R.drawable.ic_file_type_doc, + onClicked = { onItemSelected(FileType.DOCUMENT) }, + ) + } + add { + BottomSheetItem( + title = stringResource(R.string.file_type_bottom_sheet_spreadsheet), + icon = R.drawable.ic_file_type_spreadsheet, + onClicked = { onItemSelected(FileType.SPREADSHEET) }, + ) + } + add { + BottomSheetItem( + title = stringResource(R.string.file_type_bottom_sheet_presentation), + icon = R.drawable.ic_file_type_presentation, + onClicked = { onItemSelected(FileType.PRESENTATION) }, + ) + } + } + ) + } +} + +@Composable +private fun BottomSheetItem( + title: String, + icon: Int = R.drawable.ic_folder, + onClicked: () -> Unit, +) { + MenuBottomSheetItem( + title = title, + onItemClick = onClicked, + leading = { + Image( + painter = painterResource(id = icon), + contentDescription = null, + modifier = Modifier + .size(MaterialTheme.wireDimensions.wireIconButtonSize) + ) + }, + ) +} + +@MultipleThemePreviews +@Composable +fun PreviewFileTypeBottomSheetDialog() { + WireTheme { + FileTypeBottomSheetDialog( + sheetState = rememberWireModalSheetState(WireSheetValue.Expanded(value = Unit)), + onDismiss = {}, + onItemSelected = {}, + ) + } +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/CreateFileScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/CreateFileScreen.kt new file mode 100644 index 00000000000..9de6f8459ec --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/CreateFileScreen.kt @@ -0,0 +1,184 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.cells.ui.create.file + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.navigation.compose.hiltViewModel +import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.wire.android.feature.cells.R +import com.wire.android.feature.cells.ui.common.FileNameError +import com.wire.android.navigation.PreviewNavigator +import com.wire.android.navigation.PreviewResultBackNavigator +import com.wire.android.navigation.WireNavigator +import com.wire.android.navigation.annotation.features.cells.WireDestination +import com.wire.android.navigation.style.PopUpNavigationAnimation +import com.wire.android.ui.common.HandleActions +import com.wire.android.ui.common.WireDialog +import com.wire.android.ui.common.WireDialogButtonProperties +import com.wire.android.ui.common.WireDialogButtonType +import com.wire.android.ui.common.button.WireButtonState +import com.wire.android.ui.common.button.WirePrimaryButton +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.preview.MultipleThemePreviews +import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.common.textfield.WireTextField +import com.wire.android.ui.common.textfield.WireTextFieldState +import com.wire.android.ui.common.topappbar.NavigationIconType +import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.ui.theme.wireDimensions +import java.util.Locale + +@WireDestination( + style = PopUpNavigationAnimation::class, + navArgsDelegate = CreateFileScreenNavArgs::class, +) +@Composable +fun CreateFileScreen( + navigator: WireNavigator, + resultNavigator: ResultBackNavigator, + modifier: Modifier = Modifier, + createFileViewModel: CreateFileViewModel = hiltViewModel() +) { + val showErrorDialog = remember { mutableStateOf(false) } + + if (showErrorDialog.value) { + WireDialog( + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false, usePlatformDefaultWidth = false), + title = stringResource(id = R.string.cells_create_file), + text = stringResource(id = R.string.create_file_error), + onDismiss = { showErrorDialog.value = false }, + dismissButtonProperties = WireDialogButtonProperties( + onClick = { showErrorDialog.value = false }, + text = stringResource(id = R.string.cancel), + type = WireDialogButtonType.Secondary, + ) + ) + } + + WireScaffold( + modifier = modifier, + topBar = { + WireCenterAlignedTopAppBar( + onNavigationPressed = { navigator.navigateBack() }, + navigationIconType = NavigationIconType.Close(), + elevation = dimensions().spacing0x, + title = stringResource(id = R.string.create_file_screen_title, createFileViewModel.fileExtension), + ) + }, + bottomBar = { + AnimatedVisibility( + visible = true, + enter = fadeIn() + expandVertically(), + exit = shrinkVertically() + fadeOut(), + ) { + Surface( + color = MaterialTheme.wireColorScheme.background, + shadowElevation = MaterialTheme.wireDimensions.bottomNavigationShadowElevation + ) { + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier + .padding(dimensions().spacing16x) + ) { + with(createFileViewModel) { + WirePrimaryButton( + text = stringResource(R.string.cells_create_file), + onClick = { + createFileViewModel.createFile(fileNameTextFieldState.text.toString()) + }, + state = if (viewState.saveEnabled && !viewState.loading) { + WireButtonState.Default + } else { + WireButtonState.Disabled + }, + loading = viewState.loading + ) + } + } + } + } + } + ) { + WireTextField( + textState = createFileViewModel.fileNameTextFieldState, + placeholderText = stringResource(R.string.cell_file_name), + labelText = stringResource(R.string.cell_file_name).uppercase(Locale.getDefault()), + modifier = Modifier + .padding(it) + .padding( + top = dimensions().spacing32x, + start = dimensions().spacing16x, + end = dimensions().spacing16x + ), + state = computeNameErrorState(createFileViewModel.viewState.error), + ) + } + + HandleActions(createFileViewModel.actions) { action -> + when (action) { + CreateFileViewModelAction.Success -> { + resultNavigator.setResult(true) + resultNavigator.navigateBack() + } + CreateFileViewModelAction.Failure -> { + showErrorDialog.value = true + } + } + } +} + +@Composable +private fun computeNameErrorState(error: FileNameError?): WireTextFieldState { + val messageRes = when (error) { + FileNameError.NameEmpty -> R.string.cell_file_name + FileNameError.NameExceedLimit -> R.string.long_file_name_error + FileNameError.NameAlreadyExist -> R.string.rename_file_already_exist + FileNameError.InvalidName -> R.string.rename_invalid_name + null -> return WireTextFieldState.Default + } + + return WireTextFieldState.Error(stringResource(id = messageRes)) +} + +@MultipleThemePreviews +@Composable +fun PreviewCreateFileScreen() { + WireTheme { + CreateFileScreen( + navigator = PreviewNavigator, + resultNavigator = PreviewResultBackNavigator as ResultBackNavigator, + ) + } +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/CreateFileScreenNavArgs.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/CreateFileScreenNavArgs.kt new file mode 100644 index 00000000000..28e2669c1fc --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/CreateFileScreenNavArgs.kt @@ -0,0 +1,23 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.cells.ui.create.file + +data class CreateFileScreenNavArgs( + val uuid: String, + val fileType: FileType, +) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/CreateFileViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/CreateFileViewModel.kt new file mode 100644 index 00000000000..235fcf42b4c --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/CreateFileViewModel.kt @@ -0,0 +1,114 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.cells.ui.create.file + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.wire.android.feature.cells.ui.common.FileNameError +import com.wire.android.feature.cells.ui.common.validateFileName +import com.wire.android.feature.cells.ui.navArgs +import com.wire.android.ui.common.ActionsViewModel +import com.wire.android.ui.common.textfield.textAsFlow +import com.wire.kalium.cells.domain.usecase.create.CreateDocumentFileUseCase +import com.wire.kalium.cells.domain.usecase.create.CreatePresentationFileUseCase +import com.wire.kalium.cells.domain.usecase.create.CreateSpreadsheetFileUseCase +import com.wire.kalium.common.functional.onFailure +import com.wire.kalium.common.functional.onSuccess +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class CreateFileViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, + private val createPresentationFileUseCase: CreatePresentationFileUseCase, + private val createDocumentFileUseCase: CreateDocumentFileUseCase, + private val createSpreadsheetFileUseCase: CreateSpreadsheetFileUseCase +) : ActionsViewModel() { + + private val navArgs: CreateFileScreenNavArgs = savedStateHandle.navArgs() + + val fileExtension: String = navArgs.fileType.getExtension() + + val fileNameTextFieldState: TextFieldState = TextFieldState() + + var viewState: CreateFolderViewState by mutableStateOf(CreateFolderViewState()) + private set + + init { + viewModelScope.launch { + fileNameTextFieldState.textAsFlow().map { it.trim() }.collectLatest { name -> + val fileValidationResult = name.validateFileName().takeIf { name.isNotEmpty() } + viewState = viewState.copy( + saveEnabled = fileValidationResult == null && name.isNotEmpty(), + error = fileValidationResult + ) + } + } + } + + internal fun createFile(fileName: String) = viewModelScope.launch { + viewState = viewState.copy(loading = true) + + val fullPath = "${navArgs.uuid}/${fileName.trim()}" + + val result = when (navArgs.fileType) { + FileType.DOCUMENT -> + createDocumentFileUseCase(fullPath) + + FileType.SPREADSHEET -> + createSpreadsheetFileUseCase(fullPath) + + FileType.PRESENTATION -> + createPresentationFileUseCase(fullPath) + } + + result.onSuccess { + sendAction(CreateFileViewModelAction.Success) + }.onFailure { + sendAction(CreateFileViewModelAction.Failure) + } + + viewState = viewState.copy(loading = false) + } + + + fun FileType.getExtension(): String = + when (this) { + FileType.PRESENTATION -> ".pptx" + FileType.DOCUMENT -> ".docx" + FileType.SPREADSHEET -> ".xlsx" + } +} + +sealed interface CreateFileViewModelAction { + data object Success : CreateFileViewModelAction + data object Failure : CreateFileViewModelAction +} + +data class CreateFolderViewState( + val loading: Boolean = false, + val saveEnabled: Boolean = false, + val error: FileNameError? = null, +) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/FileType.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/FileType.kt new file mode 100644 index 00000000000..6063ce1f733 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/file/FileType.kt @@ -0,0 +1,22 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.cells.ui.create.file + +enum class FileType { + DOCUMENT, PRESENTATION, SPREADSHEET +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/createfolder/CreateFolderScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/folder/CreateFolderScreen.kt similarity index 96% rename from features/cells/src/main/java/com/wire/android/feature/cells/ui/createfolder/CreateFolderScreen.kt rename to features/cells/src/main/java/com/wire/android/feature/cells/ui/create/folder/CreateFolderScreen.kt index 84209ac8e1e..dfcfe634e7d 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/createfolder/CreateFolderScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/folder/CreateFolderScreen.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.feature.cells.ui.createfolder +package com.wire.android.feature.cells.ui.create.folder import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically @@ -117,7 +117,7 @@ fun CreateFolderScreen( text = stringResource(R.string.cells_create_folder), onClick = { createFolder( - folderName = fileNameTextFieldState.text.toString() + folderName = folderNameTextFieldState.text.toString() ) }, state = if (viewState.saveEnabled && !viewState.loading) { @@ -134,7 +134,7 @@ fun CreateFolderScreen( } ) { WireTextField( - textState = createFolderViewModel.fileNameTextFieldState, + textState = createFolderViewModel.folderNameTextFieldState, placeholderText = stringResource(R.string.cells_folder_name), labelText = stringResource(R.string.cells_folder_name).uppercase(Locale.getDefault()), modifier = Modifier @@ -166,7 +166,7 @@ private fun computeNameErrorState(error: FileNameError?): WireTextFieldState { val messageRes = when (error) { FileNameError.NameEmpty -> R.string.cells_folder_name FileNameError.NameExceedLimit -> R.string.rename_long_folder_name_error - FileNameError.NameAlreadyExist -> R.string.rename_already_exist + FileNameError.NameAlreadyExist -> R.string.rename_folder_already_exist FileNameError.InvalidName -> R.string.rename_invalid_name null -> return WireTextFieldState.Default } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/createfolder/CreateFolderScreenNavArgs.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/folder/CreateFolderScreenNavArgs.kt similarity index 93% rename from features/cells/src/main/java/com/wire/android/feature/cells/ui/createfolder/CreateFolderScreenNavArgs.kt rename to features/cells/src/main/java/com/wire/android/feature/cells/ui/create/folder/CreateFolderScreenNavArgs.kt index 4725131fead..0512d1b58df 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/createfolder/CreateFolderScreenNavArgs.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/folder/CreateFolderScreenNavArgs.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.feature.cells.ui.createfolder +package com.wire.android.feature.cells.ui.create.folder data class CreateFolderScreenNavArgs( val uuid: String? diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/createfolder/CreateFolderViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/folder/CreateFolderViewModel.kt similarity index 91% rename from features/cells/src/main/java/com/wire/android/feature/cells/ui/createfolder/CreateFolderViewModel.kt rename to features/cells/src/main/java/com/wire/android/feature/cells/ui/create/folder/CreateFolderViewModel.kt index 15cf11af971..a660699839d 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/createfolder/CreateFolderViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/create/folder/CreateFolderViewModel.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.feature.cells.ui.createfolder +package com.wire.android.feature.cells.ui.create.folder import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.getValue @@ -28,7 +28,7 @@ import com.wire.android.feature.cells.ui.common.validateFileName import com.wire.android.feature.cells.ui.navArgs import com.wire.android.ui.common.ActionsViewModel import com.wire.android.ui.common.textfield.textAsFlow -import com.wire.kalium.cells.domain.usecase.CreateFolderUseCase +import com.wire.kalium.cells.domain.usecase.create.CreateFolderUseCase import com.wire.kalium.common.functional.onFailure import com.wire.kalium.common.functional.onSuccess import dagger.hilt.android.lifecycle.HiltViewModel @@ -45,14 +45,14 @@ class CreateFolderViewModel @Inject constructor( private val navArgs: CreateFolderScreenNavArgs = savedStateHandle.navArgs() - val fileNameTextFieldState: TextFieldState = TextFieldState() + val folderNameTextFieldState: TextFieldState = TextFieldState() var viewState: CreateFolderViewState by mutableStateOf(CreateFolderViewState()) private set init { viewModelScope.launch { - fileNameTextFieldState.textAsFlow().map { it.trim() }.collectLatest { name -> + folderNameTextFieldState.textAsFlow().map { it.trim() }.collectLatest { name -> val fileValidationResult = name.validateFileName().takeIf { name.isNotEmpty() } viewState = viewState.copy( saveEnabled = fileValidationResult == null && name.isNotEmpty(), diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/dialog/CellsNewActionBottomSheet.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/dialog/CellsNewActionBottomSheet.kt index 7da774053d6..f835e435631 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/dialog/CellsNewActionBottomSheet.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/dialog/CellsNewActionBottomSheet.kt @@ -40,6 +40,7 @@ internal fun CellsNewActionBottomSheet( sheetState: WireModalSheetState, onDismiss: () -> Unit, onCreateFolder: () -> Unit, + onCreateFile: () -> Unit, ) { WireModalSheetLayout( onDismissRequest = onDismiss, @@ -51,7 +52,12 @@ internal fun CellsNewActionBottomSheet( CreateFolderSheetItem( title = stringResource(R.string.cells_create_folder), onClicked = onCreateFolder, - enabled = true + ) + } + add { + CreateFileSheetItem( + title = stringResource(R.string.cells_create_file), + onClicked = onCreateFile, ) } } @@ -63,7 +69,6 @@ internal fun CellsNewActionBottomSheet( private fun CreateFolderSheetItem( title: String, onClicked: () -> Unit, - enabled: Boolean, ) { MenuBottomSheetItem( title = title, @@ -76,7 +81,25 @@ private fun CreateFolderSheetItem( .size(MaterialTheme.wireDimensions.wireIconButtonSize) ) }, - enabled = enabled + ) +} + +@Composable +private fun CreateFileSheetItem( + title: String, + onClicked: () -> Unit, +) { + MenuBottomSheetItem( + title = title, + onItemClick = onClicked, + leading = { + Icon( + painter = painterResource(id = com.wire.android.ui.common.R.drawable.ic_plus), + contentDescription = null, + modifier = Modifier + .size(MaterialTheme.wireDimensions.wireIconButtonSize) + ) + }, ) } @@ -87,7 +110,8 @@ private fun PreviewFilesNewActionsBottomSheet() { CellsNewActionBottomSheet( sheetState = rememberWireModalSheetState(WireSheetValue.Expanded(value = Unit)), onDismiss = {}, - onCreateFolder = {} + onCreateFolder = {}, + onCreateFile = {} ) } } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeScreen.kt index c0b53e8b4db..a1a497e3d4f 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeScreen.kt @@ -151,9 +151,13 @@ private fun computeNameErrorState(error: FileNameError?, isFolder: Boolean): Wir val messageRes = when (error) { FileNameError.NameEmpty -> if (isFolder) R.string.rename_enter_folder_name else R.string.rename_enter_file_name + FileNameError.NameExceedLimit -> if (isFolder) R.string.rename_long_folder_name_error else R.string.rename_long_file_name_error - FileNameError.NameAlreadyExist -> R.string.rename_already_exist + + FileNameError.NameAlreadyExist -> + if (isFolder) R.string.rename_folder_already_exist else R.string.rename_file_already_exist + FileNameError.InvalidName -> R.string.rename_invalid_name null -> return WireTextFieldState.Default } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModel.kt index 54608dac720..41ed4f1e136 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModel.kt @@ -34,7 +34,7 @@ import com.wire.android.util.addBeforeExtension import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.util.ui.UIText import com.wire.kalium.cells.domain.model.NodeVersion -import com.wire.kalium.cells.domain.usecase.DownloadCellVersionUseCase +import com.wire.kalium.cells.domain.usecase.download.DownloadCellVersionUseCase import com.wire.kalium.cells.domain.usecase.versioning.GetNodeVersionsUseCase import com.wire.kalium.cells.domain.usecase.versioning.RestoreNodeVersionUseCase import com.wire.kalium.common.functional.onFailure diff --git a/features/cells/src/main/res/values-de/strings.xml b/features/cells/src/main/res/values-de/strings.xml index 56cb7c2edae..464d135d9bb 100644 --- a/features/cells/src/main/res/values-de/strings.xml +++ b/features/cells/src/main/res/values-de/strings.xml @@ -100,7 +100,7 @@ Datei wurde umbenannt Ordner wurde umbenannt Umbenennen nicht möglich - Eine Datei mit diesem Namen ist bereits vorhanden + Eine Datei mit diesem Namen ist bereits vorhanden Filter Tags Tags auswählen diff --git a/features/cells/src/main/res/values-ru/strings.xml b/features/cells/src/main/res/values-ru/strings.xml index a33c10572fd..6c5a0b03178 100644 --- a/features/cells/src/main/res/values-ru/strings.xml +++ b/features/cells/src/main/res/values-ru/strings.xml @@ -109,7 +109,7 @@ Папка переименована Не удается переименовать Используйте название без \"/\" и не начинающееся с \".\" - Файл с таким названием уже существует + Файл с таким названием уже существует Фильтр Теги выбрать теги diff --git a/features/cells/src/main/res/values/strings.xml b/features/cells/src/main/res/values/strings.xml index 369c99457cc..900864b12b5 100644 --- a/features/cells/src/main/res/values/strings.xml +++ b/features/cells/src/main/res/values/strings.xml @@ -108,7 +108,8 @@ Folder was renamed Unable to rename Use a name without "/" and not starting with "." - File with this name already exists + File with this name already exists + Folder with this name already exists Filter Tags select tags @@ -195,4 +196,12 @@ Show Downloading… Search files or folders + Create File + Unable to create file. Please try again + File Name + Use a shorter file name (at most 64 characters) + Document + Spreadsheet + Presentation + Create %1$s file diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt index ad3278a93bc..85c94eb10fe 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt @@ -31,7 +31,7 @@ import com.wire.android.feature.cells.util.FileHelper import com.wire.android.feature.cells.util.FileNameResolver import com.wire.kalium.cells.domain.model.Node import com.wire.kalium.cells.domain.usecase.DeleteCellAssetUseCase -import com.wire.kalium.cells.domain.usecase.DownloadCellFileUseCase +import com.wire.kalium.cells.domain.usecase.download.DownloadCellFileUseCase import com.wire.kalium.cells.domain.usecase.GetAllTagsUseCase import com.wire.kalium.cells.domain.usecase.GetEditorUrlUseCase import com.wire.kalium.cells.domain.usecase.GetPaginatedFilesFlowUseCase diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/create/file/CreateFileViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/create/file/CreateFileViewModelTest.kt new file mode 100644 index 00000000000..a59ea857a06 --- /dev/null +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/create/file/CreateFileViewModelTest.kt @@ -0,0 +1,266 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.cells.ui.create.file + +import androidx.lifecycle.SavedStateHandle +import com.wire.android.feature.cells.ui.common.FileNameError +import com.wire.kalium.cells.domain.usecase.create.CreateDocumentFileUseCase +import com.wire.kalium.cells.domain.usecase.create.CreatePresentationFileUseCase +import com.wire.kalium.cells.domain.usecase.create.CreateSpreadsheetFileUseCase +import com.wire.kalium.common.error.CoreFailure +import com.wire.kalium.common.functional.Either +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class CreateFileViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + @BeforeEach + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @AfterEach + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `given empty file name, when name changes, then save is disabled and no error`() = runTest(StandardTestDispatcher()) { + // Given + val (_, viewModel) = Arrangement() + .withFileTypeReturning(FileType.DOCUMENT) + .arrange() + + // When + viewModel.fileNameTextFieldState.edit { replace(0, length, "") } + advanceUntilIdle() + + // Then + assertFalse(viewModel.viewState.saveEnabled) + assertEquals(null, viewModel.viewState.error) + } + + + @Test + fun `given valid file name, when name changes, then save is enabled and no error`() = runTest { + // Given + val (_, viewModel) = Arrangement() + .withFileTypeReturning(FileType.DOCUMENT) + .arrange() + + // When + viewModel.fileNameTextFieldState.edit { replace(0, length, "Valid Name") } + advanceUntilIdle() + + // Then + assertTrue(viewModel.viewState.saveEnabled) + assertEquals(null, viewModel.viewState.error) + } + + @Test + fun `given invalid file name, when name changes, then save is disabled and error is shown`() = runTest { + // Given + val (_, viewModel) = Arrangement() + .withFileTypeReturning(FileType.DOCUMENT) + .arrange() + + // When + viewModel.fileNameTextFieldState.edit { replace(0, length, "Invalid/Name") } + advanceUntilIdle() + + // Then + assertFalse(viewModel.viewState.saveEnabled) + assertEquals(FileNameError.InvalidName, viewModel.viewState.error) + } + + + @Test + fun `given document file type, when createFile is called, then document use case is invoked`() = runTest { + // Given + val (arrangement, viewModel) = Arrangement() + .withFileTypeReturning(FileType.DOCUMENT) + .withCreateDocumentFileUseCaseReturning(Either.Right(Unit)) + .arrange() + val fileName = "NewDoc" + + // When + viewModel.createFile(fileName) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { arrangement.createDocumentFileUseCase(any()) } + assertEquals(CreateFileViewModelAction.Success, viewModel.actions.first()) + } + + @Test + fun `given failure from createDocumentUseCase, when createFile is called, then failure action is sent`() = runTest { + // Given + val (arrangement, viewModel) = Arrangement() + .withFileTypeReturning(FileType.DOCUMENT) + .withCreateDocumentFileUseCaseReturning(Either.Left(CoreFailure.InvalidEventSenderID)) + .arrange() + val fileName = "NewDoc" + + // When + viewModel.createFile(fileName) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { arrangement.createDocumentFileUseCase(any()) } + assertEquals(CreateFileViewModelAction.Failure, viewModel.actions.first()) + } + + @Test + fun `given spreadsheet file type, when createFile is called, then spreadsheet use case is invoked`() = runTest { + // Given + val (arrangement, viewModel) = Arrangement() + .withFileTypeReturning(FileType.SPREADSHEET) + .withCreateSpreadsheetFileUseCaseReturning(Either.Right(Unit)) + .arrange() + val fileName = "NewSheet" + + // When + viewModel.createFile(fileName) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { arrangement.createSpreadsheetFileUseCase(any()) } + assertEquals(CreateFileViewModelAction.Success, viewModel.actions.first()) + } + + @Test + fun `given failure from createSpreadSheetUseCase, when createFile is called, then failure action is sent`() = runTest { + // Given + val (arrangement, viewModel) = Arrangement() + .withFileTypeReturning(FileType.SPREADSHEET) + .withCreateSpreadsheetFileUseCaseReturning(Either.Left(CoreFailure.InvalidEventSenderID)) + .arrange() + val fileName = "NewSheet" + + // When + viewModel.createFile(fileName) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { arrangement.createSpreadsheetFileUseCase(any()) } + assertEquals(CreateFileViewModelAction.Failure, viewModel.actions.first()) + } + + @Test + fun `given presentation file type, when createFile is called, then presentation use case is invoked`() = runTest { + // Given + val (arrangement, viewModel) = Arrangement() + .withFileTypeReturning(FileType.PRESENTATION) + .withCreatePresentationFileUseCaseReturning(Either.Right(Unit)) + .arrange() + val fileName = "NewSlides" + + // When + viewModel.createFile(fileName) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { arrangement.createPresentationFileUseCase(any()) } + assertEquals(CreateFileViewModelAction.Success, viewModel.actions.first()) + } + + @Test + fun `given failure from createPresentationUseCase, when createFile is called, then failure action is sent`() = runTest { + // Given + val (arrangement, viewModel) = Arrangement() + .withFileTypeReturning(FileType.PRESENTATION) + .withCreatePresentationFileUseCaseReturning(Either.Left(CoreFailure.InvalidEventSenderID)) + .arrange() + val fileName = "NewSlides" + + // When + viewModel.createFile(fileName) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { arrangement.createPresentationFileUseCase(any()) } + assertEquals(CreateFileViewModelAction.Failure, viewModel.actions.first()) + } + + private class Arrangement { + + @MockK + lateinit var savedStateHandle: SavedStateHandle + + @MockK + lateinit var createPresentationFileUseCase: CreatePresentationFileUseCase + + @MockK + lateinit var createDocumentFileUseCase: CreateDocumentFileUseCase + + @MockK + lateinit var createSpreadsheetFileUseCase: CreateSpreadsheetFileUseCase + + private val testUuid = "test-uuid" + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + every { savedStateHandle.get("uuid") } returns testUuid + } + + private val viewModel by lazy { + CreateFileViewModel( + savedStateHandle = savedStateHandle, + createPresentationFileUseCase = createPresentationFileUseCase, + createDocumentFileUseCase = createDocumentFileUseCase, + createSpreadsheetFileUseCase = createSpreadsheetFileUseCase, + ) + } + + fun withFileTypeReturning(result: FileType) = apply { + every { savedStateHandle.get("fileType") } returns result + } + + fun withCreateDocumentFileUseCaseReturning(result: Either) = apply { + coEvery { createDocumentFileUseCase(any()) } returns result + } + + fun withCreateSpreadsheetFileUseCaseReturning(result: Either) = apply { + coEvery { createSpreadsheetFileUseCase(any()) } returns result + } + + fun withCreatePresentationFileUseCaseReturning(result: Either) = apply { + coEvery { createPresentationFileUseCase(any()) } returns result + } + + fun arrange() = this to viewModel + } +} diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/create/folder/CreateFolderViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/create/folder/CreateFolderViewModelTest.kt new file mode 100644 index 00000000000..25509aba7e4 --- /dev/null +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/create/folder/CreateFolderViewModelTest.kt @@ -0,0 +1,167 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.cells.ui.create.folder + +import androidx.lifecycle.SavedStateHandle +import com.wire.android.feature.cells.ui.common.FileNameError +import com.wire.kalium.cells.domain.usecase.create.CreateFolderUseCase +import com.wire.kalium.common.error.CoreFailure +import com.wire.kalium.common.functional.Either +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class CreateFolderViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + @BeforeEach + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @AfterEach + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `given empty folder name, when name changes, then save is disabled and no error`() = runTest { + // Given + val (_, viewModel) = Arrangement() + .arrange() + + // When + viewModel.folderNameTextFieldState.edit { replace(0, length, "") } + advanceUntilIdle() + + // Then + assertFalse(viewModel.viewState.saveEnabled) + assertEquals(null, viewModel.viewState.error) + } + + @Test + fun `given valid folder name, when name changes, then save is enabled and no error`() = runTest { + // Given + val (_, viewModel) = Arrangement() + .arrange() + + // When + viewModel.folderNameTextFieldState.edit { replace(0, length, "Valid Folder") } + advanceUntilIdle() + + // Then + assertTrue(viewModel.viewState.saveEnabled) + assertEquals(null, viewModel.viewState.error) + } + + @Test + fun `given invalid folder name, when name changes, then save is disabled and error is shown`() = runTest { + // Given + val (_, viewModel) = Arrangement() + .arrange() + + // When + viewModel.folderNameTextFieldState.edit { replace(0, length, "Invalid/Folder") } + advanceUntilIdle() + + // Then + assertFalse(viewModel.viewState.saveEnabled) + assertEquals(FileNameError.InvalidName, viewModel.viewState.error) + } + + @Test + fun `given valid folder name, when createFolder is called, then create folder use case is invoked`() = runTest { + // Given + val (arrangement, viewModel) = Arrangement() + .withCreateFolderUseCaseReturning(Either.Right(Unit)) + .arrange() + val folderName = "NewFolder" + + // When + viewModel.createFolder(folderName) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { arrangement.createFolderUseCase(any()) } + assertEquals(CreateFolderViewModelAction.Success, viewModel.actions.first()) + } + + @Test + fun `given failure from createFolderUseCase, when createFolder is called, then failure action is sent`() = runTest { + // Given + val (arrangement, viewModel) = Arrangement() + .withCreateFolderUseCaseReturning(Either.Left(CoreFailure.InvalidEventSenderID)) + .arrange() + val folderName = "NewFolder" + + // When + viewModel.createFolder(folderName) + advanceUntilIdle() + + // Then + coVerify(exactly = 1) { arrangement.createFolderUseCase(any()) } + assertEquals(CreateFolderViewModelAction.Failure, viewModel.actions.first()) + } + + private class Arrangement { + + @MockK + lateinit var savedStateHandle: SavedStateHandle + + @MockK + lateinit var createFolderUseCase: CreateFolderUseCase + + private val testUuid = "test-uuid" + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + every { savedStateHandle.get("uuid") } returns testUuid + } + + private val viewModel by lazy { + CreateFolderViewModel( + savedStateHandle = savedStateHandle, + createFolderUseCase = createFolderUseCase, + ) + } + + fun withCreateFolderUseCaseReturning(result: Either) = apply { + coEvery { createFolderUseCase(any()) } returns result + } + + fun arrange() = this to viewModel + } +} diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModelTest.kt index 06a1df84bd5..97df51f8b10 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModelTest.kt @@ -31,7 +31,7 @@ import com.wire.android.util.ui.resolveForTest import com.wire.android.util.ui.toUIText import com.wire.kalium.cells.domain.model.NodeVersion import com.wire.kalium.cells.domain.model.PreSignedUrl -import com.wire.kalium.cells.domain.usecase.DownloadCellVersionUseCase +import com.wire.kalium.cells.domain.usecase.download.DownloadCellVersionUseCase import com.wire.kalium.cells.domain.usecase.versioning.GetNodeVersionsUseCase import com.wire.kalium.cells.domain.usecase.versioning.RestoreNodeVersionUseCase import com.wire.kalium.common.error.CoreFailure diff --git a/kalium b/kalium index a7b551cc572..90177a0ae2a 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit a7b551cc57277942e43cd100ab99068459a9cec2 +Subproject commit 90177a0ae2a9a1da8ae8b8d503288b2651b942f5