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 af5abd41a56..dde9f6df3c9 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 @@ -26,14 +26,15 @@ 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.GetFoldersUseCase -import com.wire.kalium.cells.domain.usecase.GetPaginatedNodesUseCase import com.wire.kalium.cells.domain.usecase.GetPaginatedFilesFlowUseCase +import com.wire.kalium.cells.domain.usecase.GetPaginatedNodesUseCase import com.wire.kalium.cells.domain.usecase.MoveNodeUseCase import com.wire.kalium.cells.domain.usecase.ObserveAttachmentDraftsUseCase import com.wire.kalium.cells.domain.usecase.PublishAttachmentsUseCase import com.wire.kalium.cells.domain.usecase.RefreshCellAssetStateUseCase import com.wire.kalium.cells.domain.usecase.RemoveAttachmentDraftUseCase import com.wire.kalium.cells.domain.usecase.RemoveAttachmentDraftsUseCase +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.SetWireCellForConversationUseCase @@ -141,4 +142,9 @@ class CellsModule { @Provides fun provideRestoreNodeFromRecycleBinUseCase(cellsScope: CellsScope): RestoreNodeFromRecycleBinUseCase = cellsScope.restoreNodeFromRecycleBin + + @ViewModelScoped + @Provides + fun provideRenameNodeUseCase(cellsScope: CellsScope): RenameNodeUseCase = + cellsScope.renameNodeUseCase } 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 6a086aa43dc..a82e7328f8d 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/WireMainNavGraph.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/WireMainNavGraph.kt @@ -26,6 +26,7 @@ import com.wire.android.feature.cells.ui.destinations.CreateFolderScreenDestinat import com.wire.android.feature.cells.ui.destinations.MoveToFolderScreenDestination import com.wire.android.feature.cells.ui.destinations.PublicLinkScreenDestination import com.wire.android.feature.cells.ui.destinations.RecycleBinScreenDestination +import com.wire.android.feature.cells.ui.destinations.RenameNodeScreenDestination import com.wire.android.feature.sketch.destinations.DrawingCanvasScreenDestination import com.wire.android.ui.NavGraphs @@ -40,6 +41,7 @@ object WireMainNavGraph : NavGraphSpec { .plus(CreateFolderScreenDestination) .plus(MoveToFolderScreenDestination) .plus(RecycleBinScreenDestination) + .plus(RenameNodeScreenDestination) override val destinationsByRoute = destinations.associateBy { it.route } override val nestedNavGraphs = NavGraphs.wireRoot.nestedNavGraphs } diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/common/handle/UsernameTextField.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/common/handle/UsernameTextField.kt index b97552b9b8c..6ada107bee8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/common/handle/UsernameTextField.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/create/common/handle/UsernameTextField.kt @@ -29,7 +29,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.wire.android.R -import com.wire.android.ui.common.ShakeAnimation +import com.wire.android.ui.common.animation.ShakeAnimation import com.wire.android.ui.common.error.CoreFailureErrorDialog import com.wire.android.ui.common.textfield.DefaultEmailDone import com.wire.android.ui.common.textfield.WireTextField diff --git a/app/src/main/kotlin/com/wire/android/ui/common/groupname/GroupConversationNameComponent.kt b/app/src/main/kotlin/com/wire/android/ui/common/groupname/GroupConversationNameComponent.kt index eca20b37996..b87b91eec64 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/groupname/GroupConversationNameComponent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/groupname/GroupConversationNameComponent.kt @@ -50,7 +50,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import com.wire.android.R import com.wire.android.ui.common.Icon -import com.wire.android.ui.common.ShakeAnimation +import com.wire.android.ui.common.animation.ShakeAnimation import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.dimensions diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/NewConversationFolderScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/NewConversationFolderScreen.kt index 3f57fffdfa5..34edfe04229 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/NewConversationFolderScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/NewConversationFolderScreen.kt @@ -41,7 +41,7 @@ import com.ramcosta.composedestinations.spec.DestinationStyle import com.wire.android.R import com.wire.android.navigation.Navigator import com.wire.android.navigation.annotation.app.WireDestination -import com.wire.android.ui.common.ShakeAnimation +import com.wire.android.ui.common.animation.ShakeAnimation import com.wire.android.ui.common.button.WireButtonState.Default import com.wire.android.ui.common.button.WireButtonState.Disabled import com.wire.android.ui.common.button.WirePrimaryButton diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameScreen.kt index dc083a760eb..353a75af7ef 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameScreen.kt @@ -44,10 +44,11 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.ramcosta.composedestinations.result.ResultBackNavigator import com.ramcosta.composedestinations.spec.DestinationStyle import com.wire.android.R +import com.wire.android.model.DisplayNameState import com.wire.android.navigation.Navigator import com.wire.android.navigation.annotation.app.WireDestination import com.wire.android.ui.common.Icon -import com.wire.android.ui.common.ShakeAnimation +import com.wire.android.ui.common.animation.ShakeAnimation import com.wire.android.ui.common.button.WireButtonState.Default import com.wire.android.ui.common.button.WireButtonState.Disabled import com.wire.android.ui.common.button.WirePrimaryButton diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameViewModel.kt index 7fa2557a32a..8b0a17cfa29 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameViewModel.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.wire.android.model.DisplayNameState import com.wire.android.ui.common.textfield.textAsFlow import com.wire.kalium.logic.feature.user.DisplayNameUpdateResult import com.wire.kalium.logic.feature.user.GetSelfUserUseCase diff --git a/app/src/test/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameViewModelTest.kt index 4a5d7e83446..7b8d33553d5 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/settings/account/displayname/ChangeDisplayNameViewModelTest.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.SnapshotExtension import com.wire.android.framework.TestUser +import com.wire.android.model.DisplayNameState import com.wire.kalium.common.error.CoreFailure import com.wire.kalium.logic.feature.user.DisplayNameUpdateResult import com.wire.kalium.logic.feature.user.GetSelfUserUseCase diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/DisplayNameState.kt b/core/ui-common/src/main/kotlin/com/wire/android/model/DisplayNameState.kt similarity index 92% rename from app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/DisplayNameState.kt rename to core/ui-common/src/main/kotlin/com/wire/android/model/DisplayNameState.kt index 1a50e4aedc2..28324711f1d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/account/displayname/DisplayNameState.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/model/DisplayNameState.kt @@ -1,6 +1,6 @@ /* * Wire - * Copyright (C) 2024 Wire Swiss GmbH + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.ui.home.settings.account.displayname +package com.wire.android.model data class DisplayNameState( val loading: Boolean = false, diff --git a/app/src/main/kotlin/com/wire/android/ui/common/ShakeAnimation.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/animation/ShakeAnimation.kt similarity index 94% rename from app/src/main/kotlin/com/wire/android/ui/common/ShakeAnimation.kt rename to core/ui-common/src/main/kotlin/com/wire/android/ui/common/animation/ShakeAnimation.kt index 75d25fc53ff..bb883065acb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/ShakeAnimation.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/animation/ShakeAnimation.kt @@ -1,6 +1,6 @@ /* * Wire - * Copyright (C) 2024 Wire Swiss GmbH + * 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 @@ -16,7 +16,7 @@ * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.ui.common +package com.wire.android.ui.common.animation import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.keyframes @@ -27,6 +27,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.Dp +import com.wire.android.ui.common.dimensions import kotlinx.coroutines.launch @Composable diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt index 8c72df5ed4c..8c4ee342696 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt @@ -79,6 +79,7 @@ fun AllFilesScreen( ) ) }, - showMoveToFolderScreen = { _, _, _ -> } + showMoveToFolderScreen = { _, _, _ -> }, + showRenameScreen = {} ) } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt index 1902ed58a23..6f7f1e5fdad 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt @@ -64,6 +64,7 @@ internal fun CellScreenContent( downloadFileState: StateFlow, menuState: Flow, showPublicLinkScreen: (PublicLinkScreenData) -> Unit, + showRenameScreen: (CellNodeUi) -> Unit, showMoveToFolderScreen: (String, String, String) -> Unit, isAllFiles: Boolean, isSearchResult: Boolean = false, @@ -164,7 +165,7 @@ internal fun CellScreenContent( isFolder = action.cellNode is CellNodeUi.Folder ) ) - + is ShowRenameScreen -> showRenameScreen(action.cellNode) is ShowMoveToFolderScreen -> showMoveToFolderScreen(action.currentPath, action.nodeToMovePath, action.uuid) is RefreshData -> pagingListItems.refresh() } 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 bfb0b288072..1db2d39c8ee 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 @@ -161,7 +161,7 @@ class CellViewModel @Inject constructor( sendAction(ShowError(CellError.OTHER_ERROR)) return@launch } -// + val (nodeName, nodeRemotePath) = when (node) { is CellNodeUi.File -> Pair(node.name, node.remotePath) is CellNodeUi.Folder -> Pair(node.name + ZIP_EXTENSION, node.remotePath + ZIP_EXTENSION) @@ -262,6 +262,7 @@ class CellViewModel @Inject constructor( add(NodeBottomSheetAction.PUBLIC_LINK) } add(NodeBottomSheetAction.MOVE) + add(NodeBottomSheetAction.RENAME) add(NodeBottomSheetAction.DELETE) } } @@ -280,6 +281,7 @@ class CellViewModel @Inject constructor( add(NodeBottomSheetAction.SHARE) add(NodeBottomSheetAction.DOWNLOAD) add(NodeBottomSheetAction.MOVE) + add(NodeBottomSheetAction.RENAME) add(NodeBottomSheetAction.DELETE) } } @@ -315,6 +317,7 @@ class CellViewModel @Inject constructor( ) } + NodeBottomSheetAction.RENAME -> sendAction(ShowRenameScreen(node)) NodeBottomSheetAction.DOWNLOAD -> downloadNode(node) NodeBottomSheetAction.RESTORE -> sendAction(ShowRestoreConfirmation(node = node)) NodeBottomSheetAction.DELETE -> sendAction(ShowDeleteConfirmation(node = node, isPermanentDelete = false)) @@ -407,6 +410,7 @@ internal data class ShowDeleteConfirmation(val node: CellNodeUi, val isPermanent internal data class ShowRestoreConfirmation(val node: CellNodeUi) : CellViewAction internal data class ShowError(val error: CellError) : CellViewAction internal data class ShowPublicLinkScreen(val cellNode: CellNodeUi) : CellViewAction +internal data class ShowRenameScreen(val cellNode: CellNodeUi) : CellViewAction internal data class ShowMoveToFolderScreen(val currentPath: String, val nodeToMovePath: String, val uuid: String) : CellViewAction internal data object RefreshData : CellViewAction 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 1f0824a7298..c2e77f5bdd7 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 @@ -40,6 +40,7 @@ import com.wire.android.feature.cells.ui.destinations.CreateFolderScreenDestinat import com.wire.android.feature.cells.ui.destinations.MoveToFolderScreenDestination import com.wire.android.feature.cells.ui.destinations.PublicLinkScreenDestination import com.wire.android.feature.cells.ui.destinations.RecycleBinScreenDestination +import com.wire.android.feature.cells.ui.destinations.RenameNodeScreenDestination import com.wire.android.feature.cells.ui.dialog.CellsNewActionBottomSheet import com.wire.android.feature.cells.ui.dialog.CellsOptionsBottomSheet import com.wire.android.feature.cells.ui.model.CellNodeUi @@ -227,6 +228,18 @@ fun ConversationFilesScreenContent( ) ) ) + }, + showRenameScreen = { cellNodeUi -> + navigator.navigate( + NavigationCommand( + RenameNodeScreenDestination( + uuid = cellNodeUi.uuid, + currentPath = cellNodeUi.remotePath, + isFolder = cellNodeUi is CellNodeUi.Folder, + nodeName = cellNodeUi.name, + ) + ) + ) } ) } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/NodeBottomSheetAction.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/NodeBottomSheetAction.kt index aed8a269f0b..a52693ee5a6 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/NodeBottomSheetAction.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/NodeBottomSheetAction.kt @@ -28,6 +28,7 @@ enum class NodeBottomSheetAction( SHARE(R.string.share_label, R.drawable.ic_share), PUBLIC_LINK(R.string.public_link, R.drawable.ic_file_link), MOVE(R.string.move_label, R.drawable.ic_folder), + RENAME(R.string.rename_label, R.drawable.ic_rename), DOWNLOAD(R.string.download_label, R.drawable.ic_save), RESTORE(R.string.restore_label, R.drawable.ic_restore), DELETE(R.string.delete_label, R.drawable.ic_delete, true), diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt index 5d6c05d98e4..5c2065123b1 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt @@ -118,7 +118,8 @@ fun RecycleBinScreen( ) ) ) - } + }, + showRenameScreen = { } ) } } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeNavArgs.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeNavArgs.kt new file mode 100644 index 00000000000..b3625675d21 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeNavArgs.kt @@ -0,0 +1,25 @@ +/* + * 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.rename + +data class RenameNodeNavArgs( + val uuid: String? = null, + val currentPath: String? = null, + val isFolder: Boolean? = null, + val nodeName: String? = null +) 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 new file mode 100644 index 00000000000..6425f78dd30 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeScreen.kt @@ -0,0 +1,182 @@ +/* + * 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.rename + +import android.widget.Toast +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import com.wire.android.feature.cells.R +import com.wire.android.feature.cells.ui.FullScreenLoading +import com.wire.android.feature.cells.ui.rename.RenameNodeViewModel.Companion.NAME_MAX_COUNT +import com.wire.android.model.ClickBlockParams +import com.wire.android.model.DisplayNameState +import com.wire.android.navigation.PreviewNavigator +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.animation.ShakeAnimation +import com.wire.android.ui.common.button.WireButtonState.Default +import com.wire.android.ui.common.button.WireButtonState.Disabled +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.DefaultText +import com.wire.android.ui.common.textfield.WireTextField +import com.wire.android.ui.common.textfield.WireTextFieldState +import com.wire.android.ui.common.textfield.maxLengthWithCallback +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 + +@WireDestination( + style = PopUpNavigationAnimation::class, + navArgsDelegate = RenameNodeNavArgs::class, +) +@Composable +fun RenameNodeScreen( + navigator: WireNavigator, + modifier: Modifier = Modifier, + renameNodeViewModel: RenameNodeViewModel = hiltViewModel() +) { + val context = LocalContext.current + + WireScaffold( + modifier = modifier, + topBar = { + WireCenterAlignedTopAppBar( + onNavigationPressed = { navigator.navigateBack() }, + title = if (renameNodeViewModel.isFolder() == true) { + stringResource(R.string.rename_folder_label) + } else { + stringResource(R.string.rename_file_label) + }, + navigationIconType = NavigationIconType.Close(), + elevation = dimensions().spacing0x, + ) + }, + bottomBar = { + Surface( + color = MaterialTheme.wireColorScheme.background, + shadowElevation = MaterialTheme.wireDimensions.bottomNavigationShadowElevation + ) { + WirePrimaryButton( + text = stringResource(R.string.rename_label), + onClick = { renameNodeViewModel.renameNode() }, + state = if (renameNodeViewModel.displayNameState.saveEnabled) Default else Disabled, + clickBlockParams = ClickBlockParams(blockWhenSyncing = true, blockWhenConnecting = true), + ) + } + } + ) { innerPadding -> + + val keyboardController = LocalSoftwareKeyboardController.current + + Box(modifier = Modifier.padding(innerPadding)) { + ShakeAnimation { animate -> + WireTextField( + textState = renameNodeViewModel.textState, + labelText = if (renameNodeViewModel.isFolder() == true) { + stringResource(R.string.rename_folder_label).uppercase() + } else { + stringResource(R.string.rename_file_label).uppercase() + }, + inputTransformation = InputTransformation.maxLengthWithCallback(NAME_MAX_COUNT, animate), + lineLimits = TextFieldLineLimits.SingleLine, + state = computeNameErrorState(renameNodeViewModel.displayNameState.error, renameNodeViewModel.isFolder()), + keyboardOptions = KeyboardOptions.DefaultText, + descriptionText = if (renameNodeViewModel.isFolder() == true) { + stringResource(id = R.string.rename_long_folder_name_error) + } else { + stringResource(id = R.string.rename_long_file_name_error) + }, + onKeyboardAction = { keyboardController?.hide() }, + modifier = Modifier.padding( + horizontal = MaterialTheme.wireDimensions.spacing16x + ) + ) + } + } + } + + if (renameNodeViewModel.displayNameState.loading) { + FullScreenLoading() + } + + HandleActions(renameNodeViewModel.actions) { action -> + when (action) { + is RenameNodeViewModelAction.Success -> { + val message = if (renameNodeViewModel.isFolder() == true) { + context.resources.getString(R.string.rename_folder_renamed) + } else { + context.resources.getString(R.string.rename_file_renamed) + } + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + + is RenameNodeViewModelAction.Failure -> + Toast.makeText(context, context.resources.getString(R.string.rename_failure), Toast.LENGTH_SHORT).show() + } + navigator.navigateBack() + } +} + +@Composable +private fun computeNameErrorState( + error: DisplayNameState.NameError, + isFolder: Boolean? +): WireTextFieldState { + return when (error) { + is DisplayNameState.NameError.TextFieldError -> { + val messageRes = when (error) { + DisplayNameState.NameError.TextFieldError.NameEmptyError -> + if (isFolder == true) R.string.rename_enter_folder_name else R.string.rename_enter_file_name + + DisplayNameState.NameError.TextFieldError.NameExceedLimitError -> + if (isFolder == true) R.string.rename_long_folder_name_error else R.string.rename_long_file_name_error + } + WireTextFieldState.Error(stringResource(id = messageRes)) + } + + else -> WireTextFieldState.Default + } +} + +@MultipleThemePreviews +@Composable +fun PreviewRenameNodeScreen() { + WireTheme { + RenameNodeScreen( + navigator = PreviewNavigator + ) + } +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeViewModel.kt new file mode 100644 index 00000000000..c8d5d90225f --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/rename/RenameNodeViewModel.kt @@ -0,0 +1,96 @@ +/* + * 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.rename + +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.navArgs +import com.wire.android.model.DisplayNameState +import com.wire.android.ui.common.ActionsViewModel +import com.wire.android.ui.common.textfield.textAsFlow +import com.wire.kalium.cells.domain.usecase.RenameNodeUseCase +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.launch +import javax.inject.Inject + +@HiltViewModel +class RenameNodeViewModel @Inject constructor( + val savedStateHandle: SavedStateHandle, + private val renameNodeUseCase: RenameNodeUseCase, +) : ActionsViewModel() { + + private val navArgs: RenameNodeNavArgs = savedStateHandle.navArgs() + + fun isFolder(): Boolean? = navArgs.isFolder + + val textState: TextFieldState = TextFieldState(navArgs.nodeName ?: "") + var displayNameState: DisplayNameState by mutableStateOf(DisplayNameState()) + private set + + init { + viewModelScope.launch { + textState.textAsFlow().collectLatest { + displayNameState = displayNameState.copy( + saveEnabled = it.trim().isNotEmpty() && it.length <= NAME_MAX_COUNT && it.trim() != navArgs.nodeName, + error = when { + it.trim().isEmpty() -> DisplayNameState.NameError.TextFieldError.NameEmptyError + it.length > NAME_MAX_COUNT -> DisplayNameState.NameError.TextFieldError.NameExceedLimitError + else -> DisplayNameState.NameError.None + } + ) + } + } + } + + fun renameNode() { + displayNameState = displayNameState.copy(loading = true) + viewModelScope.launch { + renameNodeUseCase.invoke(navArgs.uuid!!, navArgs.currentPath!!, textState.text.toString()) + .onSuccess { + displayNameState = displayNameState.copy( + loading = false, + completed = DisplayNameState.Completed.Success, + ) + sendAction(RenameNodeViewModelAction.Success) + } + .onFailure { + displayNameState = displayNameState.copy( + loading = false, + completed = DisplayNameState.Completed.Failure, + ) + sendAction(RenameNodeViewModelAction.Failure) + } + } + } + + companion object { + const val NAME_MAX_COUNT = 64 + } +} + +sealed interface RenameNodeViewModelAction { + data object Success : RenameNodeViewModelAction + data object Failure : RenameNodeViewModelAction +} diff --git a/features/cells/src/main/res/drawable/ic_rename.xml b/features/cells/src/main/res/drawable/ic_rename.xml new file mode 100644 index 00000000000..a47e10cbb4e --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_rename.xml @@ -0,0 +1,10 @@ + + + diff --git a/features/cells/src/main/res/values/strings.xml b/features/cells/src/main/res/values/strings.xml index 7bd4637fe05..b2317c39ca6 100644 --- a/features/cells/src/main/res/values/strings.xml +++ b/features/cells/src/main/res/values/strings.xml @@ -79,4 +79,16 @@ This file %1$s will be restored and available again to everyone in this conversation. This folder %1$s will be restored and available again to everyone in this conversation. Restore + Rename file + Rename folder + Rename + File name + Folder name + Use a shorter file name (most 64 characters) + Use a shorter folder name (most 64 characters) + Enter a file name + Enter a folder name + File was renamed + Folder was renamed + Failed to rename diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/rename/RenameNodeViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/rename/RenameNodeViewModelTest.kt new file mode 100644 index 00000000000..846b9b9f082 --- /dev/null +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/rename/RenameNodeViewModelTest.kt @@ -0,0 +1,137 @@ +/* + * 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.rename + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.wire.android.feature.cells.ui.navArgs +import com.wire.android.model.DisplayNameState +import com.wire.kalium.cells.domain.usecase.RenameNodeUseCase +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.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.Dispatchers +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.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class RenameNodeViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @BeforeEach + fun beforeEach() { + Dispatchers.setMain(dispatcher) + } + + @AfterEach + fun afterEach() { + Dispatchers.resetMain() + } + + @Test + fun `given renameNodeUseCase success, when rename is called, then send success action`() = runTest { + val (_, viewModel) = Arrangement() + .withRenameNodeUseCaseReturning(Either.Right(Unit)) + .arrange() + + viewModel.renameNode() + + advanceUntilIdle() + viewModel.actions.test { + with(expectMostRecentItem()) { + assertEquals(false, viewModel.displayNameState.loading) + assertEquals(DisplayNameState.Completed.Success, viewModel.displayNameState.completed) + assertTrue(this is RenameNodeViewModelAction.Success) + } + } + } + + @Test + fun `given renameNodeUseCase failure, when rename is called, then send failure action`() = runTest { + val (_, viewModel) = Arrangement() + .withRenameNodeUseCaseReturning(Either.Left(CoreFailure.InvalidEventSenderID)) + .arrange() + + viewModel.renameNode() + + advanceUntilIdle() + viewModel.actions.test { + with(expectMostRecentItem()) { + assertEquals(false, viewModel.displayNameState.loading) + assertEquals(DisplayNameState.Completed.Failure, viewModel.displayNameState.completed) + assertTrue(this is RenameNodeViewModelAction.Failure) + } + } + } + + private class Arrangement { + + @MockK + lateinit var savedStateHandle: SavedStateHandle + + @MockK + lateinit var renameNodeUseCase: RenameNodeUseCase + + init { + + MockKAnnotations.init(this, relaxUnitFun = true) + + every { savedStateHandle.navArgs() } returns RenameNodeNavArgs( + uuid = UUID, + currentPath = CURRENT_PATH, + nodeName = NODE_NAME, + isFolder = true + ) + every { savedStateHandle.get("uuid") } returns UUID + every { savedStateHandle.get("currentPath") } returns CURRENT_PATH + every { savedStateHandle.get("isFolder") } returns true + every { savedStateHandle.get("nodeName") } returns NODE_NAME + } + + private val viewModel by lazy { + RenameNodeViewModel( + savedStateHandle = savedStateHandle, + renameNodeUseCase = renameNodeUseCase, + ) + } + + fun withRenameNodeUseCaseReturning(result: Either) = apply { + coEvery { renameNodeUseCase(any(), any(), any()) } returns result + } + + fun arrange() = this to viewModel + } + + companion object { + const val CURRENT_PATH = "currentPath" + const val NODE_TO_MOVE_PATH = "nodeToMovePath" + const val UUID = "uuid" + const val NODE_NAME = "nodeName" + } +}