diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/chip/ChipData.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/chip/ChipData.kt deleted file mode 100644 index a8fe7bd9654..00000000000 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/chip/ChipData.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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.chip - -import androidx.compose.runtime.Immutable -import java.util.UUID - -@Immutable -data class ChipData( - val id: String = UUID.randomUUID().toString(), - val label: String, - val isSelected: Boolean, - val isEnabled: Boolean -) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsScreen.kt index b6872e2aee0..529f20125dd 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -52,9 +53,9 @@ 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.button.WireButtonState +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.chip.ChipData import com.wire.android.ui.common.chip.WireFilterChip import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions @@ -80,6 +81,8 @@ fun AddRemoveTagsScreen( ) { val context = LocalContext.current + val viewState by addRemoveTagsViewModel.state.collectAsState() + WireScaffold( modifier = modifier, snackbarHost = {}, @@ -90,21 +93,20 @@ fun AddRemoveTagsScreen( onNavigationPressed = { navigator.navigateBack() }, + elevation = dimensions().spacing0x, ) }, bottomBar = { - val isLoading = addRemoveTagsViewModel.isLoading.collectAsState().value - val tags = addRemoveTagsViewModel.suggestedTags Column( modifier = Modifier.background(colorsScheme().background) ) { - if (tags.isNotEmpty()) { + if (viewState.suggestedTags.isNotEmpty()) { LazyRow( modifier = Modifier .fillMaxWidth() .padding(start = dimensions().spacing16x, end = dimensions().spacing16x) ) { - tags.forEach { tag -> + viewState.suggestedTags.forEach { tag -> item { WireFilterChip( modifier = Modifier.padding( @@ -131,15 +133,13 @@ fun AddRemoveTagsScreen( .padding(horizontal = dimensions().spacing16x) .height(dimensions().groupButtonHeight) ) { - val shouldDisabledSaveButton = - isLoading || addRemoveTagsViewModel.initialTags == addRemoveTagsViewModel.addedTags.collectAsState().value WirePrimaryButton( text = stringResource(R.string.save_label), onClick = { addRemoveTagsViewModel.updateTags() }, - state = if (shouldDisabledSaveButton) WireButtonState.Disabled else WireButtonState.Default, - loading = isLoading, + state = if (viewState.tagsUpdated && !viewState.isLoading) Default else Disabled, + loading = viewState.isLoading, clickBlockParams = ClickBlockParams(blockWhenSyncing = true, blockWhenConnecting = true), ) } @@ -151,7 +151,7 @@ fun AddRemoveTagsScreen( AddRemoveTagsScreenContent( internalPadding = internalPadding, textFieldState = addRemoveTagsViewModel.tagsTextState, - addedTags = addRemoveTagsViewModel.addedTags.collectAsState().value, + addedTags = viewState.addedTags, onAddTag = { tag -> addRemoveTagsViewModel.addTag(tag) }, @@ -217,18 +217,20 @@ fun AddRemoveTagsScreenContent( ChipAndTextFieldLayout( textFieldState = textFieldState, - tags = addedTags.map { ChipData(label = it, isSelected = true, isEnabled = false) }.toSet(), isValidTag = isValidTag, onDone = onAddTag, onRemoveLastTag = onRemoveLastTag, - ) { data: ChipData -> - - WireFilterChip( - label = data.label, - isSelected = data.isSelected, - onSelectChip = onRemoveTag - ) - } + chipsLayout = { + addedTags.forEach { item -> + WireFilterChip( + modifier = Modifier.align(Alignment.CenterVertically), + label = item, + isSelected = true, + onSelectChip = onRemoveTag + ) + } + } + ) } } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsViewModel.kt index 10425611e9c..3480bf3ad8c 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/AddRemoveTagsViewModel.kt @@ -19,9 +19,6 @@ package com.wire.android.feature.cells.ui.tags import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope @@ -30,11 +27,11 @@ import com.wire.android.ui.common.ActionsViewModel import com.wire.kalium.cells.domain.usecase.GetAllTagsUseCase import com.wire.kalium.cells.domain.usecase.RemoveNodeTagsUseCase import com.wire.kalium.cells.domain.usecase.UpdateNodeTagsUseCase -import com.wire.kalium.common.functional.getOrElse import com.wire.kalium.common.functional.onFailure import com.wire.kalium.common.functional.onSuccess import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.update @@ -50,98 +47,93 @@ class AddRemoveTagsViewModel @Inject constructor( ) : ActionsViewModel() { private val navArgs: AddRemoveTagsNavArgs = savedStateHandle.navArgs() + private val initialTags: Set = navArgs.tags.toSet() + private val disallowedChars = listOf(",", ";", "/", "\\", "\"", "\'", "<", ">") - val isLoading = MutableStateFlow(false) + private val _state = MutableStateFlow(TagsViewState(addedTags = initialTags)) + val state = _state.asStateFlow() val tagsTextState = TextFieldState() - val initialTags: Set = navArgs.tags.toSet() - - val disallowedChars = listOf(",", ";", "/", "\\", "\"", "\'", "<", ">") - - var allTags: Set = emptySet() - private set - - val addedTags: MutableStateFlow> = MutableStateFlow(navArgs.tags.toSet()) - - var suggestedTags by mutableStateOf>(emptySet()) - private set - init { viewModelScope.launch { - allTags = getAllTagsUseCase().getOrElse { emptySet() } - updateSuggestions("") // initial state - } - - viewModelScope.launch { - snapshotFlow { tagsTextState.text.toString() } - .debounce(TYPING_DEBOUNCE_TIME) - .collectLatest { - onQueryChanged(it) - } + getAllTagsUseCase().onSuccess { tags -> + _state.update { it.copy(allTags = tags) } + } + launch { + snapshotFlow { tagsTextState.text.toString() } + .debounce(TYPING_DEBOUNCE_TIME) + .collectLatest { updateViewState() } + } } } - fun onQueryChanged(query: String) { - updateSuggestions(query) + fun isValidTag(): Boolean = with(tagsTextState) { + disallowedChars.none { it in text } && text.length in ALLOWED_LENGTH } - private fun updateSuggestions(query: String) { - suggestedTags = allTags - .asSequence() - .filter { query.isBlank() || it.contains(query, ignoreCase = true) } - .filter { it !in addedTags.value } - .toSet() - } - - fun isValidTag(): Boolean = disallowedChars.none { - it in tagsTextState.text - } && tagsTextState.text.length in ALLOWED_LENGTH - fun addTag(tag: String) { tag.trim().let { newTag -> - if (newTag.isNotBlank() && newTag !in addedTags.value) { - addedTags.update { it + newTag } - updateSuggestions("") + val addedTags = state.value.addedTags + if (newTag.isNotBlank() && newTag !in addedTags) { + updateViewState(addedTags + tag) tagsTextState.clearText() } } } fun removeTag(tag: String) { - addedTags.update { it - tag } - updateSuggestions("") + updateViewState(state.value.addedTags - tag) } fun removeLastTag() { - addedTags.value.lastOrNull()?.let { lastTag -> - removeTag(lastTag) + state.value.addedTags.lastOrNull()?.let { removeTag(it) } + } + + fun updateTags() = viewModelScope.launch { + _state.update { it.copy(isLoading = true) } + + if (state.value.addedTags.isEmpty()) { + removeNodeTagsUseCase(navArgs.uuid) + } else { + updateNodeTagsUseCase(navArgs.uuid, state.value.addedTags) } + .onSuccess { sendAction(AddRemoveTagsViewModelAction.Success) } + .onFailure { sendAction(AddRemoveTagsViewModelAction.Failure) } + .also { _state.update { it.copy(isLoading = false) } } } - fun updateTags() { - viewModelScope.launch { - isLoading.value = true - val result = if (addedTags.value.isEmpty()) { - removeNodeTagsUseCase(navArgs.uuid) - } else { - updateNodeTagsUseCase(navArgs.uuid, addedTags.value) - } + fun updateViewState() { + updateViewState(state.value.addedTags) + } - result - .onSuccess { sendAction(AddRemoveTagsViewModelAction.Success) } - .onFailure { sendAction(AddRemoveTagsViewModelAction.Failure) } - .also { isLoading.value = false } + fun updateViewState(addedTags: Set) { + _state.update { current -> + current.copy( + addedTags = addedTags, + suggestedTags = current.allTags + .filter { it !in addedTags } + .filter { it.contains(tagsTextState.text.toString(), ignoreCase = true) } + .toSet(), + tagsUpdated = addedTags != initialTags + ) } } - companion object { + private companion object { val ALLOWED_LENGTH = 1..30 - const val TYPING_DEBOUNCE_TIME = 200L } } +data class TagsViewState( + val isLoading: Boolean = false, + val tagsUpdated: Boolean = false, + val addedTags: Set = emptySet(), + val suggestedTags: Set = emptySet(), + val allTags: Set = emptySet(), +) + sealed interface AddRemoveTagsViewModelAction { data object Success : AddRemoveTagsViewModelAction data object Failure : AddRemoveTagsViewModelAction diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/ChipWithTextFieldLayout.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/ChipWithTextFieldLayout.kt index 5cee208d0da..9df2b511be1 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/ChipWithTextFieldLayout.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/tags/ChipWithTextFieldLayout.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.FlowRowScope import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn @@ -44,6 +45,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key @@ -52,8 +54,8 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization import com.wire.android.feature.cells.R -import com.wire.android.ui.common.chip.ChipData import com.wire.android.ui.common.chip.WireFilterChip import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions @@ -61,9 +63,9 @@ import com.wire.android.ui.common.preview.MultipleThemePreviews import com.wire.android.ui.common.textfield.WireTextFieldColors import com.wire.android.ui.common.textfield.WireTextFieldState import com.wire.android.ui.common.textfield.wireTextFieldColors +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography import kotlinx.coroutines.delay -import androidx.compose.runtime.key as runtimeKey @OptIn(ExperimentalLayoutApi::class) @Composable @@ -73,11 +75,10 @@ fun ChipAndTextFieldLayout( modifier: Modifier = Modifier, isValidTag: () -> Boolean = { false }, onDone: (String) -> Unit = { _ -> }, - tags: Set = emptySet(), textStyle: TextStyle = MaterialTheme.wireTypography.body01, style: WireTextFieldState = WireTextFieldState.Default, colors: WireTextFieldColors = wireTextFieldColors(), - chip: @Composable (data: ChipData) -> Unit + chipsLayout: @Composable FlowRowScope.() -> Unit ) { val showErrorMessage = remember { mutableStateOf(false) } @@ -100,14 +101,11 @@ fun ChipAndTextFieldLayout( .background(colorsScheme().surfaceVariant) .padding(dimensions().spacing16x) .animateContentSize(), - horizontalArrangement = Arrangement.spacedBy(dimensions().spacing8x) + horizontalArrangement = Arrangement.spacedBy(dimensions().spacing8x), + verticalArrangement = Arrangement.Bottom, ) { - tags.forEach { item -> - runtimeKey(item.id) { - chip(item) - } - } + chipsLayout() Box( modifier = Modifier @@ -137,13 +135,15 @@ fun ChipAndTextFieldLayout( ), lineLimits = TextFieldLineLimits.SingleLine, keyboardOptions = KeyboardOptions.Default.copy( - imeAction = ImeAction.Done + imeAction = ImeAction.Done, + capitalization = KeyboardCapitalization.Sentences, ), - onKeyboardAction = KeyboardActionHandler { performDefaultAction -> + onKeyboardAction = KeyboardActionHandler { _ -> if (isValidTag()) { onDone(textFieldState.text.toString()) } }, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), decorator = TextFieldDecorator { innerTextField -> Box { if (textFieldState.text.isEmpty()) { @@ -172,16 +172,17 @@ fun ChipAndTextFieldLayout( @Composable @MultipleThemePreviews -fun PreviewChipAndTextFieldLayout() { - ChipAndTextFieldLayout( - textFieldState = TextFieldState(), - tags = setOf(ChipData(id = "", label = "Chip", isSelected = false, isEnabled = false)), - onRemoveLastTag = { } - ) { data: ChipData -> - WireFilterChip( - label = data.label, - isSelected = data.isSelected, - onSelectChip = {} - ) +private fun PreviewChipAndTextFieldLayout() { + WireTheme { + ChipAndTextFieldLayout( + textFieldState = TextFieldState(), + onRemoveLastTag = {}, + ) { + WireFilterChip( + modifier = Modifier.align(Alignment.CenterVertically), + label = "Test", + isSelected = true, + ) + } } } diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/tags/AddRemoveTagsViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/tags/AddRemoveTagsViewModelTest.kt index 669614f7dea..ff78972d44a 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/tags/AddRemoveTagsViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/tags/AddRemoveTagsViewModelTest.kt @@ -32,7 +32,6 @@ 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 @@ -66,10 +65,12 @@ class AddRemoveTagsViewModelTest { .arrange() val newTag = "compose" - viewModel.addTag(newTag) - - val addedTags = viewModel.addedTags.first() - assertTrue(addedTags.contains(newTag)) + viewModel.state.test { + skipItems(1) + viewModel.addTag(newTag) + val addedTags = awaitItem().addedTags + assertTrue(addedTags.contains(newTag)) + } } @Test @@ -79,10 +80,11 @@ class AddRemoveTagsViewModelTest { .arrange() val blankTag = " " - viewModel.addTag(blankTag) - - val addedTags = viewModel.addedTags.first() - assertTrue(addedTags.isEmpty()) + viewModel.state.test { + viewModel.addTag(blankTag) + val addedTags = awaitItem().addedTags + assertTrue(addedTags.isEmpty()) + } } @Test @@ -92,12 +94,14 @@ class AddRemoveTagsViewModelTest { .arrange() val tag = "compose" - viewModel.addTag(tag) - - viewModel.addTag(tag) - val addedTags = viewModel.addedTags.first() - assertEquals(1, addedTags.count { it == tag }) + viewModel.state.test { + skipItems(1) + viewModel.addTag(tag) + viewModel.addTag(tag) + val addedTags = awaitItem().addedTags + assertEquals(1, addedTags.count { it == tag }) + } } @Test @@ -109,9 +113,12 @@ class AddRemoveTagsViewModelTest { .arrange() advanceUntilIdle() - assertTrue(viewModel.suggestedTags.contains(tagInSuggestions)) - viewModel.addTag(tagInSuggestions) - assertFalse(viewModel.suggestedTags.contains(tagInSuggestions)) + + viewModel.state.test { + assertTrue(awaitItem().suggestedTags.contains(tagInSuggestions)) + viewModel.addTag(tagInSuggestions) + assertFalse(awaitItem().suggestedTags.contains(tagInSuggestions)) + } } @Test @@ -120,13 +127,19 @@ class AddRemoveTagsViewModelTest { .withGetAllTagsUseCaseReturning(Either.Right(setOf())) .arrange() val tag = "compose" - viewModel.addTag(tag) - assertTrue(viewModel.addedTags.first().contains(tag)) - viewModel.removeTag(tag) + viewModel.state.test { + + skipItems(1) + + viewModel.addTag(tag) + assertTrue(awaitItem().addedTags.contains(tag)) - val addedTags = viewModel.addedTags.first() - assertFalse(addedTags.contains(tag)) + viewModel.removeTag(tag) + + val addedTags = awaitItem().addedTags + assertFalse(addedTags.contains(tag)) + } } @Test @@ -134,17 +147,22 @@ class AddRemoveTagsViewModelTest { val (_, viewModel) = Arrangement() .withGetAllTagsUseCaseReturning(Either.Right(setOf())) .arrange() + val existingTag = "compose" val nonExistentTag = "android" + viewModel.addTag(existingTag) - val initialTags = viewModel.addedTags.first() - // When - viewModel.removeTag(nonExistentTag) + viewModel.state.test { - // Then - val currentTags = viewModel.addedTags.first() - assertEquals(initialTags, currentTags) + skipItems(1) + + // When + viewModel.removeTag(nonExistentTag) + + // Then + expectNoEvents() + } } @Test