diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/SearchModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/SearchModule.kt index 1cbda4379f1..9ca0a42d252 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/SearchModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/SearchModule.kt @@ -23,9 +23,9 @@ import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.search.FederatedSearchParser import com.wire.kalium.logic.feature.search.IsFederationSearchAllowedUseCase -import com.wire.kalium.logic.feature.search.SearchByHandleUseCase +import com.wire.kalium.logic.feature.search.SearchUsersByHandleUseCase import com.wire.kalium.logic.feature.search.SearchScope -import com.wire.kalium.logic.feature.search.SearchUsersUseCase +import com.wire.kalium.logic.feature.search.SearchUsersByNameUseCase import dev.zacsweers.metro.BindingContainer import dev.zacsweers.metro.Provides @@ -39,10 +39,10 @@ class SearchModule { ): SearchScope = coreLogic.getSessionScope(currentAccount).search @Provides - fun provideSearchUsersUseCase(searchScope: SearchScope): SearchUsersUseCase = searchScope.searchUsers + fun provideSearchUsersByNameUseCase(searchScope: SearchScope): SearchUsersByNameUseCase = searchScope.searchUsersByName @Provides - fun provideSearchByHandleUseCase(searchScope: SearchScope): SearchByHandleUseCase = searchScope.searchByHandle + fun provideSearchUsersByHandleUseCase(searchScope: SearchScope): SearchUsersByHandleUseCase = searchScope.searchUsersByHandle @Provides fun provideFederatedSearchParser(searchScope: SearchScope): FederatedSearchParser = searchScope.federatedSearchParser diff --git a/app/src/main/kotlin/com/wire/android/navigation/MainNavHost.kt b/app/src/main/kotlin/com/wire/android/navigation/MainNavHost.kt index 87974e15cb6..0221faffbe2 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/MainNavHost.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/MainNavHost.kt @@ -30,14 +30,16 @@ import com.ramcosta.composedestinations.DestinationsNavHost import com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.NewLoginPasswordScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.NewLoginVerificationCodeScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProfileScreenDestination import com.ramcosta.composedestinations.generated.app.navArgs import com.ramcosta.composedestinations.generated.app.navgraphs.NewConversationGraph import com.ramcosta.composedestinations.generated.app.navgraphs.PersonalToTeamMigrationGraph -import com.ramcosta.composedestinations.generated.cells.destinations.SearchScreenDestination import com.ramcosta.composedestinations.generated.app.navgraphs.WireRootGraph import com.ramcosta.composedestinations.generated.app.navtype.groupConversationDetailsNavBackArgsNavType import com.ramcosta.composedestinations.generated.app.navtype.imagesPreviewNavBackArgsNavType import com.ramcosta.composedestinations.generated.app.navtype.mediaGalleryNavBackArgsNavType +import com.ramcosta.composedestinations.generated.cells.destinations.SearchScreenDestination +import com.ramcosta.composedestinations.generated.meetings.navgraphs.NewMeetingGraph import com.ramcosta.composedestinations.generated.sketch.destinations.DrawingCanvasScreenDestination import com.ramcosta.composedestinations.generated.sketch.navtype.drawingCanvasNavBackArgsNavType import com.ramcosta.composedestinations.manualcomposablecalls.composable @@ -48,12 +50,15 @@ import com.ramcosta.composedestinations.scope.resultBackNavigator import com.ramcosta.composedestinations.scope.resultRecipient import com.ramcosta.composedestinations.spec.Direction import com.wire.android.feature.cells.ui.cellViewModel +import com.wire.android.feature.meetings.navigation.MeetingNavigator +import com.wire.android.feature.meetings.ui.newMeetingViewModel import com.wire.android.feature.sketch.model.DrawingCanvasNavBackArgs import com.wire.android.navigation.transition.LocalSharedTransitionScope import com.wire.android.ui.authentication.loginEmailViewModel import com.wire.android.ui.home.conversations.ConversationScreen import com.wire.android.ui.home.newConversationViewModel import com.wire.android.ui.home.settings.teamMigrationViewModel +import com.wire.kalium.logic.data.user.UserId @OptIn(ExperimentalAnimationApi::class, ExperimentalSharedTransitionApi::class) @Composable @@ -64,6 +69,14 @@ fun MainNavHost( modifier: Modifier = Modifier, ) { val navHostEngine = rememberWireNavHostEngine(Alignment.Center) + val meetingNavigator = remember(navigator) { + MeetingNavigator( + navigator = navigator, + navigateToProfile = { userId: UserId -> + navigator.navigate(NavigationCommand(OtherUserProfileScreenDestination(userId = userId))) + } + ) + } SharedTransitionLayout(modifier = modifier) { CompositionLocalProvider(LocalSharedTransitionScope provides this) { DestinationsNavHost( @@ -80,6 +93,17 @@ fun MainNavHost( // 👇 To make LoginTypeSelector available to all destinations as a non-navigation parameter if provided if (loginTypeSelector != null) dependency(loginTypeSelector) + // 👇 To tie NewConversationViewModel to nested NewConversationGraph, + // making it shared between all screens that belong to it + navGraph(NewConversationGraph) { + val parentEntry = remember(navBackStackEntry) { + navController.getBackStackEntry(NewConversationGraph.route) + } + dependency( + newConversationViewModel(parentEntry) + ) + } + // 👇 To reuse LoginEmailViewModel from NewLoginPasswordScreen on NewLoginVerificationCodeScreen destination(NewLoginVerificationCodeScreenDestination) { val loginPasswordEntry = remember(navBackStackEntry) { @@ -118,6 +142,16 @@ fun MainNavHost( } dependency(teamMigrationViewModel(parentEntry)) } + + // 👇 To tie NewMeetingViewModel to nested NewMeetingGraph, making it shared between all screens that belong to it + // Also, make MeetingNavigator available to all destinations from NewMeetingGraph as a non-navigation parameter + navGraph(NewMeetingGraph) { + val parentEntry = remember(navBackStackEntry) { + navController.getBackStackEntry(NewMeetingGraph.route) + } + dependency(newMeetingViewModel(parentEntry)) + dependency(meetingNavigator) + } }, manualComposableCallsBuilder = { /** diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryScreen.kt index 9e783c913b8..af3c425b1cd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/channelhistory/ChannelHistoryScreen.kt @@ -17,19 +17,20 @@ */ package com.wire.android.ui.home.newconversation.channelhistory -import com.wire.android.navigation.annotation.app.WireNewConversationDestination import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import com.ramcosta.composedestinations.generated.app.destinations.ChannelHistoryCustomScreenDestination import com.ramcosta.composedestinations.result.NavResult 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.annotation.app.WireNewConversationDestination import com.wire.android.navigation.style.SlideNavigationAnimation import com.wire.android.ui.common.WirePromotionCard import com.wire.android.ui.common.colorsScheme @@ -38,7 +39,6 @@ import com.wire.android.ui.common.scaffold.WireScaffold 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.ramcosta.composedestinations.generated.app.destinations.ChannelHistoryCustomScreenDestination import com.wire.android.ui.home.conversations.details.options.ArrowType import com.wire.android.ui.home.conversations.details.options.GroupConversationOptionsItem import com.wire.android.ui.home.newconversation.NewConversationViewModel diff --git a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupOptions/GroupOptionsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupOptions/GroupOptionsScreen.kt index b71730edc6a..698af439f42 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupOptions/GroupOptionsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/newconversation/groupOptions/GroupOptionsScreen.kt @@ -19,7 +19,6 @@ package com.wire.android.ui.home.newconversation.groupOptions -import com.wire.android.navigation.annotation.app.WireNewConversationDestination import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -36,12 +35,18 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration +import com.ramcosta.composedestinations.generated.app.destinations.ChannelAccessOnCreateScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.ChannelHistoryScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.HomeScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.NewGroupConversationSearchPeopleScreenDestination import com.wire.android.BuildConfig import com.wire.android.R import com.wire.android.model.Clickable import com.wire.android.navigation.BackStackMode import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator +import com.wire.android.navigation.annotation.app.WireNewConversationDestination import com.wire.android.ui.common.TextWithLinkSuffix import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties @@ -55,17 +60,12 @@ 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.common.upgradetoapps.UpgradeToGetAppsBanner -import com.ramcosta.composedestinations.generated.app.destinations.ChannelAccessOnCreateScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ChannelHistoryScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.ConversationScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.HomeScreenDestination -import com.ramcosta.composedestinations.generated.app.destinations.NewGroupConversationSearchPeopleScreenDestination import com.wire.android.ui.home.conversations.details.options.ArrowType import com.wire.android.ui.home.conversations.details.options.GroupConversationOptionsItem +import com.wire.android.ui.home.newconversation.NewConversationViewModel import com.wire.android.ui.home.newconversation.channelaccess.ChannelAccessType import com.wire.android.ui.home.newconversation.channelhistory.ChannelHistoryType import com.wire.android.ui.home.newconversation.channelhistory.name -import com.wire.android.ui.home.newconversation.NewConversationViewModel import com.wire.android.ui.home.newconversation.common.CreateGroupErrorDialog import com.wire.android.ui.home.newconversation.common.CreateGroupState import com.wire.android.ui.home.settings.SwitchState diff --git a/core/di/src/main/kotlin/com/wire/android/di/ViewModelScoped.kt b/core/di/src/main/kotlin/com/wire/android/di/ViewModelScoped.kt index e0bc634005c..74d0ee7e152 100644 --- a/core/di/src/main/kotlin/com/wire/android/di/ViewModelScoped.kt +++ b/core/di/src/main/kotlin/com/wire/android/di/ViewModelScoped.kt @@ -171,6 +171,12 @@ interface ScopedArgs { @Stable interface PreviewProvider { val previews: List get() = emptyList() + + companion object { + fun of(vararg previews: Any) = object : PreviewProvider { + override val previews: List = previews.toList() + } + } } object EmptyPreviewProvider : PreviewProvider diff --git a/core/di/src/main/kotlin/com/wire/android/di/metro/MetroViewModelGraph.kt b/core/di/src/main/kotlin/com/wire/android/di/metro/MetroViewModelGraph.kt index ff6a3d0caee..2caf1227340 100644 --- a/core/di/src/main/kotlin/com/wire/android/di/metro/MetroViewModelGraph.kt +++ b/core/di/src/main/kotlin/com/wire/android/di/metro/MetroViewModelGraph.kt @@ -112,9 +112,13 @@ inline fun sessionKeyedMetroViewModelAs( inline fun sessionKeyedAssistedMetroViewModel( key: String? = null, previewProvider: PreviewProvider = EmptyPreviewProvider, + viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" + }, crossinline createViewModel: Factory.() -> VM, ): VM where VM : ViewModel, Factory : ManualViewModelAssistedFactory = previewProvider.findPreviewOr { assistedMetroViewModel( + viewModelStoreOwner = viewModelStoreOwner, key = sessionKeyedMetroViewModelKey( defaultKey = VM::class.qualifiedName, key = key, @@ -137,9 +141,13 @@ inline fun sessionKeyedAssistedMetroViewModel( inline fun sessionKeyedAssistedMetroViewModelAs( key: String? = null, previewProvider: PreviewProvider = EmptyPreviewProvider, + viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" + }, crossinline createViewModel: Factory.() -> VM, ): S where VM : ViewModel, VM : S, Factory : ManualViewModelAssistedFactory = previewProvider.findPreviewOr { assistedMetroViewModel( + viewModelStoreOwner = viewModelStoreOwner, key = sessionKeyedMetroViewModelKey( defaultKey = VM::class.qualifiedName, key = key, diff --git a/core/di/stability/di-debug.stability b/core/di/stability/di-debug.stability index 930e8a5e5e0..644e997877d 100644 --- a/core/di/stability/di-debug.stability +++ b/core/di/stability/di-debug.stability @@ -25,21 +25,23 @@ internal fun com.wire.android.di.manualScopedViewModelFactory(create: @[Extensio - create: STABLE (function type) @Composable -public fun com.wire.android.di.metro.sessionKeyedAssistedMetroViewModel(key: kotlin.String?, previewProvider: com.wire.android.di.PreviewProvider, createViewModel: @[ExtensionFunctionType] kotlin.Function1): VM of com.wire.android.di.metro.sessionKeyedAssistedMetroViewModel - skippable: true +public fun com.wire.android.di.metro.sessionKeyedAssistedMetroViewModel(key: kotlin.String?, previewProvider: com.wire.android.di.PreviewProvider, viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner, createViewModel: @[ExtensionFunctionType] kotlin.Function1): VM of com.wire.android.di.metro.sessionKeyedAssistedMetroViewModel + skippable: false restartable: true params: - key: STABLE (class with no mutable properties) - previewProvider: STABLE (marked @Stable or @Immutable) + - viewModelStoreOwner: RUNTIME (requires runtime check) - createViewModel: STABLE (function type) @Composable -public fun com.wire.android.di.metro.sessionKeyedAssistedMetroViewModelAs(key: kotlin.String?, previewProvider: com.wire.android.di.PreviewProvider, createViewModel: @[ExtensionFunctionType] kotlin.Function1): S of com.wire.android.di.metro.sessionKeyedAssistedMetroViewModelAs - skippable: true +public fun com.wire.android.di.metro.sessionKeyedAssistedMetroViewModelAs(key: kotlin.String?, previewProvider: com.wire.android.di.PreviewProvider, viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner, createViewModel: @[ExtensionFunctionType] kotlin.Function1): S of com.wire.android.di.metro.sessionKeyedAssistedMetroViewModelAs + skippable: false restartable: true params: - key: STABLE (class with no mutable properties) - previewProvider: STABLE (marked @Stable or @Immutable) + - viewModelStoreOwner: RUNTIME (requires runtime check) - createViewModel: STABLE (function type) @Composable diff --git a/core/search/src/main/kotlin/com/wire/android/model/Contact.kt b/core/search/src/main/kotlin/com/wire/android/model/Contact.kt index a08d1d7573c..f2eac30ca9c 100644 --- a/core/search/src/main/kotlin/com/wire/android/model/Contact.kt +++ b/core/search/src/main/kotlin/com/wire/android/model/Contact.kt @@ -17,9 +17,11 @@ */ package com.wire.android.model +import androidx.compose.runtime.Stable import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.kalium.logic.data.user.ConnectionState +@Stable data class Contact( val id: String, val domain: String, diff --git a/core/search/src/main/kotlin/com/wire/android/search/SearchMetroViewModelBindings.kt b/core/search/src/main/kotlin/com/wire/android/search/SearchMetroViewModelBindings.kt index a5311e998a8..9b29cdf54aa 100644 --- a/core/search/src/main/kotlin/com/wire/android/search/SearchMetroViewModelBindings.kt +++ b/core/search/src/main/kotlin/com/wire/android/search/SearchMetroViewModelBindings.kt @@ -35,8 +35,8 @@ object SearchMetroViewModelBindings { @ManualViewModelAssistedFactoryKey(SearchManualViewModelFactory::class) fun searchManualViewModelFactory(factory: SearchViewModelFactory): ManualViewModelAssistedFactory = object : SearchManualViewModelFactory { - override fun searchUserViewModel(conversationId: ConversationId?): SearchUserViewModel = - factory.searchUserViewModel(conversationId) + override fun searchUserViewModel(conversationId: ConversationId?, onlyConnectedContacts: Boolean): SearchUserViewModel = + factory.searchUserViewModel(conversationId, onlyConnectedContacts) override fun searchAppsViewModel(protocolInfo: Conversation.ProtocolInfo?): SearchAppsViewModel = factory.searchAppsViewModel(protocolInfo) diff --git a/core/search/src/main/kotlin/com/wire/android/search/SearchUsersAndAppsScreen.kt b/core/search/src/main/kotlin/com/wire/android/search/SearchUsersAndAppsScreen.kt index 97736889719..10ce5301906 100644 --- a/core/search/src/main/kotlin/com/wire/android/search/SearchUsersAndAppsScreen.kt +++ b/core/search/src/main/kotlin/com/wire/android/search/SearchUsersAndAppsScreen.kt @@ -72,6 +72,7 @@ import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.topappbar.search.SearchTopBar import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.conversation.Conversation +import com.wire.kalium.logic.data.id.ConversationId import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.launch @@ -83,12 +84,12 @@ fun SearchUsersAndAppsScreen( searchTitle: String, selectedContacts: ImmutableSet, onContactChecked: (Boolean, Contact) -> Unit, - onOpenUserProfile: (Contact) -> Unit, - onAppClicked: (Contact) -> Unit, onClose: () -> Unit, navigationIconType: NavigationIconType, itemActionType: ItemActionType, modifier: Modifier = Modifier, + conversationId: ConversationId? = null, + onlyConnectedContacts: Boolean = false, shouldHideBottomActionForSearch: Boolean = false, shouldHideBottomActionForServices: Boolean = false, isAppsTabVisible: Boolean = false, @@ -96,6 +97,8 @@ fun SearchUsersAndAppsScreen( initialPage: SearchPeopleTabItem = SearchPeopleTabItem.PEOPLE, conversationProtocol: Conversation.ProtocolInfo? = null, peopleBottomActions: (@Composable (FocusRequester) -> Unit)? = null, + onOpenUserProfile: (Contact) -> Unit = {}, + onAppClicked: (Contact) -> Unit = {}, ) { val searchBarState = rememberSearchbarState() val scope = rememberCoroutineScope() @@ -211,6 +214,8 @@ fun SearchUsersAndAppsScreen( when (tabs[pageIndex]) { SearchPeopleTabItem.PEOPLE -> { SearchAllPeopleOrContactsScreen( + conversationId = conversationId, + onlyConnectedContacts = onlyConnectedContacts, searchQuery = searchBarState.searchQueryTextState.text.toString(), contactsSelected = selectedContacts, onOpenUserProfile = onOpenUserProfile, @@ -268,7 +273,9 @@ private fun SearchAllPeopleOrContactsScreen( actionType: ItemActionType, onOpenUserProfile: (Contact) -> Unit, onContactChecked: (Boolean, Contact) -> Unit, - searchUserViewModel: SearchUserViewModel = searchUserViewModel(), + conversationId: ConversationId? = null, + onlyConnectedContacts: Boolean = false, + searchUserViewModel: SearchUserViewModel = searchUserViewModel(conversationId, onlyConnectedContacts), lazyListState: LazyListState = rememberLazyListState(), firstContactFocusRequester: FocusRequester? = null, nextFocusRequester: FocusRequester? = null, diff --git a/core/search/src/main/kotlin/com/wire/android/search/SearchViewModelFactory.kt b/core/search/src/main/kotlin/com/wire/android/search/SearchViewModelFactory.kt index 8622337dd64..64f794a83c5 100644 --- a/core/search/src/main/kotlin/com/wire/android/search/SearchViewModelFactory.kt +++ b/core/search/src/main/kotlin/com/wire/android/search/SearchViewModelFactory.kt @@ -28,8 +28,8 @@ import com.wire.kalium.logic.feature.auth.ValidateUserHandleUseCase import com.wire.kalium.logic.feature.featureConfig.ObserveIsAppsAllowedForUsageUseCase import com.wire.kalium.logic.feature.search.FederatedSearchParser import com.wire.kalium.logic.feature.search.IsFederationSearchAllowedUseCase -import com.wire.kalium.logic.feature.search.SearchByHandleUseCase -import com.wire.kalium.logic.feature.search.SearchUsersUseCase +import com.wire.kalium.logic.feature.search.SearchUsersByHandleUseCase +import com.wire.kalium.logic.feature.search.SearchUsersByNameUseCase import com.wire.kalium.logic.feature.service.ObserveAllServicesUseCase import com.wire.kalium.logic.feature.service.SearchServicesByNameUseCase import com.wire.kalium.logic.feature.service.SyncServicesUseCase @@ -38,8 +38,8 @@ import dev.zacsweers.metro.Inject @Suppress("LongParameterList") class SearchViewModelFactory @Inject constructor( - private val searchUsers: SearchUsersUseCase, - private val searchByHandle: SearchByHandleUseCase, + private val searchUsersByName: SearchUsersByNameUseCase, + private val searchUsersByHandle: SearchUsersByHandleUseCase, private val contactMapper: ContactMapper, private val federatedSearchParser: FederatedSearchParser, private val validateUserHandle: ValidateUserHandleUseCase, @@ -52,10 +52,14 @@ class SearchViewModelFactory @Inject constructor( private val observeIsAppsAllowedForUsage: ObserveIsAppsAllowedForUsageUseCase, private val observeSelfUser: ObserveSelfUserUseCase, ) { - fun searchUserViewModel(conversationId: ConversationId? = null) = SearchUserViewModel( + fun searchUserViewModel( + conversationId: ConversationId? = null, + onlyConnectedContacts: Boolean = false, + ) = SearchUserViewModel( conversationId = conversationId, - searchUserUseCase = searchUsers, - searchByHandleUseCase = searchByHandle, + onlyConnectedContacts = onlyConnectedContacts, + searchUsersByName = searchUsersByName, + searchUsersByHandle = searchUsersByHandle, contactMapper = contactMapper, federatedSearchParser = federatedSearchParser, validateUserHandle = validateUserHandle, diff --git a/core/search/src/main/kotlin/com/wire/android/search/SearchViewModelGraph.kt b/core/search/src/main/kotlin/com/wire/android/search/SearchViewModelGraph.kt index 1284ab88919..3c270a9fa87 100644 --- a/core/search/src/main/kotlin/com/wire/android/search/SearchViewModelGraph.kt +++ b/core/search/src/main/kotlin/com/wire/android/search/SearchViewModelGraph.kt @@ -28,16 +28,27 @@ import com.wire.kalium.logic.data.id.ConversationId import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory interface SearchManualViewModelFactory : ManualViewModelAssistedFactory { - fun searchUserViewModel(conversationId: ConversationId? = null): SearchUserViewModel + fun searchUserViewModel( + conversationId: ConversationId? = null, + onlyConnectedContacts: Boolean = false, + ): SearchUserViewModel + fun searchAppsViewModel(protocolInfo: Conversation.ProtocolInfo? = null): SearchAppsViewModel } @Composable -fun searchUserViewModel(conversationId: ConversationId? = null): SearchUserViewModel = +fun searchUserViewModel( + conversationId: ConversationId? = null, + onlyConnectedContacts: Boolean = false, +): SearchUserViewModel = sessionKeyedAssistedMetroViewModel( - key = conversationId?.let { "search_user_conversation_id_$it" } ?: "search_user" + key = listOfNotNull( + "search_user", + if (onlyConnectedContacts) "only_connected_contacts" else null, + if (conversationId != null) "conversation_id_${conversationId.value}" else null + ).joinToString("_") ) { - searchUserViewModel(conversationId) + searchUserViewModel(conversationId, onlyConnectedContacts) } @Composable diff --git a/core/search/src/main/kotlin/com/wire/android/search/users/SearchUserViewModel.kt b/core/search/src/main/kotlin/com/wire/android/search/users/SearchUserViewModel.kt index 03dd8f742b8..e4380f57800 100644 --- a/core/search/src/main/kotlin/com/wire/android/search/users/SearchUserViewModel.kt +++ b/core/search/src/main/kotlin/com/wire/android/search/users/SearchUserViewModel.kt @@ -32,9 +32,9 @@ import com.wire.kalium.logic.feature.auth.ValidateUserHandleResult import com.wire.kalium.logic.feature.auth.ValidateUserHandleUseCase import com.wire.kalium.logic.feature.search.FederatedSearchParser import com.wire.kalium.logic.feature.search.IsFederationSearchAllowedUseCase -import com.wire.kalium.logic.feature.search.SearchByHandleUseCase import com.wire.kalium.logic.feature.search.SearchUserResult -import com.wire.kalium.logic.feature.search.SearchUsersUseCase +import com.wire.kalium.logic.feature.search.SearchUsersByHandleUseCase +import com.wire.kalium.logic.feature.search.SearchUsersByNameUseCase import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf @@ -50,8 +50,9 @@ import kotlinx.coroutines.launch class SearchUserViewModel( private val conversationId: ConversationId?, - private val searchUserUseCase: SearchUsersUseCase, - private val searchByHandleUseCase: SearchByHandleUseCase, + private val onlyConnectedContacts: Boolean, + private val searchUsersByName: SearchUsersByNameUseCase, + private val searchUsersByHandle: SearchUsersByHandleUseCase, private val contactMapper: ContactMapper, private val federatedSearchParser: FederatedSearchParser, private val validateUserHandle: ValidateUserHandleUseCase, @@ -139,16 +140,18 @@ class SearchUserViewModel( } private suspend fun searchByHandle(searchTerm: String, domain: String?): SearchUserResult = - searchByHandleUseCase( + searchUsersByHandle( searchTerm, excludingConversation = conversationId, + skipRemoteSearch = onlyConnectedContacts, customDomain = domain ) private suspend fun searchByName(searchTerm: String, domain: String?): SearchUserResult = - searchUserUseCase( + searchUsersByName( searchTerm, excludingMembersOfConversation = conversationId, + skipRemoteSearch = onlyConnectedContacts, customDomain = domain ) } diff --git a/core/search/src/test/kotlin/com/wire/android/search/users/SearchUserViewModelTest.kt b/core/search/src/test/kotlin/com/wire/android/search/users/SearchUserViewModelTest.kt index 9cffec1ec6d..cbab1dd2cec 100644 --- a/core/search/src/test/kotlin/com/wire/android/search/users/SearchUserViewModelTest.kt +++ b/core/search/src/test/kotlin/com/wire/android/search/users/SearchUserViewModelTest.kt @@ -37,9 +37,9 @@ import com.wire.kalium.logic.feature.auth.ValidateUserHandleResult import com.wire.kalium.logic.feature.auth.ValidateUserHandleUseCase import com.wire.kalium.logic.feature.search.FederatedSearchParser import com.wire.kalium.logic.feature.search.IsFederationSearchAllowedUseCase -import com.wire.kalium.logic.feature.search.SearchByHandleUseCase +import com.wire.kalium.logic.feature.search.SearchUsersByHandleUseCase import com.wire.kalium.logic.feature.search.SearchUserResult -import com.wire.kalium.logic.feature.search.SearchUsersUseCase +import com.wire.kalium.logic.feature.search.SearchUsersByNameUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify @@ -79,7 +79,7 @@ class SearchUserViewModelTest { viewModel.searchQueryChanged(query) coVerify(exactly = 1) { - arrangement.searchUsersUseCase( + arrangement.searchUsersByNameUseCase( query, excludingMembersOfConversation = null, customDomain = "domain" @@ -117,7 +117,7 @@ class SearchUserViewModelTest { viewModel.searchQueryChanged(query) coVerify(exactly = 1) { - arrangement.searchUsersUseCase( + arrangement.searchUsersByNameUseCase( query, excludingMembersOfConversation = conversationId, customDomain = "domain" @@ -248,7 +248,7 @@ class SearchUserViewModelTest { viewModel.searchQueryChanged(query) coVerify(exactly = 1) { - arrangement.searchUsersUseCase( + arrangement.searchUsersByNameUseCase( query, excludingMembersOfConversation = null, customDomain = "domain" @@ -284,7 +284,7 @@ class SearchUserViewModelTest { viewModel.searchQueryChanged(query) coVerify(exactly = 1) { - arrangement.searchByHandleUseCase.invoke( + arrangement.searchUsersByHandleUseCase.invoke( query, excludingConversation = null, customDomain = "domain" @@ -328,10 +328,94 @@ class SearchUserViewModelTest { assertEquals(persistentListOf(), viewModel.state.contactsResult) } + @Test + fun `given only connected contacts is false, when searching by handle, then do not exclude remote`() = runTest { + val query = "handle" + val (arrangement, viewModel) = Arrangement() + .withOnlyConnectedContacts(false) + .withSearchByHandleResult(SearchUserResult(connected = listOf(), notConnected = listOf())) + .withFederatedSearchParserResult(FederatedSearchParser.Result(searchTerm = query, domain = "domain")) + .withIsValidHandleResult(ValidateUserHandleResult.Valid("")) + .arrange() + + viewModel.searchQueryChanged(query) + coVerify(exactly = 1) { + arrangement.searchUsersByHandleUseCase.invoke( + searchHandle = query, + excludingConversation = null, + skipRemoteSearch = false, + customDomain = "domain" + ) + } + } + + @Test + fun `given only connected contacts is true, when searching by handle, then exclude remote`() = runTest { + val query = "handle" + val (arrangement, viewModel) = Arrangement() + .withOnlyConnectedContacts(true) + .withSearchByHandleResult(SearchUserResult(connected = listOf(), notConnected = listOf())) + .withFederatedSearchParserResult(FederatedSearchParser.Result(searchTerm = query, domain = "domain")) + .withIsValidHandleResult(ValidateUserHandleResult.Valid("")) + .arrange() + + viewModel.searchQueryChanged(query) + coVerify(exactly = 1) { + arrangement.searchUsersByHandleUseCase.invoke( + searchHandle = query, + excludingConversation = null, + skipRemoteSearch = true, + customDomain = "domain" + ) + } + } + + @Test + fun `given only connected contacts is false, when searching by name, then do not exclude remote`() = runTest { + val query = "Name" + val (arrangement, viewModel) = Arrangement() + .withOnlyConnectedContacts(false) + .withSearchResult(SearchUserResult(connected = listOf(), notConnected = listOf())) + .withFederatedSearchParserResult(FederatedSearchParser.Result(searchTerm = query, domain = "domain")) + .withIsValidHandleResult(ValidateUserHandleResult.Invalid.InvalidCharacters("ame", listOf('N'))) + .arrange() + + viewModel.searchQueryChanged(query) + coVerify(exactly = 1) { + arrangement.searchUsersByNameUseCase.invoke( + searchQuery = query, + excludingMembersOfConversation = null, + skipRemoteSearch = false, + customDomain = "domain" + ) + } + } + + @Test + fun `given only connected contacts is true, when searching by name, then exclude remote`() = runTest { + val query = "Name" + val (arrangement, viewModel) = Arrangement() + .withOnlyConnectedContacts(true) + .withSearchResult(SearchUserResult(connected = listOf(), notConnected = listOf())) + .withFederatedSearchParserResult(FederatedSearchParser.Result(searchTerm = query, domain = "domain")) + .withIsValidHandleResult(ValidateUserHandleResult.Invalid.InvalidCharacters("ame", listOf('N'))) + .arrange() + + viewModel.searchQueryChanged(query) + coVerify(exactly = 1) { + arrangement.searchUsersByNameUseCase.invoke( + searchQuery = query, + excludingMembersOfConversation = null, + skipRemoteSearch = true, + customDomain = "domain" + ) + } + } + private class Arrangement { @MockK - lateinit var searchUsersUseCase: SearchUsersUseCase + lateinit var searchUsersByNameUseCase: SearchUsersByNameUseCase @MockK lateinit var contactMapper: ContactMapper @@ -343,13 +427,15 @@ class SearchUserViewModelTest { lateinit var validateUserHandle: ValidateUserHandleUseCase @MockK - lateinit var searchByHandleUseCase: SearchByHandleUseCase + lateinit var searchUsersByHandleUseCase: SearchUsersByHandleUseCase @MockK lateinit var isFederationSearchAllowedUseCase: IsFederationSearchAllowedUseCase private var conversationId: ConversationId? = null + private var onlyConnectedContacts: Boolean = false + init { MockKAnnotations.init(this, relaxUnitFun = true) every { contactMapper.fromSearchUserResult(any()) } answers { @@ -393,8 +479,12 @@ class SearchUserViewModelTest { this.conversationId = conversationId } + fun withOnlyConnectedContacts(onlyConnectedContacts: Boolean) = apply { + this.onlyConnectedContacts = onlyConnectedContacts + } + fun withSearchResult(result: SearchUserResult) = apply { - coEvery { searchUsersUseCase(any(), any(), any()) } returns result + coEvery { searchUsersByNameUseCase(any(), any(), any(), any()) } returns result } fun withFederatedSearchParserResult(result: FederatedSearchParser.Result) = apply { @@ -406,7 +496,7 @@ class SearchUserViewModelTest { } fun withSearchByHandleResult(result: SearchUserResult) = apply { - coEvery { searchByHandleUseCase(any(), any(), any()) } returns result + coEvery { searchUsersByHandleUseCase(any(), any(), any(), any()) } returns result } fun withIsFederationSearchAllowedResult(isAllowed: Boolean = true) = apply { @@ -418,8 +508,9 @@ class SearchUserViewModelTest { fun arrange() = apply { searchUserViewModel = SearchUserViewModel( conversationId = conversationId, - searchUserUseCase = searchUsersUseCase, - searchByHandleUseCase = searchByHandleUseCase, + onlyConnectedContacts = onlyConnectedContacts, + searchUsersByName = searchUsersByNameUseCase, + searchUsersByHandle = searchUsersByHandleUseCase, contactMapper = contactMapper, federatedSearchParser = federatedSearchParser, validateUserHandle = validateUserHandle, diff --git a/core/search/stability/search-debug.stability b/core/search/stability/search-debug.stability index 535b4b2e048..d722f82b059 100644 --- a/core/search/stability/search-debug.stability +++ b/core/search/stability/search-debug.stability @@ -5,7 +5,7 @@ // ./gradlew :search:stabilityDump @Composable -private fun com.wire.android.search.SearchAllPeopleOrContactsScreen(searchQuery: kotlin.String, contactsSelected: kotlinx.collections.immutable.ImmutableSet, isSearchActive: kotlin.Boolean, actionType: com.wire.android.model.ItemActionType, onOpenUserProfile: kotlin.Function1, onContactChecked: kotlin.Function2, searchUserViewModel: com.wire.android.search.users.SearchUserViewModel, lazyListState: androidx.compose.foundation.lazy.LazyListState, firstContactFocusRequester: androidx.compose.ui.focus.FocusRequester?, nextFocusRequester: androidx.compose.ui.focus.FocusRequester?): kotlin.Unit +private fun com.wire.android.search.SearchAllPeopleOrContactsScreen(searchQuery: kotlin.String, contactsSelected: kotlinx.collections.immutable.ImmutableSet, isSearchActive: kotlin.Boolean, actionType: com.wire.android.model.ItemActionType, onOpenUserProfile: kotlin.Function1, onContactChecked: kotlin.Function2, conversationId: com.wire.kalium.logic.data.id.QualifiedID?, onlyConnectedContacts: kotlin.Boolean, searchUserViewModel: com.wire.android.search.users.SearchUserViewModel, lazyListState: androidx.compose.foundation.lazy.LazyListState, firstContactFocusRequester: androidx.compose.ui.focus.FocusRequester?, nextFocusRequester: androidx.compose.ui.focus.FocusRequester?): kotlin.Unit skippable: false restartable: true params: @@ -15,25 +15,27 @@ private fun com.wire.android.search.SearchAllPeopleOrContactsScreen(searchQuery: - actionType: STABLE (class with no mutable properties) - onOpenUserProfile: STABLE (function type) - onContactChecked: STABLE (function type) + - conversationId: UNSTABLE (has mutable properties or unstable members) + - onlyConnectedContacts: STABLE (primitive type) - searchUserViewModel: UNSTABLE (has mutable properties or unstable members) - lazyListState: STABLE (marked @Stable or @Immutable) - firstContactFocusRequester: STABLE (marked @Stable or @Immutable) - nextFocusRequester: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.search.SearchUsersAndAppsScreen(searchTitle: kotlin.String, selectedContacts: kotlinx.collections.immutable.ImmutableSet, onContactChecked: kotlin.Function2, onOpenUserProfile: kotlin.Function1, onAppClicked: kotlin.Function1, onClose: kotlin.Function0, navigationIconType: com.wire.android.ui.common.topappbar.NavigationIconType, itemActionType: com.wire.android.model.ItemActionType, modifier: androidx.compose.ui.Modifier, shouldHideBottomActionForSearch: kotlin.Boolean, shouldHideBottomActionForServices: kotlin.Boolean, isAppsTabVisible: kotlin.Boolean, isConversationAppsEnabled: kotlin.Boolean, initialPage: com.wire.android.search.SearchPeopleTabItem, conversationProtocol: com.wire.kalium.logic.data.conversation.Conversation.ProtocolInfo?, peopleBottomActions: @[Composable] androidx.compose.runtime.internal.ComposableFunction1?): kotlin.Unit +public fun com.wire.android.search.SearchUsersAndAppsScreen(searchTitle: kotlin.String, selectedContacts: kotlinx.collections.immutable.ImmutableSet, onContactChecked: kotlin.Function2, onClose: kotlin.Function0, navigationIconType: com.wire.android.ui.common.topappbar.NavigationIconType, itemActionType: com.wire.android.model.ItemActionType, modifier: androidx.compose.ui.Modifier, conversationId: com.wire.kalium.logic.data.id.QualifiedID?, onlyConnectedContacts: kotlin.Boolean, shouldHideBottomActionForSearch: kotlin.Boolean, shouldHideBottomActionForServices: kotlin.Boolean, isAppsTabVisible: kotlin.Boolean, isConversationAppsEnabled: kotlin.Boolean, initialPage: com.wire.android.search.SearchPeopleTabItem, conversationProtocol: com.wire.kalium.logic.data.conversation.Conversation.ProtocolInfo?, peopleBottomActions: @[Composable] androidx.compose.runtime.internal.ComposableFunction1?, onOpenUserProfile: kotlin.Function1, onAppClicked: kotlin.Function1): kotlin.Unit skippable: false restartable: true params: - searchTitle: STABLE (String is immutable) - selectedContacts: STABLE (known stable type) - onContactChecked: STABLE (function type) - - onOpenUserProfile: STABLE (function type) - - onAppClicked: STABLE (function type) - onClose: STABLE (function type) - navigationIconType: STABLE (marked @Stable or @Immutable) - itemActionType: STABLE (class with no mutable properties) - modifier: STABLE (marked @Stable or @Immutable) + - conversationId: UNSTABLE (has mutable properties or unstable members) + - onlyConnectedContacts: STABLE (primitive type) - shouldHideBottomActionForSearch: STABLE (primitive type) - shouldHideBottomActionForServices: STABLE (primitive type) - isAppsTabVisible: STABLE (primitive type) @@ -41,6 +43,8 @@ public fun com.wire.android.search.SearchUsersAndAppsScreen(searchTitle: kotlin. - initialPage: STABLE (class with no mutable properties) - conversationProtocol: UNSTABLE (has mutable properties or unstable members) - peopleBottomActions: STABLE (composable function type) + - onOpenUserProfile: STABLE (function type) + - onAppClicked: STABLE (function type) @Composable private fun com.wire.android.search.apps.AppsList(searchQuery: kotlin.String, apps: kotlin.collections.List, onServiceClicked: kotlin.Function1, lazyListState: androidx.compose.foundation.lazy.LazyListState): kotlin.Unit @@ -112,11 +116,12 @@ public fun com.wire.android.search.searchAppsViewModel(protocolInfo: com.wire.ka - protocolInfo: UNSTABLE (has mutable properties or unstable members) @Composable -public fun com.wire.android.search.searchUserViewModel(conversationId: com.wire.kalium.logic.data.id.QualifiedID?): com.wire.android.search.users.SearchUserViewModel +public fun com.wire.android.search.searchUserViewModel(conversationId: com.wire.kalium.logic.data.id.QualifiedID?, onlyConnectedContacts: kotlin.Boolean): com.wire.android.search.users.SearchUserViewModel skippable: false restartable: true params: - conversationId: UNSTABLE (has mutable properties or unstable members) + - onlyConnectedContacts: STABLE (primitive type) @Composable public fun com.wire.android.search.users.SearchAllPeopleScreen(searchQuery: kotlin.String, contactsSearchResult: kotlinx.collections.immutable.ImmutableList, publicSearchResult: kotlinx.collections.immutable.ImmutableList, contactsSelectedSearchResult: kotlinx.collections.immutable.ImmutableList, isLoading: kotlin.Boolean, isSearchActive: kotlin.Boolean, actionType: com.wire.android.model.ItemActionType, onChecked: kotlin.Function2, onOpenUserProfile: kotlin.Function1, firstContactFocusRequester: androidx.compose.ui.focus.FocusRequester?, nextFocusRequester: androidx.compose.ui.focus.FocusRequester?, selectedContactResultsExpanded: kotlin.Boolean, onSelectedContactResultsExpansionChanged: kotlin.Function1, contactResultsExpanded: kotlin.Boolean, onContactResultsExpansionChanged: kotlin.Function1, publicResultsExpanded: kotlin.Boolean, onPublicResultsExpansionChanged: kotlin.Function1, lazyListState: androidx.compose.foundation.lazy.LazyListState): kotlin.Unit diff --git a/app/src/main/kotlin/com/wire/android/model/SnackBarMessage.kt b/core/ui-common/src/main/kotlin/com/wire/android/model/SnackBarMessage.kt similarity index 100% rename from app/src/main/kotlin/com/wire/android/model/SnackBarMessage.kt rename to core/ui-common/src/main/kotlin/com/wire/android/model/SnackBarMessage.kt diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/button/WireItemLabel.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/button/WireItemLabel.kt index 9cfdfd4905d..83ede11585d 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/button/WireItemLabel.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/button/WireItemLabel.kt @@ -36,11 +36,12 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.typography import com.wire.android.ui.theme.wireColorScheme -import com.wire.android.ui.theme.wireTypography @Composable fun WireItemLabel( @@ -49,6 +50,7 @@ fun WireItemLabel( contentPadding: PaddingValues = PaddingValues(horizontal = dimensions().spacing6x, vertical = dimensions().spacing2x), shape: Shape = RoundedCornerShape(dimensions().spacing6x), + textStyle: TextStyle = typography().label02, contentDescription: String = text ) = Box( modifier = modifier @@ -61,7 +63,7 @@ fun WireItemLabel( Text( modifier = Modifier.clearAndSetSemantics { }, text = text, - style = MaterialTheme.wireTypography.label02, + style = textStyle, ) } diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt index 417c694be26..648da699a16 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.wire.android.ui.common.R import com.wire.android.ui.theme.WireTheme @@ -90,6 +91,7 @@ fun WireTextField( colors: WireTextFieldColors = wireTextFieldColors(), onSelectedLineIndexChanged: (Int) -> Unit = { }, onLineBottomYCoordinateChanged: (Float) -> Unit = { }, + onInputSizeChanged: (IntSize) -> Unit = { }, onTap: (() -> Unit)? = null, testTag: String = String.EMPTY, validateKeyboardOptions: Boolean = true, @@ -132,6 +134,7 @@ fun WireTextField( textState::setTextAndPlaceCursorAtEnd ) ), + onInputSizeChanged = onInputSizeChanged, onTap = onTap, testTag = testTag, innerBasicTextField = { decorator, textFieldModifier -> diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt index 3071754dedb..dd2b576f1a6 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt @@ -38,7 +38,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.clearAndSetSemantics @@ -48,6 +50,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import com.wire.android.ui.common.Tint import com.wire.android.ui.common.dimensions import com.wire.android.ui.theme.wireDimensions @@ -81,6 +84,7 @@ internal fun WireTextFieldLayout( inputMinHeight: Dp = MaterialTheme.wireDimensions.textFieldMinHeight, shape: Shape = RoundedCornerShape(MaterialTheme.wireDimensions.textFieldCornerSize), colors: WireTextFieldColors = wireTextFieldColors(), + onInputSizeChanged: (IntSize) -> Unit = { }, onTap: (() -> Unit)? = null, testTag: String = String.EMPTY ) { @@ -105,9 +109,14 @@ internal fun WireTextFieldLayout( style = state, placeholderTextStyle = placeholderTextStyle, placeholderAlignment = placeholderAlignment, - inputMinHeight = inputMinHeight, colors = colors, - onTap = onTap, + onInputSizeChanged = onInputSizeChanged, + modifier = Modifier + .heightIn(min = inputMinHeight) + .clip(shape) + .let { + if (onTap != null) it.clickable(onClick = onTap) else it + } ) }, textFieldModifier = Modifier @@ -148,25 +157,19 @@ internal fun WireTextFieldLayout( private fun InnerTextLayout( innerTextField: @Composable () -> Unit, shouldShowPlaceholder: Boolean, + modifier: Modifier = Modifier, leadingIcon: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit)? = null, placeholderText: String? = null, style: WireTextFieldState = WireTextFieldState.Default, placeholderTextStyle: TextStyle = MaterialTheme.wireTypography.body01, placeholderAlignment: Alignment.Horizontal = Alignment.Start, - inputMinHeight: Dp = dimensions().spacing48x, colors: WireTextFieldColors = wireTextFieldColors(), - onTap: (() -> Unit)? = null + onInputSizeChanged: (IntSize) -> Unit = { }, ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .heightIn(min = inputMinHeight) - .then( - onTap?.let { - Modifier.clickable { onTap() } - } ?: Modifier - ) + modifier = modifier, ) { val trailingOrStateIcon: @Composable (() -> Unit)? = when { trailingIcon != null -> trailingIcon @@ -207,7 +210,9 @@ private fun InnerTextLayout( ) } Box( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .onSizeChanged(onInputSizeChanged), propagateMinConstraints = true ) { innerTextField() diff --git a/core/ui-common/src/main/kotlin/com/wire/android/util/DateAndTimeParsers.kt b/core/ui-common/src/main/kotlin/com/wire/android/util/DateAndTimeParsers.kt index 13da58b375b..57e8903b143 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/util/DateAndTimeParsers.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/util/DateAndTimeParsers.kt @@ -89,11 +89,6 @@ class DateAndTimeParsers private constructor() { this.timeZone = java.util.TimeZone.getDefault() } - private val longDateFormat = - java.text.DateFormat.getDateInstance(java.text.DateFormat.LONG, Locale.getDefault()).apply { - this.timeZone = java.util.TimeZone.getDefault() - } - private val shortTimeFormat = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT, Locale.getDefault()).apply { this.timeZone = java.util.TimeZone.getDefault() @@ -118,13 +113,9 @@ class DateAndTimeParsers private constructor() { private val videoMessageTimeFormat = DateTimeFormatter.ofPattern("mm:ss", Locale.getDefault()) .withZone(ZoneId.systemDefault()) - private val linkExpirationDateFormat = DateTimeFormatter.ofPattern("EEEE, MMMM dd", Locale.getDefault()) + private val dayOfWeekMonthDayDateFormat = DateTimeFormatter.ofPattern("EEEE, MMMM dd", Locale.getDefault()) .withZone(ZoneId.systemDefault()) - private val linkExpirationTimeFormat = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT, Locale.getDefault()).apply { - this.timeZone = java.util.TimeZone.getDefault() - } - @Deprecated("Date String parsing is discouraged and will be removed soon for direct Instant/DateTime versions") fun serverDate(stringDate: String): Date? { return try { @@ -203,10 +194,10 @@ class DateAndTimeParsers private constructor() { fun videoMessageTime(timeMs: Long): String = videoMessageTimeFormat.format(java.time.Instant.ofEpochMilli(timeMs)) - fun meetingDate(instant: Instant): String = longDateFormat.format(Date.from(instant.toJavaInstant())) + fun meetingDate(instant: Instant): String = dayOfWeekMonthDayDateFormat.format(instant.toJavaInstant()) fun meetingTime(instant: Instant): String = shortTimeFormat.format(Date.from(instant.toJavaInstant())) - fun linkExpirationDate(timeMs: Long): String = linkExpirationDateFormat.format(java.time.Instant.ofEpochMilli(timeMs)) - fun linkExpirationTime(timeMs: Long): String = linkExpirationTimeFormat.format(Date.from(java.time.Instant.ofEpochMilli(timeMs))) + fun linkExpirationDate(timeMs: Long): String = dayOfWeekMonthDayDateFormat.format(java.time.Instant.ofEpochMilli(timeMs)) + fun linkExpirationTime(timeMs: Long): String = shortTimeFormat.format(Date.from(java.time.Instant.ofEpochMilli(timeMs))) } } diff --git a/core/ui-common/src/main/res/values/strings.xml b/core/ui-common/src/main/res/values/strings.xml index 3eda786f858..c6b30d5517c 100644 --- a/core/ui-common/src/main/res/values/strings.xml +++ b/core/ui-common/src/main/res/values/strings.xml @@ -123,4 +123,5 @@ unselect Show More Show Less + Select diff --git a/core/ui-common/stability/ui-common-debug.stability b/core/ui-common/stability/ui-common-debug.stability index c44143fde69..41fbef20fc6 100644 --- a/core/ui-common/stability/ui-common-debug.stability +++ b/core/ui-common/stability/ui-common-debug.stability @@ -869,7 +869,7 @@ public fun com.wire.android.ui.common.button.WireButtonColors.rippleColor(state: - state: STABLE (class with no mutable properties) @Composable -public fun com.wire.android.ui.common.button.WireItemLabel(text: kotlin.String, modifier: androidx.compose.ui.Modifier, contentPadding: androidx.compose.foundation.layout.PaddingValues, shape: androidx.compose.ui.graphics.Shape, contentDescription: kotlin.String): kotlin.Unit +public fun com.wire.android.ui.common.button.WireItemLabel(text: kotlin.String, modifier: androidx.compose.ui.Modifier, contentPadding: androidx.compose.foundation.layout.PaddingValues, shape: androidx.compose.ui.graphics.Shape, textStyle: androidx.compose.ui.text.TextStyle, contentDescription: kotlin.String): kotlin.Unit skippable: true restartable: true params: @@ -877,6 +877,7 @@ public fun com.wire.android.ui.common.button.WireItemLabel(text: kotlin.String, - modifier: STABLE (marked @Stable or @Immutable) - contentPadding: STABLE (marked @Stable or @Immutable) - shape: STABLE (marked @Stable or @Immutable) + - textStyle: STABLE (marked @Stable or @Immutable) - contentDescription: STABLE (String is immutable) @Composable @@ -1783,21 +1784,21 @@ public fun com.wire.android.ui.common.textfield.InnerBasicTextFieldBuilder.Build - textFieldModifier: STABLE (marked @Stable or @Immutable) @Composable -private fun com.wire.android.ui.common.textfield.InnerTextLayout(innerTextField: @[Composable] androidx.compose.runtime.internal.ComposableFunction0, shouldShowPlaceholder: kotlin.Boolean, leadingIcon: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, trailingIcon: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, placeholderText: kotlin.String?, style: com.wire.android.ui.common.textfield.WireTextFieldState, placeholderTextStyle: androidx.compose.ui.text.TextStyle, placeholderAlignment: androidx.compose.ui.Alignment.Horizontal, inputMinHeight: androidx.compose.ui.unit.Dp, colors: com.wire.android.ui.common.textfield.WireTextFieldColors, onTap: kotlin.Function0?): kotlin.Unit +private fun com.wire.android.ui.common.textfield.InnerTextLayout(innerTextField: @[Composable] androidx.compose.runtime.internal.ComposableFunction0, shouldShowPlaceholder: kotlin.Boolean, modifier: androidx.compose.ui.Modifier, leadingIcon: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, trailingIcon: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, placeholderText: kotlin.String?, style: com.wire.android.ui.common.textfield.WireTextFieldState, placeholderTextStyle: androidx.compose.ui.text.TextStyle, placeholderAlignment: androidx.compose.ui.Alignment.Horizontal, colors: com.wire.android.ui.common.textfield.WireTextFieldColors, onInputSizeChanged: kotlin.Function1): kotlin.Unit skippable: true restartable: true params: - innerTextField: STABLE (composable function type) - shouldShowPlaceholder: STABLE (primitive type) + - modifier: STABLE (marked @Stable or @Immutable) - leadingIcon: STABLE (composable function type) - trailingIcon: STABLE (composable function type) - placeholderText: STABLE (class with no mutable properties) - style: STABLE (class with no mutable properties) - placeholderTextStyle: STABLE (marked @Stable or @Immutable) - placeholderAlignment: STABLE (marked @Stable or @Immutable) - - inputMinHeight: STABLE (marked @Stable or @Immutable) - colors: STABLE (marked @Stable or @Immutable) - - onTap: STABLE (function type) + - onInputSizeChanged: STABLE (function type) @Composable private fun com.wire.android.ui.common.textfield.VisibilityIconButton(isVisible: kotlin.Boolean, onVisibleChange: kotlin.Function1): kotlin.Unit @@ -1847,7 +1848,7 @@ public fun com.wire.android.ui.common.textfield.WirePasswordTextField(textState: - testTag: STABLE (String is immutable) @Composable -public fun com.wire.android.ui.common.textfield.WireTextField(textState: androidx.compose.foundation.text.input.TextFieldState, modifier: androidx.compose.ui.Modifier, inputModifier: androidx.compose.ui.Modifier, placeholderText: kotlin.String?, labelText: kotlin.String?, labelMandatoryIcon: kotlin.Boolean, descriptionText: kotlin.String?, semanticDescription: kotlin.String?, leadingIcon: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, trailingIcon: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, state: com.wire.android.ui.common.textfield.WireTextFieldState, autoFillType: com.wire.android.ui.common.textfield.WireAutoFillType, lineLimits: androidx.compose.foundation.text.input.TextFieldLineLimits, inputTransformation: androidx.compose.foundation.text.input.InputTransformation, outputTransformation: androidx.compose.foundation.text.input.OutputTransformation?, keyboardOptions: androidx.compose.foundation.text.KeyboardOptions, onKeyboardAction: androidx.compose.foundation.text.input.KeyboardActionHandler?, scrollState: androidx.compose.foundation.ScrollState, interactionSource: androidx.compose.foundation.interaction.MutableInteractionSource, textStyle: androidx.compose.ui.text.TextStyle, placeholderTextStyle: androidx.compose.ui.text.TextStyle, placeholderAlignment: androidx.compose.ui.Alignment.Horizontal, inputMinHeight: androidx.compose.ui.unit.Dp, shape: androidx.compose.ui.graphics.Shape, colors: com.wire.android.ui.common.textfield.WireTextFieldColors, onSelectedLineIndexChanged: kotlin.Function1, onLineBottomYCoordinateChanged: kotlin.Function1, onTap: kotlin.Function0?, testTag: kotlin.String, validateKeyboardOptions: kotlin.Boolean, enabled: kotlin.Boolean): kotlin.Unit +public fun com.wire.android.ui.common.textfield.WireTextField(textState: androidx.compose.foundation.text.input.TextFieldState, modifier: androidx.compose.ui.Modifier, inputModifier: androidx.compose.ui.Modifier, placeholderText: kotlin.String?, labelText: kotlin.String?, labelMandatoryIcon: kotlin.Boolean, descriptionText: kotlin.String?, semanticDescription: kotlin.String?, leadingIcon: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, trailingIcon: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, state: com.wire.android.ui.common.textfield.WireTextFieldState, autoFillType: com.wire.android.ui.common.textfield.WireAutoFillType, lineLimits: androidx.compose.foundation.text.input.TextFieldLineLimits, inputTransformation: androidx.compose.foundation.text.input.InputTransformation, outputTransformation: androidx.compose.foundation.text.input.OutputTransformation?, keyboardOptions: androidx.compose.foundation.text.KeyboardOptions, onKeyboardAction: androidx.compose.foundation.text.input.KeyboardActionHandler?, scrollState: androidx.compose.foundation.ScrollState, interactionSource: androidx.compose.foundation.interaction.MutableInteractionSource, textStyle: androidx.compose.ui.text.TextStyle, placeholderTextStyle: androidx.compose.ui.text.TextStyle, placeholderAlignment: androidx.compose.ui.Alignment.Horizontal, inputMinHeight: androidx.compose.ui.unit.Dp, shape: androidx.compose.ui.graphics.Shape, colors: com.wire.android.ui.common.textfield.WireTextFieldColors, onSelectedLineIndexChanged: kotlin.Function1, onLineBottomYCoordinateChanged: kotlin.Function1, onInputSizeChanged: kotlin.Function1, onTap: kotlin.Function0?, testTag: kotlin.String, validateKeyboardOptions: kotlin.Boolean, enabled: kotlin.Boolean): kotlin.Unit skippable: true restartable: true params: @@ -1878,6 +1879,7 @@ public fun com.wire.android.ui.common.textfield.WireTextField(textState: android - colors: STABLE (marked @Stable or @Immutable) - onSelectedLineIndexChanged: STABLE (function type) - onLineBottomYCoordinateChanged: STABLE (function type) + - onInputSizeChanged: STABLE (function type) - onTap: STABLE (function type) - testTag: STABLE (String is immutable) - validateKeyboardOptions: STABLE (primitive type) @@ -1949,7 +1951,7 @@ public fun com.wire.android.ui.common.textfield.WireTextFieldColors.textColor(st - state: STABLE (class with no mutable properties) @Composable -internal fun com.wire.android.ui.common.textfield.WireTextFieldLayout(shouldShowPlaceholder: kotlin.Boolean, innerBasicTextField: com.wire.android.ui.common.textfield.InnerBasicTextFieldBuilder, modifier: androidx.compose.ui.Modifier, placeholderText: kotlin.String?, labelText: kotlin.String?, labelMandatoryIcon: kotlin.Boolean, descriptionText: kotlin.String?, semanticDescription: kotlin.String?, leadingIcon: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, trailingIcon: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, state: com.wire.android.ui.common.textfield.WireTextFieldState, interactionSource: androidx.compose.foundation.interaction.MutableInteractionSource, placeholderTextStyle: androidx.compose.ui.text.TextStyle, placeholderAlignment: androidx.compose.ui.Alignment.Horizontal, inputMinHeight: androidx.compose.ui.unit.Dp, shape: androidx.compose.ui.graphics.Shape, colors: com.wire.android.ui.common.textfield.WireTextFieldColors, onTap: kotlin.Function0?, testTag: kotlin.String): kotlin.Unit +internal fun com.wire.android.ui.common.textfield.WireTextFieldLayout(shouldShowPlaceholder: kotlin.Boolean, innerBasicTextField: com.wire.android.ui.common.textfield.InnerBasicTextFieldBuilder, modifier: androidx.compose.ui.Modifier, placeholderText: kotlin.String?, labelText: kotlin.String?, labelMandatoryIcon: kotlin.Boolean, descriptionText: kotlin.String?, semanticDescription: kotlin.String?, leadingIcon: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, trailingIcon: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, state: com.wire.android.ui.common.textfield.WireTextFieldState, interactionSource: androidx.compose.foundation.interaction.MutableInteractionSource, placeholderTextStyle: androidx.compose.ui.text.TextStyle, placeholderAlignment: androidx.compose.ui.Alignment.Horizontal, inputMinHeight: androidx.compose.ui.unit.Dp, shape: androidx.compose.ui.graphics.Shape, colors: com.wire.android.ui.common.textfield.WireTextFieldColors, onInputSizeChanged: kotlin.Function1, onTap: kotlin.Function0?, testTag: kotlin.String): kotlin.Unit skippable: false restartable: true params: @@ -1970,6 +1972,7 @@ internal fun com.wire.android.ui.common.textfield.WireTextFieldLayout(shouldShow - inputMinHeight: STABLE (marked @Stable or @Immutable) - shape: STABLE (marked @Stable or @Immutable) - colors: STABLE (marked @Stable or @Immutable) + - onInputSizeChanged: STABLE (function type) - onTap: STABLE (function type) - testTag: STABLE (String is immutable) diff --git a/features/meetings/build.gradle.kts b/features/meetings/build.gradle.kts index 958c9eafaa2..f57bb6555dc 100644 --- a/features/meetings/build.gradle.kts +++ b/features/meetings/build.gradle.kts @@ -13,8 +13,9 @@ plugins { dependencies { implementation("com.wire.kalium:kalium-common") implementation("com.wire.kalium:kalium-logic") - implementation(project(":core:di")) - implementation(project(":core:ui-common")) + implementation(projects.core.di) + implementation(projects.core.uiCommon) + implementation(projects.core.search) implementation(libs.androidx.core) implementation(libs.androidx.appcompat) implementation(libs.ktx.immutableCollections) diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/navigation/MeetingNavigator.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/navigation/MeetingNavigator.kt new file mode 100644 index 00000000000..f30b4a4ede6 --- /dev/null +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/navigation/MeetingNavigator.kt @@ -0,0 +1,30 @@ +/* + * Wire + * Copyright (C) 2026 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.meetings.navigation + +import com.wire.android.navigation.WireNavigator +import com.wire.kalium.logic.data.user.UserId + +/** + * Navigator for the meetings feature. It extends [WireNavigator] and adds meeting-specific navigation functions that allows navigating to + * different screens from other modules without needing to know about the implementation details of the whole navigation system. + */ +class MeetingNavigator( + val navigator: WireNavigator, + val navigateToProfile: (userId: UserId) -> Unit +) : WireNavigator by navigator diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsMetroViewModelBindings.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsMetroViewModelBindings.kt index 22d5dbeb02b..36ae5ac23b6 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsMetroViewModelBindings.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsMetroViewModelBindings.kt @@ -18,7 +18,8 @@ package com.wire.android.feature.meetings.ui import androidx.lifecycle.ViewModel -import com.wire.android.feature.meetings.ui.create.NewMeetingType +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.CreationExtras import com.wire.android.feature.meetings.ui.create.NewMeetingViewModelImpl import com.wire.android.feature.meetings.ui.list.MeetingListViewModelImpl import com.wire.android.feature.meetings.ui.options.MeetingOptionsMenuViewModelImpl @@ -27,6 +28,8 @@ import dev.zacsweers.metro.IntoMap import dev.zacsweers.metro.Provides import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey +import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactory +import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactoryKey import dev.zacsweers.metrox.viewmodel.ViewModelKey @BindingContainer @@ -39,9 +42,6 @@ object MeetingsMetroViewModelBindings { object : MeetingsManualViewModelFactory { override fun meetingListViewModel(type: MeetingsTabItem): MeetingListViewModelImpl = factory.meetingListViewModel(type) - - override fun newMeetingViewModel(type: NewMeetingType): NewMeetingViewModelImpl = - factory.newMeetingViewModel(type) } @Provides @@ -49,4 +49,13 @@ object MeetingsMetroViewModelBindings { @ViewModelKey(MeetingOptionsMenuViewModelImpl::class) fun meetingOptionsMenuViewModel(factory: MeetingsViewModelFactory): ViewModel = factory.meetingOptionsMenuViewModel() + + @Provides + @IntoMap + @ViewModelAssistedFactoryKey(NewMeetingViewModelImpl::class) + fun newMeetingViewModel(factory: MeetingsViewModelFactory): ViewModelAssistedFactory = + object : ViewModelAssistedFactory { + override fun create(extras: CreationExtras): ViewModel = + factory.newMeetingViewModel(extras.createSavedStateHandle()) + } } diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsViewModelFactory.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsViewModelFactory.kt index 62065767f3b..6fb88d571c3 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsViewModelFactory.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsViewModelFactory.kt @@ -17,7 +17,7 @@ */ package com.wire.android.feature.meetings.ui -import com.wire.android.feature.meetings.ui.create.NewMeetingType +import androidx.lifecycle.SavedStateHandle import com.wire.android.feature.meetings.ui.create.NewMeetingViewModelImpl import com.wire.android.feature.meetings.ui.list.MeetingListViewModelImpl import com.wire.android.feature.meetings.ui.options.MeetingOptionsMenuViewModelImpl @@ -42,5 +42,5 @@ class MeetingsViewModelFactory @Inject constructor( internal fun meetingOptionsMenuViewModel() = MeetingOptionsMenuViewModelImpl(getMeeting = getMeeting) - internal fun newMeetingViewModel(type: NewMeetingType) = NewMeetingViewModelImpl(type) + internal fun newMeetingViewModel(savedStateHandle: SavedStateHandle) = NewMeetingViewModelImpl(savedStateHandle) } diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsViewModelGraph.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsViewModelGraph.kt index 34b05c72cb7..dbfd39147ca 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsViewModelGraph.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsViewModelGraph.kt @@ -20,11 +20,15 @@ package com.wire.android.feature.meetings.ui import androidx.compose.runtime.Composable -import com.wire.android.di.metro.sessionKeyedAssistedMetroViewModel -import com.wire.android.di.metro.sessionKeyedMetroViewModel +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import com.wire.android.di.PreviewProvider +import com.wire.android.di.metro.sessionKeyedAssistedMetroViewModelAs +import com.wire.android.di.metro.sessionKeyedMetroViewModelAs import com.wire.android.feature.meetings.ui.create.NewMeetingType import com.wire.android.feature.meetings.ui.create.NewMeetingViewModel import com.wire.android.feature.meetings.ui.create.NewMeetingViewModelImpl +import com.wire.android.feature.meetings.ui.create.NewMeetingViewModelPreview import com.wire.android.feature.meetings.ui.list.MeetingListViewModel import com.wire.android.feature.meetings.ui.list.MeetingListViewModelImpl import com.wire.android.feature.meetings.ui.options.MeetingOptionsMenuViewModel @@ -33,14 +37,13 @@ import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory interface MeetingsManualViewModelFactory : ManualViewModelAssistedFactory { fun meetingListViewModel(type: MeetingsTabItem): MeetingListViewModelImpl - fun newMeetingViewModel(type: NewMeetingType): NewMeetingViewModelImpl } @Composable fun meetingListViewModel( type: MeetingsTabItem, ): MeetingListViewModel = - sessionKeyedAssistedMetroViewModel( + sessionKeyedAssistedMetroViewModelAs( key = "meeting_list_${type.name}", ) { meetingListViewModel(type) @@ -48,12 +51,15 @@ fun meetingListViewModel( @Composable fun meetingOptionsMenuListViewModel(): MeetingOptionsMenuViewModel = - sessionKeyedMetroViewModel() + sessionKeyedMetroViewModelAs() @Composable -fun newMeetingViewModel(type: NewMeetingType): NewMeetingViewModel = - sessionKeyedAssistedMetroViewModel( - key = "new_meeting_${type.name}", - ) { - newMeetingViewModel(type) - } +fun newMeetingViewModel( + viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" + }, +): NewMeetingViewModel = + sessionKeyedMetroViewModelAs( + viewModelStoreOwner = viewModelStoreOwner, + previewProvider = PreviewProvider.of(NewMeetingViewModelPreview(NewMeetingType.Schedule)), + ) diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingParticipantsScreen.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingParticipantsScreen.kt new file mode 100644 index 00000000000..6dd88f4ab26 --- /dev/null +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingParticipantsScreen.kt @@ -0,0 +1,110 @@ +/* + * Wire + * Copyright (C) 2026 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.meetings.ui.create + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import com.wire.android.feature.meetings.R +import com.wire.android.feature.meetings.navigation.MeetingNavigator +import com.wire.android.model.ItemActionType +import com.wire.android.navigation.annotation.features.meetings.WireNewMeetingDestination +import com.wire.android.navigation.style.PopUpNavigationAnimation +import com.wire.android.search.SearchUsersAndAppsScreen +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.topappbar.NavigationIconType +import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.ui.theme.wireDimensions +import com.wire.kalium.logic.data.user.UserId +import com.wire.android.ui.common.R as commonR + +@WireNewMeetingDestination( + style = PopUpNavigationAnimation::class, +) +@Composable +fun NewMeetingParticipantsScreen( + navigator: MeetingNavigator, + newMeetingViewModel: NewMeetingViewModel, +) { + SearchUsersAndAppsScreen( + onlyConnectedContacts = true, + searchTitle = stringResource(R.string.new_meeting_participants_title), + selectedContacts = newMeetingViewModel.state.selectedContacts, + onContactChecked = newMeetingViewModel::updateSelectedContact, + onClose = { + newMeetingViewModel.resetSelectedContacts() + navigator.navigateBack() + }, + navigationIconType = NavigationIconType.Back(R.string.content_description_new_meeting_participants_back_icon), + itemActionType = ItemActionType.CHECK, + isAppsTabVisible = false, + onOpenUserProfile = { contact -> + navigator.navigateToProfile(UserId(contact.id, contact.domain)) + }, + peopleBottomActions = { focusRequester -> + SelectButton( + onClick = { + newMeetingViewModel.confirmSelectedContacts() + navigator.navigateBack() + }, + buttonModifier = Modifier.focusRequester(focusRequester), + ) + } + ) +} + +@Composable +private fun SelectButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + buttonModifier: Modifier = Modifier, + leadingIcon: @Composable (() -> Unit)? = null, + elevation: Dp = MaterialTheme.wireDimensions.bottomNavigationShadowElevation +) { + Surface( + color = MaterialTheme.wireColorScheme.background, + shadowElevation = elevation + ) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .padding(horizontal = dimensions().spacing16x) + .height(dimensions().groupButtonHeight) + ) { + WirePrimaryButton( + text = stringResource(commonR.string.label_select), + leadingIcon = leadingIcon, + onClick = onClick, + state = WireButtonState.Default, + modifier = buttonModifier, + ) + } + } +} diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingScreen.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingScreen.kt index 69a6ac095f5..51bafd687b4 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingScreen.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingScreen.kt @@ -17,38 +17,72 @@ */ package com.wire.android.feature.meetings.ui.create +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.rememberTextMeasurer +import com.ramcosta.composedestinations.generated.meetings.destinations.NewMeetingParticipantsScreenDestination import com.wire.android.feature.meetings.R -import com.wire.android.feature.meetings.ui.newMeetingViewModel +import com.wire.android.feature.meetings.ui.create.NewMeetingViewModel.Companion.MEETING_NAME_MAX_COUNT import com.wire.android.feature.meetings.ui.util.PreviewMultipleThemes +import com.wire.android.model.Contact +import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.WireNavigator import com.wire.android.navigation.annotation.features.meetings.WireNewMeetingDestination 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 import com.wire.android.ui.common.button.WirePrimaryButton +import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.common.spacers.VerticalSpace import com.wire.android.ui.common.textfield.DefaultEmailDone +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.common.typography +import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions +import com.wire.kalium.logic.data.user.ConnectionState +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.toPersistentSet +import com.wire.android.ui.common.R as commonR @WireNewMeetingDestination( start = true, @@ -59,24 +93,37 @@ import com.wire.android.ui.theme.wireDimensions fun NewMeetingScreen( navigator: WireNavigator, navArgs: NewMeetingNavArgs, + newMeetingViewModel: NewMeetingViewModel, ) { - val meetingListViewModel: NewMeetingViewModel = when { - LocalInspectionMode.current -> NewMeetingViewModelPreview(navArgs.type) - else -> newMeetingViewModel(navArgs.type) - } NewMeetingContent( type = navArgs.type, onBackPressed = navigator::navigateBack, - titleState = meetingListViewModel.titleTextState, + state = newMeetingViewModel.state, + titleState = newMeetingViewModel.titleTextState, + onParticipantsClicked = { + navigator.navigate(NavigationCommand(NewMeetingParticipantsScreenDestination)) + }, + onCreateClicked = { + newMeetingViewModel.createMeeting() + } ) + + HandleActions(newMeetingViewModel.actions) { action -> + when (action) { + is NewMeetingViewActions.Success -> navigator.navigateBack() + } + } } @Composable fun NewMeetingContent( + state: NewMeetingState, titleState: TextFieldState, type: NewMeetingType, - onBackPressed: () -> Unit, modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, + onParticipantsClicked: () -> Unit = {}, + onCreateClicked: () -> Unit = {}, ) { WireScaffold( modifier = modifier, @@ -86,7 +133,7 @@ fun NewMeetingContent( title = stringResource(type.title), onNavigationPressed = onBackPressed, navigationIconType = NavigationIconType.Back( - contentDescription = R.string.contnt_description_new_meeting_back_icon + contentDescription = R.string.content_description_new_meeting_back_icon ), ) }, @@ -101,7 +148,15 @@ fun NewMeetingContent( end = dimensions().spacing16x, ) ) { - TitleInput(titleState = titleState) + TitleInput( + titleState = titleState, + titleError = state.titleError, + ) + VerticalSpace.x24() + ParticipantsInput( + participants = state.confirmedContacts, + onClick = onParticipantsClicked, + ) } }, bottomBar = { @@ -110,21 +165,21 @@ fun NewMeetingContent( color = MaterialTheme.wireColorScheme.background, modifier = Modifier.fillMaxWidth(), ) { - WirePrimaryButton( - text = stringResource(type.action), - leadingIcon = { - Icon( - painter = painterResource(type.icon), - contentDescription = null, // no separate content description as the text already describes the action - modifier = Modifier.padding(dimensions().spacing4x), - ) - }, - state = WireButtonState.Disabled, // TODO - onClick = { /*TODO*/ }, - modifier = Modifier - .fillMaxWidth() - .padding(dimensions().spacing16x), - ) + WirePrimaryButton( + text = stringResource(type.action), + leadingIcon = { + Icon( + painter = painterResource(type.icon), + contentDescription = null, // no separate content description as the text already describes the action + modifier = Modifier.padding(dimensions().spacing4x), + ) + }, + state = if (state.continueButtonEnabled) WireButtonState.Default else WireButtonState.Disabled, + onClick = onCreateClicked, + modifier = Modifier + .fillMaxWidth() + .padding(dimensions().spacing16x), + ) } } ) @@ -133,14 +188,104 @@ fun NewMeetingContent( @Composable private fun TitleInput( titleState: TextFieldState, + titleError: NewMeetingState.TitleError?, ) { + val keyboardController = LocalSoftwareKeyboardController.current + + ShakeAnimation { animate -> + WireTextField( + textState = titleState, + state = when (titleError) { + is NewMeetingState.TitleError.TitleEmptyError -> + WireTextFieldState.Error(stringResource(R.string.new_meeting_title_name_error_empty)) + is NewMeetingState.TitleError.TitleExceedsLimitError -> + WireTextFieldState.Error(stringResource(R.string.new_meeting_title_name_error_exceeded_limit)) + else -> WireTextFieldState.Default + }, + placeholderText = stringResource(R.string.new_meeting_title_input_placeholder), + labelText = stringResource(R.string.new_meeting_title_input_label).uppercase(), + semanticDescription = stringResource(R.string.new_meeting_title_input_placeholder), + keyboardOptions = KeyboardOptions.DefaultText, + onKeyboardAction = { keyboardController?.hide() }, + testTag = "titleInput", + inputTransformation = InputTransformation.maxLengthWithCallback(MEETING_NAME_MAX_COUNT, animate), + trailingIcon = { + Box( + modifier = Modifier + .width(dimensions().spacing64x) + .height(dimensions().spacing40x), + contentAlignment = Alignment.CenterEnd + ) { + AnimatedVisibility( + visible = titleState.text.isNotBlank(), + enter = fadeIn(), + exit = fadeOut() + ) { + IconButton( + modifier = Modifier.padding(start = dimensions().spacing12x), + onClick = titleState::clearText, + ) { + Icon( + painter = painterResource(id = commonR.drawable.ic_clear_search), + contentDescription = stringResource(commonR.string.content_description_clear_content) + ) + } + } + } + }, + ) + } +} + +@Composable +private fun ParticipantsInput( + participants: ImmutableSet, + onClick: () -> Unit, +) { + val resources = LocalResources.current + val textStyle = typography().body01 + val textColor = colorsScheme().onSurface + val suffixColor = colorsScheme().secondaryText + val textMeasurer = rememberTextMeasurer() + val textFieldState = rememberTextFieldState(participants.joinToString(", ") { it.name }) + var innerTextWidthPx by remember { mutableIntStateOf(0) } + val truncationTransformation = remember(innerTextWidthPx) { + TextListTruncationTransformation( + availableWidthPx = innerTextWidthPx, + textMeasurer = textMeasurer, + textStyle = textStyle, + textColor = textColor, + suffixColor = suffixColor, + provideSuffixText = { count -> + resources.getQuantityString(R.plurals.new_meeting_participants_input_more_suffix, count, count) + }, + ) + } + + LaunchedEffect(participants) { + textFieldState.setTextAndPlaceCursorAtEnd(participants.joinToString(", ") { it.name }) + } + WireTextField( - textState = titleState, - placeholderText = stringResource(R.string.new_meeting_title_input_placeholder), - labelText = stringResource(R.string.new_meeting_title_input_label).uppercase(), - semanticDescription = stringResource(R.string.new_meeting_title_input_placeholder), + textState = textFieldState, + placeholderText = stringResource(R.string.new_meeting_participants_input_placeholder), + labelText = stringResource(R.string.new_meeting_participants_input_label).uppercase(), + semanticDescription = stringResource(R.string.new_meeting_participants_input_placeholder), keyboardOptions = KeyboardOptions.DefaultEmailDone, - testTag = "titleInput", + state = WireTextFieldState.ReadOnly, + onInputSizeChanged = { innerTextWidthPx = it.width }, + outputTransformation = truncationTransformation, + onTap = onClick, + trailingIcon = { + Icon( + painter = painterResource(R.drawable.ic_expand), + contentDescription = null, + tint = colorsScheme().onSurfaceVariant, + modifier = Modifier + .padding(dimensions().spacing16x) + .size(dimensions().spacing16x) + ) + } ) } @@ -148,9 +293,9 @@ private fun TitleInput( @Composable fun PreviewNewMeetingScreen_MeetNow() = WireTheme { NewMeetingContent( - titleState = rememberTextFieldState(), + titleState = rememberTextFieldState("Meeting with 9 users"), type = NewMeetingType.MeetNow, - onBackPressed = {}, + state = NewMeetingState(confirmedContacts = buildContacts(names.size), continueButtonEnabled = true), ) } @@ -160,6 +305,22 @@ fun PreviewNewMeetingScreen_Schedule() = WireTheme { NewMeetingContent( titleState = rememberTextFieldState(), type = NewMeetingType.Schedule, - onBackPressed = {}, + state = NewMeetingState(), ) } + +private val names: List = listOf( + "Alice Smith", "Bob Johnson", "Charlie Brown", "David Wilson", "Eve Davis", "Frank Miller", "Grace Lee", "Hank Taylor", "Ivy Anderson" +) + +private fun buildContacts(count: Int) = List(count) { + Contact( + id = "id_$it", + domain = "domain", + name = names[it % names.size], + handle = names[it % names.size].lowercase().replace(" ", "."), + label = names[it % names.size].lowercase().replace(" ", "."), + membership = Membership.Standard, + connectionState = ConnectionState.ACCEPTED, + ) +}.toPersistentSet() diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingViewModel.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingViewModel.kt index 48144e10203..326d95c21ed 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingViewModel.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingViewModel.kt @@ -18,21 +18,118 @@ package com.wire.android.feature.meetings.ui.create import androidx.compose.foundation.text.input.TextFieldState -import androidx.lifecycle.ViewModel +import androidx.compose.runtime.Stable +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.ramcosta.composedestinations.generated.meetings.navArgs +import com.wire.android.feature.meetings.ui.create.NewMeetingViewModel.Companion.MEETING_NAME_MAX_COUNT +import com.wire.android.model.Contact +import com.wire.android.ui.common.ActionsManager +import com.wire.android.ui.common.ActionsViewModel +import com.wire.android.ui.common.textfield.textAsFlow +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch -interface NewMeetingViewModel { +interface NewMeetingViewModel : ActionsManager { val type: NewMeetingType val titleTextState: TextFieldState + val state: NewMeetingState + + fun updateSelectedContact(selected: Boolean, contact: Contact) {} + fun confirmSelectedContacts() {} + fun resetSelectedContacts() {} + fun createMeeting() {} + + companion object { + const val MEETING_NAME_MAX_COUNT = 64 + } } class NewMeetingViewModelPreview( override val type: NewMeetingType ) : NewMeetingViewModel { override val titleTextState: TextFieldState = TextFieldState() + override val state: NewMeetingState = NewMeetingState() } class NewMeetingViewModelImpl( - override val type: NewMeetingType, -) : ViewModel(), NewMeetingViewModel { + savedStateHandle: SavedStateHandle +) : ActionsViewModel(), NewMeetingViewModel { + val navArgs: NewMeetingNavArgs = savedStateHandle.navArgs() + override val type: NewMeetingType = navArgs.type override val titleTextState: TextFieldState = TextFieldState() + override var state: NewMeetingState by mutableStateOf(NewMeetingState()) + private set + + init { + viewModelScope.launch { + titleTextState.textAsFlow().collectLatest { + if (state.titleError != null) validateTitle() + validateContinueButton() + } + } + } + + override fun updateSelectedContact(selected: Boolean, contact: Contact) { + state = state.copy( + selectedContacts = when (selected) { + true -> state.selectedContacts.plus(contact).toPersistentSet() + false -> state.selectedContacts.minus(contact).toPersistentSet() + } + ) + } + + override fun confirmSelectedContacts() { + state = state.copy(confirmedContacts = state.selectedContacts) + } + + override fun resetSelectedContacts() { + state = state.copy(selectedContacts = state.confirmedContacts) + } + + private fun validateContinueButton() { + state = state.copy(continueButtonEnabled = titleTextState.text.isNotEmpty()) + } + + private fun validateTitle(): Boolean { + state = state.copy( + titleError = when { + titleTextState.text.isEmpty() -> NewMeetingState.TitleError.TitleEmptyError + titleTextState.text.length > MEETING_NAME_MAX_COUNT -> NewMeetingState.TitleError.TitleExceedsLimitError + else -> null + } + ) + return state.titleError == null + } + + override fun createMeeting() { + if (validateTitle()) { + // TODO implement meeting creation + sendAction(NewMeetingViewActions.Success) + } + } +} + +@Stable +data class NewMeetingState( + val selectedContacts: ImmutableSet = persistentSetOf(), + val confirmedContacts: ImmutableSet = persistentSetOf(), + val continueButtonEnabled: Boolean = false, + val titleError: TitleError? = null, +) { + @Stable + sealed interface TitleError { + data object TitleEmptyError : TitleError + data object TitleExceedsLimitError : TitleError + } +} + +sealed interface NewMeetingViewActions { + data object Success : NewMeetingViewActions } diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/TextListTruncationTransformation.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/TextListTruncationTransformation.kt new file mode 100644 index 00000000000..764ae282a44 --- /dev/null +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/TextListTruncationTransformation.kt @@ -0,0 +1,87 @@ +/* + * Wire + * Copyright (C) 2026 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.meetings.ui.create + +import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle + +/** + * Custom [OutputTransformation] to handle dynamic truncation of a comma-separated list of items (e.g., participant names) based on + * available width. It iteratively reduces the number of visible items and appends a dynamic suffix (e.g., "and X more") until the text + * fits within the specified width. The transformation ensures that the visible items and the ellipsis are styled in main color, + * while the dynamic suffix is styled in different color. + * Example: "Alice, Bob, Charlie... +2 more" + */ +class TextListTruncationTransformation( + private val availableWidthPx: Int, + private val textMeasurer: TextMeasurer, + private val textStyle: TextStyle, + private val textColor: Color, + private val suffixColor: Color, + private val provideSuffixText: (Int) -> String, + private val separator: String = ", ", + private val ellipsis: String = "... ", +) : OutputTransformation { + @Suppress("CyclomaticComplexMethod", "NestedBlockDepth", "ReturnCount") + override fun TextFieldBuffer.transformOutput() { + if (availableWidthPx <= 0) return + + val fullText = asCharSequence().toString() + if (fullText.isEmpty()) return + + val allItems = fullText.split(separator).map { it.trim() }.filter { it.isNotEmpty() } + if (allItems.isEmpty()) return + + fun visibleNames(visibleCount: Int): String = allItems.take(visibleCount).joinToString(separator) + fun suffixText(remainingCount: Int): String = if (remainingCount > 0) provideSuffixText(remainingCount) else "" + + for (i in allItems.size downTo 0) { + val ellipsis = if (i < allItems.size) ellipsis else "" + val text = visibleNames(i) + ellipsis + val suffix = suffixText(allItems.size - i) + if (textMeasurer.measure(text + suffix, textStyle).size.width <= availableWidthPx) { + if (i < allItems.size) { + val currentItem = allItems[i] + for (j in currentItem.length downTo 1) { + val truncatedItem = currentItem.take(j) + val separatorBeforeTruncatedItem = if (i > 0) separator else "" + val ellipsisAfterTruncatedItem = if (i + 1 < allItems.size || j < currentItem.length) ellipsis else "" + val textWithTruncated = visibleNames(i) + separatorBeforeTruncatedItem + truncatedItem + ellipsisAfterTruncatedItem + val suffixWithoutTruncated = suffixText(allItems.size - i - 1) + if (textMeasurer.measure(textWithTruncated + suffixWithoutTruncated, textStyle).size.width <= availableWidthPx) { + applyVisuals(this, textWithTruncated, suffixWithoutTruncated) + return + } + } + } + applyVisuals(this, text, suffix) + return + } + } + } + + private fun applyVisuals(buffer: TextFieldBuffer, mainTextWithEllipsis: String, suffix: String) { + buffer.replace(0, buffer.length, mainTextWithEllipsis + suffix) + buffer.addStyle(SpanStyle(color = textColor), 0, mainTextWithEllipsis.length) + buffer.addStyle(SpanStyle(color = suffixColor), mainTextWithEllipsis.length, mainTextWithEllipsis.length + suffix.length) + } +} diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/list/MeetingItem.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/list/MeetingItem.kt index 4ce2f18b4ff..98539b635f6 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/list/MeetingItem.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/list/MeetingItem.kt @@ -200,7 +200,7 @@ private fun MeetingOngoingDurationTimeSublineText(startedTime: Instant) { @Composable private fun RepeatingIntervalInfoLabel(repeatingInterval: RepeatingInterval?) { repeatingInterval?.let { - WireItemLabel(text = stringResource(repeatingInterval.nameResId)) + WireItemLabel(text = stringResource(repeatingInterval.nameResId), textStyle = typography().label01) } } diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/list/MeetingList.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/list/MeetingList.kt index 9a873e219f1..a5db8804662 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/list/MeetingList.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/list/MeetingList.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource @@ -52,7 +53,7 @@ fun MeetingList( openMeetingOptions: (meetingId: String) -> Unit = {}, ) { val meetingListViewModel: MeetingListViewModel = when { - LocalInspectionMode.current -> MeetingListViewModelPreview(type = type) + LocalInspectionMode.current -> remember(type) { MeetingListViewModelPreview(type = type) } else -> meetingListViewModel(type) } val lazyPagingItems = meetingListViewModel.meetings.collectAsLazyPagingItems() diff --git a/features/meetings/src/main/res/drawable/ic_expand.xml b/features/meetings/src/main/res/drawable/ic_expand.xml new file mode 100644 index 00000000000..4f214f77431 --- /dev/null +++ b/features/meetings/src/main/res/drawable/ic_expand.xml @@ -0,0 +1,24 @@ + + + + diff --git a/features/meetings/src/main/res/values-de/strings.xml b/features/meetings/src/main/res/values-de/strings.xml index 36f11618984..0977ad58f4b 100644 --- a/features/meetings/src/main/res/values-de/strings.xml +++ b/features/meetings/src/main/res/values-de/strings.xml @@ -51,5 +51,5 @@ Planen Titel Meeting-Titel eingeben - Neue Meetingansicht schließen + Neue Meetingansicht schließen diff --git a/features/meetings/src/main/res/values-ru/strings.xml b/features/meetings/src/main/res/values-ru/strings.xml index db3b1ff58fd..7d90af0c63a 100644 --- a/features/meetings/src/main/res/values-ru/strings.xml +++ b/features/meetings/src/main/res/values-ru/strings.xml @@ -58,5 +58,5 @@ Расписание Название Введите название встречи - Закрыть вид новой встречи + Закрыть вид новой встречи diff --git a/features/meetings/src/main/res/values/strings.xml b/features/meetings/src/main/res/values/strings.xml index dbacfd53044..5c54be3ee3b 100644 --- a/features/meetings/src/main/res/values/strings.xml +++ b/features/meetings/src/main/res/values/strings.xml @@ -51,11 +51,23 @@ "Start a meeting with team members, guests or external parties." Previous meetings will be listed here. New Meeting + Select participants Meet Now Schedule a Meetingv Start Meeting Schedule Title Enter meeting title - Close new meeting view + Participants + Select participants + Close new meeting view + Close select participants view + Please enter a meeting name + Meeting name should not exceed 64 characters + + + +%1$d more + +%1$d more + + diff --git a/features/meetings/stability/meetings-debug.stability b/features/meetings/stability/meetings-debug.stability index 04444a3c10f..ecf91e9adfc 100644 --- a/features/meetings/stability/meetings-debug.stability +++ b/features/meetings/stability/meetings-debug.stability @@ -4,6 +4,12 @@ // Do not edit this file directly. To update it, run: // ./gradlew :meetings:stabilityDump +@Composable +public fun com.ramcosta.composedestinations.generated.meetings.destinations.NewMeetingParticipantsScreenDestination.Content(): kotlin.Unit + skippable: true + restartable: true + params: + @Composable public fun com.ramcosta.composedestinations.generated.meetings.destinations.NewMeetingScreenDestination.Content(): kotlin.Unit skippable: true @@ -30,29 +36,61 @@ public fun com.wire.android.feature.meetings.ui.NewMeetingBottomSheet(sheetState - onScheduleClick: STABLE (function type) @Composable -public fun com.wire.android.feature.meetings.ui.create.NewMeetingContent(titleState: androidx.compose.foundation.text.input.TextFieldState, type: com.wire.android.feature.meetings.ui.create.NewMeetingType, onBackPressed: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit +public fun com.wire.android.feature.meetings.ui.create.NewMeetingContent(state: com.wire.android.feature.meetings.ui.create.NewMeetingState, titleState: androidx.compose.foundation.text.input.TextFieldState, type: com.wire.android.feature.meetings.ui.create.NewMeetingType, modifier: androidx.compose.ui.Modifier, onBackPressed: kotlin.Function0, onParticipantsClicked: kotlin.Function0, onCreateClicked: kotlin.Function0): kotlin.Unit skippable: true restartable: true params: + - state: STABLE (marked @Stable or @Immutable) - titleState: STABLE (marked @Stable or @Immutable) - type: STABLE (class with no mutable properties) - - onBackPressed: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) + - onBackPressed: STABLE (function type) + - onParticipantsClicked: STABLE (function type) + - onCreateClicked: STABLE (function type) @Composable -public fun com.wire.android.feature.meetings.ui.create.NewMeetingScreen(navigator: com.wire.android.navigation.WireNavigator, navArgs: com.wire.android.feature.meetings.ui.create.NewMeetingNavArgs): kotlin.Unit - skippable: true +public fun com.wire.android.feature.meetings.ui.create.NewMeetingParticipantsScreen(navigator: com.wire.android.feature.meetings.navigation.MeetingNavigator, newMeetingViewModel: com.wire.android.feature.meetings.ui.create.NewMeetingViewModel): kotlin.Unit + skippable: false + restartable: true + params: + - navigator: STABLE (class with no mutable properties) + - newMeetingViewModel: RUNTIME (requires runtime check) + +@Composable +public fun com.wire.android.feature.meetings.ui.create.NewMeetingScreen(navigator: com.wire.android.navigation.WireNavigator, navArgs: com.wire.android.feature.meetings.ui.create.NewMeetingNavArgs, newMeetingViewModel: com.wire.android.feature.meetings.ui.create.NewMeetingViewModel): kotlin.Unit + skippable: false restartable: true params: - navigator: STABLE (marked @Stable or @Immutable) - navArgs: STABLE (class with no mutable properties) + - newMeetingViewModel: RUNTIME (requires runtime check) + +@Composable +private fun com.wire.android.feature.meetings.ui.create.ParticipantsInput(participants: kotlinx.collections.immutable.ImmutableSet, onClick: kotlin.Function0): kotlin.Unit + skippable: true + restartable: true + params: + - participants: STABLE (known stable type) + - onClick: STABLE (function type) @Composable -private fun com.wire.android.feature.meetings.ui.create.TitleInput(titleState: androidx.compose.foundation.text.input.TextFieldState): kotlin.Unit +private fun com.wire.android.feature.meetings.ui.create.SelectButton(onClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier, buttonModifier: androidx.compose.ui.Modifier, leadingIcon: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, elevation: androidx.compose.ui.unit.Dp): kotlin.Unit + skippable: true + restartable: true + params: + - onClick: STABLE (function type) + - modifier: STABLE (marked @Stable or @Immutable) + - buttonModifier: STABLE (marked @Stable or @Immutable) + - leadingIcon: STABLE (composable function type) + - elevation: STABLE (marked @Stable or @Immutable) + +@Composable +private fun com.wire.android.feature.meetings.ui.create.TitleInput(titleState: androidx.compose.foundation.text.input.TextFieldState, titleError: com.wire.android.feature.meetings.ui.create.NewMeetingState.TitleError?): kotlin.Unit skippable: true restartable: true params: - titleState: STABLE (marked @Stable or @Immutable) + - titleError: STABLE (marked @Stable or @Immutable) @Composable internal fun com.wire.android.feature.meetings.ui.list.CalendarIcon(tint: androidx.compose.ui.graphics.Color, modifier: androidx.compose.ui.Modifier): kotlin.Unit @@ -233,11 +271,11 @@ public fun com.wire.android.feature.meetings.ui.meetingOptionsMenuListViewModel( params: @Composable -public fun com.wire.android.feature.meetings.ui.newMeetingViewModel(type: com.wire.android.feature.meetings.ui.create.NewMeetingType): com.wire.android.feature.meetings.ui.create.NewMeetingViewModel - skippable: true +public fun com.wire.android.feature.meetings.ui.newMeetingViewModel(viewModelStoreOwner: androidx.lifecycle.ViewModelStoreOwner): com.wire.android.feature.meetings.ui.create.NewMeetingViewModel + skippable: false restartable: true params: - - type: STABLE (class with no mutable properties) + - viewModelStoreOwner: RUNTIME (requires runtime check) @Composable private fun com.wire.android.feature.meetings.ui.options.MeetingOptionsModalContent(meeting: com.wire.android.feature.meetings.model.MeetingItem, onStartMeeting: kotlin.Function0, onCreateConversation: kotlin.Function0, onCopyLink: kotlin.Function0, onEditMeeting: kotlin.Function0, onDeleteMeetingForMe: kotlin.Function0, onDeleteMeetingForEveryone: kotlin.Function0): kotlin.Unit diff --git a/kalium b/kalium index d4554f73e73..7fed478f72d 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit d4554f73e73fecd1654bc2f0259f03e4fd821be5 +Subproject commit 7fed478f72d9f885211a0f088ab40c96cc5670ec