diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt index bdf9c175f79..f2d6cfc6a79 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt @@ -352,4 +352,9 @@ class ConversationModule { @Provides fun provideRemoveConversationFromFolderUseCase(conversationScope: ConversationScope) = conversationScope.removeConversationFromFolder + + @ViewModelScoped + @Provides + fun provideCreateConversationFolderUseCase(conversationScope: ConversationScope) = + conversationScope.createConversationFolder } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/ConversationFoldersScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/ConversationFoldersScreen.kt index 1a786204a91..7e78f6e007c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/ConversationFoldersScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/ConversationFoldersScreen.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.home.conversations.folder -import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -37,9 +36,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import com.ramcosta.composedestinations.annotation.RootNavGraph +import com.ramcosta.composedestinations.result.NavResult import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.ramcosta.composedestinations.result.ResultRecipient import com.wire.android.R import com.wire.android.model.Clickable +import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.navigation.WireDestination import com.wire.android.navigation.style.PopUpNavigationAnimation @@ -55,6 +57,7 @@ import com.wire.android.ui.common.spacers.VerticalSpace import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.typography +import com.wire.android.ui.destinations.NewConversationFolderScreenDestination import com.wire.kalium.logic.data.conversation.ConversationFolder @RootNavGraph @@ -67,6 +70,7 @@ fun ConversationFoldersScreen( args: ConversationFoldersNavArgs, navigator: Navigator, resultNavigator: ResultBackNavigator, + resultRecipient: ResultRecipient, foldersViewModel: ConversationFoldersVM = hiltViewModel( creationCallback = { it.create(ConversationFoldersStateArgs(args.currentFolderId)) } @@ -92,8 +96,18 @@ fun ConversationFoldersScreen( foldersState = foldersViewModel.state(), onNavigationPressed = { navigator.navigateBack() }, moveConversationToFolder = moveToFolderVM::moveConversationToFolder, - onFolderSelected = foldersViewModel::onFolderSelected + onFolderSelected = foldersViewModel::onFolderSelected, + onCreateFolderPressed = { navigator.navigate(NavigationCommand(NewConversationFolderScreenDestination())) } ) + + resultRecipient.onNavResult { + when (it) { + NavResult.Canceled -> {} + is NavResult.Value -> { + foldersViewModel.onFolderSelected(it.value) + } + } + } } @Composable @@ -103,9 +117,8 @@ private fun Content( onNavigationPressed: () -> Unit = {}, moveConversationToFolder: (folder: ConversationFolder) -> Unit = {}, onFolderSelected: (folderId: String) -> Unit = {}, + onCreateFolderPressed: () -> Unit = {} ) { - val context = LocalContext.current - val lazyListState = rememberLazyListState() WireScaffold( modifier = Modifier @@ -124,13 +137,7 @@ private fun Content( WireSecondaryButton( state = WireButtonState.Default, text = stringResource(id = R.string.label_new_folder), - onClick = { - Toast.makeText( - context, - "Not implemented yet", - Toast.LENGTH_SHORT - ).show() - } + onClick = onCreateFolderPressed ) VerticalSpace.x8() val state = if (foldersState.selectedFolderId != null 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 new file mode 100644 index 00000000000..f51a2f95d04 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/NewConversationFolderScreen.kt @@ -0,0 +1,196 @@ +/* + * 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.ui.home.conversations.folder + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import com.ramcosta.composedestinations.annotation.RootNavGraph +import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.wire.android.R +import com.wire.android.navigation.Navigator +import com.wire.android.navigation.WireDestination +import com.wire.android.ui.common.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.rememberBottomBarElevationState +import com.wire.android.ui.common.rememberTopBarElevationState +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.home.settings.account.displayname.ChangeDisplayNameViewModel.Companion.NAME_MAX_COUNT +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.ui.theme.wireDimensions +import com.wire.android.util.ui.PreviewMultipleThemes +import com.wire.android.util.ui.SnackBarMessageHandler + +@RootNavGraph +@WireDestination +@Composable +fun NewConversationFolderScreen( + navigator: Navigator, + resultNavigator: ResultBackNavigator, + viewModel: NewFolderViewModel = hiltViewModel() +) { + + LaunchedEffect(viewModel.folderNameState.folderId) { + if (viewModel.folderNameState.folderId != null) { + resultNavigator.navigateBack(viewModel.folderNameState.folderId!!) + } + } + + Content( + textState = viewModel.textState, + state = viewModel.folderNameState, + onContinuePressed = { + viewModel.createFolder(viewModel.textState.text.toString()) + }, + onBackPressed = navigator::navigateBack + ) + + SnackBarMessageHandler(viewModel.infoMessage) +} + +@Composable +private fun Content( + textState: TextFieldState, + state: FolderNameState, + onContinuePressed: () -> Unit, + onBackPressed: () -> Unit, + modifier: Modifier = Modifier +) { + val scrollState = rememberScrollState() + with(state) { + WireScaffold( + modifier = modifier, + topBar = { + WireCenterAlignedTopAppBar( + elevation = scrollState.rememberTopBarElevationState().value, + onNavigationPressed = onBackPressed, + navigationIconType = NavigationIconType.Back(), + title = stringResource(id = R.string.label_new_folder) + ) + } + ) { internalPadding -> + Column( + modifier = Modifier + .padding(internalPadding) + .fillMaxSize() + ) { + val keyboardController = LocalSoftwareKeyboardController.current + + Box( + modifier = Modifier + .weight(weight = 1f, fill = true) + .fillMaxWidth() + ) { + ShakeAnimation(modifier = Modifier.align(Alignment.Center)) { animate -> + WireTextField( + textState = textState, + labelText = stringResource(R.string.new_folder_folder_name).uppercase(), + inputTransformation = InputTransformation.maxLengthWithCallback(NAME_MAX_COUNT, animate), + lineLimits = TextFieldLineLimits.SingleLine, + state = computeNameErrorState(error), + keyboardOptions = KeyboardOptions.DefaultText, + descriptionText = stringResource(id = R.string.settings_myaccount_display_name_exceeded_limit_error), + onKeyboardAction = { keyboardController?.hide() }, + modifier = Modifier.padding( + horizontal = MaterialTheme.wireDimensions.spacing16x + ) + ) + } + } + + Surface( + shadowElevation = scrollState.rememberBottomBarElevationState().value, + color = MaterialTheme.wireColorScheme.background + ) { + Box(modifier = Modifier.padding(MaterialTheme.wireDimensions.spacing16x)) { + WirePrimaryButton( + text = stringResource(R.string.new_folder_create_folder), + onClick = onContinuePressed, + fillMaxWidth = true, + state = if (buttonEnabled) Default else Disabled, + loading = loading, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + } +} + +@Composable +private fun computeNameErrorState(error: FolderNameState.NameError) = + if (error is FolderNameState.NameError.TextFieldError) { + when (error) { + FolderNameState.NameError.TextFieldError.NameEmptyError -> WireTextFieldState.Error( + stringResource(id = R.string.new_folder_error_name_empty) + ) + + FolderNameState.NameError.TextFieldError.NameExceedLimitError -> WireTextFieldState.Error( + stringResource(id = R.string.new_folder_error_name_exceeded_limit_error) + ) + + FolderNameState.NameError.TextFieldError.NameAlreadyExistError -> WireTextFieldState.Error( + stringResource(id = R.string.new_folder_error_name_exist) + ) + } + } else { + WireTextFieldState.Default + } + +@PreviewMultipleThemes +@Composable +fun PreviewNewConversationFolder() = WireTheme { + Content(TextFieldState("Secret group"), FolderNameState(), {}, {}) +} + +@PreviewMultipleThemes +@Composable +fun PreviewNewConversationFolderErrorNameExist() = WireTheme { + Content( + TextFieldState("Secret group"), + FolderNameState(error = FolderNameState.NameError.TextFieldError.NameAlreadyExistError), + {}, + {} + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/NewFolderViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/NewFolderViewModel.kt new file mode 100644 index 00000000000..f4a8360d769 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/folder/NewFolderViewModel.kt @@ -0,0 +1,117 @@ +/* + * 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.ui.home.conversations.folder + +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.ViewModel +import androidx.lifecycle.viewModelScope +import com.wire.android.R +import com.wire.android.model.SnackBarMessage +import com.wire.android.model.asSnackBarMessage +import com.wire.android.ui.common.textfield.textAsFlow +import com.wire.android.util.ui.UIText +import com.wire.kalium.logic.feature.conversation.folder.CreateConversationFolderUseCase +import com.wire.kalium.logic.feature.conversation.folder.ObserveUserFoldersUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.dropWhile +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class NewFolderViewModel @Inject constructor( + private val observeUserFolders: ObserveUserFoldersUseCase, + private val createConversationFolder: CreateConversationFolderUseCase +) : ViewModel() { + + val textState: TextFieldState = TextFieldState() + var folderNameState: FolderNameState by mutableStateOf(FolderNameState()) + private set + + private val _infoMessage = MutableSharedFlow() + val infoMessage = _infoMessage.asSharedFlow() + + init { + viewModelScope.launch { + combine( + observeUserFolders(), + textState.textAsFlow() + .dropWhile { it.isEmpty() }, // ignore first empty value to not show the error before the user typed anything + ::Pair + ) + .collect { (folders, text) -> + val nameExist = folders.any { it.name == text.trim() } + folderNameState = folderNameState.copy( + buttonEnabled = text.trim().isNotEmpty() && !nameExist && text.length <= NAME_MAX_COUNT, + error = when { + text.trim().isEmpty() -> FolderNameState.NameError.TextFieldError.NameEmptyError + text.length > NAME_MAX_COUNT -> FolderNameState.NameError.TextFieldError.NameExceedLimitError + nameExist -> FolderNameState.NameError.TextFieldError.NameAlreadyExistError + else -> FolderNameState.NameError.None + } + ) + } + } + } + + fun createFolder(folderName: String) { + viewModelScope.launch { + when (val result = createConversationFolder(folderName)) { + is CreateConversationFolderUseCase.Result.Failure -> { + _infoMessage.emit( + UIText.StringResource( + R.string.new_folder_failure, + folderName, + ).asSnackBarMessage() + ) + } + + is CreateConversationFolderUseCase.Result.Success -> { + folderNameState = folderNameState.copy( + folderId = result.folderId + ) + } + } + } + } + + companion object { + const val NAME_MAX_COUNT = 64 + } +} + +data class FolderNameState( + val folderId: String? = null, + val loading: Boolean = false, + val buttonEnabled: Boolean = false, + val error: NameError = NameError.None, +) { + sealed interface NameError { + data object None : NameError + sealed interface TextFieldError : NameError { + data object NameEmptyError : TextFieldError + data object NameExceedLimitError : TextFieldError + data object NameAlreadyExistError : TextFieldError + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 184bacf7232..36b7c4674ab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1686,5 +1686,11 @@ In group conversations, the group admin can overwrite this setting. “%1$s” could not be moved “%1$s” was removed from “%2$s” “%1$s” could not be removed + FOLDER NAME + Create Folder + A folder with this name already exists. Please choose another name. + Please enter folder name. + Minimum of 1 and maximum of 64 characters + “%1$s” folder could not be added diff --git a/app/src/test/kotlin/com/wire/android/ui/common/bottomsheet/folder/NewFolderViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/common/bottomsheet/folder/NewFolderViewModelTest.kt new file mode 100644 index 00000000000..2c04b63af46 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/common/bottomsheet/folder/NewFolderViewModelTest.kt @@ -0,0 +1,193 @@ +/* + * 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.ui.common.bottomsheet.folder + +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import app.cash.turbine.test +import com.wire.android.R +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.SnapshotExtension +import com.wire.android.model.DefaultSnackBarMessage +import com.wire.android.ui.home.conversations.folder.FolderNameState +import com.wire.android.ui.home.conversations.folder.NewFolderViewModel +import com.wire.android.util.ui.UIText +import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.data.conversation.ConversationFolder +import com.wire.kalium.logic.data.conversation.FolderType +import com.wire.kalium.logic.feature.conversation.folder.CreateConversationFolderUseCase +import com.wire.kalium.logic.feature.conversation.folder.ObserveUserFoldersUseCase +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +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.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(CoroutineTestExtension::class, SnapshotExtension::class) +class NewFolderViewModelTest { + + @Test + fun `given initial empty text, then no error should be set and button should remain disabled`() = runTest { + val (arrangement, viewModel) = Arrangement().arrange { + } + + arrangement.userFoldersChannel.send(listOf()) + advanceUntilIdle() + + arrangement.updateTextState("") + + assertFalse(viewModel.folderNameState.buttonEnabled) + assertEquals(FolderNameState.NameError.None, viewModel.folderNameState.error) + } + + @Test + fun `given folder name is empty, then buttonEnabled should be false and error should be NameEmptyError`() = runTest { + val (arrangement, viewModel) = Arrangement().arrange { + } + + arrangement.userFoldersChannel.send(listOf()) + arrangement.updateTextState("3434") + advanceUntilIdle() + + arrangement.updateTextState("") + advanceUntilIdle() + + assertFalse(viewModel.folderNameState.buttonEnabled) + assertEquals( + FolderNameState.NameError.TextFieldError.NameEmptyError, + viewModel.folderNameState.error + ) + } + + @Test + fun `given folder name exceeds limit, then buttonEnabled should be false and error should be NameExceedLimitError`() = runTest { + val (arrangement, viewModel) = Arrangement().arrange {} + arrangement.userFoldersChannel.send(listOf()) + arrangement.updateTextState("a".repeat(NewFolderViewModel.NAME_MAX_COUNT + 1)) + + advanceUntilIdle() + + assertFalse(viewModel.folderNameState.buttonEnabled) + assertEquals( + FolderNameState.NameError.TextFieldError.NameExceedLimitError, + viewModel.folderNameState.error + ) + } + + @Test + fun `given folder name already exists, then buttonEnabled should be false and error should be NameAlreadyExistError`() = runTest { + val (arrangement, viewModel) = Arrangement().arrange { + } + + arrangement.userFoldersChannel.send(listOf(ConversationFolder(id = "folderId", name = "ExistingFolder", type = FolderType.USER))) + arrangement.updateTextState("ExistingFolder") + advanceUntilIdle() + + assertFalse(viewModel.folderNameState.buttonEnabled) + assertEquals( + FolderNameState.NameError.TextFieldError.NameAlreadyExistError, + viewModel.folderNameState.error + ) + } + + @Test + fun `given valid folder name, then buttonEnabled should be true and error should be None`() = runTest { + val (arrangement, viewModel) = Arrangement().arrange { + } + + arrangement.userFoldersChannel.send(listOf(ConversationFolder(id = "folderId", name = "OtherFolder", type = FolderType.USER))) + arrangement.updateTextState("NewFolder") + advanceUntilIdle() + + assertTrue(viewModel.folderNameState.buttonEnabled) + assertEquals( + FolderNameState.NameError.None, + viewModel.folderNameState.error + ) + } + + @Test + fun `when folder creation fails, then infoMessage should emit failure message`() = runTest { + val (arrangement, viewModel) = Arrangement().arrange { + withCreateFolderResult(CreateConversationFolderUseCase.Result.Failure(CoreFailure.Unknown(null))) + } + arrangement.userFoldersChannel.send(listOf()) + + viewModel.infoMessage.test { + viewModel.createFolder("NewFolder") + val result = awaitItem() + assertEquals( + DefaultSnackBarMessage(UIText.StringResource(R.string.new_folder_failure, "NewFolder")), + result + ) + } + } + + @Test + fun `when folder creation succeeds, then folderId should be set in state`() = runTest { + val folderId = "123" + val (arrangement, viewModel) = Arrangement().arrange { + withCreateFolderResult(CreateConversationFolderUseCase.Result.Success(folderId)) + } + + arrangement.userFoldersChannel.send(listOf()) + viewModel.createFolder("NewFolder") + + assertEquals(folderId, viewModel.folderNameState.folderId) + } + + private class Arrangement { + + @MockK + lateinit var observeUserFolders: ObserveUserFoldersUseCase + + @MockK + lateinit var createConversationFolder: CreateConversationFolderUseCase + + val userFoldersChannel = Channel>(capacity = Channel.UNLIMITED) + + private lateinit var viewModel: NewFolderViewModel + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + coEvery { observeUserFolders() } returns userFoldersChannel.consumeAsFlow() + } + + fun withCreateFolderResult(result: CreateConversationFolderUseCase.Result) = apply { + coEvery { createConversationFolder(any()) } returns result + } + + fun updateTextState(text: String) { + viewModel.textState.setTextAndPlaceCursorAtEnd(text) + } + + fun arrange(block: Arrangement.() -> Unit) = apply(block).let { + viewModel = NewFolderViewModel( + observeUserFolders, + createConversationFolder + ) + this to viewModel + } + } +}